ras-stack 0.39.4 → 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,142 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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] }))
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ras-stack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Composable full-stack primitives shared across Richard Solomou's applications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authentication",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|
|
24
|
-
"config"
|
|
24
|
+
"config",
|
|
25
|
+
"examples/full-stack"
|
|
25
26
|
],
|
|
26
27
|
"type": "module",
|
|
27
28
|
"sideEffects": false,
|
|
@@ -152,6 +153,7 @@
|
|
|
152
153
|
"format:check": "oxfmt --check .",
|
|
153
154
|
"test": "vitest run --config vitest.config.ts",
|
|
154
155
|
"package:check": "node scripts/checkPackedPackage.mjs",
|
|
156
|
+
"peer:floor": "node scripts/checkPeerFloor.mjs",
|
|
155
157
|
"check": "pnpm format:check && pnpm lint && pnpm config:check && pnpm typecheck && pnpm test && pnpm build && pnpm package:check && pnpm --filter @ras-stack/example-full-stack check && node dist/cli.js policy check",
|
|
156
158
|
"version-packages": "changeset version"
|
|
157
159
|
},
|
|
@@ -171,16 +173,16 @@
|
|
|
171
173
|
"@tanstack/react-query": "^5.101.4",
|
|
172
174
|
"@tanstack/react-start": "^1.168.32",
|
|
173
175
|
"@types/better-sqlite3": "9.6.0",
|
|
174
|
-
"@types/node": "^
|
|
176
|
+
"@types/node": "^26.2.0",
|
|
175
177
|
"@types/nodemailer": "^8.0.1",
|
|
176
178
|
"@types/react": "19.2.18",
|
|
177
179
|
"@types/react-test-renderer": "19.1.0",
|
|
178
180
|
"@vitest/coverage-v8": "^4.1.10",
|
|
179
|
-
"better-sqlite3": "
|
|
181
|
+
"better-sqlite3": "12.11.1",
|
|
180
182
|
"centrifuge": "5.7.0",
|
|
181
183
|
"drizzle-orm": "0.45.2",
|
|
182
184
|
"nodemailer": "^9.0.3",
|
|
183
|
-
"oxfmt": "^0.
|
|
185
|
+
"oxfmt": "^0.62.0",
|
|
184
186
|
"oxlint": "^1.74.0",
|
|
185
187
|
"oxlint-tsgolint": "^7.0.2001",
|
|
186
188
|
"postgres": "3.4.9",
|
|
@@ -198,9 +200,9 @@
|
|
|
198
200
|
"@opentelemetry/exporter-logs-otlp-http": "^0.221.0",
|
|
199
201
|
"@opentelemetry/resources": "^2.10.0",
|
|
200
202
|
"@opentelemetry/sdk-logs": "^0.221.0",
|
|
201
|
-
"@posthog/react": ">=1 <2",
|
|
202
|
-
"@tanstack/react-query": ">=5 <6",
|
|
203
|
-
"@tanstack/react-start": ">=1 <2",
|
|
203
|
+
"@posthog/react": ">=1.1 <2",
|
|
204
|
+
"@tanstack/react-query": ">=5.62.8 <6",
|
|
205
|
+
"@tanstack/react-start": ">=1.168.10 <2",
|
|
204
206
|
"better-sqlite3": ">=12 <14",
|
|
205
207
|
"centrifuge": ">=5 <6",
|
|
206
208
|
"drizzle-orm": ">=0.45 <1",
|