@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
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";
|