@zerotal/arch 1.7.0

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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
package/docs/social.md ADDED
@@ -0,0 +1,524 @@
1
+ ---
2
+ title: Social Login
3
+ description: Authenticate users through GitHub, Google, and Apple with OAuth2 drivers that handle CSRF state, token exchange, and profile normalisation for you.
4
+ ---
5
+
6
+ # Social Login
7
+
8
+ Social login ships as part of `@zerotal/auth` — OAuth2-based authentication with
9
+ built-in drivers for GitHub, Google, and Apple. CSRF state management, GET/POST
10
+ normalisation, GitHub's hidden-email fallback, and Apple's JWT signing are all
11
+ handled inside the driver — your controller stays thin.
12
+
13
+ ## Getting Started
14
+
15
+ Social login is included in `@zerotal/auth`; if you already have the auth
16
+ package installed there is nothing extra to add. Otherwise:
17
+
18
+ ```bash
19
+ # in your project root
20
+ bun add @zerotal/auth
21
+ ```
22
+
23
+ ## Register the provider
24
+
25
+ Add `SocialProvider` to the providers array in `bootstrap/providers.ts`:
26
+
27
+ ```typescript
28
+ // bootstrap/providers.ts
29
+ import { SocialProvider } from "@zerotal/auth";
30
+
31
+ const providers = [
32
+ // …your other providers
33
+ SocialProvider,
34
+ ];
35
+
36
+ export default providers;
37
+ ```
38
+
39
+ Registering the provider switches on the following (in lifecycle order):
40
+
41
+ - `onRegister` — binds the `SocialManager` as the `"social"` singleton and
42
+ registers the `Router.social()` router macro, so the macro is available in
43
+ every route file before the app boots.
44
+ - `onBooted` — reads `config/social.ts` from the container's `config` service
45
+ and instantiates a built-in driver (`github`, `google`, `apple`) for every key
46
+ it recognises. Unrecognised keys are silently skipped.
47
+
48
+ > **Note** — `SocialProvider` only runs in the `web` and `test` environments
49
+ > (`static environments = ["web", "test"]`). If the `config` service is
50
+ > unavailable at boot, no drivers are auto-registered — register them yourself
51
+ > with `Social.register()` (see [Writing a custom driver](#writing-a-custom-driver)).
52
+
53
+ ## Configuration
54
+
55
+ Create `config/social.ts` using the `SocialConfig()` helper (or `satisfies
56
+ SocialConfigShape`). The config maps each provider name to its OAuth2
57
+ credentials — there are no framework defaults, so include only the providers you
58
+ use and source secrets from the environment with `env()`:
59
+
60
+ ```typescript
61
+ // config/social.ts
62
+ import { SocialConfig } from "@zerotal/auth";
63
+ import { env } from "zerotal";
64
+
65
+ export default SocialConfig({
66
+ github: {
67
+ clientId: env("GITHUB_CLIENT_ID", ""),
68
+ clientSecret: env("GITHUB_CLIENT_SECRET", ""),
69
+ redirectUrl: env("GITHUB_REDIRECT_URL", ""), // https://myapp.com/auth/github/callback
70
+ },
71
+ google: {
72
+ clientId: env("GOOGLE_CLIENT_ID", ""),
73
+ clientSecret: env("GOOGLE_CLIENT_SECRET", ""),
74
+ redirectUrl: env("GOOGLE_REDIRECT_URL", ""),
75
+ },
76
+ // apple: { … } ← see the Apple section below
77
+ });
78
+ ```
79
+
80
+ Each provider entry accepts the following fields:
81
+
82
+ | Field | Required | Default | Description |
83
+ | -------------- | -------- | ------------------- | ------------------------------------------------------------------- |
84
+ | `clientId` | yes | — | OAuth2 client ID (Apple Service ID). |
85
+ | `clientSecret` | no\* | — | Static client secret. Apple may omit it and supply raw credentials. |
86
+ | `redirectUrl` | yes | — | The callback URL registered with the provider. |
87
+ | `scopes` | no | per-driver defaults | Override the driver's default scopes (see each provider section). |
88
+
89
+ \* For Apple you supply either `clientSecret` (a pre-signed JWT) or the raw
90
+ `teamId` / `keyId` / `privateKey` trio — see [Apple](#apple).
91
+
92
+ ## Login flow
93
+
94
+ ### 1. Write a controller
95
+
96
+ The driver handles CSRF state, session storage, and code extraction. Your
97
+ controller is a few lines per action — read the provider from `ctx.params`:
98
+
99
+ ```typescript
100
+ // app/controllers/SocialController.ts
101
+ import { Social } from "@zerotal/auth";
102
+ import type { HttpContext } from "zerotal";
103
+ import { User } from "../models/User.ts";
104
+
105
+ export class SocialController {
106
+ // Step 1 — generate state, store in session, redirect to provider
107
+ async redirect(ctx: HttpContext) {
108
+ return Social.driver(ctx.params.provider).redirect();
109
+ }
110
+
111
+ // Step 2 — verify state, extract code, fetch user profile
112
+ async callback(ctx: HttpContext) {
113
+ try {
114
+ const socialUser = await Social.driver(ctx.params.provider).user();
115
+
116
+ // Find or create the local user — that's all you need to write
117
+ const user = await User.firstOrCreate(
118
+ { provider_id: socialUser.id },
119
+ {
120
+ name: socialUser.name,
121
+ email: socialUser.email,
122
+ provider: ctx.params.provider,
123
+ },
124
+ );
125
+
126
+ ctx.session.regenerate();
127
+ ctx.session.set("user_id", user.id);
128
+ ctx.redirect("/dashboard");
129
+ } catch (e: unknown) {
130
+ const msg = e instanceof Error ? e.message : "unknown_error";
131
+ ctx.redirect(`/login?error=${msg}`);
132
+ // e.message is 'invalid_state' or 'missing_code' on validation failure,
133
+ // or a provider error message on token/profile fetch failure.
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ > **Note** — On a stateful (session) flow, `.user()` is called with no arguments;
140
+ > the driver reads the active request from async-local storage, extracts `code`
141
+ > and `state`, and verifies `state` against the session before exchanging the
142
+ > code.
143
+
144
+ ### 2. Register routes
145
+
146
+ `SocialProvider` registers `Router.social()` as a router macro — the same
147
+ mechanism used by `@zerotal/flow` (`Router.flow`) and `@zerotal/inertia`
148
+ (`Router.inertia`). Once `SocialProvider` is in your bootstrap, the macro is
149
+ available in every route file with no additional import:
150
+
151
+ ```typescript
152
+ // routes/auth.ts
153
+ import { Router } from "zerotal";
154
+ import { SocialController } from "../app/controllers/SocialController.ts";
155
+
156
+ Router.social("/auth", SocialController);
157
+ // Expands to:
158
+ // Router.get('/auth/:provider', SocialController, 'redirect');
159
+ // Router.get('/auth/:provider/callback', SocialController, 'callback');
160
+ // Router.post('/auth/:provider/callback', SocialController, 'callback'); // Apple
161
+ ```
162
+
163
+ Or register routes individually for full control:
164
+
165
+ ```typescript
166
+ // routes/auth.ts
167
+ import { Router } from "zerotal";
168
+ import { SocialController } from "../app/controllers/SocialController.ts";
169
+
170
+ Router.get("/auth/:provider", SocialController, "redirect");
171
+ Router.get("/auth/:provider/callback", SocialController, "callback");
172
+ Router.post("/auth/:provider/callback", SocialController, "callback");
173
+ ```
174
+
175
+ ### The SocialUser shape
176
+
177
+ Every driver normalises the raw provider response into the same interface:
178
+
179
+ | Property | Type | Description |
180
+ | -------------- | ------------------------- | ------------------------------------------------------- |
181
+ | `id` | `string` | Provider-unique user ID. |
182
+ | `name` | `string` | Display name. |
183
+ | `email` | `string \| null` | Primary email (always resolved — see per-driver notes). |
184
+ | `avatar` | `string \| null` | Profile picture URL. |
185
+ | `token` | `string` | Raw access token. |
186
+ | `refreshToken` | `string \| null` | Refresh token, when the provider issues one. |
187
+ | `expiresIn` | `number \| null` | Access-token lifetime in seconds, when provided. |
188
+ | `raw` | `Record<string, unknown>` | Full provider payload for custom fields. |
189
+
190
+ Most providers only return a `refreshToken` when you explicitly request offline
191
+ access — see [Requesting scopes and parameters](#requesting-scopes-and-parameters)
192
+ for Google's `access_type=offline` example. Store the refresh token if you need to
193
+ call the provider's API on the user's behalf later.
194
+
195
+ ## GitHub
196
+
197
+ Default scopes: `read:user user:email`.
198
+
199
+ ```typescript
200
+ // config/social.ts
201
+ github: {
202
+ clientId: env("GITHUB_CLIENT_ID", ""),
203
+ clientSecret: env("GITHUB_CLIENT_SECRET", ""),
204
+ redirectUrl: env("GITHUB_REDIRECT_URL", ""),
205
+ scopes: ['read:user', 'user:email', 'repo'], // override only if you need extra scopes
206
+ },
207
+ ```
208
+
209
+ GitHub returns `null` for `email` when the user has a private address.
210
+ `GitHubDriver` calls `/user/emails` internally and populates the verified
211
+ primary email before returning `SocialUser` — no extra code in your controller.
212
+
213
+ > **Note** — `GitHubDriver` omits `response_type=code` from the authorization URL;
214
+ > GitHub returns a 404 if it is present. Custom GitHub-style drivers can do the
215
+ > same by overriding `includeResponseType()` to return `false`.
216
+
217
+ ## Google
218
+
219
+ Default scopes: `openid profile email`.
220
+
221
+ ```typescript
222
+ // config/social.ts
223
+ google: {
224
+ clientId: env("GOOGLE_CLIENT_ID", ""),
225
+ clientSecret: env("GOOGLE_CLIENT_SECRET", ""),
226
+ redirectUrl: env("GOOGLE_REDIRECT_URL", ""),
227
+ },
228
+ ```
229
+
230
+ Google always returns a verified email. `socialUser.email` is never null with
231
+ the default scopes.
232
+
233
+ ## Apple
234
+
235
+ Sign in with Apple has three quirks — all handled internally by `AppleDriver`:
236
+
237
+ 1. **User profile is sent only once.** Apple sends `name` and `email` on the very
238
+ first authorization. On every subsequent login those fields are absent. Store
239
+ them in your database on the first callback.
240
+ 2. **Callbacks arrive as POST requests.** Apple uses `response_mode: form_post`.
241
+ The driver reads `code` and `state` from the POST body automatically — your
242
+ controller code is identical to other providers.
243
+ 3. **`clientSecret` is a signed ES256 JWT.** You have two options below.
244
+
245
+ > **Warning** — Because Apple sends the profile only on the first login, you must
246
+ > persist `socialUser.name` and `socialUser.email` on the first callback. Later
247
+ > logins will have `name` empty and `email` populated only from the `id_token`.
248
+
249
+ **Option A — supply raw credentials (recommended).** Pass your Apple Developer
250
+ credentials and the driver signs the JWT automatically using the Web Crypto API.
251
+ No extra dependency needed:
252
+
253
+ ```typescript
254
+ // config/social.ts
255
+ apple: {
256
+ clientId: 'com.myapp.service', // your Service ID
257
+ teamId: env("APPLE_TEAM_ID", ""), // 10-character Team ID
258
+ keyId: env("APPLE_KEY_ID", ""), // Key ID from App Store Connect
259
+ privateKey: env("APPLE_PRIVATE_KEY", ""), // full PEM string (-----BEGIN PRIVATE KEY-----)
260
+ redirectUrl: env("APPLE_REDIRECT_URL", ""),
261
+ },
262
+ ```
263
+
264
+ **Option B — pre-sign the JWT yourself** (e.g. with `apple-signin-auth`) and pass
265
+ it as `clientSecret`. Useful if you rotate the JWT externally:
266
+
267
+ ```typescript
268
+ // config/social.ts
269
+ apple: {
270
+ clientId: 'com.myapp.service',
271
+ clientSecret: generateAppleClientSecret(), // your pre-signed JWT
272
+ redirectUrl: env("APPLE_REDIRECT_URL", ""),
273
+ },
274
+ ```
275
+
276
+ > **Note** — If you supply neither a `clientSecret` nor the `teamId` / `keyId` /
277
+ > `privateKey` trio, `AppleDriver` throws `AppleClientSecretError` on the first
278
+ > token exchange.
279
+
280
+ ## More built-in providers
281
+
282
+ Alongside GitHub, Google, and Apple, six more drivers ship built-in and are
283
+ auto-registered from `config/social.ts` by their key — just supply credentials:
284
+
285
+ | Key | Driver | Default scopes | Notes |
286
+ | ----------- | ----------------- | -------------------------------- | -------------------------------------------------------------------- |
287
+ | `discord` | `DiscordDriver` | `identify email` | Avatar URL is built from the user id + avatar hash. |
288
+ | `microsoft` | `MicrosoftDriver` | `openid profile email User.Read` | Uses the common tenant + Microsoft Graph `/me`. |
289
+ | `facebook` | `FacebookDriver` | `email public_profile` | Requests `picture.type(large)` for the avatar. |
290
+ | `twitter` | `TwitterDriver` | `tweet.read users.read` | OAuth2; the profile is under `data`, and email is `null` by default. |
291
+ | `linkedin` | `LinkedInDriver` | `openid profile email` | OpenID Connect userinfo (`sub` is the id). |
292
+ | `gitlab` | `GitLabDriver` | `read_user` | Targets gitlab.com. |
293
+
294
+ ```typescript
295
+ // config/social.ts — same shape as github/google/apple
296
+ discord: {
297
+ clientId: env("DISCORD_CLIENT_ID", ""),
298
+ clientSecret: env("DISCORD_CLIENT_SECRET", ""),
299
+ redirectUrl: env("DISCORD_REDIRECT_URL", ""),
300
+ },
301
+ ```
302
+
303
+ Each is also exported (`DiscordDriver`, `MicrosoftDriver`, …) for manual
304
+ registration via `Social.register()`, and you can still
305
+ [write a custom driver](#writing-a-custom-driver) for anything not covered.
306
+
307
+ ## Requesting scopes and parameters
308
+
309
+ Scopes can be set per-provider in `config/social.ts`, but you can also add or
310
+ replace them fluently at redirect time. `.scopes()` merges with the configured /
311
+ default scopes; `.setScopes()` replaces them outright:
312
+
313
+ ```typescript
314
+ // Ask for extra GitHub scopes on top of the defaults
315
+ Social.driver("github").scopes(["repo", "read:org"]).redirect();
316
+
317
+ // Replace the scope list entirely
318
+ Social.driver("github").setScopes(["read:user"]).redirect();
319
+ ```
320
+
321
+ Use `.with()` to append provider-specific query parameters to the authorization
322
+ URL. This is how you request a refresh token from Google — Google only returns one
323
+ when you ask for offline access and force the consent screen:
324
+
325
+ ```typescript
326
+ Social.driver("google").with({ access_type: "offline", prompt: "consent" }).redirect();
327
+
328
+ // On callback, socialUser.refreshToken is now populated.
329
+ ```
330
+
331
+ Each fluent call returns a fresh copy of the driver, so the singleton registered
332
+ by `SocialProvider` is never mutated and concurrent requests don't interfere.
333
+
334
+ ## Retrieving a user from a token
335
+
336
+ If your client already holds an access token — for example a mobile app that ran
337
+ its own native OAuth SDK — skip the code exchange and fetch the profile directly
338
+ with `userFromToken()`:
339
+
340
+ ```typescript
341
+ const socialUser = await Social.driver("github").userFromToken(accessToken);
342
+ ```
343
+
344
+ The returned user has no `refreshToken` or `expiresIn` (those only come from a
345
+ code exchange), but `id`, `name`, `email`, and `avatar` are fully resolved.
346
+
347
+ ## Stateless mode
348
+
349
+ When you're building an API backend for an SPA or mobile app, you may receive a
350
+ raw `code` from the client without a session. Call `.stateless()` to skip CSRF
351
+ state verification and pass the code directly to `.user()`:
352
+
353
+ ```typescript
354
+ // app/controllers/SocialApiController.ts — POST /auth/callback { provider, code }
355
+ import { Social } from "@zerotal/auth";
356
+ import type { HttpContext } from "zerotal";
357
+
358
+ async callback(ctx: HttpContext) {
359
+ const { provider, code } = await ctx.request.json<{ provider: string; code: string }>();
360
+ const socialUser = await Social.driver(provider).stateless().user(code);
361
+ // … find or create user, return JWT …
362
+ }
363
+ ```
364
+
365
+ `.stateless()` returns a shallow copy of the driver — the singleton registered by
366
+ `SocialProvider` is never mutated.
367
+
368
+ ### Stateful or stateless — which should I use?
369
+
370
+ - **Stateful (the default).** Server-rendered apps where the browser holds a
371
+ session cookie. Call `.redirect()` then `.user()` with no arguments; the driver
372
+ generates and verifies the CSRF `state` for you.
373
+ - **Stateless.** API backends for an SPA or native app that send the raw `code`
374
+ themselves and have no server session. Call `.stateless().user(code)`; CSRF
375
+ state verification is skipped, so verify the request another way (e.g. PKCE on
376
+ the client).
377
+
378
+ > **Warning** — Stateless mode disables CSRF `state` verification. Only use it
379
+ > when the client performs its own request integrity check; otherwise prefer the
380
+ > stateful flow.
381
+
382
+ ## Testing
383
+
384
+ `Social.fake()` swaps a provider for a stub driver that never touches the network.
385
+ `redirect()` still issues a real redirect (so redirect-route tests pass), while
386
+ `user()` returns a canned profile. Build that profile with `fakeSocialUser()`,
387
+ overriding only the fields your test cares about:
388
+
389
+ ```typescript
390
+ import { Social, fakeSocialUser } from "@zerotal/auth";
391
+
392
+ test("logs a user in via GitHub", async () => {
393
+ Social.fake("github", fakeSocialUser({ id: "github-123", email: "jane@example.com" }));
394
+
395
+ const res = await app.get("/auth/github/callback");
396
+
397
+ res.assertRedirect("/dashboard");
398
+ await assertDatabaseHas("users", { email: "jane@example.com", github_id: "github-123" });
399
+ });
400
+ ```
401
+
402
+ `fakeSocialUser()` fills complete, valid defaults (including `token`,
403
+ `refreshToken`, and `expiresIn`), so you only specify what matters to the
404
+ assertion.
405
+
406
+ ## Writing a custom driver
407
+
408
+ Extend `OAuth2Driver` and implement the five abstract members — `authUrl()`,
409
+ `tokenUrl()`, `userUrl()`, `defaultScopes()`, and `normalise()`:
410
+
411
+ ```typescript
412
+ // app/social/TwitterDriver.ts
413
+ import { OAuth2Driver } from "@zerotal/auth";
414
+ import type { SocialUser } from "@zerotal/auth";
415
+
416
+ export class TwitterDriver extends OAuth2Driver {
417
+ protected authUrl() {
418
+ return "https://twitter.com/i/oauth2/authorize";
419
+ }
420
+ protected tokenUrl() {
421
+ return "https://api.twitter.com/2/oauth2/token";
422
+ }
423
+ protected userUrl() {
424
+ return "https://api.twitter.com/2/users/me?user.fields=profile_image_url";
425
+ }
426
+
427
+ protected defaultScopes() {
428
+ return ["tweet.read", "users.read"];
429
+ }
430
+
431
+ protected normalise(raw: Record<string, unknown>, token: string): SocialUser {
432
+ const data = raw["data"] as Record<string, unknown>;
433
+ return {
434
+ id: String(data["id"]),
435
+ name: String(data["name"] ?? ""),
436
+ email: null, // requires extra scope
437
+ avatar: typeof data["profile_image_url"] === "string" ? data["profile_image_url"] : null,
438
+ token,
439
+ raw,
440
+ };
441
+ }
442
+ }
443
+ ```
444
+
445
+ Register it via the `Social` facade — typically in an `AppProvider`'s `onBooted`
446
+ hook, since the `"social"` singleton is bound during `onRegister`:
447
+
448
+ ```typescript
449
+ // app/providers/AppProvider.ts
450
+ import { ServiceProvider } from "zerotal";
451
+ import { Social } from "@zerotal/auth";
452
+ import { TwitterDriver } from "../social/TwitterDriver.ts";
453
+
454
+ export class AppProvider extends ServiceProvider {
455
+ override async onBooted(): Promise<void> {
456
+ Social.register(
457
+ "twitter",
458
+ new TwitterDriver({
459
+ clientId: Bun.env.TWITTER_CLIENT_ID!,
460
+ clientSecret: Bun.env.TWITTER_CLIENT_SECRET!,
461
+ redirectUrl: Bun.env.TWITTER_REDIRECT_URL!,
462
+ }),
463
+ );
464
+ }
465
+ }
466
+ ```
467
+
468
+ `Social.register()` adds the driver to the manager directly, so a custom driver
469
+ does not need a `config/social.ts` entry (built-in keys are the only ones
470
+ auto-registered by `SocialProvider`).
471
+
472
+ > **Tip** — Override `afterNormalise()` to enrich the profile with a second
473
+ > request (this is how `GitHubDriver` resolves hidden emails), `extraAuthParams()`
474
+ > to append query params to the redirect, or `_extractCodeAndState()` to read the
475
+ > callback from somewhere other than the query string (this is how `AppleDriver`
476
+ > handles `form_post`).
477
+
478
+ ## References
479
+
480
+ ### `Social` facade
481
+
482
+ Resolves the `"social"` singleton (`SocialManager`).
483
+
484
+ | Method | Signature | Description |
485
+ | ---------- | ---------------------------------------------------- | ------------------------------------------------ |
486
+ | `driver` | `driver(name: string): OAuth2Driver` | Get a registered driver. Throws if not found. |
487
+ | `register` | `register(name: string, driver: OAuth2Driver): this` | Register a driver under a name. |
488
+ | `drivers` | `drivers(): string[]` | List all registered driver names. |
489
+ | `fake` | `fake(name: string, user?: SocialUser): SocialUser` | Swap a provider for a no-network stub (testing). |
490
+
491
+ ### `OAuth2Driver` (public methods)
492
+
493
+ | Method | Signature | Description |
494
+ | --------------- | --------------------------------------------------- | ----------------------------------------------------------------------- |
495
+ | `redirect` | `redirect(): void` | Generate CSRF state, store it in the session, redirect to the provider. |
496
+ | `user` | `user(code?: string): Promise<SocialUser>` | Exchange the callback for a profile. With `code`, runs stateless. |
497
+ | `userFromToken` | `userFromToken(token: string): Promise<SocialUser>` | Fetch a profile from an access token you already hold. |
498
+ | `scopes` | `scopes(scopes: string[]): this` | Add scopes (merged with defaults). Returns a copy. |
499
+ | `setScopes` | `setScopes(scopes: string[]): this` | Replace all scopes. Returns a copy. |
500
+ | `with` | `with(params: Record<string, string>): this` | Append optional auth-URL params (e.g. `access_type`). Returns a copy. |
501
+ | `stateless` | `stateless(): this` | Return a copy of the driver with CSRF state verification disabled. |
502
+ | `redirectUrl` | `redirectUrl(state: string): string` | Build the raw authorization URL (low-level / testing). |
503
+
504
+ ### Errors
505
+
506
+ All extend `SocialError` (which extends `ZerotalError`). The `message` of the two
507
+ validation errors is exactly the string you forward to `?error=`.
508
+
509
+ | Error | `message` | When |
510
+ | ------------------------------- | ------------------------- | -------------------------------------------------- |
511
+ | `OAuthStateMismatchError` | `invalid_state` | Callback `state` missing or doesn't match session. |
512
+ | `OAuthMissingCodeError` | `missing_code` | Callback has no authorization `code`. |
513
+ | `UnknownSocialDriverError` | driver-name message | `Social.driver(name)` with an unregistered name. |
514
+ | `SocialContextUnavailableError` | context message | Stateful flow run outside an HTTP request. |
515
+ | `OAuthTokenExchangeError` | provider message | Token exchange failed or returned no token. |
516
+ | `OAuthUserFetchError` | provider message | User-profile fetch failed. |
517
+ | `AppleClientSecretError` | Apple credentials message | Apple driver has neither a JWT nor raw key trio. |
518
+
519
+ ## Next steps
520
+
521
+ - [Authentication](/docs/authentication) — establish the session after a social login.
522
+ - [Authorization](/docs/authorization) — gate routes once the user is signed in.
523
+ - [CSRF](/docs/csrf) — understand the state protection the drivers apply.
524
+ - [Session](/docs/session) — manage the logged-in user across requests.