@clubedge/create-clubedge-app 0.31.0 → 0.32.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 (74) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +11 -10
  3. package/package.json +3 -3
  4. package/template/.env.example +21 -0
  5. package/template/.github/workflows/ci.yml +11 -0
  6. package/template/README.md +11 -1
  7. package/template/SETUP.md +37 -0
  8. package/template/apps/start/.env.example +21 -0
  9. package/template/apps/start/package.json +3 -1
  10. package/template/apps/start/src/env/auth.ts +66 -1
  11. package/template/apps/start/src/env/production.ts +19 -0
  12. package/template/apps/start/src/routes/reset-password.tsx +3 -1
  13. package/template/apps/start/src/server/auth-email.ts +2 -2
  14. package/template/apps/start/src/server/auth-forms.ts +3 -1
  15. package/template/apps/start/src/server/variants/auth.appwrite.ts +42 -0
  16. package/template/apps/start/src/server/variants/auth.firebase.ts +49 -0
  17. package/template/apps/web/package.json +3 -1
  18. package/template/apps/web/src/app/auth/callback/route.ts +3 -1
  19. package/template/apps/web/src/app/reset-password/page.tsx +6 -2
  20. package/template/apps/web/src/env/auth.ts +67 -0
  21. package/template/apps/web/src/env/production.ts +19 -0
  22. package/template/apps/web/src/server/auth-email.ts +2 -2
  23. package/template/apps/web/src/server/variants/auth.appwrite.ts +48 -0
  24. package/template/apps/web/src/server/variants/auth.firebase.ts +55 -0
  25. package/template/clubedge.template.json +97 -1
  26. package/template/firebase.json +7 -0
  27. package/template/package.json +3 -2
  28. package/template/packages/auth/package.json +3 -2
  29. package/template/packages/auth/src/conformance.ts +322 -0
  30. package/template/packages/{auth-better-auth → auth}/src/emails.ts +2 -1
  31. package/template/packages/auth/src/index.ts +7 -0
  32. package/template/packages/auth/src/links.test.ts +22 -0
  33. package/template/packages/auth/src/links.ts +28 -0
  34. package/template/packages/auth-appwrite/package.json +23 -0
  35. package/template/packages/auth-appwrite/src/conformance.live.test.ts +82 -0
  36. package/template/packages/auth-appwrite/src/index.test.ts +204 -0
  37. package/template/packages/auth-appwrite/src/index.ts +202 -0
  38. package/template/packages/auth-appwrite/tsconfig.json +4 -0
  39. package/template/packages/auth-better-auth/package.json +1 -1
  40. package/template/packages/auth-better-auth/src/conformance.test.ts +49 -0
  41. package/template/packages/auth-better-auth/src/index.ts +11 -3
  42. package/template/packages/auth-firebase/package.json +23 -0
  43. package/template/packages/auth-firebase/src/conformance.test.ts +57 -0
  44. package/template/packages/auth-firebase/src/index.ts +310 -0
  45. package/template/packages/auth-firebase/tsconfig.json +4 -0
  46. package/template/packages/auth-supabase/package.json +1 -1
  47. package/template/packages/auth-supabase/src/conformance.test.ts +71 -0
  48. package/template/packages/cache/package.json +1 -1
  49. package/template/packages/cache-redis/package.json +1 -1
  50. package/template/packages/core/package.json +1 -1
  51. package/template/packages/db/drizzle/0001_auth_user_id_text.sql +3 -0
  52. package/template/packages/db/drizzle/meta/_journal.json +7 -0
  53. package/template/packages/db/package.json +1 -1
  54. package/template/packages/db/src/schema/users.ts +3 -2
  55. package/template/packages/email/package.json +1 -1
  56. package/template/packages/email-resend/package.json +1 -1
  57. package/template/packages/email-smtp/package.json +1 -1
  58. package/template/packages/jobs/package.json +1 -1
  59. package/template/packages/jobs-bullmq/package.json +1 -1
  60. package/template/packages/jobs-inngest/package.json +1 -1
  61. package/template/packages/jobs-pg-boss/package.json +1 -1
  62. package/template/packages/local-db/package.json +1 -1
  63. package/template/packages/observability/package.json +1 -1
  64. package/template/packages/observability-otel/package.json +1 -1
  65. package/template/packages/observability-sentry/package.json +1 -1
  66. package/template/packages/payments/package.json +1 -1
  67. package/template/packages/payments-stripe/package.json +1 -1
  68. package/template/packages/storage/package.json +1 -1
  69. package/template/packages/storage-s3/package.json +1 -1
  70. package/template/packages/storage-supabase/package.json +1 -1
  71. package/template/packages/ui/package.json +1 -1
  72. package/template/pnpm-lock.yaml +793 -0
  73. package/template/supabase/config.toml +4 -0
  74. package/template.lock.json +23 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.32.0
6
+
7
+ - **Pluggable authentication**: default to Starter `v0.21.0` (#93). Authentication now has four providers behind one interface, so the pages, settings, payments, and jobs stay the same whichever signs users in:
8
+ - `--auth supabase` and `--auth better-auth`, as before.
9
+ - **`--auth firebase`** (new): Firebase Authentication from the server, with session cookies. `pnpm auth:dev` starts the Auth Emulator, so development needs no Firebase account. Reset and confirmation emails go through the app's email module.
10
+ - **`--auth appwrite`** (new): Appwrite accounts on Appwrite Cloud or your own server, with session cookies and an API key. Appwrite sends its own emails.
11
+ - `@clubedge/auth/conformance` is one test suite every auth provider passes: sign-up and sign-in, sign-out, reset through the emailed link, password changes that sign out other sessions, and account deletion. The Starter's CI runs it against all four, including Appwrite Cloud.
12
+ - Stripe payments work with every provider.
13
+ - Switch an existing project with `pnpm clubedge add auth firebase` or `pnpm clubedge add auth appwrite`. After `pnpm clubedge upgrade`, run `pnpm db:migrate`: `users.auth_user_id` becomes text, since Firebase's and Appwrite's user ids aren't UUIDs.
14
+
5
15
  ## 0.31.0
6
16
 
7
17
  - **Pluggable background jobs**: default to Starter `v0.20.0` (#91). The jobs module now has three providers behind one interface, so job definitions, routes, and the dashboard's jobs card stay the same whichever runs them:
package/README.md CHANGED
@@ -129,7 +129,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
129
129
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
130
130
 
131
131
  # Reproduce a specific Starter release or commit
132
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.20.0
132
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.21.0
133
133
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
134
134
 
135
135
  # Test local Starter changes before releasing them
@@ -148,7 +148,7 @@ pnpm clubedge add email resend payments stripe # several modules as
148
148
  pnpm clubedge remove storage payments
149
149
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
150
150
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
151
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.20.0 # or a specific tag, branch, or commit
151
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.21.0 # or a specific tag, branch, or commit
152
152
  ```
153
153
 
154
154
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -259,27 +259,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
259
259
  Each CLI release is tested against one specific Starter release:
260
260
 
261
261
  ```text
262
- create-clubedge-app v0.31.0
262
+ create-clubedge-app v0.32.0
263
263
  |
264
264
  v
265
- Clubedge Starter v0.20.0
266
- commit 166fd38a1b4e8e94e6c411ff26f978ed52ce1249
265
+ Clubedge Starter v0.21.0
266
+ commit 8f1201f2d526347ccf0365a701aaa20e883f8d8a
267
267
  ```
268
268
 
269
269
  The current relationship is:
270
270
 
271
271
  | CLI release | Default Starter release | Meaning |
272
272
  | --- | --- | --- |
273
- | `create-clubedge-app@0.31.0` | `clubedge-starter@v0.20.0` | The CLI's required, tested default |
273
+ | `create-clubedge-app@0.32.0` | `clubedge-starter@v0.21.0` | The CLI's required, tested default |
274
274
 
275
275
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
276
276
 
277
- The `v0.20.0` tag is expected to resolve to commit
278
- `166fd38a1b4e8e94e6c411ff26f978ed52ce1249`. The CLI verifies this before
277
+ The `v0.21.0` tag is expected to resolve to commit
278
+ `8f1201f2d526347ccf0365a701aaa20e883f8d8a`. The CLI verifies this before
279
279
  scaffolding with the default reference and fails rather than silently using a
280
280
  moved tag.
281
281
 
282
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.20.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
282
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.21.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
283
283
 
284
284
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
285
285
 
@@ -334,7 +334,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
334
334
  1. Make and validate changes in `clubedge-starter`.
335
335
  2. Run lint, typecheck, unit tests, browser tests, and production build.
336
336
  3. Update the Starter package versions.
337
- 4. Commit `main`, create a release tag such as `v0.20.0`, and publish the Starter release.
337
+ 4. Commit `main`, create a release tag such as `v0.21.0`, and publish the Starter release.
338
338
 
339
339
  ### CLI maintainers
340
340
 
@@ -376,6 +376,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
376
376
  | 0.30.0 | v0.19.0 | `--infra neon` with a free Neon database, the `neon` command, `deploy --preview` with a Neon branch per preview |
377
377
  | 0.30.1 | v0.19.1 | Railway previews work with a workspace token and answer on the port their domain targets |
378
378
  | 0.31.0 | v0.20.0 | Pluggable background jobs: `--jobs bullmq` and `--jobs inngest` next to pg-boss, and a conformance suite every provider passes |
379
+ | 0.32.0 | v0.21.0 | Pluggable authentication: `--auth firebase` and `--auth appwrite` next to Supabase and Better Auth, and a conformance suite every provider passes |
379
380
  | next | | Show the Starter's version of a conflicted file next to yours |
380
381
 
381
382
  ## Community
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.20.0",
26
- "starterCommit": "166fd38a1b4e8e94e6c411ff26f978ed52ce1249"
25
+ "starterRef": "v0.21.0",
26
+ "starterCommit": "8f1201f2d526347ccf0365a701aaa20e883f8d8a"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -35,6 +35,27 @@ BETTER_AUTH_SECRET=
35
35
  BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=false # clubedge:only email!=none
36
36
  # clubedge:end
37
37
 
38
+ # clubedge:if auth=firebase
39
+ # Firebase Authentication. In development, pnpm auth:dev starts the Auth Emulator, which these
40
+ # values point at. In production, set your project's id and Web API key (Project settings,
41
+ # General) and a service account (Project settings, Service accounts), and remove
42
+ # FIREBASE_AUTH_EMULATOR_HOST.
43
+ FIREBASE_PROJECT_ID=demo-clubedge
44
+ FIREBASE_API_KEY=demo-api-key
45
+ FIREBASE_CLIENT_EMAIL=
46
+ FIREBASE_PRIVATE_KEY=
47
+ FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
48
+ # clubedge:end
49
+
50
+ # clubedge:if auth=appwrite
51
+ # Appwrite (https://appwrite.io), on Appwrite Cloud or your own server. Create a project, add a
52
+ # Web platform for this app's hostname (localhost in development), and an API key with the
53
+ # sessions.write and users.write scopes. Appwrite sends its own emails (its SMTP settings).
54
+ APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
55
+ APPWRITE_PROJECT_ID=
56
+ APPWRITE_API_KEY=
57
+ # clubedge:end
58
+
38
59
  # clubedge:if cache=redis
39
60
  # Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
40
61
  # process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
@@ -22,6 +22,8 @@ jobs:
22
22
  env:
23
23
  DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres
24
24
  REDIS_URL: redis://127.0.0.1:6379 # clubedge:only jobs=bullmq
25
+ FIREBASE_AUTH_EMULATOR_HOST: 127.0.0.1:9099 # clubedge:only auth=firebase
26
+ FIREBASE_PROJECT_ID: demo-clubedge # clubedge:only auth=firebase
25
27
  steps:
26
28
  - uses: actions/checkout@v7
27
29
  - uses: pnpm/action-setup@v6
@@ -33,6 +35,15 @@ jobs:
33
35
  - run: pnpm format:check
34
36
  - run: pnpm lint
35
37
  - run: pnpm typecheck
38
+ # clubedge:if auth=firebase
39
+ # Firebase's Auth Emulator (firebase.json), for the Firebase provider's conformance tests.
40
+ - name: Start the Firebase Auth Emulator
41
+ run: |
42
+ nohup npx --yes firebase-tools@15 emulators:start --only auth --project demo-clubedge \
43
+ > "$RUNNER_TEMP/firebase.log" 2>&1 &
44
+ timeout 240 bash -c 'until curl --silent --output /dev/null http://127.0.0.1:9099/; do sleep 2; done' \
45
+ || { cat "$RUNNER_TEMP/firebase.log"; exit 1; }
46
+ # clubedge:end
36
47
  - run: pnpm test
37
48
  # Build the way the app is developed: with the env example copied into place.
38
49
  - run: cp .env.example apps/web/.env.local
@@ -57,6 +57,8 @@ Every combination in the CI matrix is generated as a real project and runs its f
57
57
 
58
58
  - Supabase Auth with server-verified sessions in secure cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=supabase -->
59
59
  - Better Auth with users and sessions in your own PostgreSQL, secure session cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=better-auth -->
60
+ - Firebase Authentication through the app's own pages, with server-side session cookies, a protected dashboard, and rate-limited sign-in; the Auth Emulator in development. <!-- clubedge:only auth=firebase -->
61
+ - Appwrite accounts through the app's own pages, on Appwrite Cloud or your own server, with server-side session cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=appwrite -->
60
62
  - Password reset by email, and email verification that can be required before sign-in. <!-- clubedge:only auth=better-auth && email!=none -->
61
63
  - Password reset by email, sent by Supabase. <!-- clubedge:only auth=supabase -->
62
64
  - An account settings page: display name, password change, and account deletion. <!-- clubedge:only auth!=none -->
@@ -97,7 +99,7 @@ Pick modules when you create a project, and change them later with `pnpm clubedg
97
99
  | Module | Options | Default |
98
100
  | ------------- | --------------------------------------------------------- | ---------- |
99
101
  | Framework | `next`, `tanstack-start` | `next` |
100
- | Auth | `supabase`, `better-auth`, `none` | `supabase` |
102
+ | Auth | `supabase`, `better-auth`, `firebase`, `appwrite`, `none` | `supabase` |
101
103
  | Storage | `s3`, `supabase`, `none` | `s3` |
102
104
  | Cache | `redis`, `memory` | `redis` |
103
105
  | Email | `smtp`, `resend`, `none` | `smtp` |
@@ -219,6 +221,12 @@ flowchart TB
219
221
  App -->|auth interface and server sessions| Auth[Better Auth]
220
222
  Auth -->|users and sessions| DB
221
223
  %% clubedge:end
224
+ %% clubedge:if auth=firebase
225
+ App -->|auth interface and server sessions| Auth[Firebase Authentication]
226
+ %% clubedge:end
227
+ %% clubedge:if auth=appwrite
228
+ App -->|auth interface and server sessions| Auth[Appwrite]
229
+ %% clubedge:end
222
230
  %% clubedge:if cache=redis
223
231
  App -->|cache and rate limits| Redis[(Redis, or memory)]
224
232
  %% clubedge:end
@@ -348,6 +356,8 @@ The TanStack Start app is `apps/start`, run with `pnpm --filter @clubedge/start
348
356
  - `packages/auth/`: `AuthProvider` and `CookieStore` interfaces <!-- clubedge:only auth!=none -->
349
357
  - `packages/auth-supabase/`: Supabase Auth adapter <!-- clubedge:only auth=supabase -->
350
358
  - `packages/auth-better-auth/`: Better Auth adapter, with its own tables and migrations <!-- clubedge:only auth=better-auth -->
359
+ - `packages/auth-firebase/`: Firebase Authentication adapter, with session cookies <!-- clubedge:only auth=firebase -->
360
+ - `packages/auth-appwrite/`: Appwrite adapter, with session cookies <!-- clubedge:only auth=appwrite -->
351
361
  - `packages/storage/`: `StorageProvider` interface <!-- clubedge:only storage!=none -->
352
362
  - `packages/storage-s3/`: S3-compatible storage adapter <!-- clubedge:only storage=s3 -->
353
363
  - `packages/storage-supabase/`: Supabase Storage adapter <!-- clubedge:only storage=supabase -->
package/template/SETUP.md CHANGED
@@ -43,6 +43,8 @@ The variables are:
43
43
  - `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (for sign-in): see [Supabase Auth](#supabase-auth). <!-- clubedge:only framework=next && auth=supabase -->
44
44
  - `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` (for sign-in): see [Supabase Auth](#supabase-auth). <!-- clubedge:only framework=tanstack-start && auth=supabase -->
45
45
  - `BETTER_AUTH_SECRET` (for sign-in): see [Better Auth](#better-auth). <!-- clubedge:only auth=better-auth -->
46
+ - `FIREBASE_PROJECT_ID`, `FIREBASE_API_KEY`, and in production a service account: see [Firebase Authentication](#firebase-authentication). <!-- clubedge:only auth=firebase -->
47
+ - `APPWRITE_ENDPOINT`, `APPWRITE_PROJECT_ID`, and `APPWRITE_API_KEY` (for sign-in): see [Appwrite](#appwrite). <!-- clubedge:only auth=appwrite -->
46
48
  - `REDIS_URL` (optional): see [Redis](#redis). <!-- clubedge:only cache=redis -->
47
49
  - `SMTP_URL` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=smtp -->
48
50
  - `RESEND_API_KEY` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=resend -->
@@ -103,6 +105,41 @@ These emails are sent by the `send-email` [background job](#background-jobs), so
103
105
 
104
106
  <!-- clubedge:end -->
105
107
 
108
+ <!-- clubedge:if auth=firebase -->
109
+
110
+ ## Firebase Authentication
111
+
112
+ [Firebase Authentication](https://firebase.google.com/docs/auth) checks passwords and keeps the accounts; the app's own pages sign users in, and the server holds the session in an HTTP-only cookie (a Firebase session cookie, checked for revocation on each request). Password reset and email confirmation links are generated by Firebase and sent through the [email module](#email), so they open the app's own pages and need no setup in the Firebase console. <!-- clubedge:only email!=none -->
113
+ [Firebase Authentication](https://firebase.google.com/docs/auth) checks passwords and keeps the accounts; the app's own pages sign users in, and the server holds the session in an HTTP-only cookie (a Firebase session cookie, checked for revocation on each request). Without the email module there is no password reset. <!-- clubedge:only email=none -->
114
+
115
+ In development, `pnpm auth:dev` starts Firebase's Auth Emulator (`firebase.json`, port 9099), which the example environment points at: no Firebase project or account is needed. Accounts live in memory until it stops.
116
+
117
+ In production:
118
+
119
+ 1. In the [Firebase console](https://console.firebase.google.com), create a project and turn on **Email/Password** under Authentication, Sign-in method.
120
+ 2. Set `FIREBASE_PROJECT_ID` and `FIREBASE_API_KEY` (the Web API key, under Project settings, General).
121
+ 3. Under Project settings, Service accounts, generate a private key, and set `FIREBASE_CLIENT_EMAIL` and `FIREBASE_PRIVATE_KEY` from it (the key's line breaks as `\n` are fine).
122
+ 4. Remove `FIREBASE_AUTH_EMULATOR_HOST`; `pnpm check:env` refuses it in production.
123
+
124
+ Changing a password or resetting it signs out every other session; deleting an account removes it from Firebase after the app's own data for the user.
125
+
126
+ <!-- clubedge:end -->
127
+
128
+ <!-- clubedge:if auth=appwrite -->
129
+
130
+ ## Appwrite
131
+
132
+ [Appwrite](https://appwrite.io) keeps the accounts, on Appwrite Cloud or your own server; the app's own pages sign users in, and the server holds the session's secret in an HTTP-only cookie (`a_session_<project>`). Appwrite sends the email confirmation and password recovery emails itself, through its SMTP settings, with links back to the app's `/auth/callback` and `/reset-password`.
133
+
134
+ 1. Create a project in [Appwrite Cloud](https://cloud.appwrite.io) (free tier) or on your Appwrite server.
135
+ 2. Under Overview, Platforms, add a **Web** platform for the app's hostname: `localhost` in development, your domain in production. Appwrite only sends links to registered hostnames.
136
+ 3. Under Overview, Integrations, API keys, create a key with the `sessions.write` and `users.write` scopes.
137
+ 4. Set `APPWRITE_ENDPOINT` (such as `https://cloud.appwrite.io/v1`), `APPWRITE_PROJECT_ID`, and `APPWRITE_API_KEY`. The key stays on the server.
138
+
139
+ Changing or resetting a password signs out every other session; deleting an account removes the Appwrite user after the app's own data for them. A self-hosted Appwrite sends email only once its SMTP settings (`_APP_SMTP_HOST` and the rest) are set.
140
+
141
+ <!-- clubedge:end -->
142
+
106
143
  ## Local services
107
144
 
108
145
  <!-- clubedge:if infra=docker -->
@@ -37,6 +37,27 @@ BETTER_AUTH_SECRET=
37
37
  BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=false # clubedge:only email!=none
38
38
  # clubedge:end
39
39
 
40
+ # clubedge:if auth=firebase
41
+ # Firebase Authentication. In development, pnpm auth:dev starts the Auth Emulator, which these
42
+ # values point at. In production, set your project's id and Web API key (Project settings,
43
+ # General) and a service account (Project settings, Service accounts), and remove
44
+ # FIREBASE_AUTH_EMULATOR_HOST.
45
+ FIREBASE_PROJECT_ID=demo-clubedge
46
+ FIREBASE_API_KEY=demo-api-key
47
+ FIREBASE_CLIENT_EMAIL=
48
+ FIREBASE_PRIVATE_KEY=
49
+ FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
50
+ # clubedge:end
51
+
52
+ # clubedge:if auth=appwrite
53
+ # Appwrite (https://appwrite.io), on Appwrite Cloud or your own server. Create a project, add a
54
+ # Web platform for this app's hostname (localhost in development), and an API key with the
55
+ # sessions.write and users.write scopes. Appwrite sends its own emails (its SMTP settings).
56
+ APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
57
+ APPWRITE_PROJECT_ID=
58
+ APPWRITE_API_KEY=
59
+ # clubedge:end
60
+
40
61
  # clubedge:if cache=redis
41
62
  # Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
42
63
  # process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,7 +14,9 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@clubedge/auth": "workspace:*",
17
+ "@clubedge/auth-appwrite": "workspace:*",
17
18
  "@clubedge/auth-better-auth": "workspace:*",
19
+ "@clubedge/auth-firebase": "workspace:*",
18
20
  "@clubedge/auth-supabase": "workspace:*",
19
21
  "@clubedge/cache": "workspace:*",
20
22
  "@clubedge/cache-redis": "workspace:*",
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { optional, parseEnv } from "./server";
3
- // clubedge:if auth=better-auth
3
+ // clubedge:if auth=better-auth|firebase|appwrite
4
4
  import { getAppUrl } from "./server";
5
5
  // clubedge:end
6
6
 
@@ -60,3 +60,68 @@ export function getBetterAuthEnv(): BetterAuthEnv | null {
60
60
  };
61
61
  }
62
62
  // clubedge:end
63
+
64
+ // clubedge:if auth=firebase
65
+ const firebaseAuthSchema = z.object({
66
+ FIREBASE_PROJECT_ID: optional(z.string()),
67
+ // The project's Web API key: Project settings, General.
68
+ FIREBASE_API_KEY: optional(z.string()),
69
+ // A service account: Project settings, Service accounts. Not needed with the emulator.
70
+ FIREBASE_CLIENT_EMAIL: optional(z.string()),
71
+ FIREBASE_PRIVATE_KEY: optional(z.string()),
72
+ // The Auth Emulator (pnpm auth:dev) in development, such as 127.0.0.1:9099.
73
+ FIREBASE_AUTH_EMULATOR_HOST: optional(z.string()),
74
+ });
75
+
76
+ export interface FirebaseAuthEnv {
77
+ projectId: string;
78
+ apiKey: string;
79
+ clientEmail?: string;
80
+ privateKey?: string;
81
+ emulatorHost?: string;
82
+ baseUrl: string;
83
+ }
84
+
85
+ /** Firebase Auth settings, or null until the project id and Web API key are set. */
86
+ export function getFirebaseAuthEnv(): FirebaseAuthEnv | null {
87
+ const env = parseEnv(firebaseAuthSchema, "Firebase Auth");
88
+ if (!env.FIREBASE_PROJECT_ID || !env.FIREBASE_API_KEY) return null;
89
+ return {
90
+ projectId: env.FIREBASE_PROJECT_ID,
91
+ apiKey: env.FIREBASE_API_KEY,
92
+ ...(env.FIREBASE_CLIENT_EMAIL ? { clientEmail: env.FIREBASE_CLIENT_EMAIL } : {}),
93
+ ...(env.FIREBASE_PRIVATE_KEY ? { privateKey: env.FIREBASE_PRIVATE_KEY } : {}),
94
+ ...(env.FIREBASE_AUTH_EMULATOR_HOST ? { emulatorHost: env.FIREBASE_AUTH_EMULATOR_HOST } : {}),
95
+ baseUrl: getAppUrl(),
96
+ };
97
+ }
98
+ // clubedge:end
99
+
100
+ // clubedge:if auth=appwrite
101
+ const appwriteAuthSchema = z.object({
102
+ // The API endpoint, such as https://cloud.appwrite.io/v1 or your server's /v1.
103
+ APPWRITE_ENDPOINT: optional(z.url()),
104
+ APPWRITE_PROJECT_ID: optional(z.string()),
105
+ // An API key with the sessions.write and users.write scopes (Overview, Integrations, API keys).
106
+ APPWRITE_API_KEY: optional(z.string()),
107
+ });
108
+
109
+ export interface AppwriteAuthEnv {
110
+ endpoint: string;
111
+ projectId: string;
112
+ apiKey: string;
113
+ baseUrl: string;
114
+ }
115
+
116
+ /** Appwrite settings, or null until the endpoint, project, and API key are set. */
117
+ export function getAppwriteAuthEnv(): AppwriteAuthEnv | null {
118
+ const env = parseEnv(appwriteAuthSchema, "Appwrite");
119
+ if (!env.APPWRITE_ENDPOINT || !env.APPWRITE_PROJECT_ID || !env.APPWRITE_API_KEY) return null;
120
+ return {
121
+ endpoint: env.APPWRITE_ENDPOINT,
122
+ projectId: env.APPWRITE_PROJECT_ID,
123
+ apiKey: env.APPWRITE_API_KEY,
124
+ baseUrl: getAppUrl(),
125
+ };
126
+ }
127
+ // clubedge:end
@@ -23,6 +23,17 @@ export function requiredVariables(env: Env): Requirement[] {
23
23
  // clubedge:if auth=better-auth
24
24
  { key: "BETTER_AUTH_SECRET", use: "Better Auth sessions" },
25
25
  // clubedge:end
26
+ // clubedge:if auth=appwrite
27
+ { key: "APPWRITE_ENDPOINT", use: "Appwrite", url: true },
28
+ { key: "APPWRITE_PROJECT_ID", use: "Appwrite" },
29
+ { key: "APPWRITE_API_KEY", use: "Appwrite" },
30
+ // clubedge:end
31
+ // clubedge:if auth=firebase
32
+ { key: "FIREBASE_PROJECT_ID", use: "Firebase Authentication" },
33
+ { key: "FIREBASE_API_KEY", use: "Firebase Authentication" },
34
+ { key: "FIREBASE_CLIENT_EMAIL", use: "Firebase Authentication (the service account)" },
35
+ { key: "FIREBASE_PRIVATE_KEY", use: "Firebase Authentication (the service account)" },
36
+ // clubedge:end
26
37
  // clubedge:if cache=redis
27
38
  { key: "REDIS_URL", use: "the cache and rate limits", url: true },
28
39
  // clubedge:end
@@ -105,6 +116,14 @@ export function checkProductionEnv(env: Env): ProductionEnvCheck {
105
116
  invalid.push("INNGEST_DEV is set: production uses Inngest Cloud's keys, so remove it");
106
117
  }
107
118
  // clubedge:end
119
+ // clubedge:if auth=firebase
120
+ // The emulator only runs on a developer's machine.
121
+ if (env.FIREBASE_AUTH_EMULATOR_HOST?.trim()) {
122
+ invalid.push(
123
+ "FIREBASE_AUTH_EMULATOR_HOST is set: production uses your Firebase project, so remove it",
124
+ );
125
+ }
126
+ // clubedge:end
108
127
  // clubedge:if auth=better-auth
109
128
  const secret = env.BETTER_AUTH_SECRET?.trim();
110
129
  if (secret && secret.length < 32) invalid.push("BETTER_AUTH_SECRET needs at least 32 characters");
@@ -1,4 +1,5 @@
1
1
  import { CircleAlert } from "lucide-react";
2
+ import { linkToken } from "@clubedge/auth";
2
3
  import { Link, createFileRoute } from "@tanstack/react-router";
3
4
 
4
5
  import { AuthForm, PasswordField, SubmitButton } from "@/components/auth/auth-form-controls";
@@ -15,7 +16,8 @@ const asString = (value: unknown) => (typeof value === "string" ? value : undefi
15
16
  export const Route = createFileRoute("/reset-password")({
16
17
  validateSearch: (search: Record<string, unknown>): { error?: string; token?: string } => ({
17
18
  error: asString(search.error),
18
- token: asString(search.token),
19
+ // Appwrite's links carry userId and secret instead of a token.
20
+ token: linkToken(search, "token") ?? undefined,
19
21
  }),
20
22
  component: ResetPasswordPage,
21
23
  });
@@ -1,5 +1,5 @@
1
1
  import "@tanstack/react-start/server-only";
2
- import type { AuthEmailSender } from "@clubedge/auth-better-auth";
2
+ import type { AuthEmailSender } from "@clubedge/auth";
3
3
  // clubedge:if email!=none && jobs=none
4
4
  import { email } from "@/server/email";
5
5
  // clubedge:end
@@ -9,7 +9,7 @@ import { getJobs } from "@/server/jobs";
9
9
  // clubedge:end
10
10
 
11
11
  /**
12
- * How Better Auth sends verification and password reset emails: through the email module, or
12
+ * How the auth provider sends verification and password reset emails (Better Auth, Firebase): through the email module, or
13
13
  * not at all without one, which turns both features off.
14
14
  */
15
15
  export function authEmailSender(): AuthEmailSender | undefined {
@@ -1,4 +1,5 @@
1
1
  import "@tanstack/react-start/server-only";
2
+ import { linkToken } from "@clubedge/auth";
2
3
  import { z } from "zod";
3
4
  import { getClientIp, safeRedirectPath, reportError } from "@clubedge/core";
4
5
  import { getAppUrl } from "@/env/server";
@@ -159,7 +160,8 @@ export async function handleSignOut(): Promise<Response> {
159
160
 
160
161
  export async function handleAuthCallback(request: Request): Promise<Response> {
161
162
  const url = new URL(request.url);
162
- const code = url.searchParams.get("code");
163
+ // Appwrite's links carry userId and secret instead of a code.
164
+ const code = linkToken(url.searchParams, "code");
163
165
  const next = safeRedirectPath(url.searchParams.get("next"));
164
166
 
165
167
  if (code && isAuthConfigured()) {
@@ -0,0 +1,42 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import type { AuthProvider, AuthUser } from "@clubedge/auth";
3
+ import { createAppwriteAuth } from "@clubedge/auth-appwrite";
4
+ import { AppError, reportError } from "@clubedge/core";
5
+ import { getAppwriteAuthEnv } from "@/env/auth";
6
+ import { requestCookieStore } from "@/server/cookies";
7
+
8
+ export type { AuthUser } from "@clubedge/auth";
9
+
10
+ export function isAuthConfigured(): boolean {
11
+ return getAppwriteAuthEnv() !== null;
12
+ }
13
+
14
+ /** Request-scoped auth provider. Throws when authentication is not configured. */
15
+ export function getAuth(): AuthProvider {
16
+ const env = getAppwriteAuthEnv();
17
+ if (!env) throw new Error("Appwrite is not configured.");
18
+ return createAppwriteAuth({ ...env, cookies: requestCookieStore() });
19
+ }
20
+
21
+ /** Validates the session. Runs once per request from src/start.ts. */
22
+ export async function refreshAuthSession(): Promise<void> {
23
+ if (!isAuthConfigured()) return;
24
+ try {
25
+ await getAuth().refreshSession();
26
+ } catch (error) {
27
+ // Pages still render; protected handlers re-check the user and reject when needed.
28
+ reportError(error, { message: "Session refresh failed" });
29
+ }
30
+ }
31
+
32
+ export async function getCurrentUser(): Promise<AuthUser | null> {
33
+ if (!isAuthConfigured()) return null;
34
+ return getAuth().getUser();
35
+ }
36
+
37
+ /** For server functions and routes: throws a 401 AppError when signed out. */
38
+ export async function requireUser(): Promise<AuthUser> {
39
+ const user = await getCurrentUser();
40
+ if (!user) throw new AppError("Authentication required.", "UNAUTHENTICATED", 401);
41
+ return user;
42
+ }
@@ -0,0 +1,49 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import type { AuthProvider, AuthUser } from "@clubedge/auth";
3
+ import { createFirebaseAuth } from "@clubedge/auth-firebase";
4
+ import { siteConfig } from "@/config/site";
5
+ import { authEmailSender } from "@/server/auth-email";
6
+ import { AppError, reportError } from "@clubedge/core";
7
+ import { getFirebaseAuthEnv } from "@/env/auth";
8
+ import { requestCookieStore } from "@/server/cookies";
9
+
10
+ export type { AuthUser } from "@clubedge/auth";
11
+
12
+ export function isAuthConfigured(): boolean {
13
+ return getFirebaseAuthEnv() !== null;
14
+ }
15
+
16
+ /** Request-scoped auth provider. Throws when authentication is not configured. */
17
+ export function getAuth(): AuthProvider {
18
+ const env = getFirebaseAuthEnv();
19
+ if (!env) throw new Error("Firebase Auth is not configured.");
20
+ return createFirebaseAuth({
21
+ ...env,
22
+ cookies: requestCookieStore(),
23
+ sendEmail: authEmailSender(),
24
+ appName: siteConfig.name,
25
+ });
26
+ }
27
+
28
+ /** Validates the session. Runs once per request from src/start.ts. */
29
+ export async function refreshAuthSession(): Promise<void> {
30
+ if (!isAuthConfigured()) return;
31
+ try {
32
+ await getAuth().refreshSession();
33
+ } catch (error) {
34
+ // Pages still render; protected handlers re-check the user and reject when needed.
35
+ reportError(error, { message: "Session refresh failed" });
36
+ }
37
+ }
38
+
39
+ export async function getCurrentUser(): Promise<AuthUser | null> {
40
+ if (!isAuthConfigured()) return null;
41
+ return getAuth().getUser();
42
+ }
43
+
44
+ /** For server functions and routes: throws a 401 AppError when signed out. */
45
+ export async function requireUser(): Promise<AuthUser> {
46
+ const user = await getCurrentUser();
47
+ if (!user) throw new AppError("Authentication required.", "UNAUTHENTICATED", 401);
48
+ return user;
49
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/web",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@clubedge/auth": "workspace:*",
16
+ "@clubedge/auth-appwrite": "workspace:*",
16
17
  "@clubedge/auth-better-auth": "workspace:*",
18
+ "@clubedge/auth-firebase": "workspace:*",
17
19
  "@clubedge/auth-supabase": "workspace:*",
18
20
  "@clubedge/cache": "workspace:*",
19
21
  "@clubedge/cache-redis": "workspace:*",
@@ -1,4 +1,5 @@
1
1
  import { NextResponse, type NextRequest } from "next/server";
2
+ import { linkToken } from "@clubedge/auth";
2
3
  import { safeRedirectPath } from "@clubedge/core";
3
4
  import { loginUrl } from "@/lib/login-url";
4
5
  import { getAuth } from "@/server/auth";
@@ -14,7 +15,8 @@ function redirectTo(path: string) {
14
15
  }
15
16
 
16
17
  export async function GET(request: NextRequest) {
17
- const code = request.nextUrl.searchParams.get("code");
18
+ // Appwrite's links carry userId and secret instead of a code.
19
+ const code = linkToken(request.nextUrl.searchParams, "code");
18
20
  const next = safeRedirectPath(request.nextUrl.searchParams.get("next"));
19
21
 
20
22
  if (code) {
@@ -1,4 +1,5 @@
1
1
  import { CircleAlert } from "lucide-react";
2
+ import { linkToken } from "@clubedge/auth";
2
3
  import Link from "next/link";
3
4
 
4
5
  import { resetPassword } from "@/app/actions/auth";
@@ -14,9 +15,12 @@ const errorMessages: Record<string, string> = {
14
15
  export default async function ResetPasswordPage({
15
16
  searchParams,
16
17
  }: {
17
- searchParams: Promise<{ error?: string; token?: string }>;
18
+ searchParams: Promise<{ error?: string; token?: string; userId?: string; secret?: string }>;
18
19
  }) {
19
- const { error, token } = await searchParams;
20
+ const search = await searchParams;
21
+ const { error } = search;
22
+ // Appwrite's links carry userId and secret instead of a token.
23
+ const token = linkToken(search, "token") ?? undefined;
20
24
 
21
25
  return (
22
26
  <AuthPage