ras-stack 0.39.5 → 0.40.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.
- package/README.md +17 -8
- package/dist/auth/settings.d.ts +1 -1
- package/dist/auth/settings.js +1 -1
- package/dist/auth/settings.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/create/index.d.ts +1 -0
- package/dist/create/index.js +69 -0
- package/dist/create/index.js.map +1 -0
- package/dist/runtime/dev.js +1 -1
- package/dist/runtime/dev.js.map +1 -1
- package/dist/runtime/index.js +77 -13
- package/dist/runtime/index.js.map +1 -1
- package/examples/full-stack/.env.example +16 -0
- package/examples/full-stack/.oxfmtrc.json +7 -0
- package/examples/full-stack/Dockerfile +37 -0
- package/examples/full-stack/Dockerfile.standalone +28 -0
- package/examples/full-stack/centrifugo.json +14 -0
- package/examples/full-stack/dockerignore.template +8 -0
- package/examples/full-stack/drizzle/0000_production_reference.sql +114 -0
- package/examples/full-stack/drizzle/meta/_journal.json +13 -0
- package/examples/full-stack/e2e/full-stack.spec.ts +45 -0
- package/examples/full-stack/gitignore.template +9 -0
- package/examples/full-stack/oxlint.json +4 -0
- package/examples/full-stack/package.json +58 -0
- package/examples/full-stack/playwright.config.ts +7 -0
- package/examples/full-stack/pnpm-workspace.template.yaml +12 -0
- package/examples/full-stack/ras-stack.assets.json +4 -0
- package/examples/full-stack/scripts/containerRuntime.ts +29 -0
- package/examples/full-stack/scripts/database.test.ts +121 -0
- package/examples/full-stack/scripts/database.ts +105 -0
- package/examples/full-stack/src/client/auth.ts +3 -0
- package/examples/full-stack/src/client/queries.ts +4 -0
- package/examples/full-stack/src/client/queryClient.ts +1 -0
- package/examples/full-stack/src/client/useRealtime.ts +20 -0
- package/examples/full-stack/src/posthog.ts +17 -0
- package/examples/full-stack/src/routeTree.gen.ts +230 -0
- package/examples/full-stack/src/router.tsx +17 -0
- package/examples/full-stack/src/routes/__root.tsx +38 -0
- package/examples/full-stack/src/routes/api/auth.$.ts +8 -0
- package/examples/full-stack/src/routes/api/centrifugo.connect.ts +29 -0
- package/examples/full-stack/src/routes/api/health.ts +6 -0
- package/examples/full-stack/src/routes/api/live.ts +5 -0
- package/examples/full-stack/src/routes/api/ready.ts +30 -0
- package/examples/full-stack/src/routes/api/uploads.$id.ts +40 -0
- package/examples/full-stack/src/routes/api/uploads.ts +25 -0
- package/examples/full-stack/src/routes/index.tsx +187 -0
- package/examples/full-stack/src/server/app.test.ts +24 -0
- package/examples/full-stack/src/server/app.ts +172 -0
- package/examples/full-stack/src/server/auth-flow.test.ts +148 -0
- package/examples/full-stack/src/server/auth.ts +60 -0
- package/examples/full-stack/src/server/environment.test.ts +43 -0
- package/examples/full-stack/src/server/environment.ts +67 -0
- package/examples/full-stack/src/server/fns.ts +38 -0
- package/examples/full-stack/src/server/messages.test.ts +38 -0
- package/examples/full-stack/src/server/messages.ts +28 -0
- package/examples/full-stack/src/server/migration.test.ts +34 -0
- package/examples/full-stack/src/server/outbox.test.ts +130 -0
- package/examples/full-stack/src/server/outbox.ts +110 -0
- package/examples/full-stack/src/server/posthog.test.ts +30 -0
- package/examples/full-stack/src/server/rate-limit.test.ts +32 -0
- package/examples/full-stack/src/server/rate-limit.ts +29 -0
- package/examples/full-stack/src/server/rpc.ts +16 -0
- package/examples/full-stack/src/server/schema.ts +134 -0
- package/examples/full-stack/src/server/session.test.ts +56 -0
- package/examples/full-stack/src/server/session.ts +13 -0
- package/examples/full-stack/src/server/uploads.test.ts +142 -0
- package/examples/full-stack/src/server/uploads.ts +199 -0
- package/examples/full-stack/src/start.ts +12 -0
- package/examples/full-stack/src/styles.css +42 -0
- package/examples/full-stack/tsconfig.json +8 -0
- package/examples/full-stack/vite.config.ts +31 -0
- package/examples/full-stack/vitest.config.ts +3 -0
- package/package.json +10 -8
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 authRequest('/sign-in/email', { email: 'ada@example.test', password: 'correct horse battery staple' })).status).toBe(401)
|
|
56
|
+
expect((await authRequest('/sign-in/email', { email: 'ada@example.test', password: 'new correct horse battery staple' })).status).toBe(
|
|
57
|
+
200,
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('marks session cookies Secure for an HTTPS origin', async () => {
|
|
62
|
+
process.env.APP_URL = 'https://example.test'
|
|
63
|
+
const response = await authRequest('/sign-up/email', {
|
|
64
|
+
name: 'Grace',
|
|
65
|
+
email: 'grace@example.test',
|
|
66
|
+
password: 'correct horse battery staple',
|
|
67
|
+
})
|
|
68
|
+
expect(response.headers.get('set-cookie')).toContain('; Secure')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
function authRequest(endpoint: string, body: object) {
|
|
73
|
+
const origin = process.env.APP_URL!
|
|
74
|
+
return app().auth.handler(
|
|
75
|
+
new Request(`${origin}/api/auth${endpoint}`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json', Origin: origin },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function messageUrl(message: string) {
|
|
84
|
+
const decoded = message
|
|
85
|
+
.replace(/=\r?\n/g, '')
|
|
86
|
+
.replace(/=([\dA-F]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
87
|
+
const match = decoded.match(/https?:\/\/[^\s]+/)
|
|
88
|
+
if (!match) throw new Error(`SMTP message contains no URL: ${decoded}`)
|
|
89
|
+
return match[0]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class TestSmtp {
|
|
93
|
+
private constructor(
|
|
94
|
+
private readonly server: Server,
|
|
95
|
+
readonly port: number,
|
|
96
|
+
readonly messages: string[],
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
static async start() {
|
|
100
|
+
const messages: string[] = []
|
|
101
|
+
const server = createServer((socket) => acceptSmtp(socket, messages))
|
|
102
|
+
await new Promise<void>((resolve, reject) => server.listen(0, '127.0.0.1', resolve).once('error', reject))
|
|
103
|
+
const address = server.address()
|
|
104
|
+
if (!address || typeof address === 'string') throw new Error('SMTP sink did not bind a TCP port')
|
|
105
|
+
return new TestSmtp(server, address.port, messages)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
close() {
|
|
109
|
+
return new Promise<void>((resolve, reject) => this.server.close((error) => (error ? reject(error) : resolve())))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function acceptSmtp(socket: Socket, messages: string[]) {
|
|
114
|
+
socket.setEncoding('utf8')
|
|
115
|
+
socket.write('220 localhost ESMTP\r\n')
|
|
116
|
+
let buffer = ''
|
|
117
|
+
let data = false
|
|
118
|
+
let message = ''
|
|
119
|
+
socket.on('data', (chunk: string) => {
|
|
120
|
+
buffer += chunk
|
|
121
|
+
while (buffer.includes('\n')) {
|
|
122
|
+
const index = buffer.indexOf('\n')
|
|
123
|
+
const line = buffer.slice(0, index).replace(/\r$/, '')
|
|
124
|
+
buffer = buffer.slice(index + 1)
|
|
125
|
+
if (data) {
|
|
126
|
+
if (line === '.') {
|
|
127
|
+
messages.push(message)
|
|
128
|
+
message = ''
|
|
129
|
+
data = false
|
|
130
|
+
socket.write('250 queued\r\n')
|
|
131
|
+
} else {
|
|
132
|
+
message += `${line.startsWith('..') ? line.slice(1) : line}\r\n`
|
|
133
|
+
}
|
|
134
|
+
} else if (/^(EHLO|HELO)/i.test(line)) {
|
|
135
|
+
socket.write('250-localhost\r\n250 PIPELINING\r\n')
|
|
136
|
+
} else if (/^(MAIL FROM|RCPT TO|RSET|NOOP)/i.test(line)) {
|
|
137
|
+
socket.write('250 ok\r\n')
|
|
138
|
+
} else if (/^DATA/i.test(line)) {
|
|
139
|
+
data = true
|
|
140
|
+
socket.write('354 end with .\r\n')
|
|
141
|
+
} else if (/^QUIT/i.test(line)) {
|
|
142
|
+
socket.end('221 bye\r\n')
|
|
143
|
+
} else {
|
|
144
|
+
socket.write('502 unsupported\r\n')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
|
|
2
|
+
import { betterAuth } from 'better-auth'
|
|
3
|
+
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
|
4
|
+
import { standardRateLimitOptions, standardSessionOptions } from 'ras-stack/auth'
|
|
5
|
+
import 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 mail = email
|
|
14
|
+
? {
|
|
15
|
+
emailVerification: {
|
|
16
|
+
sendOnSignUp: true,
|
|
17
|
+
autoSignInAfterVerification: true,
|
|
18
|
+
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
|
|
19
|
+
await email.send({
|
|
20
|
+
to: user.email,
|
|
21
|
+
subject: 'Verify your ras-stack example account',
|
|
22
|
+
text: `Verify your email: ${url}`,
|
|
23
|
+
})
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
: {}
|
|
28
|
+
return betterAuth({
|
|
29
|
+
appName: 'ras-stack full-stack example',
|
|
30
|
+
baseURL: options.environment.appUrl,
|
|
31
|
+
secret: options.secret,
|
|
32
|
+
trustedOrigins: [options.environment.appUrl],
|
|
33
|
+
database: drizzleAdapter(options.database, { provider: 'sqlite', schema }),
|
|
34
|
+
emailAndPassword: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
requireEmailVerification: Boolean(email) && options.environment.requireEmailVerification,
|
|
37
|
+
revokeSessionsOnPasswordReset: true,
|
|
38
|
+
...(email
|
|
39
|
+
? {
|
|
40
|
+
sendResetPassword: async ({ user, url }: { user: { email: string }; url: string }) => {
|
|
41
|
+
await email.send({
|
|
42
|
+
to: user.email,
|
|
43
|
+
subject: 'Reset your ras-stack example password',
|
|
44
|
+
text: `Reset your password: ${url}`,
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
49
|
+
},
|
|
50
|
+
...mail,
|
|
51
|
+
session: standardSessionOptions(),
|
|
52
|
+
rateLimit: standardRateLimitOptions(),
|
|
53
|
+
advanced: {
|
|
54
|
+
cookiePrefix: 'ras_stack_example',
|
|
55
|
+
useSecureCookies: options.environment.appUrl.startsWith('https://'),
|
|
56
|
+
disableOriginCheck: false,
|
|
57
|
+
},
|
|
58
|
+
plugins: [tanstackStartCookies()],
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { loadEnvironment } from './environment'
|
|
3
|
+
|
|
4
|
+
describe('startup environment', () => {
|
|
5
|
+
it('requires production state and proxy secrets', () => {
|
|
6
|
+
expect(() => loadEnvironment({ NODE_ENV: 'production', APP_URL: 'https://example.test', DATA_DIR: 'relative' })).toThrow(
|
|
7
|
+
'DATA_DIR must be absolute in production',
|
|
8
|
+
)
|
|
9
|
+
expect(() =>
|
|
10
|
+
loadEnvironment({
|
|
11
|
+
NODE_ENV: 'production',
|
|
12
|
+
APP_URL: 'https://example.test',
|
|
13
|
+
DATA_DIR: '/data',
|
|
14
|
+
CENTRIFUGO_API_URL: 'https://realtime.example.test/api',
|
|
15
|
+
CENTRIFUGO_API_KEY: 'key',
|
|
16
|
+
}),
|
|
17
|
+
).toThrow('CENTRIFUGO_PROXY_SECRET is required when realtime is enabled in production')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('rejects ambiguous origins and partial realtime credentials', () => {
|
|
21
|
+
expect(() => loadEnvironment({ APP_URL: 'https://example.test/path' })).toThrow('APP_URL must be an HTTP origin')
|
|
22
|
+
expect(() => loadEnvironment({ CENTRIFUGO_API_URL: 'http://localhost/api' })).toThrow(
|
|
23
|
+
'CENTRIFUGO_API_URL and CENTRIFUGO_API_KEY must be configured together',
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
['UPLOAD_GLOBAL_QUOTA_BYTES', '0'],
|
|
29
|
+
['UPLOAD_GLOBAL_MAX_FILES', '0'],
|
|
30
|
+
])('rejects an invalid %s', (name, value) => {
|
|
31
|
+
expect(() => loadEnvironment({ [name]: value })).toThrow(`${name} must be a positive integer`)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('requires the global byte cap to accept one maximum-size upload', () => {
|
|
35
|
+
expect(() => loadEnvironment({ UPLOAD_MAX_BYTES: '10', UPLOAD_GLOBAL_QUOTA_BYTES: '9' })).toThrow(
|
|
36
|
+
'UPLOAD_GLOBAL_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES',
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('supports production startup with realtime disabled', () => {
|
|
41
|
+
expect(loadEnvironment({ NODE_ENV: 'production', APP_URL: 'https://example.test', DATA_DIR: '/data' }).realtimeEnabled).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
export type AppEnvironment = ReturnType<typeof loadEnvironment>
|
|
4
|
+
|
|
5
|
+
export function loadEnvironment(environment: NodeJS.ProcessEnv = process.env) {
|
|
6
|
+
const production = environment.NODE_ENV === 'production'
|
|
7
|
+
const appUrl = validUrl(environment.APP_URL ?? (production ? '' : 'http://localhost:3100'), 'APP_URL')
|
|
8
|
+
const dataDirectory = path.resolve(environment.DATA_DIR ?? '.data/example-full-stack')
|
|
9
|
+
if (production && !path.isAbsolute(environment.DATA_DIR ?? '')) throw new Error('DATA_DIR must be absolute in production')
|
|
10
|
+
const trustProxy = booleanValue(environment.TRUST_PROXY, 'TRUST_PROXY', false)
|
|
11
|
+
const requireEmailVerification = booleanValue(environment.EMAIL_REQUIRE_VERIFICATION, 'EMAIL_REQUIRE_VERIFICATION', true)
|
|
12
|
+
const centrifugoApiUrl = environment.CENTRIFUGO_API_URL?.trim() ?? ''
|
|
13
|
+
const centrifugoApiKey = environment.CENTRIFUGO_API_KEY?.trim() ?? ''
|
|
14
|
+
if (Boolean(centrifugoApiUrl) !== Boolean(centrifugoApiKey)) {
|
|
15
|
+
throw new Error('CENTRIFUGO_API_URL and CENTRIFUGO_API_KEY must be configured together')
|
|
16
|
+
}
|
|
17
|
+
const realtimeEnabled = Boolean(centrifugoApiUrl)
|
|
18
|
+
if (production && realtimeEnabled && !environment.CENTRIFUGO_PROXY_SECRET?.trim()) {
|
|
19
|
+
throw new Error('CENTRIFUGO_PROXY_SECRET is required when realtime is enabled in production')
|
|
20
|
+
}
|
|
21
|
+
const uploadMaxBytes = positiveInteger(environment.UPLOAD_MAX_BYTES ?? '1000000', 'UPLOAD_MAX_BYTES')
|
|
22
|
+
const uploadQuotaBytes = positiveInteger(environment.UPLOAD_QUOTA_BYTES ?? '5000000', 'UPLOAD_QUOTA_BYTES')
|
|
23
|
+
const uploadGlobalQuotaBytes = positiveInteger(environment.UPLOAD_GLOBAL_QUOTA_BYTES ?? '100000000', 'UPLOAD_GLOBAL_QUOTA_BYTES')
|
|
24
|
+
const uploadGlobalMaxFiles = positiveInteger(environment.UPLOAD_GLOBAL_MAX_FILES ?? '1024', 'UPLOAD_GLOBAL_MAX_FILES')
|
|
25
|
+
if (uploadQuotaBytes < uploadMaxBytes) throw new Error('UPLOAD_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES')
|
|
26
|
+
if (uploadGlobalQuotaBytes < uploadMaxBytes) throw new Error('UPLOAD_GLOBAL_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES')
|
|
27
|
+
return {
|
|
28
|
+
appUrl,
|
|
29
|
+
centrifugoApiKey,
|
|
30
|
+
centrifugoApiUrl,
|
|
31
|
+
dataDirectory,
|
|
32
|
+
production,
|
|
33
|
+
realtimeEnabled,
|
|
34
|
+
requireEmailVerification,
|
|
35
|
+
trustProxy,
|
|
36
|
+
uploadGlobalMaxFiles,
|
|
37
|
+
uploadGlobalQuotaBytes,
|
|
38
|
+
uploadMaxBytes,
|
|
39
|
+
uploadQuotaBytes,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validUrl(value: string, name: string) {
|
|
44
|
+
let url: URL
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(value)
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`${name} must be an absolute HTTP URL`)
|
|
49
|
+
}
|
|
50
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
51
|
+
throw new Error(`${name} must be an HTTP origin without credentials, path, query, or fragment`)
|
|
52
|
+
}
|
|
53
|
+
return url.origin
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function booleanValue(value: string | undefined, name: string, fallback: boolean) {
|
|
57
|
+
if (value === undefined) return fallback
|
|
58
|
+
if (value === 'true') return true
|
|
59
|
+
if (value === 'false') return false
|
|
60
|
+
throw new Error(`${name} must be true or false`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function positiveInteger(value: string, name: string) {
|
|
64
|
+
const parsed = Number(value)
|
|
65
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`)
|
|
66
|
+
return parsed
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
3
|
+
import { desc } from 'drizzle-orm'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { app } from './app'
|
|
6
|
+
import { writeMessage } from './messages'
|
|
7
|
+
import { messages } from './schema'
|
|
8
|
+
import { currentUser } from './session'
|
|
9
|
+
import { mutationRpc, rpc } from './rpc'
|
|
10
|
+
import { limitAuthenticatedRequest } from './rate-limit'
|
|
11
|
+
|
|
12
|
+
export const snapshot = createServerFn({ method: 'GET' }).handler(() =>
|
|
13
|
+
rpc(async () => ({
|
|
14
|
+
user: await currentUser(),
|
|
15
|
+
messages: app().database.select().from(messages).orderBy(desc(messages.id)).limit(50).all(),
|
|
16
|
+
emailConfigured: Boolean(app().email),
|
|
17
|
+
realtimeEnabled: app().environment.realtimeEnabled,
|
|
18
|
+
})),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
export const addMessage = createServerFn({ method: 'POST' })
|
|
22
|
+
.validator(z.object({ body: z.string().trim().min(1).max(280) }))
|
|
23
|
+
.handler(({ data }) =>
|
|
24
|
+
mutationRpc(async () => {
|
|
25
|
+
const request = getRequest()
|
|
26
|
+
const user = await currentUser(request)
|
|
27
|
+
if (!user) throw new Response('Sign in first', { status: 401 })
|
|
28
|
+
await limitAuthenticatedRequest(request, 'messages', user.id, { window: 60, max: 30 })
|
|
29
|
+
const now = new Date()
|
|
30
|
+
const application = app()
|
|
31
|
+
return writeMessage(application.database, application.environment.realtimeEnabled, {
|
|
32
|
+
authorId: user.id,
|
|
33
|
+
author: user.name,
|
|
34
|
+
body: data.body,
|
|
35
|
+
createdAt: now,
|
|
36
|
+
})
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { clearGlobalSingleton } from 'ras-stack/server'
|
|
5
|
+
import { afterEach, beforeEach, expect, it } from 'vitest'
|
|
6
|
+
import { app, closeApp } from './app'
|
|
7
|
+
import { writeMessage } from './messages'
|
|
8
|
+
import { outboxCapacity } from './outbox'
|
|
9
|
+
import { messages, outbox, user } from './schema'
|
|
10
|
+
|
|
11
|
+
let directory: string
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-messages-'))
|
|
15
|
+
process.env.DATA_DIR = directory
|
|
16
|
+
process.env.APP_URL = 'http://localhost:3100'
|
|
17
|
+
const now = new Date()
|
|
18
|
+
app().database.insert(user).values({ id: 'alice', name: 'Alice', email: 'alice@example.test', createdAt: now, updatedAt: now }).run()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
|
|
23
|
+
await rm(directory, { recursive: true, force: true })
|
|
24
|
+
delete process.env.DATA_DIR
|
|
25
|
+
delete process.env.APP_URL
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('writes without adding to a full outbox when realtime is disabled', () => {
|
|
29
|
+
const now = new Date()
|
|
30
|
+
app().database.transaction((transaction) => {
|
|
31
|
+
for (let index = 0; index < outboxCapacity; index += 1) {
|
|
32
|
+
transaction.insert(outbox).values({ channel: 'messages:all', payload: '{}', availableAt: now, createdAt: now }).run()
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
writeMessage(app().database, false, { authorId: 'alice', author: 'Alice', body: 'stored without realtime', createdAt: now })
|
|
36
|
+
expect(app().database.select().from(messages).all()).toHaveLength(1)
|
|
37
|
+
expect(app().database.select().from(outbox).all()).toHaveLength(outboxCapacity)
|
|
38
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { app } from './app'
|
|
2
|
+
import { assertOutboxCapacity } from './outbox'
|
|
3
|
+
import { messages, outbox } from './schema'
|
|
4
|
+
|
|
5
|
+
type Database = ReturnType<typeof app>['database']
|
|
6
|
+
|
|
7
|
+
export function writeMessage(
|
|
8
|
+
database: Database,
|
|
9
|
+
realtimeEnabled: boolean,
|
|
10
|
+
values: { authorId: string; author: string; body: string; createdAt: Date },
|
|
11
|
+
) {
|
|
12
|
+
return database.transaction((transaction) => {
|
|
13
|
+
if (realtimeEnabled) assertOutboxCapacity(transaction)
|
|
14
|
+
const [message] = transaction.insert(messages).values(values).returning().all()
|
|
15
|
+
if (realtimeEnabled) {
|
|
16
|
+
transaction
|
|
17
|
+
.insert(outbox)
|
|
18
|
+
.values({
|
|
19
|
+
channel: 'messages:all',
|
|
20
|
+
payload: JSON.stringify({ type: 'message-added', id: message!.id }),
|
|
21
|
+
availableAt: values.createdAt,
|
|
22
|
+
createdAt: values.createdAt,
|
|
23
|
+
})
|
|
24
|
+
.run()
|
|
25
|
+
}
|
|
26
|
+
return message!
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Database from 'better-sqlite3'
|
|
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, expect, it } from 'vitest'
|
|
7
|
+
import { app, closeApp } from './app'
|
|
8
|
+
import { messages } from './schema'
|
|
9
|
+
|
|
10
|
+
let directory: string | undefined
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
|
|
14
|
+
if (directory) await rm(directory, { recursive: true, force: true })
|
|
15
|
+
delete process.env.DATA_DIR
|
|
16
|
+
delete process.env.APP_URL
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('preserves rows from the pre-migration messages table', async () => {
|
|
20
|
+
directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-legacy-'))
|
|
21
|
+
process.env.DATA_DIR = directory
|
|
22
|
+
process.env.APP_URL = 'http://localhost:3100'
|
|
23
|
+
const legacy = new Database(path.join(directory, 'example.sqlite'))
|
|
24
|
+
legacy.exec(`CREATE TABLE messages (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
author TEXT NOT NULL,
|
|
27
|
+
body TEXT NOT NULL,
|
|
28
|
+
created_at INTEGER NOT NULL
|
|
29
|
+
); INSERT INTO messages (author, body, created_at) VALUES ('Ada', 'legacy row', 1234);`)
|
|
30
|
+
legacy.close()
|
|
31
|
+
expect(app().database.select().from(messages).all()).toEqual([
|
|
32
|
+
{ id: 1, authorId: 'legacy-import', author: 'Ada', body: 'legacy row', createdAt: new Date(1234) },
|
|
33
|
+
])
|
|
34
|
+
})
|