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.
Files changed (110) hide show
  1. package/README.md +19 -46
  2. package/dist/changesets/cli.d.ts +1 -0
  3. package/dist/changesets/cli.js +14 -0
  4. package/dist/changesets/cli.js.map +1 -0
  5. package/dist/{policy/changesets.js → changesets/index.js} +1 -1
  6. package/dist/changesets/index.js.map +1 -0
  7. package/dist/cli.d.ts +2 -6
  8. package/dist/cli.js +3 -7
  9. package/dist/cli.js.map +1 -1
  10. package/dist/email/index.d.ts +1 -2
  11. package/dist/email/index.js +2 -2
  12. package/dist/email/index.js.map +1 -1
  13. package/dist/posthog/index.d.ts +0 -2
  14. package/dist/posthog/index.js +0 -1
  15. package/dist/posthog/index.js.map +1 -1
  16. package/package.json +14 -41
  17. package/config/oxlint/application.json +0 -16
  18. package/config/oxlint/domain.json +0 -29
  19. package/config/oxlint/layers.json +0 -116
  20. package/config/oxlint/tanstack.json +0 -4
  21. package/config/oxlint.json +0 -19
  22. package/config/typescript/base.json +0 -10
  23. package/config/typescript/browser.json +0 -8
  24. package/config/typescript/bundler.json +0 -9
  25. package/config/typescript/library.json +0 -11
  26. package/config/typescript/node-bundler.json +0 -9
  27. package/config/typescript/tanstack.json +0 -6
  28. package/dist/create/index.d.ts +0 -1
  29. package/dist/create/index.js +0 -83
  30. package/dist/create/index.js.map +0 -1
  31. package/dist/create/template/.env.example +0 -16
  32. package/dist/create/template/.oxfmtrc.json +0 -7
  33. package/dist/create/template/Dockerfile +0 -37
  34. package/dist/create/template/Dockerfile.standalone +0 -29
  35. package/dist/create/template/centrifugo.json +0 -14
  36. package/dist/create/template/dockerignore.template +0 -8
  37. package/dist/create/template/drizzle/0000_production_reference.sql +0 -114
  38. package/dist/create/template/drizzle/meta/_journal.json +0 -13
  39. package/dist/create/template/e2e/full-stack.spec.ts +0 -54
  40. package/dist/create/template/gitignore.template +0 -9
  41. package/dist/create/template/oxlint.json +0 -4
  42. package/dist/create/template/package.json +0 -60
  43. package/dist/create/template/playwright.config.ts +0 -7
  44. package/dist/create/template/pnpm-workspace.template.yaml +0 -13
  45. package/dist/create/template/ras-stack.assets.json +0 -4
  46. package/dist/create/template/scripts/containerRuntime.ts +0 -29
  47. package/dist/create/template/scripts/database.test.ts +0 -121
  48. package/dist/create/template/scripts/database.ts +0 -105
  49. package/dist/create/template/src/client/auth.ts +0 -3
  50. package/dist/create/template/src/client/queries.ts +0 -4
  51. package/dist/create/template/src/client/queryClient.ts +0 -1
  52. package/dist/create/template/src/client/useRealtime.ts +0 -20
  53. package/dist/create/template/src/posthog.ts +0 -21
  54. package/dist/create/template/src/routeTree.gen.ts +0 -230
  55. package/dist/create/template/src/router.tsx +0 -17
  56. package/dist/create/template/src/routes/__root.tsx +0 -40
  57. package/dist/create/template/src/routes/api/auth.$.ts +0 -8
  58. package/dist/create/template/src/routes/api/centrifugo.connect.ts +0 -29
  59. package/dist/create/template/src/routes/api/health.ts +0 -6
  60. package/dist/create/template/src/routes/api/live.ts +0 -5
  61. package/dist/create/template/src/routes/api/ready.ts +0 -30
  62. package/dist/create/template/src/routes/api/uploads.$id.ts +0 -40
  63. package/dist/create/template/src/routes/api/uploads.ts +0 -25
  64. package/dist/create/template/src/routes/index.tsx +0 -189
  65. package/dist/create/template/src/server/app.test.ts +0 -24
  66. package/dist/create/template/src/server/app.ts +0 -172
  67. package/dist/create/template/src/server/auth-flow.test.ts +0 -150
  68. package/dist/create/template/src/server/auth.ts +0 -56
  69. package/dist/create/template/src/server/environment.test.ts +0 -43
  70. package/dist/create/template/src/server/environment.ts +0 -67
  71. package/dist/create/template/src/server/fns.ts +0 -38
  72. package/dist/create/template/src/server/messages.test.ts +0 -38
  73. package/dist/create/template/src/server/messages.ts +0 -28
  74. package/dist/create/template/src/server/migration.test.ts +0 -34
  75. package/dist/create/template/src/server/outbox.test.ts +0 -130
  76. package/dist/create/template/src/server/outbox.ts +0 -110
  77. package/dist/create/template/src/server/posthog.test.ts +0 -34
  78. package/dist/create/template/src/server/rate-limit.test.ts +0 -32
  79. package/dist/create/template/src/server/rate-limit.ts +0 -29
  80. package/dist/create/template/src/server/rpc.ts +0 -16
  81. package/dist/create/template/src/server/schema.ts +0 -134
  82. package/dist/create/template/src/server/session.test.ts +0 -56
  83. package/dist/create/template/src/server/session.ts +0 -13
  84. package/dist/create/template/src/server/uploads.test.ts +0 -142
  85. package/dist/create/template/src/server/uploads.ts +0 -199
  86. package/dist/create/template/src/start.ts +0 -12
  87. package/dist/create/template/src/styles.css +0 -42
  88. package/dist/create/template/tsconfig.json +0 -8
  89. package/dist/create/template/vite.config.ts +0 -31
  90. package/dist/create/template/vitest.config.ts +0 -3
  91. package/dist/policy/changesets.js.map +0 -1
  92. package/dist/policy/cli.d.ts +0 -1
  93. package/dist/policy/cli.js +0 -23
  94. package/dist/policy/cli.js.map +0 -1
  95. package/dist/policy/index.d.ts +0 -14
  96. package/dist/policy/index.js +0 -132
  97. package/dist/policy/index.js.map +0 -1
  98. package/dist/policy/init-cli.d.ts +0 -12
  99. package/dist/policy/init-cli.js +0 -75
  100. package/dist/policy/init-cli.js.map +0 -1
  101. package/dist/policy/init.d.ts +0 -24
  102. package/dist/policy/init.js +0 -116
  103. package/dist/policy/init.js.map +0 -1
  104. package/dist/posthog/coverage.d.ts +0 -23
  105. package/dist/posthog/coverage.js +0 -30
  106. package/dist/posthog/coverage.js.map +0 -1
  107. package/dist/tanstack/query.d.ts +0 -3
  108. package/dist/tanstack/query.js +0 -8
  109. package/dist/tanstack/query.js.map +0 -1
  110. /package/dist/{policy/changesets.d.ts → changesets/index.d.ts} +0 -0
@@ -1,134 +0,0 @@
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
@@ -1,56 +0,0 @@
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
- }
@@ -1,13 +0,0 @@
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
- }
@@ -1,142 +0,0 @@
1
- import { mkdtemp, readdir, rm, stat } 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 { uploads, user } from './schema'
9
- import { UploadStore } from './uploads'
10
-
11
- let directory: string
12
-
13
- beforeEach(async () => {
14
- directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-upload-'))
15
- process.env.DATA_DIR = directory
16
- process.env.APP_URL = 'http://localhost:3100'
17
- const now = new Date()
18
- app()
19
- .database.insert(user)
20
- .values([
21
- { id: 'alice', name: 'Alice', email: 'alice@example.test', createdAt: now, updatedAt: now },
22
- { id: 'bob', name: 'Bob', email: 'bob@example.test', createdAt: now, updatedAt: now },
23
- ])
24
- .run()
25
- })
26
-
27
- afterEach(async () => {
28
- vi.useRealTimers()
29
- await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
30
- await rm(directory, { recursive: true, force: true })
31
- delete process.env.DATA_DIR
32
- delete process.env.APP_URL
33
- })
34
-
35
- describe('durable upload store', () => {
36
- it('persists completed content and metadata', async () => {
37
- const id = app().uploadStore.create('alice', 5, metadata('proof.txt', 'text/plain'))
38
- const upload = await app().uploadStore.append(id, 'alice', 0, new TextEncoder().encode('proof'))
39
- expect({ filename: upload.filename, offset: upload.offset, state: upload.state }).toEqual({
40
- filename: 'proof.txt',
41
- offset: 5,
42
- state: 'complete',
43
- })
44
- expect((await stat(path.join(directory, 'uploads', `${id}.bin`))).size).toBe(5)
45
- })
46
-
47
- it('continues after a rejected chunk without retaining a poisoned queue', async () => {
48
- const id = app().uploadStore.create('alice', 5, metadata('proof.txt', 'text/plain'))
49
- await expect(app().uploadStore.append(id, 'alice', 1, new TextEncoder().encode('wrong'))).rejects.toMatchObject({ status: 409 })
50
- await expect(app().uploadStore.append(id, 'alice', 0, new TextEncoder().encode('proof'))).resolves.toMatchObject({
51
- offset: 5,
52
- state: 'complete',
53
- })
54
- })
55
-
56
- it('does not reveal another user upload', () => {
57
- const id = app().uploadStore.create('alice', 5, metadata('proof.txt', 'text/plain'))
58
- expect(app().uploadStore.getOwned(id, 'bob')).toBeUndefined()
59
- })
60
-
61
- it('rejects unsafe metadata and quota overflow', () => {
62
- expect(capture(() => app().uploadStore.create('alice', 5, metadata('../proof.txt', 'text/plain')))).toMatchObject({ status: 400 })
63
- expect(capture(() => app().uploadStore.create('alice', 5, 'filename YWJj!,filetype dGV4dC9wbGFpbg=='))).toMatchObject({
64
- status: 400,
65
- })
66
- for (let index = 0; index < 5; index += 1) app().uploadStore.create('alice', 1_000_000, metadata(`proof-${index}.txt`, 'text/plain'))
67
- expect(capture(() => app().uploadStore.create('alice', 1, metadata('overflow.txt', 'text/plain')))).toMatchObject({ status: 413 })
68
- })
69
-
70
- it('removes content when metadata persistence fails', async () => {
71
- expect(capture(() => app().uploadStore.create('missing-user', 5, metadata('proof.txt', 'text/plain')))).toBeInstanceOf(Error)
72
- expect(await readdir(path.join(directory, 'uploads'))).toEqual([])
73
- })
74
-
75
- it('enforces the deployment byte cap across users', () => {
76
- const store = deploymentStore({ globalQuotaBytes: 6 })
77
- store.create('alice', 5, metadata('alice.txt', 'text/plain'))
78
- expect(capture(() => store.create('bob', 2, metadata('bob.txt', 'text/plain')))).toMatchObject({ status: 507 })
79
- })
80
-
81
- it('enforces the deployment file cap across users', () => {
82
- const store = deploymentStore({ globalMaxFiles: 1 })
83
- store.create('alice', 1, metadata('alice.txt', 'text/plain'))
84
- expect(capture(() => store.create('bob', 1, metadata('bob.txt', 'text/plain')))).toMatchObject({ status: 507 })
85
- })
86
-
87
- it('periodically removes uploads abandoned after startup', async () => {
88
- await app().uploadStore.close()
89
- vi.useFakeTimers()
90
- app().uploadStore.startCleanup(10)
91
- const id = app().uploadStore.create('alice', 5, metadata('stale.txt', 'text/plain'))
92
- app()
93
- .database.update(uploads)
94
- .set({ updatedAt: new Date(0) })
95
- .where(eq(uploads.id, id))
96
- .run()
97
- await vi.advanceTimersByTimeAsync(10)
98
- await expect(stat(path.join(directory, 'uploads', `${id}.bin`))).rejects.toMatchObject({ code: 'ENOENT' })
99
- vi.useRealTimers()
100
- })
101
-
102
- it('reports a scheduled cleanup failure and continues scheduling', async () => {
103
- vi.useFakeTimers()
104
- const onError = vi.fn()
105
- const store = deploymentStore({ onError })
106
- const cleanup = vi.spyOn(store, 'cleanupStale').mockImplementationOnce(() => {
107
- throw new Error('database unavailable')
108
- })
109
- store.startCleanup(10)
110
- await vi.advanceTimersByTimeAsync(10)
111
- expect({ calls: cleanup.mock.calls.length, errors: onError.mock.calls }).toEqual({
112
- calls: 2,
113
- errors: [[expect.objectContaining({ message: 'database unavailable' })]],
114
- })
115
- await store.close()
116
- })
117
- })
118
-
119
- function deploymentStore(overrides: Partial<ConstructorParameters<typeof UploadStore>[0]> = {}) {
120
- return new UploadStore({
121
- database: app().database,
122
- directory: path.join(directory, 'deployment-uploads'),
123
- maxBytes: 5,
124
- quotaBytes: 5,
125
- globalQuotaBytes: 100,
126
- globalMaxFiles: 100,
127
- ...overrides,
128
- })
129
- }
130
-
131
- function metadata(filename: string, filetype: string) {
132
- return `filename ${Buffer.from(filename).toString('base64')},filetype ${Buffer.from(filetype).toString('base64')}`
133
- }
134
-
135
- function capture(operation: () => unknown) {
136
- try {
137
- operation()
138
- } catch (error) {
139
- return error
140
- }
141
- throw new Error('Expected operation to fail')
142
- }
@@ -1,199 +0,0 @@
1
- import { and, eq, lt, sql } from 'drizzle-orm'
2
- import { appendFileSync, mkdirSync, rmSync, statSync } from 'node:fs'
3
- import path from 'node:path'
4
- import { randomId } from 'ras-stack/auth'
5
- import type { app } from './app'
6
- import { uploads } from './schema'
7
-
8
- type Database = ReturnType<typeof app>['database']
9
- type Upload = typeof uploads.$inferSelect
10
-
11
- export class UploadStore {
12
- private readonly locks = new Map<string, Promise<void>>()
13
- private cleanupTimer?: NodeJS.Timeout
14
-
15
- constructor(
16
- private readonly options: {
17
- database: Database
18
- directory: string
19
- maxBytes: number
20
- quotaBytes: number
21
- globalQuotaBytes: number
22
- globalMaxFiles: number
23
- onError?: (error: unknown) => void
24
- },
25
- ) {
26
- mkdirSync(options.directory, { recursive: true })
27
- }
28
-
29
- create(ownerId: string, length: number, metadataHeader: string | null) {
30
- if (!Number.isSafeInteger(length) || length < 1 || length > this.options.maxBytes) {
31
- throw new Response('Invalid upload length', { status: 400 })
32
- }
33
- const metadata = parseMetadata(metadataHeader)
34
- const id = randomId()
35
- const now = new Date()
36
- let fileCreated = false
37
- try {
38
- return this.options.database.transaction(
39
- (transaction) => {
40
- const { count, bytes } = transaction
41
- .select({ count: sql<number>`count(*)`, bytes: sql<number>`coalesce(sum(${uploads.length}), 0)` })
42
- .from(uploads)
43
- .where(eq(uploads.ownerId, ownerId))
44
- .get() ?? { count: 0, bytes: 0 }
45
- if (count >= 32) throw new Response('Too many uploads', { status: 429 })
46
- if (bytes + length > this.options.quotaBytes) throw new Response('Upload quota exceeded', { status: 413 })
47
- const global = transaction
48
- .select({ count: sql<number>`count(*)`, bytes: sql<number>`coalesce(sum(${uploads.length}), 0)` })
49
- .from(uploads)
50
- .get() ?? { count: 0, bytes: 0 }
51
- if (global.count >= this.options.globalMaxFiles || global.bytes + length > this.options.globalQuotaBytes) {
52
- throw new Response('Deployment upload storage limit reached', { status: 507 })
53
- }
54
- appendFileSync(this.file(id), new Uint8Array(), { flag: 'wx' })
55
- fileCreated = true
56
- transaction
57
- .insert(uploads)
58
- .values({
59
- id,
60
- ownerId,
61
- filename: metadata.filename,
62
- mediaType: metadata.mediaType,
63
- length,
64
- offset: 0,
65
- state: 'active',
66
- createdAt: now,
67
- updatedAt: now,
68
- })
69
- .run()
70
- return id
71
- },
72
- { behavior: 'immediate' },
73
- )
74
- } catch (error) {
75
- if (fileCreated) {
76
- try {
77
- rmSync(this.file(id), { force: true })
78
- } catch (cleanupError) {
79
- const failure = new AggregateError([error, cleanupError], 'Upload metadata failed and empty-file cleanup was incomplete')
80
- failure.cause = error
81
- throw failure
82
- }
83
- }
84
- throw error
85
- }
86
- }
87
-
88
- getOwned(id: string, ownerId: string) {
89
- const upload = this.options.database
90
- .select()
91
- .from(uploads)
92
- .where(and(eq(uploads.id, id), eq(uploads.ownerId, ownerId)))
93
- .get()
94
- if (!upload) return undefined
95
- return this.reconcile(upload)
96
- }
97
-
98
- append(id: string, ownerId: string, expectedOffset: number, chunk: Uint8Array) {
99
- const previous = this.locks.get(id) ?? Promise.resolve()
100
- const running = previous.catch(() => undefined).then(() => this.appendUnlocked(id, ownerId, expectedOffset, chunk))
101
- const stored = running.then(
102
- () => undefined,
103
- () => undefined,
104
- )
105
- this.locks.set(id, stored)
106
- void stored.finally(() => {
107
- if (this.locks.get(id) === stored) this.locks.delete(id)
108
- })
109
- return running
110
- }
111
-
112
- startCleanup(intervalMs = 60 * 60 * 1_000) {
113
- if (this.cleanupTimer) return
114
- this.runScheduledCleanup()
115
- this.cleanupTimer = setInterval(() => this.runScheduledCleanup(), intervalMs)
116
- this.cleanupTimer.unref()
117
- }
118
-
119
- async close() {
120
- if (this.cleanupTimer) clearInterval(this.cleanupTimer)
121
- this.cleanupTimer = undefined
122
- await Promise.all(this.locks.values())
123
- }
124
-
125
- cleanupStale(now = new Date(), maxAgeMs = 24 * 60 * 60 * 1_000) {
126
- const stale = this.options.database
127
- .select({ id: uploads.id })
128
- .from(uploads)
129
- .where(and(eq(uploads.state, 'active'), lt(uploads.updatedAt, new Date(now.getTime() - maxAgeMs))))
130
- .limit(100)
131
- .all()
132
- for (const { id } of stale) {
133
- rmSync(this.file(id), { force: true })
134
- this.options.database.delete(uploads).where(eq(uploads.id, id)).run()
135
- }
136
- return stale.length
137
- }
138
-
139
- private appendUnlocked(id: string, ownerId: string, expectedOffset: number, chunk: Uint8Array) {
140
- const upload = this.getOwned(id, ownerId)
141
- if (!upload) throw new Response('Upload not found', { status: 404 })
142
- if (upload.state !== 'active') throw new Response('Upload is complete', { status: 409 })
143
- if (expectedOffset !== upload.offset) throw new Response('Upload offset conflict', { status: 409 })
144
- if (chunk.length < 1 || upload.offset + chunk.length > upload.length) throw new Response('Invalid upload chunk', { status: 413 })
145
- appendFileSync(this.file(id), chunk)
146
- const offset = upload.offset + chunk.length
147
- const state = offset === upload.length ? 'complete' : 'active'
148
- this.options.database.update(uploads).set({ offset, state, updatedAt: new Date() }).where(eq(uploads.id, id)).run()
149
- return { ...upload, offset, state } satisfies Upload
150
- }
151
-
152
- private runScheduledCleanup() {
153
- try {
154
- this.cleanupStale()
155
- } catch (error) {
156
- if (this.options.onError) this.options.onError(error)
157
- else console.error({ event: 'example_upload_cleanup_failed', error })
158
- }
159
- }
160
-
161
- private reconcile(upload: Upload) {
162
- const size = statSync(this.file(upload.id)).size
163
- if (size > upload.length) throw new Error(`Upload ${upload.id} exceeds its declared length`)
164
- if (size === upload.offset) return upload
165
- const state = size === upload.length ? 'complete' : 'active'
166
- this.options.database.update(uploads).set({ offset: size, state, updatedAt: new Date() }).where(eq(uploads.id, upload.id)).run()
167
- return { ...upload, offset: size, state } satisfies Upload
168
- }
169
-
170
- private file(id: string) {
171
- return path.join(this.options.directory, `${id}.bin`)
172
- }
173
- }
174
-
175
- function parseMetadata(header: string | null) {
176
- if (!header || header.length > 2_048) throw new Response('Upload metadata is required', { status: 400 })
177
- const values = new Map<string, string>()
178
- for (const item of header.split(',')) {
179
- const [key, encoded, extra] = item.trim().split(' ')
180
- if (!key || !encoded || extra || values.has(key)) throw new Response('Invalid upload metadata', { status: 400 })
181
- let value: string
182
- try {
183
- const decoded = Buffer.from(encoded, 'base64')
184
- if (decoded.toString('base64') !== encoded) throw new Error('Non-canonical base64')
185
- value = new TextDecoder('utf-8', { fatal: true }).decode(decoded)
186
- } catch {
187
- throw new Response('Invalid upload metadata', { status: 400 })
188
- }
189
- values.set(key, value)
190
- }
191
- const filename = values.get('filename')?.trim() ?? ''
192
- const mediaType = values.get('filetype')?.trim() || 'application/octet-stream'
193
- const hasControlCharacter = filename.split('').some((character) => character.charCodeAt(0) < 32)
194
- if (!filename || filename.length > 120 || path.basename(filename) !== filename || hasControlCharacter) {
195
- throw new Response('Invalid filename', { status: 400 })
196
- }
197
- if (mediaType !== 'text/plain') throw new Response('Only text files are accepted', { status: 415 })
198
- return { filename, mediaType }
199
- }
@@ -1,12 +0,0 @@
1
- import { createStart } from '@tanstack/react-start'
2
- import { canonicalHostMiddleware } from 'ras-stack/tanstack/middleware'
3
-
4
- // The Centrifugo connect proxy is deliberately absent from this set: it calls in over the loopback interface,
5
- // which canonicalRedirect leaves alone. Listing it here would hide a regression in that behaviour from the
6
- // end-to-end run, which exercises a real Centrifugo against a real container.
7
- const canonicalHost = canonicalHostMiddleware(() => ({
8
- canonicalUrl: process.env.APP_URL,
9
- pathsServedOnAnyHost: new Set(['/api/live', '/api/ready', '/api/health']),
10
- }))
11
-
12
- export const startInstance = createStart(() => ({ requestMiddleware: [canonicalHost] }))
@@ -1,42 +0,0 @@
1
- :root {
2
- font-family: ui-sans-serif, system-ui, sans-serif;
3
- color: #172033;
4
- background: #f4f7fb;
5
- }
6
- body {
7
- margin: 0;
8
- }
9
- main {
10
- width: min(42rem, calc(100% - 2rem));
11
- margin: 3rem auto;
12
- }
13
- section,
14
- form {
15
- padding: 1rem;
16
- margin-block: 1rem;
17
- border: 1px solid #cad3e0;
18
- border-radius: 0.75rem;
19
- background: white;
20
- }
21
- label {
22
- display: grid;
23
- gap: 0.35rem;
24
- margin-block: 0.75rem;
25
- }
26
- input,
27
- button {
28
- font: inherit;
29
- padding: 0.6rem 0.75rem;
30
- }
31
- button {
32
- cursor: pointer;
33
- }
34
- ul {
35
- padding-left: 1.25rem;
36
- }
37
- .status {
38
- color: #526174;
39
- }
40
- .error {
41
- color: #a11;
42
- }
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "ras-stack/config/typescript/tanstack",
3
- "compilerOptions": {
4
- "paths": { "@/*": ["./src/*"] },
5
- "types": ["node", "vite/client", "vitest/globals"]
6
- },
7
- "include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", "vite.config.ts", "vitest.config.ts"]
8
- }
@@ -1,31 +0,0 @@
1
- import path from 'node:path'
2
- import { tanstackStart } from '@tanstack/react-start/plugin/vite'
3
- import viteReact from '@vitejs/plugin-react'
4
- import { nitro } from 'nitro/vite'
5
- import { defineConfig, loadEnv } from 'vite'
6
- import { postHogEnvironment } from 'ras-stack/posthog'
7
- import { postHogIngestProxy } from 'ras-stack/posthog/proxy'
8
-
9
- export default defineConfig(({ mode }) => {
10
- const values = loadEnv(mode, process.cwd(), '')
11
- const posthog = postHogEnvironment({ projectToken: values.VITE_POSTHOG_PROJECT_TOKEN, host: values.VITE_POSTHOG_HOST })
12
- const proxy = posthog ? postHogIngestProxy(posthog) : undefined
13
- const securityHeaders = {
14
- 'Content-Security-Policy':
15
- "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
16
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
17
- 'X-Content-Type-Options': 'nosniff',
18
- 'X-Frame-Options': 'DENY',
19
- 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
20
- }
21
- return {
22
- resolve: { alias: { '@': path.resolve(import.meta.dirname, 'src') } },
23
- server: { port: 3100, proxy: { '/connection': { target: 'ws://localhost:8100', ws: true }, ...proxy?.vite } },
24
- build: {
25
- rollupOptions: {
26
- output: { codeSplitting: { groups: [{ name: 'posthog', test: /node_modules[\\/]posthog-js/ }] } },
27
- },
28
- },
29
- plugins: [tanstackStart(), nitro({ routeRules: { '/**': { headers: securityHeaders }, ...proxy?.nitro } }), viteReact()],
30
- }
31
- })
@@ -1,3 +0,0 @@
1
- import { defineConfig } from 'vitest/config'
2
-
3
- export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'] } })
@@ -1 +0,0 @@
1
- {"version":3,"file":"changesets.js","sourceRoot":"","sources":["../../src/policy/changesets.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEhC,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY;IAChD,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,KAA4B,EAAE,EAAE;QAC3F,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAA;QACtC,MAAM,KAAK,CAAA;IACb,CAAC,CAAC,CAAA;IACF,MAAM,UAAU,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,IAAI,KAAK,WAAW,CAAC,CAAC,QAAQ,EAAE,CAAA;IAClG,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAA;IACtC,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,WAAW,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAA;IAC1E,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,MAAM,IAAI,GAAG,cAAc,IAAI,EAAE,CAAA;QACjC,iFAAiF;QACjF,4CAA4C;QAC5C,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAA;QACzD,IAAI,QAAQ,CAAA;QACZ,IAAI,CAAC;YACH,QAAQ,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAA;QAClD,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,CAAC,IAAI,CAAC,GAAG,IAAI,kCAAkC,CAAC,CAAA;YACtD,SAAQ;QACV,CAAC;QACD,KAAK,MAAM,EAAE,IAAI,EAAE,IAAI,QAAQ,EAAE,CAAC;YAChC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;gBAAE,MAAM,CAAC,IAAI,CAAC,GAAG,IAAI,qBAAqB,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACvF,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["import { parseChangesetFile } from '@changesets/parse'\nimport { getPackages } from '@manypkg/get-packages'\nimport { readdir, readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\n\nexport async function checkChangesets(root: string): Promise<string[]> {\n const files = await readdir(join(root, '.changeset')).catch((error: NodeJS.ErrnoException) => {\n if (error.code === 'ENOENT') return []\n throw error\n })\n const changesets = files.filter((file) => file.endsWith('.md') && file !== 'README.md').toSorted()\n if (changesets.length === 0) return []\n const { packages } = await getPackages(root)\n const names = new Set(packages.map(({ packageJson }) => packageJson.name))\n const errors: string[] = []\n for (const file of changesets) {\n const path = `.changeset/${file}`\n // Read serially so a large changeset directory does not open every file at once.\n // oxlint-disable-next-line no-await-in-loop\n const contents = await readFile(join(root, path), 'utf8')\n let releases\n try {\n releases = parseChangesetFile(contents).releases\n } catch {\n errors.push(`${path}: missing or invalid frontmatter`)\n continue\n }\n for (const { name } of releases) {\n if (!names.has(name)) errors.push(`${path}: unknown package ${JSON.stringify(name)}`)\n }\n }\n return errors\n}\n"]}
@@ -1 +0,0 @@
1
- export declare function runPolicyCli(arguments_: string[]): Promise<void>;