ras-stack 2.0.1 → 3.0.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 (110) hide show
  1. package/README.md +15 -42
  2. package/dist/changesets/cli.d.ts +1 -0
  3. package/dist/changesets/cli.js +14 -0
  4. package/dist/changesets/cli.js.map +1 -0
  5. package/dist/{policy/changesets.js → changesets/index.js} +1 -1
  6. package/dist/changesets/index.js.map +1 -0
  7. package/dist/cli.d.ts +2 -6
  8. package/dist/cli.js +3 -7
  9. package/dist/cli.js.map +1 -1
  10. package/dist/posthog/index.d.ts +0 -2
  11. package/dist/posthog/index.js +0 -1
  12. package/dist/posthog/index.js.map +1 -1
  13. package/dist/posthog/proxy.d.ts +11 -1
  14. package/dist/posthog/proxy.js +15 -3
  15. package/dist/posthog/proxy.js.map +1 -1
  16. package/package.json +12 -39
  17. package/config/oxlint/application.json +0 -16
  18. package/config/oxlint/domain.json +0 -29
  19. package/config/oxlint/layers.json +0 -116
  20. package/config/oxlint/tanstack.json +0 -4
  21. package/config/oxlint.json +0 -19
  22. package/config/typescript/base.json +0 -10
  23. package/config/typescript/browser.json +0 -8
  24. package/config/typescript/bundler.json +0 -9
  25. package/config/typescript/library.json +0 -11
  26. package/config/typescript/node-bundler.json +0 -9
  27. package/config/typescript/tanstack.json +0 -6
  28. package/dist/create/index.d.ts +0 -1
  29. package/dist/create/index.js +0 -83
  30. package/dist/create/index.js.map +0 -1
  31. package/dist/create/template/.env.example +0 -16
  32. package/dist/create/template/.oxfmtrc.json +0 -7
  33. package/dist/create/template/Dockerfile +0 -37
  34. package/dist/create/template/Dockerfile.standalone +0 -29
  35. package/dist/create/template/centrifugo.json +0 -14
  36. package/dist/create/template/dockerignore.template +0 -8
  37. package/dist/create/template/drizzle/0000_production_reference.sql +0 -114
  38. package/dist/create/template/drizzle/meta/_journal.json +0 -13
  39. package/dist/create/template/e2e/full-stack.spec.ts +0 -54
  40. package/dist/create/template/gitignore.template +0 -9
  41. package/dist/create/template/oxlint.json +0 -4
  42. package/dist/create/template/package.json +0 -60
  43. package/dist/create/template/playwright.config.ts +0 -7
  44. package/dist/create/template/pnpm-workspace.template.yaml +0 -13
  45. package/dist/create/template/ras-stack.assets.json +0 -4
  46. package/dist/create/template/scripts/containerRuntime.ts +0 -29
  47. package/dist/create/template/scripts/database.test.ts +0 -121
  48. package/dist/create/template/scripts/database.ts +0 -105
  49. package/dist/create/template/src/client/auth.ts +0 -3
  50. package/dist/create/template/src/client/queries.ts +0 -4
  51. package/dist/create/template/src/client/queryClient.ts +0 -1
  52. package/dist/create/template/src/client/useRealtime.ts +0 -20
  53. package/dist/create/template/src/posthog.ts +0 -21
  54. package/dist/create/template/src/routeTree.gen.ts +0 -230
  55. package/dist/create/template/src/router.tsx +0 -17
  56. package/dist/create/template/src/routes/__root.tsx +0 -40
  57. package/dist/create/template/src/routes/api/auth.$.ts +0 -8
  58. package/dist/create/template/src/routes/api/centrifugo.connect.ts +0 -29
  59. package/dist/create/template/src/routes/api/health.ts +0 -6
  60. package/dist/create/template/src/routes/api/live.ts +0 -5
  61. package/dist/create/template/src/routes/api/ready.ts +0 -30
  62. package/dist/create/template/src/routes/api/uploads.$id.ts +0 -40
  63. package/dist/create/template/src/routes/api/uploads.ts +0 -25
  64. package/dist/create/template/src/routes/index.tsx +0 -189
  65. package/dist/create/template/src/server/app.test.ts +0 -24
  66. package/dist/create/template/src/server/app.ts +0 -172
  67. package/dist/create/template/src/server/auth-flow.test.ts +0 -150
  68. package/dist/create/template/src/server/auth.ts +0 -56
  69. package/dist/create/template/src/server/environment.test.ts +0 -43
  70. package/dist/create/template/src/server/environment.ts +0 -67
  71. package/dist/create/template/src/server/fns.ts +0 -38
  72. package/dist/create/template/src/server/messages.test.ts +0 -38
  73. package/dist/create/template/src/server/messages.ts +0 -28
  74. package/dist/create/template/src/server/migration.test.ts +0 -34
  75. package/dist/create/template/src/server/outbox.test.ts +0 -130
  76. package/dist/create/template/src/server/outbox.ts +0 -110
  77. package/dist/create/template/src/server/posthog.test.ts +0 -34
  78. package/dist/create/template/src/server/rate-limit.test.ts +0 -32
  79. package/dist/create/template/src/server/rate-limit.ts +0 -29
  80. package/dist/create/template/src/server/rpc.ts +0 -16
  81. package/dist/create/template/src/server/schema.ts +0 -134
  82. package/dist/create/template/src/server/session.test.ts +0 -56
  83. package/dist/create/template/src/server/session.ts +0 -13
  84. package/dist/create/template/src/server/uploads.test.ts +0 -142
  85. package/dist/create/template/src/server/uploads.ts +0 -199
  86. package/dist/create/template/src/start.ts +0 -12
  87. package/dist/create/template/src/styles.css +0 -42
  88. package/dist/create/template/tsconfig.json +0 -8
  89. package/dist/create/template/vite.config.ts +0 -31
  90. package/dist/create/template/vitest.config.ts +0 -3
  91. package/dist/policy/changesets.js.map +0 -1
  92. package/dist/policy/cli.d.ts +0 -1
  93. package/dist/policy/cli.js +0 -23
  94. package/dist/policy/cli.js.map +0 -1
  95. package/dist/policy/index.d.ts +0 -14
  96. package/dist/policy/index.js +0 -132
  97. package/dist/policy/index.js.map +0 -1
  98. package/dist/policy/init-cli.d.ts +0 -12
  99. package/dist/policy/init-cli.js +0 -75
  100. package/dist/policy/init-cli.js.map +0 -1
  101. package/dist/policy/init.d.ts +0 -24
  102. package/dist/policy/init.js +0 -116
  103. package/dist/policy/init.js.map +0 -1
  104. package/dist/posthog/coverage.d.ts +0 -23
  105. package/dist/posthog/coverage.js +0 -30
  106. package/dist/posthog/coverage.js.map +0 -1
  107. package/dist/tanstack/query.d.ts +0 -3
  108. package/dist/tanstack/query.js +0 -8
  109. package/dist/tanstack/query.js.map +0 -1
  110. /package/dist/{policy/changesets.d.ts → changesets/index.d.ts} +0 -0
@@ -1,40 +0,0 @@
1
- import { createFileRoute } from '@tanstack/react-router'
2
- import { requireTanStackMutationOrigin } from 'ras-stack/tanstack/server'
3
- import { app } from '../../server/app'
4
- import { limitAuthenticatedRequest } from '../../server/rate-limit'
5
- import { requireCurrentUser } from '../../server/session'
6
-
7
- export const Route = createFileRoute('/api/uploads/$id')({
8
- server: {
9
- handlers: {
10
- HEAD: async ({ request, params }) => {
11
- const user = await requireCurrentUser(request)
12
- const upload = app().uploadStore.getOwned(params.id, user.id)
13
- return upload ? new Response(null, { headers: headers(upload.offset, upload.length) }) : new Response(null, { status: 404 })
14
- },
15
- PATCH: async ({ request, params }) => {
16
- requireTanStackMutationOrigin(
17
- { configured: [app().environment.appUrl], trustForwardedHeaders: app().environment.trustProxy },
18
- request,
19
- )
20
- const user = await requireCurrentUser(request)
21
- await limitAuthenticatedRequest(request, 'upload-chunk', user.id, { window: 60, max: 120 })
22
- const declaredLength = Number(request.headers.get('content-length'))
23
- if (!Number.isSafeInteger(declaredLength) || declaredLength < 1 || declaredLength > app().environment.uploadMaxBytes) {
24
- return new Response('Invalid content length', { status: 400 })
25
- }
26
- const chunk = new Uint8Array(await request.arrayBuffer())
27
- if (chunk.length !== declaredLength) return new Response('Content length mismatch', { status: 400 })
28
- const upload = await app().uploadStore.append(params.id, user.id, Number(request.headers.get('upload-offset')), chunk)
29
- return new Response(null, { status: 204, headers: headers(upload.offset, upload.length) })
30
- },
31
- },
32
- },
33
- })
34
-
35
- const headers = (offset: number, length: number) => ({
36
- 'Cache-Control': 'no-store',
37
- 'Tus-Resumable': '1.0.0',
38
- 'Upload-Length': String(length),
39
- 'Upload-Offset': String(offset),
40
- })
@@ -1,25 +0,0 @@
1
- import { createFileRoute } from '@tanstack/react-router'
2
- import { requireTanStackMutationOrigin } from 'ras-stack/tanstack/server'
3
- import { app } from '../../server/app'
4
- import { limitAuthenticatedRequest } from '../../server/rate-limit'
5
- import { requireCurrentUser } from '../../server/session'
6
-
7
- export const Route = createFileRoute('/api/uploads')({
8
- server: {
9
- handlers: {
10
- POST: async ({ request }) => {
11
- requireTanStackMutationOrigin(
12
- { configured: [app().environment.appUrl], trustForwardedHeaders: app().environment.trustProxy },
13
- request,
14
- )
15
- const user = await requireCurrentUser(request)
16
- await limitAuthenticatedRequest(request, 'upload-create', user.id, { window: 60, max: 10 })
17
- const id = app().uploadStore.create(user.id, Number(request.headers.get('upload-length')), request.headers.get('upload-metadata'))
18
- return new Response(null, {
19
- status: 201,
20
- headers: { Location: `/api/uploads/${id}`, 'Tus-Resumable': '1.0.0', 'Cache-Control': 'no-store' },
21
- })
22
- },
23
- },
24
- },
25
- })
@@ -1,189 +0,0 @@
1
- import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
2
- import { createFileRoute } from '@tanstack/react-router'
3
- import { useAuthAction } from 'ras-stack/auth/react'
4
- import { useEffect, useState } from 'react'
5
- import { authClient } from '../client/auth'
6
- import { snapshotQuery } from '../client/queries'
7
- import { queryErrorMessage } from '../client/queryClient'
8
- import { useRealtime } from '../client/useRealtime'
9
- import { addMessage } from '../server/fns'
10
-
11
- export const Route = createFileRoute('/')({
12
- loader: ({ context }) => context.queryClient.ensureQueryData(snapshotQuery()),
13
- component: Home,
14
- })
15
-
16
- function Home() {
17
- const { data } = useSuspenseQuery(snapshotQuery())
18
- const queryClient = useQueryClient()
19
- useRealtime(Boolean(data.user) && data.realtimeEnabled)
20
- return (
21
- <main>
22
- <h1>ras-stack full-stack example</h1>
23
- <p className="status">Better Auth, Drizzle migrations, durable uploads, transactional realtime, SMTP, and production lifecycle</p>
24
- {data.user ? (
25
- <Messages
26
- name={data.user.name}
27
- signOut={async () => {
28
- await authClient.signOut()
29
- await queryClient.invalidateQueries()
30
- }}
31
- />
32
- ) : (
33
- <Authentication emailConfigured={data.emailConfigured} />
34
- )}
35
- <MessageList />
36
- </main>
37
- )
38
- }
39
-
40
- function Authentication({ emailConfigured }: { emailConfigured: boolean }) {
41
- const [hydrated, setHydrated] = useState(false)
42
- const [mode, setMode] = useState<'sign-up' | 'sign-in'>('sign-up')
43
- const [notice, setNotice] = useState('')
44
- const queryClient = useQueryClient()
45
- const action = useAuthAction()
46
- useEffect(() => setHydrated(true), [])
47
- return (
48
- <form
49
- onSubmit={async (event) => {
50
- event.preventDefault()
51
- setNotice('')
52
- const form = new FormData(event.currentTarget)
53
- const name = formText(form, 'name')
54
- const email = formText(form, 'email')
55
- const password = formText(form, 'password')
56
- const result = await action.run(async () => {
57
- const response =
58
- mode === 'sign-up'
59
- ? await authClient.signUp.email({ name, email, password })
60
- : await authClient.signIn.email({ email, password })
61
- return response.error ? { error: { message: response.error.message } } : { data: response.data }
62
- })
63
- if (!result.error) {
64
- if (mode === 'sign-up' && emailConfigured) setNotice('Check your email to verify your account.')
65
- await queryClient.invalidateQueries()
66
- }
67
- }}
68
- >
69
- <h2>{mode === 'sign-up' ? 'Create account' : 'Sign in'}</h2>
70
- {mode === 'sign-up' && (
71
- <label>
72
- Name
73
- <input aria-label="Name" autoComplete="name" name="name" />
74
- </label>
75
- )}
76
- <label>
77
- Email
78
- <input aria-label="Email" autoComplete="email" name="email" type="email" />
79
- </label>
80
- <label>
81
- Password
82
- <input
83
- aria-label="Password"
84
- autoComplete={mode === 'sign-up' ? 'new-password' : 'current-password'}
85
- name="password"
86
- type="password"
87
- />
88
- </label>
89
- <button disabled={!hydrated || action.busy}>{mode === 'sign-up' ? 'Create account' : 'Sign in'}</button>
90
- <button type="button" onClick={() => setMode(mode === 'sign-up' ? 'sign-in' : 'sign-up')}>
91
- {mode === 'sign-up' ? 'Use an existing account' : 'Create a new account'}
92
- </button>
93
- {emailConfigured && (
94
- <button
95
- type="button"
96
- onClick={async (event) => {
97
- const form = event.currentTarget.form
98
- if (!form) return
99
- const email = formText(new FormData(form), 'email')
100
- await authClient.requestPasswordReset({ email, redirectTo: '/' })
101
- setNotice('If that account exists, a reset link has been sent.')
102
- }}
103
- >
104
- Reset password
105
- </button>
106
- )}
107
- {notice && <p className="status">{notice}</p>}
108
- {action.error && <p className="error">{action.error}</p>}
109
- </form>
110
- )
111
- }
112
-
113
- function formText(form: FormData, name: string) {
114
- const value = form.get(name)
115
- return typeof value === 'string' ? value : ''
116
- }
117
-
118
- function Messages({ name, signOut }: { name: string; signOut: () => Promise<void> }) {
119
- const [body, setBody] = useState('')
120
- const queryClient = useQueryClient()
121
- const mutation = useMutation({
122
- mutationFn: () => addMessage({ data: { body } }),
123
- onSuccess: async () => {
124
- setBody('')
125
- await queryClient.invalidateQueries({ queryKey: snapshotQuery().queryKey })
126
- },
127
- })
128
- return (
129
- <section>
130
- <h2>Hello, {name}</h2>
131
- <button onClick={() => void signOut()}>Sign out</button>
132
- <form
133
- onSubmit={(event) => {
134
- event.preventDefault()
135
- mutation.mutate()
136
- }}
137
- >
138
- <label>
139
- Message
140
- <input aria-label="Message" value={body} onChange={(event) => setBody(event.target.value)} />
141
- </label>
142
- <button disabled={mutation.isPending}>Add message</button>
143
- {mutation.error && <p className="error">{queryErrorMessage(mutation.error)}</p>}
144
- </form>
145
- <Upload />
146
- </section>
147
- )
148
- }
149
-
150
- function Upload() {
151
- const [status, setStatus] = useState('No upload yet.')
152
- return (
153
- <label>
154
- Upload a text file
155
- <input
156
- aria-label="Upload"
157
- type="file"
158
- accept="text/plain"
159
- onChange={async (event) => {
160
- const file = event.target.files?.[0]
161
- if (!file) return
162
- const { uploadWithTus } = await import('ras-stack/uploads')
163
- const result = await uploadWithTus(
164
- { endpoint: '/api/uploads', file, metadata: { filename: file.name, filetype: file.type } },
165
- false,
166
- )
167
- setStatus(`Uploaded to ${result.uploadUrl}`)
168
- }}
169
- />
170
- <span className="status">{status}</span>
171
- </label>
172
- )
173
- }
174
-
175
- function MessageList() {
176
- const { data } = useSuspenseQuery(snapshotQuery())
177
- return (
178
- <section>
179
- <h2>Messages</h2>
180
- <ul>
181
- {data.messages.map((message) => (
182
- <li key={message.id}>
183
- <strong>{message.author}:</strong> {message.body}
184
- </li>
185
- ))}
186
- </ul>
187
- </section>
188
- )
189
- }
@@ -1,24 +0,0 @@
1
- import { expect, it, vi } from 'vitest'
2
- import { app, closeApp } from './app'
3
-
4
- it('closes every resource once and reports aggregate shutdown failure', async () => {
5
- const failure = new Error('outbox failed')
6
- const uploadClose = vi.fn().mockResolvedValue(undefined)
7
- const publisherClose = vi.fn().mockResolvedValue(undefined)
8
- const telemetryShutdown = vi.fn().mockResolvedValue(undefined)
9
- const databaseClose = vi.fn()
10
- const resources = {
11
- outbox: { close: vi.fn().mockRejectedValue(failure) },
12
- uploadStore: { close: uploadClose },
13
- publisher: { close: publisherClose },
14
- telemetry: { shutdown: telemetryShutdown },
15
- database: { $client: { close: databaseClose } },
16
- } as unknown as ReturnType<typeof app>
17
- const closing = closeApp(resources)
18
- expect(closeApp(resources)).toBe(closing)
19
- await expect(closing).rejects.toMatchObject({ errors: [failure] })
20
- expect(uploadClose).toHaveBeenCalledOnce()
21
- expect(publisherClose).toHaveBeenCalledOnce()
22
- expect(telemetryShutdown).toHaveBeenCalledOnce()
23
- expect(databaseClose).toHaveBeenCalledOnce()
24
- })
@@ -1,172 +0,0 @@
1
- import { existsSync, mkdirSync } from 'node:fs'
2
- import path from 'node:path'
3
- import { persistedSecret, standardRateLimitOptions, standardSessionOptions } from 'ras-stack/auth'
4
- import { openDrizzleSqlite, openSqliteClient, type OpenDrizzleSqliteOptions } from 'ras-stack/database/sqlite'
5
- import { createSmtpDelivery, smtpConfigFromEnvironment } from 'ras-stack/email'
6
- import { postHogEnvironment } from 'ras-stack/posthog'
7
- import { createManagedPostHogServerTelemetry } from 'ras-stack/posthog/server'
8
- import { CentrifugoPublisher } from 'ras-stack/realtime'
9
- import { globalSingleton } from 'ras-stack/server'
10
- import { createAuth } from './auth'
11
- import { loadEnvironment } from './environment'
12
- import { OutboxWorker } from './outbox'
13
- import * as schema from './schema'
14
- import { UploadStore } from './uploads'
15
-
16
- export function app() {
17
- return globalSingleton('ras-stack.example.full-stack', createApp)
18
- }
19
-
20
- function createApp() {
21
- const environment = loadEnvironment()
22
- mkdirSync(environment.dataDirectory, { recursive: true })
23
- const databaseFile = path.join(environment.dataDirectory, 'example.sqlite')
24
- prepareLegacyMessages(databaseFile)
25
- const options: OpenDrizzleSqliteOptions<typeof schema> = {
26
- file: databaseFile,
27
- schema,
28
- migrationsFolder: migrationsDirectory(),
29
- }
30
- const database = openDrizzleSqlite(options)
31
- importLegacyMessages(database.$client)
32
- const smtp = smtpConfigFromEnvironment()
33
- const email = smtp ? createSmtpDelivery(smtp) : undefined
34
- let publishFailure: unknown
35
- const publisher = new CentrifugoPublisher({
36
- apiUrl: environment.centrifugoApiUrl,
37
- apiKey: environment.centrifugoApiKey,
38
- maxConcurrentChannels: 1,
39
- maxPendingChannels: 32,
40
- onError: (error, channel) => {
41
- publishFailure = error
42
- console.error({ event: 'example_realtime_publish_failed', channel, error })
43
- },
44
- })
45
- const telemetry = createManagedPostHogServerTelemetry({
46
- environment: postHogEnvironment({
47
- projectToken: process.env.VITE_POSTHOG_PROJECT_TOKEN,
48
- host: process.env.VITE_POSTHOG_HOST,
49
- }),
50
- serviceName: 'ras-stack-example',
51
- deploymentEnvironment: process.env.NODE_ENV,
52
- onError: (error) => console.error({ event: 'example_telemetry_failed', error }),
53
- })
54
- const auth = createAuth({
55
- database,
56
- email,
57
- environment,
58
- secret: process.env.BETTER_AUTH_SECRET ?? persistedSecret({ directory: environment.dataDirectory, filename: 'auth.secret' }),
59
- })
60
- const outbox = new OutboxWorker({
61
- database,
62
- enabled: environment.realtimeEnabled,
63
- onError: (error) => console.error({ event: 'example_outbox_drain_failed', error }),
64
- publish: async (channel, payload) => {
65
- publishFailure = undefined
66
- if (!publisher.publish(channel, payload)) throw new Error('Realtime publisher rejected the outbox item')
67
- await publisher.idle()
68
- if (publishFailure) throw publishFailure
69
- },
70
- })
71
- const uploadStore = new UploadStore({
72
- database,
73
- directory: path.join(environment.dataDirectory, 'uploads'),
74
- globalMaxFiles: environment.uploadGlobalMaxFiles,
75
- globalQuotaBytes: environment.uploadGlobalQuotaBytes,
76
- maxBytes: environment.uploadMaxBytes,
77
- onError: (error) => console.error({ event: 'example_upload_cleanup_failed', error }),
78
- quotaBytes: environment.uploadQuotaBytes,
79
- })
80
- void telemetry.start()
81
- outbox.start()
82
- uploadStore.startCleanup()
83
- const application = { auth, database, email, environment, outbox, publisher, telemetry, uploadStore }
84
- if (!process.env.VITEST) installShutdown(application)
85
- return application
86
- }
87
-
88
- function prepareLegacyMessages(file: string) {
89
- const client = openSqliteClient(file)
90
- try {
91
- if (!tableExists(client, 'messages')) return
92
- const columns = client.pragma('table_info(messages)') as Array<{ name: string }>
93
- if (columns.some((column) => column.name === 'author_id')) return
94
- if (tableExists(client, 'messages_legacy')) throw new Error('Both messages and messages_legacy exist; refusing ambiguous migration')
95
- client.exec('ALTER TABLE messages RENAME TO messages_legacy')
96
- } finally {
97
- client.close()
98
- }
99
- }
100
-
101
- function importLegacyMessages(client: ReturnType<typeof openSqliteClient>) {
102
- if (!tableExists(client, 'messages_legacy')) return
103
- client.transaction(() => {
104
- const now = Date.now()
105
- client
106
- .prepare('INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, false, ?, ?)')
107
- .run('legacy-import', 'Legacy import', 'legacy-import@invalid.example', now, now)
108
- client.exec(`INSERT INTO messages (id, author_id, author, body, created_at)
109
- SELECT id, 'legacy-import', author, body, created_at FROM messages_legacy;
110
- DROP TABLE messages_legacy;`)
111
- })()
112
- }
113
-
114
- function tableExists(client: ReturnType<typeof openSqliteClient>, name: string) {
115
- return Boolean(client.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
116
- }
117
-
118
- function migrationsDirectory() {
119
- const directories = [
120
- path.resolve(import.meta.dirname, 'drizzle'),
121
- path.resolve(import.meta.dirname, '..', 'drizzle'),
122
- path.resolve(process.cwd(), 'drizzle'),
123
- ]
124
- const directory = directories.find((candidate) => existsSync(path.join(candidate, 'meta', '_journal.json')))
125
- if (!directory) throw new Error(`Drizzle migrations are missing from ${directories.join(', ')}`)
126
- return directory
127
- }
128
-
129
- const closing = new WeakMap<object, Promise<void>>()
130
-
131
- export function closeApp(value = app()) {
132
- const existing = closing.get(value)
133
- if (existing) return existing
134
- const promise = (async () => {
135
- const failures: unknown[] = []
136
- try {
137
- await value.outbox.close()
138
- } catch (error) {
139
- failures.push(error)
140
- }
141
- for (const result of await Promise.allSettled([value.uploadStore.close(), value.publisher.close(), value.telemetry.shutdown()])) {
142
- if (result.status === 'rejected') failures.push(result.reason)
143
- }
144
- try {
145
- value.database.$client.close()
146
- } catch (error) {
147
- failures.push(error)
148
- }
149
- if (failures.length) throw new AggregateError(failures, 'Failed to close the full-stack example cleanly')
150
- })()
151
- closing.set(value, promise)
152
- return promise
153
- }
154
-
155
- export { standardRateLimitOptions, standardSessionOptions }
156
-
157
- function installShutdown(application: ReturnType<typeof createApp>) {
158
- let started = false
159
- for (const signal of ['SIGINT', 'SIGTERM'] as const) {
160
- process.once(signal, () => {
161
- if (started) return
162
- started = true
163
- void closeApp(application).then(
164
- () => process.exit(0),
165
- (error) => {
166
- console.error({ event: 'example_shutdown_failed', error })
167
- process.exit(1)
168
- },
169
- )
170
- })
171
- }
172
- }
@@ -1,150 +0,0 @@
1
- import { createServer, type Server, type Socket } from 'node:net'
2
- import { mkdtemp, rm } from 'node:fs/promises'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { clearGlobalSingleton } from 'ras-stack/server'
6
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7
- import { app, closeApp } from './app'
8
- import { currentUser } from './session'
9
-
10
- let directory: string
11
- let smtp: TestSmtp | undefined
12
-
13
- beforeEach(async () => {
14
- directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-auth-flow-'))
15
- process.env.DATA_DIR = directory
16
- })
17
-
18
- afterEach(async () => {
19
- await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
20
- await smtp?.close()
21
- smtp = undefined
22
- await rm(directory, { recursive: true, force: true })
23
- for (const name of ['APP_URL', 'SMTP_HOST', 'SMTP_PORT', 'EMAIL_FROM', 'EMAIL_REQUIRE_VERIFICATION']) delete process.env[name]
24
- })
25
-
26
- describe('production auth flows', () => {
27
- it('verifies an email over SMTP and completes a password reset', async () => {
28
- smtp = await TestSmtp.start()
29
- process.env.APP_URL = 'http://localhost:3100'
30
- process.env.SMTP_HOST = '127.0.0.1'
31
- process.env.SMTP_PORT = String(smtp.port)
32
- process.env.EMAIL_FROM = 'auth@example.test'
33
- process.env.EMAIL_REQUIRE_VERIFICATION = 'true'
34
-
35
- expect(
36
- (
37
- await authRequest('/sign-up/email', {
38
- name: 'Ada',
39
- email: 'ada@example.test',
40
- password: 'correct horse battery staple',
41
- })
42
- ).status,
43
- ).toBe(200)
44
- expect((await authRequest('/sign-in/email', { email: 'ada@example.test', password: 'correct horse battery staple' })).status).toBe(403)
45
-
46
- const verification = await app().auth.handler(new Request(messageUrl(smtp.messages[0]!)))
47
- const cookie = verification.headers.get('set-cookie')
48
- expect(cookie).toContain('ras_stack_example.session_token=')
49
- expect((await currentUser(new Request('http://localhost:3100', { headers: { cookie: cookie! } })))?.email).toBe('ada@example.test')
50
-
51
- expect((await authRequest('/request-password-reset', { email: 'ada@example.test', redirectTo: '/' })).status).toBe(200)
52
- const resetLink = new URL(messageUrl(smtp.messages[1]!))
53
- const token = resetLink.pathname.split('/').at(-1)
54
- expect((await authRequest('/reset-password', { newPassword: 'new correct horse battery staple', token })).status).toBe(200)
55
- expect(await currentUser(new Request('http://localhost:3100', { headers: { cookie: cookie! } }))).toBeUndefined()
56
- expect((await authRequest('/reset-password', { newPassword: 'replayed password', token })).status).toBe(400)
57
- expect((await authRequest('/sign-in/email', { email: 'ada@example.test', password: 'correct horse battery staple' })).status).toBe(401)
58
- expect((await authRequest('/sign-in/email', { email: 'ada@example.test', password: 'new correct horse battery staple' })).status).toBe(
59
- 200,
60
- )
61
- })
62
-
63
- it('marks session cookies Secure for an HTTPS origin', async () => {
64
- process.env.APP_URL = 'https://example.test'
65
- const response = await authRequest('/sign-up/email', {
66
- name: 'Grace',
67
- email: 'grace@example.test',
68
- password: 'correct horse battery staple',
69
- })
70
- expect(response.headers.get('set-cookie')).toContain('; Secure')
71
- })
72
- })
73
-
74
- function authRequest(endpoint: string, body: object) {
75
- const origin = process.env.APP_URL!
76
- return app().auth.handler(
77
- new Request(`${origin}/api/auth${endpoint}`, {
78
- method: 'POST',
79
- headers: { 'Content-Type': 'application/json', Origin: origin },
80
- body: JSON.stringify(body),
81
- }),
82
- )
83
- }
84
-
85
- function messageUrl(message: string) {
86
- const decoded = message
87
- .replace(/=\r?\n/g, '')
88
- .replace(/=([\dA-F]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
89
- const match = decoded.match(/https?:\/\/[^\s]+/)
90
- if (!match) throw new Error(`SMTP message contains no URL: ${decoded}`)
91
- return match[0]
92
- }
93
-
94
- class TestSmtp {
95
- private constructor(
96
- private readonly server: Server,
97
- readonly port: number,
98
- readonly messages: string[],
99
- ) {}
100
-
101
- static async start() {
102
- const messages: string[] = []
103
- const server = createServer((socket) => acceptSmtp(socket, messages))
104
- await new Promise<void>((resolve, reject) => server.listen(0, '127.0.0.1', resolve).once('error', reject))
105
- const address = server.address()
106
- if (!address || typeof address === 'string') throw new Error('SMTP sink did not bind a TCP port')
107
- return new TestSmtp(server, address.port, messages)
108
- }
109
-
110
- close() {
111
- return new Promise<void>((resolve, reject) => this.server.close((error) => (error ? reject(error) : resolve())))
112
- }
113
- }
114
-
115
- function acceptSmtp(socket: Socket, messages: string[]) {
116
- socket.setEncoding('utf8')
117
- socket.write('220 localhost ESMTP\r\n')
118
- let buffer = ''
119
- let data = false
120
- let message = ''
121
- socket.on('data', (chunk: string) => {
122
- buffer += chunk
123
- while (buffer.includes('\n')) {
124
- const index = buffer.indexOf('\n')
125
- const line = buffer.slice(0, index).replace(/\r$/, '')
126
- buffer = buffer.slice(index + 1)
127
- if (data) {
128
- if (line === '.') {
129
- messages.push(message)
130
- message = ''
131
- data = false
132
- socket.write('250 queued\r\n')
133
- } else {
134
- message += `${line.startsWith('..') ? line.slice(1) : line}\r\n`
135
- }
136
- } else if (/^(EHLO|HELO)/i.test(line)) {
137
- socket.write('250-localhost\r\n250 PIPELINING\r\n')
138
- } else if (/^(MAIL FROM|RCPT TO|RSET|NOOP)/i.test(line)) {
139
- socket.write('250 ok\r\n')
140
- } else if (/^DATA/i.test(line)) {
141
- data = true
142
- socket.write('354 end with .\r\n')
143
- } else if (/^QUIT/i.test(line)) {
144
- socket.end('221 bye\r\n')
145
- } else {
146
- socket.write('502 unsupported\r\n')
147
- }
148
- }
149
- })
150
- }
@@ -1,56 +0,0 @@
1
- import { drizzleAdapter } from '@better-auth/drizzle-adapter'
2
- import { betterAuth } from 'better-auth'
3
- import { tanstackStartCookies } from 'better-auth/tanstack-start'
4
- import { standardAccountOptions, standardEmailAndPasswordOptions, standardRateLimitOptions, standardSessionOptions } from 'ras-stack/auth'
5
- import { createAuthEmailHandler, type EmailDelivery } from 'ras-stack/email'
6
- import type { AppEnvironment } from './environment'
7
- import * as schema from './schema'
8
-
9
- type Database = Parameters<typeof drizzleAdapter>[0]
10
-
11
- export function createAuth(options: { database: Database; email?: EmailDelivery; environment: AppEnvironment; secret: string }) {
12
- const email = options.email
13
- const sendVerificationEmail = email
14
- ? createAuthEmailHandler(email, ({ user, url }) => ({
15
- to: user.email,
16
- subject: 'Verify your ras-stack example account',
17
- text: `Verify your email: ${url}`,
18
- }))
19
- : undefined
20
- const sendResetPassword = email
21
- ? createAuthEmailHandler(email, ({ user, url }) => ({
22
- to: user.email,
23
- subject: 'Reset your ras-stack example password',
24
- text: `Reset your password: ${url}`,
25
- }))
26
- : undefined
27
- return betterAuth({
28
- appName: 'ras-stack full-stack example',
29
- baseURL: options.environment.appUrl,
30
- secret: options.secret,
31
- trustedOrigins: [options.environment.appUrl],
32
- database: drizzleAdapter(options.database, { provider: 'sqlite', schema }),
33
- account: standardAccountOptions(),
34
- emailAndPassword: standardEmailAndPasswordOptions({
35
- requireEmailVerification: Boolean(email) && options.environment.requireEmailVerification,
36
- ...(sendResetPassword ? { sendResetPassword } : {}),
37
- }),
38
- ...(sendVerificationEmail
39
- ? {
40
- emailVerification: {
41
- sendOnSignUp: true,
42
- autoSignInAfterVerification: true,
43
- sendVerificationEmail,
44
- },
45
- }
46
- : {}),
47
- session: standardSessionOptions(),
48
- rateLimit: standardRateLimitOptions(),
49
- advanced: {
50
- cookiePrefix: 'ras_stack_example',
51
- useSecureCookies: options.environment.appUrl.startsWith('https://'),
52
- disableOriginCheck: false,
53
- },
54
- plugins: [tanstackStartCookies()],
55
- })
56
- }