@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.
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/package.json +36 -0
- package/src/auth/helpers.ts +99 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/setup.ts +497 -0
- package/src/index.ts +73 -0
- package/src/lib/index.ts +8 -0
- package/src/lib/rateLimit.ts +92 -0
- package/src/lib/scheduling.ts +41 -0
- package/src/lib/validation.ts +63 -0
- package/src/notifications/index.ts +242 -0
- package/src/payments/index.ts +404 -0
- package/src/schema/authTables.ts +38 -0
- package/src/schema/chatTables.ts +58 -0
- package/src/schema/index.ts +8 -0
- package/src/schema/notificationTables.ts +58 -0
- package/src/schema/paymentTables.ts +47 -0
- package/src/schema/tenantScoping.ts +243 -0
- package/src/schema/tenantTables.ts +84 -0
- package/src/webhooks/hmac.ts +125 -0
- package/src/webhooks/index.ts +41 -0
- package/src/webhooks/sharedSecret.ts +48 -0
- package/src/webhooks/stripe.ts +97 -0
- package/src/webhooks/twilio.ts +80 -0
|
@@ -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
|
+
}
|