From 32b8a09b39b278e6fc5e0921d422f9fc62448017 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 10 Aug 2026 18:34:36 -0600 Subject: [PATCH 1/2] fix(security): enforce shared billing idempotency boundaries --- docs/billing-access-policy.md | 49 +++ examples/webhook-router.ts | 15 +- package.json | 5 + src/apps.ts | 141 ++++++- src/billing-access-policy.ts | 347 ++++++++++++++++ src/connect/index.ts | 56 ++- src/connectors/adapters/tangle-id.ts | 81 +++- src/consumer.ts | 64 ++- .../__tests__/delegated-tools.test.ts | 34 +- src/delegated-tools/handler.ts | 15 + src/delegated-tools/lease.ts | 22 +- src/idempotency.ts | 386 ++++++++++++++++++ src/index.ts | 2 + src/middleware/index.ts | 18 + src/stripe/errors.ts | 25 ++ src/stripe/index.ts | 2 +- src/stripe/middleware.ts | 178 ++++---- src/stripe/pricing.ts | 29 +- src/stripe/subscription-state.ts | 63 ++- src/stripe/tenant-config.ts | 7 + src/stripe/webhooks.ts | 187 +++++++-- src/webhooks/router.ts | 91 +++-- tests/apps-client.test.ts | 75 ++++ tests/billing-access-policy.test.ts | 136 ++++++ tests/connect-flow.test.ts | 75 +++- tests/consumer-client.test.ts | 27 ++ tests/idempotency-store.test.ts | 239 +++++++++++ tests/platform-boundary-contract.test.ts | 59 +++ tests/stripe-billing-middleware.test.ts | 157 ++++--- tests/stripe-pricing.test.ts | 78 +++- tests/stripe-state-machine.test.ts | 4 +- tests/stripe-webhooks-dispatcher.test.ts | 151 ++++++- tests/tangle-id.test.ts | 108 +++-- tests/tangle-middleware.test.ts | 22 + tests/webhook-router.test.ts | 48 ++- tsup.config.ts | 1 + 36 files changed, 2654 insertions(+), 343 deletions(-) create mode 100644 docs/billing-access-policy.md create mode 100644 src/billing-access-policy.ts create mode 100644 src/idempotency.ts create mode 100644 tests/billing-access-policy.test.ts create mode 100644 tests/idempotency-store.test.ts create mode 100644 tests/platform-boundary-contract.test.ts diff --git a/docs/billing-access-policy.md b/docs/billing-access-policy.md new file mode 100644 index 0000000..41c2470 --- /dev/null +++ b/docs/billing-access-policy.md @@ -0,0 +1,49 @@ +# Billing Access Policy + +Products cannot grant company-funded value from signup, trial, promotion, fallback, or synthetic paths. + +`src/billing-access-policy.ts` is the shared boundary. + +Call `parseTrustedPlatformEvidence` only at a response boundary owned by Platform. + +Pass the resulting opaque evidence object to `decideBillingAccess`. + +Do not pass caller strings such as `paid_purchase`, `paid_subscription`, or `byok`. + +Human access requires Platform proof of a verified, non-placeholder email. + +Paid purchases, paid subscriptions, BYOK, explicit named services, and external administrator evidence remain valid. + +`requireActiveSubscription` also requires paid-subscription evidence that matches the stored Stripe subscription for human access. + +Product-funded trials always deny access. + +Checkout requires a positive plan amount and a price id in the tenant `approvedPriceIds` allowlist. + +Zero-dollar invoices and trial subscription events produce diagnostic events only. + +Stripe updates, deletes, lifecycle events, and paid invoices must match the stored customer and subscription identity. + +Foreign or unbound Stripe events produce diagnostics and cannot mutate state or emit paid entitlement. + +The Platform exchange endpoint must enforce `requireVerifiedEmail` before key or balance issuance. + +The package cannot prove that a remote deployment enforces that ordering without a live Platform credential. + +Direct administrator CLI grants are outside this package and remain unchanged. + +## Production webhook idempotency + +`WebhookRouter` and `StripeBillingDispatcher` require a shared atomic idempotency store when `runtime` is `production`. + +They reject a missing store and a process-local store before accepting requests. + +`FileSystemWebhookIdempotencyStore` and `FileSystemStripeEventIdempotencyStore` provide durable file-backed claims when every worker mounts the same directory. + +Use Redis, D1, Postgres, or another shared backend by implementing `AtomicIdempotencyStore` with `scope: 'shared'` and an atomic claim operation. + +In-memory stores are available only for tests and explicit development runtimes. + +The filesystem adapter stores one claim per hashed key and uses an exclusive per-key lock plus atomic replacement. + +The lock lease recovers after a worker crash; malformed or unavailable storage fails closed. diff --git a/examples/webhook-router.ts b/examples/webhook-router.ts index 2fa44f3..e10f8fb 100644 --- a/examples/webhook-router.ts +++ b/examples/webhook-router.ts @@ -11,21 +11,18 @@ import { stripeWebhookProvider, docusealWebhookProvider, slackWebhookProvider, + FileSystemWebhookIdempotencyStore, } from '@tangle-network/agent-integrations/webhooks' -const idempotency = (() => { - const seen = new Set() - return { - seen: (id: string) => seen.has(id), - remember: (id: string) => { - seen.add(id) - }, - } -})() +// Every worker must mount this directory from the same durable filesystem. +const idempotency = new FileSystemWebhookIdempotencyStore( + process.env.WEBHOOK_IDEMPOTENCY_DIR ?? './var/webhook-idempotency', +) const router = new WebhookRouter({ providers: [stripeWebhookProvider, docusealWebhookProvider, slackWebhookProvider], idempotency, + runtime: 'production', resolveSecret: async (providerId) => { // In production: pull from a secret manager keyed by the requesting // tenant. Headers (e.g., a Stripe Account-Id) are available to scope diff --git a/package.json b/package.json index 79da4b0..04fc852 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,11 @@ "import": "./dist/middleware/index.js", "default": "./dist/middleware/index.js" }, + "./idempotency": { + "types": "./dist/idempotency.d.ts", + "import": "./dist/idempotency.js", + "default": "./dist/idempotency.js" + }, "./webhooks": { "types": "./dist/webhooks/index.d.ts", "import": "./dist/webhooks/index.js", diff --git a/src/apps.ts b/src/apps.ts index 08a8862..c5730f6 100644 --- a/src/apps.ts +++ b/src/apps.ts @@ -1,4 +1,5 @@ import { IntegrationRuntimeError, normalizeIntegrationError } from './errors.js' +import { TANGLE_API_KEY_PREFIX, TANGLE_BROKER_TOKEN_PREFIX } from './connectors/adapters/tangle-id.js' /** * Self-service external-apps client — the brokered hub-exec path. @@ -26,6 +27,17 @@ export interface TangleAppsClientOptions { endpoint: string /** Test seam. Defaults to global `fetch`. */ fetchImpl?: typeof fetch + /** Platform-backed owner check for optional user claims on app operations. */ + ownerPolicy?: AppsOwnerPolicy +} + +export interface AppsOwnerPolicy { + authorize(input: { + operation: 'mint_broker_token' | 'exchange_auth_code' + ownerUserId: string + grantId?: string + clientId?: string + }): Promise | boolean } export interface RegisterAppInput { @@ -56,6 +68,8 @@ export interface BrokerToken { expiresIn: number scope: string connectionId?: string + /** Absolute expiry derived from the broker response. */ + expiresAt: number } interface PlatformEnvelope { @@ -65,19 +79,23 @@ interface PlatformEnvelope { } interface TokenResponse { - access_token: string - expires_in: number - scope: string + access_token?: unknown + expires_in?: unknown + scope?: unknown connection_id?: string } +const MAX_BROKER_TOKEN_TTL_SECONDS = 3_600 + export class TangleAppsClient { private readonly endpoint: string private readonly fetchImpl: typeof fetch + private readonly ownerPolicy?: AppsOwnerPolicy constructor(options: TangleAppsClientOptions) { this.endpoint = options.endpoint.replace(/\/$/, '') this.fetchImpl = options.fetchImpl ?? fetch + this.ownerPolicy = options.ownerPolicy } /** @@ -86,6 +104,7 @@ export class TangleAppsClient { * client_secret; persist the secret immediately (never retrievable again). */ async registerApp(input: RegisterAppInput, ownerBearer: string): Promise { + assertOwnerBearer(ownerBearer) const data = await this.request<{ app: AppSummary; clientSecret: string }>( 'POST', '/v1/apps', @@ -97,12 +116,15 @@ export class TangleAppsClient { /** List the caller's registered apps (no secrets). */ async listApps(ownerBearer: string): Promise { + assertOwnerBearer(ownerBearer) const data = await this.request<{ apps: AppSummary[] }>('GET', '/v1/apps', undefined, ownerBearer) return data.apps ?? [] } /** Revoke an app and cascade-kill its grants + tokens. */ revokeApp(appId: string, ownerBearer: string): Promise<{ revoked: boolean }> { + assertNonEmpty(appId, 'appId') + assertOwnerBearer(ownerBearer) return this.request('POST', `/v1/apps/${encodeURIComponent(appId)}/revoke`, {}, ownerBearer) } @@ -116,7 +138,17 @@ export class TangleAppsClient { clientSecret: string grantId: string ttlSeconds?: number + ownerUserId?: string }): Promise { + assertNonEmpty(input.clientId, 'clientId') + assertNonEmpty(input.clientSecret, 'clientSecret') + assertNonEmpty(input.grantId, 'grantId') + await this.authorizeOwner({ + operation: 'mint_broker_token', + ownerUserId: input.ownerUserId, + grantId: input.grantId, + clientId: input.clientId, + }) const data = await this.request( 'POST', `/v1/apps/grants/${encodeURIComponent(input.grantId)}/mint-broker-token`, @@ -124,7 +156,7 @@ export class TangleAppsClient { client_id: input.clientId, client_secret: input.clientSecret, grant_id: input.grantId, - ...(input.ttlSeconds ? { ttl_seconds: input.ttlSeconds } : {}), + ...(input.ttlSeconds !== undefined ? { ttl_seconds: input.ttlSeconds } : {}), }, ) return toBrokerToken(data) @@ -141,7 +173,17 @@ export class TangleAppsClient { code: string redirectUri: string connectionId?: string + ownerUserId?: string }): Promise { + assertNonEmpty(input.clientId, 'clientId') + assertNonEmpty(input.clientSecret, 'clientSecret') + assertNonEmpty(input.code, 'code') + assertNonEmpty(input.redirectUri, 'redirectUri') + await this.authorizeOwner({ + operation: 'exchange_auth_code', + ownerUserId: input.ownerUserId, + clientId: input.clientId, + }) const data = await this.request('POST', '/v1/apps/oauth/token', { grant_type: 'authorization_code', client_id: input.clientId, @@ -153,6 +195,36 @@ export class TangleAppsClient { return toBrokerToken(data) } + private async authorizeOwner(input: { + operation: 'mint_broker_token' | 'exchange_auth_code' + ownerUserId?: string + grantId?: string + clientId?: string + }): Promise { + if (input.ownerUserId === undefined) return + assertNonEmpty(input.ownerUserId, 'ownerUserId') + if (!this.ownerPolicy) { + throw new IntegrationRuntimeError({ + code: 'input_invalid', + status: 403, + message: 'Tangle apps ownerUserId requires a Platform-backed ownerPolicy', + }) + } + const allowed = await this.ownerPolicy.authorize({ + operation: input.operation, + ownerUserId: input.ownerUserId.trim(), + ...(input.grantId ? { grantId: input.grantId } : {}), + ...(input.clientId ? { clientId: input.clientId } : {}), + }) + if (!allowed) { + throw new IntegrationRuntimeError({ + code: 'provider_auth_failed', + status: 403, + message: 'Tangle apps owner policy rejected the requested owner', + }) + } + } + private async request( method: 'GET' | 'POST' | 'DELETE', path: string, @@ -202,10 +274,65 @@ export function createTangleAppsClient(options: TangleAppsClientOptions): Tangle } function toBrokerToken(data: TokenResponse): BrokerToken { + if ( + typeof data.access_token !== 'string' || + !/^sk-tan-broker-[A-Za-z0-9][A-Za-z0-9._-]*$/.test(data.access_token) || + typeof data.expires_in !== 'number' || + !Number.isSafeInteger(data.expires_in) || + data.expires_in <= 0 || + data.expires_in > MAX_BROKER_TOKEN_TTL_SECONDS || + typeof data.scope !== 'string' || + data.scope.trim().length === 0 || + (data.connection_id !== undefined && (typeof data.connection_id !== 'string' || data.connection_id.trim().length === 0)) + ) { + throw new IntegrationRuntimeError({ + code: 'input_invalid', + status: 502, + message: 'Tangle broker returned an invalid token, expiry, or scope', + }) + } + const accessToken = data.access_token + const expiresIn = data.expires_in + const scope = data.scope.trim().split(/\s+/).join(' ') + const expiresAt = Date.now() + expiresIn * 1000 + if (!Number.isFinite(expiresAt)) { + throw new IntegrationRuntimeError({ + code: 'input_invalid', + status: 502, + message: 'Tangle broker returned an invalid expiry', + }) + } return { - accessToken: data.access_token, - expiresIn: data.expires_in, - scope: data.scope, + accessToken, + expiresIn, + scope, + expiresAt, connectionId: data.connection_id, } } + +function assertNonEmpty(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || !value.trim()) { + throw new IntegrationRuntimeError({ + code: 'input_invalid', + status: 400, + message: `Tangle apps ${name} is required`, + }) + } +} + +function assertOwnerBearer(value: unknown): asserts value is string { + assertNonEmpty(value, 'owner bearer') + if ( + value.startsWith('sk-') && + (!value.startsWith(TANGLE_API_KEY_PREFIX) || + value.startsWith(TANGLE_BROKER_TOKEN_PREFIX) || + value.length <= TANGLE_API_KEY_PREFIX.length) + ) { + throw new IntegrationRuntimeError({ + code: 'input_invalid', + status: 400, + message: 'Tangle apps owner bearer must be a Platform key or session bearer', + }) + } +} diff --git a/src/billing-access-policy.ts b/src/billing-access-policy.ts new file mode 100644 index 0000000..1a252a8 --- /dev/null +++ b/src/billing-access-policy.ts @@ -0,0 +1,347 @@ +/** + * Shared billing and identity policy for product integrations. + * + * Product code must not prove access by sending a string such as + * `paid_purchase` or `byok`. The only accepted proof is an object parsed from + * the Platform response by `parseTrustedPlatformEvidence`. + */ + +export const PLATFORM_ACCESS_POLICY_VERSION = 1 as const +export const PLATFORM_ACCESS_ISSUER = 'id.tangle.tools' as const + +export const PRODUCT_FREE_CREDIT_SOURCES = Object.freeze([ + 'signup', + 'trial', + 'promo', + 'fallback', + 'synthetic', +] as const) + +export type ProductFreeCreditSource = (typeof PRODUCT_FREE_CREDIT_SOURCES)[number] + +export type TrustedFundingEvidence = + | { + kind: 'paid_purchase' + evidenceId: string + amountUsd: number + paidAt: string + } + | { + kind: 'paid_subscription' + evidenceId: string + subscriptionId: string + status: 'active' | 'past_due' + amountUsd: number + currentPeriodEnd?: string | null + } + | { + kind: 'byok' + evidenceId: string + provider: string + keyId: string + } + | { + kind: 'named_service' + evidenceId: string + serviceId: string + serviceName: string + } + | { + kind: 'admin' + evidenceId: string + adminId: string + } + +export type TrustedPlatformPrincipal = + | { + kind: 'human' + userId: string + email: string + emailVerified: true + } + | { + kind: 'service_principal' + userId: string + serviceId: string + serviceName: string + } + | { + kind: 'admin' + userId: string + adminId: string + } + +export interface TrustedPlatformEvidence { + issuer: typeof PLATFORM_ACCESS_ISSUER + policyVersion: typeof PLATFORM_ACCESS_POLICY_VERSION + evidenceId: string + principal: TrustedPlatformPrincipal + funding: TrustedFundingEvidence + /** Platform response time. Consumers may use this for short cache TTLs. */ + issuedAt: string +} + +/** The wire shape returned by Platform's access/evidence endpoint. */ +export interface PlatformAccessEvidencePayload { + policyVersion?: unknown + issuer?: unknown + evidenceId?: unknown + issuedAt?: unknown + emailVerified?: unknown + user?: { id?: unknown; email?: unknown } + principal?: { + kind?: unknown + id?: unknown + name?: unknown + } + funding?: { + kind?: unknown + id?: unknown + amountUsd?: unknown + paidAt?: unknown + subscriptionId?: unknown + status?: unknown + currentPeriodEnd?: unknown + provider?: unknown + keyId?: unknown + serviceId?: unknown + serviceName?: unknown + adminId?: unknown + } +} + +export type BillingAccessDecision = + | { + allowed: true + basis: TrustedFundingEvidence['kind'] + principal: TrustedPlatformPrincipal + } + | { + allowed: false + code: + | 'product_free_credits_disabled' + | 'email_verification_required' + | 'real_email_required' + | 'platform_evidence_required' + | 'platform_evidence_subject_mismatch' + | 'paid_evidence_required' + reason: string + } + +/** Public policy values make accidental re-enablement visible in reviews. */ +export const NO_PRODUCT_FREE_CREDITS_POLICY = Object.freeze({ + productFreeCredits: false, + productFreeTrials: false, + productPromotions: false, + productFallbackCredits: false, + productSyntheticCredits: false, + humanEmailVerificationRequired: true, + platformEvidenceRequired: true, +}) + +/* WeakSet prevents a caller from constructing a lookalike object and passing it + * as proof. Only the parser below can mark an object as Platform-issued. */ +const trustedEvidenceObjects = new WeakSet() + +/** + * Parse and mark an access response from Platform. + * + * `null` means the response is missing a required policy, identity, or funding + * field. Products must deny access when parsing returns null. + */ +export function parseTrustedPlatformEvidence( + payload: unknown, + options: { expectedUserId?: string } = {}, +): TrustedPlatformEvidence | null { + if (!isRecord(payload)) return null + if (payload.policyVersion !== PLATFORM_ACCESS_POLICY_VERSION) return null + if (payload.issuer !== PLATFORM_ACCESS_ISSUER) return null + + const evidenceId = readNonEmptyString(payload.evidenceId) + const issuedAt = readNonEmptyString(payload.issuedAt) + const user = isRecord(payload.user) ? payload.user : undefined + const principal = isRecord(payload.principal) ? payload.principal : undefined + const funding = isRecord(payload.funding) ? payload.funding : undefined + if (!evidenceId || !issuedAt || !Number.isFinite(Date.parse(issuedAt)) || !user || !principal || !funding) return null + + const userId = readNonEmptyString(user.id) + if (!userId || (options.expectedUserId && userId !== options.expectedUserId)) return null + + const principalValue = parsePrincipal({ payload, user, principal, userId }) + const fundingValue = parseFunding(funding) + if (!principalValue || !fundingValue) return null + if (principalValue.kind === 'human' && payload.emailVerified !== true) return null + + const result: TrustedPlatformEvidence = Object.freeze({ + issuer: PLATFORM_ACCESS_ISSUER, + policyVersion: PLATFORM_ACCESS_POLICY_VERSION, + evidenceId, + principal: Object.freeze(principalValue), + funding: Object.freeze(fundingValue), + issuedAt, + }) + trustedEvidenceObjects.add(result) + return result +} + +export function isTrustedPlatformEvidence(value: unknown): value is TrustedPlatformEvidence { + return isRecord(value) && trustedEvidenceObjects.has(value) +} + +export function decideBillingAccess(input: { + evidence?: TrustedPlatformEvidence + expectedUserId?: string +}): BillingAccessDecision { + const evidence = input.evidence + if (!evidence || !isTrustedPlatformEvidence(evidence)) { + return { + allowed: false, + code: 'platform_evidence_required', + reason: 'Platform must attest the identity and funding source', + } + } + if (input.expectedUserId && evidence.principal.userId !== input.expectedUserId) { + return { + allowed: false, + code: 'platform_evidence_subject_mismatch', + reason: 'Platform evidence belongs to a different owner', + } + } + if (evidence.principal.kind === 'human') { + if (evidence.principal.emailVerified !== true) { + return { + allowed: false, + code: 'email_verification_required', + reason: 'Human access requires a verified email', + } + } + if (!isRealNonPlaceholderEmail(evidence.principal.email)) { + return { + allowed: false, + code: 'real_email_required', + reason: 'Human access requires a real non-placeholder email', + } + } + } + return { allowed: true, basis: evidence.funding.kind, principal: evidence.principal } +} + +/** Reject positive trial periods before a checkout request reaches Stripe. */ +export function assertNoProductFreeTrial(trialDays: number | undefined): void { + if (trialDays === undefined || trialDays === 0) return + if (!Number.isInteger(trialDays) || trialDays < 0) { + throw new Error('billing: trialDays must be a non-negative integer') + } + throw new Error('billing: product-funded free trials are disabled') +} + +/** + * Shared email check for every product boundary. It intentionally rejects + * test/example domains and common synthetic addresses. Platform remains the + * authority for inbox verification; this prevents accidental local bypasses. + */ +export function isRealNonPlaceholderEmail(value: unknown): value is string { + if (typeof value !== 'string') return false + const email = value.trim().toLowerCase() + if (email.length < 6 || email.length > 320 || /\s/.test(email)) return false + const match = /^([^@]+)@([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)$/.exec(email) + if (!match) return false + const local = match[1]! + const domain = match[2]! + if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) return false + if (domain === 'localhost' || domain.endsWith('.test') || domain.endsWith('.invalid') || domain.endsWith('.example')) { + return false + } + if ( + /(?:^|[.-])(test|example|placeholder|invalid|disposable|tempmail|mailinator|10minutemail|guerrillamail|yopmail)(?:[.-]|$)/.test( + domain, + ) + ) { + return false + } + if (/^(test|tester|example|placeholder|no-?reply|noreply)(?:[+._-]|$)/.test(local)) return false + if (/^0x[a-f0-9]{40}@tangle\.tools$/i.test(email)) return false + if (email.endsWith('@users.noreply.tangle.tools')) return false + return true +} + +function parsePrincipal(input: { + payload: Record + user: Record + principal: Record + userId: string +}): TrustedPlatformPrincipal | null { + const kind = readNonEmptyString(input.principal.kind) + if (kind === 'human') { + const email = readNonEmptyString(input.user.email) + return email && input.payload.emailVerified === true && isRealNonPlaceholderEmail(email) + ? { kind, userId: input.userId, email, emailVerified: true } + : null + } + if (kind === 'service_principal') { + const serviceId = readNonEmptyString(input.principal.id) + const serviceName = readNonEmptyString(input.principal.name) + return serviceId && serviceName + ? { kind, userId: input.userId, serviceId, serviceName } + : null + } + if (kind === 'admin') { + const adminId = readNonEmptyString(input.principal.id) + return adminId ? { kind, userId: input.userId, adminId } : null + } + return null +} + +function parseFunding(value: Record): TrustedFundingEvidence | null { + const kind = readNonEmptyString(value.kind) + const evidenceId = readNonEmptyString(value.id) + if (!kind || !evidenceId) return null + if (kind === 'paid_purchase') { + const amountUsd = readPositiveNumber(value.amountUsd) + const paidAt = readNonEmptyString(value.paidAt) + return amountUsd !== null && paidAt ? { kind, evidenceId, amountUsd, paidAt } : null + } + if (kind === 'paid_subscription') { + const subscriptionId = readNonEmptyString(value.subscriptionId) + const status = value.status === 'active' || value.status === 'past_due' ? value.status : null + const amountUsd = readPositiveNumber(value.amountUsd) + if (!subscriptionId || !status || amountUsd === null) return null + return { + kind, + evidenceId, + subscriptionId, + status, + amountUsd, + ...(value.currentPeriodEnd === null || typeof value.currentPeriodEnd === 'string' + ? { currentPeriodEnd: value.currentPeriodEnd } + : {}), + } + } + if (kind === 'byok') { + const provider = readNonEmptyString(value.provider) + const keyId = readNonEmptyString(value.keyId) + return provider && keyId ? { kind, evidenceId, provider, keyId } : null + } + if (kind === 'named_service') { + const serviceId = readNonEmptyString(value.serviceId) + const serviceName = readNonEmptyString(value.serviceName) + return serviceId && serviceName ? { kind, evidenceId, serviceId, serviceName } : null + } + if (kind === 'admin') { + const adminId = readNonEmptyString(value.adminId) + return adminId ? { kind, evidenceId, adminId } : null + } + return null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function readPositiveNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} diff --git a/src/connect/index.ts b/src/connect/index.ts index 75a3ac1..e8d9540 100644 --- a/src/connect/index.ts +++ b/src/connect/index.ts @@ -43,10 +43,16 @@ import { createTangleIdentityClient, DEFAULT_TANGLE_PLATFORM_URL, + TANGLE_API_KEY_PREFIX, + TANGLE_BROKER_TOKEN_PREFIX, TangleIdentityUnreachableError, type TangleIdentityOptions, type TangleUserSummary, } from '../connectors/adapters/tangle-id.js' +import { + isRealNonPlaceholderEmail, + PLATFORM_ACCESS_POLICY_VERSION, +} from '../billing-access-policy.js' export interface ConnectFlowOptions extends TangleIdentityOptions { /** Base URL of id.tangle.tools (defaults to {@link DEFAULT_TANGLE_PLATFORM_URL}). */ @@ -89,6 +95,8 @@ export interface FinishConnectOutput { user: TangleUserSummary /** Initial balance the platform returns alongside the key. */ balance: number + /** Versioned proof that Platform applied its current access policy. */ + paidAccessPolicyVersion: typeof PLATFORM_ACCESS_POLICY_VERSION } /** Initiate a cross-product connect flow. Returns the URL the product @@ -133,7 +141,11 @@ export async function finishConnectFlow( res = await fetchImpl(`${baseUrl}/cross-site/exchange`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ code: input.code, app: input.appId }), + body: JSON.stringify({ + code: input.code, + app: input.appId, + requireVerifiedEmail: true, + }), signal: AbortSignal.timeout(timeoutMs), }) } catch (err) { @@ -155,22 +167,47 @@ export async function finishConnectFlow( const body = (await res.json().catch(() => null)) as | { apiKey?: string - user?: { id?: string; email?: string; name?: string | null; image?: string | null } + paidAccessPolicyVersion?: number + emailVerified?: boolean + user?: { + id?: string + email?: string + emailVerified?: boolean + name?: string | null + image?: string | null + } balance?: number } | null - if (!body || typeof body.apiKey !== 'string' || !body.user || typeof body.user.id !== 'string') { - throw new TangleIdentityUnreachableError('connect/finish: exchange response had an invalid shape') + if ( + !body || + typeof body.apiKey !== 'string' || + !isNonEmptyTangleApiKey(body.apiKey) || + body.paidAccessPolicyVersion !== PLATFORM_ACCESS_POLICY_VERSION || + body.emailVerified !== true || + !body.user || + typeof body.user.id !== 'string' || + !body.user.id.trim() || + !isRealNonPlaceholderEmail(body.user.email) || + body.user.emailVerified !== true || + (body.balance !== undefined && (!Number.isFinite(body.balance) || body.balance < 0)) + ) { + throw new TangleIdentityUnreachableError( + 'connect/finish: Platform did not prove a verified real email and current access policy', + { status: 403 }, + ) } return { apiKey: body.apiKey, user: { id: body.user.id, - ...(typeof body.user.email === 'string' ? { email: body.user.email } : {}), + email: body.user.email, + emailVerified: true, ...(body.user.name !== undefined ? { name: body.user.name } : {}), ...(body.user.image !== undefined ? { image: body.user.image } : {}), }, balance: typeof body.balance === 'number' && Number.isFinite(body.balance) ? body.balance : 0, + paidAccessPolicyVersion: PLATFORM_ACCESS_POLICY_VERSION, } } @@ -179,13 +216,20 @@ export async function revokeConnectFlow( opts: ConnectFlowOptions, input: { apiKey: string }, ): Promise { - if (!input.apiKey) { + if (!isNonEmptyTangleApiKey(input.apiKey)) { throw new TangleIdentityUnreachableError('connect/revoke: apiKey is required') } const client = createTangleIdentityClient(opts) await client.revokeSession(input.apiKey) } +function isNonEmptyTangleApiKey(value: unknown): value is string { + return typeof value === 'string' && + value.startsWith(TANGLE_API_KEY_PREFIX) && + !value.startsWith(TANGLE_BROKER_TOKEN_PREFIX) && + value.length > TANGLE_API_KEY_PREFIX.length +} + /** * Convenience: build a tiny session manager keyed by `state` for products * that don't already have a CSRF store. NOT recommended for production — diff --git a/src/connectors/adapters/tangle-id.ts b/src/connectors/adapters/tangle-id.ts index 68a7de8..874b10b 100644 --- a/src/connectors/adapters/tangle-id.ts +++ b/src/connectors/adapters/tangle-id.ts @@ -69,6 +69,7 @@ import { type ConnectorInvocation, CredentialsExpired, } from '../types.js' +import { isRealNonPlaceholderEmail } from '../../billing-access-policy.js' /** Default platform URL (matches `DEFAULT_PLATFORM_URL` in tcloud). */ export const DEFAULT_TANGLE_PLATFORM_URL = 'https://id.tangle.tools' @@ -82,6 +83,9 @@ const PLATFORM_FETCH_TIMEOUT_MS = 5_000 * without a round-trip. */ export const TANGLE_API_KEY_PREFIX = 'sk-tan-' +/** Broker keys are scoped to hub execution, never user or owner identity. */ +export const TANGLE_BROKER_TOKEN_PREFIX = 'sk-tan-broker-' + /** Service-token prefix. Mirrored from the platform's middleware so we * can refuse to forward service tokens through the user-session path. */ export const TANGLE_SERVICE_TOKEN_PREFIX = 'svc_' @@ -125,6 +129,12 @@ export type TangleTokenVerifyResult = credentialId?: string /** Product the credential is scoped to, when known. */ product?: string + /** Platform proof that a human controls a real inbox. */ + emailVerified?: boolean + /** Platform-owned machine identity. */ + servicePrincipal?: boolean + /** Real email when Platform returns it for this credential. */ + email?: string /** Owner shape — `user` for personal credentials, `team` for * team-owned API keys. Always matches the workspace's owner type. */ ownerType: 'user' | 'team' @@ -144,10 +154,13 @@ export type TangleTokenVerifyFailure = | 'unknown_kind' | 'service_token_refused' | 'malformed' + | 'email_verification_required' + | 'real_email_required' export interface TangleUserSummary { id: string email?: string + emailVerified?: boolean name?: string | null image?: string | null } @@ -473,16 +486,33 @@ export interface TangleInvitationSummary { export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): TangleIdentityClient { const baseUrl = (opts.baseUrl ?? DEFAULT_TANGLE_PLATFORM_URL).replace(/\/+$/, '') const serviceToken = opts.serviceToken - const serviceName = opts.serviceName ?? 'integrations' + const serviceName = opts.serviceName?.trim() const fetchImpl = opts.fetchImpl ?? fetch const timeoutMs = opts.timeoutMs ?? PLATFORM_FETCH_TIMEOUT_MS + if ( + serviceToken && + (!serviceToken.startsWith(TANGLE_SERVICE_TOKEN_PREFIX) || serviceToken.length <= TANGLE_SERVICE_TOKEN_PREFIX.length) + ) { + throw new TangleIdentityUnreachableError('tangle-id: serviceToken must start with svc_') + } + if (serviceToken && !serviceName) { + throw new TangleIdentityUnreachableError( + 'tangle-id: serviceName is required for service-to-service calls', + ) + } + function s2sHeaders(): Record { if (!serviceToken) { throw new TangleIdentityUnreachableError( 'tangle-id: serviceToken is required for service-to-service calls (verify, get_user, list_workspaces, revoke)', ) } + if (!serviceName) { + throw new TangleIdentityUnreachableError( + 'tangle-id: serviceName is required for service-to-service calls', + ) + } return { 'content-type': 'application/json', authorization: `Bearer ${serviceToken}`, @@ -543,6 +573,10 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta product?: string allowedModels?: unknown expiresAt?: string + emailVerified?: boolean + emailVerificationRequired?: boolean + servicePrincipal?: boolean + email?: string } | null if (!body || typeof body.valid !== 'boolean') { @@ -551,6 +585,15 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta if (!body.valid || !body.userId) { return { valid: false, reason: 'revoked' } } + if (body.emailVerificationRequired === true) { + return { valid: false, reason: 'email_verification_required' } + } + if (body.servicePrincipal !== true && body.emailVerified !== true) { + return { valid: false, reason: 'email_verification_required' } + } + if (body.servicePrincipal !== true && !isRealNonPlaceholderEmail(body.email)) { + return { valid: false, reason: 'real_email_required' } + } const scopes = Array.isArray(body.allowedModels) ? body.allowedModels.filter((value): value is string => typeof value === 'string') : [] @@ -565,10 +608,13 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta userId: body.userId, workspaceId: body.ownerType === 'team' && body.ownerId ? body.ownerId : body.userId, ownerType: body.ownerType ?? 'user', + emailVerified: body.emailVerified === true, scopes, ...(Number.isFinite(expiresAt) ? { expiresAt: expiresAt as number } : {}), ...(body.keyId ? { credentialId: body.keyId } : {}), ...(body.product ? { product: body.product } : {}), + ...(body.email ? { email: body.email } : {}), + ...(body.servicePrincipal !== undefined ? { servicePrincipal: body.servicePrincipal } : {}), } } @@ -597,13 +643,19 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta } const body = (await res.json().catch(() => null)) as | { - user?: { id?: string; email?: string } + user?: { id?: string; email?: string; emailVerified?: boolean } session?: { id?: string; expiresAt?: string; activeTeamId?: string | null } } | null if (!body || !body.user || typeof body.user.id !== 'string') { return { valid: false, reason: 'expired' } } + if (body.user.emailVerified !== true) { + return { valid: false, reason: 'email_verification_required' } + } + if (!isRealNonPlaceholderEmail(body.user.email)) { + return { valid: false, reason: 'real_email_required' } + } const expiresAtRaw = body.session?.expiresAt const expiresAt = expiresAtRaw ? Date.parse(expiresAtRaw) : NaN return { @@ -612,6 +664,8 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta userId: body.user.id, workspaceId: body.session?.activeTeamId || body.user.id, ownerType: body.session?.activeTeamId ? 'team' : 'user', + email: body.user.email, + emailVerified: true, scopes: [], ...(Number.isFinite(expiresAt) ? { expiresAt } : {}), ...(body.session?.id ? { credentialId: body.session.id } : {}), @@ -632,6 +686,12 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta return { valid: false, reason: 'service_token_refused' } } if (token.startsWith(TANGLE_API_KEY_PREFIX)) { + if (token.startsWith(TANGLE_BROKER_TOKEN_PREFIX)) { + return { valid: false, reason: 'service_token_refused' } + } + if (token.length <= TANGLE_API_KEY_PREFIX.length) { + return { valid: false, reason: 'malformed' } + } return verifyApiKey(token) } // Anything else — treat as a session bearer (Better Auth-emitted @@ -660,7 +720,16 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta ) } const body = (await res.json().catch(() => null)) as - | { success?: boolean; data?: { id?: string; email?: string; name?: string | null; image?: string | null } } + | { + success?: boolean + data?: { + id?: string + email?: string + emailVerified?: boolean + name?: string | null + image?: string | null + } + } | null const data = body?.data if (!data || typeof data.id !== 'string') { @@ -669,6 +738,7 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta return { id: data.id, ...(typeof data.email === 'string' ? { email: data.email } : {}), + ...(typeof data.emailVerified === 'boolean' ? { emailVerified: data.emailVerified } : {}), ...(data.name !== undefined ? { name: data.name } : {}), ...(data.image !== undefined ? { image: data.image } : {}), } @@ -737,6 +807,11 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta ) } if (token.startsWith(TANGLE_API_KEY_PREFIX)) { + if (token.startsWith(TANGLE_BROKER_TOKEN_PREFIX)) { + throw new TangleIdentityUnreachableError( + 'tangle-id: refusing to revoke a broker token through the user-key path', + ) + } // We don't know the key id until we verify; do that first so // revoke is keyed by id (the only thing the platform's DELETE // /v1/keys/{id} accepts). Bad-key responses are no-ops. diff --git a/src/consumer.ts b/src/consumer.ts index 53ea089..a74ef1c 100644 --- a/src/consumer.ts +++ b/src/consumer.ts @@ -49,6 +49,11 @@ import type { } from './runtime.js' import type { IntegrationHealthcheckResult } from './healthcheck.js' import { DEFAULT_TANGLE_PLATFORM_URL } from './connectors/adapters/tangle-id.js' +import { + TANGLE_API_KEY_PREFIX, + TANGLE_BROKER_TOKEN_PREFIX, + TANGLE_SERVICE_TOKEN_PREFIX, +} from './connectors/adapters/tangle-id.js' /** Matches the platform's `PLATFORM_USER_ID_PATTERN` (`auth.ts`). A user id * that fails this is rejected client-side before the request leaves. */ @@ -91,6 +96,21 @@ export interface IntegrationHubClientOptions { /** Max attempts on transient (network / 502 / 503 / 504) failures. * Default 2 — i.e. one retry. */ maxAttempts?: number + /** + * Authoritative owner check for caller-supplied user ids. Service-token + * requests may use the Platform's authenticated named-service context; a + * user-key request must provide a Platform-backed policy. + */ + ownerPolicy?: IntegrationOwnerPolicy +} + +export interface IntegrationOwnerPolicy { + authorize(input: { + userId: string + product: string + authMode: IntegrationHubAuth['mode'] + serviceName?: string + }): Promise | boolean } /** Thrown for every non-2xx response and every transport failure. Carries the @@ -200,26 +220,39 @@ export class IntegrationHubClient { private readonly fetchImpl: typeof fetch private readonly timeoutMs: number private readonly maxAttempts: number + private readonly ownerPolicy: IntegrationOwnerPolicy constructor(options: IntegrationHubClientOptions) { - if (!options.product) { + if (!options.product?.trim()) { throw new Error('IntegrationHubClient: product is required') } - if (options.auth.mode === 'service' && !options.auth.serviceToken) { + if ( + options.auth.mode === 'service' && + (!options.auth.serviceToken?.startsWith(TANGLE_SERVICE_TOKEN_PREFIX) || + options.auth.serviceToken.length <= TANGLE_SERVICE_TOKEN_PREFIX.length) + ) { throw new Error('IntegrationHubClient: service auth requires a serviceToken') } - if (options.auth.mode === 'service' && !options.auth.serviceName) { + if (options.auth.mode === 'service' && !options.auth.serviceName?.trim()) { throw new Error('IntegrationHubClient: service auth requires a serviceName') } - if (options.auth.mode === 'user-key' && !options.auth.apiKey) { + if ( + options.auth.mode === 'user-key' && + (!options.auth.apiKey?.startsWith(TANGLE_API_KEY_PREFIX) || + options.auth.apiKey.startsWith(TANGLE_BROKER_TOKEN_PREFIX) || + options.auth.apiKey.length <= TANGLE_API_KEY_PREFIX.length) + ) { throw new Error('IntegrationHubClient: user-key auth requires an apiKey') } this.endpoint = (options.endpoint ?? DEFAULT_TANGLE_PLATFORM_URL).replace(/\/+$/, '') - this.product = options.product + this.product = options.product.trim() this.auth = options.auth this.fetchImpl = options.fetchImpl ?? fetch this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS this.maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS) + this.ownerPolicy = options.ownerPolicy ?? { + authorize: ({ authMode, serviceName }) => authMode === 'service' && Boolean(serviceName?.trim()), + } } /** @@ -229,8 +262,10 @@ export class IntegrationHubClient { * reachable by a service token by design. */ async resolveManifest(input: ResolveManifestInput): Promise { + const product = input.product?.trim() ?? this.product + if (!product) throw new Error('IntegrationHubClient: per-call product is required') return this.request('POST', '/resolve-manifest', input.userId, { - product: input.product ?? this.product, + product, manifest: input.manifest, ownerUserId: input.userId, }) @@ -368,6 +403,23 @@ export class IntegrationHubClient { retryable: false, }) } + const requestedProduct = + body && typeof body.product === 'string' && body.product.trim() ? body.product.trim() : this.product + const ownerAllowed = await this.ownerPolicy.authorize({ + userId, + product: requestedProduct, + authMode: this.auth.mode, + ...(this.auth.mode === 'service' ? { serviceName: this.auth.serviceName } : {}), + }) + if (!ownerAllowed) { + throw new IntegrationHubRequestError({ + status: 403, + code: 'owner_policy_denied', + message: `${method} ${path} rejected: Platform owner policy did not authorize user ${userId}`, + endpoint: `${method} ${path}`, + retryable: false, + }) + } const url = `${this.endpoint}/v1/integrations${path}` const endpointLabel = `${method} /v1/integrations${path}` const headers = this.buildHeaders(userId, body !== undefined) diff --git a/src/delegated-tools/__tests__/delegated-tools.test.ts b/src/delegated-tools/__tests__/delegated-tools.test.ts index 75b8c12..ebfaa5c 100644 --- a/src/delegated-tools/__tests__/delegated-tools.test.ts +++ b/src/delegated-tools/__tests__/delegated-tools.test.ts @@ -113,6 +113,7 @@ describe('issueDelegatedToolLease', () => { secret: SECRET, callbackUrl: 'https://app.example/api/delegated/mcp', now: t0, + ownerPolicy: { authorize: () => true }, }) expect(lease).not.toBeNull() expect(lease!.allowedTools).toEqual(['gcal__events.create']) @@ -124,9 +125,25 @@ describe('issueDelegatedToolLease', () => { it('fail-closed: no secret ⇒ null lease', async () => { expect( - await issueDelegatedToolLease({ workspaceId: WORKSPACE, allowedTools: ['x'], ttlSeconds: 60 }), + await issueDelegatedToolLease({ workspaceId: WORKSPACE, allowedTools: ['x'], ttlSeconds: 60, ownerPolicy: { authorize: () => true } }), ).toBeNull() }) + + it('fail-closed: missing or denying owner policy ⇒ null lease', async () => { + await expect(issueDelegatedToolLease({ + workspaceId: WORKSPACE, + allowedTools: ['x'], + ttlSeconds: 60, + secret: SECRET, + })).resolves.toBeNull() + await expect(issueDelegatedToolLease({ + workspaceId: WORKSPACE, + allowedTools: ['x'], + ttlSeconds: 60, + secret: SECRET, + ownerPolicy: { authorize: () => false }, + })).resolves.toBeNull() + }) }) function tool(name: string, invoke?: ResolvedDelegatedTool['invoke']): ResolvedDelegatedTool { @@ -142,6 +159,7 @@ function seams(overrides: Partial = {}): DelegatedToolCa verifyToken: async (bearer) => verifyDelegatedToolToken(bearer, { secret: SECRET }), resolveTool: async (_ws, name) => (name === 'gcal__create' ? tool(name) : null), isIntegrationConnected: async () => true, + authorizeOwner: async () => true, ...overrides, } } @@ -257,6 +275,20 @@ describe('handleDelegatedToolCall — fail-closed gates', () => { ) expect(res.error?.code).toBe(-32001) }) + + it('owner policy blocks a valid bearer before tool lookup', async () => { + let resolved = false + const res = await handleDelegatedToolCall( + { id: 6, method: 'tools/call', params: { name: 'gcal__create' } }, + await bearer(['gcal__create']), + seams({ + authorizeOwner: async () => false, + resolveTool: async () => { resolved = true; return null }, + }), + ) + expect(res.error?.code).toBe(-32001) + expect(resolved).toBe(false) + }) }) describe('handleDelegatedToolCall — invocation errors', () => { diff --git a/src/delegated-tools/handler.ts b/src/delegated-tools/handler.ts index 507c266..a465de7 100644 --- a/src/delegated-tools/handler.ts +++ b/src/delegated-tools/handler.ts @@ -67,6 +67,12 @@ export interface DelegatedToolCallSeams { workspaceId: string, tool: ResolvedDelegatedTool, ): Promise | boolean + /** Re-checks that the product still owns this delegated workspace. */ + authorizeOwner(input: { + workspaceId: string + operation: 'initialize' | 'list' | 'call' + toolName?: string + }): Promise | boolean /** * Optional advertised name/version for the JSON-RPC `initialize` handshake. * Defaults to `{ name: 'delegated-tools', version: '1' }`. @@ -128,6 +134,9 @@ export async function handleDelegatedToolCall( if (!claims) return rpcError(id, -32001, 'Unauthorized') if (method === 'initialize') { + if (!(await seams.authorizeOwner({ workspaceId: claims.workspaceId, operation: 'initialize' }))) { + return rpcError(id, -32001, 'Unauthorized') + } const info = seams.serverInfo ?? { name: 'delegated-tools', version: '1' } return rpcResult(id, { protocolVersion: PROTOCOL_VERSION, @@ -137,6 +146,9 @@ export async function handleDelegatedToolCall( } if (method === 'tools/list') { + if (!(await seams.authorizeOwner({ workspaceId: claims.workspaceId, operation: 'list' }))) { + return rpcError(id, -32001, 'Unauthorized') + } const tools: DelegatedToolDescriptor[] = [] for (const name of claims.allowedTools) { const tool = await seams.resolveTool(claims.workspaceId, name) @@ -160,6 +172,9 @@ export async function handleDelegatedToolCall( : {} if (!name) return rpcError(id, -32602, 'Missing tool name') + if (!(await seams.authorizeOwner({ workspaceId: claims.workspaceId, operation: 'call', toolName: name }))) { + return rpcError(id, -32001, 'Unauthorized') + } if (!claims.allowedTools.includes(name)) { return rpcError(id, -32602, `Tool not delegated to this session: ${name}`) } diff --git a/src/delegated-tools/lease.ts b/src/delegated-tools/lease.ts index d143e9f..efd3496 100644 --- a/src/delegated-tools/lease.ts +++ b/src/delegated-tools/lease.ts @@ -25,6 +25,16 @@ export interface IssueDelegatedToolLeaseInput { callbackUrl?: string /** Override the clock (epoch ms) — for tests. */ now?: number + /** Product-owned check that this workspace may delegate these tools now. */ + ownerPolicy?: DelegatedToolOwnerPolicy +} + +export interface DelegatedToolOwnerPolicy { + authorize(input: { + workspaceId: string + allowedTools: readonly string[] + operation: 'issue_lease' + }): Promise | boolean } export interface DelegatedToolLease { @@ -45,10 +55,16 @@ export interface DelegatedToolLease { export async function issueDelegatedToolLease( input: IssueDelegatedToolLeaseInput, ): Promise { + const workspaceId = input.workspaceId.trim() + const allowedTools = input.allowedTools.filter((tool) => typeof tool === 'string' && tool.trim()) + if (!workspaceId || allowedTools.length === 0 || allowedTools.length !== input.allowedTools.length) return null + if (!Number.isInteger(input.ttlSeconds) || input.ttlSeconds <= 0 || input.ttlSeconds > 3600) return null + if (!input.ownerPolicy) return null + if (!(await input.ownerPolicy.authorize({ workspaceId, allowedTools, operation: 'issue_lease' }))) return null const now = input.now ?? Date.now() const token = await mintDelegatedToolToken({ - workspaceId: input.workspaceId, - allowedTools: input.allowedTools, + workspaceId, + allowedTools, ttlSeconds: input.ttlSeconds, secret: input.secret, prefix: input.prefix, @@ -57,7 +73,7 @@ export async function issueDelegatedToolLease( if (!token) return null return { token, - allowedTools: input.allowedTools, + allowedTools, expiresAt: now + input.ttlSeconds * 1000, callbackUrl: input.callbackUrl, } diff --git a/src/idempotency.ts b/src/idempotency.ts new file mode 100644 index 0000000..6953e6d --- /dev/null +++ b/src/idempotency.ts @@ -0,0 +1,386 @@ +import { createHash, randomUUID } from 'node:crypto' + +export type IdempotencyRuntime = 'production' | 'development' | 'test' + +/** + * Atomic claim storage shared by every worker that can receive the same event. + * `scope` is part of the construction contract so production cannot silently + * accept a process-local implementation. + */ +export interface AtomicIdempotencyStore { + readonly scope?: 'process' | 'shared' + claim(key: string, ttlMs: number): Promise | boolean + release?(key: string): Promise | void + /** Retain a successful claim while clearing only local ownership state. */ + complete?(key: string): Promise | void +} + +/** Process-local implementation for tests and explicitly single-process apps. */ +export class InMemoryAtomicIdempotencyStore implements AtomicIdempotencyStore { + readonly scope = 'process' as const + private readonly entries = new Map() + private readonly owners = new Map() + + claim(key: string, ttlMs: number): boolean { + assertClaimInput(key, ttlMs) + // A long-running handler must not reclaim its own expired key while the + // original delivery can still call release. A fresh process may reclaim + // an expired durable claim; this local guard only preserves ownership + // within one process. + if (this.owners.has(key)) return false + const now = Date.now() + const existing = this.entries.get(key) + if (existing && existing.expiresAt > now) return false + + const token = randomUUID() + this.entries.set(key, { expiresAt: now + ttlMs, token }) + this.owners.set(key, token) + return true + } + + release(key: string): void { + assertKey(key) + const token = this.owners.get(key) + if (!token) return + const current = this.entries.get(key) + if (current?.token === token) this.entries.delete(key) + if (this.owners.get(key) === token) this.owners.delete(key) + } + + complete(key: string): void { + assertKey(key) + this.owners.delete(key) + } +} + +export interface FileSystemAtomicIdempotencyStoreOptions { + /** Optional path-safe filename namespace for colocated stores. */ + namespace?: string + /** Maximum time to wait for another worker's per-key lock. */ + lockWaitMs?: number + /** Lease used to recover a lock left by a crashed worker. */ + lockLeaseMs?: number +} + +interface ClaimRecord { + version: 1 + keyHash: string + token: string + expiresAt: number +} + +interface LockRecord { + token: string + expiresAt: number +} + +/** + * Durable file-per-key claims using the repository's existing filesystem + * persistence convention. The state write is atomic, and an exclusive lock + * serializes read/replace decisions across worker processes. + * + * All workers must use the same shared filesystem directory. A lock left by a + * crashed worker is recoverable after its lease expires; malformed files fail + * closed instead of risking a duplicate claim. + */ +export class FileSystemAtomicIdempotencyStore implements AtomicIdempotencyStore { + readonly scope = 'shared' as const + private readonly lockWaitMs: number + private readonly lockLeaseMs: number + private readonly filePrefix: string + private readonly owners = new Map() + + constructor( + private readonly rootDir: string, + options: FileSystemAtomicIdempotencyStoreOptions = {}, + ) { + if (!rootDir.trim()) throw new Error('FileSystemAtomicIdempotencyStore requires a root directory') + if (options.namespace !== undefined && !/^[A-Za-z0-9_.-]+$/.test(options.namespace)) { + throw new Error('Idempotency namespace must contain only path-safe characters') + } + this.filePrefix = options.namespace ? `${options.namespace}-` : '' + this.lockWaitMs = positiveOption(options.lockWaitMs ?? 30_000, 'lockWaitMs') + this.lockLeaseMs = positiveOption(options.lockLeaseMs ?? 60_000, 'lockLeaseMs') + if (this.lockLeaseMs <= 0) throw new Error('lockLeaseMs must be positive') + } + + async claim(key: string, ttlMs: number): Promise { + assertClaimInput(key, ttlMs) + // See the in-memory implementation: do not let one worker replace its + // own live handler's claim after expiry and then release the replacement. + if (this.owners.has(key)) return false + const keyHash = hashKey(key) + return this.withLock(keyHash, async () => { + const file = await this.statePath(keyHash) + const current = await this.readState(file, keyHash) + const now = Date.now() + if (current && current.expiresAt > now) return false + + const token = randomUUID() + await this.writeState(file, { + version: 1, + keyHash, + token, + expiresAt: now + ttlMs, + }) + this.owners.set(key, token) + return true + }) + } + + async release(key: string): Promise { + assertKey(key) + const token = this.owners.get(key) + if (!token) return + + const keyHash = hashKey(key) + await this.withLock(keyHash, async () => { + const file = await this.statePath(keyHash) + const current = await this.readState(file, keyHash) + if (current?.token === token) await this.removeState(file) + if (this.owners.get(key) === token) this.owners.delete(key) + }) + } + + complete(key: string): void { + assertKey(key) + this.owners.delete(key) + } + + private async statePath(keyHash: string): Promise { + const path = await import('node:path') + return path.join(this.rootDir, `${this.filePrefix}${keyHash}.json`) + } + + private async lockPath(keyHash: string): Promise { + const path = await import('node:path') + return path.join(this.rootDir, `${this.filePrefix}${keyHash}.lock`) + } + + private async ensureRoot(): Promise { + const fs = await import('node:fs/promises') + await fs.mkdir(this.rootDir, { recursive: true, mode: 0o700 }) + } + + private async readState(file: string, expectedKeyHash: string): Promise { + const fs = await import('node:fs/promises') + let raw: string + try { + raw = await fs.readFile(file, 'utf8') + } catch (err) { + if (isNodeENOENT(err)) return null + throw err + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error(`Invalid idempotency state for ${expectedKeyHash}`) + } + if (!isClaimRecord(parsed) || parsed.keyHash !== expectedKeyHash) { + throw new Error(`Invalid idempotency state for ${expectedKeyHash}`) + } + return parsed + } + + private async writeState(file: string, record: ClaimRecord): Promise { + const fs = await import('node:fs/promises') + const tmp = `${file}.tmp-${process.pid}-${randomUUID()}` + await fs.writeFile(tmp, JSON.stringify(record), { encoding: 'utf8', mode: 0o600 }) + try { + await fs.rename(tmp, file) + } catch (err) { + await removeIfPresent(tmp) + throw err + } + } + + private async removeState(file: string): Promise { + const fs = await import('node:fs/promises') + try { + await fs.unlink(file) + } catch (err) { + if (!isNodeENOENT(err)) throw err + } + } + + private async withLock(keyHash: string, work: () => Promise): Promise { + await this.ensureRoot() + const fs = await import('node:fs/promises') + const lockFile = await this.lockPath(keyHash) + const startedAt = Date.now() + + while (true) { + const token = randomUUID() + let handle: import('node:fs/promises').FileHandle | undefined + try { + handle = await fs.open(lockFile, 'wx', 0o600) + const lock: LockRecord = { token, expiresAt: Date.now() + this.lockLeaseMs } + await handle.writeFile(JSON.stringify(lock), 'utf8') + await handle.close() + handle = undefined + + try { + return await work() + } finally { + await this.releaseLock(lockFile, token) + } + } catch (err) { + if (handle) await handle.close().catch(() => undefined) + if (!isNodeEEXIST(err)) throw err + + const current = await this.readLock(lockFile) + if (current && current.expiresAt <= Date.now()) { + await this.reclaimExpiredLock(lockFile) + continue + } + if (Date.now() - startedAt >= this.lockWaitMs) { + throw new Error(`Timed out acquiring idempotency lock for ${keyHash}`) + } + await delay(5) + } + } + } + + private async readLock(file: string): Promise { + const fs = await import('node:fs/promises') + let raw: string + try { + raw = await fs.readFile(file, 'utf8') + } catch (err) { + if (isNodeENOENT(err)) return null + throw err + } + if (!raw.trim()) return null + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('Invalid idempotency lock') + } + if (!isLockRecord(parsed)) throw new Error('Invalid idempotency lock') + return parsed + } + + private async reclaimExpiredLock(file: string): Promise { + const fs = await import('node:fs/promises') + const path = await import('node:path') + const stale = path.join(this.rootDir, `${path.basename(file)}.stale-${randomUUID()}`) + try { + await fs.rename(file, stale) + await fs.unlink(stale) + } catch (err) { + if (!isNodeENOENT(err)) throw err + } + } + + private async releaseLock(file: string, token: string): Promise { + const current = await this.readLock(file) + if (current?.token !== token) return + const fs = await import('node:fs/promises') + try { + await fs.unlink(file) + } catch (err) { + if (!isNodeENOENT(err)) throw err + } + } +} + +export interface ResolveAtomicIdempotencyStoreOptions { + component: string + store?: AtomicIdempotencyStore + runtime?: IdempotencyRuntime +} + +/** + * Resolve the safe default for a caller. Production has no implicit fallback: + * the caller must inject a store that declares shared atomic scope. + */ +export function resolveAtomicIdempotencyStore(options: ResolveAtomicIdempotencyStoreOptions): AtomicIdempotencyStore { + const runtime = options.runtime ?? currentRuntime() + if (isProductionEnvironment() && runtime !== 'production') { + throw new Error(`${options.component}: production environment cannot use a non-production idempotency runtime`) + } + if (options.store) { + if (runtime === 'production' && options.store.scope !== 'shared') { + throw new Error(`${options.component}: production requires a shared atomic idempotency store`) + } + return options.store + } + if (runtime === 'production') { + throw new Error(`${options.component}: shared atomic idempotency store is required in production`) + } + return new InMemoryAtomicIdempotencyStore() +} + +function currentRuntime(): IdempotencyRuntime { + if (typeof process !== 'undefined' && process.env.VITEST) return 'test' + const nodeEnv = typeof process !== 'undefined' ? process.env.NODE_ENV : undefined + if (nodeEnv === 'test') return 'test' + if (nodeEnv === 'development') return 'development' + return 'production' +} + +function isProductionEnvironment(): boolean { + return typeof process !== 'undefined' && process.env.NODE_ENV === 'production' +} + +function assertClaimInput(key: string, ttlMs: number): void { + assertKey(key) + if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('Idempotency TTL must be a positive finite number') + if (!Number.isFinite(Date.now() + ttlMs)) throw new Error('Idempotency TTL exceeds the supported clock range') +} + +function assertKey(key: string): void { + if (typeof key !== 'string' || !key.trim()) throw new Error('Idempotency key must be a non-empty string') +} + +function positiveOption(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive finite number`) + return value +} + +function hashKey(key: string): string { + return createHash('sha256').update(key, 'utf8').digest('hex') +} + +function isClaimRecord(value: unknown): value is ClaimRecord { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return candidate.version === 1 + && typeof candidate.keyHash === 'string' + && /^[a-f0-9]{64}$/.test(candidate.keyHash) + && typeof candidate.token === 'string' + && candidate.token.length > 0 + && Number.isFinite(candidate.expiresAt) +} + +function isLockRecord(value: unknown): value is LockRecord { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return typeof candidate.token === 'string' + && candidate.token.length > 0 + && Number.isFinite(candidate.expiresAt) +} + +function isNodeENOENT(err: unknown): boolean { + return !!err && typeof err === 'object' && (err as { code?: string }).code === 'ENOENT' +} + +function isNodeEEXIST(err: unknown): boolean { + return !!err && typeof err === 'object' && (err as { code?: string }).code === 'EEXIST' +} + +async function removeIfPresent(file: string): Promise { + const fs = await import('node:fs/promises') + try { + await fs.unlink(file) + } catch (err) { + if (!isNodeENOENT(err)) throw err + } +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/index.ts b/src/index.ts index 5ecd20d..d6907cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,8 @@ export * from './audit.js' export * from './approval.js' export * from './actions.js' export * from './bridge.js' +export * from './billing-access-policy.js' +export * from './idempotency.js' export * from './apps.js' export * from './client.js' export * from './consumer.js' diff --git a/src/middleware/index.ts b/src/middleware/index.ts index bf4dc7f..e6ea689 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -46,6 +46,7 @@ import { type TangleTokenVerifyFailure, type TangleTokenVerifyResult, } from '../connectors/adapters/tangle-id.js' +import { isRealNonPlaceholderEmail } from '../billing-access-policy.js' /** Auth context the middleware attaches to the request on success. */ export interface TangleAuthContext { @@ -61,6 +62,12 @@ export interface TangleAuthContext { ownerType: 'user' | 'team' /** Product the credential is scoped to, when known. */ product?: string + /** Platform proof that this identity passed email policy. */ + emailVerified?: boolean + /** Real email returned by Platform when available. */ + email?: string + /** Platform-created machine principal. */ + servicePrincipal?: boolean } export type TangleAuthOutcome = @@ -114,6 +121,7 @@ export async function requireTangleAuth( workspaceId: '', scopes: [], kind: 'session', + emailVerified: false, ownerType: 'user', }, } @@ -136,6 +144,13 @@ export async function requireTangleAuth( return { ok: false, status, reason: result.reason } } + if (!result.servicePrincipal && result.emailVerified !== true) { + return { ok: false, status: 403, reason: 'email_verification_required' } + } + if (!result.servicePrincipal && !isRealNonPlaceholderEmail(result.email)) { + return { ok: false, status: 403, reason: 'real_email_required' } + } + return { ok: true, auth: { @@ -144,6 +159,9 @@ export async function requireTangleAuth( scopes: result.scopes, kind: result.kind, ownerType: result.ownerType, + ...(result.emailVerified !== undefined ? { emailVerified: result.emailVerified } : {}), + ...(result.email ? { email: result.email } : {}), + ...(result.servicePrincipal !== undefined ? { servicePrincipal: result.servicePrincipal } : {}), ...(result.expiresAt !== undefined ? { expiresAt: result.expiresAt } : {}), ...(result.credentialId ? { credentialId: result.credentialId } : {}), ...(result.product ? { product: result.product } : {}), diff --git a/src/stripe/errors.ts b/src/stripe/errors.ts index a5a8bc0..b91ae2e 100644 --- a/src/stripe/errors.ts +++ b/src/stripe/errors.ts @@ -35,6 +35,12 @@ export type BillingErrorCode = | 'subscription_past_due' | 'trial_expired' | 'free_tier_exhausted' + | 'platform_evidence_required' + | 'platform_evidence_subject_mismatch' + | 'email_verification_required' + | 'real_email_required' + | 'paid_evidence_required' + | 'product_free_credits_disabled' | 'tenant_not_configured' | 'webhook_secret_missing' | 'webhook_event_unknown' @@ -95,6 +101,12 @@ function mapToIntegrationCode(code: BillingErrorCode): IntegrationRuntimeError[' case 'subscription_past_due': case 'trial_expired': case 'free_tier_exhausted': + case 'platform_evidence_required': + case 'platform_evidence_subject_mismatch': + case 'email_verification_required': + case 'real_email_required': + case 'paid_evidence_required': + case 'product_free_credits_disabled': return 'action_denied' case 'tenant_not_configured': return 'provider_error' @@ -113,6 +125,12 @@ function statusForBillingCode(code: BillingErrorCode): number { case 'subscription_past_due': case 'trial_expired': case 'free_tier_exhausted': + case 'platform_evidence_required': + case 'platform_evidence_subject_mismatch': + case 'email_verification_required': + case 'real_email_required': + case 'paid_evidence_required': + case 'product_free_credits_disabled': return 403 case 'tenant_not_configured': return 500 @@ -135,6 +153,13 @@ function defaultUserAction(code: BillingErrorCode): IntegrationUserAction | unde return { type: 'change_request', label: 'Choose a plan' } case 'free_tier_exhausted': return { type: 'change_request', label: 'Upgrade for more usage' } + case 'platform_evidence_required': + case 'platform_evidence_subject_mismatch': + case 'email_verification_required': + case 'real_email_required': + case 'paid_evidence_required': + case 'product_free_credits_disabled': + return { type: 'change_request', label: 'Verify access to continue' } case 'tenant_not_configured': case 'webhook_secret_missing': return { type: 'contact_support', label: 'Contact support' } diff --git a/src/stripe/index.ts b/src/stripe/index.ts index f26c749..ce5d103 100644 --- a/src/stripe/index.ts +++ b/src/stripe/index.ts @@ -7,7 +7,7 @@ * webhooks.ts — typed event dispatcher on top of WebhookRouter * pricing.ts — PricingPlan shape + checkout/portal URL helpers * tenant-config.ts — per-product Stripe key routing - * middleware.ts — requireActiveSubscription + trial + free-tier + * middleware.ts — paid-subscription access checks * errors.ts — BillingError taxonomy on IntegrationRuntimeError * * Layering: diff --git a/src/stripe/middleware.ts b/src/stripe/middleware.ts index 19ae4f4..4d707e7 100644 --- a/src/stripe/middleware.ts +++ b/src/stripe/middleware.ts @@ -9,10 +9,10 @@ * → 'allow' | { allowed: false, error: BillingError } * * withTrialAccess({ workspaceId, days, trialStore }) - * → allow while trial < days expired since workspace creation + * → always deny product-funded trial access * * getRemainingFreeTier({ workspaceId, freeTierStore }) - * → { remaining: number, total: number } + * → always { remaining: 0, total: 0, exhausted: true } * * Frameworks: we don't import Hono / Express. The middleware shape is a * pure async function returning a decision. The product wires it into @@ -26,6 +26,7 @@ */ import { BillingError } from './errors.js' +import { decideBillingAccess } from '../billing-access-policy.js' import { gateAccess, type SubscriptionRecord, @@ -39,6 +40,10 @@ import { export interface RequireActiveSubscriptionInput { workspaceId: string store: SubscriptionStore + /** Parsed Platform proof for the owner or an explicitly named service/admin. */ + accessEvidence?: import('../billing-access-policy.js').TrustedPlatformEvidence + /** Optional owner id to compare with Platform proof. */ + expectedUserId?: string /** Strict mode: reject `past_due`. Default false (allow with warn). */ denyPastDue?: boolean } @@ -50,10 +55,10 @@ export type SubscriptionGateResult = /** * Gate decision for a route that requires an active subscription. * - * Returns `{ allowed: true }` on `active` / `trialing` and on - * `past_due` (unless `denyPastDue`). Returns `{ allowed: false, error }` - * with a typed `BillingError` for any other state — the consumer maps - * the error's `status` to the HTTP response. + * Returns `{ allowed: true }` on `active` and on `past_due` (unless + * `denyPastDue`). A `trialing` record is denied because product-funded + * trials are disabled. Returns `{ allowed: false, error }` with a typed + * `BillingError` for any other state. */ export async function requireActiveSubscription( input: RequireActiveSubscriptionInput, @@ -69,12 +74,54 @@ export async function requireActiveSubscription( }), } } + const accessDecision = decideBillingAccess({ + evidence: input.accessEvidence, + expectedUserId: input.expectedUserId, + }) + if (!accessDecision.allowed) { + return { + allowed: false, + error: new BillingError({ + code: accessDecision.code, + message: accessDecision.reason, + context: { workspaceId: input.workspaceId }, + }), + } + } + if ( + accessDecision.basis !== 'paid_subscription' && + accessDecision.principal.kind === 'human' + ) { + return { + allowed: false, + error: new BillingError({ + code: 'paid_evidence_required', + message: 'Active subscription access requires matching paid subscription evidence.', + context: { workspaceId: input.workspaceId }, + }), + } + } + if ( + input.accessEvidence?.funding.kind === 'paid_subscription' && + input.accessEvidence.funding.subscriptionId !== record.subscriptionId + ) { + return { + allowed: false, + error: new BillingError({ + code: 'platform_evidence_subject_mismatch', + message: 'Subscription evidence does not match the stored subscription.', + context: { workspaceId: input.workspaceId, subscriptionId: record.subscriptionId }, + }), + } + } const decision = gateAccess(record.state) if (!decision.allowed) { return { allowed: false, error: new BillingError({ - code: decision.reason === 'subscription_inactive' + code: decision.reason === 'trial_expired' + ? 'trial_expired' + : decision.reason === 'subscription_inactive' ? 'subscription_inactive' : decision.reason === 'subscription_past_due' ? 'subscription_past_due' @@ -102,26 +149,16 @@ export async function requireActiveSubscription( }), } } - // Surface `trial_ending` warn when within 72h of trial end. - let warn = decision.warn - if (!warn && record.state === 'trialing' && record.trialEnd) { - const TRIAL_WARN_SECONDS = 72 * 60 * 60 - const nowSec = Math.floor(Date.now() / 1000) - if (record.trialEnd - nowSec < TRIAL_WARN_SECONDS) { - warn = 'trial_ending' - } - } - return { allowed: true, record, warn } + return { allowed: true, record, warn: decision.warn } } /* ---------------------------------------------------------------------- */ /* withTrialAccess */ /* ---------------------------------------------------------------------- */ -/** Workspace creation timestamp store — required by `withTrialAccess`. */ +/** Legacy workspace timestamp store retained for source compatibility. */ export interface TrialStore { - /** Returns workspace creation timestamp (ms epoch), or null if the - * workspace doesn't exist yet. */ + /** Returns a workspace creation timestamp (ms epoch), or null. */ getCreatedAt(workspaceId: string): Promise | number | null } @@ -135,54 +172,33 @@ export interface WithTrialAccessInput { } export interface TrialAccessResult { - /** Whether the workspace is still inside its free-trial window. */ + /** Always false while product-funded trials are disabled. */ inTrial: boolean - /** Days remaining (rounded down). Zero when `inTrial` is false. */ + /** Always zero while product-funded trials are disabled. */ daysRemaining: number - /** Trial end timestamp (ms epoch), null when no workspace found. */ + /** Always null while product-funded trials are disabled. */ trialEndsAt: number | null } /** - * Free-trial gate independent of Stripe state. Use BEFORE a workspace - * has a Stripe subscription (the product's onboarding period). Compose - * with `requireActiveSubscription`: trial OR active sub passes the gate. - * - * Composition pattern: + * Legacy compatibility helper. Product-funded trials are disabled, so this + * function always returns a denied trial result without reading the store. * - * const trial = await withTrialAccess(...) - * if (trial.inTrial) return next() - * const sub = await requireActiveSubscription(...) - * if (sub.allowed) return next() - * return respond(sub.error) + * Callers must use `requireActiveSubscription` for product access. */ export async function withTrialAccess(input: WithTrialAccessInput): Promise { - const createdAt = await input.trialStore.getCreatedAt(input.workspaceId) - if (createdAt === null) { - return { inTrial: false, daysRemaining: 0, trialEndsAt: null } - } - const now = (input.now ?? Date.now)() - const trialEndsAt = createdAt + input.days * 24 * 60 * 60 * 1000 - const remainingMs = trialEndsAt - now - if (remainingMs <= 0) { - return { inTrial: false, daysRemaining: 0, trialEndsAt } - } - const daysRemaining = Math.floor(remainingMs / (24 * 60 * 60 * 1000)) - return { inTrial: true, daysRemaining, trialEndsAt } + const decision = decideBillingAccess({}) + if (decision.allowed) throw new Error('billing: unexpected trial access allowance') + return { inTrial: false, daysRemaining: 0, trialEndsAt: null } } /* ---------------------------------------------------------------------- */ /* getRemainingFreeTier */ /* ---------------------------------------------------------------------- */ -/** Free-tier counter store — abstract over the consumer's metering - * pipeline. The interface is read-only; products own counter increment - * on usage (e.g., increment on every API call in their own metrics - * layer). */ +/** Legacy read-only counter store retained for source compatibility. */ export interface FreeTierStore { - /** Returns `{ used, total }` for the workspace. Implementations - * return `{ used: 0, total: }` for unknown workspaces if - * the product wants implicit free-tier grant. */ + /** Returns `{ used, total }` for a workspace. */ getUsage(workspaceId: string): Promise<{ used: number; total: number }> | { used: number; total: number } } @@ -201,21 +217,15 @@ export interface FreeTierResult { } /** - * Return how much free-tier quota the workspace has left. Pure projection - * over the store; consumers use the result to decide whether to grant the - * route or return `BillingError(code: 'free_tier_exhausted')`. - * - * Why this isn't a gate function itself: free-tier "exhausted" is rarely - * a hard deny — most products throttle, queue, or upsell instead. The - * decision is product-specific; we provide the read and the typed error - * but stop short of opining on the response shape. + * Legacy compatibility helper. Product-funded free-tier quota is disabled, + * so this function always returns zero without reading the consumer store. */ export async function getRemainingFreeTier( input: GetRemainingFreeTierInput, ): Promise { - const { used, total } = await input.freeTierStore.getUsage(input.workspaceId) - const remaining = Math.max(0, total - used) - return { remaining, total, exhausted: remaining === 0 } + const decision = decideBillingAccess({}) + if (decision.allowed) throw new Error('billing: unexpected free-tier allowance') + return { remaining: 0, total: 0, exhausted: true } } /* ---------------------------------------------------------------------- */ @@ -225,6 +235,8 @@ export async function getRemainingFreeTier( export interface ComposedGateInput { workspaceId: string store: SubscriptionStore + accessEvidence?: import('../billing-access-policy.js').TrustedPlatformEvidence + expectedUserId?: string trialStore?: TrialStore trialDays?: number denyPastDue?: boolean @@ -232,49 +244,17 @@ export interface ComposedGateInput { } /** - * Compose `withTrialAccess` || `requireActiveSubscription`. Most product - * routes want this exact combo — passes if EITHER the workspace is - * inside its free trial OR has an active subscription. Returns the - * subscription error from `requireActiveSubscription` when both fail - * (the more actionable of the two — the customer can convert it into - * a checkout). + * Legacy compatibility helper. Trial inputs are ignored and access depends + * only on the paid subscription state. */ export async function gateSubscriptionOrTrial( input: ComposedGateInput, ): Promise { - if (input.trialStore && input.trialDays) { - const trial = await withTrialAccess({ - workspaceId: input.workspaceId, - days: input.trialDays, - trialStore: input.trialStore, - now: input.now, - }) - if (trial.inTrial) { - // Synthesize a record-shaped result so the consumer's downstream - // code path is uniform — but flag it as via-trial. - const trialRecord = trialSyntheticRecord(input.workspaceId, trial.trialEndsAt ?? 0) - return { allowed: true, record: trialRecord, viaTrial: true, daysRemaining: trial.daysRemaining } - } - } return requireActiveSubscription({ workspaceId: input.workspaceId, store: input.store, + accessEvidence: input.accessEvidence, + expectedUserId: input.expectedUserId, denyPastDue: input.denyPastDue, }) } - -function trialSyntheticRecord(workspaceId: string, trialEndsAt: number): SubscriptionRecord { - return { - workspaceId, - customerId: '', - subscriptionId: '', - state: 'trialing', - priceId: null, - currentPeriodEnd: Math.floor(trialEndsAt / 1000), - trialEnd: Math.floor(trialEndsAt / 1000), - cancelAtPeriodEnd: false, - version: 0, - lastEventId: null, - updatedAt: Date.now(), - } -} diff --git a/src/stripe/pricing.ts b/src/stripe/pricing.ts index 65fb937..e417380 100644 --- a/src/stripe/pricing.ts +++ b/src/stripe/pricing.ts @@ -21,6 +21,7 @@ */ import type { StripeClient } from './tenant-config.js' +import { assertNoProductFreeTrial } from '../billing-access-policy.js' export interface PricingPlanFeature { /** Short label rendered in pricing table rows. */ @@ -52,8 +53,10 @@ export interface PricingPlan { monthly?: string yearly?: string } - /** Optional trial-day grant. The dispatcher writes `trialEnd` based - * on Stripe's response; this field is only the request-time intent. */ + /** + * Legacy compatibility field. Positive values are rejected because + * product-funded free trials are disabled. + */ trialDays?: number /** Optional metadata threaded into Stripe Subscription metadata — the * product can use these for analytics or grant-feature lookup. */ @@ -95,7 +98,7 @@ export interface CreateCheckoutUrlInput { * `${workspaceId}:${plan.id}:${billing}`) so the same user clicking * twice gets the same checkout session. */ idempotencyKey: string - /** Trial override — if set, beats `plan.trialDays`. */ + /** Legacy compatibility field. Positive values are rejected. */ trialDays?: number /** Optional extra metadata mixed into Stripe metadata. */ metadata?: Record @@ -124,10 +127,23 @@ export async function createCheckoutUrl( client: StripeClient, input: CreateCheckoutUrlInput, ): Promise { + if (!input.workspaceId.trim()) throw new Error('pricing: workspaceId is required') + if (!input.plan.id.trim() || !input.plan.name.trim()) throw new Error('pricing: plan id and name are required') + if (!input.idempotencyKey.trim()) throw new Error('pricing: idempotencyKey is required') const priceId = input.plan.stripePriceIds[input.billing] if (!priceId) { throw new Error(`pricing: plan '${input.plan.id}' has no Stripe price for cadence '${input.billing}'`) } + if (!/^price_[A-Za-z0-9_]+$/.test(priceId)) { + throw new Error(`pricing: invalid Stripe price id '${priceId}'`) + } + if (!client.config.approvedPriceIds?.includes(priceId)) { + throw new Error(`pricing: Stripe price '${priceId}' is not approved for product '${client.productId}'`) + } + const planUsd = input.billing === 'monthly' ? input.plan.monthlyUsd : input.plan.yearlyUsd + if (typeof planUsd !== 'number' || !Number.isFinite(planUsd) || planUsd <= 0) { + throw new Error(`pricing: ${input.billing} price must be greater than zero`) + } const successUrl = input.successUrl ?? client.config.successUrl const cancelUrl = input.cancelUrl ?? client.config.cancelUrl if (!successUrl || !cancelUrl) { @@ -135,6 +151,7 @@ export async function createCheckoutUrl( } const trialDays = input.trialDays ?? input.plan.trialDays + assertNoProductFreeTrial(trialDays) const body: Record = { mode: 'subscription', success_url: successUrl, @@ -148,12 +165,12 @@ export async function createCheckoutUrl( } if (input.customerId) body.customer = input.customerId if (input.customerEmail && !input.customerId) body.customer_email = input.customerEmail - if (trialDays && trialDays > 0) { - body['subscription_data[trial_period_days]'] = trialDays - } // Mix in plan-defined metadata + caller-supplied metadata. const extra = { ...(input.plan.metadata ?? {}), ...(input.metadata ?? {}) } for (const [k, v] of Object.entries(extra)) { + if (k === 'workspaceId' || k === 'planId') { + throw new Error(`pricing: metadata key '${k}' is reserved`) + } body[`metadata[${k}]`] = v body[`subscription_data[metadata][${k}]`] = v } diff --git a/src/stripe/subscription-state.ts b/src/stripe/subscription-state.ts index f3b0302..2a248fe 100644 --- a/src/stripe/subscription-state.ts +++ b/src/stripe/subscription-state.ts @@ -7,7 +7,7 @@ * * incomplete — first invoice not paid within 23 hours * incomplete_expired — first invoice failed, no retry coming - * trialing — inside a trial window (treat as active) + * trialing — inside a trial window (not product access) * active — paying, current * past_due — auto-renewal failed; grace period running * canceled — terminal; ended at period boundary or hard @@ -39,6 +39,7 @@ */ import { BillingError } from './errors.js' +import { FileSystemAtomicIdempotencyStore } from '../idempotency.js' export type SubscriptionState = | 'incomplete' @@ -167,7 +168,8 @@ export function applyTransition( * Map a state to an access decision. * * Rule rationale: - * active, trialing → allow + * active → allow + * trialing → deny (product-funded trials are disabled) * past_due → allow + warn (dunning grace) * paused → deny (operator action; resume restores) * canceled, unpaid → deny (terminal financial states) @@ -182,8 +184,9 @@ export function applyTransition( export function gateAccess(state: SubscriptionState): AccessDecision { switch (state) { case 'active': - case 'trialing': return { allowed: true } + case 'trialing': + return { allowed: false, reason: 'trial_expired' } case 'past_due': return { allowed: true, warn: 'past_due' } case 'paused': @@ -234,9 +237,8 @@ export class InMemorySubscriptionStore implements SubscriptionStore { /** * File-per-workspace JSON store. One file per workspace under * `/.json`. Cheap, durable, debuggable — adequate - * for self-hosted product agents. CAS is implemented via the version - * field plus a write that re-reads the file under a brief lock window - * (rename-temp-to-target pattern, atomic on POSIX). + * for self-hosted product agents. CAS combines the version check with the + * shared per-workspace lock from `FileSystemAtomicIdempotencyStore`. * * Why per-file and not one JSONL: subscriptions are * accessed by workspaceId 99% of the time, scanning a JSONL on every @@ -247,7 +249,11 @@ export class InMemorySubscriptionStore implements SubscriptionStore { * `saveIfVersion()`, so the CAS catches the race. */ export class FileSystemSubscriptionStore implements SubscriptionStore { - constructor(private readonly rootDir: string) {} + private readonly writeLock: FileSystemAtomicIdempotencyStore + + constructor(private readonly rootDir: string) { + this.writeLock = new FileSystemAtomicIdempotencyStore(rootDir, { namespace: 'subscription-lock' }) + } async load(workspaceId: string): Promise { const fs = await import('node:fs/promises') @@ -263,21 +269,48 @@ export class FileSystemSubscriptionStore implements SubscriptionStore { } async save(record: SubscriptionRecord): Promise { + const lockKey = this.lockKey(record.workspaceId) + if (!(await this.writeLock.claim(lockKey, 30_000))) { + throw new Error(`Subscription write contention for ${record.workspaceId}`) + } + try { + await this.writeRecord(record) + } finally { + await this.writeLock.release?.(lockKey) + } + } + + async saveIfVersion(record: SubscriptionRecord, expectedVersion: number): Promise { + const lockKey = this.lockKey(record.workspaceId) + if (!(await this.writeLock.claim(lockKey, 30_000))) return false + try { + const existing = await this.load(record.workspaceId) + if (existing && existing.version !== expectedVersion) return false + if (!existing && expectedVersion !== 0) return false + await this.writeRecord(record) + return true + } finally { + await this.writeLock.release?.(lockKey) + } + } + + private async writeRecord(record: SubscriptionRecord): Promise { const fs = await import('node:fs/promises') const path = await import('node:path') await fs.mkdir(this.rootDir, { recursive: true }) const file = path.join(this.rootDir, this.fileName(record.workspaceId)) - const tmp = `${file}.tmp-${process.pid}-${Date.now()}` + const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` await fs.writeFile(tmp, JSON.stringify(record), 'utf-8') - await fs.rename(tmp, file) + try { + await fs.rename(tmp, file) + } catch (err) { + await fs.unlink(tmp).catch(() => undefined) + throw err + } } - async saveIfVersion(record: SubscriptionRecord, expectedVersion: number): Promise { - const existing = await this.load(record.workspaceId) - if (existing && existing.version !== expectedVersion) return false - if (!existing && expectedVersion !== 0) return false - await this.save(record) - return true + private lockKey(workspaceId: string): string { + return `subscription:${workspaceId}` } /** Safe filename: workspaceId is restricted to a charset that maps 1:1 diff --git a/src/stripe/tenant-config.ts b/src/stripe/tenant-config.ts index 579c837..2e88c7c 100644 --- a/src/stripe/tenant-config.ts +++ b/src/stripe/tenant-config.ts @@ -60,6 +60,8 @@ export interface TenantStripeConfig { secretKey: string /** Webhook signing secret (`whsec_...`). */ webhookSecret: string + /** Price ids the product explicitly approved for checkout. */ + approvedPriceIds?: readonly string[] /** Optional default URLs the checkout/portal generators fall back to. */ successUrl?: string cancelUrl?: string @@ -96,10 +98,15 @@ export class EnvTenantConfigResolver implements TenantConfigResolver { const sk = this.env[`STRIPE_SK_${key}`] const wh = this.env[`STRIPE_WHSEC_${key}`] if (!sk || !wh) return null + const approvedPriceIds = (this.env[`STRIPE_APPROVED_PRICE_IDS_${key}`] ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) return { productId, secretKey: sk, webhookSecret: wh, + ...(approvedPriceIds.length > 0 ? { approvedPriceIds } : {}), successUrl: this.env[`STRIPE_SUCCESS_URL_${key}`], cancelUrl: this.env[`STRIPE_CANCEL_URL_${key}`], } diff --git a/src/stripe/webhooks.ts b/src/stripe/webhooks.ts index dcbb3f2..acfbdf9 100644 --- a/src/stripe/webhooks.ts +++ b/src/stripe/webhooks.ts @@ -49,6 +49,13 @@ */ import type { WebhookEnvelope } from '../webhooks/router.js' +import { + FileSystemAtomicIdempotencyStore, + InMemoryAtomicIdempotencyStore, + resolveAtomicIdempotencyStore, + type AtomicIdempotencyStore, + type IdempotencyRuntime, +} from '../idempotency.js' import { BillingError } from './errors.js' import { applyTransition, @@ -94,6 +101,11 @@ interface StripeInvoicePayload { metadata?: Record } +interface StripeSubscriptionIdentity { + customerId: string + subscriptionId: string +} + interface StripeEvent { id: string type: string @@ -114,6 +126,11 @@ export type StripeBillingEvent = eventId: string record: SubscriptionRecord } + | { + kind: 'subscription.trial_ignored' + eventId: string + record: SubscriptionRecord + } | { kind: 'subscription.updated' eventId: string @@ -148,6 +165,12 @@ export type StripeBillingEvent = invoiceId: string amountPaid: number } + | { + kind: 'invoice.zero_dollar_ignored' + eventId: string + invoiceId: string + amountPaid: number + } | { kind: 'invoice.payment_failed' eventId: string @@ -177,6 +200,14 @@ export type StripeBillingEvent = * caught by `dispatch()` and surfaced through `onError`. */ export type StripeBillingListener = (event: StripeBillingEvent) => void | Promise +export interface StripeEventIdempotencyStore extends AtomicIdempotencyStore {} + +/** Process-local store for direct dispatcher use and tests. */ +export class InMemoryStripeEventIdempotencyStore extends InMemoryAtomicIdempotencyStore implements StripeEventIdempotencyStore {} + +/** Durable store for workers that share the same filesystem directory. */ +export class FileSystemStripeEventIdempotencyStore extends FileSystemAtomicIdempotencyStore implements StripeEventIdempotencyStore {} + export interface StripeBillingDispatcherOptions { store: SubscriptionStore /** Maps a Stripe `customer.id` → the workspaceId the product uses to @@ -199,6 +230,12 @@ export interface StripeBillingDispatcherOptions { now?(): number /** Max retries on `saveIfVersion` contention. Default 3. */ maxCasRetries?: number + /** Separate atomic event store for direct dispatcher calls. */ + idempotency?: StripeEventIdempotencyStore + /** Runtime controls the safe default. Production fails closed without a + * shared store; test/development use an in-memory store when omitted. */ + runtime?: IdempotencyRuntime + idempotencyTtlMs?: number } /* ---------------------------------------------------------------------- */ @@ -216,6 +253,8 @@ export class StripeBillingDispatcher { private readonly onError: NonNullable private readonly now: () => number private readonly maxCasRetries: number + private readonly idempotency: StripeEventIdempotencyStore + private readonly idempotencyTtlMs: number constructor(opts: StripeBillingDispatcherOptions) { this.store = opts.store @@ -224,6 +263,12 @@ export class StripeBillingDispatcher { this.onError = opts.onError ?? defaultOnError this.now = opts.now ?? Date.now this.maxCasRetries = opts.maxCasRetries ?? 3 + this.idempotency = resolveAtomicIdempotencyStore({ + component: 'StripeBillingDispatcher', + store: opts.idempotency, + runtime: opts.runtime, + }) + this.idempotencyTtlMs = opts.idempotencyTtlMs ?? 7 * 24 * 60 * 60 * 1000 } /** Drive one envelope through the pipeline. Idempotent w.r.t. the @@ -237,8 +282,21 @@ export class StripeBillingDispatcher { }) return } + if (!(await this.idempotency.claim(evt.id, this.idempotencyTtlMs))) { + await this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) + return + } try { await this.handle(evt) + } catch (err) { + await this.idempotency.release?.(evt.id) + this.onError(err, { eventId: evt.id, type: evt.type }) + return + } + // Retain the durable replay claim, but clear process-local ownership so a + // later delivery can reclaim the key after the configured TTL. + try { + await this.idempotency.complete?.(evt.id) } catch (err) { this.onError(err, { eventId: evt.id, type: evt.type }) } @@ -272,13 +330,18 @@ export class StripeBillingDispatcher { private async handleSubCreated(evt: StripeEvent): Promise { const sub = evt.data.object as StripeSubscriptionPayload + const identity = subscriptionIdentity(sub) + if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ - customerId: sub.customer, + customerId: identity.customerId, subscriptionMetadata: sub.metadata, }) if (!workspaceId) return this.emitNoWorkspace(evt) const existing = await this.store.load(workspaceId) + if (existing && !matchesSubscription(existing, identity)) { + return this.emitUnbound(evt, 'subscription identity does not match the workspace record') + } if (existing && existing.lastEventId === evt.id) { return this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) } @@ -296,8 +359,8 @@ export class StripeBillingDispatcher { const record = makeSubscriptionRecord({ workspaceId, - customerId: sub.customer, - subscriptionId: sub.id, + customerId: identity.customerId, + subscriptionId: identity.subscriptionId, state: parseState(sub.status, evt.id), priceId: extractPriceId(sub), currentPeriodEnd: sub.current_period_end ?? null, @@ -308,14 +371,20 @@ export class StripeBillingDispatcher { const stamped: SubscriptionRecord = { ...record, lastEventId: evt.id } const expectedVersion = existing?.version ?? 0 const written = await this.cas(stamped, expectedVersion) - if (!written) return - await this.emit({ kind: 'subscription.created', eventId: evt.id, record: stamped }) + if (!written) return this.emitUnbound(evt, 'subscription create lost a concurrent compare-and-set') + await this.emit( + stamped.state === 'trialing' + ? { kind: 'subscription.trial_ignored', eventId: evt.id, record: stamped } + : { kind: 'subscription.created', eventId: evt.id, record: stamped }, + ) } private async handleSubUpdated(evt: StripeEvent): Promise { const sub = evt.data.object as StripeSubscriptionPayload + const identity = subscriptionIdentity(sub) + if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ - customerId: sub.customer, + customerId: identity.customerId, subscriptionMetadata: sub.metadata, }) if (!workspaceId) return this.emitNoWorkspace(evt) @@ -337,15 +406,20 @@ export class StripeBillingDispatcher { ) return { next, - emit: { kind: 'subscription.updated', eventId: evt.id, previousState: current.state, record: next }, + emit: + nextState === 'trialing' + ? { kind: 'subscription.trial_ignored', eventId: evt.id, record: next } + : { kind: 'subscription.updated', eventId: evt.id, previousState: current.state, record: next }, } - }) + }, identity) } private async handleSubDeleted(evt: StripeEvent): Promise { const sub = evt.data.object as StripeSubscriptionPayload + const identity = subscriptionIdentity(sub) + if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ - customerId: sub.customer, + customerId: identity.customerId, subscriptionMetadata: sub.metadata, }) if (!workspaceId) return this.emitNoWorkspace(evt) @@ -362,18 +436,23 @@ export class StripeBillingDispatcher { next, emit: { kind: 'subscription.deleted', eventId: evt.id, record: next }, } - }) + }, identity) } private async handleTrialWillEnd(evt: StripeEvent): Promise { const sub = evt.data.object as StripeSubscriptionPayload + const identity = subscriptionIdentity(sub) + if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ - customerId: sub.customer, + customerId: identity.customerId, subscriptionMetadata: sub.metadata, }) if (!workspaceId) return this.emitNoWorkspace(evt) const current = await this.store.load(workspaceId) if (!current) return this.emitNoWorkspace(evt) + if (!matchesSubscription(current, identity)) { + return this.emitUnbound(evt, 'subscription identity does not match the workspace record') + } if (current.lastEventId === evt.id) { return this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) } @@ -387,7 +466,7 @@ export class StripeBillingDispatcher { updatedAt: this.now(), } const written = await this.cas(next, current.version) - if (!written) return + if (!written) return this.emitUnbound(evt, 'trial update lost a concurrent compare-and-set') await this.emit({ kind: 'subscription.trial_will_end', eventId: evt.id, @@ -398,8 +477,10 @@ export class StripeBillingDispatcher { private async handleSubLifecycle(evt: StripeEvent, target: SubscriptionState): Promise { const sub = evt.data.object as StripeSubscriptionPayload + const identity = subscriptionIdentity(sub) + if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ - customerId: sub.customer, + customerId: identity.customerId, subscriptionMetadata: sub.metadata, }) if (!workspaceId) return this.emitNoWorkspace(evt) @@ -410,36 +491,56 @@ export class StripeBillingDispatcher { const next = applyTransition(current, { state: target }, { eventId: evt.id, now: this.now }) const kind = target === 'paused' ? 'subscription.paused' : 'subscription.resumed' return { next, emit: { kind, eventId: evt.id, record: next } } - }) + }, identity) } /* ----------------------- invoice event handlers ---------------------- */ private async handleInvoicePaid(evt: StripeEvent): Promise { const inv = evt.data.object as StripeInvoicePayload + if (!invoiceIdentity(inv)) return this.emitUnbound(evt, 'paid invoice is missing customer or invoice id') const workspaceId = await this.resolveWorkspaceId({ customerId: inv.customer ?? '', invoiceMetadata: inv.metadata, }) - let record: SubscriptionRecord | null = null - if (workspaceId) record = await this.store.load(workspaceId) + const amountPaid = typeof inv.amount_paid === 'number' && Number.isFinite(inv.amount_paid) ? inv.amount_paid : 0 + if (amountPaid <= 0) { + await this.emit({ + kind: 'invoice.zero_dollar_ignored', + eventId: evt.id, + invoiceId: inv.id, + amountPaid, + }) + return + } + if (!workspaceId) return this.emitNoWorkspace(evt) + const record = await this.store.load(workspaceId) + if (!record) return this.emitUnbound(evt, 'paid invoice has no subscription record') + if (record.customerId !== inv.customer || (inv.subscription && record.subscriptionId !== inv.subscription)) { + return this.emitUnbound(evt, 'paid invoice identity does not match the workspace record') + } await this.emit({ kind: 'invoice.paid', eventId: evt.id, record, invoiceId: inv.id, - amountPaid: inv.amount_paid ?? 0, + amountPaid, }) } private async handleInvoicePaymentFailed(evt: StripeEvent): Promise { const inv = evt.data.object as StripeInvoicePayload + if (!invoiceIdentity(inv)) return this.emitUnbound(evt, 'failed invoice is missing customer or invoice id') const workspaceId = await this.resolveWorkspaceId({ customerId: inv.customer ?? '', invoiceMetadata: inv.metadata, }) - let record: SubscriptionRecord | null = null - if (workspaceId) record = await this.store.load(workspaceId) + if (!workspaceId) return this.emitNoWorkspace(evt) + const record = await this.store.load(workspaceId) + if (!record) return this.emitUnbound(evt, 'failed invoice has no subscription record') + if (record.customerId !== inv.customer || (inv.subscription && record.subscriptionId !== inv.subscription)) { + return this.emitUnbound(evt, 'failed invoice identity does not match the workspace record') + } await this.emit({ kind: 'invoice.payment_failed', eventId: evt.id, @@ -454,15 +555,20 @@ export class StripeBillingDispatcher { /** Load, apply a transformation, CAS-write. The transformation may * return 'replay' / 'out_of_order' for the dispatcher to emit * diagnostic events instead. Retries on contention up to - * `maxCasRetries`; if exhausted, emits via `onError`. */ + * `maxCasRetries`; if exhausted, throws so the event claim is released + * and the provider can retry. */ private async advance( evt: StripeEvent, workspaceId: string, transform: (current: SubscriptionRecord) => { next: SubscriptionRecord; emit: StripeBillingEvent } | 'replay' | 'out_of_order', + identity?: StripeSubscriptionIdentity, ): Promise { for (let attempt = 0; attempt < this.maxCasRetries; attempt++) { const current = await this.store.load(workspaceId) if (!current) return this.emitNoWorkspace(evt) + if (identity && !matchesSubscription(current, identity)) { + return this.emitUnbound(evt, 'subscription identity does not match the workspace record') + } const result = transform(current) if (result === 'replay') { return this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) @@ -478,19 +584,17 @@ export class StripeBillingDispatcher { const written = await this.store.saveIfVersion(result.next, current.version) if (written) return this.emit(result.emit) } - this.onError(new BillingError({ + throw new BillingError({ code: 'webhook_event_unknown', message: `CAS contention exhausted after ${this.maxCasRetries} attempts`, context: { workspaceId, eventId: evt.id }, - }), { eventId: evt.id, type: evt.type }) + }) } private async cas(record: SubscriptionRecord, expectedVersion: number): Promise { - for (let attempt = 0; attempt < this.maxCasRetries; attempt++) { - const ok = await this.store.saveIfVersion(record, expectedVersion + attempt) - if (ok) return true - } - return false + // The record was built from one loaded version. Retrying it against a + // newer expected version would overwrite another event with stale state. + return this.store.saveIfVersion(record, expectedVersion) } private async emit(event: StripeBillingEvent): Promise { @@ -513,6 +617,15 @@ export class StripeBillingDispatcher { reason: 'workspaceId could not be resolved from event payload', }) } + + private emitUnbound(evt: StripeEvent, reason: string): Promise { + return this.emit({ + kind: 'event_dropped_out_of_order', + eventId: evt.id, + type: evt.type, + reason, + }) + } } /* ---------------------------------------------------------------------- */ @@ -533,6 +646,26 @@ function defaultResolveWorkspaceId(input: { subscriptionMetadata?: Record | boolean - /** Marks a providerEventId as processed. Called AFTER `deliver()` has - * been invoked. */ - remember(providerEventId: string, ttlMs: number): Promise | void -} +export interface WebhookIdempotencyStore extends AtomicIdempotencyStore {} + +/** Process-local atomic store for tests and explicitly single-process apps. */ +export class InMemoryWebhookIdempotencyStore extends InMemoryAtomicIdempotencyStore implements WebhookIdempotencyStore {} + +/** Durable store for workers that share the same filesystem directory. */ +export class FileSystemWebhookIdempotencyStore extends FileSystemAtomicIdempotencyStore implements WebhookIdempotencyStore {} export interface WebhookRouterOptions { /** Provider registry. Pass any number of providers; routing is by id. */ @@ -147,9 +154,11 @@ export interface WebhookRouterOptions { /** Resolve the signing secret for a provider id at request time. The * router never holds secrets — the consumer's vault resolves them. */ resolveSecret(providerId: string, headers: WebhookHeaders): Promise | string | null - /** Optional idempotency-dedup hook. Required for providers that don't - * sign timestamps in their signature scheme (DocuSeal, Drive push). */ + /** Atomic idempotency store. Production requires shared atomic storage. */ idempotency?: WebhookIdempotencyStore + /** Runtime controls the safe default. Production fails closed without a + * shared store; test/development use an in-memory store when omitted. */ + runtime?: IdempotencyRuntime /** TTL on idempotency entries. Default 7 days — long enough that a * provider's normal retry-window can't re-deliver. */ idempotencyTtlMs?: number @@ -179,7 +188,7 @@ export class WebhookRouter { private readonly providers: Map private readonly deliver: WebhookRouterOptions['deliver'] private readonly resolveSecret: WebhookRouterOptions['resolveSecret'] - private readonly idempotency?: WebhookIdempotencyStore + private readonly idempotency: WebhookIdempotencyStore private readonly idempotencyTtlMs: number private readonly onError: NonNullable private readonly nowFn: () => number @@ -188,7 +197,11 @@ export class WebhookRouter { this.providers = new Map(opts.providers.map((p) => [p.id, p])) this.deliver = opts.deliver this.resolveSecret = opts.resolveSecret - this.idempotency = opts.idempotency + this.idempotency = resolveAtomicIdempotencyStore({ + component: 'WebhookRouter', + store: opts.idempotency, + runtime: opts.runtime, + }) this.idempotencyTtlMs = opts.idempotencyTtlMs ?? 7 * 24 * 60 * 60 * 1000 this.onError = opts.onError ?? defaultOnError this.nowFn = opts.now ?? Date.now @@ -222,13 +235,18 @@ export class WebhookRouter { return { status: 400, body: { error: 'parse_error', message: errMessage(err) } } } - const accepted: WebhookEnvelope[] = [] - for (const event of events) { - if (event.providerEventId && this.idempotency) { - const already = await this.idempotency.seen(event.providerEventId) - if (already) continue + const accepted: Array<{ event: WebhookEnvelope; key: string }> = [] + try { + for (const [index, event] of events.entries()) { + const key = eventKey(provider.id, event, index, request.rawBody) + if (!(await this.idempotency.claim(key, this.idempotencyTtlMs))) continue + accepted.push({ event, key }) } - accepted.push(event) + } catch (err) { + // Do not strand earlier claims when a later claim detects an unavailable + // or corrupt shared store. The request remains failed closed. + await Promise.allSettled(accepted.map(({ key }) => this.idempotency.release?.(key))) + throw err } // Deliver async — do NOT block the HTTP response. Errors land in @@ -248,13 +266,24 @@ export class WebhookRouter { return { status: 200, body: { received: accepted.length, total: events.length } } } - private async deliverEach(events: WebhookEnvelope[]): Promise { - for (const event of events) { + private async deliverEach(events: Array<{ event: WebhookEnvelope; key: string }>): Promise { + for (const { event, key } of events) { try { await this.deliver(event) - if (event.providerEventId && this.idempotency) { - await this.idempotency.remember(event.providerEventId, this.idempotencyTtlMs) - } + } catch (err) { + await this.idempotency.release?.(key) + this.onError(err, { + provider: event.provider, + eventType: event.eventType, + providerEventId: event.providerEventId, + }) + continue + } + // Keep the durable claim for its TTL, but let this process reclaim the + // key after expiry. A long-running delivery still owns the key until it + // reaches this point or releases it on failure. + try { + await this.idempotency.complete?.(key) } catch (err) { this.onError(err, { provider: event.provider, @@ -274,3 +303,11 @@ function defaultOnError(err: unknown, context: { provider: string; eventType?: s function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err) } + +function eventKey(providerId: string, event: WebhookEnvelope, index: number, rawBody: string): string { + if (event.providerEventId?.trim()) return `${providerId}:id:${event.providerEventId.trim()}` + // Providers without stable event ids still need collision-resistant keys. + // A 32-bit hash could drop a distinct signed event after a birthday collision. + const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('hex') + return `${providerId}:body:${event.eventType}:${index}:${bodyHash}` +} diff --git a/tests/apps-client.test.ts b/tests/apps-client.test.ts index 4caf263..4fb04fe 100644 --- a/tests/apps-client.test.ts +++ b/tests/apps-client.test.ts @@ -98,4 +98,79 @@ describe('TangleAppsClient', () => { await client.mintBrokerToken({ clientId: 'a', clientSecret: 'b', grantId: 'g' }) expect(url).toBe(`${ENDPOINT}/v1/apps/grants/g/mint-broker-token`) }) + + it.each([ + ['wrong prefix', { access_token: 'sk-other-token', expires_in: 60, scope: 'read' }], + ['missing token', { expires_in: 60, scope: 'read' }], + ['zero expiry', { access_token: 'sk-tan-broker-x', expires_in: 0, scope: 'read' }], + ['negative expiry', { access_token: 'sk-tan-broker-x', expires_in: -1, scope: 'read' }], + ['expiry beyond Platform maximum', { access_token: 'sk-tan-broker-x', expires_in: 3601, scope: 'read' }], + ['empty scope', { access_token: 'sk-tan-broker-x', expires_in: 60, scope: ' ' }], + ])('rejects broker response with %s', async (_label, response) => { + const client = new TangleAppsClient({ + endpoint: ENDPOINT, + fetchImpl: mockFetch(() => jsonResponse({ success: true, data: response })), + }) + await expect(client.mintBrokerToken({ clientId: 'a', clientSecret: 'b', grantId: 'g' })).rejects.toMatchObject({ + code: 'input_invalid', + status: 502, + }) + }) + + it('rejects an owner claim without a Platform owner policy', async () => { + const client = new TangleAppsClient({ endpoint: ENDPOINT, fetchImpl: mockFetch(() => jsonResponse({})) }) + await expect(client.mintBrokerToken({ + clientId: 'a', + clientSecret: 'b', + grantId: 'g', + ownerUserId: 'user_1', + })).rejects.toMatchObject({ code: 'input_invalid', status: 403 }) + }) + + it('does not accept a broker key as an app owner bearer', async () => { + const fetchImpl = mockFetch(() => jsonResponse({})) + const client = new TangleAppsClient({ endpoint: ENDPOINT, fetchImpl }) + await expect(client.listApps('sk-tan-broker-owner')).rejects.toMatchObject({ + code: 'input_invalid', + status: 400, + }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('does not send a broker request for a rejected owner claim', async () => { + const fetchImpl = mockFetch(() => jsonResponse({ + access_token: 'sk-tan-broker-x', + expires_in: 60, + scope: 'read', + })) + const client = new TangleAppsClient({ + endpoint: ENDPOINT, + fetchImpl, + ownerPolicy: { authorize: () => false }, + }) + await expect(client.exchangeAuthCode({ + clientId: 'a', + clientSecret: 'b', + code: 'agc_code', + redirectUri: 'https://app/callback', + ownerUserId: 'user_1', + })).rejects.toMatchObject({ code: 'provider_auth_failed', status: 403 }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('does not let a blank owner claim bypass the owner policy', async () => { + const fetchImpl = mockFetch(() => jsonResponse({ + access_token: 'sk-tan-broker-x', + expires_in: 60, + scope: 'read', + })) + const client = new TangleAppsClient({ endpoint: ENDPOINT, fetchImpl }) + await expect(client.mintBrokerToken({ + clientId: 'a', + clientSecret: 'b', + grantId: 'g', + ownerUserId: ' ', + })).rejects.toMatchObject({ code: 'input_invalid', status: 400 }) + expect(fetchImpl).not.toHaveBeenCalled() + }) }) diff --git a/tests/billing-access-policy.test.ts b/tests/billing-access-policy.test.ts new file mode 100644 index 0000000..db2af2b --- /dev/null +++ b/tests/billing-access-policy.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { + assertNoProductFreeTrial, + decideBillingAccess, + NO_PRODUCT_FREE_CREDITS_POLICY, + parseTrustedPlatformEvidence, + PRODUCT_FREE_CREDIT_SOURCES, + type PlatformAccessEvidencePayload, +} from '../src/billing-access-policy' + +function evidencePayload( + overrides: Partial & { + funding?: Record + principal?: Record + } = {}, +): PlatformAccessEvidencePayload { + return { + policyVersion: 1, + issuer: 'id.tangle.tools', + evidenceId: 'evidence_1', + issuedAt: '2026-08-10T12:00:00.000Z', + emailVerified: true, + user: { id: 'user_1', email: 'person@company.com' }, + principal: { kind: 'human' }, + funding: { + kind: 'paid_purchase', + id: 'purchase_1', + amountUsd: 10, + paidAt: '2026-08-10T11:59:00.000Z', + }, + ...overrides, + } +} + +function evidence(overrides: Parameters[0] = {}) { + const parsed = parseTrustedPlatformEvidence(evidencePayload(overrides), { expectedUserId: 'user_1' }) + if (!parsed) throw new Error('test fixture did not parse') + return parsed +} + +describe('NO_PRODUCT_FREE_CREDITS_POLICY', () => { + it('keeps every product-funded source disabled and requires Platform evidence', () => { + expect(NO_PRODUCT_FREE_CREDITS_POLICY).toEqual({ + productFreeCredits: false, + productFreeTrials: false, + productPromotions: false, + productFallbackCredits: false, + productSyntheticCredits: false, + humanEmailVerificationRequired: true, + platformEvidenceRequired: true, + }) + for (const source of PRODUCT_FREE_CREDIT_SOURCES) { + expect(decideBillingAccess({})).toMatchObject({ + allowed: false, + code: 'platform_evidence_required', + }) + expect(source).toBeTruthy() + } + }) + + it('rejects a caller claim even when it names a paid or free source', () => { + expect(decideBillingAccess({ source: 'paid_purchase' } as never)).toMatchObject({ + allowed: false, + code: 'platform_evidence_required', + }) + expect(decideBillingAccess({ source: 'trial' } as never)).toMatchObject({ + allowed: false, + code: 'platform_evidence_required', + }) + }) +}) + +describe('Platform evidence', () => { + it.each([ + ['paid_purchase', { kind: 'paid_purchase', id: 'purchase_1', amountUsd: 10, paidAt: '2026-08-10T11:59:00.000Z' }], + ['paid_subscription', { kind: 'paid_subscription', id: 'sub_evidence', subscriptionId: 'sub_1', status: 'active', amountUsd: 29 }], + ['byok', { kind: 'byok', id: 'byok_1', provider: 'openai', keyId: 'key_1' }], + ['named_service', { kind: 'named_service', id: 'service_1', serviceId: 'service:blueprint-agent', serviceName: 'blueprint-agent' }], + ['admin', { kind: 'admin', id: 'admin_1', adminId: 'admin_user_1' }], + ] as const)('allows trusted %s evidence', (_name, funding) => { + const parsed = evidence({ funding }) + expect(decideBillingAccess({ evidence: parsed, expectedUserId: 'user_1' })).toMatchObject({ + allowed: true, + basis: funding.kind, + }) + }) + + it('rejects a lookalike object copied from a trusted result', () => { + const parsed = evidence() + const lookalike = structuredClone(parsed) + expect(decideBillingAccess({ evidence: lookalike })).toMatchObject({ + allowed: false, + code: 'platform_evidence_required', + }) + }) + + it('rejects a different owner', () => { + expect(decideBillingAccess({ evidence: evidence(), expectedUserId: 'user_2' })).toMatchObject({ + allowed: false, + code: 'platform_evidence_subject_mismatch', + }) + }) + + it('rejects unverified, placeholder, and zero-dollar evidence', () => { + expect(parseTrustedPlatformEvidence(evidencePayload({ emailVerified: false }))).toBeNull() + expect(parseTrustedPlatformEvidence(evidencePayload({ user: { id: 'user_1', email: 'test@example.com' } }))).toBeNull() + expect(parseTrustedPlatformEvidence(evidencePayload({ funding: { kind: 'paid_purchase', id: 'p', amountUsd: 0, paidAt: 'now' } }))).toBeNull() + expect(parseTrustedPlatformEvidence(evidencePayload({ issuedAt: 'not-a-date' }))).toBeNull() + }) + + it('rejects missing or unknown principal types instead of defaulting to human', () => { + expect(parseTrustedPlatformEvidence(evidencePayload({ principal: undefined }))).toBeNull() + expect(parseTrustedPlatformEvidence(evidencePayload({ principal: { kind: 'unknown' } }))).toBeNull() + }) + + it('freezes parsed evidence so a caller cannot rewrite its funding basis', () => { + const parsed = evidence() + expect(Object.isFrozen(parsed)).toBe(true) + expect(Object.isFrozen(parsed.principal)).toBe(true) + expect(Object.isFrozen(parsed.funding)).toBe(true) + expect(() => { + ;(parsed.funding as { kind: string }).kind = 'admin' + }).toThrow() + expect(decideBillingAccess({ evidence: parsed }).allowed).toBe(true) + }) +}) + +describe('assertNoProductFreeTrial', () => { + it('rejects positive, negative, and non-integer trial periods', () => { + expect(() => assertNoProductFreeTrial(14)).toThrow(/product-funded free trials are disabled/) + expect(() => assertNoProductFreeTrial(-1)).toThrow(/non-negative integer/) + expect(() => assertNoProductFreeTrial(1.5)).toThrow(/non-negative integer/) + expect(() => assertNoProductFreeTrial(undefined)).not.toThrow() + expect(() => assertNoProductFreeTrial(0)).not.toThrow() + }) +}) diff --git a/tests/connect-flow.test.ts b/tests/connect-flow.test.ts index 9cb5379..a8b2932 100644 --- a/tests/connect-flow.test.ts +++ b/tests/connect-flow.test.ts @@ -47,7 +47,9 @@ describe('finishConnectFlow', () => { return new Response( JSON.stringify({ apiKey: 'sk-tan-mintkey', - user: { id: 'usr_1', email: 'a@b.c', name: 'A B', image: null }, + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'usr_1', email: 'a@company.com', emailVerified: true, name: 'A B', image: null }, balance: 100, }), { status: 200, headers: { 'content-type': 'application/json' } }, @@ -58,17 +60,18 @@ describe('finishConnectFlow', () => { { code: 'c1', appId: 'evals' }, ) expect(capturedUrl).toBe('https://id.example.com/cross-site/exchange') - expect(capturedBody).toEqual({ code: 'c1', app: 'evals' }) + expect(capturedBody).toEqual({ code: 'c1', app: 'evals', requireVerifiedEmail: true }) expect(out).toEqual({ apiKey: 'sk-tan-mintkey', - user: { id: 'usr_1', email: 'a@b.c', name: 'A B', image: null }, + user: { id: 'usr_1', email: 'a@company.com', emailVerified: true, name: 'A B', image: null }, balance: 100, + paidAccessPolicyVersion: 1, }) }) it('returns balance 0 when the platform omits it (defensive default)', async () => { const fetchImpl = vi.fn(async () => - new Response(JSON.stringify({ apiKey: 'sk-tan-k', user: { id: 'u' } }), { + new Response(JSON.stringify({ apiKey: 'sk-tan-k', paidAccessPolicyVersion: 1, emailVerified: true, user: { id: 'u', email: 'person@company.com', emailVerified: true } }), { status: 200, headers: { 'content-type': 'application/json' }, }), @@ -77,6 +80,43 @@ describe('finishConnectFlow', () => { expect(out.balance).toBe(0) }) + it('rejects an exchange for an unverified human before returning the company key', async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ apiKey: 'sk-tan-k', user: { id: 'u', emailVerified: false } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + await expect( + finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' }), + ).rejects.toMatchObject({ status: 403 }) + }) + + it('rejects an exchange when the platform omits email verification', async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ apiKey: 'sk-tan-k', user: { id: 'u' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + await expect( + finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' }), + ).rejects.toMatchObject({ status: 403 }) + }) + + it('rejects a contradictory exchange with only top-level email verification', async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ + apiKey: 'sk-tan-k', + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'u', email: 'person@company.com' }, + }), { status: 200 }), + ) + await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })) + .rejects.toMatchObject({ status: 403 }) + }) + it('throws Unreachable on 401 from /cross-site/exchange (replay / expired code)', async () => { const fetchImpl = vi.fn(async () => new Response('bad', { status: 401 })) await expect( @@ -96,6 +136,29 @@ describe('finishConnectFlow', () => { ).rejects.toBeInstanceOf(TangleIdentityUnreachableError) }) + it('rejects a non-sk-tan exchange key even when the rest of the response looks valid', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + apiKey: 'pk-live-not-a-tangle-key', + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'u', email: 'person@company.com' }, + balance: 100, + }), { status: 200 })) + await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })) + .rejects.toMatchObject({ status: 403 }) + }) + + it('rejects a broker token at the user-key exchange boundary', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + apiKey: 'sk-tan-broker-not-a-user-key', + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'u', email: 'person@company.com', emailVerified: true }, + }), { status: 200 })) + await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })) + .rejects.toMatchObject({ status: 403 }) + }) + it('throws Unreachable on network failure', async () => { const fetchImpl = vi.fn(async () => { throw new Error('econnrefused') @@ -118,14 +181,14 @@ describe('revokeConnectFlow', () => { const url = String(input) seen.push(`${init?.method ?? 'GET'} ${url.split('/').slice(3).join('/')}`) if (url.endsWith('/v1/keys/verify')) { - return new Response(JSON.stringify({ valid: true, userId: 'u', keyId: 'k1' }), { + return new Response(JSON.stringify({ valid: true, userId: 'u', keyId: 'k1', emailVerified: true, email: 'owner@company.com' }), { status: 200, headers: { 'content-type': 'application/json' }, }) } return new Response(null, { status: 204 }) }) - await revokeConnectFlow({ serviceToken: 'svc_x', fetchImpl }, { apiKey: 'sk-tan-x' }) + await revokeConnectFlow({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }, { apiKey: 'sk-tan-x' }) expect(seen).toEqual(['POST v1/keys/verify', 'DELETE v1/keys/k1']) }) diff --git a/tests/consumer-client.test.ts b/tests/consumer-client.test.ts index 0729fce..ffdc0d2 100644 --- a/tests/consumer-client.test.ts +++ b/tests/consumer-client.test.ts @@ -123,6 +123,18 @@ describe('IntegrationHubClient — construction', () => { auth: { mode: 'user-key', apiKey: '' }, }), ).toThrow(/apiKey/) + expect(() => + createIntegrationHubClient({ + product: 'blueprint-agent', + auth: { mode: 'user-key', apiKey: 'sk-other-key' }, + }), + ).toThrow(/apiKey/) + expect(() => + createIntegrationHubClient({ + product: 'blueprint-agent', + auth: { mode: 'user-key', apiKey: 'sk-tan-broker-owner' }, + }), + ).toThrow(/apiKey/) }) it('trims a trailing slash off the endpoint', async () => { @@ -383,6 +395,7 @@ describe('user-key auth mode', () => { const client = createIntegrationHubClient({ product: 'blueprint-agent', auth: { mode: 'user-key', apiKey: 'sk-tan-userkey' }, + ownerPolicy: { authorize: ({ userId }) => userId === 'usr_1' }, fetchImpl: fn, }) await client.resolveManifest({ userId: 'usr_1', manifest: manifest() }) @@ -390,6 +403,20 @@ describe('user-key auth mode', () => { expect(calls[0].headers.get('x-service-name')).toBeNull() expect(calls[0].headers.get('x-platform-user-id')).toBeNull() }) + + it('fails closed for a user claim without an owner policy', async () => { + const { fn } = mockFetch(() => ok(resolution('ready'))) + const client = createIntegrationHubClient({ + product: 'blueprint-agent', + auth: { mode: 'user-key', apiKey: 'sk-tan-userkey' }, + fetchImpl: fn, + }) + await expect(client.resolveManifest({ userId: 'usr_1', manifest: manifest() })).rejects.toMatchObject({ + code: 'owner_policy_denied', + status: 403, + }) + expect(fn).not.toHaveBeenCalled() + }) }) // ─── Error handling ─────────────────────────────────────────────────── diff --git a/tests/idempotency-store.test.ts b/tests/idempotency-store.test.ts new file mode 100644 index 0000000..19e3d5a --- /dev/null +++ b/tests/idempotency-store.test.ts @@ -0,0 +1,239 @@ +import { createHmac } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + FileSystemAtomicIdempotencyStore, + InMemoryAtomicIdempotencyStore, +} from '../src/idempotency' +import { + FileSystemStripeEventIdempotencyStore, + StripeBillingDispatcher, +} from '../src/stripe/webhooks' +import { + FileSystemSubscriptionStore, + InMemorySubscriptionStore, + makeSubscriptionRecord, +} from '../src/stripe/subscription-state' +import { + FileSystemWebhookIdempotencyStore, + WebhookRouter, + stripeWebhookProvider, +} from '../src/webhooks' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'agent-integrations-idempotency-')) + roots.push(root) + return root +} + +function signedStripeRequest(eventId: string): { + providerId: string + rawBody: string + headers: Record +} { + const rawBody = JSON.stringify({ id: eventId, type: 'customer.created', data: { object: {} } }) + const timestamp = Math.floor(Date.now() / 1000) + const signature = createHmac('sha256', 'whsec_test') + .update(`${timestamp}.${rawBody}`) + .digest('hex') + return { + providerId: 'stripe', + rawBody, + headers: { 'stripe-signature': `t=${timestamp},v1=${signature}` }, + } +} + +function flushDeliveries(): Promise { + return new Promise((resolve) => setTimeout(resolve, 10)) +} + +describe('FileSystemAtomicIdempotencyStore', () => { + it('allows one winner across 100 independent workers', async () => { + const root = await makeRoot() + const stores = Array.from({ length: 100 }, () => new FileSystemAtomicIdempotencyStore(root)) + + const results = await Promise.all(stores.map((store) => store.claim('stripe:event:100-way', 60_000))) + + expect(results.filter(Boolean)).toHaveLength(1) + expect(results.filter((result) => !result)).toHaveLength(99) + }) + + it('keeps an active claim across a new store instance and releases safely', async () => { + const root = await makeRoot() + const first = new FileSystemAtomicIdempotencyStore(root) + const restarted = new FileSystemAtomicIdempotencyStore(root) + const key = 'stripe:event:restart' + + expect(await first.claim(key, 60_000)).toBe(true) + expect(await restarted.claim(key, 60_000)).toBe(false) + await first.release(key) + expect(await restarted.claim(key, 60_000)).toBe(true) + }) + + it('allows the same worker to reclaim a completed claim after its TTL', async () => { + vi.useFakeTimers() + try { + const store = new InMemoryAtomicIdempotencyStore() + const key = 'stripe:event:ttl' + + expect(await store.claim(key, 1_000)).toBe(true) + expect(await store.claim(key, 1_000)).toBe(false) + store.complete(key) + expect(await store.claim(key, 1_000)).toBe(false) + + vi.advanceTimersByTime(1_001) + expect(await store.claim(key, 1_000)).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('keeps filesystem subscription CAS atomic across 100 concurrent writers', async () => { + const root = await makeRoot() + const first = new FileSystemSubscriptionStore(root) + const stores = Array.from({ length: 100 }, () => new FileSystemSubscriptionStore(root)) + const base = makeSubscriptionRecord({ + workspaceId: 'workspace_cas', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + }) + await first.save(base) + const next = { ...base, state: 'past_due' as const, version: 1 } + + const results = await Promise.all(stores.map((store) => store.saveIfVersion(next, 0))) + + expect(results.filter(Boolean)).toHaveLength(1) + expect((await first.load('workspace_cas'))?.state).toBe('past_due') + }) + + it('does not make process-local storage look production-safe', () => { + expect(() => new WebhookRouter({ + providers: [], + deliver: () => undefined, + resolveSecret: () => null, + runtime: 'production', + })).toThrow('shared atomic idempotency store is required') + + expect(() => new WebhookRouter({ + providers: [], + deliver: () => undefined, + resolveSecret: () => null, + idempotency: new InMemoryAtomicIdempotencyStore(), + runtime: 'production', + })).toThrow('production requires a shared atomic idempotency store') + + expect(() => new StripeBillingDispatcher({ + store: new InMemorySubscriptionStore(), + runtime: 'production', + })).toThrow('shared atomic idempotency store is required') + }) +}) + +describe('cross-instance webhook boundaries', () => { + it('deduplicates 100 signed requests across two router instances', async () => { + const root = await makeRoot() + const delivered: string[] = [] + const makeRouter = (store: FileSystemWebhookIdempotencyStore) => new WebhookRouter({ + providers: [stripeWebhookProvider], + runtime: 'production', + idempotency: store, + resolveSecret: () => 'whsec_test', + deliver: (event) => { + delivered.push(event.providerEventId ?? 'missing') + }, + }) + const routerA = makeRouter(new FileSystemWebhookIdempotencyStore(root)) + const routerB = makeRouter(new FileSystemWebhookIdempotencyStore(root)) + const request = signedStripeRequest('evt_router_cross_instance') + + const responses = await Promise.all(Array.from({ length: 100 }, (_, index) => ( + index % 2 === 0 ? routerA.handle(request) : routerB.handle(request) + ))) + + expect(responses.every((response) => response.status === 200)).toBe(true) + expect(responses.filter((response) => (response.body as { received?: number }).received === 1)).toHaveLength(1) + await flushDeliveries() + expect(delivered).toEqual(['evt_router_cross_instance']) + }) + + it('deduplicates direct Stripe dispatch across two dispatcher instances', async () => { + const root = await makeRoot() + const events: string[] = [] + const makeDispatcher = (idempotency: FileSystemStripeEventIdempotencyStore) => new StripeBillingDispatcher({ + store: new InMemorySubscriptionStore(), + runtime: 'production', + idempotency, + listener: (event) => { + events.push(event.kind) + }, + }) + const dispatcherA = makeDispatcher(new FileSystemStripeEventIdempotencyStore(root)) + const dispatcherB = makeDispatcher(new FileSystemStripeEventIdempotencyStore(root)) + const envelope = { + provider: 'stripe', + eventType: 'customer.created', + receivedAt: Date.now(), + headers: {}, + payload: { id: 'evt_dispatch_cross_instance', type: 'customer.created', data: { object: {} } }, + } + + await Promise.all(Array.from({ length: 100 }, (_, index) => ( + index % 2 === 0 ? dispatcherA.dispatch(envelope) : dispatcherB.dispatch(envelope) + ))) + + expect(events.filter((kind) => kind === 'event_unhandled')).toHaveLength(1) + expect(events.filter((kind) => kind === 'event_replay')).toHaveLength(99) + }) +}) + +describe('storage failures', () => { + it('rejects the webhook instead of accepting it when shared storage is unavailable', async () => { + const root = await makeRoot() + const blockedPath = join(root, 'not-a-directory') + await writeFile(blockedPath, 'blocked') + const delivered: string[] = [] + const router = new WebhookRouter({ + providers: [stripeWebhookProvider], + runtime: 'production', + idempotency: new FileSystemWebhookIdempotencyStore(blockedPath), + resolveSecret: () => 'whsec_test', + deliver: (event) => { + delivered.push(event.providerEventId ?? 'missing') + }, + }) + + await expect(router.handle(signedStripeRequest('evt_storage_unavailable'))).rejects.toThrow() + await flushDeliveries() + expect(delivered).toHaveLength(0) + }) + + it('rejects direct Stripe processing when shared storage is unavailable', async () => { + const root = await makeRoot() + const blockedPath = join(root, 'not-a-directory') + await writeFile(blockedPath, 'blocked') + const dispatcher = new StripeBillingDispatcher({ + store: new InMemorySubscriptionStore(), + runtime: 'production', + idempotency: new FileSystemStripeEventIdempotencyStore(blockedPath), + }) + + await expect(dispatcher.dispatch({ + provider: 'stripe', + eventType: 'customer.created', + receivedAt: Date.now(), + headers: {}, + payload: { id: 'evt_storage_unavailable_dispatch', type: 'customer.created', data: { object: {} } }, + })).rejects.toThrow() + }) +}) diff --git a/tests/platform-boundary-contract.test.ts b/tests/platform-boundary-contract.test.ts new file mode 100644 index 0000000..6168ad5 --- /dev/null +++ b/tests/platform-boundary-contract.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { finishConnectFlow } from '../src/connect' +import { createTangleIdentityClient } from '../src/connectors/adapters/tangle-id' + +describe('Platform live-boundary contracts', () => { + it('accepts the current verified exchange response and sends the email policy request', async () => { + let request: { url: string; body: unknown } | undefined + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + request = { url: String(input), body: JSON.parse(String(init?.body)) } + return new Response(JSON.stringify({ + apiKey: 'sk-tan-contract-key', + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'user_1', email: 'person@company.com', emailVerified: true }, + subscription: { plan: 'pro', status: 'active' }, + balance: 0, + }), { status: 200 }) + }) + + const result = await finishConnectFlow({ baseUrl: 'https://id.tangle.tools', fetchImpl }, { + code: 'code_1', + appId: 'product_1', + }) + + expect(request).toEqual({ + url: 'https://id.tangle.tools/cross-site/exchange', + body: { code: 'code_1', app: 'product_1', requireVerifiedEmail: true }, + }) + expect(result).toMatchObject({ apiKey: 'sk-tan-contract-key', balance: 0, paidAccessPolicyVersion: 1 }) + }) + + it('does not accept an exchange response that would bypass verified email', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + error: 'email_verification_required', + }), { status: 403 })) + + await expect(finishConnectFlow({ fetchImpl }, { code: 'code_2', appId: 'product_1' })) + .rejects.toMatchObject({ status: 403 }) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + + it('rejects a Platform key verification response with a placeholder email', async () => { + const client = createTangleIdentityClient({ + serviceToken: 'svc_contract', + serviceName: 'agent-integrations-tests', + fetchImpl: vi.fn(async () => new Response(JSON.stringify({ + valid: true, + userId: 'user_1', + emailVerified: true, + email: 'test@example.com', + }), { status: 200 })), + }) + + await expect(client.verifyToken('sk-tan-contract-key')).resolves.toEqual({ + valid: false, + reason: 'real_email_required', + }) + }) +}) diff --git a/tests/stripe-billing-middleware.test.ts b/tests/stripe-billing-middleware.test.ts index 2443abb..74a337b 100644 --- a/tests/stripe-billing-middleware.test.ts +++ b/tests/stripe-billing-middleware.test.ts @@ -13,6 +13,47 @@ import { type SubscriptionRecord, } from '../src/stripe/subscription-state' import { BillingError } from '../src/stripe/errors' +import { parseTrustedPlatformEvidence } from '../src/billing-access-policy' + +function paidEvidence(subscriptionId = 'sub_1') { + const evidence = parseTrustedPlatformEvidence({ + policyVersion: 1, + issuer: 'id.tangle.tools', + evidenceId: `evidence-${subscriptionId}`, + issuedAt: '2026-08-10T12:00:00.000Z', + emailVerified: true, + principal: { kind: 'human' }, + user: { id: 'user_1', email: 'person@company.com' }, + funding: { + kind: 'paid_subscription', + id: `funding-${subscriptionId}`, + subscriptionId, + status: 'active', + amountUsd: 29, + }, + }, { expectedUserId: 'user_1' }) + if (!evidence) throw new Error('invalid test evidence') + return evidence +} + +function namedServiceEvidence() { + const evidence = parseTrustedPlatformEvidence({ + policyVersion: 1, + issuer: 'id.tangle.tools', + evidenceId: 'service-evidence', + issuedAt: '2026-08-10T12:00:00.000Z', + principal: { kind: 'service_principal', id: 'service:blueprint-agent', name: 'blueprint-agent' }, + user: { id: 'service-user' }, + funding: { + kind: 'named_service', + id: 'service-funding', + serviceId: 'service:blueprint-agent', + serviceName: 'blueprint-agent', + }, + }) + if (!evidence) throw new Error('invalid service evidence') + return evidence +} function seededStore(state: SubscriptionRecord['state'], overrides: Partial = {}) { const store = new InMemorySubscriptionStore() @@ -45,7 +86,7 @@ describe('requireActiveSubscription', () => { it('allows active subscription with no warning', async () => { const { store, rec } = seededStore('active') await store.save(rec) - const out = await requireActiveSubscription({ workspaceId: 'ws_1', store }) + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, accessEvidence: paidEvidence() }) expect(out.allowed).toBe(true) if (out.allowed) { expect(out.warn).toBeUndefined() @@ -56,7 +97,7 @@ describe('requireActiveSubscription', () => { it('allows past_due with a past_due warning (dunning grace)', async () => { const { store, rec } = seededStore('past_due') await store.save(rec) - const out = await requireActiveSubscription({ workspaceId: 'ws_1', store }) + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, accessEvidence: paidEvidence() }) expect(out.allowed).toBe(true) if (out.allowed) expect(out.warn).toBe('past_due') }) @@ -64,7 +105,7 @@ describe('requireActiveSubscription', () => { it('denies past_due when denyPastDue=true (strict mode for irreversible actions)', async () => { const { store, rec } = seededStore('past_due') await store.save(rec) - const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, denyPastDue: true }) + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, denyPastDue: true, accessEvidence: paidEvidence() }) expect(out.allowed).toBe(false) if (!out.allowed) { expect(out.error.billingCode).toBe('subscription_past_due') @@ -74,67 +115,61 @@ describe('requireActiveSubscription', () => { it('denies canceled with subscription_inactive billing code', async () => { const { store, rec } = seededStore('canceled') await store.save(rec) - const out = await requireActiveSubscription({ workspaceId: 'ws_1', store }) + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, accessEvidence: paidEvidence() }) expect(out.allowed).toBe(false) if (!out.allowed) { expect(out.error.billingCode).toBe('subscription_inactive') } }) - it('attaches trial_ending warning when trial ends within 72h', async () => { + it('denies a trialing subscription as product-funded trial access', async () => { const trialEnd = Math.floor(Date.now() / 1000) + 60 * 60 // 1h from now const { store, rec } = seededStore('trialing', { trialEnd }) await store.save(rec) - const out = await requireActiveSubscription({ workspaceId: 'ws_1', store }) - expect(out.allowed).toBe(true) - if (out.allowed) expect(out.warn).toBe('trial_ending') + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, accessEvidence: paidEvidence() }) + expect(out.allowed).toBe(false) + if (!out.allowed) expect(out.error.billingCode).toBe('trial_expired') }) - it('omits trial_ending when trial is far in the future', async () => { + it('denies a trialing subscription even when the trial end is far away', async () => { const trialEnd = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60 // 30d const { store, rec } = seededStore('trialing', { trialEnd }) await store.save(rec) - const out = await requireActiveSubscription({ workspaceId: 'ws_1', store }) - if (!out.allowed) throw new Error('expected allowed') - expect(out.warn).toBeUndefined() - }) -}) - -describe('withTrialAccess', () => { - const trialStore = (createdAt: number | null): TrialStore => ({ - getCreatedAt: () => createdAt, + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, accessEvidence: paidEvidence() }) + expect(out.allowed).toBe(false) + if (!out.allowed) expect(out.error.billingCode).toBe('trial_expired') }) - it('returns inTrial=false when workspace has no creation timestamp', async () => { - const out = await withTrialAccess({ workspaceId: 'ws', days: 14, trialStore: trialStore(null) }) - expect(out).toEqual({ inTrial: false, daysRemaining: 0, trialEndsAt: null }) + it('requires Platform evidence before allowing an active subscription', async () => { + const { store, rec } = seededStore('active') + await store.save(rec) + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store }) + expect(out.allowed).toBe(false) + if (!out.allowed) expect(out.error.billingCode).toBe('platform_evidence_required') }) - it('inTrial when within the window, daysRemaining floored', async () => { - const now = 1_700_000_000_000 - const createdAt = now - 5 * 24 * 60 * 60 * 1000 - 3_600_000 // 5d 1h ago - const out = await withTrialAccess({ - workspaceId: 'ws', - days: 14, - trialStore: trialStore(createdAt), - now: () => now, - }) - expect(out.inTrial).toBe(true) - expect(out.daysRemaining).toBe(8) - expect(out.trialEndsAt).toBe(createdAt + 14 * 24 * 60 * 60 * 1000) + it('preserves an explicitly named service context for active subscriptions', async () => { + const { store, rec } = seededStore('active') + await store.save(rec) + const out = await requireActiveSubscription({ workspaceId: 'ws_1', store, accessEvidence: namedServiceEvidence() }) + expect(out.allowed).toBe(true) }) +}) - it('inTrial=false when expired', async () => { - const now = 1_700_000_000_000 - const createdAt = now - 30 * 24 * 60 * 60 * 1000 +describe('withTrialAccess', () => { + it('always denies without reading signup or workspace timestamps', async () => { + const trialStore: TrialStore = { + getCreatedAt: () => { + throw new Error('trial store must not be read') + }, + } const out = await withTrialAccess({ workspaceId: 'ws', days: 14, - trialStore: trialStore(createdAt), - now: () => now, + trialStore, + now: () => 1_700_000_000_000, }) - expect(out.inTrial).toBe(false) - expect(out.daysRemaining).toBe(0) + expect(out).toEqual({ inTrial: false, daysRemaining: 0, trialEndsAt: null }) }) }) @@ -144,7 +179,7 @@ describe('getRemainingFreeTier', () => { it('reports exhausted when used >= total', async () => { expect(await getRemainingFreeTier({ workspaceId: 'w', freeTierStore: fts(100, 100) })).toEqual({ remaining: 0, - total: 100, + total: 0, exhausted: true, }) }) @@ -152,36 +187,49 @@ describe('getRemainingFreeTier', () => { it('caps remaining at zero, never negative', async () => { expect(await getRemainingFreeTier({ workspaceId: 'w', freeTierStore: fts(150, 100) })).toEqual({ remaining: 0, - total: 100, + total: 0, exhausted: true, }) }) - it('reports remaining when under quota', async () => { + it('reports no quota when the consumer store says value remains', async () => { expect(await getRemainingFreeTier({ workspaceId: 'w', freeTierStore: fts(20, 100) })).toEqual({ - remaining: 80, - total: 100, - exhausted: false, + remaining: 0, + total: 0, + exhausted: true, + }) + }) + + it('does not read the free-tier store', async () => { + const freeTierStore: FreeTierStore = { + getUsage: () => { + throw new Error('free-tier store must not be read') + }, + } + await expect(getRemainingFreeTier({ workspaceId: 'w', freeTierStore })).resolves.toEqual({ + remaining: 0, + total: 0, + exhausted: true, }) }) }) describe('gateSubscriptionOrTrial', () => { - it('passes via trial without needing a subscription record', async () => { + it('does not pass via trial without a paid subscription record', async () => { const store = new InMemorySubscriptionStore() - const now = Date.now() - const trialStore: TrialStore = { getCreatedAt: () => now - 24 * 60 * 60 * 1000 } // 1d ago + const trialStore: TrialStore = { + getCreatedAt: () => { + throw new Error('trial store must not be read') + }, + } const out = await gateSubscriptionOrTrial({ workspaceId: 'ws_new', store, trialStore, trialDays: 7, }) - expect(out.allowed).toBe(true) - if (out.allowed) { - expect(out.viaTrial).toBe(true) - expect(out.record.state).toBe('trialing') - } + expect(out.allowed).toBe(false) + if (!out.allowed) expect(out.error.billingCode).toBe('subscription_required') }) it('falls back to subscription gate when trial expired', async () => { @@ -195,6 +243,7 @@ describe('gateSubscriptionOrTrial', () => { store, trialStore, trialDays: 14, + accessEvidence: paidEvidence(), }) expect(out.allowed).toBe(true) if (out.allowed) { diff --git a/tests/stripe-pricing.test.ts b/tests/stripe-pricing.test.ts index e65f189..7d35294 100644 --- a/tests/stripe-pricing.test.ts +++ b/tests/stripe-pricing.test.ts @@ -16,7 +16,6 @@ const plans: PricingPlan[] = [ yearlyUsd: 290, features: [{ label: 'unlimited', included: true }], stripePriceIds: { monthly: 'price_pro_m', yearly: 'price_pro_y' }, - trialDays: 14, }, { id: 'starter', @@ -49,6 +48,7 @@ describe('createCheckoutUrl', () => { productId: 'legal' as const, secretKey: 'sk', webhookSecret: 'wh', + approvedPriceIds: ['price_pro_m', 'price_pro_y', 'price_starter_m'], successUrl: 'https://app/success', cancelUrl: 'https://app/cancel', }, @@ -91,6 +91,7 @@ describe('createCheckoutUrl', () => { expect(params.get('subscription_data[metadata][workspaceId]')).toBe('ws_1') expect(params.get('metadata[planId]')).toBe('pro') expect(params.get('subscription_data[metadata][planId]')).toBe('pro') + expect(params.get('subscription_data[trial_period_days]')).toBeNull() }) it('threads through caller metadata into both maps', async () => { @@ -108,29 +109,46 @@ describe('createCheckoutUrl', () => { expect(params.get('subscription_data[metadata][campaign]')).toBe('launch-q1') }) - it('uses plan.trialDays when no per-call override', async () => { + it.each(['workspaceId', 'planId'])('rejects caller metadata that overrides %s', async (key) => { const captured: { body?: string } = {} const { client } = clientWithCapture(captured) - await createCheckoutUrl(client, { - workspaceId: 'ws', + await expect(createCheckoutUrl(client, { + workspaceId: 'ws_2', plan: plans[0], billing: 'monthly', - idempotencyKey: 'i', - }) - expect(new URLSearchParams(captured.body).get('subscription_data[trial_period_days]')).toBe('14') + idempotencyKey: 'idem', + metadata: { [key]: 'other-owner' }, + })).rejects.toThrow(/metadata key .* reserved/) + expect(captured.body).toBeUndefined() }) - it('per-call trialDays beats plan.trialDays', async () => { + it('rejects a legacy plan trial before reaching Stripe', async () => { const captured: { body?: string } = {} const { client } = clientWithCapture(captured) - await createCheckoutUrl(client, { - workspaceId: 'ws', - plan: plans[0], - billing: 'monthly', - idempotencyKey: 'i', - trialDays: 30, - }) - expect(new URLSearchParams(captured.body).get('subscription_data[trial_period_days]')).toBe('30') + await expect( + createCheckoutUrl(client, { + workspaceId: 'ws', + plan: { ...plans[0], trialDays: 14 }, + billing: 'monthly', + idempotencyKey: 'i', + }), + ).rejects.toThrow(/product-funded free trials are disabled/) + expect(captured.body).toBeUndefined() + }) + + it('rejects a per-call trial before reaching Stripe', async () => { + const captured: { body?: string } = {} + const { client } = clientWithCapture(captured) + await expect( + createCheckoutUrl(client, { + workspaceId: 'ws', + plan: plans[0], + billing: 'monthly', + idempotencyKey: 'i', + trialDays: 30, + }), + ).rejects.toThrow(/product-funded free trials are disabled/) + expect(captured.body).toBeUndefined() }) it('throws when the plan has no price for the requested cadence', async () => { @@ -147,7 +165,7 @@ describe('createCheckoutUrl', () => { }) it('throws when neither per-call nor tenant config has successUrl/cancelUrl', async () => { - const client = buildStripeClient({ productId: 'tax', secretKey: 'sk', webhookSecret: 'wh' }) + const client = buildStripeClient({ productId: 'tax', secretKey: 'sk', webhookSecret: 'wh', approvedPriceIds: ['price_pro_m'] }) await expect( createCheckoutUrl(client, { workspaceId: 'w', @@ -170,6 +188,32 @@ describe('createCheckoutUrl', () => { }) expect(new URLSearchParams(captured.body).get('customer')).toBe('cus_42') }) + + it('rejects an unapproved Stripe price before reaching Stripe', async () => { + const captured: { body?: string } = {} + const { client } = clientWithCapture(captured) + const unapproved = { ...plans[0], stripePriceIds: { monthly: 'price_unapproved' } } + await expect(createCheckoutUrl(client, { + workspaceId: 'w', + plan: unapproved, + billing: 'monthly', + idempotencyKey: 'i', + })).rejects.toThrow(/not approved/) + expect(captured.body).toBeUndefined() + }) + + it.each([0, -1])('rejects a %s-dollar plan before reaching Stripe', async (amount) => { + const captured: { body?: string } = {} + const { client } = clientWithCapture(captured) + const zeroPlan = { ...plans[0], monthlyUsd: amount } + await expect(createCheckoutUrl(client, { + workspaceId: 'w', + plan: zeroPlan, + billing: 'monthly', + idempotencyKey: 'i', + })).rejects.toThrow(/greater than zero/) + expect(captured.body).toBeUndefined() + }) }) describe('createBillingPortalUrl', () => { diff --git a/tests/stripe-state-machine.test.ts b/tests/stripe-state-machine.test.ts index 0580645..77d7fbf 100644 --- a/tests/stripe-state-machine.test.ts +++ b/tests/stripe-state-machine.test.ts @@ -115,9 +115,9 @@ describe('applyTransition', () => { }) describe('gateAccess', () => { - it('allows active and trialing without warnings', () => { + it('allows active but denies trialing product access', () => { expect(gateAccess('active')).toEqual({ allowed: true }) - expect(gateAccess('trialing')).toEqual({ allowed: true }) + expect(gateAccess('trialing')).toEqual({ allowed: false, reason: 'trial_expired' }) }) it('allows past_due with a dunning warning (rule: do not lock customers out mid-grace)', () => { diff --git a/tests/stripe-webhooks-dispatcher.test.ts b/tests/stripe-webhooks-dispatcher.test.ts index 82c2bf5..60ef94c 100644 --- a/tests/stripe-webhooks-dispatcher.test.ts +++ b/tests/stripe-webhooks-dispatcher.test.ts @@ -79,7 +79,7 @@ describe('StripeBillingDispatcher — created', () => { expect(stored?.state).toBe('trialing') expect(stored?.lastEventId).toBe('evt_1') expect(captured).toHaveLength(1) - expect(captured[0]).toMatchObject({ kind: 'subscription.created', eventId: 'evt_1' }) + expect(captured[0]).toMatchObject({ kind: 'subscription.trial_ignored', eventId: 'evt_1' }) }) it('drops a created event when a non-incomplete record already exists (out-of-order)', async () => { @@ -195,6 +195,39 @@ describe('StripeBillingDispatcher — updated', () => { ) expect(events[0]).toMatchObject({ kind: 'event_dropped_out_of_order' }) }) + + it('does not let a different Stripe subscription mutate the workspace record', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + })) + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + + await dispatcher.dispatch(makeEnvelope( + subEvent({ + id: 'evt_foreign', + type: 'customer.subscription.updated', + status: 'canceled', + workspaceId: 'ws_1', + customerId: 'cus_attacker', + subscriptionId: 'sub_attacker', + }), + 'customer.subscription.updated', + )) + + expect((await store.load('ws_1'))?.state).toBe('active') + expect(events[0]).toMatchObject({ kind: 'event_dropped_out_of_order', eventId: 'evt_foreign' }) + expect((events[0] as { reason: string }).reason).toContain('identity') + }) }) describe('StripeBillingDispatcher — deleted + lifecycle', () => { @@ -325,8 +358,96 @@ describe('StripeBillingDispatcher — invoice', () => { expect((events[0] as { record: SubscriptionRecord | null }).record?.workspaceId).toBe('ws_1') }) - it('emits invoice.payment_failed and degrades to record:null when no workspaceId resolvable', async () => { + it('does not emit paid entitlement for a zero-dollar invoice', async () => { + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store: new InMemorySubscriptionStore(), + listener: (event) => { events.push(event) }, + }) + await dispatcher.dispatch(makeEnvelope({ + id: 'evt_zero', + type: 'invoice.paid', + data: { object: { id: 'in_zero', amount_paid: 0, customer: 'cus_1' } }, + }, 'invoice.paid')) + expect(events).toEqual([{ kind: 'invoice.zero_dollar_ignored', eventId: 'evt_zero', invoiceId: 'in_zero', amountPaid: 0 }]) + }) + + it('processes one of 100 concurrent copies of the same event', async () => { + const events: StripeBillingEvent[] = [] + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + })) + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + const envelope = makeEnvelope({ + id: 'evt_100', + type: 'invoice.paid', + data: { + object: { + id: 'in_100', + amount_paid: 100, + customer: 'cus_1', + subscription: 'sub_1', + metadata: { workspaceId: 'ws_1' }, + }, + }, + }, 'invoice.paid') + await Promise.all(Array.from({ length: 100 }, () => dispatcher.dispatch(envelope))) + expect(events.filter((event) => event.kind === 'invoice.paid')).toHaveLength(1) + }) + + it('does not emit paid entitlement for a foreign invoice', async () => { const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + })) + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + + await dispatcher.dispatch(makeEnvelope({ + id: 'evt_foreign_invoice', + type: 'invoice.paid', + data: { + object: { + id: 'in_foreign', + amount_paid: 100, + customer: 'cus_attacker', + subscription: 'sub_attacker', + metadata: { workspaceId: 'ws_1' }, + }, + }, + }, 'invoice.paid')) + + expect(events.some((event) => event.kind === 'invoice.paid')).toBe(false) + expect(events[0]).toMatchObject({ kind: 'event_dropped_out_of_order', eventId: 'evt_foreign_invoice' }) + }) + + it('emits invoice.payment_failed only for a bound subscription record', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + })) const events: StripeBillingEvent[] = [] const dispatcher = new StripeBillingDispatcher({ store, @@ -339,7 +460,15 @@ describe('StripeBillingDispatcher — invoice', () => { { id: 'evt_if', type: 'invoice.payment_failed', - data: { object: { id: 'in_2', amount_due: 1000 } }, + data: { + object: { + id: 'in_2', + amount_due: 1000, + customer: 'cus_1', + subscription: 'sub_1', + metadata: { workspaceId: 'ws_1' }, + }, + }, }, 'invoice.payment_failed', ), @@ -348,8 +477,22 @@ describe('StripeBillingDispatcher — invoice', () => { kind: 'invoice.payment_failed', invoiceId: 'in_2', amountDue: 1000, - record: null, + record: expect.objectContaining({ workspaceId: 'ws_1' }), + }) + }) + + it('drops a failed invoice when no workspace can be resolved', async () => { + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store: new InMemorySubscriptionStore(), + listener: (event) => { events.push(event) }, }) + await dispatcher.dispatch(makeEnvelope({ + id: 'evt_if_unbound', + type: 'invoice.payment_failed', + data: { object: { id: 'in_unbound', amount_due: 1000, customer: 'cus_1' } }, + }, 'invoice.payment_failed')) + expect(events[0]).toMatchObject({ kind: 'event_dropped_out_of_order', eventId: 'evt_if_unbound' }) }) }) diff --git a/tests/tangle-id.test.ts b/tests/tangle-id.test.ts index c6653b0..a8eb640 100644 --- a/tests/tangle-id.test.ts +++ b/tests/tangle-id.test.ts @@ -22,14 +22,28 @@ function emptyResponse(status: number): Response { } describe('tangle-id verifyToken', () => { + it('fails closed when a service token has no named service', () => { + expect(() => createTangleIdentityClient({ serviceToken: 'svc_x' })).toThrow(/serviceName is required/) + }) + it('refuses service tokens without making a network call (privilege escalation guard)', async () => { const fetchImpl = vi.fn(async () => jsonResponse({})) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await client.verifyToken(`${TANGLE_SERVICE_TOKEN_PREFIX}abc`) expect(result).toEqual({ valid: false, reason: 'service_token_refused' }) expect(fetchImpl).not.toHaveBeenCalled() }) + it('refuses broker keys as user identity without making a network call', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ valid: true })) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) + await expect(client.verifyToken('sk-tan-broker-owner')).resolves.toEqual({ + valid: false, + reason: 'service_token_refused', + }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + it('routes sk-tan-* keys to /v1/keys/verify and returns normalized scopes + team workspace', async () => { let capturedPath = '' let capturedAuth = '' @@ -39,6 +53,8 @@ describe('tangle-id verifyToken', () => { return jsonResponse({ valid: true, userId: 'usr_1', + email: 'owner@company.com', + emailVerified: true, ownerId: 'team_1', ownerType: 'team', keyId: 'key_42', @@ -50,6 +66,7 @@ describe('tangle-id verifyToken', () => { const client = createTangleIdentityClient({ baseUrl: 'https://id.example.com', serviceToken: 'svc_service', + serviceName: 'test-suite', fetchImpl, }) const result = await client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`) @@ -63,6 +80,7 @@ describe('tangle-id verifyToken', () => { ownerType: 'team', credentialId: 'key_42', product: 'legal', + emailVerified: true, }) if (result.valid) { expect(result.scopes).toEqual(['gpt-4', 'claude-3', 'product:legal']) @@ -72,25 +90,26 @@ describe('tangle-id verifyToken', () => { it('falls back to userId workspace for personal (non-team) API keys', async () => { const fetchImpl = vi.fn(async () => - jsonResponse({ valid: true, userId: 'usr_5', allowedModels: [] }), + jsonResponse({ valid: true, userId: 'usr_5', allowedModels: [], emailVerified: true, email: 'owner@company.com' }), ) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await client.verifyToken(`${TANGLE_API_KEY_PREFIX}x`) if (!result.valid) throw new Error('expected valid') expect(result.workspaceId).toBe('usr_5') expect(result.ownerType).toBe('user') + expect(result.emailVerified).toBe(true) }) it('returns service_token_refused on 401 from /v1/keys/verify', async () => { const fetchImpl = vi.fn(async () => emptyResponse(401)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`) expect(result).toEqual({ valid: false, reason: 'service_token_refused' }) }) it('throws TangleIdentityUnreachableError on 5xx from /v1/keys/verify (fail-closed for platform)', async () => { const fetchImpl = vi.fn(async () => new Response('boom', { status: 503 })) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).rejects.toBeInstanceOf( TangleIdentityUnreachableError, ) @@ -98,7 +117,7 @@ describe('tangle-id verifyToken', () => { it('returns malformed when /v1/keys/verify response has no valid field', async () => { const fetchImpl = vi.fn(async () => jsonResponse({})) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) expect(await client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).toEqual({ valid: false, reason: 'malformed', @@ -107,7 +126,7 @@ describe('tangle-id verifyToken', () => { it('returns revoked for valid:false on /v1/keys/verify', async () => { const fetchImpl = vi.fn(async () => jsonResponse({ valid: false })) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) expect(await client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).toEqual({ valid: false, reason: 'revoked', @@ -121,7 +140,7 @@ describe('tangle-id verifyToken', () => { capturedPath = String(input) capturedAuth = (init?.headers as Record)['authorization'] ?? '' return jsonResponse({ - user: { id: 'usr_9', email: 'a@b.c' }, + user: { id: 'usr_9', email: 'owner@company.com', emailVerified: true }, session: { id: 'sess_42', expiresAt: '2026-06-01T00:00:00.000Z', activeTeamId: 'team_99' }, }) }) @@ -137,6 +156,7 @@ describe('tangle-id verifyToken', () => { expect(result.workspaceId).toBe('team_99') expect(result.ownerType).toBe('team') expect(result.credentialId).toBe('sess_42') + expect(result.emailVerified).toBe(true) }) it('maps session 401/403 to expired without throwing', async () => { @@ -156,7 +176,7 @@ describe('tangle-id verifyToken', () => { const fetchImpl = vi.fn(async () => { throw new Error('econnrefused') }) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).rejects.toBeInstanceOf( TangleIdentityUnreachableError, ) @@ -174,7 +194,7 @@ describe('tangle-id listWorkspaces / switchWorkspace', () => { ], }), ) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const workspaces = await client.listWorkspaces('usr_1') expect(workspaces).toHaveLength(2) expect(workspaces[0]).toEqual({ @@ -191,7 +211,7 @@ describe('tangle-id listWorkspaces / switchWorkspace', () => { const fetchImpl = vi.fn(async () => jsonResponse({ success: true, data: [{ id: 'team_x', name: 'X', role: 'superadmin' }] }), ) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const [ws] = await client.listWorkspaces('usr_x') expect(ws.role).toBe('member') }) @@ -206,14 +226,14 @@ describe('tangle-id listWorkspaces / switchWorkspace', () => { ], }), ) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const out = await client.switchWorkspace('usr_x', 'team_b') expect(out).toEqual({ ok: true, workspaceId: 'team_b', scopes: ['stripe:*'] }) }) it('switchWorkspace throws on missing workspace', async () => { const fetchImpl = vi.fn(async () => jsonResponse({ success: true, data: [] })) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.switchWorkspace('u', 'w')).rejects.toBeInstanceOf( TangleIdentityUnreachableError, ) @@ -223,23 +243,30 @@ describe('tangle-id listWorkspaces / switchWorkspace', () => { describe('tangle-id revokeSession', () => { it('refuses to revoke service tokens', async () => { const fetchImpl = vi.fn(async () => emptyResponse(200)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.revokeSession('svc_foo')).rejects.toBeInstanceOf( TangleIdentityUnreachableError, ) expect(fetchImpl).not.toHaveBeenCalled() }) + it('refuses to revoke broker tokens through the user-key path', async () => { + const fetchImpl = vi.fn(async () => emptyResponse(200)) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) + await expect(client.revokeSession('sk-tan-broker-owner')).rejects.toThrow(/broker token/) + expect(fetchImpl).not.toHaveBeenCalled() + }) + it('verifies the API key, then DELETEs /v1/keys/{credentialId}', async () => { const calls: Array<{ url: string; method: string }> = [] const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: String(input), method: init?.method ?? 'GET' }) if (String(input).endsWith('/v1/keys/verify')) { - return jsonResponse({ valid: true, userId: 'u1', keyId: 'key_77' }) + return jsonResponse({ valid: true, userId: 'u1', keyId: 'key_77', emailVerified: true, email: 'owner@company.com' }) } return emptyResponse(204) }) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await client.revokeSession(`${TANGLE_API_KEY_PREFIX}token`) expect(calls.map((c) => c.method)).toEqual(['POST', 'DELETE']) expect(calls[1].url).toContain('/v1/keys/key_77') @@ -248,11 +275,11 @@ describe('tangle-id revokeSession', () => { it('treats 404 on key delete as a successful no-op (idempotent revoke)', async () => { const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { if (String(input).endsWith('/v1/keys/verify')) { - return jsonResponse({ valid: true, userId: 'u1', keyId: 'key_77' }) + return jsonResponse({ valid: true, userId: 'u1', keyId: 'key_77', emailVerified: true, email: 'owner@company.com' }) } return emptyResponse(404) }) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.revokeSession(`${TANGLE_API_KEY_PREFIX}token`)).resolves.toBeUndefined() }) @@ -265,7 +292,7 @@ describe('tangle-id revokeSession', () => { describe('tangle-id adapter wiring', () => { it('exposes the platform-contract capabilities including workspace/member write paths', () => { - const adapter = tangleIdentity({ serviceToken: 'svc_x' }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite' }) const names = adapter.manifest.capabilities.map((c) => c.name).sort() expect(names).toEqual( [ @@ -283,7 +310,7 @@ describe('tangle-id adapter wiring', () => { }) it('manifest declares native-idempotency for every mutation capability', () => { - const adapter = tangleIdentity({ serviceToken: 'svc_x' }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite' }) const mutationNames = adapter.manifest.capabilities .filter((c) => c.class === 'mutation') .map((c) => c.name) @@ -306,7 +333,7 @@ describe('tangle-id adapter wiring', () => { }) it('newly added mutations declare externalEffect: true (real upstream side effects)', () => { - const adapter = tangleIdentity({ serviceToken: 'svc_x' }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite' }) const targets = new Set([ 'workspaces.create', 'workspaces.delete', @@ -322,9 +349,9 @@ describe('tangle-id adapter wiring', () => { it('executeRead routes verify_token to the client and round-trips the typed result', async () => { const fetchImpl = vi.fn(async () => - jsonResponse({ valid: true, userId: 'u', allowedModels: ['gpt-4'] }), + jsonResponse({ valid: true, userId: 'u', allowedModels: ['gpt-4'], emailVerified: true, email: 'owner@company.com' }), ) - const adapter = tangleIdentity({ serviceToken: 'svc_x', fetchImpl }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await adapter.executeRead!({ source: makeSource(), capabilityName: 'verify_token', @@ -335,7 +362,7 @@ describe('tangle-id adapter wiring', () => { }) it('rejects unknown capability with a descriptive error', async () => { - const adapter = tangleIdentity({ serviceToken: 'svc_x' }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite' }) await expect( adapter.executeRead!({ source: makeSource(), @@ -394,6 +421,7 @@ describe('tangle-id createWorkspace', () => { const client = createTangleIdentityClient({ baseUrl: 'https://id.example.com', serviceToken: 'svc_x', + serviceName: 'test-suite', fetchImpl, }) const workspace = await client.createWorkspace('usr_1', { name: 'New Team' }) @@ -412,7 +440,7 @@ describe('tangle-id createWorkspace', () => { it('surfaces CredentialsExpired on 401', async () => { const fetchImpl = vi.fn(async () => emptyResponse(401)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.createWorkspace('usr_1', { name: 'X' })).rejects.toMatchObject({ name: 'CredentialsExpired', }) @@ -420,7 +448,7 @@ describe('tangle-id createWorkspace', () => { it('throws on malformed response', async () => { const fetchImpl = vi.fn(async () => jsonResponse({ success: true, data: {} })) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.createWorkspace('usr_1', { name: 'X' })).rejects.toBeInstanceOf( TangleIdentityUnreachableError, ) @@ -436,7 +464,7 @@ describe('tangle-id deleteWorkspace', () => { capturedMethod = init?.method ?? 'GET' return emptyResponse(204) }) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await client.deleteWorkspace('team_42') expect(capturedMethod).toBe('DELETE') expect(capturedPath).toContain('/v1/teams/team_42') @@ -444,13 +472,13 @@ describe('tangle-id deleteWorkspace', () => { it('treats 404 as an idempotent no-op', async () => { const fetchImpl = vi.fn(async () => emptyResponse(404)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.deleteWorkspace('team_missing')).resolves.toBeUndefined() }) it('surfaces CredentialsExpired on 401', async () => { const fetchImpl = vi.fn(async () => emptyResponse(401)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.deleteWorkspace('team_1')).rejects.toMatchObject({ name: 'CredentialsExpired', }) @@ -477,7 +505,7 @@ describe('tangle-id inviteMember', () => { }, }) }) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const invitation = await client.inviteMember('team_1', 'alice@example.com', 'admin') expect(capturedMethod).toBe('POST') expect(capturedPath).toContain('/v1/teams/team_1/invitations') @@ -504,7 +532,7 @@ describe('tangle-id inviteMember', () => { }, }), ) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const invitation = await client.inviteMember('team_1', 'b@example.com') expect(invitation.role).toBe('member') expect(invitation.status).toBe('pending') @@ -512,7 +540,7 @@ describe('tangle-id inviteMember', () => { it('surfaces CredentialsExpired on 401', async () => { const fetchImpl = vi.fn(async () => emptyResponse(401)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect( client.inviteMember('team_1', 'a@b.c'), ).rejects.toMatchObject({ name: 'CredentialsExpired' }) @@ -528,7 +556,7 @@ describe('tangle-id removeMember', () => { capturedMethod = init?.method ?? 'GET' return emptyResponse(204) }) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await client.removeMember('team_1', 'usr_5') expect(capturedMethod).toBe('DELETE') expect(capturedPath).toContain('/v1/teams/team_1/members/usr_5') @@ -536,13 +564,13 @@ describe('tangle-id removeMember', () => { it('treats 404 as an idempotent no-op', async () => { const fetchImpl = vi.fn(async () => emptyResponse(404)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.removeMember('team_1', 'usr_5')).resolves.toBeUndefined() }) it('surfaces CredentialsExpired on 401', async () => { const fetchImpl = vi.fn(async () => emptyResponse(401)) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', fetchImpl }) + const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) await expect(client.removeMember('team_1', 'usr_5')).rejects.toMatchObject({ name: 'CredentialsExpired', }) @@ -557,7 +585,7 @@ describe('tangle-id adapter executeMutation routing', () => { data: { id: 'team_x', name: 'X', role: 'owner', scopes: [] }, }), ) - const adapter = tangleIdentity({ serviceToken: 'svc_x', fetchImpl }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await adapter.executeMutation!({ source: makeSource(), capabilityName: 'workspaces.create', @@ -570,7 +598,7 @@ describe('tangle-id adapter executeMutation routing', () => { it('routes workspaces.delete through the client and returns ok', async () => { const fetchImpl = vi.fn(async () => emptyResponse(204)) - const adapter = tangleIdentity({ serviceToken: 'svc_x', fetchImpl }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await adapter.executeMutation!({ source: makeSource(), capabilityName: 'workspaces.delete', @@ -593,7 +621,7 @@ describe('tangle-id adapter executeMutation routing', () => { }, }), ) - const adapter = tangleIdentity({ serviceToken: 'svc_x', fetchImpl }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await adapter.executeMutation!({ source: makeSource(), capabilityName: 'members.invite', @@ -606,7 +634,7 @@ describe('tangle-id adapter executeMutation routing', () => { it('routes members.remove through the client', async () => { const fetchImpl = vi.fn(async () => emptyResponse(204)) - const adapter = tangleIdentity({ serviceToken: 'svc_x', fetchImpl }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) const result = await adapter.executeMutation!({ source: makeSource(), capabilityName: 'members.remove', @@ -617,7 +645,7 @@ describe('tangle-id adapter executeMutation routing', () => { }) it('rejects unknown mutation capability', async () => { - const adapter = tangleIdentity({ serviceToken: 'svc_x' }) + const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite' }) await expect( adapter.executeMutation!({ source: makeSource(), diff --git a/tests/tangle-middleware.test.ts b/tests/tangle-middleware.test.ts index 8056e93..2e26e4e 100644 --- a/tests/tangle-middleware.test.ts +++ b/tests/tangle-middleware.test.ts @@ -81,9 +81,11 @@ describe('requireTangleAuth', () => { valid: true, kind: 'api_key', userId: 'usr_1', + email: 'owner@company.com', workspaceId: 'team_1', scopes: ['gmail:read'], ownerType: 'team', + emailVerified: true, credentialId: 'key_1', product: 'legal', expiresAt: 1_700_000_000_000, @@ -99,9 +101,25 @@ describe('requireTangleAuth', () => { credentialId: 'key_1', product: 'legal', expiresAt: 1_700_000_000_000, + emailVerified: true, + email: 'owner@company.com', }) }) + it('returns 403 when a valid credential is not tied to a verified email', async () => { + const client = makeClient(async () => ({ + valid: true, + kind: 'api_key', + userId: 'usr_1', + workspaceId: 'usr_1', + scopes: [], + ownerType: 'user', + emailVerified: false, + })) + const out = await requireTangleAuth(reqWith({ authorization: 'Bearer sk-tan-x' }), { client }) + expect(out).toEqual({ ok: false, status: 403, reason: 'email_verification_required' }) + }) + it('maps service_token_refused to 403 (distinct from 401 bad-token)', async () => { const client = makeClient(async () => ({ valid: false, reason: 'service_token_refused' })) const out = await requireTangleAuth(reqWith({ authorization: 'Bearer sk-tan-x' }), { client }) @@ -156,9 +174,11 @@ describe('honoTangleAuthMiddleware', () => { valid: true, kind: 'session', userId: 'u', + email: 'owner@company.com', workspaceId: 'u', scopes: [], ownerType: 'user', + emailVerified: true, })) const handler = honoTangleAuthMiddleware({ client }) const set = vi.fn() @@ -209,9 +229,11 @@ describe('expressTangleAuthMiddleware', () => { valid: true, kind: 'session', userId: 'u', + email: 'owner@company.com', workspaceId: 'u', scopes: [], ownerType: 'user', + emailVerified: true, })) const handler = expressTangleAuthMiddleware({ client }) const req: ExpressLikeRequest = { diff --git a/tests/webhook-router.test.ts b/tests/webhook-router.test.ts index f329dfb..6ca7aa4 100644 --- a/tests/webhook-router.test.ts +++ b/tests/webhook-router.test.ts @@ -9,6 +9,7 @@ import { gdriveWebhookProvider, genericHmacWebhookProvider, hellosignWebhookProvider, + InMemoryWebhookIdempotencyStore, type WebhookEnvelope, type WebhookIdempotencyStore, } from '../src/webhooks/index' @@ -100,13 +101,15 @@ describe('WebhookRouter', () => { expect(r.status).toBe(401) }) - it('idempotency.seen short-circuits a duplicate event', async () => { + it('atomic idempotency claim short-circuits a duplicate event', async () => { const delivered: WebhookEnvelope[] = [] const seen = new Set(['evt_1']) const idempotency: WebhookIdempotencyStore = { - seen: (id) => seen.has(id), - remember: (id) => { - seen.add(id) + claim: (id) => { + const key = id.replace('stripe:id:', '') + if (seen.has(key)) return false + seen.add(key) + return true }, } const router = new WebhookRouter({ @@ -131,12 +134,12 @@ describe('WebhookRouter', () => { expect(delivered).toHaveLength(0) }) - it('records idempotency entries after a successful deliver', async () => { - const remembered: string[] = [] + it('claims an idempotency entry before a successful deliver', async () => { + const claimed: string[] = [] const idempotency: WebhookIdempotencyStore = { - seen: () => false, - remember: (id) => { - remembered.push(id) + claim: (id) => { + claimed.push(id) + return true }, } const router = new WebhookRouter({ @@ -150,7 +153,32 @@ describe('WebhookRouter', () => { const sig = `t=${ts},v1=${createHmac('sha256', 'whsec_test').update(`${ts}.${body}`).digest('hex')}` await router.handle({ providerId: 'stripe', rawBody: body, headers: { 'stripe-signature': sig } }) await flushMicrotasks() - expect(remembered).toEqual(['evt_2']) + expect(claimed).toEqual(['stripe:id:evt_2']) + }) + + it('delivers a duplicate webhook exactly once under 100 concurrent requests', async () => { + const delivered: WebhookEnvelope[] = [] + const router = new WebhookRouter({ + providers: [stripeWebhookProvider], + deliver: async (event) => { + await Promise.resolve() + delivered.push(event) + }, + resolveSecret: async () => 'whsec_test', + idempotency: new InMemoryWebhookIdempotencyStore(), + }) + const ts = Math.floor(Date.now() / 1000) + const body = JSON.stringify({ id: 'evt_concurrent', type: 'invoice.paid' }) + const sig = `t=${ts},v1=${createHmac('sha256', 'whsec_test').update(`${ts}.${body}`).digest('hex')}` + const request = { + providerId: 'stripe', + rawBody: body, + headers: { 'stripe-signature': sig }, + } + const responses = await Promise.all(Array.from({ length: 100 }, () => router.handle(request))) + expect(responses.filter((response) => (response.body as { received?: number }).received === 1)).toHaveLength(1) + await flushMicrotasks() + expect(delivered).toHaveLength(1) }) it('routes a DocuSeal webhook end-to-end', async () => { diff --git a/tsup.config.ts b/tsup.config.ts index 9563a91..600ee76 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ 'connectors/adapters/index': 'src/connectors/adapters/index.ts', 'connect/index': 'src/connect/index.ts', 'middleware/index': 'src/middleware/index.ts', + idempotency: 'src/idempotency.ts', 'webhooks/index': 'src/webhooks/index.ts', 'conversation-events/index': 'src/conversation-events/index.ts', 'delegated-tools/index': 'src/delegated-tools/index.ts', From b4d0413a2c8937e9b388c15c67308c01d903db64 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 10 Aug 2026 22:02:41 -0600 Subject: [PATCH 2/2] fix(billing): close issuance and webhook races --- docs/billing-access-policy.md | 80 +++- examples/webhook-router.ts | 18 +- src/billing-access-policy.ts | 257 +++++++++--- src/connect/index.ts | 88 +++-- src/connectors/adapters/tangle-id.ts | 168 ++++++-- src/idempotency.ts | 454 ++++++++++++++-------- src/middleware/index.ts | 6 + src/stripe/errors.ts | 8 + src/stripe/subscription-state.ts | 236 +++++++++-- src/stripe/webhooks.ts | 441 +++++++++++++++++---- src/webhooks/router.ts | 76 ++-- tests/billing-access-policy.test.ts | 379 ++++++++++++++++-- tests/connect-flow.test.ts | 78 +++- tests/filesystem-cross-process.test.ts | 293 ++++++++++++++ tests/idempotency-store.test.ts | 68 +++- tests/platform-boundary-contract.test.ts | 43 +- tests/stripe-billing-middleware.test.ts | 76 +++- tests/stripe-state-machine.test.ts | 1 + tests/stripe-webhooks-dispatcher.test.ts | 474 ++++++++++++++++++++++- tests/tangle-id.test.ts | 133 ++++++- tests/tangle-middleware.test.ts | 8 +- tests/webhook-router.test.ts | 99 ++++- 22 files changed, 2898 insertions(+), 586 deletions(-) create mode 100644 tests/filesystem-cross-process.test.ts diff --git a/docs/billing-access-policy.md b/docs/billing-access-policy.md index 41c2470..317d384 100644 --- a/docs/billing-access-policy.md +++ b/docs/billing-access-policy.md @@ -4,12 +4,40 @@ Products cannot grant company-funded value from signup, trial, promotion, fallba `src/billing-access-policy.ts` is the shared boundary. -Call `parseTrustedPlatformEvidence` only at a response boundary owned by Platform. +Call `verifyTrustedPlatformEvidence` with a short-lived JWT signed by Platform. + +Require the exact product audience and expected user id. + +Use an asymmetric Platform public key or trusted JWKS resolver. + +Use a shared atomic replay store in production. + +The store must retain completed paid-purchase records for the package's 100-year replay period. + +It must fail the claim if it cannot honor that retention period. Pass the resulting opaque evidence object to `decideBillingAccess`. +One `paid_purchase` evidence object allows one successful decision. + Do not pass caller strings such as `paid_purchase`, `paid_subscription`, or `byok`. +The verifier rejects unsigned tokens, broad or wrong audiences, wrong subjects, expired or future tokens, replayed ids, and mismatched principals. + +Platform must assign one immutable funding-record id to each billable entitlement. + +Re-signing the same paid-purchase record with a new JWT id remains a replay throughout that retention period. + +The paid-purchase claim is global across product audiences and principals for the same record id. + +Paid subscriptions, BYOK, named services, and administrator evidence claim the JWT id only through token expiry. + +Platform can issue a fresh JWT id for continuing evidence without consuming the entitlement permanently. + +Service display names are signed hints. + +The immutable service id and Platform funding record establish identity. + Human access requires Platform proof of a verified, non-placeholder email. Paid purchases, paid subscriptions, BYOK, explicit named services, and external administrator evidence remain valid. @@ -24,13 +52,35 @@ Zero-dollar invoices and trial subscription events produce diagnostic events onl Stripe updates, deletes, lifecycle events, and paid invoices must match the stored customer and subscription identity. +Current Stripe invoices read subscription identity from `parent.subscription_details`; legacy invoices use the top-level field. + +Distinct subscription events with equal timestamps use `retrieveSubscription` to read current state through authenticated Stripe access. + +The dispatcher returns a failure when that read is missing, fails, or returns a mismatched subscription. + +A subscription event or paid invoice that arrives before its subscription record also returns a failure for retry. + Foreign or unbound Stripe events produce diagnostics and cannot mutate state or emit paid entitlement. -The Platform exchange endpoint must enforce `requireVerifiedEmail` before key or balance issuance. +The Platform exchange endpoint must validate the shared exchange schema before code consumption or key replacement. + +The response can omit the one-time `apiKey` on replay, but it always returns `keyId`. + +`finishConnectFlow` accepts that replay shape so it does not reject the shared contract after code consumption. + +API-key verification requires `expectedProduct` and checks the returned `product`, `keyId`, and immutable `provisionedByService` fields. + +The request sends Platform-side product enforcement for `router` and `sandbox`, the values the shared request contract accepts. + +All other shared product values are checked against the signed-integration expectation after Platform returns the verified record. + +The caller-supplied `serviceName` header does not establish key provenance. + +API-key auth returns `apiKeyId`, `product`, and immutable `provisionedByService` for downstream spend attribution. The package cannot prove that a remote deployment enforces that ordering without a live Platform credential. -Direct administrator CLI grants are outside this package and remain unchanged. +Direct administrator CLI grants and their `referenceId` contract are outside this package. ## Production webhook idempotency @@ -38,12 +88,32 @@ Direct administrator CLI grants are outside this package and remain unchanged. They reject a missing store and a process-local store before accepting requests. +The router awaits every `deliver` callback before it returns 2xx. + +A failed callback or a concurrent active delivery returns 503 so the provider retries. + +The callback must durably enqueue with a unique `providerEventId` before it resolves. + +If a callback performs a side effect before it throws, that side effect must use the same idempotency key. + `FileSystemWebhookIdempotencyStore` and `FileSystemStripeEventIdempotencyStore` provide durable file-backed claims when every worker mounts the same directory. Use Redis, D1, Postgres, or another shared backend by implementing `AtomicIdempotencyStore` with `scope: 'shared'` and an atomic claim operation. In-memory stores are available only for tests and explicit development runtimes. -The filesystem adapter stores one claim per hashed key and uses an exclusive per-key lock plus atomic replacement. +The filesystem adapter uses an append-only decision chain and atomic hard links. + +Renewal, completion, release, and takeover contend on the same next-node path. + +An active owner renews its processing lease. + +A crashed owner becomes recoverable after the lease. + +A stale owner cannot complete or release its successor's claim. + +Claim files and directories are synchronized before success returns. + +Malformed or unavailable storage fails closed. -The lock lease recovers after a worker crash; malformed or unavailable storage fails closed. +Every worker must mount one filesystem with atomic hard-link and directory-sync semantics. diff --git a/examples/webhook-router.ts b/examples/webhook-router.ts index e10f8fb..85a4d74 100644 --- a/examples/webhook-router.ts +++ b/examples/webhook-router.ts @@ -2,8 +2,8 @@ * Wire the inbound webhook router behind a single HTTP handler. * * The router takes care of signature verification, parsing, and - * idempotency dedup. The product's `deliver()` callback runs async and - * sees a normalized envelope. + * idempotency dedup. The product's `deliver()` callback must finish a + * durable, idempotent enqueue before it resolves. */ import { @@ -33,8 +33,18 @@ const router = new WebhookRouter({ return null }, deliver: async (event) => { - console.log(`[webhook] ${event.eventType} (${event.providerEventId ?? 'no-id'})`) - // Branch on eventType and enqueue domain-specific work here. + if (!event.providerEventId) throw new Error('A stable provider event id is required for durable enqueue') + const queueUrl = process.env.WEBHOOK_QUEUE_URL + if (!queueUrl) throw new Error('WEBHOOK_QUEUE_URL is required') + const queued = await fetch(queueUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': event.providerEventId, + }, + body: JSON.stringify(event), + }) + if (!queued.ok) throw new Error(`Webhook enqueue failed with ${queued.status}`) }, }) diff --git a/src/billing-access-policy.ts b/src/billing-access-policy.ts index 1a252a8..1579cb0 100644 --- a/src/billing-access-policy.ts +++ b/src/billing-access-policy.ts @@ -1,13 +1,34 @@ +import { + jwtVerify, + type CryptoKey as JoseCryptoKey, + type JWK, + type JWTPayload, + type JWTVerifyGetKey, + type JWTVerifyOptions, + type KeyObject, +} from 'jose' +import { + resolveAtomicIdempotencyStore, + type AtomicIdempotencyStore, + type IdempotencyRuntime, +} from './idempotency.js' + /** * Shared billing and identity policy for product integrations. * * Product code must not prove access by sending a string such as * `paid_purchase` or `byok`. The only accepted proof is an object parsed from - * the Platform response by `parseTrustedPlatformEvidence`. + * a signed Platform token verified by `verifyTrustedPlatformEvidence`. */ export const PLATFORM_ACCESS_POLICY_VERSION = 1 as const export const PLATFORM_ACCESS_ISSUER = 'id.tangle.tools' as const +/** + * Keep a paid-purchase record consumed for the practical lifetime of the + * product. A backend that cannot retain this TTL must fail the claim instead + * of shortening it, because a shortened TTL can issue the same purchase twice. + */ +export const PLATFORM_FUNDING_REPLAY_RETENTION_MS = 100 * 365 * 24 * 60 * 60 * 1000 export const PRODUCT_FREE_CREDIT_SOURCES = Object.freeze([ 'signup', @@ -74,19 +95,23 @@ export type TrustedPlatformPrincipal = export interface TrustedPlatformEvidence { issuer: typeof PLATFORM_ACCESS_ISSUER policyVersion: typeof PLATFORM_ACCESS_POLICY_VERSION + /** Immutable Platform funding-record id. Purchases use it for replay protection. */ evidenceId: string + /** One-time JWT id used to audit the signed presentation. */ + tokenId: string principal: TrustedPlatformPrincipal funding: TrustedFundingEvidence - /** Platform response time. Consumers may use this for short cache TTLs. */ + /** Product identifier the Platform signature binds this proof to. */ + audience: string + /** Platform signing time. Consumers may use this for short cache TTLs. */ issuedAt: string + /** Hard token expiry from the signed JWT. */ + expiresAt: string } -/** The wire shape returned by Platform's access/evidence endpoint. */ +/** Custom claims inside Platform's signed access-evidence JWT. */ export interface PlatformAccessEvidencePayload { policyVersion?: unknown - issuer?: unknown - evidenceId?: unknown - issuedAt?: unknown emailVerified?: unknown user?: { id?: unknown; email?: unknown } principal?: { @@ -110,6 +135,32 @@ export interface PlatformAccessEvidencePayload { } } +export type PlatformEvidenceVerificationKey = + | JoseCryptoKey + | KeyObject + | JWK + | Uint8Array + | JWTVerifyGetKey + +export interface VerifyTrustedPlatformEvidenceOptions { + /** Exact product/service audience expected by this consumer. */ + audience: string + /** Exact owner id expected by this request. */ + expectedUserId: string + /** Platform public key or trusted JWKS resolver. Never use a shared HMAC key. */ + verificationKey: PlatformEvidenceVerificationKey + /** Shared atomic store for purchase and signed-presentation replay claims. */ + replayStore: AtomicIdempotencyStore + /** Production requires a shared replay store. */ + runtime?: IdempotencyRuntime + /** Test clock override. */ + now?(): number + /** Allowed clock skew in seconds. Default 5. */ + clockToleranceSeconds?: number + /** Maximum age from `iat` in seconds. Default 300. */ + maxTokenAgeSeconds?: number +} + export type BillingAccessDecision = | { allowed: true @@ -123,6 +174,8 @@ export type BillingAccessDecision = | 'email_verification_required' | 'real_email_required' | 'platform_evidence_required' + | 'platform_evidence_expired' + | 'platform_evidence_replayed' | 'platform_evidence_subject_mismatch' | 'paid_evidence_required' reason: string @@ -140,46 +193,125 @@ export const NO_PRODUCT_FREE_CREDITS_POLICY = Object.freeze({ }) /* WeakSet prevents a caller from constructing a lookalike object and passing it - * as proof. Only the parser below can mark an object as Platform-issued. */ + * as proof. Only successful signature verification can mark an object trusted. */ const trustedEvidenceObjects = new WeakSet() +const consumedPurchaseEvidenceObjects = new WeakSet() /** - * Parse and mark an access response from Platform. + * Verify and consume one signed access proof from Platform. * - * `null` means the response is missing a required policy, identity, or funding - * field. Products must deny access when parsing returns null. + * Standard JWT claims bind issuer, audience, subject, issue time, not-before, + * expiry, and one-time id. Custom claims bind the principal to the funding + * record. Invalid or replayed tokens return null. Replay-store failures throw, + * so callers return a non-success response instead of granting access. */ -export function parseTrustedPlatformEvidence( - payload: unknown, - options: { expectedUserId?: string } = {}, -): TrustedPlatformEvidence | null { +export async function verifyTrustedPlatformEvidence( + signedEvidence: string, + options: VerifyTrustedPlatformEvidenceOptions, +): Promise { + if (typeof signedEvidence !== 'string' || !signedEvidence.trim()) return null + if (!options.audience.trim() || !options.expectedUserId.trim()) return null + const nowMs = (options.now ?? Date.now)() + if (!Number.isFinite(nowMs)) return null + const clockToleranceSeconds = options.clockToleranceSeconds ?? 5 + const maxTokenAgeSeconds = options.maxTokenAgeSeconds ?? 300 + if (!Number.isFinite(clockToleranceSeconds) || clockToleranceSeconds < 0) return null + if (!Number.isFinite(maxTokenAgeSeconds) || maxTokenAgeSeconds <= 0) return null + + let verified: { payload: JWTPayload } + try { + const verifyOptions: JWTVerifyOptions = { + algorithms: ['ES256', 'ES384', 'EdDSA', 'PS256', 'PS384', 'RS256', 'RS384'], + issuer: PLATFORM_ACCESS_ISSUER, + audience: options.audience, + subject: options.expectedUserId, + requiredClaims: ['iss', 'aud', 'sub', 'iat', 'nbf', 'exp', 'jti'], + currentDate: new Date(nowMs), + clockTolerance: clockToleranceSeconds, + maxTokenAge: maxTokenAgeSeconds, + } + if (typeof options.verificationKey === 'function') { + verified = await jwtVerify(signedEvidence, options.verificationKey, verifyOptions) + } else { + verified = await jwtVerify(signedEvidence, options.verificationKey, verifyOptions) + } + } catch { + return null + } + + const payload = verified.payload + const nowSeconds = Math.floor(nowMs / 1000) + if ( + payload.aud !== options.audience + || payload.sub !== options.expectedUserId + || typeof payload.iat !== 'number' + || typeof payload.nbf !== 'number' + || typeof payload.exp !== 'number' + || typeof payload.jti !== 'string' + || !payload.jti.trim() + || payload.iat > nowSeconds + || payload.nbf > nowSeconds + || payload.exp <= nowSeconds + ) return null if (!isRecord(payload)) return null if (payload.policyVersion !== PLATFORM_ACCESS_POLICY_VERSION) return null - if (payload.issuer !== PLATFORM_ACCESS_ISSUER) return null - - const evidenceId = readNonEmptyString(payload.evidenceId) - const issuedAt = readNonEmptyString(payload.issuedAt) const user = isRecord(payload.user) ? payload.user : undefined const principal = isRecord(payload.principal) ? payload.principal : undefined const funding = isRecord(payload.funding) ? payload.funding : undefined - if (!evidenceId || !issuedAt || !Number.isFinite(Date.parse(issuedAt)) || !user || !principal || !funding) return null + if (!user || !principal || !funding) return null const userId = readNonEmptyString(user.id) - if (!userId || (options.expectedUserId && userId !== options.expectedUserId)) return null + if (!userId || userId !== options.expectedUserId || payload.sub !== userId) return null const principalValue = parsePrincipal({ payload, user, principal, userId }) const fundingValue = parseFunding(funding) if (!principalValue || !fundingValue) return null if (principalValue.kind === 'human' && payload.emailVerified !== true) return null + if (!principalMatchesFunding(principalValue, fundingValue)) return null + + const replayStore = resolveAtomicIdempotencyStore({ + component: 'verifyTrustedPlatformEvidence', + store: options.replayStore, + runtime: options.runtime, + }) + const tokenTtlMs = Math.max(1, Math.ceil((payload.exp - nowSeconds + clockToleranceSeconds) * 1000)) + const oneTimePurchase = fundingValue.kind === 'paid_purchase' + const replayKey = [ + 'platform-access', + PLATFORM_ACCESS_ISSUER, + PLATFORM_ACCESS_POLICY_VERSION, + oneTimePurchase ? 'funding' : 'presentation', + oneTimePurchase ? fundingValue.evidenceId : payload.jti, + ].join(':') + const replayTtlMs = oneTimePurchase + ? Math.max(PLATFORM_FUNDING_REPLAY_RETENTION_MS, tokenTtlMs) + : tokenTtlMs + if (!(await replayStore.claim(replayKey, replayTtlMs))) return null const result: TrustedPlatformEvidence = Object.freeze({ issuer: PLATFORM_ACCESS_ISSUER, policyVersion: PLATFORM_ACCESS_POLICY_VERSION, - evidenceId, + evidenceId: fundingValue.evidenceId, + tokenId: payload.jti, principal: Object.freeze(principalValue), funding: Object.freeze(fundingValue), - issuedAt, + audience: options.audience, + issuedAt: new Date(payload.iat * 1000).toISOString(), + expiresAt: new Date(payload.exp * 1000).toISOString(), }) + try { + await replayStore.complete(replayKey) + } catch (completeError) { + try { + await replayStore.release(replayKey) + } catch (releaseError) { + throw new AggregateError( + [completeError, releaseError], + 'Platform evidence claim could not complete or release', + ) + } + throw completeError + } trustedEvidenceObjects.add(result) return result } @@ -200,6 +332,14 @@ export function decideBillingAccess(input: { reason: 'Platform must attest the identity and funding source', } } + const expiresAt = Date.parse(evidence.expiresAt) + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + return { + allowed: false, + code: 'platform_evidence_expired', + reason: 'Platform evidence has expired', + } + } if (input.expectedUserId && evidence.principal.userId !== input.expectedUserId) { return { allowed: false, @@ -223,6 +363,16 @@ export function decideBillingAccess(input: { } } } + if (evidence.funding.kind === 'paid_purchase') { + if (consumedPurchaseEvidenceObjects.has(evidence)) { + return { + allowed: false, + code: 'platform_evidence_replayed', + reason: 'Paid purchase evidence was already consumed', + } + } + consumedPurchaseEvidenceObjects.add(evidence) + } return { allowed: true, basis: evidence.funding.kind, principal: evidence.principal } } @@ -235,34 +385,19 @@ export function assertNoProductFreeTrial(trialDays: number | undefined): void { throw new Error('billing: product-funded free trials are disabled') } -/** - * Shared email check for every product boundary. It intentionally rejects - * test/example domains and common synthetic addresses. Platform remains the - * authority for inbox verification; this prevents accidental local bypasses. - */ +const PLATFORM_EMAIL_PATTERN = + /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/ +const PLATFORM_PLACEHOLDER_EMAIL_PATTERN = + /(?:@users\.noreply\.tangle\.tools$|^0x[a-f0-9]{40}@tangle\.tools$)/i + +/** Mirror Platform's shared `platformRealEmailSchema` until its package is published. */ export function isRealNonPlaceholderEmail(value: unknown): value is string { if (typeof value !== 'string') return false - const email = value.trim().toLowerCase() - if (email.length < 6 || email.length > 320 || /\s/.test(email)) return false - const match = /^([^@]+)@([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)$/.exec(email) - if (!match) return false - const local = match[1]! - const domain = match[2]! - if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) return false - if (domain === 'localhost' || domain.endsWith('.test') || domain.endsWith('.invalid') || domain.endsWith('.example')) { - return false - } - if ( - /(?:^|[.-])(test|example|placeholder|invalid|disposable|tempmail|mailinator|10minutemail|guerrillamail|yopmail)(?:[.-]|$)/.test( - domain, - ) - ) { - return false - } - if (/^(test|tester|example|placeholder|no-?reply|noreply)(?:[+._-]|$)/.test(local)) return false - if (/^0x[a-f0-9]{40}@tangle\.tools$/i.test(email)) return false - if (email.endsWith('@users.noreply.tangle.tools')) return false - return true + const email = value.trim() + return email.length >= 3 + && email.length <= 320 + && PLATFORM_EMAIL_PATTERN.test(email) + && !PLATFORM_PLACEHOLDER_EMAIL_PATTERN.test(email) } function parsePrincipal(input: { @@ -299,13 +434,20 @@ function parseFunding(value: Record): TrustedFundingEvidence | if (kind === 'paid_purchase') { const amountUsd = readPositiveNumber(value.amountUsd) const paidAt = readNonEmptyString(value.paidAt) - return amountUsd !== null && paidAt ? { kind, evidenceId, amountUsd, paidAt } : null + return amountUsd !== null && paidAt && Number.isFinite(Date.parse(paidAt)) + ? { kind, evidenceId, amountUsd, paidAt } + : null } if (kind === 'paid_subscription') { const subscriptionId = readNonEmptyString(value.subscriptionId) const status = value.status === 'active' || value.status === 'past_due' ? value.status : null const amountUsd = readPositiveNumber(value.amountUsd) if (!subscriptionId || !status || amountUsd === null) return null + if ( + value.currentPeriodEnd !== undefined + && value.currentPeriodEnd !== null + && (typeof value.currentPeriodEnd !== 'string' || !Number.isFinite(Date.parse(value.currentPeriodEnd))) + ) return null return { kind, evidenceId, @@ -334,6 +476,23 @@ function parseFunding(value: Record): TrustedFundingEvidence | return null } +function principalMatchesFunding( + principal: TrustedPlatformPrincipal, + funding: TrustedFundingEvidence, +): boolean { + if (principal.kind === 'human') { + return funding.kind === 'paid_purchase' + || funding.kind === 'paid_subscription' + || funding.kind === 'byok' + } + if (principal.kind === 'service_principal') { + return funding.kind === 'named_service' + && funding.serviceId === principal.serviceId + && funding.serviceName === principal.serviceName + } + return funding.kind === 'admin' && funding.adminId === principal.adminId +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/connect/index.ts b/src/connect/index.ts index e8d9540..23ef7a4 100644 --- a/src/connect/index.ts +++ b/src/connect/index.ts @@ -15,11 +15,12 @@ * checks the session cookie; if absent it punts to the login page * with a callback back to /cross-site/authorize. * - * 2. callback({ code, app, state }) → { apiKey, user, workspaceId } + * 2. callback({ code, app, state }) → { keyId, apiKey?, user } * id.tangle.tools redirects back to the product's `returnUrl` with * `?code=…&app=…&state=…`. The product calls `finish()` with the - * code; the helper POSTs /cross-site/exchange and returns the minted - * key + identity. `state` is verified by the caller against its own + * code; the helper POSTs /cross-site/exchange and returns the stable + * key id, optional one-time secret, and identity. `state` is verified + * by the caller against its own * session (we never see it twice; CSRF is the caller's responsibility * per the platform contract — see `cross-site.ts` line 148). * @@ -31,7 +32,8 @@ * encrypted-credentials store the product already runs (sandbox uses Redis, * gtm uses Postgres, blueprints uses CF KV). The recipe is identical to * sandbox/api/src/lib/platform-client.ts — caller supplies a store, this - * module hands back the raw key once and never persists it. + * module hands back the raw key once and never persists it. A replay returns + * the stable key id without returning the secret again. * * Why not invent a new wire protocol: tcloud + sandbox already speak this * one against the live platform deployment. Diverging breaks the boundary @@ -54,6 +56,40 @@ import { PLATFORM_ACCESS_POLICY_VERSION, } from '../billing-access-policy.js' +/** Request accepted by Platform's `/cross-site/exchange` route. */ +export interface PlatformExchangeRequest { + code: string + app: string +} + +/** + * Platform's shared `ExchangeResponse` contract. + * + * `emailVerified` is intentionally top-level. Platform checks eligibility + * before it consumes the code or replaces the product key. The nested user + * object does not contain another verification flag. + */ +export interface PlatformExchangeResponse { + apiKey?: string + keyId: string + paidAccessPolicyVersion: typeof PLATFORM_ACCESS_POLICY_VERSION + emailVerified: true + user: { + id: string + email: string + name?: string | null + image?: string | null + } + subscription?: { + plan: 'free' | 'pro' | 'enterprise' + sandboxTier: 'free' | 'pro' | 'enterprise' + routerTier: 'free' | 'pro' | 'enterprise' + status: string + currentPeriodEnd: string | null + } + balance: 0 +} + export interface ConnectFlowOptions extends TangleIdentityOptions { /** Base URL of id.tangle.tools (defaults to {@link DEFAULT_TANGLE_PLATFORM_URL}). */ baseUrl?: string @@ -87,10 +123,10 @@ export interface FinishConnectInput { } export interface FinishConnectOutput { - /** Newly-minted `sk-tan-*` API key bound to the calling user. Returned - * ONCE — caller is responsible for stashing it in the product's - * encrypted credentials store. */ - apiKey: string + /** Stable Platform key id. Persist this even when a replay omits the secret. */ + keyId: string + /** Newly minted secret. Platform intentionally omits it on a replay. */ + apiKey?: string /** Identity hydrated from the exchange response. */ user: TangleUserSummary /** Initial balance the platform returns alongside the key. */ @@ -122,7 +158,7 @@ export function startConnectFlow( } /** Finish a cross-product connect flow. Calls /cross-site/exchange and - * returns the minted API key + hydrated user identity. */ + * returns the stable key binding plus hydrated user identity. */ export async function finishConnectFlow( opts: ConnectFlowOptions, input: FinishConnectInput, @@ -144,8 +180,7 @@ export async function finishConnectFlow( body: JSON.stringify({ code: input.code, app: input.appId, - requireVerifiedEmail: true, - }), + } satisfies PlatformExchangeRequest), signal: AbortSignal.timeout(timeoutMs), }) } catch (err) { @@ -164,41 +199,28 @@ export async function finishConnectFlow( { status: res.status }, ) } - const body = (await res.json().catch(() => null)) as - | { - apiKey?: string - paidAccessPolicyVersion?: number - emailVerified?: boolean - user?: { - id?: string - email?: string - emailVerified?: boolean - name?: string | null - image?: string | null - } - balance?: number - } - | null + const body = (await res.json().catch(() => null)) as Partial | null if ( !body || - typeof body.apiKey !== 'string' || - !isNonEmptyTangleApiKey(body.apiKey) || + (body.apiKey !== undefined && !isNonEmptyTangleApiKey(body.apiKey)) || + typeof body.keyId !== 'string' || + !body.keyId.trim() || body.paidAccessPolicyVersion !== PLATFORM_ACCESS_POLICY_VERSION || body.emailVerified !== true || !body.user || typeof body.user.id !== 'string' || !body.user.id.trim() || !isRealNonPlaceholderEmail(body.user.email) || - body.user.emailVerified !== true || - (body.balance !== undefined && (!Number.isFinite(body.balance) || body.balance < 0)) + body.balance !== 0 ) { throw new TangleIdentityUnreachableError( - 'connect/finish: Platform did not prove a verified real email and current access policy', + 'connect/finish: Platform returned an invalid exchange contract', { status: 403 }, ) } return { - apiKey: body.apiKey, + keyId: body.keyId, + ...(body.apiKey ? { apiKey: body.apiKey } : {}), user: { id: body.user.id, email: body.user.email, @@ -206,7 +228,7 @@ export async function finishConnectFlow( ...(body.user.name !== undefined ? { name: body.user.name } : {}), ...(body.user.image !== undefined ? { image: body.user.image } : {}), }, - balance: typeof body.balance === 'number' && Number.isFinite(body.balance) ? body.balance : 0, + balance: 0, paidAccessPolicyVersion: PLATFORM_ACCESS_POLICY_VERSION, } } diff --git a/src/connectors/adapters/tangle-id.ts b/src/connectors/adapters/tangle-id.ts index 874b10b..c228c85 100644 --- a/src/connectors/adapters/tangle-id.ts +++ b/src/connectors/adapters/tangle-id.ts @@ -100,8 +100,11 @@ export interface TangleIdentityOptions { * routes unauthenticated (rare; never in production). */ serviceToken?: string - /** Service identity claimed in the `X-Service-Name` header. */ + /** Routing hint sent with the authenticated service token. This is not key provenance. */ serviceName?: string + /** Product the authenticated service expects this key to serve. API-key + * authentication fails closed when this is absent. */ + expectedProduct?: PlatformKeyProduct /** Injected fetch — defaults to global. Tests pass a vi mock. */ fetchImpl?: typeof fetch /** Per-call timeout override (default {@link PLATFORM_FETCH_TIMEOUT_MS}). */ @@ -127,8 +130,12 @@ export type TangleTokenVerifyResult = /** Stable id of the credential row, when known (key.id for API * keys, session.id for sessions). Useful for revoke + audit. */ credentialId?: string + /** Stable Platform API-key row id. Present only for API-key auth. */ + apiKeyId?: string /** Product the credential is scoped to, when known. */ - product?: string + product?: PlatformKeyProduct + /** Immutable Platform service that provisioned this key. */ + provisionedByService?: string /** Platform proof that a human controls a real inbox. */ emailVerified?: boolean /** Platform-owned machine identity. */ @@ -156,6 +163,72 @@ export type TangleTokenVerifyFailure = | 'malformed' | 'email_verification_required' | 'real_email_required' + | 'product_scope_required' + | 'product_scope_mismatch' + +/** Product values accepted by Platform's shared key-verification contract. */ +export type PlatformKeyProduct = + | 'router' + | 'sandbox' + | 'hub' + | 'intelligence' + | 'blueprint-agent' + | 'evals' + | 'agent-builder' + | 'audits' + | 'tax-agent' + | 'legal-agent' + | 'gtm-agent' + | 'creative-agent' + | 'insurance-agent' + +/** Products accepted by Platform's `expectedProduct` request field. */ +export type PlatformProductPrincipal = 'router' | 'sandbox' + +/** Request accepted by Platform's authenticated `/v1/keys/verify` route. */ +export interface PlatformKeyVerifyRequest { + key: string + expectedProduct?: PlatformProductPrincipal +} + +/** Shared response from Platform's authenticated `/v1/keys/verify` route. */ +export interface PlatformKeyVerifyResponse { + valid: boolean + email?: string + emailVerified?: boolean + emailVerificationRequired?: boolean + servicePrincipal?: boolean + provisionedByService?: string + userId?: string + ownerId?: string + teamId?: string + ownerType?: 'user' | 'team' + memberUserId?: string + keyId?: string + product?: PlatformKeyProduct + name?: string + scopes?: string[] + budgetRemaining?: number + monthlyBudgetRemaining?: number + allowedModels?: string[] + rpmLimit?: number + plan?: string + sandboxTier?: 'free' | 'pro' | 'enterprise' + blueprintAgentTier?: 'free' | 'pro' | 'enterprise' + role?: 'admin' | 'member' | 'readonly' +} + +type VerifiedPlatformKeyResponse = PlatformKeyVerifyResponse & { + valid: true + emailVerified: true + servicePrincipal: boolean + provisionedByService: string + userId: string + ownerId: string + ownerType: 'user' | 'team' + keyId: string + name: string +} export interface TangleUserSummary { id: string @@ -545,11 +618,19 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta } } - async function verifyApiKey(token: string): Promise { + async function verifyApiKey( + token: string, + requireProductScope: boolean, + ): Promise { const res = await jsonFetch('/v1/keys/verify', { method: 'POST', headers: s2sHeaders(), - body: JSON.stringify({ key: token }), + body: JSON.stringify({ + key: token, + ...(isPlatformProductPrincipal(opts.expectedProduct) + ? { expectedProduct: opts.expectedProduct } + : {}), + } satisfies PlatformKeyVerifyRequest), }) if (res.status === 401) { // Service token rejected — distinct from "token is bad". The @@ -563,58 +644,42 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta { status: res.status }, ) } - const body = (await res.json().catch(() => null)) as - | { - valid?: boolean - userId?: string - ownerId?: string - ownerType?: 'user' | 'team' - keyId?: string - product?: string - allowedModels?: unknown - expiresAt?: string - emailVerified?: boolean - emailVerificationRequired?: boolean - servicePrincipal?: boolean - email?: string - } - | null + const body = (await res.json().catch(() => null)) as Partial | null if (!body || typeof body.valid !== 'boolean') { return { valid: false, reason: 'malformed' } } - if (!body.valid || !body.userId) { + if (!body.valid) { return { valid: false, reason: 'revoked' } } if (body.emailVerificationRequired === true) { return { valid: false, reason: 'email_verification_required' } } - if (body.servicePrincipal !== true && body.emailVerified !== true) { - return { valid: false, reason: 'email_verification_required' } + if (!isValidPlatformKeyResponse(body)) return { valid: false, reason: 'malformed' } + if (requireProductScope && !opts.expectedProduct) { + return { valid: false, reason: 'product_scope_required' } } - if (body.servicePrincipal !== true && !isRealNonPlaceholderEmail(body.email)) { - return { valid: false, reason: 'real_email_required' } + if (requireProductScope && body.product !== opts.expectedProduct) { + return { valid: false, reason: 'product_scope_mismatch' } } - const scopes = Array.isArray(body.allowedModels) - ? body.allowedModels.filter((value): value is string => typeof value === 'string') - : [] + const scopes = [ + ...(Array.isArray(body.scopes) ? body.scopes : []), + ...(Array.isArray(body.allowedModels) ? body.allowedModels : []), + ].filter((value, index, all): value is string => typeof value === 'string' && all.indexOf(value) === index) if (body.product) scopes.push(`product:${body.product}`) - const expiresAt = - typeof body.expiresAt === 'string' && body.expiresAt - ? Date.parse(body.expiresAt) - : undefined return { valid: true, kind: 'api_key', userId: body.userId, - workspaceId: body.ownerType === 'team' && body.ownerId ? body.ownerId : body.userId, - ownerType: body.ownerType ?? 'user', - emailVerified: body.emailVerified === true, + workspaceId: body.ownerId, + ownerType: body.ownerType, + emailVerified: true, scopes, - ...(Number.isFinite(expiresAt) ? { expiresAt: expiresAt as number } : {}), - ...(body.keyId ? { credentialId: body.keyId } : {}), + credentialId: body.keyId, + apiKeyId: body.keyId, ...(body.product ? { product: body.product } : {}), + provisionedByService: body.provisionedByService, + servicePrincipal: body.servicePrincipal, ...(body.email ? { email: body.email } : {}), - ...(body.servicePrincipal !== undefined ? { servicePrincipal: body.servicePrincipal } : {}), } } @@ -692,7 +757,7 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta if (token.length <= TANGLE_API_KEY_PREFIX.length) { return { valid: false, reason: 'malformed' } } - return verifyApiKey(token) + return verifyApiKey(token, true) } // Anything else — treat as a session bearer (Better Auth-emitted // JWTs are opaque to us). Wrong-issuer / random-string lands @@ -815,7 +880,7 @@ export function createTangleIdentityClient(opts: TangleIdentityOptions = {}): Ta // We don't know the key id until we verify; do that first so // revoke is keyed by id (the only thing the platform's DELETE // /v1/keys/{id} accepts). Bad-key responses are no-ops. - const v = await verifyApiKey(token) + const v = await verifyApiKey(token, false) if (!v.valid || !v.credentialId) return const res = await jsonFetch(`/v1/keys/${encodeURIComponent(v.credentialId)}`, { method: 'DELETE', @@ -1051,3 +1116,26 @@ function readOptionalRole( if (value === 'owner' || value === 'admin' || value === 'member') return value return undefined } + +function isValidPlatformKeyResponse( + value: Partial, +): value is VerifiedPlatformKeyResponse { + if (value.valid !== true || value.emailVerified !== true) return false + if (typeof value.servicePrincipal !== 'boolean') return false + if (!readRequiredString(value.userId)) return false + if (!readRequiredString(value.ownerId)) return false + if (value.ownerType !== 'user' && value.ownerType !== 'team') return false + if (!readRequiredString(value.keyId)) return false + if (!readRequiredString(value.name)) return false + if (!readRequiredString(value.provisionedByService)) return false + if (value.servicePrincipal === false && !isRealNonPlaceholderEmail(value.email)) return false + return true +} + +function readRequiredString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isPlatformProductPrincipal(value: unknown): value is PlatformProductPrincipal { + return value === 'router' || value === 'sandbox' +} diff --git a/src/idempotency.ts b/src/idempotency.ts index 6953e6d..5de5f25 100644 --- a/src/idempotency.ts +++ b/src/idempotency.ts @@ -2,40 +2,52 @@ import { createHash, randomUUID } from 'node:crypto' export type IdempotencyRuntime = 'production' | 'development' | 'test' +export type AtomicClaimStatus = 'acquired' | 'in_progress' | 'completed' + /** * Atomic claim storage shared by every worker that can receive the same event. - * `scope` is part of the construction contract so production cannot silently - * accept a process-local implementation. + * `claimStatus` distinguishes a finished replay from concurrent work. HTTP + * handlers need that distinction so they never acknowledge another worker's + * unfinished attempt. */ export interface AtomicIdempotencyStore { readonly scope?: 'process' | 'shared' claim(key: string, ttlMs: number): Promise | boolean - release?(key: string): Promise | void + claimStatus(key: string, ttlMs: number): Promise | AtomicClaimStatus + release(key: string): Promise | void /** Retain a successful claim while clearing only local ownership state. */ - complete?(key: string): Promise | void + complete(key: string): Promise | void +} + +interface InMemoryClaim { + expiresAt: number + token: string + status: 'processing' | 'completed' } /** Process-local implementation for tests and explicitly single-process apps. */ export class InMemoryAtomicIdempotencyStore implements AtomicIdempotencyStore { readonly scope = 'process' as const - private readonly entries = new Map() + private readonly entries = new Map() private readonly owners = new Map() claim(key: string, ttlMs: number): boolean { + return this.claimStatus(key, ttlMs) === 'acquired' + } + + claimStatus(key: string, ttlMs: number): AtomicClaimStatus { assertClaimInput(key, ttlMs) - // A long-running handler must not reclaim its own expired key while the - // original delivery can still call release. A fresh process may reclaim - // an expired durable claim; this local guard only preserves ownership - // within one process. - if (this.owners.has(key)) return false + if (this.owners.has(key)) return 'in_progress' const now = Date.now() const existing = this.entries.get(key) - if (existing && existing.expiresAt > now) return false + if (existing && existing.expiresAt > now) { + return existing.status === 'completed' ? 'completed' : 'in_progress' + } const token = randomUUID() - this.entries.set(key, { expiresAt: now + ttlMs, token }) + this.entries.set(key, { expiresAt: now + ttlMs, token, status: 'processing' }) this.owners.set(key, token) - return true + return 'acquired' } release(key: string): void { @@ -43,52 +55,81 @@ export class InMemoryAtomicIdempotencyStore implements AtomicIdempotencyStore { const token = this.owners.get(key) if (!token) return const current = this.entries.get(key) - if (current?.token === token) this.entries.delete(key) + if (current?.token === token && current.status === 'processing') this.entries.delete(key) if (this.owners.get(key) === token) this.owners.delete(key) } complete(key: string): void { assertKey(key) - this.owners.delete(key) + const token = this.owners.get(key) + if (!token) return + const current = this.entries.get(key) + if (current?.token === token) current.status = 'completed' + if (this.owners.get(key) === token) this.owners.delete(key) } } export interface FileSystemAtomicIdempotencyStoreOptions { /** Optional path-safe filename namespace for colocated stores. */ namespace?: string - /** Maximum time to wait for another worker's per-key lock. */ - lockWaitMs?: number - /** Lease used to recover a lock left by a crashed worker. */ + /** Lease for work in progress. Active owners renew it until completion. */ + processingLeaseMs?: number + /** Renewal cadence. Must be shorter than `processingLeaseMs`. */ + heartbeatIntervalMs?: number + /** @deprecated Use `processingLeaseMs`. Preserved for existing callers. */ lockLeaseMs?: number + /** @deprecated Append-only claims do not wait on a lock. */ + lockWaitMs?: number } -interface ClaimRecord { +interface LegacyClaimRecord { version: 1 keyHash: string token: string expiresAt: number } -interface LockRecord { +interface ClaimJournalNode { + version: 3 + kind: 'claim' | 'completed' | 'available' + keyHash: string token: string + predecessorToken: string | null + ownerToken: string | null expiresAt: number } +type StoredClaimRecord = LegacyClaimRecord | ClaimJournalNode + +interface LocalClaimOwner { + ownerToken: string + ttlMs: number + timer?: ReturnType + renewing: boolean + closing: boolean +} + +const MAX_CLAIM_CHAIN_DEPTH = 100_000 + /** - * Durable file-per-key claims using the repository's existing filesystem - * persistence convention. The state write is atomic, and an exclusive lock - * serializes read/replace decisions across worker processes. + * Durable append-only claim journal. * - * All workers must use the same shared filesystem directory. A lock left by a - * crashed worker is recoverable after its lease expires; malformed files fail - * closed instead of risking a duplicate claim. + * Every decision is linked to one deterministic path derived from the prior + * node token. Renewal, completion, release, and takeover therefore race the + * same atomic hard-link. Only one decision can win. Active owners renew a + * processing lease; a crashed process stops renewing and becomes recoverable. + * A stale process cannot complete or release a successor's claim. + * + * Version-1 state cannot distinguish active work from completion. It is read as + * in progress until expiry, so a rolling deployment never acknowledges work + * that an older process may still own. New state is never replaced or deleted. */ export class FileSystemAtomicIdempotencyStore implements AtomicIdempotencyStore { readonly scope = 'shared' as const - private readonly lockWaitMs: number - private readonly lockLeaseMs: number private readonly filePrefix: string - private readonly owners = new Map() + private readonly processingLeaseMs: number + private readonly heartbeatIntervalMs: number + private readonly owners = new Map() constructor( private readonly rootDir: string, @@ -98,152 +139,198 @@ export class FileSystemAtomicIdempotencyStore implements AtomicIdempotencyStore if (options.namespace !== undefined && !/^[A-Za-z0-9_.-]+$/.test(options.namespace)) { throw new Error('Idempotency namespace must contain only path-safe characters') } + if (options.lockWaitMs !== undefined) positiveOption(options.lockWaitMs, 'lockWaitMs') this.filePrefix = options.namespace ? `${options.namespace}-` : '' - this.lockWaitMs = positiveOption(options.lockWaitMs ?? 30_000, 'lockWaitMs') - this.lockLeaseMs = positiveOption(options.lockLeaseMs ?? 60_000, 'lockLeaseMs') - if (this.lockLeaseMs <= 0) throw new Error('lockLeaseMs must be positive') + this.processingLeaseMs = positiveOption( + options.processingLeaseMs ?? options.lockLeaseMs ?? 60_000, + 'processingLeaseMs', + ) + this.heartbeatIntervalMs = positiveOption( + options.heartbeatIntervalMs ?? Math.max(1, Math.floor(this.processingLeaseMs / 3)), + 'heartbeatIntervalMs', + ) + if (this.heartbeatIntervalMs >= this.processingLeaseMs) { + throw new Error('heartbeatIntervalMs must be shorter than processingLeaseMs') + } } async claim(key: string, ttlMs: number): Promise { - assertClaimInput(key, ttlMs) - // See the in-memory implementation: do not let one worker replace its - // own live handler's claim after expiry and then release the replacement. - if (this.owners.has(key)) return false - const keyHash = hashKey(key) - return this.withLock(keyHash, async () => { - const file = await this.statePath(keyHash) - const current = await this.readState(file, keyHash) - const now = Date.now() - if (current && current.expiresAt > now) return false - - const token = randomUUID() - await this.writeState(file, { - version: 1, - keyHash, - token, - expiresAt: now + ttlMs, - }) - this.owners.set(key, token) - return true - }) + return (await this.claimStatus(key, ttlMs)) === 'acquired' } - async release(key: string): Promise { - assertKey(key) - const token = this.owners.get(key) - if (!token) return - + async claimStatus(key: string, ttlMs: number): Promise { + assertClaimInput(key, ttlMs) + if (this.owners.has(key)) return 'in_progress' + await this.ensureRoot() const keyHash = hashKey(key) - await this.withLock(keyHash, async () => { - const file = await this.statePath(keyHash) - const current = await this.readState(file, keyHash) - if (current?.token === token) await this.removeState(file) - if (this.owners.get(key) === token) this.owners.delete(key) - }) - } - complete(key: string): void { - assertKey(key) - this.owners.delete(key) - } + while (true) { + const head = await this.readHead(keyHash) + const now = Date.now() + if (!head) { + const ownerToken = randomUUID() + const record = this.claimNode(keyHash, null, ownerToken, now) + if (!(await this.writeExclusive(await this.rootPath(keyHash), record))) continue + this.startOwnership(key, ownerToken, ttlMs) + return 'acquired' + } - private async statePath(keyHash: string): Promise { - const path = await import('node:path') - return path.join(this.rootDir, `${this.filePrefix}${keyHash}.json`) + if (head.version === 1) { + if (head.expiresAt > now) return 'in_progress' + } else { + if (head.kind === 'completed' && head.expiresAt > now) return 'completed' + if (head.kind === 'claim' && head.expiresAt > now) return 'in_progress' + } + + const ownerToken = randomUUID() + const successor = this.claimNode(keyHash, head.token, ownerToken, now) + if (!(await this.writeExclusive(await this.successorPath(keyHash, head.token), successor))) continue + this.startOwnership(key, ownerToken, ttlMs) + return 'acquired' + } } - private async lockPath(keyHash: string): Promise { - const path = await import('node:path') - return path.join(this.rootDir, `${this.filePrefix}${keyHash}.lock`) + async release(key: string): Promise { + await this.transitionOwned(key, 'available') } - private async ensureRoot(): Promise { - const fs = await import('node:fs/promises') - await fs.mkdir(this.rootDir, { recursive: true, mode: 0o700 }) + async complete(key: string): Promise { + await this.transitionOwned(key, 'completed') } - private async readState(file: string, expectedKeyHash: string): Promise { - const fs = await import('node:fs/promises') - let raw: string + private async transitionOwned(key: string, kind: 'completed' | 'available'): Promise { + assertKey(key) + const owner = this.owners.get(key) + if (!owner) return + owner.closing = true + this.stopHeartbeat(owner) + const keyHash = hashKey(key) try { - raw = await fs.readFile(file, 'utf8') + while (true) { + const head = await this.readHead(keyHash) + if ( + !head + || head.version === 1 + || head.kind !== 'claim' + || head.ownerToken !== owner.ownerToken + ) { + this.owners.delete(key) + if (kind === 'completed') { + throw new Error(`Idempotency ownership was lost before completion for ${key}`) + } + return + } + + const next: ClaimJournalNode = { + version: 3, + kind, + keyHash, + token: randomUUID(), + predecessorToken: head.token, + ownerToken: owner.ownerToken, + expiresAt: kind === 'completed' ? Date.now() + owner.ttlMs : 0, + } + if (!(await this.writeExclusive(await this.successorPath(keyHash, head.token), next))) continue + this.owners.delete(key) + return + } } catch (err) { - if (isNodeENOENT(err)) return null + if (this.owners.get(key) === owner) { + if (kind === 'available') { + this.owners.delete(key) + } else { + owner.closing = false + this.scheduleHeartbeat(key, owner) + } + } throw err } + } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - throw new Error(`Invalid idempotency state for ${expectedKeyHash}`) + private async readHead(keyHash: string): Promise { + let current = await this.readClaim(await this.rootPath(keyHash), keyHash) + if (!current) return null + for (let depth = 0; depth < MAX_CLAIM_CHAIN_DEPTH; depth++) { + const next = await this.readClaim(await this.successorPath(keyHash, current.token), keyHash) + if (!next) return current + if (next.version !== 3 || next.predecessorToken !== current.token) { + throw new Error(`Invalid idempotency successor for ${keyHash}`) + } + current = next } + throw new Error(`Idempotency claim chain exceeds ${MAX_CLAIM_CHAIN_DEPTH} records for ${keyHash}`) + } + + private async readClaim(file: string, expectedKeyHash: string): Promise { + const parsed = await this.readJson(file) + if (parsed === null) return null if (!isClaimRecord(parsed) || parsed.keyHash !== expectedKeyHash) { throw new Error(`Invalid idempotency state for ${expectedKeyHash}`) } return parsed } - private async writeState(file: string, record: ClaimRecord): Promise { - const fs = await import('node:fs/promises') - const tmp = `${file}.tmp-${process.pid}-${randomUUID()}` - await fs.writeFile(tmp, JSON.stringify(record), { encoding: 'utf8', mode: 0o600 }) - try { - await fs.rename(tmp, file) - } catch (err) { - await removeIfPresent(tmp) - throw err + private claimNode( + keyHash: string, + predecessorToken: string | null, + ownerToken: string, + now: number, + ): ClaimJournalNode { + return { + version: 3, + kind: 'claim', + keyHash, + token: randomUUID(), + predecessorToken, + ownerToken, + expiresAt: now + this.processingLeaseMs, } } - private async removeState(file: string): Promise { - const fs = await import('node:fs/promises') - try { - await fs.unlink(file) - } catch (err) { - if (!isNodeENOENT(err)) throw err - } + private startOwnership(key: string, ownerToken: string, ttlMs: number): void { + const owner: LocalClaimOwner = { ownerToken, ttlMs, renewing: false, closing: false } + this.owners.set(key, owner) + this.scheduleHeartbeat(key, owner) } - private async withLock(keyHash: string, work: () => Promise): Promise { - await this.ensureRoot() - const fs = await import('node:fs/promises') - const lockFile = await this.lockPath(keyHash) - const startedAt = Date.now() + private scheduleHeartbeat(key: string, owner: LocalClaimOwner): void { + if (owner.timer || owner.closing || this.owners.get(key) !== owner) return + owner.timer = setInterval(() => { + void this.renewOwnership(key, owner).catch(() => undefined) + }, this.heartbeatIntervalMs) + owner.timer.unref?.() + } - while (true) { - const token = randomUUID() - let handle: import('node:fs/promises').FileHandle | undefined - try { - handle = await fs.open(lockFile, 'wx', 0o600) - const lock: LockRecord = { token, expiresAt: Date.now() + this.lockLeaseMs } - await handle.writeFile(JSON.stringify(lock), 'utf8') - await handle.close() - handle = undefined - - try { - return await work() - } finally { - await this.releaseLock(lockFile, token) - } - } catch (err) { - if (handle) await handle.close().catch(() => undefined) - if (!isNodeEEXIST(err)) throw err + private stopHeartbeat(owner: LocalClaimOwner): void { + if (owner.timer) clearInterval(owner.timer) + owner.timer = undefined + } - const current = await this.readLock(lockFile) - if (current && current.expiresAt <= Date.now()) { - await this.reclaimExpiredLock(lockFile) - continue - } - if (Date.now() - startedAt >= this.lockWaitMs) { - throw new Error(`Timed out acquiring idempotency lock for ${keyHash}`) + private async renewOwnership(key: string, owner: LocalClaimOwner): Promise { + if (owner.renewing || owner.closing || this.owners.get(key) !== owner) return + owner.renewing = true + try { + const keyHash = hashKey(key) + while (!owner.closing && this.owners.get(key) === owner) { + const head = await this.readHead(keyHash) + if ( + !head + || head.version === 1 + || head.kind !== 'claim' + || head.ownerToken !== owner.ownerToken + ) { + this.stopHeartbeat(owner) + return } - await delay(5) + const renewal = this.claimNode(keyHash, head.token, owner.ownerToken, Date.now()) + if (await this.writeExclusive(await this.successorPath(keyHash, head.token), renewal)) return } + } finally { + owner.renewing = false } } - private async readLock(file: string): Promise { + private async readJson(file: string): Promise { const fs = await import('node:fs/promises') let raw: string try { @@ -252,38 +339,52 @@ export class FileSystemAtomicIdempotencyStore implements AtomicIdempotencyStore if (isNodeENOENT(err)) return null throw err } - if (!raw.trim()) return null - let parsed: unknown try { - parsed = JSON.parse(raw) + return JSON.parse(raw) as unknown } catch { - throw new Error('Invalid idempotency lock') + throw new Error(`Invalid idempotency JSON at ${file}`) } - if (!isLockRecord(parsed)) throw new Error('Invalid idempotency lock') - return parsed } - private async reclaimExpiredLock(file: string): Promise { + private async writeExclusive(file: string, value: object): Promise { const fs = await import('node:fs/promises') const path = await import('node:path') - const stale = path.join(this.rootDir, `${path.basename(file)}.stale-${randomUUID()}`) + const tmp = `${file}.tmp-${process.pid}-${randomUUID()}` + const handle = await fs.open(tmp, 'wx', 0o600) try { - await fs.rename(file, stale) - await fs.unlink(stale) - } catch (err) { - if (!isNodeENOENT(err)) throw err + await handle.writeFile(JSON.stringify(value), 'utf8') + await handle.sync() + await handle.close() + try { + await fs.link(tmp, file) + await syncDirectory(path.dirname(file)) + return true + } catch (err) { + if (isNodeEEXIST(err)) return false + throw err + } + } finally { + await handle.close().catch(() => undefined) + await removeIfPresent(tmp) } } - private async releaseLock(file: string, token: string): Promise { - const current = await this.readLock(file) - if (current?.token !== token) return + private async rootPath(keyHash: string): Promise { + const path = await import('node:path') + return path.join(this.rootDir, `${this.filePrefix}${keyHash}.json`) + } + + private async successorPath(keyHash: string, predecessorToken: string): Promise { + const path = await import('node:path') + return path.join( + this.rootDir, + `${this.filePrefix}${keyHash}.next-${hashToken(predecessorToken)}.json`, + ) + } + + private async ensureRoot(): Promise { const fs = await import('node:fs/promises') - try { - await fs.unlink(file) - } catch (err) { - if (!isNodeENOENT(err)) throw err - } + await fs.mkdir(this.rootDir, { recursive: true, mode: 0o700 }) } } @@ -345,23 +446,30 @@ function hashKey(key: string): string { return createHash('sha256').update(key, 'utf8').digest('hex') } -function isClaimRecord(value: unknown): value is ClaimRecord { - if (!value || typeof value !== 'object') return false - const candidate = value as Partial - return candidate.version === 1 - && typeof candidate.keyHash === 'string' - && /^[a-f0-9]{64}$/.test(candidate.keyHash) - && typeof candidate.token === 'string' - && candidate.token.length > 0 - && Number.isFinite(candidate.expiresAt) +function hashToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex') } -function isLockRecord(value: unknown): value is LockRecord { +function isClaimRecord(value: unknown): value is StoredClaimRecord { if (!value || typeof value !== 'object') return false - const candidate = value as Partial - return typeof candidate.token === 'string' + const candidate = value as Record + const common = typeof candidate.keyHash === 'string' + && /^[a-f0-9]{64}$/.test(candidate.keyHash) + && typeof candidate.token === 'string' && candidate.token.length > 0 && Number.isFinite(candidate.expiresAt) + if (!common) return false + if (candidate.version === 1) return true + if (candidate.version !== 3) return false + if (candidate.kind !== 'claim' && candidate.kind !== 'completed' && candidate.kind !== 'available') return false + if ( + candidate.predecessorToken !== null + && (typeof candidate.predecessorToken !== 'string' || !candidate.predecessorToken.length) + ) return false + if (candidate.kind === 'claim' || candidate.kind === 'completed') { + return typeof candidate.ownerToken === 'string' && candidate.ownerToken.length > 0 + } + return candidate.ownerToken === null || (typeof candidate.ownerToken === 'string' && candidate.ownerToken.length > 0) } function isNodeENOENT(err: unknown): boolean { @@ -381,6 +489,12 @@ async function removeIfPresent(file: string): Promise { } } -async function delay(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)) +async function syncDirectory(directory: string): Promise { + const fs = await import('node:fs/promises') + const handle = await fs.open(directory, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } } diff --git a/src/middleware/index.ts b/src/middleware/index.ts index e6ea689..a126c06 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -58,10 +58,14 @@ export interface TangleAuthContext { expiresAt?: number /** Stable credential id (key id for API keys, session id for sessions). */ credentialId?: string + /** Stable Platform API-key row id. Present only for API-key auth. */ + apiKeyId?: string /** Owner-shape on the platform side. */ ownerType: 'user' | 'team' /** Product the credential is scoped to, when known. */ product?: string + /** Immutable Platform service that provisioned this key. */ + provisionedByService?: string /** Platform proof that this identity passed email policy. */ emailVerified?: boolean /** Real email returned by Platform when available. */ @@ -164,7 +168,9 @@ export async function requireTangleAuth( ...(result.servicePrincipal !== undefined ? { servicePrincipal: result.servicePrincipal } : {}), ...(result.expiresAt !== undefined ? { expiresAt: result.expiresAt } : {}), ...(result.credentialId ? { credentialId: result.credentialId } : {}), + ...(result.apiKeyId ? { apiKeyId: result.apiKeyId } : {}), ...(result.product ? { product: result.product } : {}), + ...(result.provisionedByService ? { provisionedByService: result.provisionedByService } : {}), }, } } diff --git a/src/stripe/errors.ts b/src/stripe/errors.ts index b91ae2e..faba813 100644 --- a/src/stripe/errors.ts +++ b/src/stripe/errors.ts @@ -36,6 +36,8 @@ export type BillingErrorCode = | 'trial_expired' | 'free_tier_exhausted' | 'platform_evidence_required' + | 'platform_evidence_expired' + | 'platform_evidence_replayed' | 'platform_evidence_subject_mismatch' | 'email_verification_required' | 'real_email_required' @@ -102,6 +104,8 @@ function mapToIntegrationCode(code: BillingErrorCode): IntegrationRuntimeError[' case 'trial_expired': case 'free_tier_exhausted': case 'platform_evidence_required': + case 'platform_evidence_expired': + case 'platform_evidence_replayed': case 'platform_evidence_subject_mismatch': case 'email_verification_required': case 'real_email_required': @@ -126,6 +130,8 @@ function statusForBillingCode(code: BillingErrorCode): number { case 'trial_expired': case 'free_tier_exhausted': case 'platform_evidence_required': + case 'platform_evidence_expired': + case 'platform_evidence_replayed': case 'platform_evidence_subject_mismatch': case 'email_verification_required': case 'real_email_required': @@ -154,6 +160,8 @@ function defaultUserAction(code: BillingErrorCode): IntegrationUserAction | unde case 'free_tier_exhausted': return { type: 'change_request', label: 'Upgrade for more usage' } case 'platform_evidence_required': + case 'platform_evidence_expired': + case 'platform_evidence_replayed': case 'platform_evidence_subject_mismatch': case 'email_verification_required': case 'real_email_required': diff --git a/src/stripe/subscription-state.ts b/src/stripe/subscription-state.ts index 2a248fe..acb9aed 100644 --- a/src/stripe/subscription-state.ts +++ b/src/stripe/subscription-state.ts @@ -39,7 +39,6 @@ */ import { BillingError } from './errors.js' -import { FileSystemAtomicIdempotencyStore } from '../idempotency.js' export type SubscriptionState = | 'incomplete' @@ -96,6 +95,14 @@ export interface SubscriptionRecord { /** Last event id we processed for this subscription — defends against * Stripe re-delivering the same event and us racing the dedupe store. */ lastEventId: string | null + /** Stripe `event.created` for the newest applied subscription event. */ + lastEventCreatedAt: number | null + /** State before the newest applied event. This lets a failed durable + * listener retry the exact typed update after the state write succeeded. */ + lastEventPreviousState?: SubscriptionState | null + /** Event whose state is durable but whose listener has not completed. + * A different subscription event cannot advance until this is cleared. */ + pendingEventId?: string | null /** Wall-clock ms of last successful write. */ updatedAt: number } @@ -140,7 +147,12 @@ export function isValidTransition(from: SubscriptionState, to: SubscriptionState export function applyTransition( current: SubscriptionRecord, next: Partial & { state: SubscriptionState }, - options: { eventId?: string; now?: () => number } = {}, + options: { + eventId?: string + eventCreatedAt?: number + pendingEventId?: string | null + now?: () => number + } = {}, ): SubscriptionRecord { if (!isValidTransition(current.state, next.state)) { throw new BillingError({ @@ -160,6 +172,11 @@ export function applyTransition( ...next, version: current.version + 1, lastEventId: options.eventId ?? current.lastEventId, + lastEventCreatedAt: options.eventCreatedAt ?? current.lastEventCreatedAt, + lastEventPreviousState: options.eventId ? current.state : (current.lastEventPreviousState ?? null), + pendingEventId: options.pendingEventId !== undefined + ? options.pendingEventId + : (current.pendingEventId ?? null), updatedAt: now, } } @@ -231,86 +248,144 @@ export class InMemorySubscriptionStore implements SubscriptionStore { } /* ---------------------------------------------------------------------- */ -/* persistence adapter: filesystem (JSONL) */ +/* persistence adapter: filesystem */ /* ---------------------------------------------------------------------- */ /** * File-per-workspace JSON store. One file per workspace under - * `/.json`. Cheap, durable, debuggable — adequate - * for self-hosted product agents. CAS combines the version check with the - * shared per-workspace lock from `FileSystemAtomicIdempotencyStore`. + * `/.versions/.json`. Each version is written + * completely and then hard-linked to its final path. Two processes racing the + * same expected version therefore contend on one atomic filesystem operation. * - * Why per-file and not one JSONL: subscriptions are - * accessed by workspaceId 99% of the time, scanning a JSONL on every - * request burns I/O. The file-per-workspace pattern keeps reads O(1). + * Why per-workspace versions and not one JSONL: subscriptions are accessed by + * workspace id. Reads inspect only that workspace's immutable versions. * * The store does NOT use `fs.watch` — webhooks are the only writer in * production, and webhooks always go through `applyTransition()` → * `saveIfVersion()`, so the CAS catches the race. */ export class FileSystemSubscriptionStore implements SubscriptionStore { - private readonly writeLock: FileSystemAtomicIdempotencyStore - constructor(private readonly rootDir: string) { - this.writeLock = new FileSystemAtomicIdempotencyStore(rootDir, { namespace: 'subscription-lock' }) + if (!rootDir.trim()) throw new Error('FileSystemSubscriptionStore requires a root directory') } async load(workspaceId: string): Promise { const fs = await import('node:fs/promises') const path = await import('node:path') - const file = path.join(this.rootDir, this.fileName(workspaceId)) + const versionDir = await this.versionDir(workspaceId) + let versionFiles: string[] = [] try { - const raw = await fs.readFile(file, 'utf-8') - return JSON.parse(raw) as SubscriptionRecord + versionFiles = (await fs.readdir(versionDir)) + .filter((file) => /^\d+\.json$/.test(file)) + .sort((left, right) => Number.parseInt(right, 10) - Number.parseInt(left, 10)) } catch (err) { - if (isNodeENOENT(err)) return null - throw err + if (!isNodeENOENT(err)) throw err + } + + const candidates: SubscriptionRecord[] = [] + if (versionFiles[0]) { + candidates.push(await this.readRecord(path.join(versionDir, versionFiles[0]), workspaceId)) + } + const legacyFile = path.join(this.rootDir, this.fileName(workspaceId)) + try { + candidates.push(await this.readRecord(legacyFile, workspaceId)) + } catch (err) { + if (!isNodeENOENT(err)) throw err + } + if (candidates.length === 0) return null + candidates.sort((left, right) => right.version - left.version) + if ( + candidates[1] + && candidates[0]!.version === candidates[1].version + && !sameSubscriptionRecord(candidates[0]!, candidates[1]) + ) { + throw new Error(`Conflicting subscription version ${candidates[0]!.version} for ${workspaceId}`) } + return { ...candidates[0]! } } async save(record: SubscriptionRecord): Promise { - const lockKey = this.lockKey(record.workspaceId) - if (!(await this.writeLock.claim(lockKey, 30_000))) { - throw new Error(`Subscription write contention for ${record.workspaceId}`) + assertSubscriptionRecord(record) + const existing = await this.load(record.workspaceId) + if (existing) { + if (existing.version > record.version) { + throw new Error(`Stale subscription save for ${record.workspaceId}`) + } + if (existing.version === record.version) { + if (sameSubscriptionRecord(existing, record)) return + throw new Error(`Conflicting subscription version ${record.version} for ${record.workspaceId}`) + } + if (record.version !== existing.version + 1) { + throw new Error(`Subscription save must advance exactly one version for ${record.workspaceId}`) + } + } else if (record.version !== 0) { + throw new Error(`Initial subscription version must be 0 for ${record.workspaceId}`) } - try { - await this.writeRecord(record) - } finally { - await this.writeLock.release?.(lockKey) + if (!(await this.writeVersion(record))) { + const winner = await this.load(record.workspaceId) + if (winner && sameSubscriptionRecord(winner, record)) return + throw new Error(`Subscription write contention for ${record.workspaceId}`) } } async saveIfVersion(record: SubscriptionRecord, expectedVersion: number): Promise { - const lockKey = this.lockKey(record.workspaceId) - if (!(await this.writeLock.claim(lockKey, 30_000))) return false + assertSubscriptionRecord(record) + if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0) return false + const existing = await this.load(record.workspaceId) + if (existing && existing.version !== expectedVersion) return false + if (!existing && expectedVersion !== 0) return false + if (existing && record.version !== expectedVersion + 1) return false + if (!existing && record.version !== 0) return false + return this.writeVersion(record) + } + + private async readRecord(file: string, expectedWorkspaceId: string): Promise { + const fs = await import('node:fs/promises') + const raw = await fs.readFile(file, 'utf8') + let parsed: unknown try { - const existing = await this.load(record.workspaceId) - if (existing && existing.version !== expectedVersion) return false - if (!existing && expectedVersion !== 0) return false - await this.writeRecord(record) - return true - } finally { - await this.writeLock.release?.(lockKey) + parsed = JSON.parse(raw) + } catch { + throw new Error(`Invalid subscription JSON at ${file}`) } + const record = normalizeSubscriptionRecord(parsed) + if (record.workspaceId !== expectedWorkspaceId) { + throw new Error(`Subscription workspace mismatch at ${file}`) + } + return record } - private async writeRecord(record: SubscriptionRecord): Promise { + private async writeVersion(record: SubscriptionRecord): Promise { const fs = await import('node:fs/promises') const path = await import('node:path') - await fs.mkdir(this.rootDir, { recursive: true }) - const file = path.join(this.rootDir, this.fileName(record.workspaceId)) - const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` - await fs.writeFile(tmp, JSON.stringify(record), 'utf-8') + const versionDir = await this.versionDir(record.workspaceId) + await fs.mkdir(versionDir, { recursive: true, mode: 0o700 }) + const finalFile = path.join(versionDir, `${record.version}.json`) + const tmp = path.join(versionDir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`) + const handle = await fs.open(tmp, 'wx', 0o600) try { - await fs.rename(tmp, file) + await handle.writeFile(JSON.stringify(record), 'utf8') + await handle.sync() + await handle.close() + try { + await fs.link(tmp, finalFile) + await syncDirectory(versionDir) + return true + } catch (err) { + if (isNodeEEXIST(err)) return false + throw err + } } catch (err) { - await fs.unlink(tmp).catch(() => undefined) throw err + } finally { + await handle.close().catch(() => undefined) + await fs.unlink(tmp).catch(() => undefined) } } - private lockKey(workspaceId: string): string { - return `subscription:${workspaceId}` + private async versionDir(workspaceId: string): Promise { + const path = await import('node:path') + return path.join(this.rootDir, `${this.fileName(workspaceId)}.versions`) } /** Safe filename: workspaceId is restricted to a charset that maps 1:1 @@ -344,6 +419,9 @@ export function makeSubscriptionRecord(input: { currentPeriodEnd: number | null trialEnd?: number | null cancelAtPeriodEnd?: boolean + eventId?: string + eventCreatedAt?: number + pendingEventId?: string | null now?: () => number }): SubscriptionRecord { const now = (input.now ?? Date.now)() @@ -357,7 +435,79 @@ export function makeSubscriptionRecord(input: { trialEnd: input.trialEnd ?? null, cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? false, version: 0, - lastEventId: null, + lastEventId: input.eventId ?? null, + lastEventCreatedAt: input.eventCreatedAt ?? null, + lastEventPreviousState: null, + pendingEventId: input.pendingEventId ?? null, updatedAt: now, } } + +function normalizeSubscriptionRecord(value: unknown): SubscriptionRecord { + if (!value || typeof value !== 'object') throw new Error('Invalid subscription record') + const candidate = value as Partial + const record = { + ...candidate, + lastEventCreatedAt: candidate.lastEventCreatedAt ?? null, + lastEventPreviousState: candidate.lastEventPreviousState ?? null, + pendingEventId: candidate.pendingEventId ?? null, + } as SubscriptionRecord + assertSubscriptionRecord(record) + return record +} + +function assertSubscriptionRecord(record: SubscriptionRecord): void { + if (!record || typeof record !== 'object') throw new Error('Invalid subscription record') + if (!record.workspaceId?.trim()) throw new Error('Subscription workspaceId is required') + if (!record.customerId?.trim()) throw new Error('Subscription customerId is required') + if (!record.subscriptionId?.trim()) throw new Error('Subscription subscriptionId is required') + if (!SUBSCRIPTION_STATES.includes(record.state)) throw new Error('Invalid subscription state') + if (!Number.isSafeInteger(record.version) || record.version < 0) throw new Error('Invalid subscription version') + if (record.lastEventId !== null && typeof record.lastEventId !== 'string') throw new Error('Invalid lastEventId') + if ( + record.lastEventCreatedAt !== null + && (!Number.isSafeInteger(record.lastEventCreatedAt) || record.lastEventCreatedAt < 0) + ) throw new Error('Invalid lastEventCreatedAt') + if ( + record.lastEventPreviousState !== undefined + && record.lastEventPreviousState !== null + && !SUBSCRIPTION_STATES.includes(record.lastEventPreviousState) + ) throw new Error('Invalid lastEventPreviousState') + if ( + record.pendingEventId !== undefined + && record.pendingEventId !== null + && (typeof record.pendingEventId !== 'string' || !record.pendingEventId.trim()) + ) throw new Error('Invalid pendingEventId') + if (!Number.isFinite(record.updatedAt)) throw new Error('Invalid subscription updatedAt') +} + +function sameSubscriptionRecord(left: SubscriptionRecord, right: SubscriptionRecord): boolean { + return left.workspaceId === right.workspaceId + && left.customerId === right.customerId + && left.subscriptionId === right.subscriptionId + && left.state === right.state + && left.priceId === right.priceId + && left.currentPeriodEnd === right.currentPeriodEnd + && left.trialEnd === right.trialEnd + && left.cancelAtPeriodEnd === right.cancelAtPeriodEnd + && left.version === right.version + && left.lastEventId === right.lastEventId + && left.lastEventCreatedAt === right.lastEventCreatedAt + && (left.lastEventPreviousState ?? null) === (right.lastEventPreviousState ?? null) + && (left.pendingEventId ?? null) === (right.pendingEventId ?? null) + && left.updatedAt === right.updatedAt +} + +function isNodeEEXIST(err: unknown): boolean { + return !!err && typeof err === 'object' && (err as { code?: string }).code === 'EEXIST' +} + +async function syncDirectory(directory: string): Promise { + const fs = await import('node:fs/promises') + const handle = await fs.open(directory, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } +} diff --git a/src/stripe/webhooks.ts b/src/stripe/webhooks.ts index acfbdf9..6c08d00 100644 --- a/src/stripe/webhooks.ts +++ b/src/stripe/webhooks.ts @@ -20,12 +20,13 @@ * 1. Idempotency at two layers — the router de-dupes at the event id; * the dispatcher's `saveIfVersion` defends against the second * router instance (multi-region) racing the same event. The - * consumer's subscriber sees an event AT MOST ONCE per `eventId`. + * a finished claim is distinguishable from concurrent work. Listener + * failure releases the claim and reaches the HTTP layer as a failure. * * 2. Order-independence — Stripe doesn't guarantee delivery order. - * We process events whose `event.created` timestamp is older than - * the stored `updatedAt` only when the resulting state would be a - * valid transition; otherwise we drop with `dropped:'out_of_order'`. + * We persist the newest applied `event.created` timestamp and reject + * older subscription events. Stripe timestamps have one-second resolution, + * so equal timestamps require an authenticated current-state read. * * 3. Explicit unknown handling — events we don't have a handler for * are not dropped silently; we emit them as @@ -73,10 +74,10 @@ import { /** Subset of Stripe's `Subscription` object we read. Keep narrow — the * full object has 70+ fields; we only need the ones that map to our * `SubscriptionRecord`. New fields land here on demand. */ -interface StripeSubscriptionPayload { +export interface StripeSubscriptionSnapshot { id: string status: string - customer: string + customer: string | { id?: string } current_period_end?: number | null cancel_at_period_end?: boolean | null trial_end?: number | null @@ -91,7 +92,16 @@ interface StripeSubscriptionPayload { interface StripeInvoicePayload { id: string + /** Legacy Stripe versions expose the subscription at the top level. */ subscription?: string | null + /** Stripe 2025-03-31.basil and newer move subscription identity here. */ + parent?: { + type?: string + subscription_details?: { + subscription?: string | { id?: string } | null + metadata?: Record + } | null + } | null customer?: string status?: string /** Cents. */ @@ -195,9 +205,8 @@ export type StripeBillingEvent = reason: string } -/** Listener — the product agent wires this to whatever side-effect bus - * it owns (audit log, in-process emitter, durable queue). Throws are - * caught by `dispatch()` and surfaced through `onError`. */ +/** Listener — production consumers must durably enqueue by `eventId` before + * resolving. A throw releases the claim and reaches the HTTP boundary. */ export type StripeBillingListener = (event: StripeBillingEvent) => void | Promise export interface StripeEventIdempotencyStore extends AtomicIdempotencyStore {} @@ -220,11 +229,14 @@ export interface StripeBillingDispatcherOptions { subscriptionMetadata?: Record invoiceMetadata?: Record }): Promise | string | null + /** Retrieve the current subscription through an authenticated Stripe API + * client. Equal `event.created` timestamps fail until this callback returns + * an authoritative snapshot. */ + retrieveSubscription?(subscriptionId: string): Promise /** Single typed listener (most consumers want one — they route inside * it themselves). Compose multiple via `combineListeners(a, b)`. */ listener?: StripeBillingListener - /** Surface unexpected dispatcher errors (validation, store contention - * exhausted) without crashing the webhook handler. */ + /** Observe unexpected dispatcher errors before they are rethrown. */ onError?(err: unknown, context: { eventId: string; type: string }): void /** Override `Date.now()` for tests. */ now?(): number @@ -249,6 +261,7 @@ export interface StripeBillingDispatcherOptions { export class StripeBillingDispatcher { private readonly store: SubscriptionStore private readonly resolveWorkspaceId: NonNullable + private readonly retrieveSubscription?: StripeBillingDispatcherOptions['retrieveSubscription'] private readonly listener?: StripeBillingListener private readonly onError: NonNullable private readonly now: () => number @@ -259,6 +272,7 @@ export class StripeBillingDispatcher { constructor(opts: StripeBillingDispatcherOptions) { this.store = opts.store this.resolveWorkspaceId = opts.resolveWorkspaceId ?? defaultResolveWorkspaceId + this.retrieveSubscription = opts.retrieveSubscription this.listener = opts.listener this.onError = opts.onError ?? defaultOnError this.now = opts.now ?? Date.now @@ -268,6 +282,9 @@ export class StripeBillingDispatcher { store: opts.idempotency, runtime: opts.runtime, }) + if (isProductionRuntime(opts.runtime) && !opts.listener) { + throw new Error('StripeBillingDispatcher: production requires a durable billing listener') + } this.idempotencyTtlMs = opts.idempotencyTtlMs ?? 7 * 24 * 60 * 60 * 1000 } @@ -276,29 +293,37 @@ export class StripeBillingDispatcher { async dispatch(envelope: WebhookEnvelope): Promise { const evt = envelope.payload as StripeEvent | undefined if (!evt || typeof evt !== 'object' || typeof evt.id !== 'string' || typeof evt.type !== 'string') { - this.onError(new Error('Stripe envelope missing id or type'), { + const error = new Error('Stripe envelope missing id or type') + this.onError(error, { eventId: 'unknown', type: 'unknown', }) - return + throw error } - if (!(await this.idempotency.claim(evt.id, this.idempotencyTtlMs))) { + const claimStatus = await this.idempotency.claimStatus(evt.id, this.idempotencyTtlMs) + if (claimStatus === 'completed') { await this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) return } - try { - await this.handle(evt) - } catch (err) { - await this.idempotency.release?.(evt.id) - this.onError(err, { eventId: evt.id, type: evt.type }) - return + if (claimStatus === 'in_progress') { + const error = new BillingError({ + code: 'webhook_event_unknown', + message: `Stripe event ${evt.id} is already in progress`, + context: { eventId: evt.id }, + }) + throw error } - // Retain the durable replay claim, but clear process-local ownership so a - // later delivery can reclaim the key after the configured TTL. try { - await this.idempotency.complete?.(evt.id) + await this.handle(evt) + await this.idempotency.complete(evt.id) } catch (err) { + try { + await this.idempotency.release(evt.id) + } catch (releaseError) { + this.onError(releaseError, { eventId: evt.id, type: evt.type }) + } this.onError(err, { eventId: evt.id, type: evt.type }) + throw err } } @@ -329,7 +354,8 @@ export class StripeBillingDispatcher { /* --------------------- subscription event handlers ------------------- */ private async handleSubCreated(evt: StripeEvent): Promise { - const sub = evt.data.object as StripeSubscriptionPayload + const eventCreatedAt = requireEventCreatedAt(evt) + const sub = evt.data.object as StripeSubscriptionSnapshot const identity = subscriptionIdentity(sub) if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ @@ -342,8 +368,20 @@ export class StripeBillingDispatcher { if (existing && !matchesSubscription(existing, identity)) { return this.emitUnbound(evt, 'subscription identity does not match the workspace record') } + this.assertNoPendingSubscriptionEvent(existing, evt) if (existing && existing.lastEventId === evt.id) { - return this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) + return this.emitPersisted( + existing.state === 'trialing' + ? { kind: 'subscription.trial_ignored', eventId: evt.id, record: existing } + : { kind: 'subscription.created', eventId: evt.id, record: existing }, + workspaceId, + ) + } + if (existing && isOlderEvent(existing, eventCreatedAt)) { + return this.emitOlderEvent(evt, existing) + } + if (existing && isEqualTimestampEvent(existing, eventCreatedAt)) { + return this.reconcileEqualTimestamp(evt, workspaceId, identity) } // Create-only: if a record already exists with a non-incomplete state @@ -357,30 +395,47 @@ export class StripeBillingDispatcher { }) } - const record = makeSubscriptionRecord({ - workspaceId, - customerId: identity.customerId, - subscriptionId: identity.subscriptionId, - state: parseState(sub.status, evt.id), - priceId: extractPriceId(sub), - currentPeriodEnd: sub.current_period_end ?? null, - trialEnd: sub.trial_end ?? null, - cancelAtPeriodEnd: sub.cancel_at_period_end ?? false, - now: this.now, - }) - const stamped: SubscriptionRecord = { ...record, lastEventId: evt.id } + const nextState = parseState(sub.status, evt.id) + const record = existing + ? applyTransition( + existing, + { + state: nextState, + priceId: extractPriceId(sub), + currentPeriodEnd: sub.current_period_end ?? null, + trialEnd: sub.trial_end ?? null, + cancelAtPeriodEnd: sub.cancel_at_period_end ?? false, + }, + { eventId: evt.id, eventCreatedAt, pendingEventId: evt.id, now: this.now }, + ) + : makeSubscriptionRecord({ + workspaceId, + customerId: identity.customerId, + subscriptionId: identity.subscriptionId, + state: nextState, + priceId: extractPriceId(sub), + currentPeriodEnd: sub.current_period_end ?? null, + trialEnd: sub.trial_end ?? null, + cancelAtPeriodEnd: sub.cancel_at_period_end ?? false, + eventId: evt.id, + eventCreatedAt, + pendingEventId: evt.id, + now: this.now, + }) const expectedVersion = existing?.version ?? 0 - const written = await this.cas(stamped, expectedVersion) + const written = await this.cas(record, expectedVersion) if (!written) return this.emitUnbound(evt, 'subscription create lost a concurrent compare-and-set') - await this.emit( - stamped.state === 'trialing' - ? { kind: 'subscription.trial_ignored', eventId: evt.id, record: stamped } - : { kind: 'subscription.created', eventId: evt.id, record: stamped }, + await this.emitPersisted( + record.state === 'trialing' + ? { kind: 'subscription.trial_ignored', eventId: evt.id, record } + : { kind: 'subscription.created', eventId: evt.id, record }, + workspaceId, ) } private async handleSubUpdated(evt: StripeEvent): Promise { - const sub = evt.data.object as StripeSubscriptionPayload + const eventCreatedAt = requireEventCreatedAt(evt) + const sub = evt.data.object as StripeSubscriptionSnapshot const identity = subscriptionIdentity(sub) if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ @@ -391,7 +446,18 @@ export class StripeBillingDispatcher { const nextState = parseState(sub.status, evt.id) await this.advance(evt, workspaceId, (current) => { - if (current.lastEventId === evt.id) return 'replay' + if (current.lastEventId === evt.id) { + return { + emitOnly: nextState === 'trialing' + ? { kind: 'subscription.trial_ignored', eventId: evt.id, record: current } + : { + kind: 'subscription.updated', + eventId: evt.id, + previousState: current.lastEventPreviousState ?? current.state, + record: current, + }, + } + } if (!isValidTransition(current.state, nextState)) return 'out_of_order' const next = applyTransition( current, @@ -402,7 +468,7 @@ export class StripeBillingDispatcher { trialEnd: sub.trial_end ?? current.trialEnd, cancelAtPeriodEnd: sub.cancel_at_period_end ?? current.cancelAtPeriodEnd, }, - { eventId: evt.id, now: this.now }, + { eventId: evt.id, eventCreatedAt, pendingEventId: evt.id, now: this.now }, ) return { next, @@ -415,7 +481,8 @@ export class StripeBillingDispatcher { } private async handleSubDeleted(evt: StripeEvent): Promise { - const sub = evt.data.object as StripeSubscriptionPayload + const eventCreatedAt = requireEventCreatedAt(evt) + const sub = evt.data.object as StripeSubscriptionSnapshot const identity = subscriptionIdentity(sub) if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ @@ -425,12 +492,14 @@ export class StripeBillingDispatcher { if (!workspaceId) return this.emitNoWorkspace(evt) await this.advance(evt, workspaceId, (current) => { - if (current.lastEventId === evt.id) return 'replay' + if (current.lastEventId === evt.id) { + return { emitOnly: { kind: 'subscription.deleted', eventId: evt.id, record: current } } + } if (current.state === 'canceled') return 'replay' // terminal — fine to no-op const next = applyTransition( current, { state: 'canceled', priceId: null, currentPeriodEnd: sub.current_period_end ?? current.currentPeriodEnd }, - { eventId: evt.id, now: this.now }, + { eventId: evt.id, eventCreatedAt, pendingEventId: evt.id, now: this.now }, ) return { next, @@ -440,7 +509,8 @@ export class StripeBillingDispatcher { } private async handleTrialWillEnd(evt: StripeEvent): Promise { - const sub = evt.data.object as StripeSubscriptionPayload + const eventCreatedAt = requireEventCreatedAt(evt) + const sub = evt.data.object as StripeSubscriptionSnapshot const identity = subscriptionIdentity(sub) if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ @@ -449,34 +519,48 @@ export class StripeBillingDispatcher { }) if (!workspaceId) return this.emitNoWorkspace(evt) const current = await this.store.load(workspaceId) - if (!current) return this.emitNoWorkspace(evt) + if (!current) throw retryableStripeEvent(evt, 'subscription state is not available yet') if (!matchesSubscription(current, identity)) { return this.emitUnbound(evt, 'subscription identity does not match the workspace record') } + this.assertNoPendingSubscriptionEvent(current, evt) if (current.lastEventId === evt.id) { - return this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) + return this.emitPersisted({ + kind: 'subscription.trial_will_end', + eventId: evt.id, + record: current, + trialEndsAt: current.trialEnd ?? 0, + }, workspaceId) + } + if (isOlderEvent(current, eventCreatedAt)) return this.emitOlderEvent(evt, current) + if (isEqualTimestampEvent(current, eventCreatedAt)) { + return this.reconcileEqualTimestamp(evt, workspaceId, identity) } // No state transition — trial_will_end is informational. Update // lastEventId so a replay is detected. const next: SubscriptionRecord = { ...current, lastEventId: evt.id, + lastEventCreatedAt: eventCreatedAt, + lastEventPreviousState: current.state, + pendingEventId: evt.id, trialEnd: sub.trial_end ?? current.trialEnd, version: current.version + 1, updatedAt: this.now(), } const written = await this.cas(next, current.version) if (!written) return this.emitUnbound(evt, 'trial update lost a concurrent compare-and-set') - await this.emit({ + await this.emitPersisted({ kind: 'subscription.trial_will_end', eventId: evt.id, record: next, trialEndsAt: sub.trial_end ?? next.trialEnd ?? 0, - }) + }, workspaceId) } private async handleSubLifecycle(evt: StripeEvent, target: SubscriptionState): Promise { - const sub = evt.data.object as StripeSubscriptionPayload + const eventCreatedAt = requireEventCreatedAt(evt) + const sub = evt.data.object as StripeSubscriptionSnapshot const identity = subscriptionIdentity(sub) if (!identity) return this.emitUnbound(evt, 'subscription event is missing customer or subscription id') const workspaceId = await this.resolveWorkspaceId({ @@ -486,9 +570,16 @@ export class StripeBillingDispatcher { if (!workspaceId) return this.emitNoWorkspace(evt) await this.advance(evt, workspaceId, (current) => { - if (current.lastEventId === evt.id) return 'replay' + if (current.lastEventId === evt.id) { + const kind = target === 'paused' ? 'subscription.paused' : 'subscription.resumed' + return { emitOnly: { kind, eventId: evt.id, record: current } } + } if (!isValidTransition(current.state, target)) return 'out_of_order' - const next = applyTransition(current, { state: target }, { eventId: evt.id, now: this.now }) + const next = applyTransition( + current, + { state: target }, + { eventId: evt.id, eventCreatedAt, pendingEventId: evt.id, now: this.now }, + ) const kind = target === 'paused' ? 'subscription.paused' : 'subscription.resumed' return { next, emit: { kind, eventId: evt.id, record: next } } }, identity) @@ -499,9 +590,11 @@ export class StripeBillingDispatcher { private async handleInvoicePaid(evt: StripeEvent): Promise { const inv = evt.data.object as StripeInvoicePayload if (!invoiceIdentity(inv)) return this.emitUnbound(evt, 'paid invoice is missing customer or invoice id') + const subscription = invoiceSubscription(inv) + if (!subscription) return this.emitUnbound(evt, 'paid invoice is missing a subscription id') const workspaceId = await this.resolveWorkspaceId({ customerId: inv.customer ?? '', - invoiceMetadata: inv.metadata, + invoiceMetadata: subscription.metadata ?? inv.metadata, }) const amountPaid = typeof inv.amount_paid === 'number' && Number.isFinite(inv.amount_paid) ? inv.amount_paid : 0 if (amountPaid <= 0) { @@ -515,8 +608,8 @@ export class StripeBillingDispatcher { } if (!workspaceId) return this.emitNoWorkspace(evt) const record = await this.store.load(workspaceId) - if (!record) return this.emitUnbound(evt, 'paid invoice has no subscription record') - if (record.customerId !== inv.customer || (inv.subscription && record.subscriptionId !== inv.subscription)) { + if (!record) throw retryableStripeEvent(evt, 'paid invoice subscription state is not available yet') + if (record.customerId !== inv.customer || record.subscriptionId !== subscription.subscriptionId) { return this.emitUnbound(evt, 'paid invoice identity does not match the workspace record') } await this.emit({ @@ -531,14 +624,16 @@ export class StripeBillingDispatcher { private async handleInvoicePaymentFailed(evt: StripeEvent): Promise { const inv = evt.data.object as StripeInvoicePayload if (!invoiceIdentity(inv)) return this.emitUnbound(evt, 'failed invoice is missing customer or invoice id') + const subscription = invoiceSubscription(inv) + if (!subscription) return this.emitUnbound(evt, 'failed invoice is missing a subscription id') const workspaceId = await this.resolveWorkspaceId({ customerId: inv.customer ?? '', - invoiceMetadata: inv.metadata, + invoiceMetadata: subscription.metadata ?? inv.metadata, }) if (!workspaceId) return this.emitNoWorkspace(evt) const record = await this.store.load(workspaceId) - if (!record) return this.emitUnbound(evt, 'failed invoice has no subscription record') - if (record.customerId !== inv.customer || (inv.subscription && record.subscriptionId !== inv.subscription)) { + if (!record) throw retryableStripeEvent(evt, 'failed invoice subscription state is not available yet') + if (record.customerId !== inv.customer || record.subscriptionId !== subscription.subscriptionId) { return this.emitUnbound(evt, 'failed invoice identity does not match the workspace record') } await this.emit({ @@ -560,15 +655,27 @@ export class StripeBillingDispatcher { private async advance( evt: StripeEvent, workspaceId: string, - transform: (current: SubscriptionRecord) => { next: SubscriptionRecord; emit: StripeBillingEvent } | 'replay' | 'out_of_order', - identity?: StripeSubscriptionIdentity, + transform: (current: SubscriptionRecord) => + | { next: SubscriptionRecord; emit: StripeBillingEvent } + | { emitOnly: StripeBillingEvent } + | 'replay' + | 'out_of_order', + identity: StripeSubscriptionIdentity, ): Promise { for (let attempt = 0; attempt < this.maxCasRetries; attempt++) { const current = await this.store.load(workspaceId) - if (!current) return this.emitNoWorkspace(evt) + if (!current) throw retryableStripeEvent(evt, 'subscription state is not available yet') if (identity && !matchesSubscription(current, identity)) { return this.emitUnbound(evt, 'subscription identity does not match the workspace record') } + this.assertNoPendingSubscriptionEvent(current, evt) + const eventCreatedAt = requireEventCreatedAt(evt) + if (current.lastEventId !== evt.id && isOlderEvent(current, eventCreatedAt)) { + return this.emitOlderEvent(evt, current) + } + if (current.lastEventId !== evt.id && isEqualTimestampEvent(current, eventCreatedAt)) { + return this.reconcileEqualTimestamp(evt, workspaceId, identity) + } const result = transform(current) if (result === 'replay') { return this.emit({ kind: 'event_replay', eventId: evt.id, type: evt.type }) @@ -581,8 +688,9 @@ export class StripeBillingDispatcher { reason: `current=${current.state}`, }) } + if ('emitOnly' in result) return this.emitPersisted(result.emitOnly, workspaceId) const written = await this.store.saveIfVersion(result.next, current.version) - if (written) return this.emit(result.emit) + if (written) return this.emitPersisted(result.emit, workspaceId) } throw new BillingError({ code: 'webhook_event_unknown', @@ -599,14 +707,106 @@ export class StripeBillingDispatcher { private async emit(event: StripeBillingEvent): Promise { if (!this.listener) return - try { - await this.listener(event) - } catch (err) { - this.onError(err, { - eventId: 'eventId' in event ? event.eventId : 'unknown', - type: event.kind, - }) + await this.listener(event) + } + + private async emitPersisted(event: StripeBillingEvent, workspaceId: string): Promise { + await this.emit(event) + await this.clearPendingSubscriptionEvent(workspaceId, event.eventId) + } + + private assertNoPendingSubscriptionEvent( + record: SubscriptionRecord | null, + evt: StripeEvent, + ): void { + if (!record?.pendingEventId || record.pendingEventId === evt.id) return + throw new BillingError({ + code: 'webhook_event_unknown', + message: `Subscription event ${record.pendingEventId} still needs durable delivery`, + context: { workspaceId: record.workspaceId, eventId: evt.id }, + }) + } + + private async clearPendingSubscriptionEvent(workspaceId: string, eventId: string): Promise { + for (let attempt = 0; attempt < this.maxCasRetries; attempt++) { + const current = await this.store.load(workspaceId) + if (!current) throw new Error(`Subscription record ${workspaceId} disappeared after delivery`) + if (current.pendingEventId === null || current.pendingEventId === undefined) return + if (current.pendingEventId !== eventId) { + throw new Error(`Subscription pending event changed from ${eventId} to ${current.pendingEventId}`) + } + const cleared: SubscriptionRecord = { + ...current, + pendingEventId: null, + version: current.version + 1, + updatedAt: this.now(), + } + if (await this.store.saveIfVersion(cleared, current.version)) return } + throw new BillingError({ + code: 'webhook_event_unknown', + message: `Pending event clear exhausted after ${this.maxCasRetries} attempts`, + context: { workspaceId, eventId }, + }) + } + + private async reconcileEqualTimestamp( + evt: StripeEvent, + workspaceId: string, + expectedIdentity: StripeSubscriptionIdentity, + ): Promise { + if (!this.retrieveSubscription) { + throw retryableStripeEvent(evt, 'equal event.created timestamps require Stripe reconciliation') + } + const snapshot = await this.retrieveSubscription(expectedIdentity.subscriptionId) + const canonicalIdentity = subscriptionIdentity(snapshot) + if ( + !canonicalIdentity + || canonicalIdentity.customerId !== expectedIdentity.customerId + || canonicalIdentity.subscriptionId !== expectedIdentity.subscriptionId + ) { + throw retryableStripeEvent(evt, 'Stripe reconciliation returned a mismatched subscription') + } + const canonicalState = parseState(snapshot.status, evt.id) + const eventCreatedAt = requireEventCreatedAt(evt) + + for (let attempt = 0; attempt < this.maxCasRetries; attempt++) { + const current = await this.store.load(workspaceId) + if (!current) throw retryableStripeEvent(evt, 'subscription state is not available yet') + if (!matchesSubscription(current, expectedIdentity)) { + return this.emitUnbound(evt, 'subscription identity does not match the workspace record') + } + this.assertNoPendingSubscriptionEvent(current, evt) + if (current.lastEventId === evt.id) { + return this.emitPersisted(canonicalBillingEvent(evt, current, current), workspaceId) + } + if (isOlderEvent(current, eventCreatedAt)) return this.emitOlderEvent(evt, current) + if (!isEqualTimestampEvent(current, eventCreatedAt)) { + throw retryableStripeEvent(evt, 'subscription state changed during Stripe reconciliation') + } + const next: SubscriptionRecord = { + ...current, + state: canonicalState, + priceId: canonicalState === 'canceled' ? null : extractPriceId(snapshot), + currentPeriodEnd: snapshot.current_period_end ?? null, + trialEnd: snapshot.trial_end ?? null, + cancelAtPeriodEnd: snapshot.cancel_at_period_end ?? false, + version: current.version + 1, + lastEventId: evt.id, + lastEventCreatedAt: eventCreatedAt, + lastEventPreviousState: current.state, + pendingEventId: evt.id, + updatedAt: this.now(), + } + if (await this.store.saveIfVersion(next, current.version)) { + return this.emitPersisted(canonicalBillingEvent(evt, current, next), workspaceId) + } + } + throw new BillingError({ + code: 'webhook_event_unknown', + message: `Stripe reconciliation contention exhausted after ${this.maxCasRetries} attempts`, + context: { workspaceId, eventId: evt.id }, + }) } private emitNoWorkspace(evt: StripeEvent): Promise { @@ -626,6 +826,15 @@ export class StripeBillingDispatcher { reason, }) } + + private emitOlderEvent(evt: StripeEvent, current: SubscriptionRecord): Promise { + return this.emit({ + kind: 'event_dropped_out_of_order', + eventId: evt.id, + type: evt.type, + reason: `event.created=${evt.created} is older than stored=${current.lastEventCreatedAt}`, + }) + } } /* ---------------------------------------------------------------------- */ @@ -649,9 +858,10 @@ function defaultResolveWorkspaceId(input: { subscriptionMetadata?: Record } | null { + if (invoice.parent !== undefined && invoice.parent !== null) { + if (invoice.parent.type !== 'subscription_details') return null + const details = invoice.parent.subscription_details + const subscriptionId = readExpandableId(details?.subscription) + if (!subscriptionId) return null + return { + subscriptionId, + ...(details?.metadata ? { metadata: details.metadata } : {}), + } + } + const subscriptionId = readExpandableId(invoice.subscription) + return subscriptionId ? { subscriptionId } : null +} + +function readExpandableId(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) return value + if (!value || typeof value !== 'object') return null + const id = (value as { id?: unknown }).id + return typeof id === 'string' && id.trim() ? id : null +} + function matchesSubscription(record: SubscriptionRecord, identity: StripeSubscriptionIdentity): boolean { return record.customerId === identity.customerId && record.subscriptionId === identity.subscriptionId } +function requireEventCreatedAt(evt: StripeEvent): number { + if (!Number.isSafeInteger(evt.created) || (evt.created as number) < 0) { + throw new BillingError({ + code: 'webhook_event_unknown', + message: 'Stripe subscription event is missing a valid event.created timestamp', + context: { eventId: evt.id }, + }) + } + return evt.created as number +} + +function isOlderEvent(record: SubscriptionRecord, eventCreatedAt: number): boolean { + return record.lastEventCreatedAt !== null && eventCreatedAt < record.lastEventCreatedAt +} + +function isEqualTimestampEvent(record: SubscriptionRecord, eventCreatedAt: number): boolean { + return record.lastEventCreatedAt !== null && eventCreatedAt === record.lastEventCreatedAt +} + +function retryableStripeEvent(evt: StripeEvent, message: string): BillingError { + return new BillingError({ + code: 'webhook_event_unknown', + message, + context: { eventId: evt.id }, + }) +} + +function canonicalBillingEvent( + evt: StripeEvent, + previous: SubscriptionRecord, + current: SubscriptionRecord, +): StripeBillingEvent { + if (current.state === 'canceled') { + return { kind: 'subscription.deleted', eventId: evt.id, record: current } + } + if (current.state === 'trialing') { + return { kind: 'subscription.trial_ignored', eventId: evt.id, record: current } + } + return { + kind: 'subscription.updated', + eventId: evt.id, + previousState: previous.state, + record: current, + } +} + +function isProductionRuntime(runtime: IdempotencyRuntime | undefined): boolean { + if (runtime) return runtime === 'production' + if (typeof process !== 'undefined' && process.env.VITEST) return false + const nodeEnv = typeof process !== 'undefined' ? process.env.NODE_ENV : undefined + return nodeEnv !== 'test' && nodeEnv !== 'development' +} + function defaultOnError(err: unknown, context: { eventId: string; type: string }): void { // eslint-disable-next-line no-console console.error('[StripeBillingDispatcher]', context, err) @@ -695,9 +982,9 @@ function canApplyFreshCreate(state: SubscriptionState): boolean { // A 'created' event on a record that already advanced past // incomplete means we've already processed the lifecycle and a // retried-late 'created' should be dropped. - return state === 'incomplete' || state === 'incomplete_expired' + return state === 'incomplete' } -function extractPriceId(sub: StripeSubscriptionPayload): string | null { +function extractPriceId(sub: StripeSubscriptionSnapshot): string | null { return sub.items?.data?.[0]?.price?.id ?? null } diff --git a/src/webhooks/router.ts b/src/webhooks/router.ts index 91b0971..015ca93 100644 --- a/src/webhooks/router.ts +++ b/src/webhooks/router.ts @@ -10,10 +10,9 @@ * Failure → 401 fast, no downstream work. * 3. Calls the provider's `parse(rawBody, headers)` to extract zero or * more normalized events. - * 4. Enqueues each event for async processing via the consumer-supplied - * `deliver(event)` callback (best-effort fire-and-forget — the - * router does NOT block the HTTP response on the consumer's work). - * 5. Returns 200 fast with `{received: events.length}`. + * 4. Awaits the consumer-supplied `deliver(event)` callback. + * 5. Returns 2xx only after every accepted event finishes durably. A failed + * or concurrently active delivery returns 503 so the provider retries. * * Replay protection: providers that sign timestamps (Stripe, Slack) * already reject stale signatures inside `verifySignature`. For providers @@ -30,7 +29,8 @@ * Stability: `@stable` — additions to `WebhookEnvelope` must be * additive; the router's HTTP contract (paths, status codes) is frozen * at 200 (ok), 400 (bad request), 401 (bad signature), 404 (unknown - * provider), 405 (provider has no inbound surface). + * provider), 405 (provider has no inbound surface), and 503 (delivery + * failed or another worker still owns the delivery). */ import { createHash } from 'node:crypto' @@ -147,9 +147,8 @@ export class FileSystemWebhookIdempotencyStore extends FileSystemAtomicIdempoten export interface WebhookRouterOptions { /** Provider registry. Pass any number of providers; routing is by id. */ providers: WebhookProvider[] - /** Async callback invoked with every accepted event. Fire-and-forget - * from the router's perspective — the HTTP response is sent before - * this resolves. Throws are caught and reported via `onError`. */ + /** Async callback invoked with every accepted event. The callback must + * finish its durable enqueue or processing before it resolves. */ deliver(event: WebhookEnvelope): Promise | void /** Resolve the signing secret for a provider id at request time. The * router never holds secrets — the consumer's vault resolves them. */ @@ -236,25 +235,34 @@ export class WebhookRouter { } const accepted: Array<{ event: WebhookEnvelope; key: string }> = [] + let inProgress = 0 try { for (const [index, event] of events.entries()) { const key = eventKey(provider.id, event, index, request.rawBody) - if (!(await this.idempotency.claim(key, this.idempotencyTtlMs))) continue - accepted.push({ event, key }) + const status = await this.idempotency.claimStatus(key, this.idempotencyTtlMs) + if (status === 'acquired') accepted.push({ event, key }) + if (status === 'in_progress') inProgress++ } } catch (err) { // Do not strand earlier claims when a later claim detects an unavailable // or corrupt shared store. The request remains failed closed. - await Promise.allSettled(accepted.map(({ key }) => this.idempotency.release?.(key))) + await Promise.allSettled(accepted.map(({ key }) => this.idempotency.release(key))) throw err } - // Deliver async — do NOT block the HTTP response. Errors land in - // `onError`; the provider already got its 200 by then so it will - // not retry. - queueMicrotask(() => { - void this.deliverEach(accepted) - }) + const delivery = await this.deliverEach(accepted) + if (delivery.failed > 0 || inProgress > 0) { + return { + status: 503, + body: { + error: delivery.failed > 0 ? 'delivery_failed' : 'delivery_in_progress', + received: delivery.succeeded, + failed: delivery.failed, + inProgress, + total: events.length, + }, + } + } if (provider.successResponse) { return { @@ -266,32 +274,54 @@ export class WebhookRouter { return { status: 200, body: { received: accepted.length, total: events.length } } } - private async deliverEach(events: Array<{ event: WebhookEnvelope; key: string }>): Promise { + private async deliverEach( + events: Array<{ event: WebhookEnvelope; key: string }>, + ): Promise<{ succeeded: number; failed: number }> { + let succeeded = 0 + let failed = 0 for (const { event, key } of events) { try { await this.deliver(event) } catch (err) { - await this.idempotency.release?.(key) + try { + await this.idempotency.release(key) + } catch (releaseError) { + this.onError(releaseError, { + provider: event.provider, + eventType: event.eventType, + providerEventId: event.providerEventId, + }) + } this.onError(err, { provider: event.provider, eventType: event.eventType, providerEventId: event.providerEventId, }) + failed++ continue } - // Keep the durable claim for its TTL, but let this process reclaim the - // key after expiry. A long-running delivery still owns the key until it - // reaches this point or releases it on failure. try { - await this.idempotency.complete?.(key) + await this.idempotency.complete(key) + succeeded++ } catch (err) { + try { + await this.idempotency.release(key) + } catch (releaseError) { + this.onError(releaseError, { + provider: event.provider, + eventType: event.eventType, + providerEventId: event.providerEventId, + }) + } this.onError(err, { provider: event.provider, eventType: event.eventType, providerEventId: event.providerEventId, }) + failed++ } } + return { succeeded, failed } } } diff --git a/tests/billing-access-policy.test.ts b/tests/billing-access-policy.test.ts index db2af2b..c0cda2a 100644 --- a/tests/billing-access-policy.test.ts +++ b/tests/billing-access-policy.test.ts @@ -1,12 +1,32 @@ -import { describe, expect, it } from 'vitest' +import { generateKeyPair, SignJWT, type CryptoKey } from 'jose' +import { beforeAll, describe, expect, it, vi } from 'vitest' import { assertNoProductFreeTrial, decideBillingAccess, NO_PRODUCT_FREE_CREDITS_POLICY, - parseTrustedPlatformEvidence, + PLATFORM_FUNDING_REPLAY_RETENTION_MS, PRODUCT_FREE_CREDIT_SOURCES, + verifyTrustedPlatformEvidence, type PlatformAccessEvidencePayload, } from '../src/billing-access-policy' +import { InMemoryAtomicIdempotencyStore } from '../src/idempotency' + +const NOW_MS = Date.now() +const NOW_SECONDS = Math.floor(NOW_MS / 1000) +const AUDIENCE = 'legal-agent' + +let privateKey: CryptoKey +let publicKey: CryptoKey +let otherPrivateKey: CryptoKey +let evidenceSequence = 0 + +beforeAll(async () => { + const platformKeys = await generateKeyPair('ES256') + const otherKeys = await generateKeyPair('ES256') + privateKey = platformKeys.privateKey + publicKey = platformKeys.publicKey + otherPrivateKey = otherKeys.privateKey +}) function evidencePayload( overrides: Partial & { @@ -16,9 +36,6 @@ function evidencePayload( ): PlatformAccessEvidencePayload { return { policyVersion: 1, - issuer: 'id.tangle.tools', - evidenceId: 'evidence_1', - issuedAt: '2026-08-10T12:00:00.000Z', emailVerified: true, user: { id: 'user_1', email: 'person@company.com' }, principal: { kind: 'human' }, @@ -32,10 +49,42 @@ function evidencePayload( } } -function evidence(overrides: Parameters[0] = {}) { - const parsed = parseTrustedPlatformEvidence(evidencePayload(overrides), { expectedUserId: 'user_1' }) - if (!parsed) throw new Error('test fixture did not parse') - return parsed +async function signedEvidence(input: { + payload?: PlatformAccessEvidencePayload + issuer?: string + audience?: string + subject?: string + issuedAt?: number + notBefore?: number + expiresAt?: number + jwtId?: string + signingKey?: CryptoKey +} = {}): Promise { + return new SignJWT((input.payload ?? evidencePayload()) as Record) + .setProtectedHeader({ alg: 'ES256', kid: 'platform-2026-08' }) + .setIssuer(input.issuer ?? 'id.tangle.tools') + .setAudience(input.audience ?? AUDIENCE) + .setSubject(input.subject ?? 'user_1') + .setIssuedAt(input.issuedAt ?? NOW_SECONDS) + .setNotBefore(input.notBefore ?? NOW_SECONDS - 1) + .setExpirationTime(input.expiresAt ?? NOW_SECONDS + 300) + .setJti(input.jwtId ?? `evidence_${++evidenceSequence}`) + .sign(input.signingKey ?? privateKey) +} + +async function verifyEvidence( + token: string, + overrides: Partial[1]> = {}, +) { + return verifyTrustedPlatformEvidence(token, { + audience: AUDIENCE, + expectedUserId: 'user_1', + verificationKey: publicKey, + replayStore: new InMemoryAtomicIdempotencyStore(), + runtime: 'test', + now: () => NOW_MS, + ...overrides, + }) } describe('NO_PRODUCT_FREE_CREDITS_POLICY', () => { @@ -70,51 +119,305 @@ describe('NO_PRODUCT_FREE_CREDITS_POLICY', () => { }) }) -describe('Platform evidence', () => { +describe('signed Platform evidence', () => { it.each([ - ['paid_purchase', { kind: 'paid_purchase', id: 'purchase_1', amountUsd: 10, paidAt: '2026-08-10T11:59:00.000Z' }], - ['paid_subscription', { kind: 'paid_subscription', id: 'sub_evidence', subscriptionId: 'sub_1', status: 'active', amountUsd: 29 }], - ['byok', { kind: 'byok', id: 'byok_1', provider: 'openai', keyId: 'key_1' }], - ['named_service', { kind: 'named_service', id: 'service_1', serviceId: 'service:blueprint-agent', serviceName: 'blueprint-agent' }], - ['admin', { kind: 'admin', id: 'admin_1', adminId: 'admin_user_1' }], - ] as const)('allows trusted %s evidence', (_name, funding) => { - const parsed = evidence({ funding }) - expect(decideBillingAccess({ evidence: parsed, expectedUserId: 'user_1' })).toMatchObject({ + ['paid_purchase', evidencePayload()], + ['paid_subscription', evidencePayload({ + funding: { + kind: 'paid_subscription', + id: 'sub_evidence', + subscriptionId: 'sub_1', + status: 'active', + amountUsd: 29, + }, + })], + ['byok', evidencePayload({ + funding: { kind: 'byok', id: 'byok_1', provider: 'openai', keyId: 'key_1' }, + })], + ['named_service', evidencePayload({ + emailVerified: undefined, + user: { id: 'service-user' }, + principal: { kind: 'service_principal', id: 'service:blueprint-agent', name: 'blueprint-agent' }, + funding: { + kind: 'named_service', + id: 'service_1', + serviceId: 'service:blueprint-agent', + serviceName: 'blueprint-agent', + }, + })], + ['admin', evidencePayload({ + emailVerified: undefined, + user: { id: 'admin_user_1' }, + principal: { kind: 'admin', id: 'admin_user_1' }, + funding: { kind: 'admin', id: 'admin_1', adminId: 'admin_user_1' }, + })], + ] as const)('allows cryptographically verified %s evidence', async (basis, payload) => { + const subject = basis === 'named_service' ? 'service-user' : basis === 'admin' ? 'admin_user_1' : 'user_1' + const token = await signedEvidence({ payload, subject }) + const parsed = await verifyEvidence(token, { expectedUserId: subject }) + expect(parsed).not.toBeNull() + expect(decideBillingAccess({ evidence: parsed ?? undefined, expectedUserId: subject })).toMatchObject({ allowed: true, - basis: funding.kind, + basis, }) }) - it('rejects a lookalike object copied from a trusted result', () => { - const parsed = evidence() - const lookalike = structuredClone(parsed) - expect(decideBillingAccess({ evidence: lookalike })).toMatchObject({ + it('rejects unsigned JSON and a token signed by an untrusted key', async () => { + await expect(verifyEvidence(JSON.stringify(evidencePayload()))).resolves.toBeNull() + await expect(verifyEvidence(await signedEvidence({ signingKey: otherPrivateKey }))).resolves.toBeNull() + }) + + it('fails closed for wrong issuer, audience, subject, expiry, and future timestamps', async () => { + const tokens = [ + await signedEvidence({ issuer: 'attacker.example' }), + await signedEvidence({ audience: 'tax-agent' }), + await signedEvidence({ subject: 'user_2' }), + await signedEvidence({ expiresAt: NOW_SECONDS - 1 }), + await signedEvidence({ notBefore: NOW_SECONDS + 60 }), + await signedEvidence({ issuedAt: NOW_SECONDS + 60, notBefore: NOW_SECONDS - 1 }), + ] + for (const token of tokens) { + await expect(verifyEvidence(token)).resolves.toBeNull() + } + }) + + it('claims the signed funding record once and rejects token replay', async () => { + const replayStore = new InMemoryAtomicIdempotencyStore() + const token = await signedEvidence({ jwtId: 'one-time-evidence' }) + const options = { replayStore } + await expect(verifyEvidence(token, options)).resolves.not.toBeNull() + await expect(verifyEvidence(token, options)).resolves.toBeNull() + }) + + it('rejects a previously verified object after its signed expiry', async () => { + const parsed = await verifyEvidence(await signedEvidence({ expiresAt: NOW_SECONDS + 1 })) + if (!parsed) throw new Error('test fixture did not verify') + const now = vi.spyOn(Date, 'now').mockReturnValue(NOW_MS + 2_000) + try { + expect(decideBillingAccess({ evidence: parsed })).toMatchObject({ + allowed: false, + code: 'platform_evidence_expired', + }) + } finally { + now.mockRestore() + } + }) + + it('consumes a verified paid purchase object once', async () => { + const parsed = await verifyEvidence(await signedEvidence()) + if (!parsed) throw new Error('test fixture did not verify') + expect(decideBillingAccess({ evidence: parsed })).toMatchObject({ + allowed: true, + basis: 'paid_purchase', + }) + expect(decideBillingAccess({ evidence: parsed })).toMatchObject({ allowed: false, - code: 'platform_evidence_required', + code: 'platform_evidence_replayed', }) }) - it('rejects a different owner', () => { - expect(decideBillingAccess({ evidence: evidence(), expectedUserId: 'user_2' })).toMatchObject({ - allowed: false, - code: 'platform_evidence_subject_mismatch', + it('allows one verifier to consume signed funding under simultaneous requests', async () => { + const replayStore = new InMemoryAtomicIdempotencyStore() + const token = await signedEvidence({ jwtId: 'simultaneous-evidence' }) + const results = await Promise.all(Array.from({ length: 100 }, () => verifyEvidence(token, { replayStore }))) + expect(results.filter((result) => result !== null)).toHaveLength(1) + expect(results.filter((result) => result === null)).toHaveLength(99) + }) + + it('rejects the same funding record when Platform re-signs it with a new jti', async () => { + const replayStore = new InMemoryAtomicIdempotencyStore() + const first = await signedEvidence({ jwtId: 'presentation_1' }) + const second = await signedEvidence({ jwtId: 'presentation_2' }) + await expect(verifyEvidence(first, { replayStore })).resolves.toMatchObject({ + evidenceId: 'purchase_1', + tokenId: 'presentation_1', + }) + await expect(verifyEvidence(second, { replayStore })).resolves.toBeNull() + }) + + it('rejects the same paid purchase across product audiences', async () => { + const replayStore = new InMemoryAtomicIdempotencyStore() + const legalToken = await signedEvidence({ audience: 'legal-agent', jwtId: 'legal-presentation' }) + const taxToken = await signedEvidence({ + audience: 'tax-agent', + jwtId: 'tax-presentation', }) + + await expect(verifyEvidence(legalToken, { audience: 'legal-agent', replayStore })) + .resolves.not.toBeNull() + await expect(verifyEvidence(taxToken, { audience: 'tax-agent', replayStore })) + .resolves.toBeNull() + }) + + it('rejects a replayed continuing presentation but accepts a refreshed token', async () => { + const replayStore = new InMemoryAtomicIdempotencyStore() + const payload = evidencePayload({ + funding: { + kind: 'paid_subscription', + id: 'subscription-funding-1', + subscriptionId: 'sub_1', + status: 'active', + amountUsd: 29, + }, + }) + const first = await signedEvidence({ payload, jwtId: 'subscription-presentation-1' }) + const refreshed = await signedEvidence({ payload, jwtId: 'subscription-presentation-2' }) + + await expect(verifyEvidence(first, { replayStore })).resolves.not.toBeNull() + await expect(verifyEvidence(first, { replayStore })).resolves.toBeNull() + await expect(verifyEvidence(refreshed, { replayStore })).resolves.not.toBeNull() + }) + + it('retains a consumed funding record beyond the short-lived JWT', async () => { + const inner = new InMemoryAtomicIdempotencyStore() + let claimedTtlMs = 0 + const replayStore = { + scope: 'shared' as const, + claim: (key: string, ttlMs: number) => { + claimedTtlMs = ttlMs + return inner.claim(key, ttlMs) + }, + claimStatus: (key: string, ttlMs: number) => inner.claimStatus(key, ttlMs), + release: (key: string) => inner.release(key), + complete: (key: string) => inner.complete(key), + } + await expect(verifyEvidence(await signedEvidence(), { replayStore })).resolves.not.toBeNull() + expect(claimedTtlMs).toBe(PLATFORM_FUNDING_REPLAY_RETENTION_MS) + expect(claimedTtlMs).toBeGreaterThan(300_000) }) - it('rejects unverified, placeholder, and zero-dollar evidence', () => { - expect(parseTrustedPlatformEvidence(evidencePayload({ emailVerified: false }))).toBeNull() - expect(parseTrustedPlatformEvidence(evidencePayload({ user: { id: 'user_1', email: 'test@example.com' } }))).toBeNull() - expect(parseTrustedPlatformEvidence(evidencePayload({ funding: { kind: 'paid_purchase', id: 'p', amountUsd: 0, paidAt: 'now' } }))).toBeNull() - expect(parseTrustedPlatformEvidence(evidencePayload({ issuedAt: 'not-a-date' }))).toBeNull() + it('retains a continuing presentation only through its signed lifetime', async () => { + const inner = new InMemoryAtomicIdempotencyStore() + let claimedTtlMs = 0 + const replayStore = { + scope: 'shared' as const, + claim: (key: string, ttlMs: number) => { + claimedTtlMs = ttlMs + return inner.claim(key, ttlMs) + }, + claimStatus: (key: string, ttlMs: number) => inner.claimStatus(key, ttlMs), + release: (key: string) => inner.release(key), + complete: (key: string) => inner.complete(key), + } + const token = await signedEvidence({ + payload: evidencePayload({ + funding: { + kind: 'paid_subscription', + id: 'subscription-funding-ttl', + subscriptionId: 'sub_1', + status: 'active', + amountUsd: 29, + }, + }), + jwtId: 'subscription-presentation-ttl', + }) + + await expect(verifyEvidence(token, { replayStore })).resolves.not.toBeNull() + expect(claimedTtlMs).toBe(305_000) + }) + + it('releases a claim when durable completion fails so verification can retry', async () => { + const inner = new InMemoryAtomicIdempotencyStore() + let failCompletion = true + let releases = 0 + const replayStore = { + scope: 'shared' as const, + claim: (key: string, ttlMs: number) => inner.claim(key, ttlMs), + claimStatus: (key: string, ttlMs: number) => inner.claimStatus(key, ttlMs), + release: (key: string) => { + releases++ + inner.release(key) + }, + complete: (key: string) => { + if (failCompletion) { + failCompletion = false + throw new Error('completion unavailable') + } + inner.complete(key) + }, + } + const token = await signedEvidence({ jwtId: 'completion-retry' }) + + await expect(verifyEvidence(token, { replayStore })).rejects.toThrow('completion unavailable') + await expect(verifyEvidence(token, { replayStore })).resolves.not.toBeNull() + expect(releases).toBe(1) }) - it('rejects missing or unknown principal types instead of defaulting to human', () => { - expect(parseTrustedPlatformEvidence(evidencePayload({ principal: undefined }))).toBeNull() - expect(parseTrustedPlatformEvidence(evidencePayload({ principal: { kind: 'unknown' } }))).toBeNull() + it('rejects a multi-audience token instead of treating a broad audience as exact', async () => { + const token = await new SignJWT(evidencePayload() as Record) + .setProtectedHeader({ alg: 'ES256', kid: 'platform-2026-08' }) + .setIssuer('id.tangle.tools') + .setAudience([AUDIENCE, 'tax-agent']) + .setSubject('user_1') + .setIssuedAt(NOW_SECONDS) + .setNotBefore(NOW_SECONDS - 1) + .setExpirationTime(NOW_SECONDS + 300) + .setJti('broad-audience') + .sign(privateKey) + await expect(verifyEvidence(token)).resolves.toBeNull() + }) + + it('rejects mismatched service and admin principals even when the token is signed', async () => { + const serviceToken = await signedEvidence({ + subject: 'service-user', + payload: evidencePayload({ + emailVerified: undefined, + user: { id: 'service-user' }, + principal: { kind: 'service_principal', id: 'service:blueprint-agent', name: 'blueprint-agent' }, + funding: { + kind: 'named_service', + id: 'service-funding', + serviceId: 'service:tax-agent', + serviceName: 'tax-agent', + }, + }), + }) + const adminToken = await signedEvidence({ + subject: 'admin_user_1', + payload: evidencePayload({ + emailVerified: undefined, + user: { id: 'admin_user_1' }, + principal: { kind: 'admin', id: 'admin_user_1' }, + funding: { kind: 'admin', id: 'admin-funding', adminId: 'admin_user_2' }, + }), + }) + await expect(verifyEvidence(serviceToken, { expectedUserId: 'service-user' })).resolves.toBeNull() + await expect(verifyEvidence(adminToken, { expectedUserId: 'admin_user_1' })).resolves.toBeNull() + }) + + it('rejects unverified, Platform-placeholder, and zero-dollar claims before consuming the jti', async () => { + const payloads = [ + evidencePayload({ emailVerified: false }), + evidencePayload({ + user: { id: 'user_1', email: '0x1111111111111111111111111111111111111111@tangle.tools' }, + }), + evidencePayload({ + funding: { kind: 'paid_purchase', id: 'p', amountUsd: 0, paidAt: '2026-08-10T11:00:00.000Z' }, + }), + ] + for (const payload of payloads) { + await expect(verifyEvidence(await signedEvidence({ payload }))).resolves.toBeNull() + } + }) + + it('accepts addresses accepted by the shared Platform email contract', async () => { + const payload = evidencePayload({ user: { id: 'user_1', email: 'test@example.com' } }) + await expect(verifyEvidence(await signedEvidence({ payload }))).resolves.not.toBeNull() + }) + + it('rejects a lookalike object copied from a verified result', async () => { + const parsed = await verifyEvidence(await signedEvidence()) + if (!parsed) throw new Error('test fixture did not verify') + const lookalike = structuredClone(parsed) + expect(decideBillingAccess({ evidence: lookalike })).toMatchObject({ + allowed: false, + code: 'platform_evidence_required', + }) }) - it('freezes parsed evidence so a caller cannot rewrite its funding basis', () => { - const parsed = evidence() + it('freezes verified evidence so a caller cannot rewrite its funding basis', async () => { + const parsed = await verifyEvidence(await signedEvidence()) + if (!parsed) throw new Error('test fixture did not verify') expect(Object.isFrozen(parsed)).toBe(true) expect(Object.isFrozen(parsed.principal)).toBe(true) expect(Object.isFrozen(parsed.funding)).toBe(true) diff --git a/tests/connect-flow.test.ts b/tests/connect-flow.test.ts index a8b2932..8791666 100644 --- a/tests/connect-flow.test.ts +++ b/tests/connect-flow.test.ts @@ -47,10 +47,11 @@ describe('finishConnectFlow', () => { return new Response( JSON.stringify({ apiKey: 'sk-tan-mintkey', + keyId: 'key_1', paidAccessPolicyVersion: 1, emailVerified: true, - user: { id: 'usr_1', email: 'a@company.com', emailVerified: true, name: 'A B', image: null }, - balance: 100, + user: { id: 'usr_1', email: 'a@company.com', name: 'A B', image: null }, + balance: 0, }), { status: 200, headers: { 'content-type': 'application/json' } }, ) @@ -60,18 +61,26 @@ describe('finishConnectFlow', () => { { code: 'c1', appId: 'evals' }, ) expect(capturedUrl).toBe('https://id.example.com/cross-site/exchange') - expect(capturedBody).toEqual({ code: 'c1', app: 'evals', requireVerifiedEmail: true }) + expect(capturedBody).toEqual({ code: 'c1', app: 'evals' }) expect(out).toEqual({ apiKey: 'sk-tan-mintkey', + keyId: 'key_1', user: { id: 'usr_1', email: 'a@company.com', emailVerified: true, name: 'A B', image: null }, - balance: 100, + balance: 0, paidAccessPolicyVersion: 1, }) }) - it('returns balance 0 when the platform omits it (defensive default)', async () => { + it('accepts the exact Platform exchange contract without a nested verification field', async () => { const fetchImpl = vi.fn(async () => - new Response(JSON.stringify({ apiKey: 'sk-tan-k', paidAccessPolicyVersion: 1, emailVerified: true, user: { id: 'u', email: 'person@company.com', emailVerified: true } }), { + new Response(JSON.stringify({ + apiKey: 'sk-tan-k', + keyId: 'key_1', + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'u', email: 'person@company.com' }, + balance: 0, + }), { status: 200, headers: { 'content-type': 'application/json' }, }), @@ -104,17 +113,19 @@ describe('finishConnectFlow', () => { ).rejects.toMatchObject({ status: 403 }) }) - it('rejects a contradictory exchange with only top-level email verification', async () => { + it('accepts top-level email verification because that is the shared Platform contract', async () => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ apiKey: 'sk-tan-k', + keyId: 'key_1', paidAccessPolicyVersion: 1, emailVerified: true, user: { id: 'u', email: 'person@company.com' }, + balance: 0, }), { status: 200 }), ) await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })) - .rejects.toMatchObject({ status: 403 }) + .resolves.toMatchObject({ apiKey: 'sk-tan-k', user: { id: 'u', emailVerified: true } }) }) it('throws Unreachable on 401 from /cross-site/exchange (replay / expired code)', async () => { @@ -124,25 +135,47 @@ describe('finishConnectFlow', () => { ).rejects.toBeInstanceOf(TangleIdentityUnreachableError) }) - it('throws Unreachable on malformed response (missing apiKey)', async () => { + it('accepts a secret-free replay and returns the stable key id', async () => { const fetchImpl = vi.fn(async () => - new Response(JSON.stringify({ user: { id: 'u' } }), { + new Response(JSON.stringify({ + keyId: 'key_replay', + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'u', email: 'person@company.com' }, + balance: 0, + }), { status: 200, headers: { 'content-type': 'application/json' }, }), ) - await expect( - finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' }), - ).rejects.toBeInstanceOf(TangleIdentityUnreachableError) + await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })).resolves.toEqual({ + keyId: 'key_replay', + user: { id: 'u', email: 'person@company.com', emailVerified: true }, + balance: 0, + paidAccessPolicyVersion: 1, + }) + }) + + it('throws Unreachable on malformed response missing the stable key id', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + apiKey: 'sk-tan-k', + paidAccessPolicyVersion: 1, + emailVerified: true, + user: { id: 'u', email: 'person@company.com' }, + balance: 0, + }), { status: 200 })) + await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })) + .rejects.toBeInstanceOf(TangleIdentityUnreachableError) }) it('rejects a non-sk-tan exchange key even when the rest of the response looks valid', async () => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ apiKey: 'pk-live-not-a-tangle-key', + keyId: 'key_1', paidAccessPolicyVersion: 1, emailVerified: true, user: { id: 'u', email: 'person@company.com' }, - balance: 100, + balance: 0, }), { status: 200 })) await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })) .rejects.toMatchObject({ status: 403 }) @@ -151,9 +184,11 @@ describe('finishConnectFlow', () => { it('rejects a broker token at the user-key exchange boundary', async () => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ apiKey: 'sk-tan-broker-not-a-user-key', + keyId: 'key_1', paidAccessPolicyVersion: 1, emailVerified: true, - user: { id: 'u', email: 'person@company.com', emailVerified: true }, + user: { id: 'u', email: 'person@company.com' }, + balance: 0, }), { status: 200 })) await expect(finishConnectFlow({ fetchImpl }, { code: 'c', appId: 'a' })) .rejects.toMatchObject({ status: 403 }) @@ -181,7 +216,18 @@ describe('revokeConnectFlow', () => { const url = String(input) seen.push(`${init?.method ?? 'GET'} ${url.split('/').slice(3).join('/')}`) if (url.endsWith('/v1/keys/verify')) { - return new Response(JSON.stringify({ valid: true, userId: 'u', keyId: 'k1', emailVerified: true, email: 'owner@company.com' }), { + return new Response(JSON.stringify({ + valid: true, + userId: 'u', + ownerId: 'u', + ownerType: 'user', + keyId: 'k1', + name: 'User key', + provisionedByService: 'user', + emailVerified: true, + servicePrincipal: false, + email: 'owner@company.com', + }), { status: 200, headers: { 'content-type': 'application/json' }, }) diff --git a/tests/filesystem-cross-process.test.ts b/tests/filesystem-cross-process.test.ts new file mode 100644 index 0000000..dc4a5f3 --- /dev/null +++ b/tests/filesystem-cross-process.test.ts @@ -0,0 +1,293 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { pathToFileURL } from 'node:url' +import { resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { FileSystemAtomicIdempotencyStore } from '../src/idempotency' +import { + FileSystemSubscriptionStore, + makeSubscriptionRecord, + type SubscriptionRecord, +} from '../src/stripe/subscription-state' + +const idempotencyModule = pathToFileURL(resolve('dist/idempotency.js')).href +const subscriptionModule = pathToFileURL(resolve('dist/stripe/index.js')).href +const testRoots: string[] = [] + +const idempotencyWorker = ` +import { FileSystemAtomicIdempotencyStore } from ${JSON.stringify(idempotencyModule)} + +const store = new FileSystemAtomicIdempotencyStore(process.env.STORE_ROOT, { + processingLeaseMs: Number(process.env.LEASE_MS ?? 60000), + heartbeatIntervalMs: Number(process.env.HEARTBEAT_MS ?? 20000), +}) +const key = process.env.CLAIM_KEY +const ttlMs = Number(process.env.TTL_MS ?? 60000) +const mode = process.env.WORKER_MODE ?? 'claim' + +function send(message) { + return new Promise((resolve) => process.send(message, resolve)) +} + +await send({ type: 'ready' }) +process.once('message', async (message) => { + if (message !== 'go') return + const acquired = await store.claim(key, ttlMs) + await send({ type: 'claimed', acquired }) + if (!acquired || mode === 'claim') return process.exit(0) + + if (mode === 'hold') { + process.once('message', async (next) => { + if (next !== 'complete') return + await store.complete(key) + await send({ type: 'completed' }) + process.exit(0) + }) + return + } + + if (mode === 'stale') { + process.once('message', async (next) => { + if (next !== 'block') return + await send({ type: 'blocking' }) + const until = Date.now() + Number(process.env.BLOCK_MS ?? 250) + while (Date.now() < until) Math.sqrt(81) + try { + await store.complete(key) + await send({ type: 'stale_complete', outcome: 'completed' }) + } catch (error) { + await send({ type: 'stale_complete', outcome: 'fenced', message: String(error) }) + } + process.exit(0) + }) + } +}) +` + +const subscriptionWorker = ` +import { FileSystemSubscriptionStore } from ${JSON.stringify(subscriptionModule)} + +const store = new FileSystemSubscriptionStore(process.env.STORE_ROOT) +const candidate = JSON.parse(process.env.CANDIDATE) + +function send(message) { + return new Promise((resolve) => process.send(message, resolve)) +} + +await send({ type: 'ready' }) +process.once('message', async (message) => { + if (message !== 'go') return + const written = await store.saveIfVersion(candidate, 0) + await send({ type: 'written', written, eventId: candidate.lastEventId }) + process.exit(0) +}) +` + +beforeAll(async () => { + await runCommand('pnpm', ['build']) +}, 30_000) + +afterAll(async () => { + const { rm } = await import('node:fs/promises') + await Promise.all(testRoots.map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('filesystem stores across processes', () => { + it('allows one claim winner across separate Node processes', async () => { + const root = resolve(await temporaryDirectory('claim-race')) + const workers = Array.from({ length: 16 }, () => startWorker(idempotencyWorker, { + STORE_ROOT: root, + CLAIM_KEY: 'cross-process-claim', + WORKER_MODE: 'claim', + })) + await Promise.all(workers.map((worker) => waitForMessage(worker, 'ready'))) + const results = workers.map((worker) => waitForMessage<{ acquired: boolean }>(worker, 'claimed')) + workers.forEach((worker) => worker.send('go')) + + const claims = await Promise.all(results) + expect(claims.filter((result) => result.acquired)).toHaveLength(1) + expect(claims.filter((result) => !result.acquired)).toHaveLength(15) + await Promise.all(workers.map(waitForExit)) + }, 20_000) + + it('renews an active owner beyond its lease and blocks a second process', async () => { + const root = resolve(await temporaryDirectory('over-lease')) + const owner = startWorker(idempotencyWorker, { + STORE_ROOT: root, + CLAIM_KEY: 'over-lease-active', + WORKER_MODE: 'hold', + LEASE_MS: '90', + HEARTBEAT_MS: '20', + }) + await waitForMessage(owner, 'ready') + const claimed = waitForMessage<{ acquired: boolean }>(owner, 'claimed') + owner.send('go') + expect((await claimed).acquired).toBe(true) + + await delay(280) + const contender = new FileSystemAtomicIdempotencyStore(root, { + processingLeaseMs: 90, + heartbeatIntervalMs: 20, + }) + expect(await contender.claim('over-lease-active', 60_000)).toBe(false) + + const completed = waitForMessage(owner, 'completed') + owner.send('complete') + await completed + expect(await contender.claimStatus('over-lease-active', 60_000)).toBe('completed') + await waitForExit(owner) + }, 20_000) + + it('fences a stalled owner after a successor takes over', async () => { + const root = resolve(await temporaryDirectory('stale-owner')) + const stale = startWorker(idempotencyWorker, { + STORE_ROOT: root, + CLAIM_KEY: 'stale-owner', + WORKER_MODE: 'stale', + LEASE_MS: '80', + HEARTBEAT_MS: '20', + BLOCK_MS: '260', + }) + await waitForMessage(stale, 'ready') + const claimed = waitForMessage<{ acquired: boolean }>(stale, 'claimed') + stale.send('go') + expect((await claimed).acquired).toBe(true) + const blocking = waitForMessage(stale, 'blocking') + const staleCompletion = waitForMessage<{ outcome: string; message?: string }>(stale, 'stale_complete') + stale.send('block') + await blocking + + await delay(150) + const successor = new FileSystemAtomicIdempotencyStore(root, { + processingLeaseMs: 80, + heartbeatIntervalMs: 20, + }) + expect(await successor.claim('stale-owner', 60_000)).toBe(true) + const staleResult = await staleCompletion + expect(staleResult.outcome).toBe('fenced') + expect(staleResult.message).toContain('ownership was lost') + expect(await successor.claimStatus('stale-owner', 60_000)).toBe('in_progress') + await successor.complete('stale-owner') + expect(await successor.claimStatus('stale-owner', 60_000)).toBe('completed') + await waitForExit(stale) + }, 20_000) + + it('allows one subscription CAS winner across separate Node processes', async () => { + const root = resolve(await temporaryDirectory('subscription-cas')) + const store = new FileSystemSubscriptionStore(root) + const base = makeSubscriptionRecord({ + workspaceId: 'workspace_cross_process', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + eventCreatedAt: 1, + }) + await store.save(base) + + const candidates: SubscriptionRecord[] = Array.from({ length: 16 }, (_, index) => ({ + ...base, + state: 'past_due', + version: 1, + lastEventId: `evt_${index}`, + lastEventCreatedAt: index + 2, + updatedAt: index + 2, + })) + const workers = candidates.map((candidate) => startWorker(subscriptionWorker, { + STORE_ROOT: root, + CANDIDATE: JSON.stringify(candidate), + })) + await Promise.all(workers.map((worker) => waitForMessage(worker, 'ready'))) + const results = workers.map((worker) => waitForMessage<{ written: boolean; eventId: string }>(worker, 'written')) + workers.forEach((worker) => worker.send('go')) + + const writes = await Promise.all(results) + expect(writes.filter((result) => result.written)).toHaveLength(1) + const winner = writes.find((result) => result.written)! + expect((await store.load('workspace_cross_process'))?.lastEventId).toBe(winner.eventId) + await Promise.all(workers.map(waitForExit)) + }, 20_000) +}) + +interface TestWorker extends ChildProcess { + stderrText: string +} + +function startWorker(source: string, extraEnv: Record): TestWorker { + const env: NodeJS.ProcessEnv = { ...process.env, ...extraEnv } + delete env.FORCE_COLOR + const child = spawn(process.execPath, ['--input-type=module', '-e', source], { + cwd: process.cwd(), + env, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }) as TestWorker + child.stderrText = '' + child.stderr?.on('data', (chunk) => { child.stderrText += String(chunk) }) + return child +} + +function waitForMessage>( + child: TestWorker, + type: string, +): Promise { + return new Promise((resolveMessage, rejectMessage) => { + const onMessage = (message: unknown) => { + if (!message || typeof message !== 'object' || (message as { type?: unknown }).type !== type) return + cleanup() + resolveMessage(message as T) + } + const onError = (error: Error) => { + cleanup() + rejectMessage(error) + } + const onExit = (code: number | null) => { + cleanup() + rejectMessage(new Error(`worker exited ${code}: ${child.stderrText}`)) + } + const cleanup = () => { + child.off('message', onMessage) + child.off('error', onError) + child.off('exit', onExit) + } + child.on('message', onMessage) + child.on('error', onError) + child.on('exit', onExit) + }) +} + +function waitForExit(child: TestWorker): Promise { + if (child.exitCode !== null) return Promise.resolve() + return new Promise((resolveExit, rejectExit) => { + child.once('error', rejectExit) + child.once('exit', (code) => { + if (code === 0) resolveExit() + else rejectExit(new Error(`worker exited ${code}: ${child.stderrText}`)) + }) + }) +} + +async function temporaryDirectory(label: string): Promise { + const { mkdtemp } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const root = await mkdtemp(join(tmpdir(), `agent-integrations-${label}-`)) + testRoots.push(root) + return root +} + +function delay(ms: number): Promise { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolveCommand, rejectCommand) => { + const child = spawn(command, args, { cwd: process.cwd(), stdio: 'pipe' }) + let stderr = '' + child.stderr.on('data', (chunk) => { stderr += String(chunk) }) + child.once('error', rejectCommand) + child.once('exit', (code) => { + if (code === 0) resolveCommand() + else rejectCommand(new Error(`${command} exited ${code}: ${stderr}`)) + }) + }) +} diff --git a/tests/idempotency-store.test.ts b/tests/idempotency-store.test.ts index 19e3d5a..a408213 100644 --- a/tests/idempotency-store.test.ts +++ b/tests/idempotency-store.test.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'node:crypto' +import { createHash, createHmac } from 'node:crypto' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -78,6 +78,22 @@ describe('FileSystemAtomicIdempotencyStore', () => { expect(await restarted.claim(key, 60_000)).toBe(true) }) + it('fails closed for an unexpired legacy claim during a rolling deployment', async () => { + const root = await makeRoot() + const key = 'stripe:event:legacy' + const keyHash = createHash('sha256').update(key).digest('hex') + await writeFile(join(root, `${keyHash}.json`), JSON.stringify({ + version: 1, + keyHash, + token: 'legacy-owner', + expiresAt: Date.now() + 60_000, + })) + const store = new FileSystemAtomicIdempotencyStore(root) + + expect(await store.claimStatus(key, 60_000)).toBe('in_progress') + expect(await store.claim(key, 60_000)).toBe(false) + }) + it('allows the same worker to reclaim a completed claim after its TTL', async () => { vi.useFakeTimers() try { @@ -138,18 +154,33 @@ describe('FileSystemAtomicIdempotencyStore', () => { runtime: 'production', })).toThrow('shared atomic idempotency store is required') }) + + it('requires a durable billing listener for direct production dispatch', async () => { + const root = await makeRoot() + expect(() => new StripeBillingDispatcher({ + store: new InMemorySubscriptionStore(), + runtime: 'production', + idempotency: new FileSystemStripeEventIdempotencyStore(root), + })).toThrow('production requires a durable billing listener') + }) }) describe('cross-instance webhook boundaries', () => { it('deduplicates 100 signed requests across two router instances', async () => { const root = await makeRoot() const delivered: string[] = [] + let releaseDelivery!: () => void + let deliveryStarted!: () => void + const started = new Promise((resolve) => { deliveryStarted = resolve }) + const held = new Promise((resolve) => { releaseDelivery = resolve }) const makeRouter = (store: FileSystemWebhookIdempotencyStore) => new WebhookRouter({ providers: [stripeWebhookProvider], runtime: 'production', idempotency: store, resolveSecret: () => 'whsec_test', - deliver: (event) => { + deliver: async (event) => { + deliveryStarted() + await held delivered.push(event.providerEventId ?? 'missing') }, }) @@ -157,25 +188,34 @@ describe('cross-instance webhook boundaries', () => { const routerB = makeRouter(new FileSystemWebhookIdempotencyStore(root)) const request = signedStripeRequest('evt_router_cross_instance') - const responses = await Promise.all(Array.from({ length: 100 }, (_, index) => ( + const winner = routerA.handle(request) + await started + const duplicates = await Promise.all(Array.from({ length: 99 }, (_, index) => ( index % 2 === 0 ? routerA.handle(request) : routerB.handle(request) ))) - - expect(responses.every((response) => response.status === 200)).toBe(true) - expect(responses.filter((response) => (response.body as { received?: number }).received === 1)).toHaveLength(1) - await flushDeliveries() + expect(duplicates.every((response) => response.status === 503)).toBe(true) + releaseDelivery() + expect((await winner).status).toBe(200) expect(delivered).toEqual(['evt_router_cross_instance']) }) it('deduplicates direct Stripe dispatch across two dispatcher instances', async () => { const root = await makeRoot() const events: string[] = [] + let releaseListener!: () => void + let listenerStarted!: () => void + const started = new Promise((resolve) => { listenerStarted = resolve }) + const held = new Promise((resolve) => { releaseListener = resolve }) const makeDispatcher = (idempotency: FileSystemStripeEventIdempotencyStore) => new StripeBillingDispatcher({ store: new InMemorySubscriptionStore(), runtime: 'production', idempotency, - listener: (event) => { + listener: async (event) => { events.push(event.kind) + if (event.kind === 'event_unhandled') { + listenerStarted() + await held + } }, }) const dispatcherA = makeDispatcher(new FileSystemStripeEventIdempotencyStore(root)) @@ -188,12 +228,17 @@ describe('cross-instance webhook boundaries', () => { payload: { id: 'evt_dispatch_cross_instance', type: 'customer.created', data: { object: {} } }, } - await Promise.all(Array.from({ length: 100 }, (_, index) => ( + const winner = dispatcherA.dispatch(envelope) + await started + const duplicates = await Promise.allSettled(Array.from({ length: 99 }, (_, index) => ( index % 2 === 0 ? dispatcherA.dispatch(envelope) : dispatcherB.dispatch(envelope) ))) - + expect(duplicates.every((result) => result.status === 'rejected')).toBe(true) + releaseListener() + await winner + await dispatcherB.dispatch(envelope) expect(events.filter((kind) => kind === 'event_unhandled')).toHaveLength(1) - expect(events.filter((kind) => kind === 'event_replay')).toHaveLength(99) + expect(events.filter((kind) => kind === 'event_replay')).toHaveLength(1) }) }) @@ -226,6 +271,7 @@ describe('storage failures', () => { store: new InMemorySubscriptionStore(), runtime: 'production', idempotency: new FileSystemStripeEventIdempotencyStore(blockedPath), + listener: () => undefined, }) await expect(dispatcher.dispatch({ diff --git a/tests/platform-boundary-contract.test.ts b/tests/platform-boundary-contract.test.ts index 6168ad5..15594ff 100644 --- a/tests/platform-boundary-contract.test.ts +++ b/tests/platform-boundary-contract.test.ts @@ -3,16 +3,23 @@ import { finishConnectFlow } from '../src/connect' import { createTangleIdentityClient } from '../src/connectors/adapters/tangle-id' describe('Platform live-boundary contracts', () => { - it('accepts the current verified exchange response and sends the email policy request', async () => { + it('accepts the current verified exchange response and sends only the shared request fields', async () => { let request: { url: string; body: unknown } | undefined const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { request = { url: String(input), body: JSON.parse(String(init?.body)) } return new Response(JSON.stringify({ apiKey: 'sk-tan-contract-key', + keyId: 'key_1', paidAccessPolicyVersion: 1, emailVerified: true, - user: { id: 'user_1', email: 'person@company.com', emailVerified: true }, - subscription: { plan: 'pro', status: 'active' }, + user: { id: 'user_1', email: 'person@example.com' }, + subscription: { + plan: 'pro', + sandboxTier: 'pro', + routerTier: 'pro', + status: 'active', + currentPeriodEnd: null, + }, balance: 0, }), { status: 200 }) }) @@ -24,9 +31,14 @@ describe('Platform live-boundary contracts', () => { expect(request).toEqual({ url: 'https://id.tangle.tools/cross-site/exchange', - body: { code: 'code_1', app: 'product_1', requireVerifiedEmail: true }, + body: { code: 'code_1', app: 'product_1' }, + }) + expect(result).toMatchObject({ + apiKey: 'sk-tan-contract-key', + keyId: 'key_1', + balance: 0, + paidAccessPolicyVersion: 1, }) - expect(result).toMatchObject({ apiKey: 'sk-tan-contract-key', balance: 0, paidAccessPolicyVersion: 1 }) }) it('does not accept an exchange response that would bypass verified email', async () => { @@ -39,21 +51,32 @@ describe('Platform live-boundary contracts', () => { expect(fetchImpl).toHaveBeenCalledTimes(1) }) - it('rejects a Platform key verification response with a placeholder email', async () => { + it('consumes the shared Platform key-verification contract and immutable provenance', async () => { const client = createTangleIdentityClient({ serviceToken: 'svc_contract', serviceName: 'agent-integrations-tests', + expectedProduct: 'legal-agent', fetchImpl: vi.fn(async () => new Response(JSON.stringify({ valid: true, userId: 'user_1', + ownerId: 'user_1', + ownerType: 'user', emailVerified: true, - email: 'test@example.com', + email: 'person@example.com', + servicePrincipal: false, + keyId: 'key_1', + name: 'Legal product key', + product: 'legal-agent', + provisionedByService: 'legal-agent', }), { status: 200 })), }) - await expect(client.verifyToken('sk-tan-contract-key')).resolves.toEqual({ - valid: false, - reason: 'real_email_required', + await expect(client.verifyToken('sk-tan-contract-key')).resolves.toMatchObject({ + valid: true, + credentialId: 'key_1', + apiKeyId: 'key_1', + product: 'legal-agent', + provisionedByService: 'legal-agent', }) }) }) diff --git a/tests/stripe-billing-middleware.test.ts b/tests/stripe-billing-middleware.test.ts index 74a337b..eac6b18 100644 --- a/tests/stripe-billing-middleware.test.ts +++ b/tests/stripe-billing-middleware.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { generateKeyPair, SignJWT } from 'jose' +import { beforeAll, describe, expect, it } from 'vitest' import { gateSubscriptionOrTrial, getRemainingFreeTier, @@ -13,35 +14,53 @@ import { type SubscriptionRecord, } from '../src/stripe/subscription-state' import { BillingError } from '../src/stripe/errors' -import { parseTrustedPlatformEvidence } from '../src/billing-access-policy' +import { + verifyTrustedPlatformEvidence, + type TrustedPlatformEvidence, +} from '../src/billing-access-policy' +import { InMemoryAtomicIdempotencyStore } from '../src/idempotency' + +let paidEvidenceValue: TrustedPlatformEvidence +let namedServiceEvidenceValue: TrustedPlatformEvidence -function paidEvidence(subscriptionId = 'sub_1') { - const evidence = parseTrustedPlatformEvidence({ +beforeAll(async () => { + const { privateKey, publicKey } = await generateKeyPair('ES256') + const now = Math.floor(Date.now() / 1000) + const verify = async (token: string, expectedUserId: string) => { + const evidence = await verifyTrustedPlatformEvidence(token, { + audience: 'stripe-middleware-tests', + expectedUserId, + verificationKey: publicKey, + replayStore: new InMemoryAtomicIdempotencyStore(), + runtime: 'test', + }) + if (!evidence) throw new Error('invalid signed test evidence') + return evidence + } + paidEvidenceValue = await verify(await new SignJWT({ policyVersion: 1, - issuer: 'id.tangle.tools', - evidenceId: `evidence-${subscriptionId}`, - issuedAt: '2026-08-10T12:00:00.000Z', emailVerified: true, principal: { kind: 'human' }, user: { id: 'user_1', email: 'person@company.com' }, funding: { kind: 'paid_subscription', - id: `funding-${subscriptionId}`, - subscriptionId, + id: 'funding-sub_1', + subscriptionId: 'sub_1', status: 'active', amountUsd: 29, }, - }, { expectedUserId: 'user_1' }) - if (!evidence) throw new Error('invalid test evidence') - return evidence -} - -function namedServiceEvidence() { - const evidence = parseTrustedPlatformEvidence({ + }) + .setProtectedHeader({ alg: 'ES256', kid: 'test' }) + .setIssuer('id.tangle.tools') + .setAudience('stripe-middleware-tests') + .setSubject('user_1') + .setIssuedAt(now) + .setNotBefore(now - 1) + .setExpirationTime(now + 300) + .setJti('stripe-paid-evidence') + .sign(privateKey), 'user_1') + namedServiceEvidenceValue = await verify(await new SignJWT({ policyVersion: 1, - issuer: 'id.tangle.tools', - evidenceId: 'service-evidence', - issuedAt: '2026-08-10T12:00:00.000Z', principal: { kind: 'service_principal', id: 'service:blueprint-agent', name: 'blueprint-agent' }, user: { id: 'service-user' }, funding: { @@ -51,8 +70,23 @@ function namedServiceEvidence() { serviceName: 'blueprint-agent', }, }) - if (!evidence) throw new Error('invalid service evidence') - return evidence + .setProtectedHeader({ alg: 'ES256', kid: 'test' }) + .setIssuer('id.tangle.tools') + .setAudience('stripe-middleware-tests') + .setSubject('service-user') + .setIssuedAt(now) + .setNotBefore(now - 1) + .setExpirationTime(now + 300) + .setJti('stripe-service-evidence') + .sign(privateKey), 'service-user') +}) + +function paidEvidence(): TrustedPlatformEvidence { + return paidEvidenceValue +} + +function namedServiceEvidence(): TrustedPlatformEvidence { + return namedServiceEvidenceValue } function seededStore(state: SubscriptionRecord['state'], overrides: Partial = {}) { diff --git a/tests/stripe-state-machine.test.ts b/tests/stripe-state-machine.test.ts index 77d7fbf..dcd028b 100644 --- a/tests/stripe-state-machine.test.ts +++ b/tests/stripe-state-machine.test.ts @@ -23,6 +23,7 @@ function baseRecord(overrides: Partial = {}): SubscriptionRe cancelAtPeriodEnd: false, version: 0, lastEventId: null, + lastEventCreatedAt: null, updatedAt: 0, ...overrides, } diff --git a/tests/stripe-webhooks-dispatcher.test.ts b/tests/stripe-webhooks-dispatcher.test.ts index 60ef94c..499e023 100644 --- a/tests/stripe-webhooks-dispatcher.test.ts +++ b/tests/stripe-webhooks-dispatcher.test.ts @@ -33,11 +33,12 @@ function subEvent(opts: { trialEnd?: number | null cancelAtPeriodEnd?: boolean | null currentPeriodEnd?: number | null + created?: number }) { return { id: opts.id, type: opts.type, - created: 1, + created: opts.created ?? 1, data: { object: { id: opts.subscriptionId ?? 'sub_1', @@ -78,6 +79,7 @@ describe('StripeBillingDispatcher — created', () => { const stored = await store.load('ws_1') expect(stored?.state).toBe('trialing') expect(stored?.lastEventId).toBe('evt_1') + expect(stored?.lastEventCreatedAt).toBe(1) expect(captured).toHaveLength(1) expect(captured[0]).toMatchObject({ kind: 'subscription.trial_ignored', eventId: 'evt_1' }) }) @@ -163,7 +165,8 @@ describe('StripeBillingDispatcher — updated', () => { ) const stored = (await store.load('ws_1'))! expect(stored.state).toBe('past_due') - expect(stored.version).toBe(1) + expect(stored.version).toBe(2) + expect(stored.pendingEventId).toBeNull() expect(events[0]).toMatchObject({ kind: 'subscription.updated', previousState: 'active', @@ -228,6 +231,93 @@ describe('StripeBillingDispatcher — updated', () => { expect(events[0]).toMatchObject({ kind: 'event_dropped_out_of_order', eventId: 'evt_foreign' }) expect((events[0] as { reason: string }).reason).toContain('identity') }) + + it('persists the newest Stripe timestamp and rejects an older valid transition', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + eventCreatedAt: 50, + })) + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + + await dispatcher.dispatch(makeEnvelope(subEvent({ + id: 'evt_newer', + type: 'customer.subscription.updated', + status: 'past_due', + workspaceId: 'ws_1', + created: 200, + }), 'customer.subscription.updated')) + await dispatcher.dispatch(makeEnvelope(subEvent({ + id: 'evt_older', + type: 'customer.subscription.updated', + status: 'active', + workspaceId: 'ws_1', + created: 100, + }), 'customer.subscription.updated')) + + const stored = await store.load('ws_1') + expect(stored?.state).toBe('past_due') + expect(stored?.lastEventCreatedAt).toBe(200) + expect(events.map((event) => event.kind)).toEqual([ + 'subscription.updated', + 'event_dropped_out_of_order', + ]) + expect((events[1] as { reason: string }).reason).toContain('older than stored=200') + }) + + it('returns a retryable failure for an equal timestamp without Stripe reconciliation', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + eventCreatedAt: 50, + })) + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + + await dispatcher.dispatch(makeEnvelope(subEvent({ + id: 'evt_same_second_newer', + type: 'customer.subscription.updated', + status: 'past_due', + workspaceId: 'ws_1', + priceId: 'price_new', + created: 200, + }), 'customer.subscription.updated')) + await expect(dispatcher.dispatch(makeEnvelope(subEvent({ + id: 'evt_same_second_ambiguous', + type: 'customer.subscription.updated', + status: 'active', + workspaceId: 'ws_1', + priceId: 'price_old', + created: 200, + }), 'customer.subscription.updated'))).rejects.toThrow( + 'equal event.created timestamps require Stripe reconciliation', + ) + + const stored = await store.load('ws_1') + expect(stored).toMatchObject({ + state: 'past_due', + priceId: 'price_new', + lastEventCreatedAt: 200, + }) + expect(events).toHaveLength(1) + }) }) describe('StripeBillingDispatcher — deleted + lifecycle', () => { @@ -259,6 +349,102 @@ describe('StripeBillingDispatcher — deleted + lifecycle', () => { expect(events[0]).toMatchObject({ kind: 'subscription.deleted' }) }) + it('reconciles an equal-timestamp cancellation before acknowledging it', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 100, + eventId: 'evt_active_same_second', + eventCreatedAt: 200, + })) + const events: StripeBillingEvent[] = [] + const retrieved: string[] = [] + let retrievalAttempts = 0 + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + onError: () => undefined, + retrieveSubscription: async (subscriptionId) => { + retrieved.push(subscriptionId) + retrievalAttempts++ + if (retrievalAttempts === 1) throw new Error('Stripe read unavailable') + return { + id: 'sub_1', + customer: 'cus_1', + status: 'canceled', + current_period_end: 200, + cancel_at_period_end: false, + trial_end: null, + items: { data: [{ price: { id: 'price_1' } }] }, + } + }, + }) + + const deletion = makeEnvelope(subEvent({ + id: 'evt_cancel_same_second', + type: 'customer.subscription.deleted', + status: 'canceled', + workspaceId: 'ws_1', + created: 200, + }), 'customer.subscription.deleted') + + await expect(dispatcher.dispatch(deletion)).rejects.toThrow('Stripe read unavailable') + expect((await store.load('ws_1'))?.state).toBe('active') + await expect(dispatcher.dispatch(deletion)).resolves.toBeUndefined() + + expect(retrieved).toEqual(['sub_1', 'sub_1']) + expect(await store.load('ws_1')).toMatchObject({ + state: 'canceled', + lastEventId: 'evt_cancel_same_second', + lastEventCreatedAt: 200, + pendingEventId: null, + }) + expect(events).toEqual([ + expect.objectContaining({ kind: 'subscription.deleted', eventId: 'evt_cancel_same_second' }), + ]) + }) + + it('retries a cancellation delivered before subscription creation', async () => { + const store = new InMemorySubscriptionStore() + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + const deletion = makeEnvelope(subEvent({ + id: 'evt_delete_before_create', + type: 'customer.subscription.deleted', + status: 'canceled', + workspaceId: 'ws_1', + created: 200, + }), 'customer.subscription.deleted') + const creation = makeEnvelope(subEvent({ + id: 'evt_create_after_delete', + type: 'customer.subscription.created', + status: 'active', + workspaceId: 'ws_1', + created: 100, + }), 'customer.subscription.created') + + await expect(dispatcher.dispatch(deletion)).rejects.toThrow('subscription state is not available yet') + await expect(dispatcher.dispatch(creation)).resolves.toBeUndefined() + await expect(dispatcher.dispatch(deletion)).resolves.toBeUndefined() + + expect(await store.load('ws_1')).toMatchObject({ + state: 'canceled', + lastEventId: 'evt_delete_before_create', + lastEventCreatedAt: 200, + }) + expect(events.map((event) => event.kind)).toEqual([ + 'subscription.created', + 'subscription.deleted', + ]) + }) + it('a second delete on an already-canceled record is a replay no-op', async () => { const store = new InMemorySubscriptionStore() await store.save(makeSubscriptionRecord({ @@ -304,13 +490,25 @@ describe('StripeBillingDispatcher — deleted + lifecycle', () => { }) await dispatcher.dispatch( makeEnvelope( - subEvent({ id: 'evt_p', type: 'customer.subscription.paused', status: 'paused', workspaceId: 'ws_1' }), + subEvent({ + id: 'evt_p', + type: 'customer.subscription.paused', + status: 'paused', + workspaceId: 'ws_1', + created: 1, + }), 'customer.subscription.paused', ), ) await dispatcher.dispatch( makeEnvelope( - subEvent({ id: 'evt_r', type: 'customer.subscription.resumed', status: 'active', workspaceId: 'ws_1' }), + subEvent({ + id: 'evt_r', + type: 'customer.subscription.resumed', + status: 'active', + workspaceId: 'ws_1', + created: 2, + }), 'customer.subscription.resumed', ), ) @@ -346,6 +544,7 @@ describe('StripeBillingDispatcher — invoice', () => { object: { id: 'in_1', customer: 'cus_1', + subscription: 'sub_1', amount_paid: 4200, metadata: { workspaceId: 'ws_1' }, }, @@ -358,6 +557,50 @@ describe('StripeBillingDispatcher — invoice', () => { expect((events[0] as { record: SubscriptionRecord | null }).record?.workspaceId).toBe('ws_1') }) + it('binds a current Stripe invoice through parent.subscription_details', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'p', + currentPeriodEnd: 1, + })) + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + + await dispatcher.dispatch(makeEnvelope({ + id: 'evt_parent_invoice', + type: 'invoice.paid', + data: { + object: { + id: 'in_parent', + customer: 'cus_1', + amount_paid: 4200, + parent: { + type: 'subscription_details', + subscription_details: { + subscription: 'sub_1', + metadata: { workspaceId: 'ws_1' }, + }, + }, + }, + }, + }, 'invoice.paid')) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + kind: 'invoice.paid', + eventId: 'evt_parent_invoice', + invoiceId: 'in_parent', + amountPaid: 4200, + }) + }) + it('does not emit paid entitlement for a zero-dollar invoice', async () => { const events: StripeBillingEvent[] = [] const dispatcher = new StripeBillingDispatcher({ @@ -367,7 +610,7 @@ describe('StripeBillingDispatcher — invoice', () => { await dispatcher.dispatch(makeEnvelope({ id: 'evt_zero', type: 'invoice.paid', - data: { object: { id: 'in_zero', amount_paid: 0, customer: 'cus_1' } }, + data: { object: { id: 'in_zero', amount_paid: 0, customer: 'cus_1', subscription: 'sub_1' } }, }, 'invoice.paid')) expect(events).toEqual([{ kind: 'invoice.zero_dollar_ignored', eventId: 'evt_zero', invoiceId: 'in_zero', amountPaid: 0 }]) }) @@ -400,10 +643,87 @@ describe('StripeBillingDispatcher — invoice', () => { }, }, }, 'invoice.paid') - await Promise.all(Array.from({ length: 100 }, () => dispatcher.dispatch(envelope))) + const results = await Promise.allSettled(Array.from({ length: 100 }, () => dispatcher.dispatch(envelope))) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(99) expect(events.filter((event) => event.kind === 'invoice.paid')).toHaveLength(1) }) + it('retries a paid invoice delivered before subscription state exists', async () => { + const store = new InMemorySubscriptionStore() + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + const invoice = makeEnvelope({ + id: 'evt_invoice_before_state', + type: 'invoice.paid', + data: { + object: { + id: 'in_before_state', + amount_paid: 2900, + customer: 'cus_1', + subscription: 'sub_1', + metadata: { workspaceId: 'ws_1' }, + }, + }, + }, 'invoice.paid') + + await expect(dispatcher.dispatch(invoice)).rejects.toThrow( + 'paid invoice subscription state is not available yet', + ) + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 100, + })) + await expect(dispatcher.dispatch(invoice)).resolves.toBeUndefined() + await expect(dispatcher.dispatch(invoice)).resolves.toBeUndefined() + + expect(events.filter((event) => event.kind === 'invoice.paid')).toHaveLength(1) + expect(events.at(-1)).toMatchObject({ kind: 'event_replay', eventId: 'evt_invoice_before_state' }) + }) + + it('requires a non-null invoice subscription that matches the stored record', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_1', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 1, + })) + const events: StripeBillingEvent[] = [] + const dispatcher = new StripeBillingDispatcher({ + store, + listener: (event) => { events.push(event) }, + }) + + await dispatcher.dispatch(makeEnvelope({ + id: 'evt_missing_subscription', + type: 'invoice.paid', + data: { + object: { + id: 'in_missing_subscription', + amount_paid: 100, + customer: 'cus_1', + metadata: { workspaceId: 'ws_1' }, + }, + }, + }, 'invoice.paid')) + + expect(events.some((event) => event.kind === 'invoice.paid')).toBe(false) + expect(events[0]).toMatchObject({ + kind: 'event_dropped_out_of_order', + eventId: 'evt_missing_subscription', + }) + }) + it('does not emit paid entitlement for a foreign invoice', async () => { const store = new InMemorySubscriptionStore() await store.save(makeSubscriptionRecord({ @@ -465,8 +785,13 @@ describe('StripeBillingDispatcher — invoice', () => { id: 'in_2', amount_due: 1000, customer: 'cus_1', - subscription: 'sub_1', - metadata: { workspaceId: 'ws_1' }, + parent: { + type: 'subscription_details', + subscription_details: { + subscription: 'sub_1', + metadata: { workspaceId: 'ws_1' }, + }, + }, }, }, }, @@ -518,24 +843,143 @@ describe('StripeBillingDispatcher — meta', () => { store: new InMemorySubscriptionStore(), onError, }) - await dispatcher.dispatch(makeEnvelope({ data: { object: {} } })) + await expect(dispatcher.dispatch(makeEnvelope({ data: { object: {} } }))).rejects.toThrow( + 'missing id or type', + ) expect(onError).toHaveBeenCalled() }) - it('listener errors are caught and surfaced via onError', async () => { + it('listener errors reach the caller and a retry can finish once', async () => { const onError = vi.fn() + const events: string[] = [] + let attempts = 0 const dispatcher = new StripeBillingDispatcher({ store: new InMemorySubscriptionStore(), onError, - listener: () => { - throw new Error('listener boom') + listener: (event) => { + events.push(event.kind) + if (event.kind === 'event_unhandled' && attempts++ === 0) throw new Error('listener boom') }, }) - await dispatcher.dispatch( - makeEnvelope({ id: 'evt_z', type: 'charge.captured', data: { object: {} } }, 'charge.captured'), - ) + const envelope = makeEnvelope({ id: 'evt_z', type: 'charge.captured', data: { object: {} } }, 'charge.captured') + await expect(dispatcher.dispatch(envelope)).rejects.toThrow('listener boom') + await expect(dispatcher.dispatch(envelope)).resolves.toBeUndefined() + await expect(dispatcher.dispatch(envelope)).resolves.toBeUndefined() + expect(events).toEqual(['event_unhandled', 'event_unhandled', 'event_replay']) expect(onError).toHaveBeenCalledWith(expect.any(Error), expect.objectContaining({ eventId: 'evt_z' })) }) + + it('retries the original typed event when state persisted before listener failure', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_retry', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 100, + eventId: 'evt_prior', + eventCreatedAt: 10, + })) + const seen: StripeBillingEvent[] = [] + const durableQueue = new Set() + let updateAttempts = 0 + const dispatcher = new StripeBillingDispatcher({ + store, + onError: () => undefined, + listener: (event) => { + seen.push(event) + if (event.kind !== 'subscription.updated') return + updateAttempts++ + if (updateAttempts === 1) throw new Error('queue unavailable') + durableQueue.add(event.eventId) + }, + }) + const envelope = makeEnvelope(subEvent({ + id: 'evt_retry_update', + type: 'customer.subscription.updated', + status: 'past_due', + workspaceId: 'ws_retry', + created: 20, + })) + + await expect(dispatcher.dispatch(envelope)).rejects.toThrow('queue unavailable') + expect((await store.load('ws_retry'))?.state).toBe('past_due') + await expect(dispatcher.dispatch(envelope)).resolves.toBeUndefined() + await expect(dispatcher.dispatch(envelope)).resolves.toBeUndefined() + + expect(seen.map((event) => event.kind)).toEqual([ + 'subscription.updated', + 'subscription.updated', + 'event_replay', + ]) + expect(seen[1]).toMatchObject({ + kind: 'subscription.updated', + eventId: 'evt_retry_update', + previousState: 'active', + record: { state: 'past_due' }, + }) + expect(durableQueue).toEqual(new Set(['evt_retry_update'])) + }) + + it('blocks an intervening subscription event until the pending event is durably delivered', async () => { + const store = new InMemorySubscriptionStore() + await store.save(makeSubscriptionRecord({ + workspaceId: 'ws_pending', + customerId: 'cus_1', + subscriptionId: 'sub_1', + state: 'active', + priceId: 'price_1', + currentPeriodEnd: 100, + eventId: 'evt_prior', + eventCreatedAt: 10, + })) + const durableQueue = new Set() + let failFirstDelivery = true + const dispatcher = new StripeBillingDispatcher({ + store, + onError: () => undefined, + listener: (event) => { + if (!event.kind.startsWith('subscription.')) return + if (event.eventId === 'evt_pending_first' && failFirstDelivery) { + failFirstDelivery = false + throw new Error('queue unavailable') + } + durableQueue.add(event.eventId) + }, + }) + const first = makeEnvelope(subEvent({ + id: 'evt_pending_first', + type: 'customer.subscription.updated', + status: 'past_due', + workspaceId: 'ws_pending', + created: 20, + })) + const second = makeEnvelope(subEvent({ + id: 'evt_pending_second', + type: 'customer.subscription.updated', + status: 'active', + workspaceId: 'ws_pending', + created: 21, + })) + + await expect(dispatcher.dispatch(first)).rejects.toThrow('queue unavailable') + await expect(dispatcher.dispatch(second)).rejects.toThrow('still needs durable delivery') + expect(await store.load('ws_pending')).toMatchObject({ + state: 'past_due', + lastEventId: 'evt_pending_first', + pendingEventId: 'evt_pending_first', + }) + + await expect(dispatcher.dispatch(first)).resolves.toBeUndefined() + await expect(dispatcher.dispatch(second)).resolves.toBeUndefined() + expect(await store.load('ws_pending')).toMatchObject({ + state: 'active', + lastEventId: 'evt_pending_second', + pendingEventId: null, + }) + expect(durableQueue).toEqual(new Set(['evt_pending_first', 'evt_pending_second'])) + }) }) describe('combineListeners', () => { diff --git a/tests/tangle-id.test.ts b/tests/tangle-id.test.ts index a8eb640..28d594b 100644 --- a/tests/tangle-id.test.ts +++ b/tests/tangle-id.test.ts @@ -6,6 +6,7 @@ import { TANGLE_SERVICE_TOKEN_PREFIX, tangleIdentity, TangleIdentityUnreachableError, + type PlatformKeyVerifyResponse, } from '../src/connectors/adapters/tangle-id' function jsonResponse(body: unknown, init: ResponseInit = {}): Response { @@ -21,6 +22,25 @@ function emptyResponse(status: number): Response { return new Response(nullBodyStatus ? null : '', { status }) } +function verifiedHumanKey( + overrides: Partial = {}, +): PlatformKeyVerifyResponse { + return { + valid: true, + email: 'owner@company.com', + emailVerified: true, + servicePrincipal: false, + provisionedByService: 'legal-agent', + userId: 'usr_1', + ownerId: 'usr_1', + ownerType: 'user', + keyId: 'key_1', + product: 'legal-agent', + name: 'Legal product key', + ...overrides, + } +} + describe('tangle-id verifyToken', () => { it('fails closed when a service token has no named service', () => { expect(() => createTangleIdentityClient({ serviceToken: 'svc_x' })).toThrow(/serviceName is required/) @@ -47,31 +67,31 @@ describe('tangle-id verifyToken', () => { it('routes sk-tan-* keys to /v1/keys/verify and returns normalized scopes + team workspace', async () => { let capturedPath = '' let capturedAuth = '' + let capturedBody: unknown const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { capturedPath = String(input) capturedAuth = (init?.headers as Record)['authorization'] ?? '' - return jsonResponse({ - valid: true, + capturedBody = JSON.parse(String(init?.body)) + return jsonResponse(verifiedHumanKey({ userId: 'usr_1', - email: 'owner@company.com', - emailVerified: true, ownerId: 'team_1', ownerType: 'team', keyId: 'key_42', - product: 'legal', + provisionedByService: 'legal-agent', allowedModels: ['gpt-4', 'claude-3'], - expiresAt: '2026-12-31T00:00:00.000Z', - }) + })) }) const client = createTangleIdentityClient({ baseUrl: 'https://id.example.com', serviceToken: 'svc_service', serviceName: 'test-suite', + expectedProduct: 'legal-agent', fetchImpl, }) const result = await client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`) expect(capturedPath).toBe('https://id.example.com/v1/keys/verify') expect(capturedAuth).toBe('Bearer svc_service') + expect(capturedBody).toEqual({ key: `${TANGLE_API_KEY_PREFIX}token` }) expect(result).toMatchObject({ valid: true, kind: 'api_key', @@ -79,20 +99,26 @@ describe('tangle-id verifyToken', () => { workspaceId: 'team_1', ownerType: 'team', credentialId: 'key_42', - product: 'legal', + apiKeyId: 'key_42', + product: 'legal-agent', + provisionedByService: 'legal-agent', emailVerified: true, }) if (result.valid) { - expect(result.scopes).toEqual(['gpt-4', 'claude-3', 'product:legal']) - expect(result.expiresAt).toBe(Date.parse('2026-12-31T00:00:00.000Z')) + expect(result.scopes).toEqual(['gpt-4', 'claude-3', 'product:legal-agent']) } }) it('falls back to userId workspace for personal (non-team) API keys', async () => { const fetchImpl = vi.fn(async () => - jsonResponse({ valid: true, userId: 'usr_5', allowedModels: [], emailVerified: true, email: 'owner@company.com' }), + jsonResponse(verifiedHumanKey({ userId: 'usr_5', ownerId: 'usr_5', allowedModels: [] })), ) - const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) + const client = createTangleIdentityClient({ + serviceToken: 'svc_x', + serviceName: 'test-suite', + expectedProduct: 'legal-agent', + fetchImpl, + }) const result = await client.verifyToken(`${TANGLE_API_KEY_PREFIX}x`) if (!result.valid) throw new Error('expected valid') expect(result.workspaceId).toBe('usr_5') @@ -100,6 +126,76 @@ describe('tangle-id verifyToken', () => { expect(result.emailVerified).toBe(true) }) + it('rejects the unsafe generic-key path when no expected product is configured', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(verifiedHumanKey())) + const client = createTangleIdentityClient({ + serviceToken: 'svc_x', + serviceName: 'legal-agent', + fetchImpl, + }) + await expect(client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).resolves.toEqual({ + valid: false, + reason: 'product_scope_required', + }) + }) + + it('sends Platform-supported product-principal enforcement for router keys', async () => { + let capturedBody: unknown + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)) + return jsonResponse(verifiedHumanKey({ + product: 'router', + provisionedByService: 'router', + name: 'Tangle Router access', + })) + }) + const client = createTangleIdentityClient({ + serviceToken: 'svc_x', + serviceName: 'router', + expectedProduct: 'router', + fetchImpl, + }) + + await expect(client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).resolves.toMatchObject({ + valid: true, + product: 'router', + }) + expect(capturedBody).toEqual({ + key: `${TANGLE_API_KEY_PREFIX}token`, + expectedProduct: 'router', + }) + }) + + it('rejects a key whose verified product differs from the expected product', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(verifiedHumanKey({ product: 'tax-agent' }))) + const client = createTangleIdentityClient({ + serviceToken: 'svc_x', + serviceName: 'legal-agent', + expectedProduct: 'legal-agent', + fetchImpl, + }) + await expect(client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).resolves.toEqual({ + valid: false, + reason: 'product_scope_mismatch', + }) + }) + + it('does not treat the caller-supplied service name as key provenance', async () => { + const response = verifiedHumanKey() + delete response.provisionedByService + const fetchImpl = vi.fn(async () => jsonResponse(response)) + const client = createTangleIdentityClient({ + serviceToken: 'svc_x', + serviceName: 'legal-agent', + expectedProduct: 'legal-agent', + fetchImpl, + }) + await expect(client.verifyToken(`${TANGLE_API_KEY_PREFIX}token`)).resolves.toEqual({ + valid: false, + reason: 'malformed', + }) + }) + it('returns service_token_refused on 401 from /v1/keys/verify', async () => { const fetchImpl = vi.fn(async () => emptyResponse(401)) const client = createTangleIdentityClient({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) @@ -262,7 +358,7 @@ describe('tangle-id revokeSession', () => { const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: String(input), method: init?.method ?? 'GET' }) if (String(input).endsWith('/v1/keys/verify')) { - return jsonResponse({ valid: true, userId: 'u1', keyId: 'key_77', emailVerified: true, email: 'owner@company.com' }) + return jsonResponse(verifiedHumanKey({ userId: 'u1', ownerId: 'u1', keyId: 'key_77' })) } return emptyResponse(204) }) @@ -275,7 +371,7 @@ describe('tangle-id revokeSession', () => { it('treats 404 on key delete as a successful no-op (idempotent revoke)', async () => { const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { if (String(input).endsWith('/v1/keys/verify')) { - return jsonResponse({ valid: true, userId: 'u1', keyId: 'key_77', emailVerified: true, email: 'owner@company.com' }) + return jsonResponse(verifiedHumanKey({ userId: 'u1', ownerId: 'u1', keyId: 'key_77' })) } return emptyResponse(404) }) @@ -349,9 +445,14 @@ describe('tangle-id adapter wiring', () => { it('executeRead routes verify_token to the client and round-trips the typed result', async () => { const fetchImpl = vi.fn(async () => - jsonResponse({ valid: true, userId: 'u', allowedModels: ['gpt-4'], emailVerified: true, email: 'owner@company.com' }), + jsonResponse(verifiedHumanKey({ userId: 'u', ownerId: 'u', allowedModels: ['gpt-4'] })), ) - const adapter = tangleIdentity({ serviceToken: 'svc_x', serviceName: 'test-suite', fetchImpl }) + const adapter = tangleIdentity({ + serviceToken: 'svc_x', + serviceName: 'test-suite', + expectedProduct: 'legal-agent', + fetchImpl, + }) const result = await adapter.executeRead!({ source: makeSource(), capabilityName: 'verify_token', diff --git a/tests/tangle-middleware.test.ts b/tests/tangle-middleware.test.ts index 2e26e4e..97c7ca2 100644 --- a/tests/tangle-middleware.test.ts +++ b/tests/tangle-middleware.test.ts @@ -87,7 +87,9 @@ describe('requireTangleAuth', () => { ownerType: 'team', emailVerified: true, credentialId: 'key_1', - product: 'legal', + apiKeyId: 'key_1', + product: 'legal-agent', + provisionedByService: 'legal-agent', expiresAt: 1_700_000_000_000, })) const out = await requireTangleAuth(reqWith({ authorization: 'Bearer sk-tan-x' }), { client }) @@ -99,7 +101,9 @@ describe('requireTangleAuth', () => { kind: 'api_key', ownerType: 'team', credentialId: 'key_1', - product: 'legal', + apiKeyId: 'key_1', + product: 'legal-agent', + provisionedByService: 'legal-agent', expiresAt: 1_700_000_000_000, emailVerified: true, email: 'owner@company.com', diff --git a/tests/webhook-router.test.ts b/tests/webhook-router.test.ts index 6ca7aa4..bae4c7d 100644 --- a/tests/webhook-router.test.ts +++ b/tests/webhook-router.test.ts @@ -15,9 +15,7 @@ import { } from '../src/webhooks/index' function flushMicrotasks(): Promise { - // Two await ticks: queueMicrotask delivers on the next microtask; the - // delivery itself awaits, so two ticks is enough to drain a single - // deliver call without setImmediate. + // Yield once for provider callbacks that schedule their own microtasks. return new Promise((r) => setTimeout(r, 0)) } @@ -111,6 +109,14 @@ describe('WebhookRouter', () => { seen.add(key) return true }, + claimStatus: (id) => { + const key = id.replace('stripe:id:', '') + if (seen.has(key)) return 'completed' + seen.add(key) + return 'acquired' + }, + release: () => undefined, + complete: () => undefined, } const router = new WebhookRouter({ providers: [stripeWebhookProvider], @@ -141,6 +147,12 @@ describe('WebhookRouter', () => { claimed.push(id) return true }, + claimStatus: (id) => { + claimed.push(id) + return 'acquired' + }, + release: () => undefined, + complete: () => undefined, } const router = new WebhookRouter({ providers: [stripeWebhookProvider], @@ -158,10 +170,15 @@ describe('WebhookRouter', () => { it('delivers a duplicate webhook exactly once under 100 concurrent requests', async () => { const delivered: WebhookEnvelope[] = [] + let releaseDelivery!: () => void + let deliveryStarted!: () => void + const started = new Promise((resolve) => { deliveryStarted = resolve }) + const held = new Promise((resolve) => { releaseDelivery = resolve }) const router = new WebhookRouter({ providers: [stripeWebhookProvider], deliver: async (event) => { - await Promise.resolve() + deliveryStarted() + await held delivered.push(event) }, resolveSecret: async () => 'whsec_test', @@ -175,9 +192,15 @@ describe('WebhookRouter', () => { rawBody: body, headers: { 'stripe-signature': sig }, } - const responses = await Promise.all(Array.from({ length: 100 }, () => router.handle(request))) - expect(responses.filter((response) => (response.body as { received?: number }).received === 1)).toHaveLength(1) - await flushMicrotasks() + const winner = router.handle(request) + await started + const duplicates = await Promise.all(Array.from({ length: 99 }, () => router.handle(request))) + expect(duplicates.every((response) => response.status === 503)).toBe(true) + expect(duplicates.every((response) => (response.body as { error?: string }).error === 'delivery_in_progress')).toBe(true) + releaseDelivery() + const accepted = await winner + expect(accepted.status).toBe(200) + expect((accepted.body as { received?: number }).received).toBe(1) expect(delivered).toHaveLength(1) }) @@ -315,12 +338,16 @@ describe('WebhookRouter', () => { expect(r.status).toBe(400) }) - it('does not block the response when deliver() throws', async () => { + it('returns non-2xx on delivery failure and retries the work exactly once', async () => { const errors: unknown[] = [] + let attempts = 0 + let credits = 0 const router = new WebhookRouter({ providers: [stripeWebhookProvider], deliver: async () => { - throw new Error('downstream-fail') + attempts++ + if (attempts === 1) throw new Error('downstream-fail') + credits++ }, resolveSecret: async () => 'whsec_test', onError: (err) => { @@ -330,13 +357,59 @@ describe('WebhookRouter', () => { const ts = Math.floor(Date.now() / 1000) const body = JSON.stringify({ id: 'evt_x', type: 'x' }) const sig = `t=${ts},v1=${createHmac('sha256', 'whsec_test').update(`${ts}.${body}`).digest('hex')}` - const r = await router.handle({ + const request = { providerId: 'stripe', rawBody: body, headers: { 'stripe-signature': sig }, - }) - expect(r.status).toBe(200) - await flushMicrotasks() + } + const failed = await router.handle(request) + const retried = await router.handle(request) + const replayed = await router.handle(request) + expect(failed.status).toBe(503) + expect((failed.body as { error?: string }).error).toBe('delivery_failed') + expect(retried.status).toBe(200) + expect(replayed.status).toBe(200) + expect(attempts).toBe(2) + expect(credits).toBe(1) expect(errors).toHaveLength(1) }) + + it('retries safely when claim completion fails after a durable enqueue', async () => { + const inner = new InMemoryWebhookIdempotencyStore() + let completionAttempts = 0 + const idempotency: WebhookIdempotencyStore = { + claim: (key, ttlMs) => inner.claim(key, ttlMs), + claimStatus: (key, ttlMs) => inner.claimStatus(key, ttlMs), + release: (key) => inner.release(key), + complete: (key) => { + if (completionAttempts++ === 0) throw new Error('completion storage failed') + inner.complete(key) + }, + } + const queue = new Set() + let enqueueAttempts = 0 + const router = new WebhookRouter({ + providers: [stripeWebhookProvider], + idempotency, + resolveSecret: async () => 'whsec_test', + onError: () => undefined, + deliver: async (event) => { + enqueueAttempts++ + if (event.providerEventId) queue.add(event.providerEventId) + }, + }) + const ts = Math.floor(Date.now() / 1000) + const body = JSON.stringify({ id: 'evt_complete_retry', type: 'invoice.paid' }) + const sig = `t=${ts},v1=${createHmac('sha256', 'whsec_test').update(`${ts}.${body}`).digest('hex')}` + const request = { + providerId: 'stripe', + rawBody: body, + headers: { 'stripe-signature': sig }, + } + + expect((await router.handle(request)).status).toBe(503) + expect((await router.handle(request)).status).toBe(200) + expect(enqueueAttempts).toBe(2) + expect(queue).toEqual(new Set(['evt_complete_retry'])) + }) })