bunderstack 0.15.2 → 0.17.0-beta.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 +25 -138
- package/package.json +22 -14
- package/src/access.ts +24 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +73 -77
- package/src/cron.ts +2 -1
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +28 -21
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +283 -294
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +75 -21
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +14 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +142 -42
- package/src/manifest.ts +84 -93
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -408
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
- package/src/realtime/index.ts +0 -250
- package/src/realtime/redis.ts +0 -228
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { getOpenAPIMeta } from '@orpc/openapi'
|
|
2
|
+
|
|
3
|
+
const RESERVED_EXACT = new Set([
|
|
4
|
+
'/api/health',
|
|
5
|
+
'/api/openapi.json',
|
|
6
|
+
'/api/realtime',
|
|
7
|
+
'/api/rpc',
|
|
8
|
+
'/api/auth',
|
|
9
|
+
'/api/files',
|
|
10
|
+
])
|
|
11
|
+
const RESERVED_PREFIXES = ['/api/rpc/', '/api/auth/', '/api/files/']
|
|
12
|
+
|
|
13
|
+
function collisionForBunderstackPath(path: string): string | undefined {
|
|
14
|
+
if (RESERVED_EXACT.has(path)) return 'it is reserved by bunderstack'
|
|
15
|
+
const prefix = RESERVED_PREFIXES.find((value) => path.startsWith(value))
|
|
16
|
+
return prefix ? `"${prefix}*" is reserved by bunderstack` : undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ApiRegistryEntry {
|
|
20
|
+
handle: string
|
|
21
|
+
operationId: string
|
|
22
|
+
method: string
|
|
23
|
+
path: string
|
|
24
|
+
source: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApiRegistry {
|
|
28
|
+
entries: ApiRegistryEntry[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ForeignSpecEntry {
|
|
32
|
+
spec: Record<string, unknown>
|
|
33
|
+
prefix?: string
|
|
34
|
+
source?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BuildApiRegistryOptions {
|
|
38
|
+
nativeRouter?: Record<string, unknown>
|
|
39
|
+
foreignSpecs?: Array<Record<string, unknown> | ForeignSpecEntry>
|
|
40
|
+
/** Native procedures generated by Bunderstack itself may own reserved paths. */
|
|
41
|
+
reservedCoreHandles?: ReadonlySet<string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const HTTP_METHODS = new Set([
|
|
45
|
+
'GET',
|
|
46
|
+
'POST',
|
|
47
|
+
'PUT',
|
|
48
|
+
'PATCH',
|
|
49
|
+
'DELETE',
|
|
50
|
+
'HEAD',
|
|
51
|
+
'OPTIONS',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
export function normalizeApiPath(path: string, prefix?: string): string {
|
|
55
|
+
let cleanPath = path.startsWith('/') ? path : '/' + path
|
|
56
|
+
if (prefix) {
|
|
57
|
+
let cleanPrefix = prefix.startsWith('/') ? prefix : '/' + prefix
|
|
58
|
+
if (cleanPrefix.endsWith('/')) {
|
|
59
|
+
cleanPrefix = cleanPrefix.slice(0, -1)
|
|
60
|
+
}
|
|
61
|
+
if (cleanPath === cleanPrefix || cleanPath.startsWith(cleanPrefix + '/')) {
|
|
62
|
+
// Path already starts with prefix
|
|
63
|
+
} else {
|
|
64
|
+
cleanPath = cleanPrefix + cleanPath
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return cleanPath.replace(/\/+/g, '/')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ForeignSpecOptions {
|
|
71
|
+
prefix?: string
|
|
72
|
+
source?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeForeignOpenAPISpec(
|
|
76
|
+
spec: Record<string, unknown>,
|
|
77
|
+
options: ForeignSpecOptions = {},
|
|
78
|
+
): Record<string, unknown> {
|
|
79
|
+
const prefix = options.prefix
|
|
80
|
+
const source = options.source
|
|
81
|
+
const cloned: Record<string, unknown> = JSON.parse(JSON.stringify(spec))
|
|
82
|
+
|
|
83
|
+
if (cloned.paths && typeof cloned.paths === 'object') {
|
|
84
|
+
const normalizedPaths: Record<string, unknown> = {}
|
|
85
|
+
for (const [routePath, pathItem] of Object.entries(
|
|
86
|
+
cloned.paths as Record<string, unknown>,
|
|
87
|
+
)) {
|
|
88
|
+
const canonicalPath = normalizeApiPath(routePath, prefix)
|
|
89
|
+
normalizedPaths[canonicalPath] = pathItem
|
|
90
|
+
}
|
|
91
|
+
cloned.paths = normalizedPaths
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (source) {
|
|
95
|
+
cloned['x-bunderstack-source'] = source
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return cloned
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function mergeApiRoutersStrict(
|
|
102
|
+
target: Record<string, unknown>,
|
|
103
|
+
source?: Record<string, unknown>,
|
|
104
|
+
prefix: string[] = [],
|
|
105
|
+
): Record<string, unknown> {
|
|
106
|
+
if (!source) return { ...target }
|
|
107
|
+
const result: Record<string, unknown> = { ...target }
|
|
108
|
+
|
|
109
|
+
for (const [key, val] of Object.entries(source)) {
|
|
110
|
+
const currentPath = [...prefix, key]
|
|
111
|
+
const handle = currentPath.join('.')
|
|
112
|
+
const targetVal = result[key]
|
|
113
|
+
|
|
114
|
+
if (targetVal !== undefined) {
|
|
115
|
+
const isTargetNamespace =
|
|
116
|
+
typeof targetVal === 'object' &&
|
|
117
|
+
targetVal !== null &&
|
|
118
|
+
!('~orpc' in targetVal)
|
|
119
|
+
|
|
120
|
+
const isSourceNamespace =
|
|
121
|
+
typeof val === 'object' &&
|
|
122
|
+
val !== null &&
|
|
123
|
+
!('~orpc' in val)
|
|
124
|
+
|
|
125
|
+
if (isTargetNamespace && isSourceNamespace) {
|
|
126
|
+
result[key] = mergeApiRoutersStrict(
|
|
127
|
+
targetVal as Record<string, unknown>,
|
|
128
|
+
val as Record<string, unknown>,
|
|
129
|
+
currentPath,
|
|
130
|
+
)
|
|
131
|
+
} else {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[bunderstack] Router merge collision at handle "${handle}": duplicate procedure or namespace collision`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
result[key] = val
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizePathSegments(path: string): string[] {
|
|
145
|
+
return path.split('/').filter(Boolean)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isParamSegment(segment: string): boolean {
|
|
149
|
+
return (
|
|
150
|
+
(segment.startsWith('{') && segment.endsWith('}')) ||
|
|
151
|
+
segment.startsWith(':')
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizePathForCollision(path: string): string {
|
|
156
|
+
const segments = normalizePathSegments(path)
|
|
157
|
+
const normalized = segments.map((seg) =>
|
|
158
|
+
isParamSegment(seg) ? '{param}' : seg,
|
|
159
|
+
)
|
|
160
|
+
return '/' + normalized.join('/')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function walkNativeRouter(
|
|
164
|
+
obj: Record<string, unknown>,
|
|
165
|
+
pathSegments: string[] = [],
|
|
166
|
+
): ApiRegistryEntry[] {
|
|
167
|
+
const entries: ApiRegistryEntry[] = []
|
|
168
|
+
|
|
169
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
170
|
+
if (!value || typeof value !== 'object') continue
|
|
171
|
+
const currentSegments = [...pathSegments, key]
|
|
172
|
+
|
|
173
|
+
if ('~orpc' in value) {
|
|
174
|
+
const meta = (getOpenAPIMeta(value as any) || {}) as Record<string, unknown>
|
|
175
|
+
const handle = currentSegments.join('.')
|
|
176
|
+
const method = ((meta.method as string) || 'GET').toUpperCase()
|
|
177
|
+
const routePath = (meta.path as string) || '/' + currentSegments.join('/')
|
|
178
|
+
const operationId =
|
|
179
|
+
typeof meta.operationId === 'string' ? meta.operationId : handle
|
|
180
|
+
|
|
181
|
+
entries.push({
|
|
182
|
+
handle,
|
|
183
|
+
operationId,
|
|
184
|
+
method,
|
|
185
|
+
path: routePath,
|
|
186
|
+
source: 'native',
|
|
187
|
+
})
|
|
188
|
+
} else {
|
|
189
|
+
entries.push(
|
|
190
|
+
...walkNativeRouter(
|
|
191
|
+
value as Record<string, unknown>,
|
|
192
|
+
currentSegments,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return entries
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function extractForeignEntries(
|
|
202
|
+
specs: Array<Record<string, unknown> | ForeignSpecEntry>,
|
|
203
|
+
): ApiRegistryEntry[] {
|
|
204
|
+
const entries: ApiRegistryEntry[] = []
|
|
205
|
+
|
|
206
|
+
for (const item of specs) {
|
|
207
|
+
if (!item) continue
|
|
208
|
+
let specObj: Record<string, unknown>
|
|
209
|
+
let source = 'foreign'
|
|
210
|
+
|
|
211
|
+
if ('spec' in item && typeof item.spec === 'object' && item.spec !== null) {
|
|
212
|
+
const entry = item as ForeignSpecEntry
|
|
213
|
+
source = entry.source || 'foreign'
|
|
214
|
+
specObj = normalizeForeignOpenAPISpec(entry.spec, {
|
|
215
|
+
prefix: entry.prefix,
|
|
216
|
+
source,
|
|
217
|
+
})
|
|
218
|
+
} else {
|
|
219
|
+
const rawSpec = item as Record<string, unknown>
|
|
220
|
+
source = (rawSpec['x-bunderstack-source'] as string) || 'foreign'
|
|
221
|
+
specObj = normalizeForeignOpenAPISpec(rawSpec, { source })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!specObj || typeof specObj.paths !== 'object' || !specObj.paths) continue
|
|
225
|
+
const paths = specObj.paths as Record<string, Record<string, unknown>>
|
|
226
|
+
|
|
227
|
+
for (const [routePath, pathItem] of Object.entries(paths)) {
|
|
228
|
+
if (!pathItem || typeof pathItem !== 'object') continue
|
|
229
|
+
|
|
230
|
+
for (const [methodKey, operation] of Object.entries(pathItem)) {
|
|
231
|
+
const uppercaseMethod = methodKey.toUpperCase()
|
|
232
|
+
if (!HTTP_METHODS.has(uppercaseMethod)) continue
|
|
233
|
+
if (!operation || typeof operation !== 'object') continue
|
|
234
|
+
|
|
235
|
+
const op = operation as Record<string, unknown>
|
|
236
|
+
const handle = `foreign:${source}:${uppercaseMethod}:${routePath}`
|
|
237
|
+
const operationId =
|
|
238
|
+
typeof op.operationId === 'string'
|
|
239
|
+
? op.operationId
|
|
240
|
+
: handle
|
|
241
|
+
|
|
242
|
+
entries.push({
|
|
243
|
+
handle,
|
|
244
|
+
operationId,
|
|
245
|
+
method: uppercaseMethod,
|
|
246
|
+
path: routePath,
|
|
247
|
+
source,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return entries
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function buildApiRegistry(
|
|
257
|
+
options: BuildApiRegistryOptions = {},
|
|
258
|
+
): Promise<ApiRegistry> {
|
|
259
|
+
const entries: ApiRegistryEntry[] = []
|
|
260
|
+
|
|
261
|
+
if (options.nativeRouter) {
|
|
262
|
+
entries.push(...walkNativeRouter(options.nativeRouter))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (options.foreignSpecs) {
|
|
266
|
+
entries.push(...extractForeignEntries(options.foreignSpecs))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const errors: string[] = []
|
|
270
|
+
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (entry.source !== 'native') continue
|
|
273
|
+
if (options.reservedCoreHandles?.has(entry.handle)) continue
|
|
274
|
+
const reason = collisionForBunderstackPath(entry.path)
|
|
275
|
+
if (reason) {
|
|
276
|
+
errors.push(
|
|
277
|
+
`Reserved route collision on ${entry.method} ${entry.path}: native handle "${entry.handle}" conflicts because ${reason}`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Check duplicate handles
|
|
283
|
+
const handleMap = new Map<string, ApiRegistryEntry>()
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
const existing = handleMap.get(entry.handle)
|
|
286
|
+
if (existing) {
|
|
287
|
+
errors.push(
|
|
288
|
+
`Duplicate handle "${entry.handle}": collision between ${existing.source} (${existing.method} ${existing.path}) and ${entry.source} (${entry.method} ${entry.path})`,
|
|
289
|
+
)
|
|
290
|
+
} else {
|
|
291
|
+
handleMap.set(entry.handle, entry)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Check duplicate operation IDs
|
|
296
|
+
const opIdMap = new Map<string, ApiRegistryEntry>()
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
const existing = opIdMap.get(entry.operationId)
|
|
299
|
+
if (existing) {
|
|
300
|
+
errors.push(
|
|
301
|
+
`Duplicate operation ID "${entry.operationId}": collision between ${existing.source} (${existing.handle}) and ${entry.source} (${entry.handle})`,
|
|
302
|
+
)
|
|
303
|
+
} else {
|
|
304
|
+
opIdMap.set(entry.operationId, entry)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check method/path collisions (exact and parameter ambiguity)
|
|
309
|
+
const routePatternMap = new Map<string, ApiRegistryEntry>()
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
const normalizedPath = normalizePathForCollision(entry.path)
|
|
312
|
+
const routeKey = `${entry.method} ${normalizedPath}`
|
|
313
|
+
|
|
314
|
+
const existing = routePatternMap.get(routeKey)
|
|
315
|
+
if (existing) {
|
|
316
|
+
if (existing.path === entry.path) {
|
|
317
|
+
errors.push(
|
|
318
|
+
`Exact method/path collision on ${entry.method} ${entry.path}: collision between ${existing.source} (${existing.handle}) and ${entry.source} (${entry.handle})`,
|
|
319
|
+
)
|
|
320
|
+
} else {
|
|
321
|
+
errors.push(
|
|
322
|
+
`Ambiguous parameter path collision on ${entry.method} (${existing.path} vs ${entry.path}): collision between ${existing.source} (${existing.handle}) and ${entry.source} (${entry.handle})`,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
routePatternMap.set(routeKey, entry)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (errors.length > 0) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`[bunderstack] Route registry validation failed (${errors.length} error(s)):\n` +
|
|
333
|
+
errors.join('\n'),
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { entries }
|
|
338
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import '@orpc/openapi/extensions/route'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
|
|
4
|
+
import { createApiBuilder } from './builder'
|
|
5
|
+
import { mergeApiRoutersStrict } from './registry'
|
|
6
|
+
|
|
7
|
+
export interface BuildApiRouterOptions {
|
|
8
|
+
crud: Record<string, unknown>
|
|
9
|
+
storage: Record<string, unknown>
|
|
10
|
+
realtime?: Record<string, unknown>
|
|
11
|
+
custom?: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildApiRouter(options: BuildApiRouterOptions) {
|
|
15
|
+
const builder = createApiBuilder<
|
|
16
|
+
Record<string, unknown>,
|
|
17
|
+
Record<string, unknown>
|
|
18
|
+
>()
|
|
19
|
+
const health = builder.public
|
|
20
|
+
.route({ method: 'GET', path: '/api/health', tags: ['system'] })
|
|
21
|
+
.output(v.strictObject({ status: v.literal('ok') }))
|
|
22
|
+
.handler(() => ({ status: 'ok' as const }))
|
|
23
|
+
|
|
24
|
+
return [
|
|
25
|
+
{ health },
|
|
26
|
+
options.crud,
|
|
27
|
+
options.storage,
|
|
28
|
+
options.realtime,
|
|
29
|
+
options.custom,
|
|
30
|
+
].reduce<Record<string, unknown>>(
|
|
31
|
+
(router, addition) => mergeApiRoutersStrict(router, addition),
|
|
32
|
+
{},
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import '@orpc/openapi/extensions/route'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
StorageExecutionContext,
|
|
6
|
+
StorageOperations,
|
|
7
|
+
} from '../storage/operations'
|
|
8
|
+
import type { BucketStorageRegistry } from '../storage/registry'
|
|
9
|
+
import type { ApiContext } from './context'
|
|
10
|
+
|
|
11
|
+
import { createApiBuilder } from './builder'
|
|
12
|
+
|
|
13
|
+
const uploadResultSchema = v.strictObject({
|
|
14
|
+
fileId: v.string(),
|
|
15
|
+
url: v.string(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const prepareUploadSchema = v.variant('mode', [
|
|
19
|
+
v.strictObject({
|
|
20
|
+
mode: v.literal('proxy'),
|
|
21
|
+
uploadUrl: v.string(),
|
|
22
|
+
}),
|
|
23
|
+
v.strictObject({
|
|
24
|
+
mode: v.literal('presign'),
|
|
25
|
+
fileId: v.string(),
|
|
26
|
+
uploadUrl: v.string(),
|
|
27
|
+
method: v.literal('PUT'),
|
|
28
|
+
confirmUrl: v.string(),
|
|
29
|
+
}),
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
const responseHeadersSchema = v.record(
|
|
33
|
+
v.string(),
|
|
34
|
+
v.union([v.string(), v.array(v.string()), v.undefined()]),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async function executionContext(
|
|
38
|
+
context: ApiContext<Record<string, unknown>, Record<string, unknown>>,
|
|
39
|
+
): Promise<StorageExecutionContext> {
|
|
40
|
+
const session = await context.getSession()
|
|
41
|
+
return {
|
|
42
|
+
request: context.request,
|
|
43
|
+
user: session.user,
|
|
44
|
+
session: { activeOrganizationId: session.activeOrganizationId },
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function headersRecord(headers: Headers): Record<string, string> {
|
|
49
|
+
const result: Record<string, string> = {}
|
|
50
|
+
headers.forEach((value, key) => {
|
|
51
|
+
result[key] = value
|
|
52
|
+
})
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildBucketProcedures(
|
|
57
|
+
name: string,
|
|
58
|
+
operations: StorageOperations,
|
|
59
|
+
builder: ReturnType<
|
|
60
|
+
typeof createApiBuilder<Record<string, unknown>, Record<string, unknown>>
|
|
61
|
+
>,
|
|
62
|
+
) {
|
|
63
|
+
const prepareUpload = builder.public
|
|
64
|
+
.route({
|
|
65
|
+
method: 'POST',
|
|
66
|
+
path: `/api/files/${name}/presign`,
|
|
67
|
+
summary: `Prepare upload to ${name}`,
|
|
68
|
+
tags: ['files'],
|
|
69
|
+
})
|
|
70
|
+
.input(
|
|
71
|
+
v.strictObject({
|
|
72
|
+
filename: v.optional(v.string()),
|
|
73
|
+
contentType: v.optional(v.string()),
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
.output(prepareUploadSchema)
|
|
77
|
+
.handler(({ input, context }) =>
|
|
78
|
+
executionContext(context).then((exec) =>
|
|
79
|
+
operations.prepareUpload(name, input, exec),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const upload = builder.public
|
|
84
|
+
.route({
|
|
85
|
+
method: 'POST',
|
|
86
|
+
path: `/api/files/${name}`,
|
|
87
|
+
summary: `Upload to ${name}`,
|
|
88
|
+
tags: ['files'],
|
|
89
|
+
inputStructure: 'detailed',
|
|
90
|
+
outputStructure: 'detailed',
|
|
91
|
+
requestBodyHint: 'form-data',
|
|
92
|
+
})
|
|
93
|
+
.input(
|
|
94
|
+
v.strictObject({
|
|
95
|
+
params: v.optional(v.strictObject({}), {}),
|
|
96
|
+
query: v.optional(v.record(v.string(), v.unknown()), {}),
|
|
97
|
+
headers: v.optional(v.record(v.string(), v.unknown()), {}),
|
|
98
|
+
body: v.strictObject({ file: v.file() }),
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
.output(
|
|
102
|
+
v.strictObject({
|
|
103
|
+
status: v.literal(201),
|
|
104
|
+
body: uploadResultSchema,
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
.handler(async ({ input, context }) => {
|
|
108
|
+
const result = await operations.upload(
|
|
109
|
+
name,
|
|
110
|
+
input.body.file,
|
|
111
|
+
await executionContext(context),
|
|
112
|
+
)
|
|
113
|
+
return {
|
|
114
|
+
status: result.status,
|
|
115
|
+
body: { fileId: result.fileId, url: result.url },
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const confirmUpload = builder.public
|
|
120
|
+
.route({
|
|
121
|
+
method: 'POST',
|
|
122
|
+
path: `/api/files/${name}/{id}/confirm`,
|
|
123
|
+
summary: `Confirm upload to ${name}`,
|
|
124
|
+
tags: ['files'],
|
|
125
|
+
})
|
|
126
|
+
.input(v.strictObject({ id: v.string() }))
|
|
127
|
+
.output(uploadResultSchema)
|
|
128
|
+
.handler(({ input, context }) =>
|
|
129
|
+
executionContext(context).then((exec) =>
|
|
130
|
+
operations.confirmUpload(name, input.id, exec),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const download = builder.public
|
|
135
|
+
.route({
|
|
136
|
+
method: 'GET',
|
|
137
|
+
path: `/api/files/${name}/{+path}`,
|
|
138
|
+
summary: `Download from ${name}`,
|
|
139
|
+
tags: ['files'],
|
|
140
|
+
inputStructure: 'detailed',
|
|
141
|
+
outputStructure: 'detailed',
|
|
142
|
+
})
|
|
143
|
+
.input(
|
|
144
|
+
v.strictObject({
|
|
145
|
+
params: v.strictObject({ path: v.string() }),
|
|
146
|
+
query: v.optional(v.record(v.string(), v.string()), {}),
|
|
147
|
+
headers: v.optional(v.record(v.string(), v.unknown()), {}),
|
|
148
|
+
body: v.optional(v.undefined()),
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
.output(
|
|
152
|
+
v.union([
|
|
153
|
+
v.strictObject({
|
|
154
|
+
status: v.literal(200),
|
|
155
|
+
headers: responseHeadersSchema,
|
|
156
|
+
body: v.unknown(),
|
|
157
|
+
}),
|
|
158
|
+
v.strictObject({
|
|
159
|
+
status: v.literal(302),
|
|
160
|
+
headers: responseHeadersSchema,
|
|
161
|
+
body: v.optional(v.undefined()),
|
|
162
|
+
}),
|
|
163
|
+
]),
|
|
164
|
+
)
|
|
165
|
+
.handler(async ({ input, context }) => {
|
|
166
|
+
const result = await operations.download(
|
|
167
|
+
name,
|
|
168
|
+
input.params.path,
|
|
169
|
+
input.query,
|
|
170
|
+
await executionContext(context),
|
|
171
|
+
)
|
|
172
|
+
if (result.kind === 'redirect') {
|
|
173
|
+
return {
|
|
174
|
+
status: 302 as const,
|
|
175
|
+
headers: { Location: result.url },
|
|
176
|
+
body: undefined,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
status: 200 as const,
|
|
181
|
+
headers: headersRecord(result.headers),
|
|
182
|
+
body: result.body,
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const deleteFile = builder.public
|
|
187
|
+
.route({
|
|
188
|
+
method: 'DELETE',
|
|
189
|
+
path: `/api/files/${name}/{+path}`,
|
|
190
|
+
summary: `Delete from ${name}`,
|
|
191
|
+
tags: ['files'],
|
|
192
|
+
successStatus: 204,
|
|
193
|
+
})
|
|
194
|
+
.input(v.strictObject({ path: v.string() }))
|
|
195
|
+
.output(v.undefined())
|
|
196
|
+
.handler(async ({ input, context }) => {
|
|
197
|
+
await operations.delete(
|
|
198
|
+
name,
|
|
199
|
+
input.path,
|
|
200
|
+
await executionContext(context),
|
|
201
|
+
)
|
|
202
|
+
return undefined
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return { prepareUpload, upload, confirmUpload, download, delete: deleteFile }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildStorageApiRouter(
|
|
209
|
+
registry: BucketStorageRegistry,
|
|
210
|
+
operations: StorageOperations,
|
|
211
|
+
) {
|
|
212
|
+
const builder = createApiBuilder<
|
|
213
|
+
Record<string, unknown>,
|
|
214
|
+
Record<string, unknown>
|
|
215
|
+
>()
|
|
216
|
+
return {
|
|
217
|
+
files: Object.fromEntries(
|
|
218
|
+
[...registry.keys()].map((name) => [
|
|
219
|
+
name,
|
|
220
|
+
buildBucketProcedures(name, operations, builder),
|
|
221
|
+
]),
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AnyRouter } from '@orpc/server'
|
|
2
|
+
import type { Table } from 'drizzle-orm'
|
|
3
|
+
|
|
4
|
+
import type { AccessUser } from '../access'
|
|
5
|
+
import type { TableCrudProcedures } from './crud-router'
|
|
6
|
+
|
|
7
|
+
export interface ProtectedContextAdditions {
|
|
8
|
+
user: AccessUser
|
|
9
|
+
session: {
|
|
10
|
+
activeOrganizationId: string | null
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type AuthTableName = 'user' | 'session' | 'account' | 'verification'
|
|
15
|
+
type InferSelect<T> = T extends { $inferSelect: infer R } ? R : never
|
|
16
|
+
|
|
17
|
+
type DisabledKeys<TAccess> = {
|
|
18
|
+
[K in keyof TAccess & string]: TAccess[K] extends { crud: false } ? K : never
|
|
19
|
+
}[keyof TAccess & string]
|
|
20
|
+
|
|
21
|
+
type ExplicitKeys<TSchema, TAccess> = {
|
|
22
|
+
[K in keyof TAccess & keyof TSchema & string]: TAccess[K] extends {
|
|
23
|
+
crud: false
|
|
24
|
+
}
|
|
25
|
+
? never
|
|
26
|
+
: K extends AuthTableName
|
|
27
|
+
? TAccess[K] extends { exposeAuthTable: true }
|
|
28
|
+
? K extends 'user'
|
|
29
|
+
? K
|
|
30
|
+
: never
|
|
31
|
+
: never
|
|
32
|
+
: K
|
|
33
|
+
}[keyof TAccess & keyof TSchema & string]
|
|
34
|
+
|
|
35
|
+
type ConventionKeys<TSchema> = {
|
|
36
|
+
[K in keyof TSchema & string]: K extends AuthTableName
|
|
37
|
+
? never
|
|
38
|
+
: InferSelect<TSchema[K]> extends { userId: unknown }
|
|
39
|
+
? K
|
|
40
|
+
: never
|
|
41
|
+
}[keyof TSchema & string]
|
|
42
|
+
|
|
43
|
+
export type ExposedApiTables<TSchema, TAccess> = [TAccess] extends [undefined]
|
|
44
|
+
? ConventionKeys<TSchema>
|
|
45
|
+
:
|
|
46
|
+
| ExplicitKeys<TSchema, TAccess>
|
|
47
|
+
| Exclude<
|
|
48
|
+
ConventionKeys<TSchema>,
|
|
49
|
+
DisabledKeys<TAccess> | (keyof TAccess & string)
|
|
50
|
+
>
|
|
51
|
+
|
|
52
|
+
export type CrudApiRouterFor<
|
|
53
|
+
TSchema extends Record<string, unknown>,
|
|
54
|
+
TAccess = undefined,
|
|
55
|
+
> = {
|
|
56
|
+
[K in ExposedApiTables<TSchema, TAccess> as TSchema[K] extends Table
|
|
57
|
+
? K
|
|
58
|
+
: never]: TableCrudProcedures<Extract<TSchema[K], Table>>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type IsProcedure<T> = T extends { '~orpc': unknown } ? true : false
|
|
62
|
+
|
|
63
|
+
export type MergeApiRouterTypes<A, B> = {
|
|
64
|
+
[K in keyof A | keyof B]: K extends keyof A
|
|
65
|
+
? K extends keyof B
|
|
66
|
+
? IsProcedure<A[K]> extends true
|
|
67
|
+
? never
|
|
68
|
+
: IsProcedure<B[K]> extends true
|
|
69
|
+
? never
|
|
70
|
+
: A[K] extends Record<string, unknown>
|
|
71
|
+
? B[K] extends Record<string, unknown>
|
|
72
|
+
? MergeApiRouterTypes<A[K], B[K]>
|
|
73
|
+
: never
|
|
74
|
+
: never
|
|
75
|
+
: A[K]
|
|
76
|
+
: K extends keyof B
|
|
77
|
+
? B[K]
|
|
78
|
+
: never
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type UnifiedApiRouter<
|
|
82
|
+
TCrud extends AnyRouter,
|
|
83
|
+
TCustom extends AnyRouter | undefined,
|
|
84
|
+
> = TCustom extends AnyRouter ? MergeApiRouterTypes<TCrud, TCustom> : TCrud
|
package/src/auth.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/auth.ts
|
|
2
2
|
import { betterAuth } from 'better-auth'
|
|
3
3
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
4
|
+
import { openAPI } from 'better-auth/plugins'
|
|
4
5
|
|
|
5
6
|
import type { AuthSessionResolver } from './access'
|
|
6
7
|
import type { BetterAuthConfig } from './config'
|
|
@@ -13,8 +14,12 @@ export function createAuth(
|
|
|
13
14
|
dialect: Dialect,
|
|
14
15
|
userSchema?: Record<string, unknown>,
|
|
15
16
|
) {
|
|
17
|
+
const hasOpenApi = cfg.plugins?.some((p: any) => p.id === 'open-api')
|
|
18
|
+
const plugins = hasOpenApi ? cfg.plugins : [...(cfg.plugins || []), openAPI()]
|
|
19
|
+
|
|
16
20
|
return betterAuth({
|
|
17
21
|
...cfg,
|
|
22
|
+
plugins,
|
|
18
23
|
database: drizzleAdapter(db as Parameters<typeof drizzleAdapter>[0], {
|
|
19
24
|
provider: dialect === 'pg' ? 'pg' : 'sqlite',
|
|
20
25
|
...(userSchema ? { schema: userSchema } : {}),
|