bunderstack 0.15.1 → 0.16.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 +7 -7
- package/package.json +2 -1
- package/src/access.ts +18 -0
- package/src/blueprint-generator.ts +63 -17
- package/src/blueprint.ts +123 -30
- package/src/cli.ts +10 -2
- package/src/config.ts +22 -44
- package/src/cron.ts +2 -1
- package/src/crud.ts +28 -14
- package/src/env.ts +17 -9
- package/src/handler.ts +5 -5
- package/src/index.ts +86 -86
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -28
- package/src/jobs/define.ts +68 -12
- package/src/jobs/index.ts +7 -9
- package/src/jobs/queue.ts +10 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +145 -45
- package/src/list-query.ts +2 -4
- package/src/manifest.ts +65 -21
- package/src/realtime/index.ts +3 -11
- package/src/realtime/redis.ts +3 -12
- package/src/routes.ts +137 -0
- package/src/storage/buckets.ts +2 -1
- package/src/storage/file-meta.ts +1 -1
- package/src/storage/router.ts +2 -6
- package/src/storage/s3.ts +9 -3
- package/src/trpc.ts +1 -1
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -131
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
package/src/manifest.ts
CHANGED
|
@@ -5,13 +5,13 @@ import type { Dialect } from './dialect'
|
|
|
5
5
|
import type { EnvConfigInput } from './env'
|
|
6
6
|
import type { JobsDefs } from './jobs/define'
|
|
7
7
|
import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import {
|
|
10
|
-
bunderstackCronRuns,
|
|
11
10
|
bunderstackFiles,
|
|
12
11
|
bunderstackIdempotency,
|
|
13
12
|
bunderstackJobs,
|
|
14
13
|
} from './internal-tables'
|
|
14
|
+
import { parseCron } from './jobs/cron'
|
|
15
15
|
|
|
16
16
|
export type ManifestEnvVar = {
|
|
17
17
|
key: string
|
|
@@ -47,8 +47,12 @@ const nonEmpty = z.string().min(1)
|
|
|
47
47
|
const migrationDirectory = nonEmpty.refine(
|
|
48
48
|
(value) =>
|
|
49
49
|
value.startsWith('/') ||
|
|
50
|
-
(!value.includes('\\') &&
|
|
51
|
-
|
|
50
|
+
(!value.includes('\\') &&
|
|
51
|
+
value.split('/').every((part) => part !== '..' && part !== '')),
|
|
52
|
+
{
|
|
53
|
+
message:
|
|
54
|
+
'migrationsDirectory must be an absolute path or a relative path without traversal',
|
|
55
|
+
},
|
|
52
56
|
)
|
|
53
57
|
const cronSchedule = nonEmpty.refine(
|
|
54
58
|
(value) => {
|
|
@@ -85,7 +89,10 @@ const manifestSchema = z
|
|
|
85
89
|
defaultBucket: nonEmpty,
|
|
86
90
|
buckets: z.array(
|
|
87
91
|
z
|
|
88
|
-
.object({
|
|
92
|
+
.object({
|
|
93
|
+
name: nonEmpty,
|
|
94
|
+
visibility: z.enum(['public', 'private']),
|
|
95
|
+
})
|
|
89
96
|
.strict(),
|
|
90
97
|
),
|
|
91
98
|
})
|
|
@@ -133,13 +140,16 @@ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
|
|
|
133
140
|
function rejectDuplicates(collection: string, values: readonly string[]): void {
|
|
134
141
|
const seen = new Set<string>()
|
|
135
142
|
for (const value of values) {
|
|
136
|
-
if (seen.has(value))
|
|
143
|
+
if (seen.has(value))
|
|
144
|
+
throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
|
|
137
145
|
seen.add(value)
|
|
138
146
|
}
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
function describeTables(schema: Record<string, unknown>) {
|
|
142
|
-
const systemNames = new Set<string>(
|
|
150
|
+
const systemNames = new Set<string>(
|
|
151
|
+
systemTables().map((table) => table.physicalName),
|
|
152
|
+
)
|
|
143
153
|
return sortBy(
|
|
144
154
|
Object.entries(schema).flatMap(([exportName, value]) =>
|
|
145
155
|
isTable(value) && !systemNames.has(getTableName(value))
|
|
@@ -163,16 +173,19 @@ function describeSection(
|
|
|
163
173
|
|
|
164
174
|
function systemTables() {
|
|
165
175
|
return [
|
|
166
|
-
{
|
|
176
|
+
{
|
|
177
|
+
exportName: '_system.files',
|
|
178
|
+
physicalName: getTableName(bunderstackFiles),
|
|
179
|
+
system: true,
|
|
180
|
+
},
|
|
167
181
|
{
|
|
168
182
|
exportName: '_system.idempotency',
|
|
169
183
|
physicalName: getTableName(bunderstackIdempotency),
|
|
170
184
|
system: true,
|
|
171
185
|
},
|
|
172
|
-
{ exportName: '_system.jobs', physicalName: getTableName(bunderstackJobs), system: true },
|
|
173
186
|
{
|
|
174
|
-
exportName: '_system.
|
|
175
|
-
physicalName: getTableName(
|
|
187
|
+
exportName: '_system.jobs',
|
|
188
|
+
physicalName: getTableName(bunderstackJobs),
|
|
176
189
|
system: true,
|
|
177
190
|
},
|
|
178
191
|
]
|
|
@@ -180,13 +193,34 @@ function systemTables() {
|
|
|
180
193
|
|
|
181
194
|
export function parseManifest(value: unknown): BunderstackManifest {
|
|
182
195
|
const manifest = manifestSchema.parse(value) as BunderstackManifest
|
|
183
|
-
rejectDuplicates(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
rejectDuplicates(
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
rejectDuplicates(
|
|
197
|
+
'database physical table',
|
|
198
|
+
manifest.database.tables.map((entry) => entry.physicalName),
|
|
199
|
+
)
|
|
200
|
+
rejectDuplicates(
|
|
201
|
+
'database export table',
|
|
202
|
+
manifest.database.tables.map((entry) => entry.exportName),
|
|
203
|
+
)
|
|
204
|
+
rejectDuplicates(
|
|
205
|
+
'storage bucket',
|
|
206
|
+
manifest.storage.buckets.map((entry) => entry.name),
|
|
207
|
+
)
|
|
208
|
+
rejectDuplicates(
|
|
209
|
+
'environment key',
|
|
210
|
+
manifest.environment.map((entry) => entry.key),
|
|
211
|
+
)
|
|
212
|
+
rejectDuplicates(
|
|
213
|
+
'background job',
|
|
214
|
+
manifest.background.jobs.map((entry) => entry.name),
|
|
215
|
+
)
|
|
216
|
+
rejectDuplicates(
|
|
217
|
+
'background cron',
|
|
218
|
+
manifest.background.cron.map((entry) => entry.name),
|
|
219
|
+
)
|
|
220
|
+
rejectDuplicates(
|
|
221
|
+
'background maintenance',
|
|
222
|
+
manifest.background.maintenance.map((entry) => entry.name),
|
|
223
|
+
)
|
|
190
224
|
return manifest
|
|
191
225
|
}
|
|
192
226
|
|
|
@@ -210,14 +244,20 @@ export function buildManifest(args: {
|
|
|
210
244
|
? [{ key: 'SMTP_URL', required: true, scope: 'server' as const }]
|
|
211
245
|
: []),
|
|
212
246
|
]
|
|
213
|
-
rejectDuplicates(
|
|
247
|
+
rejectDuplicates(
|
|
248
|
+
'environment key',
|
|
249
|
+
environment.map((entry) => entry.key),
|
|
250
|
+
)
|
|
214
251
|
|
|
215
252
|
return parseManifest({
|
|
216
253
|
version: 3,
|
|
217
254
|
database: {
|
|
218
255
|
dialect: args.dialect,
|
|
219
256
|
migrationsDirectory: args.migrationsDirectory,
|
|
220
|
-
tables: sortBy(
|
|
257
|
+
tables: sortBy(
|
|
258
|
+
[...systemTables(), ...describeTables(args.schema)],
|
|
259
|
+
(entry) => entry.physicalName,
|
|
260
|
+
),
|
|
221
261
|
},
|
|
222
262
|
storage: {
|
|
223
263
|
defaultBucket: args.storage.defaultBucket,
|
|
@@ -249,7 +289,11 @@ export function buildManifest(args: {
|
|
|
249
289
|
(entry) => entry.name,
|
|
250
290
|
),
|
|
251
291
|
maintenance: [
|
|
252
|
-
{
|
|
292
|
+
{
|
|
293
|
+
name: 'storage-sweep',
|
|
294
|
+
schedule: '0 4 * * *',
|
|
295
|
+
timezone: 'UTC' as const,
|
|
296
|
+
},
|
|
253
297
|
],
|
|
254
298
|
},
|
|
255
299
|
})
|
package/src/realtime/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
checkAccessSync,
|
|
6
6
|
resolveSession,
|
|
7
7
|
rowMatchesScope,
|
|
8
|
+
tableEntryForName,
|
|
8
9
|
type AccessUser,
|
|
9
10
|
type AuthSessionResolver,
|
|
10
11
|
type ResolvedAccess,
|
|
@@ -55,15 +56,6 @@ export type RealtimeBroker = {
|
|
|
55
56
|
): void | Promise<void>
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function tableEntry(
|
|
59
|
-
access: ResolvedAccess,
|
|
60
|
-
tableName: string,
|
|
61
|
-
): ResolvedTableAccess | undefined {
|
|
62
|
-
for (const entry of access.values()) {
|
|
63
|
-
if (entry.tableName === tableName) return entry
|
|
64
|
-
}
|
|
65
|
-
return undefined
|
|
66
|
-
}
|
|
67
59
|
|
|
68
60
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
61
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -166,7 +158,7 @@ export function createRealtimeBroker(opts: {
|
|
|
166
158
|
record: Record<string, unknown>,
|
|
167
159
|
id: unknown,
|
|
168
160
|
): boolean {
|
|
169
|
-
const entry =
|
|
161
|
+
const entry = tableEntryForName(opts.access, table)
|
|
170
162
|
if (!entry) return false
|
|
171
163
|
const topicMatch =
|
|
172
164
|
s.subscriptions.has(table) ||
|
|
@@ -232,7 +224,7 @@ export function createRealtimeBroker(opts: {
|
|
|
232
224
|
subscribers.delete(id)
|
|
233
225
|
},
|
|
234
226
|
publish(table, action, record) {
|
|
235
|
-
const entry =
|
|
227
|
+
const entry = tableEntryForName(opts.access, table)
|
|
236
228
|
if (!entry) return
|
|
237
229
|
const eventId = nextId++
|
|
238
230
|
buffer.push({ eventId, table, action, record })
|
package/src/realtime/redis.ts
CHANGED
|
@@ -12,9 +12,9 @@ import type { RealtimeAction, RealtimeBroker } from './index'
|
|
|
12
12
|
import {
|
|
13
13
|
checkAccessSync,
|
|
14
14
|
rowMatchesScope,
|
|
15
|
+
tableEntryForName,
|
|
15
16
|
type AccessUser,
|
|
16
17
|
type ResolvedAccess,
|
|
17
|
-
type ResolvedTableAccess,
|
|
18
18
|
} from '../access'
|
|
19
19
|
|
|
20
20
|
export type RedisLike = {
|
|
@@ -45,15 +45,6 @@ type WireEvent = {
|
|
|
45
45
|
record: Record<string, unknown>
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function tableEntry(
|
|
49
|
-
access: ResolvedAccess,
|
|
50
|
-
name: string,
|
|
51
|
-
): ResolvedTableAccess | undefined {
|
|
52
|
-
for (const entry of access.values())
|
|
53
|
-
if (entry.tableName === name) return entry
|
|
54
|
-
return undefined
|
|
55
|
-
}
|
|
56
|
-
|
|
57
48
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
58
49
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
59
50
|
}
|
|
@@ -109,7 +100,7 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
109
100
|
table: string,
|
|
110
101
|
record: Record<string, unknown>,
|
|
111
102
|
): boolean {
|
|
112
|
-
const entry =
|
|
103
|
+
const entry = tableEntryForName(opts.access, table)
|
|
113
104
|
if (!entry) return false
|
|
114
105
|
const id = record['id']
|
|
115
106
|
const topicMatch =
|
|
@@ -208,7 +199,7 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
208
199
|
subscribers.delete(id)
|
|
209
200
|
},
|
|
210
201
|
async publish(table, action, record) {
|
|
211
|
-
if (!
|
|
202
|
+
if (!tableEntryForName(opts.access, table)) return
|
|
212
203
|
try {
|
|
213
204
|
const client = getRedis()
|
|
214
205
|
const eventId = await client.incr(counterKey)
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/routes.ts — mounting user-supplied Hono routes inside the app.
|
|
2
|
+
|
|
3
|
+
/** A route as Hono reports it on `app.routes`. */
|
|
4
|
+
export type DeclaredRoute = { method: string; path: string }
|
|
5
|
+
|
|
6
|
+
const RESERVED_EXACT = ['/health', '/api/health', '/api/realtime'] as const
|
|
7
|
+
|
|
8
|
+
const RESERVED_PREFIXES = [
|
|
9
|
+
'/api/auth/',
|
|
10
|
+
'/api/trpc/',
|
|
11
|
+
'/api/files/',
|
|
12
|
+
'/files/',
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
/** The first path segment under `/api/`, or undefined when not under it. */
|
|
16
|
+
function apiSegment(path: string): string | undefined {
|
|
17
|
+
if (!path.startsWith('/api/')) return undefined
|
|
18
|
+
return path.slice('/api/'.length).split('/')[0]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function collisionFor(
|
|
22
|
+
route: DeclaredRoute,
|
|
23
|
+
tableNames: readonly string[],
|
|
24
|
+
): string | undefined {
|
|
25
|
+
const { path } = route
|
|
26
|
+
if (RESERVED_EXACT.includes(path as (typeof RESERVED_EXACT)[number])) {
|
|
27
|
+
return `it is reserved by bunderstack`
|
|
28
|
+
}
|
|
29
|
+
for (const prefix of RESERVED_PREFIXES) {
|
|
30
|
+
if (path.startsWith(prefix)) {
|
|
31
|
+
return `"${prefix}*" is reserved by bunderstack`
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const segment = apiSegment(path)
|
|
35
|
+
if (segment === undefined) return undefined
|
|
36
|
+
if (segment === '*' || segment.startsWith(':')) {
|
|
37
|
+
return `a parameter or wildcard here would shadow every generated CRUD route`
|
|
38
|
+
}
|
|
39
|
+
if (tableNames.includes(segment)) {
|
|
40
|
+
return `it collides with the generated CRUD route for table "${segment}"`
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Throws when any declared route would collide with a bunderstack route.
|
|
47
|
+
*
|
|
48
|
+
* Custom routes are registered before the built-ins, so a collision silently
|
|
49
|
+
* shadows core behaviour — including authentication. Failing at construction is
|
|
50
|
+
* the cheapest place to find out.
|
|
51
|
+
*/
|
|
52
|
+
export function validateCustomRoutes(
|
|
53
|
+
routes: readonly DeclaredRoute[],
|
|
54
|
+
tableNames: readonly string[],
|
|
55
|
+
): void {
|
|
56
|
+
const problems: string[] = []
|
|
57
|
+
for (const route of routes) {
|
|
58
|
+
const reason = collisionFor(route, tableNames)
|
|
59
|
+
if (reason) {
|
|
60
|
+
problems.push(` ${route.method} ${route.path} — ${reason}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (problems.length === 0) return
|
|
64
|
+
throw new Error(
|
|
65
|
+
`[bunderstack] routes: ${problems.length} route(s) collide with bunderstack's own:\n${problems.join('\n')}\nChoose different paths.`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
import type { Hono } from 'hono'
|
|
70
|
+
|
|
71
|
+
import type { AccessUser, AuthSessionResolver } from './access'
|
|
72
|
+
import type { DbFor } from './db'
|
|
73
|
+
import type { EmailFacade } from './email'
|
|
74
|
+
import type { JobsRuntimeFacade } from './jobs/define'
|
|
75
|
+
import type { RealtimeFacade } from './realtime/facade'
|
|
76
|
+
import type { AuthInstance, StorageFacade } from './index'
|
|
77
|
+
|
|
78
|
+
import { resolveAccessUser, resolveSession } from './access'
|
|
79
|
+
|
|
80
|
+
export type RouteContext<
|
|
81
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
82
|
+
TEnvResult = Record<string, unknown>,
|
|
83
|
+
> = {
|
|
84
|
+
db: DbFor<TSchema>
|
|
85
|
+
env: TEnvResult
|
|
86
|
+
storage: StorageFacade
|
|
87
|
+
email: EmailFacade
|
|
88
|
+
jobs: JobsRuntimeFacade
|
|
89
|
+
realtime: RealtimeFacade<TSchema>
|
|
90
|
+
auth: AuthInstance
|
|
91
|
+
/** Resolve the caller's session. Costs an auth round-trip; call only when needed. */
|
|
92
|
+
getSession(
|
|
93
|
+
request: Request,
|
|
94
|
+
): Promise<{ user: AccessUser | null; activeOrganizationId: string | null }>
|
|
95
|
+
/** Convenience wrapper over getSession when the organization is irrelevant. */
|
|
96
|
+
getUser(request: Request): Promise<AccessUser | null>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Alias mirroring the JobContext / BunderstackJobContext pair. */
|
|
100
|
+
export type BunderstackRouteContext<
|
|
101
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
102
|
+
TEnvResult = Record<string, unknown>,
|
|
103
|
+
> = RouteContext<TSchema, TEnvResult>
|
|
104
|
+
|
|
105
|
+
export type RoutesBuilder<
|
|
106
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
107
|
+
TEnvResult = Record<string, unknown>,
|
|
108
|
+
> = (ctx: RouteContext<TSchema, TEnvResult>) => Hono
|
|
109
|
+
|
|
110
|
+
export function createRouteContext<
|
|
111
|
+
TSchema extends Record<string, unknown>,
|
|
112
|
+
TEnvResult,
|
|
113
|
+
>(deps: {
|
|
114
|
+
db: DbFor<TSchema>
|
|
115
|
+
env: TEnvResult
|
|
116
|
+
storage: StorageFacade
|
|
117
|
+
email: EmailFacade
|
|
118
|
+
jobs: JobsRuntimeFacade
|
|
119
|
+
realtime: RealtimeFacade<TSchema>
|
|
120
|
+
auth: AuthInstance
|
|
121
|
+
authResolver: AuthSessionResolver | undefined
|
|
122
|
+
}): RouteContext<TSchema, TEnvResult> {
|
|
123
|
+
return {
|
|
124
|
+
db: deps.db,
|
|
125
|
+
env: deps.env,
|
|
126
|
+
storage: deps.storage,
|
|
127
|
+
email: deps.email,
|
|
128
|
+
jobs: deps.jobs,
|
|
129
|
+
realtime: deps.realtime,
|
|
130
|
+
auth: deps.auth,
|
|
131
|
+
// Lazy on purpose: a webhook has no session, and resolving one eagerly
|
|
132
|
+
// would spend an auth round-trip per request on a value nobody reads.
|
|
133
|
+
getSession: (request) => resolveSession(deps.authResolver, request.headers),
|
|
134
|
+
getUser: (request) => resolveAccessUser(deps.authResolver, request.headers),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
package/src/storage/buckets.ts
CHANGED
|
@@ -266,7 +266,8 @@ export function resolveBuckets(
|
|
|
266
266
|
input: StorageConfigInput | undefined,
|
|
267
267
|
env: Record<string, string | undefined> = process.env,
|
|
268
268
|
): ResolvedStorageBuckets {
|
|
269
|
-
const sharedBackend =
|
|
269
|
+
const sharedBackend =
|
|
270
|
+
platformS3Backend(env) ?? resolveSharedBackend(input, env)
|
|
270
271
|
const bucketsInput = input?.buckets
|
|
271
272
|
|
|
272
273
|
const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
|
package/src/storage/file-meta.ts
CHANGED
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
|
|
30
30
|
import { eq, and, lt, sql } from 'drizzle-orm'
|
|
31
31
|
|
|
32
|
-
import type { AnyDb } from '../dialect'
|
|
33
32
|
import type { ScopeMap } from '../access'
|
|
33
|
+
import type { AnyDb } from '../dialect'
|
|
34
34
|
|
|
35
35
|
import { rowMatchesScope } from '../access'
|
|
36
36
|
import { bunderstackFiles, filesTableFor } from '../internal-tables'
|
package/src/storage/router.ts
CHANGED
|
@@ -27,11 +27,7 @@ import {
|
|
|
27
27
|
sumReadySize,
|
|
28
28
|
type FileMetaRow,
|
|
29
29
|
} from './file-meta'
|
|
30
|
-
import {
|
|
31
|
-
parseTransformSpec,
|
|
32
|
-
transformHash,
|
|
33
|
-
transformImage,
|
|
34
|
-
} from './thumbnails'
|
|
30
|
+
import { parseTransformSpec, transformHash, transformImage } from './thumbnails'
|
|
35
31
|
|
|
36
32
|
export interface BucketStorageRouterOptions {
|
|
37
33
|
registry: BucketStorageRegistry
|
|
@@ -379,7 +375,7 @@ export function buildBucketStorageRouter(
|
|
|
379
375
|
if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
|
|
380
376
|
const { bucket, adapter } = entry
|
|
381
377
|
|
|
382
|
-
const mountPrefix = c.req.routePath.replace(/\/:[
|
|
378
|
+
const mountPrefix = c.req.routePath.replace(/\/:[^/]+\/\*$/, '')
|
|
383
379
|
const id = c.req.path.slice(`${mountPrefix}/${bucketName}/`.length)
|
|
384
380
|
const fileId = `${bucketName}/${id}`
|
|
385
381
|
|
package/src/storage/s3.ts
CHANGED
|
@@ -39,11 +39,17 @@ export class S3StorageAdapter implements StorageAdapter {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
async get(fileId: string): Promise<Response> {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// `stat` (a HEAD, same cost as `exists`) is the only way to learn the
|
|
43
|
+
// stored Content-Type: a lazily-constructed `client.file(key)` reports
|
|
44
|
+
// `type: ""` — it never stats, and unlike `Bun.file` it does not guess
|
|
45
|
+
// from the extension either.
|
|
46
|
+
const info = await this.stat(fileId)
|
|
47
|
+
if (!info) return new Response('Not found', { status: 404 })
|
|
44
48
|
const file = this.client.file(fileId)
|
|
45
49
|
return new Response(file.stream(), {
|
|
46
|
-
headers: {
|
|
50
|
+
headers: {
|
|
51
|
+
'Content-Type': info.contentType || 'application/octet-stream',
|
|
52
|
+
},
|
|
47
53
|
})
|
|
48
54
|
}
|
|
49
55
|
|
package/src/trpc.ts
CHANGED
|
@@ -5,9 +5,9 @@ import superjson from 'superjson'
|
|
|
5
5
|
import type { AccessUser } from './access'
|
|
6
6
|
import type { DbFor } from './db'
|
|
7
7
|
import type { EmailFacade } from './email'
|
|
8
|
+
import type { StorageFacade } from './index'
|
|
8
9
|
import type { JobsRuntimeFacade } from './jobs/index'
|
|
9
10
|
import type { RealtimeFacade } from './realtime/facade'
|
|
10
|
-
import type { StorageFacade } from './index'
|
|
11
11
|
|
|
12
12
|
export type TRPCContext<
|
|
13
13
|
TSchema extends Record<string, unknown>,
|
package/src/jobs/cron-auth.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
function canonical(taskId: string, slot: number): string {
|
|
4
|
-
return `${taskId}\n${slot}`
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function signScheduleRequest(
|
|
8
|
-
secret: string,
|
|
9
|
-
taskId: string,
|
|
10
|
-
slot: number,
|
|
11
|
-
): string {
|
|
12
|
-
const digest = createHmac('sha256', secret)
|
|
13
|
-
.update(canonical(taskId, slot))
|
|
14
|
-
.digest('hex')
|
|
15
|
-
return `sha256=${digest}`
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function verifyScheduleRequest(
|
|
19
|
-
secret: string,
|
|
20
|
-
taskId: string,
|
|
21
|
-
slot: number,
|
|
22
|
-
signature: string,
|
|
23
|
-
): boolean {
|
|
24
|
-
if (!/^sha256=[0-9a-f]{64}$/.test(signature)) return false
|
|
25
|
-
const expected = Buffer.from(signScheduleRequest(secret, taskId, slot))
|
|
26
|
-
const received = Buffer.from(signature)
|
|
27
|
-
return timingSafeEqual(expected, received)
|
|
28
|
-
}
|
package/src/jobs/cron-router.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { Hono } from 'hono'
|
|
2
|
-
import { and, eq, lt } from 'drizzle-orm'
|
|
3
|
-
|
|
4
|
-
import type { AnyDb } from '../dialect'
|
|
5
|
-
import type { BackgroundDefs } from './define'
|
|
6
|
-
|
|
7
|
-
import { verifyScheduleRequest } from './cron-auth'
|
|
8
|
-
import { runCronSlot, runScheduledSlot } from './cron-runner'
|
|
9
|
-
import { cronRunsTableFor } from '../internal-tables'
|
|
10
|
-
|
|
11
|
-
const MAX_SLOT_AGE_MS = 60 * 60_000
|
|
12
|
-
const MAX_FUTURE_SLOT_MS = 60_000
|
|
13
|
-
const STORAGE_SWEEP_SCHEDULE = '0 4 * * *'
|
|
14
|
-
const SCHEDULED_RUN_RETENTION_MS = 30 * 24 * 60 * 60_000
|
|
15
|
-
|
|
16
|
-
export function buildCronRouter(args: {
|
|
17
|
-
db: AnyDb
|
|
18
|
-
defs: BackgroundDefs
|
|
19
|
-
ctx: Record<string, unknown>
|
|
20
|
-
secret: string
|
|
21
|
-
storage: { sweep: () => Promise<unknown> }
|
|
22
|
-
now?: () => number
|
|
23
|
-
}): Hono {
|
|
24
|
-
const app = new Hono()
|
|
25
|
-
const now = args.now ?? Date.now
|
|
26
|
-
|
|
27
|
-
app.post('/cron/:name', async (c) => {
|
|
28
|
-
const name = c.req.param('name')
|
|
29
|
-
const slotText = c.req.header('X-Bunderstack-Cron-Slot')
|
|
30
|
-
const signature = c.req.header('X-Bunderstack-Cron-Signature')
|
|
31
|
-
const slot = Number(slotText)
|
|
32
|
-
if (!slotText || !Number.isSafeInteger(slot) || !signature) {
|
|
33
|
-
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
34
|
-
}
|
|
35
|
-
if (!verifyScheduleRequest(args.secret, `cron:${name}`, slot, signature)) {
|
|
36
|
-
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
37
|
-
}
|
|
38
|
-
const definition = args.defs[name]
|
|
39
|
-
if (!definition || definition.kind !== 'cron') {
|
|
40
|
-
return c.json({ error: 'unknown cron' }, 404)
|
|
41
|
-
}
|
|
42
|
-
const current = now()
|
|
43
|
-
if (
|
|
44
|
-
slot % 60_000 !== 0 ||
|
|
45
|
-
slot < current - MAX_SLOT_AGE_MS ||
|
|
46
|
-
slot > current + MAX_FUTURE_SLOT_MS
|
|
47
|
-
) {
|
|
48
|
-
return c.json({ error: 'invalid cron slot' }, 400)
|
|
49
|
-
}
|
|
50
|
-
try {
|
|
51
|
-
const result = await runCronSlot({
|
|
52
|
-
db: args.db,
|
|
53
|
-
defs: args.defs,
|
|
54
|
-
ctx: args.ctx,
|
|
55
|
-
name,
|
|
56
|
-
slot,
|
|
57
|
-
now: current,
|
|
58
|
-
})
|
|
59
|
-
return c.json(
|
|
60
|
-
result,
|
|
61
|
-
result.status === 'running' ? 202 : 200,
|
|
62
|
-
)
|
|
63
|
-
} catch (error) {
|
|
64
|
-
if (
|
|
65
|
-
error instanceof Error &&
|
|
66
|
-
error.message === '[bunderstack] cron slot does not match its schedule'
|
|
67
|
-
) {
|
|
68
|
-
return c.json({ error: 'invalid cron slot' }, 400)
|
|
69
|
-
}
|
|
70
|
-
return c.json({ error: 'cron handler failed' }, 500)
|
|
71
|
-
}
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
app.post('/maintenance/:name', async (c) => {
|
|
75
|
-
const name = c.req.param('name')
|
|
76
|
-
const slotText = c.req.header('X-Bunderstack-Cron-Slot')
|
|
77
|
-
const signature = c.req.header('X-Bunderstack-Cron-Signature')
|
|
78
|
-
const slot = Number(slotText)
|
|
79
|
-
if (!slotText || !Number.isSafeInteger(slot) || !signature) {
|
|
80
|
-
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
81
|
-
}
|
|
82
|
-
if (!verifyScheduleRequest(args.secret, `maintenance:${name}`, slot, signature)) {
|
|
83
|
-
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
84
|
-
}
|
|
85
|
-
if (name !== 'storage-sweep') {
|
|
86
|
-
return c.json({ error: 'unknown maintenance task' }, 404)
|
|
87
|
-
}
|
|
88
|
-
const current = now()
|
|
89
|
-
if (
|
|
90
|
-
slot % 60_000 !== 0 ||
|
|
91
|
-
slot < current - MAX_SLOT_AGE_MS ||
|
|
92
|
-
slot > current + MAX_FUTURE_SLOT_MS
|
|
93
|
-
) {
|
|
94
|
-
return c.json({ error: 'invalid cron slot' }, 400)
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
const result = await runScheduledSlot({
|
|
98
|
-
db: args.db,
|
|
99
|
-
taskId: 'maintenance:storage-sweep',
|
|
100
|
-
schedule: STORAGE_SWEEP_SCHEDULE,
|
|
101
|
-
slot,
|
|
102
|
-
now: current,
|
|
103
|
-
run: async () => {
|
|
104
|
-
await args.storage.sweep()
|
|
105
|
-
},
|
|
106
|
-
})
|
|
107
|
-
if (result.status === 'succeeded') {
|
|
108
|
-
const t = cronRunsTableFor(args.db)
|
|
109
|
-
await args.db
|
|
110
|
-
.delete(t)
|
|
111
|
-
.where(
|
|
112
|
-
and(
|
|
113
|
-
eq(t.status, 'succeeded'),
|
|
114
|
-
lt(t.finishedAt, current - SCHEDULED_RUN_RETENTION_MS),
|
|
115
|
-
),
|
|
116
|
-
)
|
|
117
|
-
}
|
|
118
|
-
return c.json(result, result.status === 'running' ? 202 : 200)
|
|
119
|
-
} catch (error) {
|
|
120
|
-
if (
|
|
121
|
-
error instanceof Error &&
|
|
122
|
-
error.message === '[bunderstack] cron slot does not match its schedule'
|
|
123
|
-
) {
|
|
124
|
-
return c.json({ error: 'invalid cron slot' }, 400)
|
|
125
|
-
}
|
|
126
|
-
return c.json({ error: 'maintenance handler failed' }, 500)
|
|
127
|
-
}
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
return app
|
|
131
|
-
}
|