@supa-media/convex 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Tenant Scoping Discipline
3
+ *
4
+ * Query-time complement to `supaTenantTables` (`./tenantTables`). Where
5
+ * `supaTenantTables` defines the tenant + junction tables, `supaTenantScope`
6
+ * generalizes the read-enforcement discipline Fount Studios built on top of
7
+ * its (hardcoded, `organizationId`-specific) equivalent —
8
+ * `apps/convex/lib/org.ts` (`rowInOrg`, `activeOrgMemberIds`,
9
+ * `resolveActiveOrg`/`getCurrentOrg`/`requireOrg`) — parameterized by
10
+ * `tenantName` instead of a fixed field name, so it derives the exact same
11
+ * `{tenantName}Id` / `user{TenantName}s` identifiers `supaTenantTables` uses.
12
+ *
13
+ * Fount's pattern in one line: resolve the active tenant ONCE at the top of a
14
+ * handler, then filter every collected row with a cheap in-memory check — a
15
+ * null active tenant degrades to unfiltered reads (migration/pre-backfill
16
+ * safety net) rather than an error.
17
+ *
18
+ * Usage:
19
+ * ```ts
20
+ * // convex/schema.ts
21
+ * import { defineSchema } from "convex/server";
22
+ * import { supaAuthTables, supaTenantTables } from "@supa-media/convex/schema";
23
+ *
24
+ * const tenantName = "organization";
25
+ * export default defineSchema({
26
+ * ...supaAuthTables,
27
+ * ...supaTenantTables({ tenantName }),
28
+ * });
29
+ *
30
+ * // convex/lib/tenant.ts
31
+ * import { supaTenantScope } from "@supa-media/convex/schema";
32
+ * export const orgScope = supaTenantScope({ tenantName: "organization" });
33
+ *
34
+ * // convex/functions/bookings.ts
35
+ * import { requireAuthId } from "@supa-media/convex/auth";
36
+ * import { orgScope } from "../lib/tenant";
37
+ *
38
+ * export const list = query({
39
+ * handler: async (ctx) => {
40
+ * const userId = await requireAuthId(ctx);
41
+ * const orgId = await orgScope.getCurrentTenantId(ctx, userId);
42
+ * return (await ctx.db.query("bookings").collect())
43
+ * .filter((row) => orgScope.rowInTenant(row, orgId));
44
+ * },
45
+ * });
46
+ * ```
47
+ *
48
+ * Deviations from Fount's `lib/org.ts` (intentional, for genericity — see
49
+ * `packages/convex` PR description for the full callout):
50
+ * - No Super Admin cross-tenant bypass. Fount's `requireOrg`/`requireMembership`
51
+ * let a global "Super Admin" role skip the membership check; that assumes an
52
+ * app-specific roles/permissions system this package doesn't ship. A
53
+ * consumer that needs a bypass wraps `requireTenantId` with its own check.
54
+ * - Auth resolution is decoupled. Fount's `requireOrg` calls `requireAuth`
55
+ * internally; here the caller resolves `userId` itself (e.g. via
56
+ * `requireAuthId` from `@supa-media/convex/auth`) and passes it in, so this
57
+ * module has no dependency on the auth module.
58
+ */
59
+
60
+ import { ConvexError } from "convex/values";
61
+
62
+ /** Config shared with `supaTenantTables` — must use the same `tenantName`. */
63
+ export interface TenantScopeConfig {
64
+ /** Name for the tenant entity (e.g. "organization", "workspace", "community"). Must match the `tenantName` passed to `supaTenantTables`. */
65
+ tenantName: string;
66
+ }
67
+
68
+ /** Minimal DB context — works with Convex `QueryCtx` and `MutationCtx` without importing generated types. */
69
+ interface TenantScopeCtx {
70
+ db: {
71
+ get: (id: any) => Promise<any>;
72
+ query: (table: string) => any;
73
+ };
74
+ }
75
+
76
+ /** A row carrying a `{tenantName}Id` field, of unknown shape otherwise. */
77
+ type TenantScopedRow = Record<string, any>;
78
+
79
+ export interface SupaTenantScope {
80
+ /** The tenant id field name on scoped rows/documents, e.g. "organizationId". */
81
+ tenantIdField: string;
82
+ /** The field on `users` storing the user's active tenant, e.g. "activeOrganizationId". Not added by `supaAuthTables` — the consumer owns this field on their `users` table. */
83
+ activeTenantField: string;
84
+ /** The junction table name from `supaTenantTables`, e.g. "userOrganizations". */
85
+ junctionTableName: string;
86
+
87
+ /**
88
+ * Whether an org-stamped row belongs to the given tenant. A `null` tenantId
89
+ * (unresolvable — e.g. mid-migration, before backfill) degrades to
90
+ * unfiltered reads so the app keeps working; once a tenant is active, only
91
+ * its rows pass. Mirrors fount's `rowInOrg`.
92
+ */
93
+ rowInTenant(row: TenantScopedRow, tenantId: string | null): boolean;
94
+
95
+ /**
96
+ * Resolve the active tenant for a user id, or `null` if unresolvable.
97
+ * Prefers `users.{activeTenantField}`; if unset, falls back to the user's
98
+ * sole active membership row (a single-tenant user never needs an explicit
99
+ * switch). Returns `null` when ambiguous (0 or 2+ active memberships) —
100
+ * mirrors fount's `resolveActiveOrg`/`getCurrentOrg`.
101
+ */
102
+ getCurrentTenantId(
103
+ ctx: TenantScopeCtx,
104
+ userId: string,
105
+ ): Promise<string | null>;
106
+
107
+ /**
108
+ * Whether a user has an active membership row for the given tenant, via
109
+ * the junction table's `by_userId_{tenantIdField}` compound index.
110
+ */
111
+ isMemberOfTenant(
112
+ ctx: TenantScopeCtx,
113
+ userId: string,
114
+ tenantId: string,
115
+ ): Promise<boolean>;
116
+
117
+ /**
118
+ * Require a resolvable active tenant AND an active membership in it.
119
+ * Throws `ConvexError({ code: "NO_ACTIVE_TENANT" })` if no tenant is
120
+ * resolvable, `ConvexError({ code: "FORBIDDEN" })` if the user has no active
121
+ * membership in it. Mirrors fount's `requireOrg` (minus the Super Admin
122
+ * bypass — see module-level deviation note).
123
+ */
124
+ requireTenantId(ctx: TenantScopeCtx, userId: string): Promise<string>;
125
+
126
+ /**
127
+ * The set of user ids with an active membership in the tenant, for scoping
128
+ * queries over tables that have no `{tenantName}Id` column of their own
129
+ * (e.g. `users` — tenant membership lives in the junction table). Mirrors
130
+ * fount's `activeOrgMemberIds`.
131
+ */
132
+ activeTenantMemberIds(
133
+ ctx: TenantScopeCtx,
134
+ tenantId: string,
135
+ ): Promise<Set<string>>;
136
+ }
137
+
138
+ /**
139
+ * Build the tenant-scoping helpers for a given `tenantName`. The returned
140
+ * functions derive the same `{tenantName}Id` / `user{TenantName}s` identifiers
141
+ * `supaTenantTables` uses, so the two stay in lockstep as long as both are
142
+ * configured with the same `tenantName`.
143
+ */
144
+ export function supaTenantScope(config: TenantScopeConfig): SupaTenantScope {
145
+ const { tenantName } = config;
146
+ const capitalName =
147
+ tenantName.charAt(0).toUpperCase() + tenantName.slice(1);
148
+ const tenantIdField = `${tenantName}Id`;
149
+ const activeTenantField = `active${capitalName}Id`;
150
+ const junctionTableName = `user${capitalName}s`;
151
+
152
+ function rowInTenant(row: TenantScopedRow, tenantId: string | null): boolean {
153
+ return tenantId === null || row[tenantIdField] === tenantId;
154
+ }
155
+
156
+ async function findMembership(
157
+ ctx: TenantScopeCtx,
158
+ userId: string,
159
+ tenantId: string,
160
+ ): Promise<TenantScopedRow | null> {
161
+ return await ctx.db
162
+ .query(junctionTableName)
163
+ .withIndex(`by_userId_${tenantIdField}`, (q: any) =>
164
+ q.eq("userId", userId).eq(tenantIdField, tenantId),
165
+ )
166
+ .unique();
167
+ }
168
+
169
+ async function isMemberOfTenant(
170
+ ctx: TenantScopeCtx,
171
+ userId: string,
172
+ tenantId: string,
173
+ ): Promise<boolean> {
174
+ const membership = await findMembership(ctx, userId, tenantId);
175
+ return membership !== null && membership.isActive !== false;
176
+ }
177
+
178
+ async function getCurrentTenantId(
179
+ ctx: TenantScopeCtx,
180
+ userId: string,
181
+ ): Promise<string | null> {
182
+ const user = await ctx.db.get(userId);
183
+ if (user === null) return null;
184
+ if (user[activeTenantField]) return user[activeTenantField];
185
+
186
+ const memberships = await ctx.db
187
+ .query(junctionTableName)
188
+ .withIndex("by_userId", (q: any) => q.eq("userId", userId))
189
+ .collect();
190
+ const active = memberships.filter(
191
+ (m: TenantScopedRow) => m.isActive !== false,
192
+ );
193
+ return active.length === 1 ? active[0][tenantIdField] : null;
194
+ }
195
+
196
+ async function requireTenantId(
197
+ ctx: TenantScopeCtx,
198
+ userId: string,
199
+ ): Promise<string> {
200
+ const tenantId = await getCurrentTenantId(ctx, userId);
201
+ if (tenantId === null) {
202
+ throw new ConvexError({
203
+ code: "NO_ACTIVE_TENANT",
204
+ message: `No active ${tenantName} for this user`,
205
+ });
206
+ }
207
+ if (!(await isMemberOfTenant(ctx, userId, tenantId))) {
208
+ throw new ConvexError({
209
+ code: "FORBIDDEN",
210
+ message: `No active membership in the active ${tenantName}`,
211
+ });
212
+ }
213
+ return tenantId;
214
+ }
215
+
216
+ async function activeTenantMemberIds(
217
+ ctx: TenantScopeCtx,
218
+ tenantId: string,
219
+ ): Promise<Set<string>> {
220
+ const memberships = await ctx.db
221
+ .query(junctionTableName)
222
+ .withIndex(`by_${tenantIdField}`, (q: any) =>
223
+ q.eq(tenantIdField, tenantId),
224
+ )
225
+ .collect();
226
+ return new Set(
227
+ memberships
228
+ .filter((m: TenantScopedRow) => m.isActive !== false)
229
+ .map((m: TenantScopedRow) => String(m.userId)),
230
+ );
231
+ }
232
+
233
+ return {
234
+ tenantIdField,
235
+ activeTenantField,
236
+ junctionTableName,
237
+ rowInTenant,
238
+ getCurrentTenantId,
239
+ isMemberOfTenant,
240
+ requireTenantId,
241
+ activeTenantMemberIds,
242
+ };
243
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tenant Tables
3
+ *
4
+ * Creates multi-tenant schema tables with configurable names.
5
+ * Provides a tenants table, a userTenants junction table, and
6
+ * adds an activeTenantId concept to the user model.
7
+ *
8
+ * Usage:
9
+ * ```ts
10
+ * import { defineSchema } from "convex/server";
11
+ * import { supaAuthTables, supaTenantTables } from "@supa-media/convex/schema";
12
+ *
13
+ * const tenantTables = supaTenantTables({
14
+ * tenantName: "organization",
15
+ * tenantFields: {
16
+ * website: v.optional(v.string()),
17
+ * },
18
+ * });
19
+ *
20
+ * export default defineSchema({
21
+ * ...supaAuthTables,
22
+ * ...tenantTables,
23
+ * });
24
+ * ```
25
+ */
26
+
27
+ import { defineTable } from "convex/server";
28
+ import { v, type Validator } from "convex/values";
29
+
30
+ export interface TenantTableConfig {
31
+ /** Name for the tenant entity (e.g. "organization", "workspace", "community"). */
32
+ tenantName: string;
33
+ /** Additional fields to add to the tenant table. */
34
+ tenantFields?: Record<string, Validator<any, any, any>>;
35
+ }
36
+
37
+ /**
38
+ * Generate multi-tenant tables based on config.
39
+ *
40
+ * Returns an object with two table definitions:
41
+ * - `{tenantName}s` — the tenants table (e.g. "organizations")
42
+ * - `user{TenantName}s` — the junction table (e.g. "userOrganizations")
43
+ *
44
+ * The junction table includes:
45
+ * - userId, {tenantName}Id — foreign keys
46
+ * - role — user's role within the tenant
47
+ * - isActive — soft delete flag
48
+ * - joinedAt — when the user joined
49
+ */
50
+ export function supaTenantTables(config: TenantTableConfig) {
51
+ const { tenantName, tenantFields = {} } = config;
52
+ const capitalName =
53
+ tenantName.charAt(0).toUpperCase() + tenantName.slice(1);
54
+ const tenantsTableName = `${tenantName}s`;
55
+ const junctionTableName = `user${capitalName}s`;
56
+ const tenantIdField = `${tenantName}Id`;
57
+
58
+ const tenantsTable = defineTable({
59
+ name: v.string(),
60
+ slug: v.optional(v.string()),
61
+ image: v.optional(v.string()),
62
+ isActive: v.optional(v.boolean()),
63
+ createdAt: v.optional(v.number()),
64
+ ...tenantFields,
65
+ })
66
+ .index("by_slug", ["slug"])
67
+ .index("by_name", ["name"]);
68
+
69
+ const junctionTable = defineTable({
70
+ userId: v.id("users"),
71
+ [`${tenantName}Id`]: v.string(), // v.id() requires literal table name; use string for flexibility
72
+ role: v.optional(v.string()),
73
+ isActive: v.optional(v.boolean()),
74
+ joinedAt: v.optional(v.number()),
75
+ })
76
+ .index("by_userId", ["userId"])
77
+ .index(`by_${tenantIdField}`, [tenantIdField])
78
+ .index(`by_userId_${tenantIdField}`, ["userId", tenantIdField]);
79
+
80
+ return {
81
+ [tenantsTableName]: tenantsTable,
82
+ [junctionTableName]: junctionTable,
83
+ } as Record<string, ReturnType<typeof defineTable>>;
84
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Generic HMAC Signature Verification
3
+ *
4
+ * A small, dependency-free core for verifying HMAC-signed webhook requests
5
+ * (Stripe, GitHub, custom internal callbacks, ...) from inside a Convex
6
+ * `httpAction`. Convex's default runtime doesn't have `node:crypto`, so this
7
+ * uses the standard Web Crypto API (`crypto.subtle`) — the same approach
8
+ * production code in both Fount Studios and Togather already relies on for
9
+ * hand-rolled webhook verification (as opposed to pulling in a provider SDK).
10
+ *
11
+ * Provider-specific verifiers (`verifyStripeSignature`, `verifyTwilioSignature`,
12
+ * ...) are built on top of this — see the sibling files in this directory.
13
+ */
14
+
15
+ /**
16
+ * Constant-time string comparison to prevent timing attacks.
17
+ * Compares every character regardless of where a mismatch occurs, so an
18
+ * attacker can't use response-time differences to guess the signature
19
+ * byte-by-byte.
20
+ */
21
+ export function timingSafeEqual(a: string, b: string): boolean {
22
+ if (a.length !== b.length) return false;
23
+ let result = 0;
24
+ for (let i = 0; i < a.length; i++) {
25
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
26
+ }
27
+ return result === 0;
28
+ }
29
+
30
+ function toHex(bytes: ArrayBuffer): string {
31
+ return Array.from(new Uint8Array(bytes))
32
+ .map((b) => b.toString(16).padStart(2, "0"))
33
+ .join("");
34
+ }
35
+
36
+ function toBase64(bytes: ArrayBuffer): string {
37
+ let binary = "";
38
+ const view = new Uint8Array(bytes);
39
+ for (let i = 0; i < view.byteLength; i++) {
40
+ binary += String.fromCharCode(view[i] as number);
41
+ }
42
+ // btoa is a Web standard available in the Convex runtime.
43
+ return btoa(binary);
44
+ }
45
+
46
+ /** Compute an HMAC digest and encode it as hex or base64. */
47
+ export async function computeHmac(
48
+ secret: string,
49
+ message: string,
50
+ options: { hash?: "SHA-256" | "SHA-1"; encoding?: "hex" | "base64" } = {},
51
+ ): Promise<string> {
52
+ const { hash = "SHA-256", encoding = "hex" } = options;
53
+ const encoder = new TextEncoder();
54
+ const key = await crypto.subtle.importKey(
55
+ "raw",
56
+ encoder.encode(secret),
57
+ { name: "HMAC", hash },
58
+ false,
59
+ ["sign"],
60
+ );
61
+ const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
62
+ return encoding === "base64" ? toBase64(digest) : toHex(digest);
63
+ }
64
+
65
+ export interface VerifyHmacSignatureOptions {
66
+ /** Hash algorithm backing the HMAC. Defaults to "SHA-256". */
67
+ hash?: "SHA-256" | "SHA-1";
68
+ /** Digest encoding the provided signature is expressed in. Defaults to "hex". */
69
+ encoding?: "hex" | "base64";
70
+ /**
71
+ * Literal prefix the provided signature must start with (e.g. GitHub's
72
+ * `"sha256="` on `X-Hub-Signature-256`). Stripped before comparing. A
73
+ * signature missing this prefix is rejected. Omit for headers that carry
74
+ * the bare digest (e.g. a custom `x-app-signature: <hex>` header).
75
+ */
76
+ prefix?: string;
77
+ }
78
+
79
+ /**
80
+ * Verify an HMAC-signed webhook payload against a provided signature header
81
+ * value. Generic building block for simple "HMAC over the raw body" schemes —
82
+ * providers with a more structured signing input (Stripe's `t=...,v1=...`,
83
+ * Twilio's URL+params concatenation) layer their own signing-input
84
+ * construction on top of {@link computeHmac} / {@link timingSafeEqual} instead
85
+ * (see `./stripe` and `./twilio`).
86
+ *
87
+ * Usage (GitHub `X-Hub-Signature-256`, e.g. dispatching a repo webhook):
88
+ * ```ts
89
+ * const ok = await verifyHmacSignature(rawBody, request.headers.get("x-hub-signature-256"), secret, {
90
+ * prefix: "sha256=",
91
+ * });
92
+ * ```
93
+ *
94
+ * Usage (a bare hex-digest internal callback header, no prefix):
95
+ * ```ts
96
+ * const ok = await verifyHmacSignature(rawBody, request.headers.get("x-app-signature"), secret);
97
+ * ```
98
+ */
99
+ export async function verifyHmacSignature(
100
+ payload: string,
101
+ providedSignature: string | null | undefined,
102
+ secret: string,
103
+ options: VerifyHmacSignatureOptions = {},
104
+ ): Promise<boolean> {
105
+ if (!providedSignature || !secret) return false;
106
+
107
+ const { prefix } = options;
108
+ let candidate = providedSignature;
109
+ if (prefix !== undefined) {
110
+ if (!candidate.startsWith(prefix)) return false;
111
+ candidate = candidate.slice(prefix.length);
112
+ }
113
+
114
+ try {
115
+ const expected = await computeHmac(secret, payload, options);
116
+ // Hex digests are case-insensitive (providers vary on casing); base64
117
+ // digests are case-sensitive and must compare exactly.
118
+ const encoding = options.encoding ?? "hex";
119
+ return encoding === "hex"
120
+ ? timingSafeEqual(candidate.toLowerCase(), expected.toLowerCase())
121
+ : timingSafeEqual(candidate, expected);
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Webhook Signature Verification
3
+ *
4
+ * Dependency-free helpers for verifying signed (or shared-secret-gated)
5
+ * inbound webhooks from inside a Convex `httpAction`. Built on the Web Crypto
6
+ * API (`crypto.subtle`) so nothing here needs `node:crypto` or a provider SDK.
7
+ *
8
+ * - `verifyHmacSignature` / `computeHmac` / `timingSafeEqual` — the generic
9
+ * core for "HMAC over the raw body, compare against a header" schemes
10
+ * (GitHub's `X-Hub-Signature-256`, custom internal callback signing, ...).
11
+ * - `verifyStripeSignature` — Stripe's `t=...,v1=...` scheme with timestamp
12
+ * tolerance and multi-signature (secret rotation) support.
13
+ * - `verifyTwilioSignature` — Twilio's URL+sorted-params signing scheme.
14
+ * - `verifySharedSecretHeader` — for providers with no signing scheme at all
15
+ * (e.g. Resend inbound email), gated by a constant shared-secret header
16
+ * instead of a cryptographic signature.
17
+ *
18
+ * Ported from production webhook handlers in Fount Studios
19
+ * (`apps/convex/lib/webhooks/*`) and Togather (`apps/convex/http.ts`).
20
+ *
21
+ * NOTE: unlike most of this package's subpaths, this module is deliberately
22
+ * NOT re-exported from the package root (`@supa-media/convex`). The
23
+ * `./payments` subpath already exports its own `verifyStripeSignature` (a
24
+ * simpler, single-signature variant scoped to `handleStripeWebhook`); adding
25
+ * this module's `verifyStripeSignature` to the root barrel would collide with
26
+ * it. Import from `@supa-media/convex/webhooks` explicitly.
27
+ */
28
+ export {
29
+ verifyHmacSignature,
30
+ computeHmac,
31
+ timingSafeEqual,
32
+ } from "./hmac";
33
+ export type { VerifyHmacSignatureOptions } from "./hmac";
34
+
35
+ export { verifyStripeSignature } from "./stripe";
36
+ export type { VerifyStripeSignatureOptions } from "./stripe";
37
+
38
+ export { verifyTwilioSignature } from "./twilio";
39
+ export type { VerifyTwilioSignatureArgs } from "./twilio";
40
+
41
+ export { verifySharedSecretHeader } from "./sharedSecret";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared-secret header verification.
3
+ *
4
+ * Not every inbound webhook is HMAC-signed. Fount Studios' Resend inbound-email
5
+ * handler (`apps/convex/lib/webhooks/resend.ts`, `shouldDropInbound`) is a real
6
+ * example: Resend does not sign inbound webhook payloads (no Svix/HMAC scheme
7
+ * in play there), so the production guard is a constant shared-secret header
8
+ * (`x-inbound-test-secret`) compared against an env var, used to gate
9
+ * non-production deployments from ingesting real mail. This helper generalizes
10
+ * that comparison — do not reach for `verifyHmacSignature` when a provider
11
+ * genuinely has no signing scheme; a plain shared secret is what fount's
12
+ * production code actually does here.
13
+ *
14
+ * Deviation from fount's inline version: the original does a plain `!==`
15
+ * string compare (fine for a low-value dev/test gate secret). This helper
16
+ * uses a timing-safe compare instead, since it's meant to be reused for
17
+ * higher-value secrets too.
18
+ */
19
+ import { timingSafeEqual } from "./hmac";
20
+
21
+ /** Minimal header-reader interface — matches both `Request.headers` and a plain `Headers` instance. */
22
+ interface HeaderReader {
23
+ get(name: string): string | null;
24
+ }
25
+
26
+ /**
27
+ * Verify a request carries the expected value in a given header, via a
28
+ * timing-safe comparison. Returns `false` if the expected secret is unset
29
+ * (nothing to compare against) or the header is missing/mismatched.
30
+ *
31
+ * Usage:
32
+ * ```ts
33
+ * // convex/http.ts — Resend inbound email (no signing scheme; shared secret only)
34
+ * import { verifySharedSecretHeader } from "@supa-media/convex/webhooks";
35
+ *
36
+ * const ok = verifySharedSecretHeader(request.headers, "x-inbound-test-secret", process.env.INBOUND_TEST_SECRET);
37
+ * ```
38
+ */
39
+ export function verifySharedSecretHeader(
40
+ headers: HeaderReader,
41
+ headerName: string,
42
+ expectedSecret: string | undefined,
43
+ ): boolean {
44
+ if (!expectedSecret) return false;
45
+ const provided = headers.get(headerName);
46
+ if (provided === null) return false;
47
+ return timingSafeEqual(provided, expectedSecret);
48
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Stripe webhook signature verification.
3
+ *
4
+ * Stripe signs webhooks with `Stripe-Signature: t=<timestamp>,v1=<hex hmac>[,v1=<hex hmac>...]`.
5
+ * The signed payload is `${timestamp}.${rawBody}`, HMAC-SHA256'd with the
6
+ * endpoint's webhook signing secret. Stripe can send multiple `v1=` values
7
+ * during a secret rotation window — any one matching is sufficient.
8
+ *
9
+ * This is a hand-rolled Web Crypto implementation (mirrors Togather's
10
+ * `apps/convex/http.ts`) rather than the `stripe` npm SDK's
11
+ * `stripe.webhooks.constructEventAsync`. The SDK works too, but pulling it
12
+ * into a framework package for signature verification alone would be a heavy
13
+ * runtime dependency for one function — the signing scheme itself is public
14
+ * and documented, so we verify it directly with `crypto.subtle`.
15
+ *
16
+ * https://docs.stripe.com/webhooks#verify-official-libraries
17
+ */
18
+ import { timingSafeEqual, computeHmac } from "./hmac";
19
+
20
+ export interface VerifyStripeSignatureOptions {
21
+ /** Max allowed clock skew between the signed timestamp and now, in seconds. Defaults to 300 (5 minutes), matching Stripe's own tolerance. */
22
+ toleranceSeconds?: number;
23
+ }
24
+
25
+ /**
26
+ * Verify a Stripe `Stripe-Signature` header against the raw request body.
27
+ * Returns `false` (never throws) on any malformed header, expired timestamp,
28
+ * or signature mismatch.
29
+ *
30
+ * Usage:
31
+ * ```ts
32
+ * // convex/http.ts
33
+ * import { verifyStripeSignature } from "@supa-media/convex/webhooks";
34
+ *
35
+ * http.route({
36
+ * path: "/stripe/webhook",
37
+ * method: "POST",
38
+ * handler: httpAction(async (ctx, request) => {
39
+ * const body = await request.text();
40
+ * const signature = request.headers.get("stripe-signature");
41
+ * const ok = await verifyStripeSignature(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
42
+ * if (!ok) return new Response("Invalid signature", { status: 400 });
43
+ * const event = JSON.parse(body);
44
+ * // ...
45
+ * }),
46
+ * });
47
+ * ```
48
+ */
49
+ export async function verifyStripeSignature(
50
+ payload: string,
51
+ signatureHeader: string | null | undefined,
52
+ secret: string,
53
+ options: VerifyStripeSignatureOptions = {},
54
+ ): Promise<boolean> {
55
+ if (!signatureHeader || !secret) return false;
56
+ const { toleranceSeconds = 300 } = options;
57
+
58
+ try {
59
+ // Parse signature header into timestamp and all v1 signatures. Stripe
60
+ // may send multiple v1 signatures during secret rotation, so we collect
61
+ // them all and match against any one.
62
+ let timestamp = "";
63
+ const v1Signatures: string[] = [];
64
+
65
+ for (const part of signatureHeader.split(",")) {
66
+ const [key, value] = part.split("=");
67
+ if (key === undefined || value === undefined) continue;
68
+ const trimmedKey = key.trim();
69
+ if (trimmedKey === "t") {
70
+ timestamp = value;
71
+ } else if (trimmedKey === "v1") {
72
+ v1Signatures.push(value);
73
+ }
74
+ }
75
+
76
+ if (!timestamp || v1Signatures.length === 0) return false;
77
+
78
+ // Check timestamp is within tolerance to prevent replay attacks.
79
+ const currentTime = Math.floor(Date.now() / 1000);
80
+ if (Math.abs(currentTime - parseInt(timestamp, 10)) > toleranceSeconds) {
81
+ return false;
82
+ }
83
+
84
+ const signedPayload = `${timestamp}.${payload}`;
85
+ const computedSig = await computeHmac(secret, signedPayload, {
86
+ hash: "SHA-256",
87
+ encoding: "hex",
88
+ });
89
+
90
+ // Accept if any v1 signature matches (constant-time comparison).
91
+ return v1Signatures.some((sig) =>
92
+ timingSafeEqual(sig.toLowerCase(), computedSig.toLowerCase()),
93
+ );
94
+ } catch {
95
+ return false;
96
+ }
97
+ }