ras-stack 2.0.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -46
- package/dist/changesets/cli.d.ts +1 -0
- package/dist/changesets/cli.js +14 -0
- package/dist/changesets/cli.js.map +1 -0
- package/dist/{policy/changesets.js → changesets/index.js} +1 -1
- package/dist/changesets/index.js.map +1 -0
- package/dist/cli.d.ts +2 -6
- package/dist/cli.js +3 -7
- package/dist/cli.js.map +1 -1
- package/dist/email/index.d.ts +1 -2
- package/dist/email/index.js +2 -2
- package/dist/email/index.js.map +1 -1
- package/dist/posthog/index.d.ts +0 -2
- package/dist/posthog/index.js +0 -1
- package/dist/posthog/index.js.map +1 -1
- package/package.json +14 -41
- package/config/oxlint/application.json +0 -16
- package/config/oxlint/domain.json +0 -29
- package/config/oxlint/layers.json +0 -116
- package/config/oxlint/tanstack.json +0 -4
- package/config/oxlint.json +0 -19
- package/config/typescript/base.json +0 -10
- package/config/typescript/browser.json +0 -8
- package/config/typescript/bundler.json +0 -9
- package/config/typescript/library.json +0 -11
- package/config/typescript/node-bundler.json +0 -9
- package/config/typescript/tanstack.json +0 -6
- package/dist/create/index.d.ts +0 -1
- package/dist/create/index.js +0 -83
- package/dist/create/index.js.map +0 -1
- package/dist/create/template/.env.example +0 -16
- package/dist/create/template/.oxfmtrc.json +0 -7
- package/dist/create/template/Dockerfile +0 -37
- package/dist/create/template/Dockerfile.standalone +0 -29
- package/dist/create/template/centrifugo.json +0 -14
- package/dist/create/template/dockerignore.template +0 -8
- package/dist/create/template/drizzle/0000_production_reference.sql +0 -114
- package/dist/create/template/drizzle/meta/_journal.json +0 -13
- package/dist/create/template/e2e/full-stack.spec.ts +0 -54
- package/dist/create/template/gitignore.template +0 -9
- package/dist/create/template/oxlint.json +0 -4
- package/dist/create/template/package.json +0 -60
- package/dist/create/template/playwright.config.ts +0 -7
- package/dist/create/template/pnpm-workspace.template.yaml +0 -13
- package/dist/create/template/ras-stack.assets.json +0 -4
- package/dist/create/template/scripts/containerRuntime.ts +0 -29
- package/dist/create/template/scripts/database.test.ts +0 -121
- package/dist/create/template/scripts/database.ts +0 -105
- package/dist/create/template/src/client/auth.ts +0 -3
- package/dist/create/template/src/client/queries.ts +0 -4
- package/dist/create/template/src/client/queryClient.ts +0 -1
- package/dist/create/template/src/client/useRealtime.ts +0 -20
- package/dist/create/template/src/posthog.ts +0 -21
- package/dist/create/template/src/routeTree.gen.ts +0 -230
- package/dist/create/template/src/router.tsx +0 -17
- package/dist/create/template/src/routes/__root.tsx +0 -40
- package/dist/create/template/src/routes/api/auth.$.ts +0 -8
- package/dist/create/template/src/routes/api/centrifugo.connect.ts +0 -29
- package/dist/create/template/src/routes/api/health.ts +0 -6
- package/dist/create/template/src/routes/api/live.ts +0 -5
- package/dist/create/template/src/routes/api/ready.ts +0 -30
- package/dist/create/template/src/routes/api/uploads.$id.ts +0 -40
- package/dist/create/template/src/routes/api/uploads.ts +0 -25
- package/dist/create/template/src/routes/index.tsx +0 -189
- package/dist/create/template/src/server/app.test.ts +0 -24
- package/dist/create/template/src/server/app.ts +0 -172
- package/dist/create/template/src/server/auth-flow.test.ts +0 -150
- package/dist/create/template/src/server/auth.ts +0 -56
- package/dist/create/template/src/server/environment.test.ts +0 -43
- package/dist/create/template/src/server/environment.ts +0 -67
- package/dist/create/template/src/server/fns.ts +0 -38
- package/dist/create/template/src/server/messages.test.ts +0 -38
- package/dist/create/template/src/server/messages.ts +0 -28
- package/dist/create/template/src/server/migration.test.ts +0 -34
- package/dist/create/template/src/server/outbox.test.ts +0 -130
- package/dist/create/template/src/server/outbox.ts +0 -110
- package/dist/create/template/src/server/posthog.test.ts +0 -34
- package/dist/create/template/src/server/rate-limit.test.ts +0 -32
- package/dist/create/template/src/server/rate-limit.ts +0 -29
- package/dist/create/template/src/server/rpc.ts +0 -16
- package/dist/create/template/src/server/schema.ts +0 -134
- package/dist/create/template/src/server/session.test.ts +0 -56
- package/dist/create/template/src/server/session.ts +0 -13
- package/dist/create/template/src/server/uploads.test.ts +0 -142
- package/dist/create/template/src/server/uploads.ts +0 -199
- package/dist/create/template/src/start.ts +0 -12
- package/dist/create/template/src/styles.css +0 -42
- package/dist/create/template/tsconfig.json +0 -8
- package/dist/create/template/vite.config.ts +0 -31
- package/dist/create/template/vitest.config.ts +0 -3
- package/dist/policy/changesets.js.map +0 -1
- package/dist/policy/cli.d.ts +0 -1
- package/dist/policy/cli.js +0 -23
- package/dist/policy/cli.js.map +0 -1
- package/dist/policy/index.d.ts +0 -14
- package/dist/policy/index.js +0 -132
- package/dist/policy/index.js.map +0 -1
- package/dist/policy/init-cli.d.ts +0 -12
- package/dist/policy/init-cli.js +0 -75
- package/dist/policy/init-cli.js.map +0 -1
- package/dist/policy/init.d.ts +0 -24
- package/dist/policy/init.js +0 -116
- package/dist/policy/init.js.map +0 -1
- package/dist/posthog/coverage.d.ts +0 -23
- package/dist/posthog/coverage.js +0 -30
- package/dist/posthog/coverage.js.map +0 -1
- package/dist/tanstack/query.d.ts +0 -3
- package/dist/tanstack/query.js +0 -8
- package/dist/tanstack/query.js.map +0 -1
- /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
|
-
}
|