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 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
+ }