@robelest/convex-auth 0.0.2 → 0.0.3-preview.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.
Files changed (173) hide show
  1. package/dist/bin.cjs +1 -1
  2. package/dist/client/index.d.ts +33 -9
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +79 -13
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +48 -0
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/index.d.ts +10 -4
  9. package/dist/component/index.d.ts.map +1 -1
  10. package/dist/component/index.js +8 -3
  11. package/dist/component/index.js.map +1 -1
  12. package/dist/component/public.d.ts +163 -3
  13. package/dist/component/public.d.ts.map +1 -1
  14. package/dist/component/public.js +124 -0
  15. package/dist/component/public.js.map +1 -1
  16. package/dist/component/schema.d.ts +81 -2
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/schema.js +45 -0
  19. package/dist/component/schema.js.map +1 -1
  20. package/dist/providers/anonymous.d.ts +3 -0
  21. package/dist/providers/anonymous.d.ts.map +1 -1
  22. package/dist/providers/anonymous.js +3 -0
  23. package/dist/providers/anonymous.js.map +1 -1
  24. package/dist/providers/credentials.d.ts +3 -0
  25. package/dist/providers/credentials.d.ts.map +1 -1
  26. package/dist/providers/credentials.js +3 -0
  27. package/dist/providers/credentials.js.map +1 -1
  28. package/dist/providers/email.d.ts +3 -0
  29. package/dist/providers/email.d.ts.map +1 -1
  30. package/dist/providers/email.js +3 -0
  31. package/dist/providers/email.js.map +1 -1
  32. package/dist/providers/passkey.d.ts +7 -1
  33. package/dist/providers/passkey.d.ts.map +1 -1
  34. package/dist/providers/passkey.js +7 -1
  35. package/dist/providers/passkey.js.map +1 -1
  36. package/dist/providers/password.d.ts +3 -0
  37. package/dist/providers/password.d.ts.map +1 -1
  38. package/dist/providers/password.js +3 -0
  39. package/dist/providers/password.js.map +1 -1
  40. package/dist/providers/phone.d.ts +3 -0
  41. package/dist/providers/phone.d.ts.map +1 -1
  42. package/dist/providers/phone.js +3 -0
  43. package/dist/providers/phone.js.map +1 -1
  44. package/dist/providers/totp.d.ts +8 -0
  45. package/dist/providers/totp.d.ts.map +1 -1
  46. package/dist/providers/totp.js +8 -0
  47. package/dist/providers/totp.js.map +1 -1
  48. package/dist/server/convex-auth.d.ts +185 -25
  49. package/dist/server/convex-auth.d.ts.map +1 -1
  50. package/dist/server/convex-auth.js +317 -58
  51. package/dist/server/convex-auth.js.map +1 -1
  52. package/dist/server/email-templates.d.ts +18 -0
  53. package/dist/server/email-templates.d.ts.map +1 -0
  54. package/dist/server/email-templates.js +74 -0
  55. package/dist/server/email-templates.js.map +1 -0
  56. package/dist/server/errors.d.ts +146 -0
  57. package/dist/server/errors.d.ts.map +1 -0
  58. package/dist/server/errors.js +176 -0
  59. package/dist/server/errors.js.map +1 -0
  60. package/dist/server/implementation/apiKey.d.ts +74 -0
  61. package/dist/server/implementation/apiKey.d.ts.map +1 -0
  62. package/dist/server/implementation/apiKey.js +139 -0
  63. package/dist/server/implementation/apiKey.js.map +1 -0
  64. package/dist/server/implementation/index.d.ts +151 -14
  65. package/dist/server/implementation/index.d.ts.map +1 -1
  66. package/dist/server/implementation/index.js +216 -24
  67. package/dist/server/implementation/index.js.map +1 -1
  68. package/dist/server/implementation/mutations/createAccountFromCredentials.d.ts.map +1 -1
  69. package/dist/server/implementation/mutations/createAccountFromCredentials.js +2 -1
  70. package/dist/server/implementation/mutations/createAccountFromCredentials.js.map +1 -1
  71. package/dist/server/implementation/mutations/createVerificationCode.d.ts +2 -2
  72. package/dist/server/implementation/mutations/index.d.ts +6 -6
  73. package/dist/server/implementation/mutations/modifyAccount.d.ts.map +1 -1
  74. package/dist/server/implementation/mutations/modifyAccount.js +2 -1
  75. package/dist/server/implementation/mutations/modifyAccount.js.map +1 -1
  76. package/dist/server/implementation/mutations/userOAuth.d.ts.map +1 -1
  77. package/dist/server/implementation/mutations/userOAuth.js +2 -1
  78. package/dist/server/implementation/mutations/userOAuth.js.map +1 -1
  79. package/dist/server/implementation/mutations/verifierSignature.d.ts.map +1 -1
  80. package/dist/server/implementation/mutations/verifierSignature.js +2 -1
  81. package/dist/server/implementation/mutations/verifierSignature.js.map +1 -1
  82. package/dist/server/implementation/passkey.d.ts.map +1 -1
  83. package/dist/server/implementation/passkey.js +28 -29
  84. package/dist/server/implementation/passkey.js.map +1 -1
  85. package/dist/server/implementation/provider.d.ts.map +1 -1
  86. package/dist/server/implementation/provider.js +5 -4
  87. package/dist/server/implementation/provider.js.map +1 -1
  88. package/dist/server/implementation/redirects.d.ts.map +1 -1
  89. package/dist/server/implementation/redirects.js +2 -1
  90. package/dist/server/implementation/redirects.js.map +1 -1
  91. package/dist/server/implementation/refreshTokens.d.ts.map +1 -1
  92. package/dist/server/implementation/refreshTokens.js +2 -1
  93. package/dist/server/implementation/refreshTokens.js.map +1 -1
  94. package/dist/server/implementation/signIn.d.ts.map +1 -1
  95. package/dist/server/implementation/signIn.js +8 -18
  96. package/dist/server/implementation/signIn.js.map +1 -1
  97. package/dist/server/implementation/totp.d.ts.map +1 -1
  98. package/dist/server/implementation/totp.js +16 -17
  99. package/dist/server/implementation/totp.js.map +1 -1
  100. package/dist/server/implementation/users.d.ts.map +1 -1
  101. package/dist/server/implementation/users.js +3 -2
  102. package/dist/server/implementation/users.js.map +1 -1
  103. package/dist/server/index.d.ts +157 -3
  104. package/dist/server/index.d.ts.map +1 -1
  105. package/dist/server/index.js +180 -17
  106. package/dist/server/index.js.map +1 -1
  107. package/dist/server/oauth/authorizationUrl.d.ts.map +1 -1
  108. package/dist/server/oauth/authorizationUrl.js +2 -1
  109. package/dist/server/oauth/authorizationUrl.js.map +1 -1
  110. package/dist/server/oauth/callback.d.ts.map +1 -1
  111. package/dist/server/oauth/callback.js +5 -4
  112. package/dist/server/oauth/callback.js.map +1 -1
  113. package/dist/server/oauth/checks.d.ts.map +1 -1
  114. package/dist/server/oauth/checks.js +2 -1
  115. package/dist/server/oauth/checks.js.map +1 -1
  116. package/dist/server/oauth/convexAuth.d.ts.map +1 -1
  117. package/dist/server/oauth/convexAuth.js +3 -2
  118. package/dist/server/oauth/convexAuth.js.map +1 -1
  119. package/dist/server/provider_utils.d.ts +2 -0
  120. package/dist/server/provider_utils.d.ts.map +1 -1
  121. package/dist/server/types.d.ts +240 -5
  122. package/dist/server/types.d.ts.map +1 -1
  123. package/dist/server/utils.d.ts.map +1 -1
  124. package/dist/server/utils.js +2 -1
  125. package/dist/server/utils.js.map +1 -1
  126. package/dist/server/version.d.ts +2 -0
  127. package/dist/server/version.d.ts.map +1 -0
  128. package/dist/server/version.js +3 -0
  129. package/dist/server/version.js.map +1 -0
  130. package/package.json +7 -2
  131. package/src/cli/index.ts +1 -1
  132. package/src/cli/utils.ts +248 -0
  133. package/src/client/index.ts +105 -15
  134. package/src/component/_generated/component.ts +61 -0
  135. package/src/component/index.ts +11 -2
  136. package/src/component/public.ts +142 -0
  137. package/src/component/schema.ts +52 -0
  138. package/src/providers/anonymous.ts +3 -0
  139. package/src/providers/credentials.ts +3 -0
  140. package/src/providers/email.ts +3 -0
  141. package/src/providers/passkey.ts +8 -1
  142. package/src/providers/password.ts +3 -0
  143. package/src/providers/phone.ts +3 -0
  144. package/src/providers/totp.ts +9 -0
  145. package/src/server/convex-auth.ts +385 -73
  146. package/src/server/email-templates.ts +77 -0
  147. package/src/server/errors.ts +269 -0
  148. package/src/server/implementation/apiKey.ts +186 -0
  149. package/src/server/implementation/index.ts +288 -28
  150. package/src/server/implementation/mutations/createAccountFromCredentials.ts +2 -1
  151. package/src/server/implementation/mutations/modifyAccount.ts +2 -3
  152. package/src/server/implementation/mutations/userOAuth.ts +2 -1
  153. package/src/server/implementation/mutations/verifierSignature.ts +2 -1
  154. package/src/server/implementation/passkey.ts +33 -35
  155. package/src/server/implementation/provider.ts +5 -8
  156. package/src/server/implementation/redirects.ts +2 -3
  157. package/src/server/implementation/refreshTokens.ts +2 -1
  158. package/src/server/implementation/signIn.ts +9 -18
  159. package/src/server/implementation/totp.ts +18 -21
  160. package/src/server/implementation/users.ts +4 -7
  161. package/src/server/index.ts +240 -37
  162. package/src/server/oauth/authorizationUrl.ts +2 -1
  163. package/src/server/oauth/callback.ts +5 -4
  164. package/src/server/oauth/checks.ts +3 -1
  165. package/src/server/oauth/convexAuth.ts +6 -3
  166. package/src/server/types.ts +254 -5
  167. package/src/server/utils.ts +3 -1
  168. package/src/server/version.ts +2 -0
  169. package/dist/server/portal.d.ts +0 -116
  170. package/dist/server/portal.d.ts.map +0 -1
  171. package/dist/server/portal.js +0 -294
  172. package/dist/server/portal.js.map +0 -1
  173. package/src/server/portal.ts +0 -375
@@ -6,11 +6,24 @@
6
6
  * ```ts
7
7
  * // convex/auth.ts
8
8
  * import { Auth, Portal } from "@robelest/convex-auth/component";
9
- * import github from "@auth/core/providers/github";
9
+ * import google from "@auth/core/providers/google";
10
10
  * import { components } from "./_generated/api";
11
11
  *
12
12
  * export const auth = new Auth(components.auth, {
13
- * providers: [github],
13
+ * providers: [google],
14
+ * email: {
15
+ * from: "My App <noreply@example.com>",
16
+ * send: async (_ctx, { from, to, subject, html }) => {
17
+ * await fetch("https://api.resend.com/emails", {
18
+ * method: "POST",
19
+ * headers: {
20
+ * Authorization: `Bearer ${process.env.AUTH_RESEND_KEY}`,
21
+ * "Content-Type": "application/json",
22
+ * },
23
+ * body: JSON.stringify({ from, to, subject, html }),
24
+ * });
25
+ * },
26
+ * },
14
27
  * });
15
28
  * export const { signIn, signOut, store } = auth;
16
29
  * export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
@@ -23,27 +36,36 @@ import {
23
36
  queryGeneric,
24
37
  mutationGeneric,
25
38
  internalMutationGeneric,
39
+ httpActionGeneric,
26
40
  } from "convex/server";
27
41
  import type { HttpRouter } from "convex/server";
28
42
  import { v } from "convex/values";
29
43
  import type { ComponentApi as AuthComponentApi } from "../component/_generated/component.js";
30
44
  import { Auth as AuthFactory } from "./implementation/index.js";
31
- import type { ConvexAuthConfig } from "./types.js";
45
+ import type { ConvexAuthConfig, EmailTransport } from "./types.js";
32
46
  import { registerStaticRoutes } from "@convex-dev/self-hosting";
33
47
  import { portalMagicLinkEmail } from "./portal-email.js";
34
- import email from "../providers/email.js";
48
+ import { defaultMagicLinkEmail } from "./email-templates.js";
49
+ import emailProvider from "../providers/email.js";
50
+ import { AUTH_VERSION } from "./version.js";
51
+ import { throwAuthError } from "./errors.js";
35
52
 
36
53
  // ============================================================================
37
54
  // Types
38
55
  // ============================================================================
39
56
 
40
57
  /**
41
- * Config for the ConvexAuth class. Extends the standard auth config.
58
+ * Config for the Auth class. Extends the standard auth config
59
+ * minus `component` (which is passed as the first constructor argument).
42
60
  *
43
- * Portal functionality (admin dashboard, magic link provider, static hosting)
44
- * is always available no configuration flag needed. The portal UI works
45
- * when you export `portalQuery`, `portalMutation`, `portalInternal` from
46
- * your `convex/auth.ts` and upload the portal static files via CLI.
61
+ * When `email` is configured, the library auto-registers:
62
+ * - A magic link provider (`id: "email"`) for user-facing sign-in
63
+ * - A portal provider (`id: "portal"`) for admin dashboard sign-in
64
+ *
65
+ * Portal functionality is always available — no configuration flag
66
+ * needed. The portal UI works when you export `portalQuery`,
67
+ * `portalMutation`, `portalInternal` from your `convex/auth.ts`
68
+ * and upload the portal static files via CLI.
47
69
  */
48
70
  export type AuthClassConfig = Omit<ConvexAuthConfig, "component">;
49
71
 
@@ -70,7 +92,7 @@ async function requirePortalAdmin(
70
92
  invite.role === "portalAdmin" && invite.acceptedByUserId === userId,
71
93
  );
72
94
  if (!isAdmin) {
73
- throw new Error("Not authorized: portal admin access required");
95
+ throwAuthError("PORTAL_NOT_AUTHORIZED");
74
96
  }
75
97
  }
76
98
 
@@ -84,7 +106,11 @@ async function requirePortalAdmin(
84
106
  *
85
107
  * ```ts
86
108
  * export const auth = new Auth(components.auth, {
87
- * providers: [github, resend({ ... })],
109
+ * providers: [google, password],
110
+ * email: {
111
+ * from: "My App <noreply@example.com>",
112
+ * send: (ctx, params) => resend.sendEmail(ctx, params),
113
+ * },
88
114
  * });
89
115
  * export const { signIn, signOut, store } = auth;
90
116
  * export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
@@ -106,23 +132,29 @@ export class Auth {
106
132
  readonly portalUrl: string;
107
133
 
108
134
  // ---- Proxied auth helper sub-objects ----
109
- /** User helpers: `.current(ctx)`, `.require(ctx)`, `.get(ctx, userId)`, `.viewer(ctx)` */
135
+ /** User helpers: `.current(ctx)`, `.require(ctx)`, `.get(ctx, userId)`, `.patch(ctx, userId, data)`, `.viewer(ctx)`, `.group.list(ctx, ...)`, `.group.get(ctx, ...)` */
110
136
  get user() { return this._auth.user; }
111
- /** Session helpers */
137
+ /** Session helpers: `.current(ctx)`, `.invalidate(ctx, { userId, except? })` */
112
138
  get session() { return this._auth.session; }
113
- /** Provider helpers */
139
+ /** Provider helpers: `.signIn(ctx, provider, args)` */
114
140
  get provider() { return this._auth.provider; }
115
- /** Account helpers */
141
+ /** Account helpers: `.create(ctx, args)`, `.get(ctx, args)`, `.updateCredentials(ctx, args)` */
116
142
  get account() { return this._auth.account; }
117
- /** Group helpers */
143
+ /** Group helpers: `.create(ctx, ...)`, `.get(ctx, id)`, `.list(ctx, ...)`, `.update(ctx, ...)`, `.delete(ctx, id)`, `.member.*` */
118
144
  get group() { return this._auth.group; }
119
- /** Invite helpers */
145
+ /** Invite helpers: `.create(ctx, ...)`, `.get(ctx, id)`, `.getByTokenHash(ctx, hash)`, `.list(ctx, ...)`, `.accept(ctx, ...)`, `.revoke(ctx, id)` */
120
146
  get invite() { return this._auth.invite; }
121
- /** Passkey helpers */
147
+ /** Passkey helpers: `.list(ctx, { userId })`, `.rename(ctx, id, name)`, `.remove(ctx, id)` */
122
148
  get passkey() { return this._auth.passkey; }
123
- /** TOTP helpers */
149
+ /** TOTP helpers: `.list(ctx, { userId })`, `.remove(ctx, id)` */
124
150
  get totp() { return this._auth.totp; }
151
+ /** API key helpers: `.create(ctx, ...)`, `.verify(ctx, rawKey)`, `.list(ctx, ...)`, `.get(ctx, id)`, `.update(ctx, ...)`, `.revoke(ctx, id)`, `.remove(ctx, id)` */
152
+ get key() { return this._auth.key; }
125
153
 
154
+ /**
155
+ * @param component - The auth component reference from `components.auth`.
156
+ * @param config - Auth configuration (providers, email transport, session, JWT, callbacks).
157
+ */
126
158
  constructor(component: AuthComponentApi, config: AuthClassConfig) {
127
159
  this.component = component;
128
160
 
@@ -131,45 +163,78 @@ export class Auth {
131
163
  ? `${process.env.CONVEX_SITE_URL.replace(/\/$/, "")}/auth`
132
164
  : "/auth";
133
165
 
134
- // Auto-register the `portal` email provider for magic link sign-in
166
+ const emailTransport = config.email;
135
167
  const providers = [...config.providers];
168
+
169
+ // Auto-register user-facing magic link provider when email is configured.
170
+ // Skipped if the user already registered their own provider with id "email".
171
+ const hasUserEmailProvider = providers.some(
172
+ (p) => typeof p === "object" && "id" in p && p.id === "email",
173
+ );
174
+ if (emailTransport && !hasUserEmailProvider) {
175
+ providers.push(
176
+ emailProvider({
177
+ id: "email",
178
+ maxAge: 60 * 60 * 24, // 24 hours
179
+ authorize: undefined, // Magic link — no OTP email check needed
180
+ async sendVerificationRequest({ identifier, url }, ctx) {
181
+ if (!ctx) {
182
+ throwAuthError("MISSING_ACTION_CONTEXT");
183
+ }
184
+ const { host } = new URL(url);
185
+ await emailTransport.send(ctx, {
186
+ from: emailTransport.from,
187
+ to: identifier,
188
+ subject: `Sign in to ${host}`,
189
+ html: defaultMagicLinkEmail(url, host),
190
+ });
191
+ },
192
+ }),
193
+ );
194
+ }
195
+
196
+ // Auto-register portal admin magic link provider.
197
+ // Uses its own styled dark-theme email template.
136
198
  providers.push(
137
- email({
199
+ emailProvider({
138
200
  id: "portal",
139
201
  maxAge: 60 * 60 * 24, // 24 hours
140
- authorize: undefined, // Magic link — no email check needed
141
- async sendVerificationRequest({ identifier, url, expires }) {
202
+ authorize: undefined, // Magic link — no OTP email check needed
203
+ async sendVerificationRequest({ identifier, url, expires }, ctx) {
204
+ if (!emailTransport) {
205
+ throwAuthError("EMAIL_CONFIG_REQUIRED");
206
+ }
207
+ if (!ctx) {
208
+ throwAuthError("MISSING_ACTION_CONTEXT");
209
+ }
210
+
211
+ // Check authorization BEFORE sending — only portal-authorized emails
212
+ const invites = await ctx.runQuery(component.public.inviteList, {
213
+ status: "accepted",
214
+ });
215
+ const hasAccess = invites.some(
216
+ (invite: any) => invite.role === "portalAdmin" && invite.email === identifier,
217
+ );
218
+ if (!hasAccess) {
219
+ throwAuthError("PORTAL_NOT_AUTHORIZED");
220
+ }
221
+
142
222
  const hours = Math.max(
143
223
  1,
144
224
  Math.floor((+expires - Date.now()) / (60 * 60 * 1000)),
145
225
  );
146
- const html = portalMagicLinkEmail(url, hours);
147
- const siteUrl = process.env.CONVEX_SITE_URL;
148
- if (!siteUrl) {
149
- throw new Error(
150
- "CONVEX_SITE_URL is required to send portal magic link email",
151
- );
152
- }
153
- const response = await fetch(`${siteUrl}/auth-email-dispatch`, {
154
- method: "POST",
155
- headers: {
156
- "Content-Type": "application/json",
157
- ...(process.env.AUTH_EMAIL_DISPATCH_SECRET
158
- ? {
159
- "x-auth-email-dispatch-secret":
160
- process.env.AUTH_EMAIL_DISPATCH_SECRET,
161
- }
162
- : {}),
163
- },
164
- body: JSON.stringify({
226
+ try {
227
+ await emailTransport.send(ctx, {
228
+ from: emailTransport.from,
165
229
  to: identifier,
166
- subject: "Sign in to Convex Auth Portal",
167
- html,
168
- }),
169
- });
170
- if (!response.ok) {
171
- throw new Error(
172
- `Could not send portal magic link email: ${response.status}`,
230
+ subject: "Sign in to Auth Portal",
231
+ html: portalMagicLinkEmail(url, hours),
232
+ });
233
+ } catch (e: unknown) {
234
+ throwAuthError(
235
+ "EMAIL_SEND_FAILED",
236
+ "Failed to send portal sign-in email.",
237
+ { detail: e instanceof Error ? e.message : String(e) },
173
238
  );
174
239
  }
175
240
  },
@@ -203,6 +268,10 @@ export class Auth {
203
268
  * auth.addHttpRoutes(http);
204
269
  * export default http;
205
270
  * ```
271
+ *
272
+ * @param http - The Convex HTTP router to register routes on.
273
+ * @param opts.pathPrefix - URL prefix for portal static files. Defaults to `"/auth"`.
274
+ * @param opts.spaFallback - Serve `index.html` for unmatched sub-paths. Defaults to `true`.
206
275
  */
207
276
  addHttpRoutes(
208
277
  http: HttpRouter,
@@ -211,9 +280,36 @@ export class Auth {
211
280
  // Core auth routes (OAuth, JWKS, etc.)
212
281
  this._auth.addHttpRoutes(http);
213
282
 
214
- // Portal static file serving
215
283
  const prefix = opts?.pathPrefix ?? "/auth";
216
284
 
285
+ // Portal configuration endpoint — serves Convex URLs + version info.
286
+ // The portal SPA fetches this at startup to discover its Convex backend,
287
+ // which is critical for custom domain deployments where the hostname
288
+ // alone doesn't reveal the Convex cloud URL.
289
+ // Registered as an exact path match before the static file prefix catch-all.
290
+ http.route({
291
+ path: `${prefix}/.well-known/portal-config`,
292
+ method: "GET",
293
+ handler: httpActionGeneric(async () => {
294
+ return new Response(
295
+ JSON.stringify({
296
+ convexUrl: process.env.CONVEX_CLOUD_URL,
297
+ siteUrl: process.env.CONVEX_SITE_URL,
298
+ version: AUTH_VERSION,
299
+ }),
300
+ {
301
+ status: 200,
302
+ headers: {
303
+ "Content-Type": "application/json",
304
+ "Cache-Control":
305
+ "public, max-age=60, stale-while-revalidate=60",
306
+ "Access-Control-Allow-Origin": "*",
307
+ },
308
+ },
309
+ );
310
+ }),
311
+ });
312
+
217
313
  // Create a shim that maps the self-hosting ComponentApi shape
218
314
  // to the auth component's portalBridge functions
219
315
  const selfHostingShim = {
@@ -241,15 +337,17 @@ export class Auth {
241
337
  // ============================================================================
242
338
 
243
339
  /**
244
- * Create portal function definitions from a ConvexAuth instance.
340
+ * Create portal function definitions from an `Auth` instance.
245
341
  *
246
- * This is a standalone function (not a class method) because Convex's
247
- * bundler can trace through `export const { x } = fn(instance)` but
248
- * cannot trace through `instance.method()`.
342
+ * Standalone function (not a class method) because Convex's bundler
343
+ * can trace `export const { x } = fn(instance)` but not `instance.method()`.
249
344
  *
250
345
  * ```ts
251
346
  * export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
252
347
  * ```
348
+ *
349
+ * @param auth - The `Auth` class instance from your `convex/auth.ts`.
350
+ * @returns `{ portalQuery, portalMutation, portalInternal }` — export all three.
253
351
  */
254
352
  export function Portal(auth: Auth) {
255
353
  const authComponent = auth.component;
@@ -309,7 +407,7 @@ export function Portal(auth: Auth) {
309
407
  case "validateInvite": {
310
408
  // userId param repurposed as tokenHash for this action
311
409
  const tokenHash = userId;
312
- if (!tokenHash) throw new Error("tokenHash required");
410
+ if (!tokenHash) throwAuthError("INVITE_TOKEN_REQUIRED");
313
411
  const invite = await ctx.runQuery(
314
412
  authComponent.public.inviteGetByTokenHash,
315
413
  { tokenHash },
@@ -328,8 +426,22 @@ export function Portal(auth: Auth) {
328
426
  authComponent.portalBridge.getCurrentDeployment,
329
427
  );
330
428
 
429
+ // ---- API Keys (portal admin) ----
430
+ case "listKeys":
431
+ return await ctx.runQuery(authComponent.public.keyList);
432
+
433
+ case "getUserKeys":
434
+ return await ctx.runQuery(authComponent.public.keyListByUserId, {
435
+ userId: userId!,
436
+ });
437
+
438
+ case "getKey":
439
+ return await ctx.runQuery(authComponent.public.keyGetById, {
440
+ keyId: userId!, // userId param repurposed as keyId
441
+ });
442
+
331
443
  default:
332
- throw new Error(`Unknown portal query action: ${action}`);
444
+ throwAuthError("PORTAL_UNKNOWN_ACTION", `Unknown portal query action: ${action}`);
333
445
  }
334
446
  },
335
447
  });
@@ -339,30 +451,42 @@ export function Portal(auth: Auth) {
339
451
  action: v.string(),
340
452
  sessionId: v.optional(v.string()),
341
453
  tokenHash: v.optional(v.string()),
454
+ // API key fields
455
+ keyId: v.optional(v.string()),
456
+ keyUserId: v.optional(v.string()),
457
+ keyName: v.optional(v.string()),
458
+ keyScopes: v.optional(
459
+ v.array(
460
+ v.object({
461
+ resource: v.string(),
462
+ actions: v.array(v.string()),
463
+ }),
464
+ ),
465
+ ),
466
+ keyRateLimit: v.optional(
467
+ v.object({
468
+ maxRequests: v.number(),
469
+ windowMs: v.number(),
470
+ }),
471
+ ),
472
+ keyExpiresAt: v.optional(v.number()),
342
473
  },
343
- handler: async (
344
- ctx: any,
345
- {
346
- action,
347
- sessionId,
348
- tokenHash,
349
- }: { action: string; sessionId?: string; tokenHash?: string },
350
- ) => {
474
+ handler: async (ctx: any, args: any) => {
351
475
  const currentUserId = await authHelper.user.require(ctx);
352
476
 
353
- switch (action) {
477
+ switch (args.action) {
354
478
  case "acceptInvite": {
355
- if (!tokenHash) throw new Error("tokenHash required");
479
+ if (!args.tokenHash) throwAuthError("INVITE_TOKEN_REQUIRED");
356
480
  const invite = await ctx.runQuery(
357
481
  authComponent.public.inviteGetByTokenHash,
358
- { tokenHash },
482
+ { tokenHash: args.tokenHash },
359
483
  );
360
- if (!invite) throw new Error("Invalid invite token");
484
+ if (!invite) throwAuthError("INVALID_INVITE");
361
485
  if (invite.status !== "pending") {
362
- throw new Error(`Invite already ${invite.status}`);
486
+ throwAuthError("INVITE_ALREADY_USED", `Invite already ${invite.status}`);
363
487
  }
364
488
  if (invite.expiresTime && invite.expiresTime < Date.now()) {
365
- throw new Error("Invite has expired");
489
+ throwAuthError("INVITE_EXPIRED");
366
490
  }
367
491
  await ctx.runMutation(authComponent.public.inviteAccept, {
368
492
  inviteId: invite._id,
@@ -374,13 +498,49 @@ export function Portal(auth: Auth) {
374
498
  case "revokeSession": {
375
499
  await requirePortalAdmin(ctx, authComponent, currentUserId);
376
500
  await ctx.runMutation(authComponent.public.sessionDelete, {
377
- sessionId: sessionId!,
501
+ sessionId: args.sessionId!,
502
+ });
503
+ return;
504
+ }
505
+
506
+ // ---- API Keys (portal admin) ----
507
+ case "createKey": {
508
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
509
+ const result = await authHelper.key.create(ctx, {
510
+ userId: args.keyUserId!,
511
+ name: args.keyName!,
512
+ scopes: args.keyScopes ?? [],
513
+ rateLimit: args.keyRateLimit,
514
+ expiresAt: args.keyExpiresAt,
378
515
  });
516
+ // Return the raw key — portal will show it once
517
+ return result;
518
+ }
519
+
520
+ case "revokeKey": {
521
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
522
+ await authHelper.key.revoke(ctx, args.keyId!);
523
+ return;
524
+ }
525
+
526
+ case "deleteKey": {
527
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
528
+ await authHelper.key.remove(ctx, args.keyId!);
529
+ return;
530
+ }
531
+
532
+ case "updateKey": {
533
+ await requirePortalAdmin(ctx, authComponent, currentUserId);
534
+ const data: Record<string, any> = {};
535
+ if (args.keyName) data.name = args.keyName;
536
+ if (args.keyScopes) data.scopes = args.keyScopes;
537
+ if (args.keyRateLimit) data.rateLimit = args.keyRateLimit;
538
+ await authHelper.key.update(ctx, args.keyId!, data);
379
539
  return;
380
540
  }
381
541
 
382
542
  default:
383
- throw new Error(`Unknown portal mutation action: ${action}`);
543
+ throwAuthError("PORTAL_UNKNOWN_ACTION", `Unknown portal mutation action: ${args.action}`);
384
544
  }
385
545
  },
386
546
  });
@@ -461,10 +621,162 @@ export function Portal(auth: Auth) {
461
621
  }
462
622
 
463
623
  default:
464
- throw new Error(`Unknown portalInternal action: ${args.action}`);
624
+ throwAuthError("PORTAL_UNKNOWN_ACTION", `Unknown portalInternal action: ${args.action}`);
465
625
  }
466
626
  },
467
627
  });
468
628
 
469
629
  return { portalQuery, portalMutation, portalInternal };
470
630
  }
631
+
632
+ // ============================================================================
633
+ // AuthCtx — ctx enrichment for customQuery / customMutation
634
+ // ============================================================================
635
+
636
+ /**
637
+ * Configuration for auth context enrichment.
638
+ */
639
+ export type AuthCtxConfig = {
640
+ /**
641
+ * When `true`, unauthenticated requests set `ctx.auth.userId` and
642
+ * `ctx.auth.user` to `null` instead of throwing.
643
+ *
644
+ * @default false
645
+ */
646
+ optional?: boolean;
647
+ /**
648
+ * Resolve additional context after authentication succeeds (e.g.
649
+ * group/role for multi-tenant apps). The returned object is spread
650
+ * into `ctx.auth`.
651
+ */
652
+ resolve?: (
653
+ ctx: any,
654
+ user: any,
655
+ ) => Promise<Record<string, unknown>> | Record<string, unknown>;
656
+ };
657
+
658
+ /**
659
+ * Create a `convex-helpers`–compatible customization object that
660
+ * enriches `ctx.auth` with the authenticated user's data.
661
+ *
662
+ * Standalone function (not a class method) because Convex's bundler
663
+ * can trace `export const x = fn(instance)` but not `instance.method()`.
664
+ *
665
+ * ### Basic usage (with `convex-helpers`)
666
+ *
667
+ * ```ts
668
+ * // convex/functions.ts
669
+ * import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
670
+ * import { query as rawQuery, mutation as rawMutation } from "./_generated/server";
671
+ * import { AuthCtx } from "\@robelest/convex-auth/component";
672
+ * import { auth } from "./auth";
673
+ *
674
+ * const authCtx = AuthCtx(auth);
675
+ *
676
+ * export const query = customQuery(rawQuery, authCtx);
677
+ * export const mutation = customMutation(rawMutation, authCtx);
678
+ * ```
679
+ *
680
+ * Then in any function file:
681
+ *
682
+ * ```ts
683
+ * // convex/messages.ts
684
+ * import { query, mutation } from "./functions";
685
+ *
686
+ * export const list = query({
687
+ * args: {},
688
+ * handler: async (ctx) => {
689
+ * // ctx.auth.userId and ctx.auth.user are already resolved
690
+ * return ctx.db.query("messages").collect();
691
+ * },
692
+ * });
693
+ * ```
694
+ *
695
+ * ### Optional auth (public routes)
696
+ *
697
+ * ```ts
698
+ * export const publicQuery = customQuery(rawQuery, AuthCtx(auth, { optional: true }));
699
+ * // ctx.auth.userId is null when unauthenticated
700
+ * ```
701
+ *
702
+ * ### Multi-tenant with group resolution
703
+ *
704
+ * ```ts
705
+ * const authCtx = AuthCtx(auth, {
706
+ * resolve: async (ctx, user) => {
707
+ * const groupId = user?.extend?.lastActiveGroup;
708
+ * const membership = await auth.user.group.get(ctx, {
709
+ * userId: user._id,
710
+ * groupId,
711
+ * });
712
+ * return { groupId, role: membership?.role ?? "member" };
713
+ * },
714
+ * });
715
+ * // ctx.auth.groupId and ctx.auth.role available in handlers
716
+ * ```
717
+ *
718
+ * @param auth - The `Auth` class instance from your `convex/auth.ts`.
719
+ * @param config - Optional configuration for optional auth and group resolution.
720
+ * @returns A `{ args, input }` customization object compatible with
721
+ * `customQuery` / `customMutation` from `convex-helpers`.
722
+ */
723
+ export function AuthCtx(auth: Auth, config?: AuthCtxConfig) {
724
+ const authHelper = (auth as any)._auth;
725
+
726
+ return {
727
+ args: {},
728
+ input: async (ctx: any, _args: any, _extra?: any) => {
729
+ const nativeAuth = ctx.auth;
730
+
731
+ if (config?.optional) {
732
+ const userId = await authHelper.user.current(ctx);
733
+ if (!userId) {
734
+ return {
735
+ ctx: {
736
+ auth: {
737
+ getUserIdentity: nativeAuth.getUserIdentity.bind(nativeAuth),
738
+ userId: null,
739
+ user: null,
740
+ },
741
+ },
742
+ args: {},
743
+ };
744
+ }
745
+ const user = await authHelper.user.get(ctx, userId);
746
+ const extra = config.resolve
747
+ ? await config.resolve(ctx, user)
748
+ : {};
749
+ return {
750
+ ctx: {
751
+ auth: {
752
+ getUserIdentity: nativeAuth.getUserIdentity.bind(nativeAuth),
753
+ userId,
754
+ user,
755
+ ...extra,
756
+ },
757
+ },
758
+ args: {},
759
+ };
760
+ }
761
+
762
+ // Required mode (default): throws NOT_SIGNED_IN
763
+ const userId = await authHelper.user.require(ctx);
764
+ const user = await authHelper.user.get(ctx, userId);
765
+ const extra = config?.resolve
766
+ ? await config.resolve(ctx, user)
767
+ : {};
768
+
769
+ return {
770
+ ctx: {
771
+ auth: {
772
+ getUserIdentity: nativeAuth.getUserIdentity.bind(nativeAuth),
773
+ userId,
774
+ user,
775
+ ...extra,
776
+ },
777
+ },
778
+ args: {},
779
+ };
780
+ },
781
+ };
782
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Default email templates generated by the Auth library.
3
+ *
4
+ * These are used when the library sends emails on behalf of the developer
5
+ * (magic links, portal admin sign-in). The developer provides the transport
6
+ * via `email.send`; the library provides the content.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ /**
12
+ * Default magic link email template.
13
+ *
14
+ * Clean, minimal design that works across email clients.
15
+ * Used by the auto-registered `email` provider when `email` is
16
+ * configured in the Auth constructor.
17
+ */
18
+ export function defaultMagicLinkEmail(url: string, host: string): string {
19
+ const escapedHost = host.replace(/[&<>"']/g, (c) =>
20
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
21
+ );
22
+
23
+ return `<!DOCTYPE html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
28
+ <title>Sign in to ${escapedHost}</title>
29
+ </head>
30
+ <body style="margin:0;padding:0;background-color:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
31
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;padding:40px 16px;">
32
+ <tr>
33
+ <td align="center">
34
+ <table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
35
+ <tr>
36
+ <td style="padding:32px 32px 0 32px;text-align:center;">
37
+ <h1 style="margin:0 0 8px 0;font-size:20px;font-weight:600;color:#111827;line-height:1.3;">
38
+ Sign in to ${escapedHost}
39
+ </h1>
40
+ </td>
41
+ </tr>
42
+ <tr>
43
+ <td style="padding:24px 32px;">
44
+ <p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#4b5563;text-align:center;">
45
+ Click the button below to sign in. This link will expire shortly.
46
+ </p>
47
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
48
+ <tr>
49
+ <td align="center" style="padding:0 0 24px 0;">
50
+ <a href="${url}" target="_blank" style="display:inline-block;background-color:#111827;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:12px 32px;border-radius:6px;line-height:1;">
51
+ Sign in
52
+ </a>
53
+ </td>
54
+ </tr>
55
+ </table>
56
+ <p style="margin:0 0 12px 0;font-size:13px;line-height:1.6;color:#9ca3af;">
57
+ If the button doesn't work, copy and paste this URL into your browser:
58
+ </p>
59
+ <p style="margin:0;font-size:13px;line-height:1.5;color:#6b7280;word-break:break-all;">
60
+ ${url}
61
+ </p>
62
+ </td>
63
+ </tr>
64
+ <tr>
65
+ <td style="padding:20px 32px;border-top:1px solid #e5e7eb;">
66
+ <p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;text-align:center;">
67
+ If you didn't request this email, you can safely ignore it.
68
+ </p>
69
+ </td>
70
+ </tr>
71
+ </table>
72
+ </td>
73
+ </tr>
74
+ </table>
75
+ </body>
76
+ </html>`;
77
+ }