@rebasepro/agent-skills 0.0.1-canary.4829d6e

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/package.json +21 -0
  4. package/skills/rebase-admin/SKILL.md +816 -0
  5. package/skills/rebase-api/SKILL.md +673 -0
  6. package/skills/rebase-api/references/.gitkeep +3 -0
  7. package/skills/rebase-auth/SKILL.md +1323 -0
  8. package/skills/rebase-auth/references/.gitkeep +3 -0
  9. package/skills/rebase-backend-postgres/SKILL.md +633 -0
  10. package/skills/rebase-backend-postgres/references/.gitkeep +3 -0
  11. package/skills/rebase-basics/SKILL.md +757 -0
  12. package/skills/rebase-basics/references/.gitkeep +3 -0
  13. package/skills/rebase-collections/SKILL.md +1421 -0
  14. package/skills/rebase-collections/references/.gitkeep +3 -0
  15. package/skills/rebase-cron-jobs/SKILL.md +704 -0
  16. package/skills/rebase-cron-jobs/references/.gitkeep +1 -0
  17. package/skills/rebase-custom-functions/SKILL.md +281 -0
  18. package/skills/rebase-deployment/SKILL.md +894 -0
  19. package/skills/rebase-deployment/references/.gitkeep +3 -0
  20. package/skills/rebase-design-language/SKILL.md +692 -0
  21. package/skills/rebase-email/SKILL.md +701 -0
  22. package/skills/rebase-email/references/.gitkeep +1 -0
  23. package/skills/rebase-entity-history/SKILL.md +485 -0
  24. package/skills/rebase-entity-history/references/.gitkeep +1 -0
  25. package/skills/rebase-local-env-setup/SKILL.md +189 -0
  26. package/skills/rebase-local-env-setup/references/.gitkeep +3 -0
  27. package/skills/rebase-realtime/SKILL.md +759 -0
  28. package/skills/rebase-realtime/references/.gitkeep +3 -0
  29. package/skills/rebase-sdk/SKILL.md +617 -0
  30. package/skills/rebase-sdk/references/.gitkeep +0 -0
  31. package/skills/rebase-security/SKILL.md +646 -0
  32. package/skills/rebase-storage/SKILL.md +989 -0
  33. package/skills/rebase-storage/references/.gitkeep +3 -0
  34. package/skills/rebase-studio/SKILL.md +772 -0
  35. package/skills/rebase-studio/references/.gitkeep +3 -0
  36. package/skills/rebase-ui-components/SKILL.md +1579 -0
  37. package/skills/rebase-ui-components/references/.gitkeep +3 -0
  38. package/skills/rebase-webhooks/SKILL.md +618 -0
  39. package/skills/rebase-webhooks/references/.gitkeep +1 -0
@@ -0,0 +1,1323 @@
1
+ ---
2
+ name: rebase-auth
3
+ description: Guide for setting up and using Rebase Authentication, roles, Row-Level Security (RLS) policies, MFA, API keys, OAuth providers, custom auth adapters, and lifecycle hooks. Use this skill when the user needs to add authentication, manage users and roles, secure data access, configure OAuth, set up MFA, create API keys, or customize the auth pipeline.
4
+ ---
5
+
6
+ # Rebase Authentication
7
+
8
+ Rebase ships a complete, built-in authentication system with JWT sessions, OAuth, MFA/TOTP, API keys, Row-Level Security, and lifecycle hooks — or you can plug in an external auth system (e.g., Clerk, Auth0, or custom identity providers) via the `AuthAdapter` interface.
9
+
10
+ > **IMPORTANT FOR AGENTS:** Always read the `rebase-basics` skill first. The auth system is configured inside `initializeRebaseBackend()` which is covered there.
11
+
12
+ ## Table of Contents
13
+
14
+ - [Server-Side Configuration (RebaseAuthConfig)](#server-side-configuration)
15
+ - [OAuth Providers](#oauth-providers)
16
+ - [Auth Lifecycle Hooks](#auth-lifecycle-hooks)
17
+ - [MFA / TOTP](#mfa--totp)
18
+ - [API Keys](#api-keys)
19
+ - [REST Endpoints](#rest-endpoints)
20
+ - [Client SDK (auth module)](#client-sdk)
21
+ - [Row-Level Security (RLS)](#row-level-security)
22
+ - [Rate Limiting](#rate-limiting)
23
+ - [Custom Auth Adapters](#custom-auth-adapters)
24
+ - [Roles & Permissions](#roles--permissions)
25
+ - [Backend Hooks](#backend-hooks)
26
+ - [Email Configuration](#email-configuration)
27
+ - [Security Concepts](#security-concepts)
28
+ - [References](#references)
29
+
30
+ ---
31
+
32
+ ## Server-Side Configuration
33
+
34
+ Authentication is configured via the `auth` property of `initializeRebaseBackend()`. It accepts **either** a `RebaseAuthConfig` object (built-in auth) or an `AuthAdapter` (external auth).
35
+
36
+ > **Auth & multiple data sources.** The built-in auth system (users, sessions, API keys) is bootstrapped on the **default** data source — the auth collection must live there (the backend warns at boot otherwise). **RLS only protects Postgres**: server collections on engines without row-level security (e.g. MongoDB) still require authentication but enforce authorization at the app layer (the backend warns for these). **Direct data sources (e.g. Firestore) bypass Rebase auth entirely** — they're governed by the external backend's own rules/token; use an `AuthAdapter` to unify identity. See the **rebase-collections** skill for the data-source model.
37
+
38
+ ### RebaseAuthConfig
39
+
40
+ | Property | Type | Default | Description |
41
+ |---|---|---|---|
42
+ | `collection` | `CollectionConfig` | Built-in users collection | The collection used for auth user storage. Import `defaultUsersCollection` from `@rebasepro/common` or pass a custom collection with required auth fields. |
43
+ | `jwtSecret` | `string` | — | **Required.** Secret for signing JWT access tokens. |
44
+ | `accessExpiresIn` | `string` | `"1h"` | Access token lifetime (e.g. `"15m"`, `"2h"`). |
45
+ | `refreshExpiresIn` | `string` | `"30d"` | Refresh token lifetime. |
46
+ | `requireAuth` | `boolean` | `true` | When `true`, data routes return 401 for unauthenticated requests. Set to `false` to rely entirely on Postgres RLS. |
47
+ | `allowRegistration` | `boolean` | `false` | Enable self-service registration via `POST /auth/register`. |
48
+ | `allowUserLookup` | `boolean` | `false` | Expose `POST /auth/find-user` — an authenticated email→minimal-profile lookup (`uid`/`displayName`/`photoURL` only) for invite flows. Enables user enumeration by signed-in users, so it's off by default. See [Inviting by email](#inviting-teammates-by-email). |
49
+ | `serviceKey` | `string` | — | Static secret for server-to-server auth. Must be ≥ 32 characters. Requests with `Authorization: Bearer <serviceKey>` get admin access. |
50
+ | `defaultRole` | `string` | — | Role ID assigned to new users (except the first user, who always gets `"admin"`). **Must NOT be `"admin"`** — throws a security error at startup. |
51
+ | `providers` | `OAuthProvider<unknown>[]` | `[]` | **Canonical** OAuth provider array. Use `create*Provider` factories or pass custom providers. Named shorthand fields below are merged into this array at startup. |
52
+ | `hooks` | `AuthHooks` | — | [Lifecycle hooks](#auth-lifecycle-hooks) to customize passwords, credentials, and auth events. |
53
+ | `email` | `EmailConfig` | — | [Email configuration](#email-configuration) for password resets, verification, and welcome emails. |
54
+ | `google` | `{ clientId, clientSecret? }` | — | Google OAuth shorthand. |
55
+ | `github` | `{ clientId, clientSecret }` | — | GitHub OAuth shorthand. |
56
+ | `microsoft` | `{ clientId, clientSecret, tenantId? }` | — | Microsoft/Entra ID shorthand. `tenantId` defaults to `"common"`. |
57
+ | `apple` | `{ clientId, teamId, keyId, privateKey }` | — | Apple Sign In shorthand. `privateKey` is the raw PEM (.p8) contents. |
58
+ | `facebook` | `{ clientId, clientSecret }` | — | Facebook/Meta OAuth. |
59
+ | `twitter` | `{ clientId, clientSecret }` | — | Twitter/X OAuth 2.0 with PKCE. |
60
+ | `discord` | `{ clientId, clientSecret }` | — | Discord OAuth. |
61
+ | `gitlab` | `{ clientId, clientSecret, baseUrl? }` | — | GitLab OAuth. `baseUrl` defaults to `"https://gitlab.com"` (supports self-hosted). |
62
+ | `linkedin` | `{ clientId, clientSecret }` | — | LinkedIn OAuth (OIDC). |
63
+ | `bitbucket` | `{ clientId, clientSecret }` | — | Bitbucket OAuth. |
64
+ | `slack` | `{ clientId, clientSecret }` | — | Slack OAuth (OIDC). |
65
+ | `spotify` | `{ clientId, clientSecret }` | — | Spotify OAuth. |
66
+
67
+ ### Minimal Example
68
+
69
+ ```typescript
70
+ import { initializeRebaseBackend } from "@rebasepro/server";
71
+ import { createPostgresAdapter } from "@rebasepro/server-postgres";
72
+
73
+ await initializeRebaseBackend({
74
+ server,
75
+ app,
76
+ database: createPostgresAdapter({ connection: db, schema }),
77
+ auth: {
78
+ jwtSecret: process.env.JWT_SECRET!,
79
+ allowRegistration: true,
80
+ serviceKey: process.env.REBASE_SERVICE_KEY,
81
+ defaultRole: "member",
82
+ google: {
83
+ clientId: process.env.GOOGLE_CLIENT_ID!,
84
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
85
+ },
86
+ github: {
87
+ clientId: process.env.GITHUB_CLIENT_ID!,
88
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
89
+ },
90
+ email: {
91
+ from: "noreply@myapp.com",
92
+ smtp: {
93
+ host: "smtp.resend.com",
94
+ port: 465,
95
+ secure: true,
96
+ auth: { user: "resend", pass: process.env.RESEND_API_KEY! },
97
+ },
98
+ appName: "MyApp",
99
+ resetPasswordUrl: "https://myapp.com",
100
+ verifyEmailUrl: "https://myapp.com",
101
+ },
102
+ },
103
+ });
104
+ ```
105
+
106
+ ### Collection-Level Auth Configuration
107
+
108
+ Instead of relying solely on the default database auth rules, you can mark any Postgres collection (such as `users.ts` or a custom `members.ts` collection) as the authentication collection. This is configured via the `auth` property on the collection itself:
109
+
110
+ ```typescript
111
+ import { PostgresCollectionConfig } from "@rebasepro/types";
112
+
113
+ const membersCollection: PostgresCollectionConfig = {
114
+ name: "Members",
115
+ slug: "members",
116
+ table: "members",
117
+ auth: {
118
+ enabled: true,
119
+
120
+ // Customize what happens when an admin creates a user via the REST API
121
+ onCreateUser: async (values, ctx) => {
122
+ const hash = await ctx.hashPassword("welcome123");
123
+ return {
124
+ values: { ...values, passwordHash: hash, emailVerified: true },
125
+ temporaryPassword: "welcome123"
126
+ };
127
+ },
128
+
129
+ // Customize what happens when an admin resets a user's password in the admin panel
130
+ onResetPassword: async (userId, ctx) => {
131
+ const tempPassword = "reset_" + Math.random().toString(36).substring(2, 8);
132
+ return {
133
+ temporaryPassword: tempPassword,
134
+ invitationSent: false
135
+ };
136
+ },
137
+
138
+ // Inject/override auth-specific actions (e.g. show/hide the reset password button)
139
+ actions: {
140
+ resetPassword: true // Or false to disable, or a custom EntityAction
141
+ }
142
+ },
143
+ properties: { ... }
144
+ };
145
+ ```
146
+
147
+ When custom hooks (`onCreateUser`, `onResetPassword`) are called, they receive an `AuthCollectionContext` facade containing:
148
+ - `hashPassword(password: string): Promise<string>` — Hash password using the configured hashing algorithm (e.g. scrypt).
149
+ - `sendEmail?: (options) => Promise<void>` — Send an email (only available when email service is configured).
150
+ - `emailConfigured: boolean` — Whether email service is configured.
151
+ - `appName: string` — The app name from email config.
152
+ - `resetPasswordUrl: string` — The password reset link base URL.
153
+
154
+ ### First-User Bootstrap
155
+
156
+ > **IMPORTANT FOR AGENTS:** The very first user registered (via `POST /auth/register` or OAuth) is automatically promoted to `"admin"`. This prevents the chicken-and-egg problem. All subsequent users receive the `defaultRole`.
157
+
158
+ ### Inviting teammates by email
159
+
160
+ Invite flows must turn an email into a user id, but the `users` collection is
161
+ RLS-protected from the client. **Do not** hand-roll an admin server function for
162
+ this — enable `allowUserLookup` and use the built-in primitive:
163
+
164
+ ```typescript
165
+ // backend: initializeRebaseBackend({ auth: { allowUserLookup: true } })
166
+
167
+ // client:
168
+ const profile = await rebase.auth.findUserByEmail("teammate@example.com");
169
+ // → { uid, displayName, photoURL } | null (never email/roles/metadata)
170
+ if (profile) {
171
+ await rebase.data.team_members.create({ team_id, user_id: profile.uid });
172
+ }
173
+ ```
174
+
175
+ The `find-user` endpoint is authenticated-only and returns just the minimal
176
+ public profile. It is off by default because it enables user enumeration by any
177
+ signed-in user.
178
+
179
+ ---
180
+
181
+ ## OAuth Providers
182
+
183
+ Rebase supports 12 built-in OAuth providers. Each provider is configured via a shorthand property on `RebaseAuthConfig` and automatically mounts a `POST /api/auth/{providerId}` endpoint.
184
+
185
+ ### Provider Reference
186
+
187
+ | Provider | ID | Config Properties | Client Payload |
188
+ |---|---|---|---|
189
+ | Google | `google` | `clientId`, `clientSecret?` | `{ idToken }` OR `{ accessToken }` OR `{ code, redirectUri }` |
190
+ | GitHub | `github` | `clientId`, `clientSecret` | `{ code, redirectUri }` |
191
+ | Microsoft | `microsoft` | `clientId`, `clientSecret`, `tenantId?` | `{ code, redirectUri }` |
192
+ | Apple | `apple` | `clientId`, `teamId`, `keyId`, `privateKey` | `{ code, redirectUri, user? }` |
193
+ | Facebook | `facebook` | `clientId`, `clientSecret` | `{ code, redirectUri }` |
194
+ | Twitter/X | `twitter` | `clientId`, `clientSecret` | `{ code, redirectUri, codeVerifier }` |
195
+ | Discord | `discord` | `clientId`, `clientSecret` | `{ code, redirectUri }` |
196
+ | GitLab | `gitlab` | `clientId`, `clientSecret`, `baseUrl?` | `{ code, redirectUri }` |
197
+ | LinkedIn | `linkedin` | `clientId`, `clientSecret` | `{ code, redirectUri }` |
198
+ | Bitbucket | `bitbucket` | `clientId`, `clientSecret` | `{ code, redirectUri }` |
199
+ | Slack | `slack` | `clientId`, `clientSecret` | `{ code, redirectUri }` |
200
+ | Spotify | `spotify` | `clientId`, `clientSecret` | `{ code, redirectUri }` |
201
+
202
+ ### Google Three-Path Support
203
+
204
+ Google is unique — it supports three verification paths:
205
+
206
+ 1. **ID Token** (One Tap / Sign In button) — `{ idToken }`. Cryptographic verification via Google's public keys. No `clientSecret` needed.
207
+ 2. **Access Token** (popup via `initTokenClient`) — `{ accessToken }`. Validated via Google's userinfo endpoint. No `clientSecret` needed.
208
+ 3. **Authorization Code** (most secure) — `{ code, redirectUri }`. Requires `clientSecret`. Tokens never touch the browser.
209
+
210
+ ### Apple Special Behavior
211
+
212
+ - Apple only sends the user's name on the **first** authorization. The frontend must capture and forward it: `{ code, redirectUri, user: { name: { firstName, lastName }, email } }`.
213
+ - Apple does not provide a profile photo (`photoUrl` is always `null`).
214
+ - The `privateKey` is the raw PEM contents of the `.p8` file downloaded from Apple Developer.
215
+
216
+ ### Twitter PKCE
217
+
218
+ Twitter uses OAuth 2.0 with PKCE. The client must send `codeVerifier` alongside `code` and `redirectUri`.
219
+
220
+ ### OAuth Account Linking
221
+
222
+ When an OAuth user signs in:
223
+ 1. If an identity record exists for `(providerId, provider)` → log in that user.
224
+ 2. If no identity exists but a user with the same email exists → **link** the provider to the existing account.
225
+ 3. If neither exists → create a new user, link the identity, assign `defaultRole`.
226
+
227
+ ### Custom OAuth Provider
228
+
229
+ You can register any OAuth provider by implementing the `OAuthProvider<T>` interface:
230
+
231
+ ```typescript
232
+ import { z } from "zod";
233
+ import type { OAuthProvider, OAuthProviderProfile } from "@rebasepro/server";
234
+
235
+ const myProvider: OAuthProvider<{ token: string }> = {
236
+ id: "my-provider",
237
+ schema: z.object({ token: z.string().min(1) }),
238
+ verify: async (payload): Promise<OAuthProviderProfile | null> => {
239
+ const userInfo = await verifyExternalToken(payload.token);
240
+ if (!userInfo) return null;
241
+ return {
242
+ providerId: userInfo.id,
243
+ email: userInfo.email,
244
+ displayName: userInfo.name || null,
245
+ photoUrl: userInfo.avatar || null,
246
+ };
247
+ },
248
+ };
249
+
250
+ // Use in config:
251
+ auth: {
252
+ jwtSecret: "...",
253
+ providers: [myProvider],
254
+ }
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Auth Lifecycle Hooks
260
+
261
+ The `AuthHooks` interface lets you customize specific behaviors of the built-in auth system. Every hook is optional — unset hooks fall through to built-in defaults.
262
+
263
+ ### Hook Reference
264
+
265
+ | Hook | Signature | Default | Behavior |
266
+ |---|---|---|---|
267
+ | `hashPassword` | `(password: string) => Promise<string>` | scrypt (Node crypto, 64-byte key, 32-byte salt) | Hash a cleartext password for storage. |
268
+ | `verifyPassword` | `(password: string, storedHash: string) => Promise<boolean>` | scrypt with timing-safe comparison | Verify cleartext password against stored hash. |
269
+ | `validatePasswordStrength` | `(password: string) => PasswordValidationResult` | Min 8 chars, 1 uppercase, 1 lowercase, 1 digit | Return `{ valid: boolean, errors: string[] }`. |
270
+ | `verifyCredentials` | `(email, password, repo: AuthRepository) => Promise<UserData \| null>` | `getUserByEmail` + `verifyPassword` | Override the entire login credential check. Return user or `null`. |
271
+ | `onAuthenticated` | `(user: UserData, method: AuthMethod) => Promise<void>` | — | Called after **any** successful auth event. Fire-and-forget. |
272
+ | `beforeUserCreate` | `(data: CreateUserData) => Promise<CreateUserData>` | Passthrough | Modify or reject user creation. Throw to abort. |
273
+ | `afterUserCreate` | `(user: UserData) => Promise<void>` | — | Called after user creation. Fire-and-forget. |
274
+ | `beforeLogin` | `(email: string, method: AuthMethod) => Promise<void>` | — | Pre-login validation. Throw to reject (e.g. account lockout). |
275
+ | `afterLogout` | `(userId: string) => Promise<void>` | — | Post-logout cleanup. Fire-and-forget. |
276
+ | `onMfaVerified` | `(userId: string, factorId: string) => Promise<void>` | — | Called after successful MFA verification. Fire-and-forget. |
277
+ | `customizeAccessToken` | `(claims: Record<string, unknown>, user: UserData) => Promise<Record<string, unknown>>` | — | Modify JWT access token claims before signing. |
278
+ | `transformAuthResponse` | `(response: AuthResponsePayload, context: TransformAuthResponseContext) => Promise<AuthResponsePayload>` | — | Transform the auth response before sending to client. Runs in-request (not fire-and-forget). Errors are caught and logged; untransformed response returned as fallback. |
279
+ | `onPasswordReset` | `(userId: string) => Promise<void>` | — | Called after successful password reset. Fire-and-forget. |
280
+ | `beforeUserDelete` | `(userId: string) => Promise<void>` | — | Throw to prevent deletion. |
281
+ | `afterUserDelete` | `(userId: string) => Promise<void>` | — | Post-deletion cleanup. Fire-and-forget. |
282
+
283
+ ### AuthMethod Values
284
+
285
+ `"login"` | `"register"` | `"oauth"` | `"refresh"` | `"password-reset"` | `"anonymous"` | `"magic-link"` | `"mfa"`
286
+
287
+ ### AuthResponsePayload
288
+
289
+ ```typescript
290
+ interface AuthResponsePayload {
291
+ user?: {
292
+ uid: string;
293
+ email: string;
294
+ displayName: string | null;
295
+ photoURL: string | null;
296
+ roles: string[];
297
+ metadata: Record<string, unknown>;
298
+ };
299
+ tokens: {
300
+ accessToken: string;
301
+ refreshToken: string;
302
+ accessTokenExpiresAt: number;
303
+ [key: string]: unknown;
304
+ };
305
+ }
306
+ ```
307
+
308
+ ### TransformAuthResponseContext
309
+
310
+ ```typescript
311
+ interface TransformAuthResponseContext {
312
+ userId: string;
313
+ method: "login" | "register" | "oauth" | "refresh" | "anonymous" | "magic-link" | "mfa";
314
+ request: Request;
315
+ }
316
+ ```
317
+
318
+ ### PasswordValidationResult
319
+
320
+ ```typescript
321
+ interface PasswordValidationResult {
322
+ valid: boolean;
323
+ errors: string[];
324
+ }
325
+ ```
326
+
327
+ ### Example: bcrypt Passwords
328
+
329
+ ```typescript
330
+ import bcrypt from "bcrypt";
331
+
332
+ auth: {
333
+ jwtSecret: "...",
334
+ hooks: {
335
+ hashPassword: (pw) => bcrypt.hash(pw, 12),
336
+ verifyPassword: (pw, hash) => bcrypt.compare(pw, hash),
337
+ validatePasswordStrength: (pw) => ({
338
+ valid: pw.length >= 6,
339
+ errors: pw.length < 6 ? ["Password must be at least 6 characters"] : [],
340
+ }),
341
+ },
342
+ }
343
+ ```
344
+
345
+ ### Example: Custom JWT Claims
346
+
347
+ ```typescript
348
+ hooks: {
349
+ customizeAccessToken: async (claims, user) => ({
350
+ ...claims,
351
+ org_id: user.metadata?.organizationId,
352
+ plan: user.metadata?.plan || "free",
353
+ }),
354
+ }
355
+ ```
356
+
357
+ ### Example: Audit Logging
358
+
359
+ ```typescript
360
+ hooks: {
361
+ onAuthenticated: async (user, method) => {
362
+ await auditLog.write({
363
+ event: "auth.success",
364
+ userId: user.id,
365
+ method,
366
+ timestamp: new Date(),
367
+ });
368
+ },
369
+ beforeLogin: async (email, method) => {
370
+ const isBlocked = await checkAccountLockout(email);
371
+ if (isBlocked) throw new Error("Account is locked");
372
+ },
373
+ }
374
+ ```
375
+
376
+ ### Example: External Token Bridge (e.g. custom auth system)
377
+
378
+ ```typescript
379
+ import admin from "firebase-admin";
380
+
381
+ hooks: {
382
+ transformAuthResponse: async (response, context) => {
383
+ // Generate a custom provider token for the authenticated user
384
+ const firebaseToken = await admin.auth().createCustomToken(context.userId);
385
+ return {
386
+ ...response,
387
+ tokens: {
388
+ ...response.tokens,
389
+ firebaseToken,
390
+ },
391
+ };
392
+ },
393
+ }
394
+ ```
395
+
396
+ The frontend can then call `signInWithCustomToken(providerToken)` immediately after login.
397
+
398
+ ---
399
+
400
+ ## MFA / TOTP
401
+
402
+ Rebase supports Multi-Factor Authentication via TOTP (Time-based One-Time Password). The flow uses an enrollment → verify → challenge pattern with recovery codes.
403
+
404
+ ### MFA Flow
405
+
406
+ 1. **Enroll** — `POST /api/auth/mfa/enroll` returns a TOTP secret, URI (for QR), and 10 recovery codes.
407
+ 2. **Verify enrollment** — `POST /api/auth/mfa/verify` with a 6-digit TOTP code to confirm the factor.
408
+ 3. **Challenge on login** — After normal login (aal1), call `POST /api/auth/mfa/challenge` to create a challenge.
409
+ 4. **Complete challenge** — `POST /api/auth/mfa/challenge/verify` with TOTP or recovery code. Upgrades token from `aal1` → `aal2`.
410
+
411
+ ### MFA Endpoints
412
+
413
+ | Method | Endpoint | Auth | Description |
414
+ |---|---|---|---|
415
+ | `POST` | `/api/auth/mfa/enroll` | Required | Start enrollment. Returns `{ factor, totp: { secret, uri, qrUri }, recoveryCodes }`. |
416
+ | `POST` | `/api/auth/mfa/verify` | Required | Verify enrollment with `{ factorId, code }` (6-digit TOTP). |
417
+ | `POST` | `/api/auth/mfa/challenge` | Required | Create challenge with `{ factorId }`. Returns `{ challengeId, factorId, expiresAt }`. Challenge expires in 5 minutes. |
418
+ | `POST` | `/api/auth/mfa/challenge/verify` | Required | Complete challenge with `{ challengeId, code }`. Returns new tokens with `aal2`. Accepts TOTP (6 digits) or recovery code (>6 chars). |
419
+ | `GET` | `/api/auth/mfa/factors` | Required | List enrolled factors: `{ factors: [{ id, factorType, friendlyName, verified, createdAt }] }`. |
420
+ | `DELETE` | `/api/auth/mfa/unenroll` | Required | Remove factor with `{ factorId }` in body. Auto-cleans recovery codes when no verified factors remain. |
421
+
422
+ ### MFA Types
423
+
424
+ ```typescript
425
+ interface MfaFactor {
426
+ id: string;
427
+ userId: string;
428
+ factorType: "totp"; // Only TOTP is supported
429
+ friendlyName?: string;
430
+ verified: boolean;
431
+ createdAt: Date;
432
+ updatedAt: Date;
433
+ }
434
+
435
+ interface MfaChallengeInfo {
436
+ id: string;
437
+ factorId: string;
438
+ createdAt: Date;
439
+ verifiedAt?: Date;
440
+ ipAddress?: string;
441
+ }
442
+ ```
443
+
444
+ ### AAL (Authentication Assurance Levels)
445
+
446
+ | Level | Meaning |
447
+ |---|---|
448
+ | `aal1` | Standard authentication (email/password, OAuth). |
449
+ | `aal2` | Elevated after MFA challenge verification. |
450
+
451
+ ---
452
+
453
+ ## API Keys
454
+
455
+ API keys provide machine-to-machine authentication for agents, MCP servers, CI pipelines, cron jobs, and third-party integrations. They are scoped to specific collections and operations, and can optionally be granted full admin access.
456
+
457
+ ### Key Format
458
+
459
+ - Prefix: `rk_` (e.g. `rk_live_abc123...`)
460
+ - Storage: SHA-256 hash of the full key. The plaintext key is returned **exactly once** at creation.
461
+ - Display: Only the first 12 characters (`key_prefix`) are shown in subsequent API responses.
462
+
463
+ ### Admin Access for Agents / MCP
464
+
465
+ By default API keys get the `service` role (data access only). Set `"admin": true` to grant the key the `admin` role, which allows it to call **all admin routes** (`/api/admin/*`) — including schema management, user management, and API key management itself.
466
+
467
+ > **Use `admin: true` for agents, MCP servers, and CI pipelines that need full control over the Rebase instance.**
468
+
469
+ ```bash
470
+ # CLI — create an admin API key
471
+ rebase api-keys create --name "My Agent" --admin
472
+
473
+ # REST
474
+ curl -X POST http://localhost:3000/api/admin/api-keys \
475
+ -H "Authorization: Bearer <service-key>" \
476
+ -H "Content-Type: application/json" \
477
+ -d '{
478
+ "name": "My Agent",
479
+ "admin": true,
480
+ "permissions": [{ "collection": "*", "operations": ["read", "write", "delete"] }]
481
+ }'
482
+ ```
483
+
484
+ ### API Key Admin Endpoints
485
+
486
+ All endpoints are mounted under `/api/admin/api-keys` and require **admin** authentication (JWT with admin role or service key).
487
+
488
+ | Method | Endpoint | Description |
489
+ |---|---|---|
490
+ | `GET` | `/api/admin/api-keys` | List all API keys (masked — no hashes). |
491
+ | `POST` | `/api/admin/api-keys` | Create a new API key. Returns the full plaintext key once. |
492
+ | `GET` | `/api/admin/api-keys/:id` | Get single API key details (masked). |
493
+ | `PUT` | `/api/admin/api-keys/:id` | Update name, permissions, admin, rate_limit, or expires_at. |
494
+ | `DELETE` | `/api/admin/api-keys/:id` | Revoke (soft-delete) an API key. |
495
+
496
+ ### Create API Key Request
497
+
498
+ ```typescript
499
+ interface CreateApiKeyRequest {
500
+ name: string;
501
+ permissions: ApiKeyPermission[];
502
+ admin?: boolean; // true = grant admin role (access to all admin routes)
503
+ rate_limit?: number | null; // Requests per 15-min window. null = unlimited
504
+ expires_at?: string | null; // ISO-8601 timestamp. null = no expiration
505
+ }
506
+
507
+ interface ApiKeyPermission {
508
+ collection: string; // Collection slug, or "*" for all collections
509
+ operations: ("read" | "write" | "delete")[];
510
+ }
511
+ ```
512
+
513
+ ### Examples
514
+
515
+ **Scoped key (read-only on one collection):**
516
+
517
+ ```bash
518
+ curl -X POST http://localhost:3000/api/admin/api-keys \
519
+ -H "Authorization: Bearer <admin-token-or-service-key>" \
520
+ -H "Content-Type: application/json" \
521
+ -d '{
522
+ "name": "Analytics Pipeline",
523
+ "permissions": [
524
+ { "collection": "events", "operations": ["read", "write"] },
525
+ { "collection": "users", "operations": ["read"] }
526
+ ],
527
+ "rate_limit": 500,
528
+ "expires_at": "2025-12-31T23:59:59Z"
529
+ }'
530
+ ```
531
+
532
+ **Admin key (for agents / MCP / CI):**
533
+
534
+ ```bash
535
+ curl -X POST http://localhost:3000/api/admin/api-keys \
536
+ -H "Authorization: Bearer <admin-token-or-service-key>" \
537
+ -H "Content-Type: application/json" \
538
+ -d '{
539
+ "name": "CI Agent",
540
+ "admin": true,
541
+ "permissions": [{ "collection": "*", "operations": ["read", "write", "delete"] }]
542
+ }'
543
+ ```
544
+
545
+ ### Using an API Key
546
+
547
+ ```bash
548
+ curl http://localhost:3000/api/data/events \
549
+ -H "Authorization: Bearer rk_live_abc123..."
550
+ ```
551
+
552
+ ### API Key Middleware Behavior
553
+
554
+ When a request arrives with a `rk_` prefixed bearer token:
555
+ 1. The token is SHA-256 hashed and looked up in the `rebase.api_keys` table.
556
+ 2. Expiry and revocation status are checked.
557
+ 3. If `admin: true`, the key is assigned `roles: ["admin", "service"]` — granting access to admin routes. Otherwise `roles: ["service"]`.
558
+ 4. Permissions are validated against the requested collection and HTTP method (`GET` → `read`, `POST`/`PUT`/`PATCH` → `write`, `DELETE` → `delete`).
559
+ 5. The DataDriver is scoped with `withAuth()` using the key's service identity (bypasses RLS).
560
+ 6. Per-key rate limiting is enforced if `rate_limit` is set.
561
+
562
+ > **WARNING FOR AGENTS:** API keys bypass RLS. They are designed for trusted server-side use only. Never expose API keys to client-side code.
563
+
564
+ ### Role Summary
565
+
566
+ | Key type | `roles` assigned | Admin routes | Data routes |
567
+ |---|---|---|---|
568
+ | Default (no `admin`) | `["service"]` | ✗ | ✓ (scoped by `permissions`) |
569
+ | `admin: true` | `["admin", "service"]` | ✓ | ✓ |
570
+
571
+ ### API Key Response Types
572
+
573
+ ```typescript
574
+ // Returned once at creation (includes the full plaintext key)
575
+ interface ApiKeyWithSecret {
576
+ id: string;
577
+ name: string;
578
+ key_prefix: string; // First 12 chars, for display
579
+ key: string; // FULL plaintext key — save this immediately
580
+ permissions: ApiKeyPermission[];
581
+ admin: boolean;
582
+ rate_limit: number | null;
583
+ created_by: string;
584
+ created_at: string;
585
+ updated_at: string;
586
+ last_used_at: string | null;
587
+ expires_at: string | null;
588
+ revoked_at: string | null;
589
+ }
590
+
591
+ // All subsequent reads (no hash, no full key)
592
+ interface ApiKeyMasked {
593
+ id: string;
594
+ name: string;
595
+ key_prefix: string;
596
+ permissions: ApiKeyPermission[];
597
+ admin: boolean;
598
+ rate_limit: number | null;
599
+ created_by: string;
600
+ created_at: string;
601
+ updated_at: string;
602
+ last_used_at: string | null;
603
+ expires_at: string | null;
604
+ revoked_at: string | null;
605
+ }
606
+ ```
607
+
608
+ ### Also update `admin` on an existing key
609
+
610
+ ```bash
611
+ curl -X PUT http://localhost:3000/api/admin/api-keys/<id> \
612
+ -H "Authorization: Bearer <admin-token-or-service-key>" \
613
+ -H "Content-Type: application/json" \
614
+ -d '{ "admin": true }'
615
+ ```
616
+
617
+ ### CLI
618
+
619
+ ```bash
620
+ # List all keys
621
+ rebase api-keys list
622
+
623
+ # Create a scoped key
624
+ rebase api-keys create --name "Read Only" --permissions '[{"collection":"orders","operations":["read"]}]'
625
+
626
+ # Create an admin key (for agents / MCP / CI)
627
+ rebase api-keys create --name "My Agent" --admin
628
+
629
+ # Revoke a key
630
+ rebase api-keys revoke <key-id>
631
+ ```
632
+
633
+ ---
634
+
635
+ ## REST Endpoints
636
+
637
+ All auth endpoints are mounted under `/api/auth`. Admin endpoints are under `/api/admin`.
638
+
639
+ ### Public Auth Endpoints
640
+
641
+ | Method | Endpoint | Rate Limit | Auth | Description |
642
+ |---|---|---|---|---|
643
+ | `POST` | `/auth/register` | default (200/15min) | No | Create account. Body: `{ email, password, displayName? }`. |
644
+ | `POST` | `/auth/login` | default | No | Email/password login. Body: `{ email, password }`. |
645
+ | `POST` | `/auth/{providerId}` | default | No | OAuth sign-in. Body varies by provider. |
646
+ | `POST` | `/auth/refresh` | — | No | Refresh access token. Body: `{ refreshToken }`. Rotates refresh token. |
647
+ | `POST` | `/auth/logout` | — | No | Invalidate refresh token. Body: `{ refreshToken? }`. |
648
+ | `POST` | `/auth/anonymous` | strict (50/15min) | No | Create anonymous user with temp credentials. |
649
+ | `POST` | `/auth/forgot-password` | strict | No | Request password reset email. Body: `{ email }`. Always returns success (security). |
650
+ | `POST` | `/auth/reset-password` | strict | No | Reset password with token. Body: `{ token, password }`. Invalidates all sessions. |
651
+ | `GET` | `/auth/verify-email` | — | No | Verify email. Query: `?token=<token>`. |
652
+ | `GET` | `/auth/config` | default | No | Get auth capabilities for frontend: `{ needsSetup, registrationEnabled, emailServiceEnabled, enabledProviders }`. |
653
+
654
+ ### Authenticated Endpoints
655
+
656
+ | Method | Endpoint | Auth | Description |
657
+ |---|---|---|---|
658
+ | `GET` | `/auth/me` | Required | Get current user profile + roles. |
659
+ | `PATCH` | `/auth/me` | Required | Update profile. Body: `{ displayName?, photoURL? }`. |
660
+ | `POST` | `/auth/change-password` | Required | Change password. Body: `{ oldPassword, newPassword }`. Invalidates all sessions. |
661
+ | `POST` | `/auth/send-verification` | Required | Send email verification link. Requires email service. |
662
+ | `GET` | `/auth/sessions` | Required | List active sessions (refresh tokens). |
663
+ | `DELETE` | `/auth/sessions` | Required | Revoke all sessions (remote logout). |
664
+ | `DELETE` | `/auth/sessions/:id` | Required | Revoke a specific session. |
665
+ | `POST` | `/auth/anonymous/link` | Required | Upgrade anonymous → permanent. Body: `{ email, password }`. |
666
+
667
+ ### Auth Response Format
668
+
669
+ All login/register/OAuth endpoints return:
670
+
671
+ ```json
672
+ {
673
+ "user": {
674
+ "uid": "uuid",
675
+ "email": "user@example.com",
676
+ "displayName": "John",
677
+ "photoURL": null,
678
+ "roles": ["member"],
679
+ "metadata": {}
680
+ },
681
+ "tokens": {
682
+ "accessToken": "eyJ...",
683
+ "refreshToken": "hex-string",
684
+ "accessTokenExpiresAt": 1700000000000
685
+ }
686
+ }
687
+ ```
688
+
689
+ > **TIP:** Use the `transformAuthResponse` hook to inject additional tokens (e.g., external system tokens) or metadata into this response. See [Auth Lifecycle Hooks](#auth-lifecycle-hooks).
690
+
691
+ ### Error Response Format
692
+
693
+ ```json
694
+ {
695
+ "error": {
696
+ "message": "Invalid email or password",
697
+ "code": "INVALID_CREDENTIALS"
698
+ }
699
+ }
700
+ ```
701
+
702
+ ### Common Error Codes
703
+
704
+ | Code | HTTP | Description |
705
+ |---|---|---|
706
+ | `INVALID_CREDENTIALS` | 401 | Wrong email/password. |
707
+ | `INVALID_TOKEN` | 401 | Invalid or expired refresh/reset token. |
708
+ | `TOKEN_EXPIRED` | 401 | Refresh token has expired. |
709
+ | `REGISTRATION_DISABLED` | 403 | `allowRegistration` is `false`. |
710
+ | `EMAIL_EXISTS` | 409 | Email already registered. |
711
+ | `WEAK_PASSWORD` | 400 | Password fails strength validation. |
712
+ | `INVALID_INPUT` | 400 | Zod validation failure. |
713
+ | `EMAIL_NOT_CONFIGURED` | 503 | Email service not set up (password reset/verification unavailable). |
714
+ | `ALREADY_VERIFIED` | 400 | Email already verified. |
715
+ | `NOT_ANONYMOUS` | 400 | User is not anonymous (cannot link). |
716
+ | `RATE_LIMITED` | 429 | Too many requests. |
717
+
718
+ ---
719
+
720
+ ## Client SDK
721
+
722
+ The client SDK's `auth` module is created via `createAuth(transport, options?)`. It manages tokens, auto-refresh, session persistence, and state change listeners.
723
+
724
+ ### CreateAuthOptions
725
+
726
+ | Option | Type | Default | Description |
727
+ |---|---|---|---|
728
+ | `storage` | `AuthStorage` | `localStorage` (browser) or in-memory | Token persistence backend. |
729
+ | `authPath` | `string` | `"/auth"` | Base path for auth endpoints. |
730
+ | `autoRefresh` | `boolean` | `true` | Auto-refresh access tokens 2 minutes before expiry. |
731
+ | `persistSession` | `boolean` | `true` | Persist session to storage between page loads. |
732
+
733
+ ### Client SDK Methods
734
+
735
+ ```typescript
736
+ const { auth } = createRebaseClient({ baseUrl: "http://localhost:3000" });
737
+
738
+ // Email/password
739
+ await auth.signInWithEmail(email, password);
740
+ await auth.signUp(email, password, displayName?);
741
+
742
+ // OAuth (all return { user, accessToken, refreshToken })
743
+ await auth.signInWithGoogle({ idToken });
744
+ await auth.signInWithGoogle({ accessToken });
745
+ await auth.signInWithGoogle({ code, redirectUri });
746
+ await auth.signInWithGitHub(code, redirectUri);
747
+ await auth.signInWithMicrosoft(code, redirectUri);
748
+ await auth.signInWithApple(code, redirectUri, user?);
749
+ await auth.signInWithFacebook(code, redirectUri);
750
+ await auth.signInWithTwitter(code, redirectUri, codeVerifier);
751
+ await auth.signInWithDiscord(code, redirectUri);
752
+ await auth.signInWithGitLab(code, redirectUri);
753
+ await auth.signInWithLinkedin(code, redirectUri);
754
+ await auth.signInWithBitbucket(code, redirectUri);
755
+ await auth.signInWithSlack(code, redirectUri);
756
+ await auth.signInWithSpotify(code, redirectUri);
757
+ await auth.signInWithOAuth(providerId, payload); // Generic
758
+
759
+ // Session
760
+ await auth.signOut();
761
+ await auth.refreshSession();
762
+ auth.getSession(); // Returns RebaseSession | null (sync)
763
+
764
+ // Profile
765
+ await auth.getUser(); // GET /auth/me
766
+ await auth.updateUser({ displayName?, photoURL? });
767
+
768
+ // Password
769
+ await auth.resetPasswordForEmail(email);
770
+ await auth.resetPassword(token, newPassword);
771
+ await auth.changePassword(oldPassword, newPassword);
772
+
773
+ // Email verification
774
+ await auth.sendVerificationEmail();
775
+ await auth.verifyEmail(token);
776
+
777
+ // Sessions
778
+ await auth.getSessions(); // List active sessions
779
+ await auth.revokeSession(sessionId);
780
+ await auth.revokeAllSessions(); // Revokes all + signs out locally
781
+
782
+ // Config
783
+ await auth.getAuthConfig(); // GET /auth/config
784
+
785
+ // State listener
786
+ const unsubscribe = auth.onAuthStateChange((event, session) => {
787
+ // event: "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED"
788
+ console.log(event, session?.user);
789
+ });
790
+ ```
791
+
792
+ ### Client Types
793
+
794
+ ```typescript
795
+ interface RebaseUser {
796
+ uid: string;
797
+ email: string | null;
798
+ displayName: string | null;
799
+ photoURL: string | null;
800
+ emailVerified?: boolean;
801
+ roles?: string[];
802
+ providerId: string;
803
+ isAnonymous: boolean;
804
+ }
805
+
806
+ interface RebaseSession {
807
+ accessToken: string;
808
+ refreshToken: string;
809
+ expiresAt: number; // Timestamp (ms)
810
+ user: RebaseUser;
811
+ }
812
+
813
+ type AuthChangeEvent = "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED";
814
+ ```
815
+
816
+ ### Custom Storage Backends
817
+
818
+ ```typescript
819
+ import { createMemoryStorage, createCookieStorage } from "@rebasepro/client";
820
+
821
+ // In-memory (Node.js / SSR)
822
+ const auth = createAuth(transport, {
823
+ storage: createMemoryStorage(),
824
+ });
825
+
826
+ // Cookie-based (SSR-friendly)
827
+ const auth = createAuth(transport, {
828
+ storage: createCookieStorage({
829
+ path: "/",
830
+ sameSite: "Lax",
831
+ secure: true,
832
+ domain: ".myapp.com",
833
+ maxAge: 365 * 24 * 60 * 60, // 1 year (default)
834
+ }),
835
+ });
836
+ ```
837
+
838
+ ### Session Restoration
839
+
840
+ On initialization (when `persistSession` is `true`):
841
+ 1. Load stored session from storage.
842
+ 2. If access token is still valid → restore session and schedule refresh.
843
+ 3. If access token is expired but refresh token exists → immediately attempt refresh.
844
+ 4. If refresh fails → clear session and emit `SIGNED_OUT`.
845
+
846
+ ---
847
+
848
+ ## Row-Level Security
849
+
850
+ Rebase implements RLS by scoping the DataDriver via `withAuth()` before each request. This injects the authenticated user's identity into the database context.
851
+
852
+ ### How RLS Scoping Works
853
+
854
+ 1. Auth middleware verifies the JWT (or API key / service key).
855
+ 2. The middleware calls `scopeDataDriver(driver, { uid, roles })`.
856
+ 3. If the driver supports `withAuth()` (e.g. Postgres), it returns a scoped clone with Postgres session variables set:
857
+ - `auth.uid()` — the user's ID
858
+ - `auth.jwt()` — the JWT claims
859
+ - `auth.roles()` — the user's role IDs
860
+ 4. All subsequent queries in that request use the scoped driver with RLS policies applied.
861
+
862
+ ### Fail-Closed Security
863
+
864
+ > **IMPORTANT FOR AGENTS:** If `withAuth()` throws an error, the request is **rejected** with 500. The system never falls back to unscoped access. This is by design (fail-closed).
865
+
866
+ ### Anonymous Users
867
+
868
+ When `requireAuth` is `false` and no token is provided, the driver is scoped with:
869
+ - `uid: "anon"`
870
+ - `roles: ["anon"]`
871
+
872
+ This allows Postgres RLS policies to handle public access explicitly.
873
+
874
+ ### Service Key Scoping
875
+
876
+ Requests with the `serviceKey` bypass RLS — they receive admin-level access with `uid: "service"` and `roles: ["admin"]`.
877
+
878
+ ### API Key Scoping
879
+
880
+ API keys use a service identity for RLS scoping: `uid: "api-key:{id}"`, `roles: ["service"]` (or `["admin", "service"]` when `admin: true`). They do not inherit the `created_by` user's identity.
881
+
882
+ ### Reserved System Identities
883
+
884
+ The auth middleware assigns these reserved identities automatically. They are visible in `context.user` (Collection Callbacks / DataHooks) and `c.get("user")` (custom functions):
885
+
886
+ | Auth Method | `userId` | `roles` | When It Occurs |
887
+ |---|---|---|---|
888
+ | JWT (end-user) | Real user ID (e.g. `"abc123"`) | User's assigned roles (e.g. `["viewer"]`) | Normal authenticated requests |
889
+ | Service Key | `"service"` | `["admin"]` | Server-side `rebase.data` calls, cron jobs, or any request with `Authorization: Bearer <serviceKey>` |
890
+ | API Key (default) | `"api-key:{id}"` | `["service"]` | Machine-to-machine API key requests |
891
+ | API Key (admin) | `"api-key:{id}"` | `["admin", "service"]` | Admin API key requests |
892
+ | Anonymous | `"anon"` | `["anon"]` | Unauthenticated when `requireAuth: false` |
893
+ | No token + `requireAuth: true` | — | — | **Rejected (401)** |
894
+
895
+ > **IMPORTANT FOR AGENTS:** Server-side `rebase.data` (the singleton used in cron jobs, custom functions, and webhooks) is built with `createRebaseClient({ token: serviceKey })`. It round-trips through the REST API, so all middleware, DataHooks, and Collection Callbacks fire with `userId: "service"`, `roles: ["admin"]`. This lets developers distinguish server-internal reads from end-user reads in callbacks.
896
+
897
+ ---
898
+
899
+ ## Rate Limiting
900
+
901
+ Rebase uses an in-memory sliding-window rate limiter with IP-based keying.
902
+
903
+ ### Pre-configured Limiters
904
+
905
+ | Limiter | Window | Limit | Applied To |
906
+ |---|---|---|---|
907
+ | `defaultAuthLimiter` | 15 minutes | 200 requests | `/auth/register`, `/auth/login`, `/auth/{provider}`, `/auth/config` |
908
+ | `strictAuthLimiter` | 15 minutes | 50 requests | `/auth/forgot-password`, `/auth/reset-password`, `/auth/anonymous` |
909
+
910
+ ### Rate Limit Response Headers
911
+
912
+ All rate-limited endpoints include:
913
+
914
+ | Header | Description |
915
+ |---|---|
916
+ | `X-RateLimit-Limit` | Maximum requests in the window. |
917
+ | `X-RateLimit-Remaining` | Remaining requests in current window. |
918
+ | `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets. |
919
+ | `Retry-After` | Seconds until the client can retry (only on 429). |
920
+
921
+ ### API Key Rate Limiting
922
+
923
+ API keys have their own per-key rate limiter. The `rate_limit` on each key specifies requests per 15-minute window. When `rate_limit` is `null`, a default of 1000 requests per 15 minutes is applied.
924
+
925
+ ### Rate Limit Error Response
926
+
927
+ ```json
928
+ {
929
+ "error": {
930
+ "message": "Too many requests, please try again later.",
931
+ "code": "RATE_LIMITED"
932
+ }
933
+ }
934
+ ```
935
+
936
+ ### Custom Rate Limiter
937
+
938
+ ```typescript
939
+ import { createRateLimiter } from "@rebasepro/server";
940
+
941
+ const myLimiter = createRateLimiter({
942
+ windowMs: 60 * 1000, // 1 minute
943
+ limit: 10, // 10 requests per minute
944
+ message: "Slow down!",
945
+ keyGenerator: (c) => {
946
+ // Custom keying (e.g., by user ID)
947
+ const user = c.get("user");
948
+ return user?.userId || c.req.header("x-forwarded-for") || "unknown";
949
+ },
950
+ });
951
+ ```
952
+
953
+ ---
954
+
955
+ ## Custom Auth Adapters
956
+
957
+ For external auth systems (Clerk, Auth0, custom providers, or custom JWT), use the `AuthAdapter` interface or the `createCustomAuthAdapter()` helper.
958
+
959
+ ### AuthAdapter Interface
960
+
961
+ ```typescript
962
+ interface AuthAdapter {
963
+ readonly id: string;
964
+ verifyRequest(request: Request): Promise<AuthenticatedUser | null>;
965
+ verifyToken?(token: string): Promise<AuthenticatedUser | null>;
966
+ userManagement?: UserManagementAdapter;
967
+ createAuthRoutes?(): Hono<any> | undefined;
968
+ createAdminRoutes?(): Hono<any> | undefined;
969
+ getCapabilities(): AuthAdapterCapabilities | Promise<AuthAdapterCapabilities>;
970
+ initialize?(): Promise<void>;
971
+ destroy?(): Promise<void>;
972
+ serviceKey?: string;
973
+ transformAuthResponse?(response: AuthResponsePayload, context: TransformAuthResponseContext): Promise<AuthResponsePayload>;
974
+ }
975
+
976
+ interface AuthenticatedUser {
977
+ uid: string;
978
+ email: string;
979
+ displayName?: string | null;
980
+ photoUrl?: string | null;
981
+ roles: string[];
982
+ isAdmin: boolean;
983
+ rawToken?: string;
984
+ claims?: Record<string, unknown>;
985
+ }
986
+ ```
987
+
988
+ ### createCustomAuthAdapter
989
+
990
+ The simplest way to plug an existing auth system into Rebase. Only `verifyRequest` is required:
991
+
992
+ ```typescript
993
+ import { createCustomAuthAdapter } from "@rebasepro/server";
994
+ import jwt from "jsonwebtoken";
995
+
996
+ const auth = createCustomAuthAdapter({
997
+ verifyRequest: async (request) => {
998
+ const token = request.headers.get("Authorization")?.replace("Bearer ", "");
999
+ if (!token) return null;
1000
+
1001
+ try {
1002
+ const decoded = jwt.verify(token, MY_SECRET) as any;
1003
+ return {
1004
+ uid: decoded.sub,
1005
+ email: decoded.email,
1006
+ displayName: decoded.name,
1007
+ roles: decoded.roles || [],
1008
+ isAdmin: decoded.roles?.includes("admin") ?? false,
1009
+ };
1010
+ } catch {
1011
+ return null;
1012
+ }
1013
+ },
1014
+
1015
+ // Optional: separate token verification for WebSocket auth
1016
+ verifyToken: async (token) => {
1017
+ // Same as above but receives just the token string
1018
+ // Default: synthesizes a Request and calls verifyRequest
1019
+ },
1020
+
1021
+ // Optional: enable user management in admin panel
1022
+ userManagement: { ... },
1023
+
1024
+ // Optional: static service key
1025
+ serviceKey: process.env.REBASE_SERVICE_KEY,
1026
+
1027
+ // Optional: override default capabilities
1028
+ capabilities: {
1029
+ emailPasswordLogin: false,
1030
+ registration: false,
1031
+ enabledProviders: ["google"],
1032
+ },
1033
+
1034
+ // Optional: enrich auth responses with external tokens
1035
+ transformAuthResponse: async (response, context) => {
1036
+ const externalToken = await generateExternalToken(context.userId);
1037
+ return {
1038
+ ...response,
1039
+ tokens: { ...response.tokens, externalToken },
1040
+ };
1041
+ },
1042
+ });
1043
+
1044
+ // Pass to initializeRebaseBackend:
1045
+ await initializeRebaseBackend({
1046
+ server, app,
1047
+ database: createPostgresAdapter({ connection: db, schema }),
1048
+ auth, // AuthAdapter directly
1049
+ });
1050
+ ```
1051
+
1052
+ ### AuthAdapterCapabilities
1053
+
1054
+ The frontend reads these from `GET /api/auth/config` to dynamically show/hide UI:
1055
+
1056
+ ```typescript
1057
+ interface AuthAdapterCapabilities {
1058
+ hasBuiltInAuthRoutes: boolean; // true for built-in, false for external
1059
+ emailPasswordLogin: boolean;
1060
+ registration: boolean;
1061
+ passwordReset: boolean;
1062
+ sessionManagement: boolean;
1063
+ profileUpdate: boolean;
1064
+ emailVerification: boolean;
1065
+ enabledProviders: string[]; // e.g. ["google", "github"]
1066
+ externalLoginUrl?: string; // Redirect URL for external auth
1067
+ needsSetup?: boolean; // true when no users exist
1068
+ registrationEnabled?: boolean;
1069
+ }
1070
+ ```
1071
+
1072
+ ### Default Capabilities for Custom Adapters
1073
+
1074
+ When using `createCustomAuthAdapter`, all capabilities default to `false`/`[]` unless overridden via `capabilities`.
1075
+
1076
+ ---
1077
+
1078
+ ## Roles & Permissions
1079
+
1080
+ ### Role Data Structure
1081
+
1082
+ ```typescript
1083
+ interface RoleData {
1084
+ id: string;
1085
+ name: string;
1086
+ isAdmin: boolean;
1087
+ defaultPermissions: {
1088
+ read?: boolean;
1089
+ create?: boolean;
1090
+ edit?: boolean;
1091
+ delete?: boolean;
1092
+ } | null;
1093
+ collectionPermissions: Record<string, {
1094
+ read?: boolean;
1095
+ create?: boolean;
1096
+ edit?: boolean;
1097
+ delete?: boolean;
1098
+ }> | null;
1099
+ }
1100
+ ```
1101
+
1102
+ ### Built-in Role Behavior
1103
+
1104
+ - The **first user** in the system is automatically assigned the `"admin"` role.
1105
+ - Subsequent users get the `defaultRole` (if configured).
1106
+ - Setting `defaultRole: "admin"` throws a startup error to prevent privilege escalation.
1107
+ - Admin status is determined by having a role with `id === "admin"` or `id === "schema-admin"`.
1108
+
1109
+ ### Admin Routes for User/Role Management
1110
+
1111
+ Admin user and role management is handled via dedicated admin routes (mounted under `/api/admin`) which require `requireAuth` + `requireAdmin` middleware.
1112
+
1113
+ ---
1114
+
1115
+ ## Backend Hooks
1116
+
1117
+ Backend hooks intercept data at the **API boundary** (after DB operations, before API responses). They are separate from auth hooks and collection-level `CollectionCallbacks`.
1118
+
1119
+ ### BackendHooks Interface
1120
+
1121
+ ```typescript
1122
+ interface BackendHooks {
1123
+ users?: UserHooks;
1124
+ data?: DataHooks;
1125
+ }
1126
+ ```
1127
+
1128
+ ### UserHooks (Admin User Management)
1129
+
1130
+ | Hook | Signature | Description |
1131
+ |---|---|---|
1132
+ | `afterRead` | `(user, context) => AdminUser \| null` | Transform user after DB read. Return `null` to hide. |
1133
+ | `beforeSave` | `(data, context) => data` | Transform before write. Throw to abort. |
1134
+ | `afterSave` | `(user, context) => void` | After user create/update. Side effects. |
1135
+ | `beforeDelete` | `(userId, context) => void` | Throw to prevent deletion. |
1136
+ | `afterDelete` | `(userId, context) => void` | After user deleted. |
1137
+
1138
+ ### DataHooks (All Collection Entities)
1139
+
1140
+ | Hook | Signature | Description |
1141
+ |---|---|---|
1142
+ | `afterRead` | `(slug, entity, context) => entity \| null` | Transform entity after read. Return `null` to filter out. |
1143
+ | `beforeSave` | `(slug, values, entityId, context) => values` | Transform before write. Throw to abort. |
1144
+ | `afterSave` | `(slug, entity, context) => void` | After entity create/update. Side effects. |
1145
+ | `beforeDelete` | `(slug, entityId, context) => void` | Throw to prevent deletion. |
1146
+ | `afterDelete` | `(slug, entityId, context) => void` | After entity deleted. |
1147
+
1148
+ ### BackendHookContext
1149
+
1150
+ ```typescript
1151
+ interface BackendHookContext {
1152
+ requestUser?: { userId: string; roles: string[] };
1153
+ method: "GET" | "POST" | "PUT" | "DELETE";
1154
+ }
1155
+ ```
1156
+
1157
+ ### Example: PII Masking
1158
+
1159
+ ```typescript
1160
+ const hooks: BackendHooks = {
1161
+ data: {
1162
+ afterRead(slug, entity, ctx) {
1163
+ if (!ctx.requestUser?.roles.includes("admin") && entity.email) {
1164
+ return { ...entity, email: "***" };
1165
+ }
1166
+ return entity;
1167
+ },
1168
+ },
1169
+ users: {
1170
+ afterRead(user, ctx) {
1171
+ // Hide system users from admin panel
1172
+ if (user.email.endsWith("@system.internal")) return null;
1173
+ return user;
1174
+ },
1175
+ },
1176
+ };
1177
+
1178
+ await initializeRebaseBackend({
1179
+ // ...
1180
+ hooks,
1181
+ });
1182
+ ```
1183
+
1184
+ ---
1185
+
1186
+ ## Email Configuration
1187
+
1188
+ Email is required for password reset, email verification, and welcome emails. Configure via `auth.email`.
1189
+
1190
+ ### EmailConfig
1191
+
1192
+ | Property | Type | Required | Description |
1193
+ |---|---|---|---|
1194
+ | `from` | `string` | Yes | Sender address (e.g. `"MyApp <noreply@myapp.com>"`). |
1195
+ | `smtp` | `SMTPConfig` | One of `smtp` or `sendEmail` | SMTP server configuration. |
1196
+ | `sendEmail` | `(options) => Promise<void>` | One of `smtp` or `sendEmail` | Custom email sending function (e.g. AWS SES, Resend SDK). |
1197
+ | `resetPasswordUrl` | `string` | No | Base URL for reset links: `{url}/reset-password?token=xxx`. |
1198
+ | `verifyEmailUrl` | `string` | No | Base URL for verification links: `{url}/verify-email?token=xxx`. |
1199
+ | `appName` | `string` | No | App name in email templates. Defaults to `"Rebase"`. |
1200
+ | `templates` | Object | No | Custom template functions (see below). |
1201
+
1202
+ ### SMTPConfig
1203
+
1204
+ ```typescript
1205
+ interface SMTPConfig {
1206
+ host: string;
1207
+ port: number;
1208
+ secure?: boolean;
1209
+ auth?: { user: string; pass: string };
1210
+ name?: string;
1211
+ }
1212
+ ```
1213
+
1214
+ ### Custom Email Templates
1215
+
1216
+ ```typescript
1217
+ email: {
1218
+ from: "noreply@myapp.com",
1219
+ smtp: { host: "smtp.example.com", port: 587 },
1220
+ templates: {
1221
+ passwordReset: (resetUrl, user) => ({
1222
+ subject: "Reset your password",
1223
+ html: `<p>Hi ${user.displayName || user.email},</p><p><a href="${resetUrl}">Reset</a></p>`,
1224
+ text: `Reset your password: ${resetUrl}`,
1225
+ }),
1226
+ emailVerification: (verifyUrl, user) => ({
1227
+ subject: "Verify your email",
1228
+ html: `<a href="${verifyUrl}">Verify</a>`,
1229
+ }),
1230
+ welcomeEmail: (user, appName) => ({
1231
+ subject: `Welcome to ${appName}!`,
1232
+ html: `<p>Welcome, ${user.displayName || user.email}!</p>`,
1233
+ }),
1234
+ userInvitation: (setPasswordUrl, user) => ({
1235
+ subject: "You've been invited",
1236
+ html: `<p>Set your password: <a href="${setPasswordUrl}">here</a></p>`,
1237
+ }),
1238
+ },
1239
+ }
1240
+ ```
1241
+
1242
+ ### Custom Email Provider (Non-SMTP)
1243
+
1244
+ ```typescript
1245
+ import { Resend } from "resend";
1246
+ const resend = new Resend(process.env.RESEND_API_KEY);
1247
+
1248
+ email: {
1249
+ from: "noreply@myapp.com",
1250
+ sendEmail: async (options) => {
1251
+ await resend.emails.send({
1252
+ from: options.from || "noreply@myapp.com",
1253
+ to: options.to,
1254
+ subject: options.subject,
1255
+ html: options.html,
1256
+ text: options.text,
1257
+ });
1258
+ },
1259
+ appName: "MyApp",
1260
+ resetPasswordUrl: "https://myapp.com",
1261
+ }
1262
+ ```
1263
+
1264
+ ---
1265
+
1266
+ ## Security Concepts
1267
+
1268
+ ### Service Key
1269
+
1270
+ A static secret for server-to-server authentication. Generate with:
1271
+ ```bash
1272
+ node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"
1273
+ ```
1274
+
1275
+ When a request includes `Authorization: Bearer <serviceKey>`:
1276
+ - It bypasses JWT verification.
1277
+ - It receives admin-level access (`uid: "service"`, `roles: ["admin"]`).
1278
+ - Comparison is done with constant-time comparison to prevent timing attacks.
1279
+ - Must be ≥ 32 characters (validated at startup).
1280
+
1281
+ > **TIP:** In Collection Callbacks and DataHooks, server-side `rebase.data` calls appear as `userId: "service"`, `roles: ["admin"]`. Use this to skip masking, bypass rate limits, or grant elevated access in your callback logic.
1282
+
1283
+ ### Token Rotation
1284
+
1285
+ Refresh tokens are rotated on every use:
1286
+ 1. Client sends refresh token to `POST /auth/refresh`.
1287
+ 2. Server deletes the old refresh token and creates a new one.
1288
+ 3. New access + refresh tokens are returned.
1289
+
1290
+ ### Password Reset Security
1291
+
1292
+ - `POST /auth/forgot-password` always returns success (doesn't reveal whether email exists).
1293
+ - Reset tokens are stored as SHA-256 hashes.
1294
+ - Tokens expire in 1 hour.
1295
+ - After password reset, **all sessions are invalidated** (all refresh tokens deleted).
1296
+
1297
+ ### Zod Input Validation
1298
+
1299
+ All auth endpoints validate input with Zod schemas:
1300
+
1301
+ | Field | Validation |
1302
+ |---|---|
1303
+ | `email` | Valid email, max 255 chars |
1304
+ | `password` | Min 1 char, max 128 chars |
1305
+ | `displayName` | Max 255 chars |
1306
+ | `photoURL` | Valid URL, max 2048 chars |
1307
+ | `refreshToken` | Min 1 char |
1308
+
1309
+ ---
1310
+
1311
+ ## References
1312
+
1313
+ - Source: `packages/server/src/auth/` — All auth implementation
1314
+ - Source: `packages/server/src/auth/routes.ts` — REST auth endpoints
1315
+ - Source: `packages/server/src/auth/auth-hooks.ts` — Lifecycle hooks
1316
+ - Source: `packages/server/src/auth/api-keys/` — API key system
1317
+ - Source: `packages/server/src/auth/rate-limiter.ts` — Rate limiting
1318
+ - Source: `packages/server/src/init.ts` — `RebaseAuthConfig` and backend init
1319
+ - Source: `packages/client/src/auth.ts` — Client SDK auth module
1320
+ - Source: `packages/types/src/types/auth_adapter.ts` — `AuthAdapter` interface
1321
+ - Source: `packages/server/src/auth/rls-scope.ts` — RLS scoping
1322
+ - Source: `packages/server/src/email/types.ts` — Email configuration
1323
+ - **Reserved Identities**: `"service"` / `"anon"` / `"api-key:{id}"` — see [Row-Level Security > Reserved System Identities](#reserved-system-identities)