bunderstack 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/package.json +69 -0
- package/src/access.ts +516 -0
- package/src/auth.ts +102 -0
- package/src/config.ts +152 -0
- package/src/crud.ts +389 -0
- package/src/db.ts +10 -0
- package/src/email.ts +200 -0
- package/src/env.ts +153 -0
- package/src/errors.ts +51 -0
- package/src/handler.ts +53 -0
- package/src/idempotency.ts +102 -0
- package/src/index.ts +368 -0
- package/src/internal-tables.ts +87 -0
- package/src/list-query.ts +413 -0
- package/src/provision.ts +46 -0
- package/src/rate-limit.ts +71 -0
- package/src/realtime/index.ts +245 -0
- package/src/realtime/redis.ts +204 -0
- package/src/schema-export.ts +4 -0
- package/src/scope.ts +17 -0
- package/src/storage/buckets.ts +273 -0
- package/src/storage/delete.ts +27 -0
- package/src/storage/file-meta.ts +224 -0
- package/src/storage/index.ts +31 -0
- package/src/storage/local.ts +69 -0
- package/src/storage/registry.ts +40 -0
- package/src/storage/router.ts +532 -0
- package/src/storage/s3.ts +93 -0
- package/src/storage/sweep.ts +26 -0
- package/src/storage/thumbnails.ts +65 -0
- package/src/trpc.ts +52 -0
- package/src/typeid.ts +116 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kirill Chuprov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# bunderstack
|
|
2
|
+
|
|
3
|
+
A batteries-included backend framework for Bun. Point it at a Drizzle schema
|
|
4
|
+
and get CRUD APIs, auth, file storage, realtime, typed custom endpoints
|
|
5
|
+
(tRPC), email, and validated env — all from a single config object and a
|
|
6
|
+
single `Request → Response` handler.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add bunderstack
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createBunderstack } from 'bunderstack'
|
|
14
|
+
import * as schema from './schema'
|
|
15
|
+
|
|
16
|
+
const app = createBunderstack({
|
|
17
|
+
schema,
|
|
18
|
+
auth: { emailAndPassword: { enabled: true } },
|
|
19
|
+
access: {
|
|
20
|
+
posts: { ownerColumn: 'userId', list: 'public', create: 'authenticated' },
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
Bun.serve({ fetch: app.handler })
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Full documentation and examples:
|
|
28
|
+
[github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
|
|
29
|
+
|
|
30
|
+
## Shipping TypeScript source
|
|
31
|
+
|
|
32
|
+
This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
|
|
33
|
+
consumes it natively. If a Node-based bundler or SSR server processes it,
|
|
34
|
+
make sure the package is bundled rather than externalized — e.g. in Vite:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
ssr: {
|
|
38
|
+
noExternal: [/^bunderstack/]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bunderstack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"backend",
|
|
7
|
+
"better-auth",
|
|
8
|
+
"bun",
|
|
9
|
+
"crud",
|
|
10
|
+
"drizzle",
|
|
11
|
+
"framework",
|
|
12
|
+
"hono",
|
|
13
|
+
"realtime",
|
|
14
|
+
"trpc"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/kirill-dev-pro/bunderstack#readme",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/kirill-dev-pro/bunderstack.git",
|
|
21
|
+
"directory": "packages/bunderstack"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "./src/index.ts",
|
|
31
|
+
"module": "./src/index.ts",
|
|
32
|
+
"types": "./src/index.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./src/index.ts",
|
|
35
|
+
"./access": "./src/access.ts",
|
|
36
|
+
"./schema": "./src/schema-export.ts",
|
|
37
|
+
"./typeid": "./src/typeid.ts",
|
|
38
|
+
"./env": "./src/env.ts",
|
|
39
|
+
"./trpc": "./src/trpc.ts"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"dev": "bun --hot ../../examples/standalone/server.ts",
|
|
44
|
+
"db:push": "drizzle-kit push",
|
|
45
|
+
"db:migrate": "drizzle-kit migrate"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@libsql/client": "^0.14.0",
|
|
49
|
+
"@trpc/server": "^11.0.0",
|
|
50
|
+
"better-auth": "^1.0.0",
|
|
51
|
+
"drizzle-orm": "^0.45.0",
|
|
52
|
+
"hono": "^4.0.0",
|
|
53
|
+
"superjson": "^2.2.0",
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"drizzle-kit": "^0.30.0",
|
|
58
|
+
"nodemailer": "^6",
|
|
59
|
+
"typescript": "^5"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"drizzle-kit": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"nodemailer": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { getTableColumns, getTableName, isTable } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { INTERNAL_TABLE_NAMES } from './internal-tables'
|
|
4
|
+
|
|
5
|
+
export const AUTH_TABLE_NAMES = new Set([
|
|
6
|
+
'user',
|
|
7
|
+
'session',
|
|
8
|
+
'account',
|
|
9
|
+
'verification',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
const EXPOSEABLE_AUTH_TABLES = new Set(['user'])
|
|
13
|
+
|
|
14
|
+
export type AccessUser = {
|
|
15
|
+
id: string
|
|
16
|
+
email: string
|
|
17
|
+
name?: string
|
|
18
|
+
role?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AccessContext = {
|
|
22
|
+
user: AccessUser | null
|
|
23
|
+
request: Request
|
|
24
|
+
row?: Record<string, unknown>
|
|
25
|
+
body?: Record<string, unknown>
|
|
26
|
+
session?: { activeOrganizationId: string | null } | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type OperationRule =
|
|
30
|
+
| 'public'
|
|
31
|
+
| 'authenticated'
|
|
32
|
+
| 'owner'
|
|
33
|
+
| 'deny'
|
|
34
|
+
| ((ctx: AccessContext) => boolean | Promise<boolean>)
|
|
35
|
+
|
|
36
|
+
export type ScopeMap = Record<string, string | string[]>
|
|
37
|
+
export type ScopeResolver = (ctx: AccessContext) => ScopeMap
|
|
38
|
+
|
|
39
|
+
export type CrudOperation = 'list' | 'get' | 'create' | 'update' | 'delete'
|
|
40
|
+
|
|
41
|
+
const RESERVED_LIST_PARAMS = new Set([
|
|
42
|
+
'limit',
|
|
43
|
+
'offset',
|
|
44
|
+
'sort',
|
|
45
|
+
'order',
|
|
46
|
+
'q',
|
|
47
|
+
'cursor',
|
|
48
|
+
'count',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
export type SortOrder = 'asc' | 'desc'
|
|
52
|
+
|
|
53
|
+
export type DefaultSort = {
|
|
54
|
+
column: string
|
|
55
|
+
order: SortOrder
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type TableAccessInput = {
|
|
59
|
+
crud?: boolean
|
|
60
|
+
/** Opt the BetterAuth `user` table into auto-CRUD (read profiles, owner-update image). */
|
|
61
|
+
exposeAuthTable?: boolean
|
|
62
|
+
ownerColumn?: string
|
|
63
|
+
list?: OperationRule
|
|
64
|
+
get?: OperationRule
|
|
65
|
+
create?: OperationRule
|
|
66
|
+
update?: OperationRule
|
|
67
|
+
delete?: OperationRule
|
|
68
|
+
writableColumns?: string[]
|
|
69
|
+
readonlyColumns?: string[]
|
|
70
|
+
/** Columns matched by `?q=` on list — opt-in; omitted columns are never searched. */
|
|
71
|
+
searchableColumns?: string[]
|
|
72
|
+
/** Columns filterable via flat `?column=value` query params. */
|
|
73
|
+
filterableColumns?: string[]
|
|
74
|
+
/** Columns allowed in `?sort=`. Defaults to `['id']`. */
|
|
75
|
+
sortableColumns?: string[]
|
|
76
|
+
/** Default list ordering when `?sort` is omitted. Defaults to `{ column: 'id', order: 'desc' }`. */
|
|
77
|
+
defaultSort?: DefaultSort
|
|
78
|
+
scope?: ScopeResolver
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type ResolvedTableAccess = {
|
|
82
|
+
tableKey: string
|
|
83
|
+
tableName: string
|
|
84
|
+
enabled: boolean
|
|
85
|
+
ownerColumn?: string
|
|
86
|
+
list: OperationRule
|
|
87
|
+
get: OperationRule
|
|
88
|
+
create: OperationRule
|
|
89
|
+
update: OperationRule
|
|
90
|
+
delete: OperationRule
|
|
91
|
+
writableColumns?: string[]
|
|
92
|
+
readonlyColumns: string[]
|
|
93
|
+
searchableColumns?: string[]
|
|
94
|
+
filterableColumns: string[]
|
|
95
|
+
sortableColumns: string[]
|
|
96
|
+
defaultSort: DefaultSort
|
|
97
|
+
scope?: ScopeResolver
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ResolvedAccess = Map<string, ResolvedTableAccess>
|
|
101
|
+
|
|
102
|
+
const DEFAULT_READONLY = [
|
|
103
|
+
'id',
|
|
104
|
+
'createdAt',
|
|
105
|
+
'updatedAt',
|
|
106
|
+
'created_at',
|
|
107
|
+
'updated_at',
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
function getSchemaTables<TSchema extends Record<string, unknown>>(
|
|
111
|
+
schema: TSchema,
|
|
112
|
+
) {
|
|
113
|
+
const tables: {
|
|
114
|
+
key: string
|
|
115
|
+
table: Parameters<typeof getTableName>[0]
|
|
116
|
+
name: string
|
|
117
|
+
columns: string[]
|
|
118
|
+
}[] = []
|
|
119
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
120
|
+
if (!isTable(value)) continue
|
|
121
|
+
const name = getTableName(value)
|
|
122
|
+
const columns = Object.keys(getTableColumns(value))
|
|
123
|
+
tables.push({ key, table: value, name, columns })
|
|
124
|
+
}
|
|
125
|
+
return tables
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveListAccess(
|
|
129
|
+
input: TableAccessInput,
|
|
130
|
+
columns: string[],
|
|
131
|
+
): Pick<
|
|
132
|
+
ResolvedTableAccess,
|
|
133
|
+
'filterableColumns' | 'sortableColumns' | 'defaultSort'
|
|
134
|
+
> {
|
|
135
|
+
const sortableColumns =
|
|
136
|
+
input.sortableColumns ?? (columns.includes('id') ? ['id'] : [])
|
|
137
|
+
const defaultSort: DefaultSort = input.defaultSort ?? {
|
|
138
|
+
column: sortableColumns[0] ?? 'id',
|
|
139
|
+
order: 'desc',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!columns.includes(defaultSort.column)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[bunderstack] defaultSort.column "${defaultSort.column}" is not a column on this table`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (!sortableColumns.includes(defaultSort.column)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[bunderstack] defaultSort.column "${defaultSort.column}" must be listed in sortableColumns`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const filterableColumns = input.filterableColumns ?? []
|
|
154
|
+
for (const col of filterableColumns) {
|
|
155
|
+
if (RESERVED_LIST_PARAMS.has(col)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`[bunderstack] filterableColumns cannot include reserved query param "${col}"`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
if (!columns.includes(col)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`[bunderstack] filterableColumns references unknown column "${col}"`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const col of sortableColumns) {
|
|
168
|
+
if (RESERVED_LIST_PARAMS.has(col)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`[bunderstack] sortableColumns cannot include reserved query param "${col}"`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
if (!columns.includes(col)) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`[bunderstack] sortableColumns references unknown column "${col}"`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { filterableColumns, sortableColumns, defaultSort }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveDefaults(
|
|
184
|
+
input: TableAccessInput,
|
|
185
|
+
ownerColumn: string | undefined,
|
|
186
|
+
columns: string[],
|
|
187
|
+
): Omit<ResolvedTableAccess, 'tableKey' | 'tableName' | 'enabled'> {
|
|
188
|
+
const listAccess = resolveListAccess(input, columns)
|
|
189
|
+
return {
|
|
190
|
+
ownerColumn,
|
|
191
|
+
list: input.list ?? 'public',
|
|
192
|
+
get: input.get ?? 'public',
|
|
193
|
+
create: input.create ?? 'public',
|
|
194
|
+
update: input.update ?? (ownerColumn ? 'owner' : 'deny'),
|
|
195
|
+
delete: input.delete ?? (ownerColumn ? 'owner' : 'deny'),
|
|
196
|
+
writableColumns: input.writableColumns,
|
|
197
|
+
readonlyColumns: [
|
|
198
|
+
...DEFAULT_READONLY,
|
|
199
|
+
...(input.readonlyColumns ?? []),
|
|
200
|
+
...(ownerColumn ? [ownerColumn] : []),
|
|
201
|
+
],
|
|
202
|
+
searchableColumns: input.searchableColumns,
|
|
203
|
+
scope: input.scope,
|
|
204
|
+
...listAccess,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function detectOwnerColumn(
|
|
209
|
+
columns: string[],
|
|
210
|
+
input?: TableAccessInput,
|
|
211
|
+
): string | undefined {
|
|
212
|
+
if (input?.ownerColumn) return input.ownerColumn
|
|
213
|
+
if (columns.includes('userId')) return 'userId'
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function validateAndResolveAccess<
|
|
218
|
+
TSchema extends Record<string, unknown>,
|
|
219
|
+
>(
|
|
220
|
+
schema: TSchema,
|
|
221
|
+
accessInput?: Record<string, TableAccessInput>,
|
|
222
|
+
): ResolvedAccess {
|
|
223
|
+
const tables = getSchemaTables(schema)
|
|
224
|
+
const tableByKey = new Map(tables.map((t) => [t.key, t]))
|
|
225
|
+
const resolved: ResolvedAccess = new Map()
|
|
226
|
+
|
|
227
|
+
if (accessInput) {
|
|
228
|
+
for (const key of Object.keys(accessInput)) {
|
|
229
|
+
if (!tableByKey.has(key)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`[bunderstack] access.${key} does not match any table in schema`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
const tableName = tableByKey.get(key)!.name
|
|
235
|
+
const input = accessInput[key]
|
|
236
|
+
if (AUTH_TABLE_NAMES.has(tableName)) {
|
|
237
|
+
if (input?.crud === false) continue
|
|
238
|
+
if (!EXPOSEABLE_AUTH_TABLES.has(tableName) || !input?.exposeAuthTable) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`[bunderstack] access.${key} cannot target auth table "${tableName}" — use { crud: false } to silence, or exposeAuthTable on user`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const { key, name, columns } of tables) {
|
|
248
|
+
const input = accessInput?.[key]
|
|
249
|
+
if (input?.crud === false) continue
|
|
250
|
+
if (INTERNAL_TABLE_NAMES.has(name)) continue
|
|
251
|
+
|
|
252
|
+
if (AUTH_TABLE_NAMES.has(name)) {
|
|
253
|
+
if (!input?.exposeAuthTable || !EXPOSEABLE_AUTH_TABLES.has(name)) continue
|
|
254
|
+
|
|
255
|
+
const ownerColumn = input.ownerColumn ?? 'id'
|
|
256
|
+
if (!columns.includes(ownerColumn)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`[bunderstack] access.${key}.ownerColumn "${ownerColumn}" is not a column on table "${name}"`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const defaults = resolveDefaults(
|
|
263
|
+
{
|
|
264
|
+
...input,
|
|
265
|
+
create: input.create ?? 'deny',
|
|
266
|
+
delete: input.delete ?? 'deny',
|
|
267
|
+
},
|
|
268
|
+
ownerColumn,
|
|
269
|
+
columns,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const existingReadonly = defaults.readonlyColumns.filter((col) =>
|
|
273
|
+
columns.includes(col),
|
|
274
|
+
)
|
|
275
|
+
const resolvedReadonly = [...new Set(existingReadonly)]
|
|
276
|
+
|
|
277
|
+
for (const col of [
|
|
278
|
+
...(defaults.writableColumns ?? []),
|
|
279
|
+
...(defaults.searchableColumns ?? []),
|
|
280
|
+
...defaults.filterableColumns,
|
|
281
|
+
...defaults.sortableColumns,
|
|
282
|
+
...resolvedReadonly,
|
|
283
|
+
]) {
|
|
284
|
+
if (!columns.includes(col)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`[bunderstack] access.${key} references unknown column "${col}" on table "${name}"`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
resolved.set(key, {
|
|
292
|
+
tableKey: key,
|
|
293
|
+
tableName: name,
|
|
294
|
+
enabled: true,
|
|
295
|
+
...defaults,
|
|
296
|
+
readonlyColumns: resolvedReadonly,
|
|
297
|
+
})
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const ownerColumn = detectOwnerColumn(columns, input)
|
|
302
|
+
const hasExplicitRules = input !== undefined
|
|
303
|
+
const hasConventionOwner =
|
|
304
|
+
ownerColumn !== undefined &&
|
|
305
|
+
input?.ownerColumn === undefined &&
|
|
306
|
+
columns.includes('userId')
|
|
307
|
+
|
|
308
|
+
if (!hasExplicitRules && !hasConventionOwner) continue
|
|
309
|
+
if (!ownerColumn && input?.crud !== true && !input?.scope) continue
|
|
310
|
+
|
|
311
|
+
if (input?.ownerColumn && !columns.includes(input.ownerColumn)) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`[bunderstack] access.${key}.ownerColumn "${input.ownerColumn}" is not a column on table "${name}"`,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const defaults = resolveDefaults(input ?? {}, ownerColumn, columns)
|
|
318
|
+
const existingReadonly = defaults.readonlyColumns.filter((col) =>
|
|
319
|
+
columns.includes(col),
|
|
320
|
+
)
|
|
321
|
+
const resolvedReadonly = [...new Set(existingReadonly)]
|
|
322
|
+
|
|
323
|
+
for (const col of [
|
|
324
|
+
...(defaults.writableColumns ?? []),
|
|
325
|
+
...(defaults.searchableColumns ?? []),
|
|
326
|
+
...defaults.filterableColumns,
|
|
327
|
+
...defaults.sortableColumns,
|
|
328
|
+
...resolvedReadonly,
|
|
329
|
+
]) {
|
|
330
|
+
if (!columns.includes(col)) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`[bunderstack] access.${key} references unknown column "${col}" on table "${name}"`,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const needsOwner = [defaults.update, defaults.delete].some(
|
|
338
|
+
(r) => r === 'owner',
|
|
339
|
+
)
|
|
340
|
+
if (needsOwner && !ownerColumn) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`[bunderstack] access.${key} requires ownerColumn for owner-based update/delete rules`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
resolved.set(key, {
|
|
347
|
+
tableKey: key,
|
|
348
|
+
tableName: name,
|
|
349
|
+
enabled: true,
|
|
350
|
+
...defaults,
|
|
351
|
+
readonlyColumns: resolvedReadonly,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return resolved
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function defineAccess<
|
|
359
|
+
TSchema extends Record<string, unknown>,
|
|
360
|
+
const TRules extends Record<string, TableAccessInput>,
|
|
361
|
+
>(schema: TSchema, rules: TRules): TRules {
|
|
362
|
+
validateAndResolveAccess(schema, rules)
|
|
363
|
+
return rules
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function rowMatchesScope(
|
|
367
|
+
row: Record<string, unknown>,
|
|
368
|
+
scope: ScopeMap,
|
|
369
|
+
): boolean {
|
|
370
|
+
for (const [col, expected] of Object.entries(scope)) {
|
|
371
|
+
const actual = row[col]
|
|
372
|
+
if (actual == null) return false
|
|
373
|
+
if (Array.isArray(expected)) {
|
|
374
|
+
if (!expected.map(String).includes(String(actual))) return false
|
|
375
|
+
} else if (String(actual) !== String(expected)) {
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function stampScope(
|
|
383
|
+
values: Record<string, unknown>,
|
|
384
|
+
scope: ScopeMap,
|
|
385
|
+
): Record<string, unknown> {
|
|
386
|
+
const out = { ...values }
|
|
387
|
+
for (const [col, expected] of Object.entries(scope)) {
|
|
388
|
+
if (!Array.isArray(expected)) out[col] = expected
|
|
389
|
+
}
|
|
390
|
+
return out
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function checkAccessSync(
|
|
394
|
+
rule: Exclude<
|
|
395
|
+
OperationRule,
|
|
396
|
+
(ctx: AccessContext) => boolean | Promise<boolean>
|
|
397
|
+
>,
|
|
398
|
+
ctx: AccessContext,
|
|
399
|
+
ownerColumn?: string,
|
|
400
|
+
): { allowed: boolean } {
|
|
401
|
+
if (rule === 'deny') return { allowed: false }
|
|
402
|
+
if (rule === 'public') return { allowed: true }
|
|
403
|
+
if (!ctx.user) return { allowed: false }
|
|
404
|
+
if (rule === 'authenticated') return { allowed: true }
|
|
405
|
+
if (rule === 'owner') {
|
|
406
|
+
if (!ownerColumn) return { allowed: false }
|
|
407
|
+
const owner = ctx.row?.[ownerColumn]
|
|
408
|
+
return { allowed: owner != null && String(owner) === ctx.user.id }
|
|
409
|
+
}
|
|
410
|
+
return { allowed: false }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function checkAccess(
|
|
414
|
+
rule: OperationRule,
|
|
415
|
+
ctx: AccessContext,
|
|
416
|
+
ownerColumn?: string,
|
|
417
|
+
): Promise<{ allowed: boolean; status: 401 | 403 }> {
|
|
418
|
+
if (rule === 'deny') return { allowed: false, status: 403 }
|
|
419
|
+
|
|
420
|
+
if (typeof rule === 'function') {
|
|
421
|
+
return (await rule(ctx))
|
|
422
|
+
? { allowed: true, status: 403 }
|
|
423
|
+
: { allowed: false, status: 403 }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (rule === 'public') return { allowed: true, status: 403 }
|
|
427
|
+
|
|
428
|
+
if (!ctx.user) return { allowed: false, status: 401 }
|
|
429
|
+
|
|
430
|
+
if (rule === 'authenticated') return { allowed: true, status: 403 }
|
|
431
|
+
|
|
432
|
+
if (rule === 'owner') {
|
|
433
|
+
if (!ownerColumn) return { allowed: false, status: 403 }
|
|
434
|
+
const rowOwner = ctx.row?.[ownerColumn] ?? ctx.body?.[ownerColumn]
|
|
435
|
+
if (rowOwner == null) return { allowed: false, status: 403 }
|
|
436
|
+
if (String(rowOwner) !== ctx.user.id) return { allowed: false, status: 403 }
|
|
437
|
+
return { allowed: true, status: 403 }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { allowed: false, status: 403 }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function sanitizeWriteBody(
|
|
444
|
+
body: Record<string, unknown>,
|
|
445
|
+
access: ResolvedTableAccess,
|
|
446
|
+
mode: 'create' | 'update',
|
|
447
|
+
userId: string | null,
|
|
448
|
+
): Record<string, unknown> {
|
|
449
|
+
const out: Record<string, unknown> = {}
|
|
450
|
+
const readonly = new Set(access.readonlyColumns)
|
|
451
|
+
|
|
452
|
+
for (const [key, value] of Object.entries(body)) {
|
|
453
|
+
// On create, allow client-supplied `id` even if it appears in readonlyColumns,
|
|
454
|
+
// but still respect an explicit writableColumns allowlist if provided.
|
|
455
|
+
if (mode === 'create' && key === 'id') {
|
|
456
|
+
if (access.writableColumns && !access.writableColumns.includes(key))
|
|
457
|
+
continue
|
|
458
|
+
out[key] = value
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
if (readonly.has(key)) continue
|
|
462
|
+
if (access.writableColumns && !access.writableColumns.includes(key))
|
|
463
|
+
continue
|
|
464
|
+
out[key] = value
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (mode === 'create' && access.ownerColumn) {
|
|
468
|
+
out[access.ownerColumn] = userId ?? null
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (mode === 'update' && access.ownerColumn) {
|
|
472
|
+
delete out[access.ownerColumn]
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return out
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export type AuthSessionResolver = {
|
|
479
|
+
api: {
|
|
480
|
+
getSession: (opts: { headers: Headers }) => Promise<{
|
|
481
|
+
user: { id: string; email: string; name?: string } | null
|
|
482
|
+
session?: { activeOrganizationId?: string | null } | null
|
|
483
|
+
} | null>
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export async function resolveAccessUser(
|
|
488
|
+
auth: AuthSessionResolver | undefined,
|
|
489
|
+
headers: Headers,
|
|
490
|
+
): Promise<AccessUser | null> {
|
|
491
|
+
if (!auth) return null
|
|
492
|
+
const session = await auth.api.getSession({ headers })
|
|
493
|
+
if (!session?.user) return null
|
|
494
|
+
return {
|
|
495
|
+
id: session.user.id,
|
|
496
|
+
email: session.user.email,
|
|
497
|
+
name: session.user.name,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export async function resolveSession(
|
|
502
|
+
auth: AuthSessionResolver | undefined,
|
|
503
|
+
headers: Headers,
|
|
504
|
+
): Promise<{ user: AccessUser | null; activeOrganizationId: string | null }> {
|
|
505
|
+
if (!auth) return { user: null, activeOrganizationId: null }
|
|
506
|
+
const session = await auth.api.getSession({ headers })
|
|
507
|
+
if (!session?.user) return { user: null, activeOrganizationId: null }
|
|
508
|
+
return {
|
|
509
|
+
user: {
|
|
510
|
+
id: session.user.id,
|
|
511
|
+
email: session.user.email,
|
|
512
|
+
name: session.user.name,
|
|
513
|
+
},
|
|
514
|
+
activeOrganizationId: session.session?.activeOrganizationId ?? null,
|
|
515
|
+
}
|
|
516
|
+
}
|