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.
Files changed (75) hide show
  1. package/README.md +17 -8
  2. package/dist/auth/settings.d.ts +1 -1
  3. package/dist/auth/settings.js +1 -1
  4. package/dist/auth/settings.js.map +1 -1
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +3 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/create/index.d.ts +1 -0
  9. package/dist/create/index.js +69 -0
  10. package/dist/create/index.js.map +1 -0
  11. package/dist/runtime/dev.js +1 -1
  12. package/dist/runtime/dev.js.map +1 -1
  13. package/dist/runtime/index.js +77 -13
  14. package/dist/runtime/index.js.map +1 -1
  15. package/examples/full-stack/.env.example +16 -0
  16. package/examples/full-stack/.oxfmtrc.json +7 -0
  17. package/examples/full-stack/Dockerfile +37 -0
  18. package/examples/full-stack/Dockerfile.standalone +28 -0
  19. package/examples/full-stack/centrifugo.json +14 -0
  20. package/examples/full-stack/dockerignore.template +8 -0
  21. package/examples/full-stack/drizzle/0000_production_reference.sql +114 -0
  22. package/examples/full-stack/drizzle/meta/_journal.json +13 -0
  23. package/examples/full-stack/e2e/full-stack.spec.ts +45 -0
  24. package/examples/full-stack/gitignore.template +9 -0
  25. package/examples/full-stack/oxlint.json +4 -0
  26. package/examples/full-stack/package.json +58 -0
  27. package/examples/full-stack/playwright.config.ts +7 -0
  28. package/examples/full-stack/pnpm-workspace.template.yaml +12 -0
  29. package/examples/full-stack/ras-stack.assets.json +4 -0
  30. package/examples/full-stack/scripts/containerRuntime.ts +29 -0
  31. package/examples/full-stack/scripts/database.test.ts +121 -0
  32. package/examples/full-stack/scripts/database.ts +105 -0
  33. package/examples/full-stack/src/client/auth.ts +3 -0
  34. package/examples/full-stack/src/client/queries.ts +4 -0
  35. package/examples/full-stack/src/client/queryClient.ts +1 -0
  36. package/examples/full-stack/src/client/useRealtime.ts +20 -0
  37. package/examples/full-stack/src/posthog.ts +17 -0
  38. package/examples/full-stack/src/routeTree.gen.ts +230 -0
  39. package/examples/full-stack/src/router.tsx +17 -0
  40. package/examples/full-stack/src/routes/__root.tsx +38 -0
  41. package/examples/full-stack/src/routes/api/auth.$.ts +8 -0
  42. package/examples/full-stack/src/routes/api/centrifugo.connect.ts +29 -0
  43. package/examples/full-stack/src/routes/api/health.ts +6 -0
  44. package/examples/full-stack/src/routes/api/live.ts +5 -0
  45. package/examples/full-stack/src/routes/api/ready.ts +30 -0
  46. package/examples/full-stack/src/routes/api/uploads.$id.ts +40 -0
  47. package/examples/full-stack/src/routes/api/uploads.ts +25 -0
  48. package/examples/full-stack/src/routes/index.tsx +187 -0
  49. package/examples/full-stack/src/server/app.test.ts +24 -0
  50. package/examples/full-stack/src/server/app.ts +172 -0
  51. package/examples/full-stack/src/server/auth-flow.test.ts +148 -0
  52. package/examples/full-stack/src/server/auth.ts +60 -0
  53. package/examples/full-stack/src/server/environment.test.ts +43 -0
  54. package/examples/full-stack/src/server/environment.ts +67 -0
  55. package/examples/full-stack/src/server/fns.ts +38 -0
  56. package/examples/full-stack/src/server/messages.test.ts +38 -0
  57. package/examples/full-stack/src/server/messages.ts +28 -0
  58. package/examples/full-stack/src/server/migration.test.ts +34 -0
  59. package/examples/full-stack/src/server/outbox.test.ts +130 -0
  60. package/examples/full-stack/src/server/outbox.ts +110 -0
  61. package/examples/full-stack/src/server/posthog.test.ts +30 -0
  62. package/examples/full-stack/src/server/rate-limit.test.ts +32 -0
  63. package/examples/full-stack/src/server/rate-limit.ts +29 -0
  64. package/examples/full-stack/src/server/rpc.ts +16 -0
  65. package/examples/full-stack/src/server/schema.ts +134 -0
  66. package/examples/full-stack/src/server/session.test.ts +56 -0
  67. package/examples/full-stack/src/server/session.ts +13 -0
  68. package/examples/full-stack/src/server/uploads.test.ts +142 -0
  69. package/examples/full-stack/src/server/uploads.ts +199 -0
  70. package/examples/full-stack/src/start.ts +12 -0
  71. package/examples/full-stack/src/styles.css +42 -0
  72. package/examples/full-stack/tsconfig.json +8 -0
  73. package/examples/full-stack/vite.config.ts +31 -0
  74. package/examples/full-stack/vitest.config.ts +3 -0
  75. package/package.json +10 -8
@@ -0,0 +1,130 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { eq } from 'drizzle-orm'
5
+ import { clearGlobalSingleton } from 'ras-stack/server'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { app, closeApp } from './app'
8
+ import { assertOutboxCapacity, OutboxWorker, outboxCapacity, outboxStatus } from './outbox'
9
+ import { outbox } from './schema'
10
+
11
+ let directory: string
12
+
13
+ beforeEach(async () => {
14
+ directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-outbox-'))
15
+ process.env.DATA_DIR = directory
16
+ process.env.APP_URL = 'http://localhost:3100'
17
+ })
18
+
19
+ afterEach(async () => {
20
+ vi.useRealTimers()
21
+ await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
22
+ await rm(directory, { recursive: true, force: true })
23
+ delete process.env.DATA_DIR
24
+ delete process.env.APP_URL
25
+ })
26
+
27
+ describe('transactional outbox worker', () => {
28
+ it('retains a failed publication and deletes it only after delivery', async () => {
29
+ const now = new Date()
30
+ app()
31
+ .database.insert(outbox)
32
+ .values({ channel: 'messages:all', payload: JSON.stringify({ id: 1 }), availableAt: now, createdAt: now })
33
+ .run()
34
+ const publish = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(undefined)
35
+ const worker = new OutboxWorker({ database: app().database, enabled: false, publish, batchSize: 1 })
36
+ await worker.drain()
37
+ const retained = app().database.select().from(outbox).get()!
38
+ expect(retained.attempts).toBe(1)
39
+ app()
40
+ .database.update(outbox)
41
+ .set({ availableAt: new Date(0) })
42
+ .where(eq(outbox.id, retained.id))
43
+ .run()
44
+ await worker.drain()
45
+ expect(app().database.select().from(outbox).all()).toEqual([])
46
+ })
47
+
48
+ it('does not overtake the oldest item while it waits for retry', async () => {
49
+ const now = new Date()
50
+ app()
51
+ .database.insert(outbox)
52
+ .values([
53
+ { channel: 'messages:all', payload: JSON.stringify({ id: 1 }), availableAt: now, createdAt: now },
54
+ { channel: 'messages:all', payload: JSON.stringify({ id: 2 }), availableAt: now, createdAt: now },
55
+ ])
56
+ .run()
57
+ const publish = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined)
58
+ const worker = new OutboxWorker({ database: app().database, enabled: false, publish })
59
+ await worker.drain()
60
+ await worker.drain()
61
+ expect(publish).toHaveBeenCalledTimes(1)
62
+ app()
63
+ .database.update(outbox)
64
+ .set({ availableAt: new Date(0) })
65
+ .run()
66
+ await worker.drain()
67
+ expect(publish.mock.calls.map(([, payload]) => payload)).toEqual([{ id: 1 }, { id: 1 }, { id: 2 }])
68
+ })
69
+
70
+ it('dead-letters terminal failures and exposes degraded status', async () => {
71
+ const now = new Date()
72
+ app()
73
+ .database.insert(outbox)
74
+ .values({ channel: 'messages:all', payload: JSON.stringify({ id: 1 }), availableAt: now, createdAt: now })
75
+ .run()
76
+ const worker = new OutboxWorker({
77
+ database: app().database,
78
+ enabled: false,
79
+ publish: vi.fn().mockRejectedValue(new Error('offline')),
80
+ maxAttempts: 2,
81
+ })
82
+ await worker.drain()
83
+ app()
84
+ .database.update(outbox)
85
+ .set({ availableAt: new Date(0) })
86
+ .run()
87
+ await worker.drain()
88
+ expect(outboxStatus(app().database)).toEqual({ pending: 0, failed: 1 })
89
+ })
90
+
91
+ it('rejects new work when pending and dead-lettered items reach capacity', () => {
92
+ const now = new Date()
93
+ app().database.transaction((transaction) => {
94
+ for (let index = 0; index < outboxCapacity - 1; index += 1) {
95
+ transaction.insert(outbox).values({ channel: 'messages:all', payload: '{}', availableAt: now, createdAt: now }).run()
96
+ }
97
+ transaction.insert(outbox).values({ channel: 'messages:all', payload: '{}', availableAt: now, createdAt: now, failedAt: now }).run()
98
+ })
99
+ expect(capture(() => assertOutboxCapacity(app().database))).toMatchObject({ status: 503 })
100
+ })
101
+
102
+ it('reports a scheduled drain failure and keeps scheduling', async () => {
103
+ vi.useFakeTimers()
104
+ const onError = vi.fn()
105
+ const worker = new OutboxWorker({ database: app().database, enabled: true, publish: vi.fn(), intervalMs: 10, onError })
106
+ const drain = vi.spyOn(worker, 'drain').mockRejectedValueOnce(new Error('database unavailable')).mockResolvedValue()
107
+ worker.start()
108
+ await vi.advanceTimersByTimeAsync(10)
109
+ expect({ calls: drain.mock.calls.length, errors: onError.mock.calls }).toEqual({
110
+ calls: 2,
111
+ errors: [[expect.objectContaining({ message: 'database unavailable' })]],
112
+ })
113
+ await worker.close()
114
+ })
115
+
116
+ it('propagates the final drain failure during close', async () => {
117
+ const worker = new OutboxWorker({ database: app().database, enabled: true, publish: vi.fn() })
118
+ vi.spyOn(worker, 'drain').mockRejectedValue(new Error('database unavailable'))
119
+ await expect(worker.close()).rejects.toThrow('database unavailable')
120
+ })
121
+ })
122
+
123
+ function capture(operation: () => unknown) {
124
+ try {
125
+ operation()
126
+ } catch (error) {
127
+ return error
128
+ }
129
+ throw new Error('Expected operation to fail')
130
+ }
@@ -0,0 +1,110 @@
1
+ import { asc, eq, isNull, sql } from 'drizzle-orm'
2
+ import type { app } from './app'
3
+ import { outbox } from './schema'
4
+
5
+ type Database = ReturnType<typeof app>['database']
6
+ export const outboxCapacity = 1_000
7
+
8
+ export class OutboxWorker {
9
+ private timer?: NodeJS.Timeout
10
+ private draining?: Promise<void>
11
+ private scheduled?: Promise<void>
12
+
13
+ constructor(
14
+ private readonly options: {
15
+ database: Database
16
+ enabled: boolean
17
+ publish: (channel: string, payload: unknown) => Promise<void>
18
+ intervalMs?: number
19
+ batchSize?: number
20
+ maxAttempts?: number
21
+ onError?: (error: unknown) => void
22
+ },
23
+ ) {}
24
+
25
+ start() {
26
+ if (!this.options.enabled || this.timer) return
27
+ this.timer = setInterval(() => this.scheduleDrain(), this.options.intervalMs ?? 500)
28
+ this.timer.unref()
29
+ this.scheduleDrain()
30
+ }
31
+
32
+ drain() {
33
+ if (this.draining) return this.draining
34
+ this.draining = this.drainBatch().finally(() => {
35
+ this.draining = undefined
36
+ })
37
+ return this.draining
38
+ }
39
+
40
+ async close() {
41
+ if (this.timer) clearInterval(this.timer)
42
+ this.timer = undefined
43
+ await this.draining
44
+ if (this.options.enabled) await this.drain()
45
+ }
46
+
47
+ private scheduleDrain() {
48
+ if (this.scheduled) return
49
+ const scheduled = this.drain()
50
+ .catch((error: unknown) => {
51
+ if (this.options.onError) this.options.onError(error)
52
+ else console.error({ event: 'example_outbox_drain_failed', error })
53
+ })
54
+ .finally(() => {
55
+ if (this.scheduled === scheduled) this.scheduled = undefined
56
+ })
57
+ this.scheduled = scheduled
58
+ }
59
+
60
+ private async drainBatch() {
61
+ const items = this.options.database
62
+ .select()
63
+ .from(outbox)
64
+ .where(isNull(outbox.failedAt))
65
+ .orderBy(asc(outbox.id))
66
+ .limit(this.options.batchSize ?? 20)
67
+ .all()
68
+ for (const item of items) {
69
+ if (item.availableAt.getTime() > Date.now()) break
70
+ try {
71
+ const payload: unknown = JSON.parse(item.payload)
72
+ // oxlint-disable-next-line no-await-in-loop
73
+ await this.options.publish(item.channel, payload)
74
+ this.options.database.delete(outbox).where(eq(outbox.id, item.id)).run()
75
+ } catch (error) {
76
+ const attempts = item.attempts + 1
77
+ const failed = attempts >= (this.options.maxAttempts ?? 8)
78
+ const delay = Math.min(60_000, 500 * 2 ** Math.min(attempts, 7))
79
+ this.options.database
80
+ .update(outbox)
81
+ .set({
82
+ attempts,
83
+ availableAt: new Date(Date.now() + delay),
84
+ failedAt: failed ? new Date() : null,
85
+ lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
86
+ })
87
+ .where(eq(outbox.id, item.id))
88
+ .run()
89
+ console.error({ event: 'example_outbox_delivery_failed', outboxId: item.id, attempts, error })
90
+ break
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ export function outboxStatus(database: Pick<Database, 'select'>): { pending: number; failed: number } {
97
+ const status = database
98
+ .select({
99
+ pending: sql<number>`coalesce(sum(case when ${outbox.failedAt} is null then 1 else 0 end), 0)`,
100
+ failed: sql<number>`coalesce(sum(case when ${outbox.failedAt} is not null then 1 else 0 end), 0)`,
101
+ })
102
+ .from(outbox)
103
+ .get()
104
+ return status ?? { pending: 0, failed: 0 }
105
+ }
106
+
107
+ export function assertOutboxCapacity(database: Pick<Database, 'select'>) {
108
+ const status = outboxStatus(database)
109
+ if (status.pending + status.failed >= outboxCapacity) throw new Response('Realtime queue is full', { status: 503 })
110
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { postHogEnvironment, postHogRequestContext } from 'ras-stack/posthog'
3
+ import { createManagedPostHogServerTelemetry, createPostHogServerClient } from 'ras-stack/posthog/server'
4
+ import { postHogCoverage } from '../posthog'
5
+
6
+ describe('PostHog integration boundary', () => {
7
+ it('stays disabled without deployment configuration', async () => {
8
+ expect(await createPostHogServerClient(postHogEnvironment({}))).toBeUndefined()
9
+ expect(postHogCoverage.browser.errorTracking).toBe(true)
10
+ })
11
+
12
+ it('propagates a browser session only with the authenticated identity', () => {
13
+ const request = new Request('https://example.test/action', {
14
+ headers: { 'x-posthog-distinct-id': 'person-123', 'x-posthog-session-id': 'session-456' },
15
+ })
16
+ expect(postHogRequestContext(request, { authenticatedDistinctId: 'person-123' })).toEqual({
17
+ distinctId: 'person-123',
18
+ sessionId: 'session-456',
19
+ properties: { $session_id: 'session-456' },
20
+ })
21
+ })
22
+
23
+ it('keeps the managed server lifecycle optional', async () => {
24
+ const telemetry = createManagedPostHogServerTelemetry({ environment: postHogEnvironment({}), serviceName: 'example' })
25
+ await telemetry.start()
26
+ await telemetry.log({ body: 'request completed', severityText: 'info' })
27
+ await telemetry.shutdown()
28
+ expect(postHogCoverage.server.logs).toBe(true)
29
+ })
30
+ })
@@ -0,0 +1,32 @@
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 { closeApp } from './app'
7
+ import { limitAuthenticatedRequest } from './rate-limit'
8
+
9
+ let directory: string
10
+
11
+ beforeEach(async () => {
12
+ directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-rate-limit-'))
13
+ process.env.DATA_DIR = directory
14
+ process.env.APP_URL = 'http://localhost:3100'
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
19
+ await rm(directory, { recursive: true, force: true })
20
+ delete process.env.DATA_DIR
21
+ delete process.env.APP_URL
22
+ })
23
+
24
+ it('keeps authenticated user budgets separate', async () => {
25
+ await limitAuthenticatedRequest(request(), 'messages', 'alice', { window: 60, max: 1 })
26
+ await expect(limitAuthenticatedRequest(request(), 'messages', 'alice', { window: 60, max: 1 })).rejects.toMatchObject({ status: 429 })
27
+ await expect(limitAuthenticatedRequest(request(), 'messages', 'bob', { window: 60, max: 1 })).resolves.toMatchObject({ remaining: 0 })
28
+ })
29
+
30
+ function request() {
31
+ return new Request('http://localhost:3100/messages')
32
+ }
@@ -0,0 +1,29 @@
1
+ import { sqliteRateLimitStore } from 'ras-stack/database/sqlite'
2
+ import { createRateLimit } from 'ras-stack/server'
3
+ import type { RateLimitRule } from 'ras-stack/auth'
4
+ import { app } from './app'
5
+
6
+ const limiters = new WeakMap<object, Map<string, ReturnType<typeof createRateLimit>>>()
7
+ const identities = new WeakMap<Request, string>()
8
+
9
+ export function limitAuthenticatedRequest(request: Request, scope: string, userId: string, rule: RateLimitRule) {
10
+ const database = app().database
11
+ let databaseLimiters = limiters.get(database)
12
+ if (!databaseLimiters) {
13
+ databaseLimiters = new Map()
14
+ limiters.set(database, databaseLimiters)
15
+ }
16
+ let limiter = databaseLimiters.get(scope)
17
+ if (!limiter) {
18
+ limiter = createRateLimit({
19
+ store: sqliteRateLimitStore(database.$client, 'app_rate_limit'),
20
+ rule,
21
+ scope,
22
+ identify: (candidate) => identities.get(candidate),
23
+ onUnavailable: 'reject',
24
+ })
25
+ databaseLimiters.set(scope, limiter)
26
+ }
27
+ identities.set(request, userId)
28
+ return limiter(request).finally(() => identities.delete(request))
29
+ }
@@ -0,0 +1,16 @@
1
+ import { createTanStackRpc, requireTanStackMutationOrigin } from 'ras-stack/tanstack/server'
2
+ import { createPostHogRpcLogger } from 'ras-stack/posthog/server'
3
+ import { app } from './app'
4
+ import { currentUser } from './session'
5
+
6
+ const reportRpcError = createPostHogRpcLogger(app().telemetry, {
7
+ logError: (error, context) => console.error({ event: 'example_server_function_failed', ...context, error }),
8
+ resolveAuthenticatedDistinctId: async (request) => (await currentUser(request))?.id,
9
+ allowAnonymousDistinctId: true,
10
+ })
11
+
12
+ export const { rpc, mutationRpc } = createTanStackRpc({
13
+ requireMutation: (request) =>
14
+ requireTanStackMutationOrigin({ configured: [app().environment.appUrl], trustForwardedHeaders: app().environment.trustProxy }, request),
15
+ logError: reportRpcError,
16
+ })
@@ -0,0 +1,134 @@
1
+ import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
2
+
3
+ const timestamps = {
4
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
5
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
6
+ }
7
+
8
+ export const user = sqliteTable(
9
+ 'user',
10
+ {
11
+ id: text().primaryKey(),
12
+ name: text().notNull(),
13
+ email: text().notNull(),
14
+ emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
15
+ image: text(),
16
+ ...timestamps,
17
+ },
18
+ (table) => [uniqueIndex('user_email_unique').on(table.email)],
19
+ )
20
+
21
+ export const session = sqliteTable(
22
+ 'session',
23
+ {
24
+ id: text().primaryKey(),
25
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
26
+ token: text().notNull(),
27
+ ...timestamps,
28
+ ipAddress: text('ip_address'),
29
+ userAgent: text('user_agent'),
30
+ userId: text('user_id')
31
+ .notNull()
32
+ .references(() => user.id, { onDelete: 'cascade' }),
33
+ },
34
+ (table) => [uniqueIndex('session_token_unique').on(table.token), index('session_user_id_idx').on(table.userId)],
35
+ )
36
+
37
+ export const account = sqliteTable(
38
+ 'account',
39
+ {
40
+ id: text().primaryKey(),
41
+ accountId: text('account_id').notNull(),
42
+ issuer: text().notNull(),
43
+ providerId: text('provider_id').notNull(),
44
+ userId: text('user_id')
45
+ .notNull()
46
+ .references(() => user.id, { onDelete: 'cascade' }),
47
+ accessToken: text('access_token'),
48
+ refreshToken: text('refresh_token'),
49
+ idToken: text('id_token'),
50
+ accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
51
+ refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
52
+ scope: text(),
53
+ password: text(),
54
+ ...timestamps,
55
+ },
56
+ (table) => [
57
+ index('account_user_id_idx').on(table.userId),
58
+ uniqueIndex('account_issuer_account_id_unique').on(table.issuer, table.accountId),
59
+ ],
60
+ )
61
+
62
+ export const verification = sqliteTable(
63
+ 'verification',
64
+ {
65
+ id: text().primaryKey(),
66
+ identifier: text().notNull(),
67
+ value: text().notNull(),
68
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
69
+ ...timestamps,
70
+ },
71
+ (table) => [index('verification_identifier_idx').on(table.identifier)],
72
+ )
73
+
74
+ export const rateLimit = sqliteTable(
75
+ 'rate_limit',
76
+ {
77
+ id: text().primaryKey(),
78
+ key: text().notNull(),
79
+ count: integer().notNull(),
80
+ lastRequest: integer('last_request').notNull(),
81
+ },
82
+ (table) => [uniqueIndex('rate_limit_key_unique').on(table.key)],
83
+ )
84
+
85
+ export const messages = sqliteTable(
86
+ 'messages',
87
+ {
88
+ id: integer().primaryKey({ autoIncrement: true }),
89
+ authorId: text('author_id')
90
+ .notNull()
91
+ .references(() => user.id, { onDelete: 'cascade' }),
92
+ author: text().notNull(),
93
+ body: text().notNull(),
94
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
95
+ },
96
+ (table) => [index('messages_author_id_idx').on(table.authorId)],
97
+ )
98
+
99
+ export const uploads = sqliteTable(
100
+ 'uploads',
101
+ {
102
+ id: text().primaryKey(),
103
+ ownerId: text('owner_id')
104
+ .notNull()
105
+ .references(() => user.id, { onDelete: 'cascade' }),
106
+ filename: text().notNull(),
107
+ mediaType: text('media_type').notNull(),
108
+ length: integer().notNull(),
109
+ offset: integer().notNull().default(0),
110
+ state: text({ enum: ['active', 'complete'] })
111
+ .notNull()
112
+ .default('active'),
113
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
114
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
115
+ },
116
+ (table) => [index('uploads_owner_id_state_idx').on(table.ownerId, table.state)],
117
+ )
118
+
119
+ export const outbox = sqliteTable(
120
+ 'outbox',
121
+ {
122
+ id: integer().primaryKey({ autoIncrement: true }),
123
+ channel: text().notNull(),
124
+ payload: text().notNull(),
125
+ attempts: integer().notNull().default(0),
126
+ availableAt: integer('available_at', { mode: 'timestamp_ms' }).notNull(),
127
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
128
+ failedAt: integer('failed_at', { mode: 'timestamp_ms' }),
129
+ lastError: text('last_error'),
130
+ },
131
+ (table) => [index('outbox_available_at_idx').on(table.availableAt, table.id)],
132
+ )
133
+
134
+ export type Message = typeof messages.$inferSelect
@@ -0,0 +1,56 @@
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, describe, expect, it } from 'vitest'
6
+ import { app, closeApp } from './app'
7
+ import { currentUser } from './session'
8
+
9
+ let directory: string
10
+
11
+ beforeEach(async () => {
12
+ directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-auth-'))
13
+ process.env.DATA_DIR = directory
14
+ process.env.APP_URL = 'http://localhost:3100'
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
19
+ await rm(directory, { recursive: true, force: true })
20
+ delete process.env.DATA_DIR
21
+ delete process.env.APP_URL
22
+ })
23
+
24
+ describe('Better Auth integration', () => {
25
+ it('creates a database-backed user and session', async () => {
26
+ const response = await authRequest('/sign-up/email', {
27
+ name: 'Ada',
28
+ email: 'ada@example.test',
29
+ password: 'correct horse battery staple',
30
+ })
31
+ expect(response.status).toBe(200)
32
+ const cookie = response.headers.get('set-cookie')
33
+ expect(cookie).toContain('ras_stack_example.session_token=')
34
+ const request = new Request('http://localhost:3100', { headers: { cookie: cookie! } })
35
+ expect((await currentUser(request))?.email).toBe('ada@example.test')
36
+ })
37
+
38
+ it('rejects a cross-origin sign-up', async () => {
39
+ const response = await authRequest(
40
+ '/sign-up/email',
41
+ { name: 'Mallory', email: 'mallory@example.test', password: 'correct horse battery staple' },
42
+ 'https://attacker.example',
43
+ )
44
+ expect(response.status).toBe(403)
45
+ })
46
+ })
47
+
48
+ function authRequest(endpoint: string, body: object, origin = 'http://localhost:3100') {
49
+ return app().auth.handler(
50
+ new Request(`http://localhost:3100/api/auth${endpoint}`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json', Origin: origin },
53
+ body: JSON.stringify(body),
54
+ }),
55
+ )
56
+ }
@@ -0,0 +1,13 @@
1
+ import { getRequest } from '@tanstack/react-start/server'
2
+ import { app } from './app'
3
+
4
+ export async function currentUser(request = getRequest()) {
5
+ const session = await app().auth.api.getSession({ headers: request.headers })
6
+ return session?.user
7
+ }
8
+
9
+ export async function requireCurrentUser(request: Request) {
10
+ const user = await currentUser(request)
11
+ if (!user) throw new Response('Sign in first', { status: 401 })
12
+ return user
13
+ }