@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Supa Media LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,517 @@
1
+ # @supa-media/convex
2
+
3
+ **The backend half of a Supa app: phone/email OTP auth on top of
4
+ `@convex-dev/auth`, base schema tables (users, tenants, chat, notifications,
5
+ payments, rate limits), tenant-scoping discipline, webhook signature
6
+ verification, and a handful of small server utilities.** It is for Convex apps —
7
+ the code here runs inside your `convex/` functions directory and nowhere else.
8
+
9
+ Most of it is not new engineering. The webhook verifiers, the tenant scoping and
10
+ the OTP wiring are ports of code that was already running in Fount Studios and
11
+ Togather, generalized so a third app does not have to write them a third time.
12
+ The source carries the reasoning; this README points at it.
13
+
14
+ ## Install
15
+
16
+ ```
17
+ pnpm add @supa-media/convex
18
+ ```
19
+
20
+ ### Peer dependencies
21
+
22
+ Two, both required — there is no optional peer in this package:
23
+
24
+ | Peer | Range |
25
+ | --- | --- |
26
+ | `convex` | `>=1.31.0` |
27
+ | `@convex-dev/auth` | `>=0.0.90` |
28
+
29
+ Install it into the workspace package that holds your Convex functions, not the
30
+ mobile app.
31
+
32
+ ## ⚠️ This package ships raw TypeScript, on purpose
33
+
34
+ `main` and `types` both point at `src/index.ts`. There is no `dist/`, no build
35
+ step, and no compiled artifact anywhere in the published tarball — only `src/`
36
+ and the licence. Convex's own bundler (esbuild) compiles your `convex/`
37
+ directory *and its dependencies*, so a build step here would be a second,
38
+ redundant compile of the same source.
39
+
40
+ What that means for you:
41
+
42
+ - **It works out of the box when imported from Convex functions.** That is the
43
+ supported consumption model, and the only one.
44
+ - **Its relative imports are extension-less** (`from "./hmac"`). Convex's bundler
45
+ and `moduleResolution: "bundler"` resolve those; plain Node's ESM loader does
46
+ **not**. Importing this package from a bare Node script, a plain `tsc`
47
+ `node16`/`nodenext` build, or any toolchain that expects compiled JS in
48
+ `node_modules` will fail to resolve. The package's own test runner needs a
49
+ 25-line resolve hook (`test/ts-loader.mjs`) to get around exactly this.
50
+ - **Your typechecker must be willing to read `.ts` inside `node_modules`.** If
51
+ you `skipLibCheck` or exclude `node_modules` from your program, you are fine;
52
+ if you have a strict `allowJs: false` + declaration-only expectation, you are
53
+ not.
54
+ - **Framework packages that ship raw TS do not depend on each other.** That is a
55
+ deliberate rule, not an oversight: `@supa-media/dev-assistant` keeps a local
56
+ copy of the HMAC helpers rather than importing this package, because pulling a
57
+ whole PR-pipeline package in to reach fifteen lines of Web Crypto would be
58
+ reuse in name only.
59
+
60
+ ## Subpaths
61
+
62
+ | Subpath | Exports |
63
+ | --- | --- |
64
+ | `.` | Everything from `./auth`, `./schema`, `./lib`, `./notifications`, `./payments` — but **not** `./webhooks` |
65
+ | `@supa-media/convex/auth` | `createSupaAuth`, `MAGIC_LINK_PROVIDER_ID`, `requireAuth`, `requireAuthId`, `getOptionalAuth`, `getCurrentUserId` |
66
+ | `@supa-media/convex/schema` | `supaAuthTables`, `supaTenantTables`, `supaTenantScope`, `supaChatTables`, `supaNotificationTables`, `supaPaymentTables` |
67
+ | `@supa-media/convex/lib` | `checkRateLimit`, `supaRateLimitTable`, `isValidPhone`, `isValidEmail`, `normalizePhone`, `normalizeEmail`, `CronSchedules`, `Delay` |
68
+ | `@supa-media/convex/notifications` | `registerPushToken`, `cleanupExpiredTokens`, `enqueueNotification`, `sendPushNotification`, `sendNotificationToUser`, `processNotificationQueue` |
69
+ | `@supa-media/convex/payments` | `getOrCreateCustomer`, `createCheckoutSession`, `getSubscriptionStatus`, `handleStripeWebhook`, `verifyStripeSignature` |
70
+ | `@supa-media/convex/webhooks` | `verifyHmacSignature`, `computeHmac`, `timingSafeEqual`, `verifyStripeSignature`, `verifyTwilioSignature`, `verifySharedSecretHeader` |
71
+
72
+ > **⚠️ `./webhooks` is deliberately absent from the root barrel.** Both it and
73
+ > `./payments` export a `verifyStripeSignature`, and they are not the same
74
+ > function — see [Two `verifyStripeSignature`s](#two-verifystripesignatures).
75
+ > Import from `@supa-media/convex/webhooks` explicitly.
76
+
77
+ ## Schema
78
+
79
+ Every table helper is a plain object of `defineTable()` results. Spread what you
80
+ want:
81
+
82
+ ```ts
83
+ // convex/schema.ts
84
+ import { defineSchema } from "convex/server";
85
+ import {
86
+ supaAuthTables,
87
+ supaTenantTables,
88
+ supaChatTables,
89
+ } from "@supa-media/convex/schema";
90
+
91
+ export default defineSchema({
92
+ ...supaAuthTables,
93
+ ...supaTenantTables({ tenantName: "organization" }),
94
+ ...supaChatTables,
95
+ // your tables…
96
+ });
97
+ ```
98
+
99
+ | Helper | Tables | Notes |
100
+ | --- | --- | --- |
101
+ | `supaAuthTables` | `@convex-dev/auth`'s `authTables`, plus `users` | `users`: `name`, `email`, `phone`, `image`, `emailVerificationTime`, `phoneVerificationTime`, `isActive`, `createdAt` — all optional. Indexed `by_email`, `by_phone`. |
102
+ | `supaTenantTables(config)` | `{tenantName}s` + `user{TenantName}s` | Factory, see below |
103
+ | `supaChatTables` | `channels`, `channelMembers`, `messages` | |
104
+ | `supaNotificationTables` | `pushTokens`, `notificationQueue` | Table names and indexes are hardcoded into `./notifications` |
105
+ | `supaPaymentTables` | `customers`, `subscriptions` | Table names and indexes are hardcoded into `./payments` |
106
+ | `supaRateLimitTable` (from `./lib`) | `rateLimits` | |
107
+
108
+ `createSupaAuth`'s user-creation callback writes `email`, `phone`, `name`,
109
+ `image`, the two verification timestamps, `isActive` and `createdAt` — so if you
110
+ define your own `users` table instead of using `supaAuthTables`, it must accept
111
+ those fields.
112
+
113
+ ### `supaTenantTables`
114
+
115
+ ```ts
116
+ function supaTenantTables(config: {
117
+ tenantName: string; // "organization", "workspace", "community"…
118
+ tenantFields?: Record<string, Validator<any, any, any>>;
119
+ }): Record<string, TableDefinition>
120
+ ```
121
+
122
+ Produces `{tenantName}s` (`name`, `slug`, `image`, `isActive`, `createdAt`, plus
123
+ your `tenantFields`; indexed `by_slug`, `by_name`) and the junction
124
+ `user{TenantName}s` (`userId`, `{tenantName}Id`, `role`, `isActive`, `joinedAt`;
125
+ indexed `by_userId`, `by_{tenantName}Id`, `by_userId_{tenantName}Id`).
126
+
127
+ > **⚠️ The junction's tenant FK is `v.string()`, not `v.id()`.** `v.id()` needs a
128
+ > literal table name, which a factory parameterized on `tenantName` does not
129
+ > have. You get no referential typing on that column — validate it yourself where
130
+ > it matters.
131
+
132
+ ### `supaTenantScope`
133
+
134
+ The query-time complement. Generalized from Fount Studios' `lib/org.ts`
135
+ (`rowInOrg` / `activeOrgMemberIds` / `requireOrg`), parameterized by
136
+ `tenantName` so it derives exactly the identifiers `supaTenantTables` created.
137
+
138
+ ```ts
139
+ const scope: SupaTenantScope = supaTenantScope({ tenantName: "organization" });
140
+
141
+ scope.tenantIdField; // "organizationId"
142
+ scope.activeTenantField; // "activeOrganizationId"
143
+ scope.junctionTableName; // "userOrganizations"
144
+
145
+ scope.rowInTenant(row, tenantId: string | null): boolean
146
+ scope.getCurrentTenantId(ctx, userId): Promise<string | null>
147
+ scope.isMemberOfTenant(ctx, userId, tenantId): Promise<boolean>
148
+ scope.requireTenantId(ctx, userId): Promise<string> // throws NO_ACTIVE_TENANT / FORBIDDEN
149
+ scope.activeTenantMemberIds(ctx, tenantId): Promise<Set<string>>
150
+ ```
151
+
152
+ The pattern in one line: resolve the active tenant **once** at the top of a
153
+ handler, then filter collected rows with the cheap in-memory `rowInTenant`.
154
+
155
+ ```ts
156
+ // convex/functions/bookings.ts
157
+ import { requireAuthId } from "@supa-media/convex/auth";
158
+ import { orgScope } from "../lib/tenant";
159
+
160
+ export const list = query({
161
+ handler: async (ctx) => {
162
+ const userId = await requireAuthId(ctx);
163
+ const orgId = await orgScope.getCurrentTenantId(ctx, userId);
164
+ return (await ctx.db.query("bookings").collect())
165
+ .filter((row) => orgScope.rowInTenant(row, orgId));
166
+ },
167
+ });
168
+ ```
169
+
170
+ Three things to know before you rely on it:
171
+
172
+ > **⚠️ `rowInTenant(row, null)` returns `true` for every row.** A null tenant id
173
+ > degrades to *unfiltered reads*. That is intentional — it is a migration safety
174
+ > net so an app keeps working before the backfill stamps rows — but it means a
175
+ > user whose active tenant cannot be resolved sees everything. `getCurrentTenantId`
176
+ > returns `null` whenever the user has 0 or 2+ active memberships and no explicit
177
+ > `activeTenantField`. On any surface where that would be a leak, use
178
+ > `requireTenantId`, which throws instead.
179
+
180
+ - **`activeTenantField` is yours to add.** `supaAuthTables` does not define
181
+ `active{TenantName}Id` on `users`. Add it to your own users table.
182
+ - **No super-admin bypass, and no auth coupling.** Fount's original let a global
183
+ Super Admin skip the membership check; that assumes a roles system this package
184
+ does not ship. Auth is decoupled too — you resolve `userId` yourself (via
185
+ `requireAuthId`) and pass it in. Wrap `requireTenantId` if you need a bypass.
186
+
187
+ ## Auth
188
+
189
+ ### `createSupaAuth`
190
+
191
+ ```ts
192
+ function createSupaAuth(config?: {
193
+ appName?: string;
194
+ methods?: Array<"email" | "phone">; // default ["email", "phone"]
195
+ magicLink?: SupaAuthMagicLinkConfig; // off unless present
196
+ resend?: { fromAddress: string; emailSubject?: (code) => string;
197
+ renderHtml?: (p: { code, email }) => string };
198
+ twilio?: { tokenBridgePath?: string }; // default "/api/internal/phone-token"
199
+ productionIdentifier?: string;
200
+ }): ReturnType<typeof convexAuth>
201
+ ```
202
+
203
+ Returns exactly what `convexAuth()` returns. The scaffold's `convex/auth.ts` is
204
+ the whole integration:
205
+
206
+ ```ts
207
+ import { createSupaAuth } from "@supa-media/convex/auth";
208
+
209
+ export const { auth, signIn, signOut, store, isAuthenticated } = createSupaAuth({
210
+ appName: "MyApp",
211
+ methods: ["email", "phone"],
212
+ resend: {
213
+ fromAddress: "auth@myapp.com",
214
+ emailSubject: (code) => `${code} is your MyApp code`,
215
+ },
216
+ });
217
+ ```
218
+
219
+ **Email OTP** posts to the Resend REST API directly with `fetch` — no `resend`
220
+ SDK dependency. With no `RESEND_API_KEY`, it logs the code to the Convex console
221
+ instead of failing, which is what makes local dev work.
222
+
223
+ **`createOrUpdateUser`** links a new auth account to an existing user by phone or
224
+ email before creating one, and refreshes the matching verification timestamp.
225
+ Note that both lookups use `ctx.db.query("users").filter(...)`, a full table
226
+ scan, even though `supaAuthTables` indexes `by_email` and `by_phone` — fine at
227
+ small scale, worth knowing at large.
228
+
229
+ **Dev bypass.** With `DEV_OTP_BYPASS=true`, the generator returns `000000`
230
+ instead of a random code. Pass `productionIdentifier` (a substring of your
231
+ production deployment name, e.g. `"giddy-donkey-905"`) and the bypass is refused
232
+ — with a console error — whenever `CONVEX_SITE_URL` contains it. Set that; it is
233
+ the only thing standing between a stray env var and a universally-known
234
+ production login code.
235
+
236
+ > **⚠️ Phone OTP needs a bridge endpoint that this package does not ship.**
237
+ > The phone provider POSTs `{ phone, token, expiresAt }` to
238
+ > `${CONVEX_SITE_URL}${tokenBridgePath}` with `Authorization: Bearer
239
+ > $PHONE_TOKEN_BRIDGE_SECRET`, and *separately* asks Twilio Verify to SMS its own
240
+ > code. Two codes are therefore in play: the `@convex-dev/auth` token you stashed,
241
+ > and Twilio's. **You must implement the bridge `httpAction` and the
242
+ > reconciliation** — check the user's Twilio code, look up the stashed token, call
243
+ > `signIn` with it. Neither piece is in this package. If `CONVEX_SITE_URL` or
244
+ > `PHONE_TOKEN_BRIDGE_SECRET` is unset, the provider logs the raw token to the
245
+ > console and returns, so local dev degrades rather than breaks.
246
+
247
+ ### Magic link (opt-in)
248
+
249
+ Set `magicLink: {}` and a **second** email provider is registered under
250
+ `MAGIC_LINK_PROVIDER_ID` (`"magic-link"`), gated on the `email` method.
251
+
252
+ ```ts
253
+ import { MAGIC_LINK_PROVIDER_ID } from "@supa-media/convex/auth";
254
+
255
+ // Mint the code yourself, put it in a URL, and mail it:
256
+ await ctx.runMutation(internal.auth.store, {
257
+ args: {
258
+ type: "createVerificationCode",
259
+ provider: MAGIC_LINK_PROVIDER_ID,
260
+ email,
261
+ code, // 32+ random bytes — see the warning below
262
+ expirationTime,
263
+ allowExtraProviders: false,
264
+ },
265
+ });
266
+ ```
267
+
268
+ Redemption needs nothing from you: `@convex-dev/auth`'s React provider reads the
269
+ `code` query param on mount, signs in, and strips it from the URL.
270
+
271
+ Why a separate provider rather than a flag on the OTP one — this is the part
272
+ worth reading before anyone "simplifies" it:
273
+
274
+ - `Email()` from `@convex-dev/auth` hardcodes an `authorize` that refuses any
275
+ verification without a matching `params.email`. Correct for a typed code,
276
+ wrong for a link whose URL is meant to carry everything. Its docstring says to
277
+ pass `authorize: undefined`; **in 0.0.90 that does nothing**, because the
278
+ factory builds its result field by field and never spreads `config`. The only
279
+ way to clear it is to spread the built provider and override afterwards.
280
+ - Clearing it on the *OTP* provider instead is one line shorter and looks
281
+ identical. It is not. `verifyCodeAndSignIn` derives its **rate-limit key from
282
+ `params.email`** — a verification carrying no email is not rate limited at all,
283
+ and the OTP secret is six digits. That turns a one-in-a-million guess against
284
+ one account into an unthrottled guess against every code in flight.
285
+ - `id` is overridden for the same reason `authorize` is: `Email()` hardcodes it
286
+ too, so without the override both providers would be called `"email"` and
287
+ `getProviderOrThrow` could not tell them apart — the separation would silently
288
+ be no separation.
289
+
290
+ The two cannot be confused at redemption: the library resolves which `authorize`
291
+ to run from the provider recorded **on the verification row**, not from what the
292
+ caller claims.
293
+
294
+ > **⚠️ Magic-link token entropy is the caller's responsibility.** This provider
295
+ > has no email check and no rate limit; the token *is* the secret. Mint 32 random
296
+ > bytes or more. Nothing here can check that, because your app mints the code.
297
+
298
+ `api.auth.signIn` is public, so anyone can request a link for an address they do
299
+ not own. That is a nuisance, not a hole: the library's own generator produces
300
+ ~190 bits, and the mail goes to the named address, not the requester. What an
301
+ attacker gets is the ability to invalidate somebody's pending code — which
302
+ `signIn("email", { email })` could always do too.
303
+
304
+ ### Auth helpers
305
+
306
+ ```ts
307
+ requireAuth(ctx): Promise<Record<string, any>> // throws NOT_AUTHENTICATED / USER_NOT_FOUND
308
+ requireAuthId(ctx): Promise<string> // throws NOT_AUTHENTICATED
309
+ getOptionalAuth(ctx): Promise<Record<string, any> | null>
310
+ getCurrentUserId(ctx): Promise<string | null>
311
+ ```
312
+
313
+ All four wrap `getAuthUserId` and take a duck-typed `{ db, auth }` context, so
314
+ they work in queries, mutations and actions without importing your generated
315
+ types. The two `require*` helpers throw **`ConvexError`** with a `{ code,
316
+ message }` payload — not a plain `Error` — which is what lets a client
317
+ distinguish "log in again" from "something broke".
318
+
319
+ Note the returns are `Record<string, any>` / `string`, not `Doc<"users">` /
320
+ `Id<"users">`: genericity costs you the generated types here. Cast at the call
321
+ site if you want them back.
322
+
323
+ ## Webhooks
324
+
325
+ Dependency-free verification for inbound webhooks, built on Web Crypto
326
+ (`crypto.subtle`) because the Convex runtime is a V8 isolate with no
327
+ `node:crypto`. Ported from production handlers in Fount Studios and Togather.
328
+
329
+ ```ts
330
+ timingSafeEqual(a: string, b: string): boolean
331
+ computeHmac(secret, message, opts?: { hash?: "SHA-256" | "SHA-1"; encoding?: "hex" | "base64" }): Promise<string>
332
+ verifyHmacSignature(payload, providedSignature, secret, opts?: { hash?, encoding?, prefix? }): Promise<boolean>
333
+ verifyStripeSignature(payload, signatureHeader, secret, opts?: { toleranceSeconds?: number }): Promise<boolean>
334
+ verifyTwilioSignature(args: { url, params, signatureHeader, authToken }): Promise<boolean>
335
+ verifySharedSecretHeader(headers, headerName, expectedSecret): boolean
336
+ ```
337
+
338
+ Every verifier **returns `false` rather than throwing** on a malformed header,
339
+ an expired timestamp, or a mismatch. Hex comparisons are case-folded (providers
340
+ disagree on casing); base64 comparisons are not.
341
+
342
+ ```ts
343
+ // convex/http.ts — Stripe
344
+ import { verifyStripeSignature } from "@supa-media/convex/webhooks";
345
+
346
+ http.route({
347
+ path: "/stripe/webhook",
348
+ method: "POST",
349
+ handler: httpAction(async (ctx, request) => {
350
+ const body = await request.text();
351
+ const ok = await verifyStripeSignature(
352
+ body,
353
+ request.headers.get("stripe-signature"),
354
+ process.env.STRIPE_WEBHOOK_SECRET!,
355
+ );
356
+ if (!ok) return new Response("Invalid signature", { status: 400 });
357
+ // …
358
+ }),
359
+ });
360
+ ```
361
+
362
+ ```ts
363
+ // GitHub's X-Hub-Signature-256 — the generic core, with a required prefix
364
+ const ok = await verifyHmacSignature(rawBody, request.headers.get("x-hub-signature-256"), secret, {
365
+ prefix: "sha256=",
366
+ });
367
+ ```
368
+
369
+ `verifyTwilioSignature` implements Twilio's own scheme — HMAC-SHA1, base64, over
370
+ `url + k1v1 + k2v2 + …` with keys sorted alphabetically:
371
+
372
+ ```ts
373
+ const params = Object.fromEntries(new URLSearchParams(await request.text()));
374
+ const ok = await verifyTwilioSignature({
375
+ url: process.env.CONVEX_SITE_URL + "/twilio/sms", // exactly as Twilio called it
376
+ params,
377
+ signatureHeader: request.headers.get("x-twilio-signature"),
378
+ authToken: process.env.TWILIO_AUTH_TOKEN!, // the auth token, not an API key secret
379
+ });
380
+ ```
381
+
382
+ > **⚠️ The Twilio URL must not be normalized.** Twilio signs the exact bytes it
383
+ > sent, query string included. Strip a trailing slash or reorder the query and
384
+ > verification fails.
385
+
386
+ `verifySharedSecretHeader` exists for providers with **no signing scheme at
387
+ all** — Resend's inbound email being the real case it was extracted from. Do not
388
+ reach for `verifyHmacSignature` when there is nothing to verify against; a
389
+ constant shared secret, compared in constant time, is the honest answer.
390
+
391
+ ### Two `verifyStripeSignature`s
392
+
393
+ | | `./webhooks` | `./payments` |
394
+ | --- | --- | --- |
395
+ | Returns | `boolean`, never throws | the parsed event, **throws** on failure |
396
+ | Multiple `v1=` (secret rotation) | accepts any match | first `v1=` only |
397
+ | Tolerance | configurable, default 300s | fixed 300s |
398
+ | Timing-safe compare | yes | yes (since 1.1.0 — it was a plain `!==` before) |
399
+
400
+ **Prefer the `./webhooks` one.** The `./payments` variant exists as a
401
+ convenience scoped to `handleStripeWebhook` and is the narrower of the two.
402
+
403
+ ## Notifications
404
+
405
+ Plain async functions over the Expo Push API — **not** Convex functions. You
406
+ wrap them in your own mutations/actions. They take a duck-typed `{ db }` context
407
+ and assume the exact table names and indexes from `supaNotificationTables`.
408
+
409
+ ```ts
410
+ registerPushToken(ctx, userId, token, platform: "ios" | "android" | "web"): Promise<void>
411
+ cleanupExpiredTokens(ctx, invalidTokens: string[]): Promise<number>
412
+ enqueueNotification(ctx, payload: NotificationPayload): Promise<string>
413
+ sendPushNotification(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]> // no ctx — network only
414
+ sendNotificationToUser(ctx, payload): Promise<{ sent: number; tokens: string[] }>
415
+ processNotificationQueue(ctx, batchSize = 100): Promise<{ processed: number; failed: number }>
416
+ ```
417
+
418
+ `registerPushToken` upserts, reassigning the token's `userId` if the same device
419
+ is now a different user. `processNotificationQueue` is built for a cron: it takes
420
+ `batchSize` pending rows and marks each `sent` or `failed` (with the error text
421
+ on the row).
422
+
423
+ > **⚠️ `sendPushNotification` calls `fetch`, so anything transitively reaching it
424
+ > — `sendNotificationToUser`, `processNotificationQueue` — must run in a Convex
425
+ > **action**, not a mutation. But those two also touch `ctx.db`, which an action
426
+ > does not have.** Split the work: read tokens / patch rows in mutations, do the
427
+ > HTTP call in an action, and drive it with `ctx.scheduler` or `ctx.runMutation`.
428
+
429
+ ## Payments
430
+
431
+ Stripe helpers that call the REST API with `fetch` and `URLSearchParams` — no
432
+ `stripe` SDK dependency. Same duck-typed `{ db }` context, same hardcoded
433
+ dependency on `supaPaymentTables`' `customers` / `subscriptions` tables.
434
+
435
+ ```ts
436
+ getOrCreateCustomer(ctx, userId): Promise<{ stripeCustomerId: string; isNew: boolean }>
437
+ createCheckoutSession(ctx, params: CheckoutSessionParams): Promise<{ url: string; sessionId: string }>
438
+ getSubscriptionStatus(ctx, userId): Promise<SubscriptionStatus>
439
+ handleStripeWebhook(ctx, event): Promise<void>
440
+ ```
441
+
442
+ `createCheckoutSession` always creates a `mode=subscription` session with a
443
+ single line item and stamps `metadata[convexUserId]`. `handleStripeWebhook`
444
+ handles four event types — `customer.subscription.created` / `.updated` /
445
+ `.deleted` and `checkout.session.completed` — upserting the `subscriptions` row
446
+ and converting Stripe's second-precision periods to milliseconds. It **does not
447
+ verify the signature**; verify before you call it. Anything else is ignored
448
+ silently. `getSubscriptionStatus` counts `active` and `trialing` as
449
+ `isActive: true`.
450
+
451
+ Both write paths read `STRIPE_SECRET_KEY` from `process.env` and throw a clear
452
+ error if it is unset.
453
+
454
+ ## Lib
455
+
456
+ ```ts
457
+ checkRateLimit(ctx, key: string, maxAttempts: number, windowMs: number): Promise<void>
458
+ ```
459
+
460
+ A DB-backed sliding-window counter over the `rateLimits` table
461
+ (`supaRateLimitTable`), meant for brute-force prevention on OTP endpoints. The
462
+ window auto-resets when it expires. Note it throws a **plain `Error`** with the
463
+ generic message "Too many attempts. Please try again later." — not a
464
+ `ConvexError` like the auth helpers, so a client cannot branch on a code.
465
+
466
+ ```ts
467
+ isValidPhone(phone): boolean // strict E.164: /^\+[1-9]\d{6,14}$/
468
+ isValidEmail(email): boolean // basic shape check, not exhaustive
469
+ normalizePhone(phone): string // strips spaces/dashes/parens/dots; THROWS if not E.164
470
+ normalizeEmail(email): string // trim + lowercase; THROWS if invalid
471
+ ```
472
+
473
+ `normalizePhone` does **not** add a country code — the input must already carry
474
+ one.
475
+
476
+ ```ts
477
+ CronSchedules.everyMinute | every5Minutes | every15Minutes | every30Minutes
478
+ | everyHour | daily | weekly | monthly
479
+ CronSchedules.dailyAt(hour: number) // 0-23, UTC
480
+ Delay.seconds(n) | minutes(n) | hours(n) | days(n) // → ms, for ctx.scheduler.runAfter
481
+ ```
482
+
483
+ ## Environment variables
484
+
485
+ | Var | Used by |
486
+ | --- | --- |
487
+ | `RESEND_API_KEY` | Email OTP send (absent → code logged to console) |
488
+ | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_VERIFY_SERVICE_SID` | Phone OTP send; the auth token also signs Twilio webhooks |
489
+ | `CONVEX_SITE_URL` | Phone token bridge target; production check for the dev bypass |
490
+ | `PHONE_TOKEN_BRIDGE_SECRET` | Bearer credential for your bridge endpoint |
491
+ | `DEV_OTP_BYPASS` | `"true"` forces the `000000` code — guard it with `productionIdentifier` |
492
+ | `STRIPE_SECRET_KEY` | `getOrCreateCustomer`, `createCheckoutSession` |
493
+ | `STRIPE_WEBHOOK_SECRET` | `./payments`' `verifyStripeSignature` fallback |
494
+
495
+ ## Maturity and test coverage
496
+
497
+ 56 tests across four files, run with `pnpm test` (`node --test` plus the
498
+ `test/ts-loader.mjs` resolve hook — no external test framework). Coverage is
499
+ deep in two places and absent in several:
500
+
501
+ | Area | Tests | |
502
+ | --- | --- | --- |
503
+ | `./webhooks` | 30 | Vector tests. Expected digests are computed independently with Node's `node:crypto`, not by calling `computeHmac`, so a bug in the implementation cannot also corrupt the expectation. Includes a real Fount Studios production fixture for Twilio. |
504
+ | `supaTenantScope` | 16 | Against an in-memory fake of the `db` interface — every branch of `getCurrentTenantId`, both `requireTenantId` throws, the null-tenant degradation |
505
+ | Magic link | 8 | Pins the upstream `Email()` factory's behaviour (that it ignores `id` and `authorize`) and that the OTP provider keeps its email check |
506
+ | `./payments` `verifyStripeSignature` | 2 | Accept valid, reject invalid |
507
+
508
+ **Untested here:** `./notifications` entirely, `checkRateLimit`, the validation
509
+ and scheduling helpers, every schema table definition, `createSupaAuth`'s
510
+ `createOrUpdateUser` linking logic, and the email/phone OTP send paths. Those are
511
+ exercised in the consuming apps, not in this package.
512
+
513
+ ---
514
+
515
+ Part of the **Supa Media framework** — https://github.com/Supa-Media/supa-framework
516
+
517
+ MIT licensed.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@supa-media/convex",
3
+ "version": "1.2.1",
4
+ "description": "Backend package for the Supa framework — OTP auth, schema helpers, and backend utilities for Convex",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "LICENSE"
10
+ ],
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./auth": "./src/auth/index.ts",
14
+ "./schema": "./src/schema/index.ts",
15
+ "./lib": "./src/lib/index.ts",
16
+ "./notifications": "./src/notifications/index.ts",
17
+ "./payments": "./src/payments/index.ts",
18
+ "./webhooks": "./src/webhooks/index.ts"
19
+ },
20
+ "peerDependencies": {
21
+ "convex": ">=1.31.0",
22
+ "@convex-dev/auth": ">=0.0.90"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Supa-Media/supa-framework.git",
27
+ "directory": "packages/convex"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org"
31
+ },
32
+ "license": "MIT",
33
+ "scripts": {
34
+ "test": "node --import ./test/register.mjs --test test/*.test.ts"
35
+ }
36
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Server-side Auth Helpers
3
+ *
4
+ * Convenience functions for requiring/checking authentication in Convex
5
+ * queries, mutations, and actions. Built on top of @convex-dev/auth.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * import { requireAuth, requireAuthId, getOptionalAuth } from "@supa-media/convex/auth";
10
+ *
11
+ * export const myQuery = query({
12
+ * handler: async (ctx) => {
13
+ * const user = await requireAuth(ctx);
14
+ * // user is guaranteed to exist
15
+ * },
16
+ * });
17
+ * ```
18
+ */
19
+
20
+ import { getAuthUserId } from "@convex-dev/auth/server";
21
+ import { ConvexError } from "convex/values";
22
+
23
+ /**
24
+ * Generic context type that works with Convex query, mutation, and action contexts.
25
+ * We use a minimal interface so consumers don't need to import generated types.
26
+ */
27
+ interface AuthContext {
28
+ db: {
29
+ get: (id: any) => Promise<any>;
30
+ };
31
+ auth: {
32
+ getUserIdentity: () => Promise<any>;
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Require authentication. Throws if the user is not authenticated.
38
+ * Returns the full user document from the users table.
39
+ */
40
+ export async function requireAuth<TCtx extends AuthContext>(
41
+ ctx: TCtx,
42
+ ): Promise<Record<string, any>> {
43
+ const userId = await getAuthUserId(ctx as any);
44
+ if (userId === null) {
45
+ throw new ConvexError({
46
+ code: "NOT_AUTHENTICATED",
47
+ message: "Not authenticated",
48
+ });
49
+ }
50
+ const user = await ctx.db.get(userId);
51
+ if (user === null) {
52
+ throw new ConvexError({
53
+ code: "USER_NOT_FOUND",
54
+ message: "User record not found",
55
+ });
56
+ }
57
+ return user;
58
+ }
59
+
60
+ /**
61
+ * Require authentication and return just the user ID.
62
+ * Throws if the user is not authenticated.
63
+ */
64
+ export async function requireAuthId<TCtx extends AuthContext>(
65
+ ctx: TCtx,
66
+ ): Promise<string> {
67
+ const userId = await getAuthUserId(ctx as any);
68
+ if (userId === null) {
69
+ throw new ConvexError({
70
+ code: "NOT_AUTHENTICATED",
71
+ message: "Not authenticated",
72
+ });
73
+ }
74
+ return userId;
75
+ }
76
+
77
+ /**
78
+ * Get the currently authenticated user, or null if not authenticated.
79
+ * Does not throw — useful for endpoints that work with or without auth.
80
+ */
81
+ export async function getOptionalAuth<TCtx extends AuthContext>(
82
+ ctx: TCtx,
83
+ ): Promise<Record<string, any> | null> {
84
+ const userId = await getAuthUserId(ctx as any);
85
+ if (userId === null) {
86
+ return null;
87
+ }
88
+ return await ctx.db.get(userId);
89
+ }
90
+
91
+ /**
92
+ * Get the current user ID, or null if not authenticated.
93
+ * Does not throw — lighter weight than getOptionalAuth when you only need the ID.
94
+ */
95
+ export async function getCurrentUserId<TCtx extends AuthContext>(
96
+ ctx: TCtx,
97
+ ): Promise<string | null> {
98
+ return await getAuthUserId(ctx as any);
99
+ }
@@ -0,0 +1,13 @@
1
+ export { createSupaAuth, MAGIC_LINK_PROVIDER_ID } from "./setup";
2
+ export type {
3
+ SupaAuthConfig,
4
+ SupaAuthMagicLinkConfig,
5
+ SupaAuthResendConfig,
6
+ SupaAuthTwilioConfig,
7
+ } from "./setup";
8
+ export {
9
+ requireAuth,
10
+ requireAuthId,
11
+ getOptionalAuth,
12
+ getCurrentUserId,
13
+ } from "./helpers";