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/src/auth.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
+
|
|
3
|
+
// src/auth.ts
|
|
4
|
+
import { betterAuth } from 'better-auth'
|
|
5
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
6
|
+
|
|
7
|
+
import type { BetterAuthConfig } from './config'
|
|
8
|
+
import type { AuthSessionResolver } from './access'
|
|
9
|
+
import type { EmailFacade } from './email'
|
|
10
|
+
|
|
11
|
+
export function createAuth(
|
|
12
|
+
db: LibSQLDatabase<Record<string, unknown>>,
|
|
13
|
+
cfg: BetterAuthConfig,
|
|
14
|
+
) {
|
|
15
|
+
return betterAuth({
|
|
16
|
+
...cfg,
|
|
17
|
+
database: drizzleAdapter(db, { provider: 'sqlite' }),
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Adapt the raw better-auth instance to our internal {@link AuthSessionResolver}
|
|
23
|
+
* contract. better-auth's `getSession` has a union return (a bare session, or a
|
|
24
|
+
* `{ headers, response }` wrapper when `returnHeaders` is set); we only ever
|
|
25
|
+
* call the bare form, so we narrow on `'user' in result` and map to our shape.
|
|
26
|
+
* Keeping this adapter here means internal modules never depend on better-auth's
|
|
27
|
+
* evolving types.
|
|
28
|
+
*/
|
|
29
|
+
export function toAuthSessionResolver(
|
|
30
|
+
auth: ReturnType<typeof createAuth>,
|
|
31
|
+
): AuthSessionResolver {
|
|
32
|
+
return {
|
|
33
|
+
api: {
|
|
34
|
+
async getSession({ headers }) {
|
|
35
|
+
const result = await auth.api.getSession({ headers })
|
|
36
|
+
if (result && 'user' in result && result.user) {
|
|
37
|
+
const session = 'session' in result ? result.session : null
|
|
38
|
+
const activeOrganizationId =
|
|
39
|
+
session &&
|
|
40
|
+
'activeOrganizationId' in session &&
|
|
41
|
+
typeof session.activeOrganizationId === 'string'
|
|
42
|
+
? session.activeOrganizationId
|
|
43
|
+
: null
|
|
44
|
+
return {
|
|
45
|
+
user: {
|
|
46
|
+
id: result.user.id,
|
|
47
|
+
email: result.user.email,
|
|
48
|
+
name: result.user.name,
|
|
49
|
+
},
|
|
50
|
+
session: session
|
|
51
|
+
? { activeOrganizationId }
|
|
52
|
+
: null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fill better-auth's email hooks from the bunderstack email facade. Only fills
|
|
63
|
+
* gaps: user-supplied handlers always win, and nothing is injected when email
|
|
64
|
+
* isn't configured. emailAndPassword is only touched when the user enabled it
|
|
65
|
+
* (injecting it unasked would enable the feature).
|
|
66
|
+
*/
|
|
67
|
+
export function withEmailAuthDefaults(
|
|
68
|
+
cfg: BetterAuthConfig,
|
|
69
|
+
email: EmailFacade,
|
|
70
|
+
emailConfigured: boolean,
|
|
71
|
+
): BetterAuthConfig {
|
|
72
|
+
if (!emailConfigured) return cfg
|
|
73
|
+
const out: BetterAuthConfig = { ...cfg }
|
|
74
|
+
|
|
75
|
+
if (cfg.emailAndPassword?.enabled && !cfg.emailAndPassword.sendResetPassword) {
|
|
76
|
+
out.emailAndPassword = {
|
|
77
|
+
...cfg.emailAndPassword,
|
|
78
|
+
sendResetPassword: async ({ user, url }) => {
|
|
79
|
+
await email.send({
|
|
80
|
+
to: user.email,
|
|
81
|
+
subject: 'Reset your password',
|
|
82
|
+
text: `Click the link to reset your password:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`,
|
|
83
|
+
})
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!cfg.emailVerification?.sendVerificationEmail) {
|
|
89
|
+
out.emailVerification = {
|
|
90
|
+
...cfg.emailVerification,
|
|
91
|
+
sendVerificationEmail: async ({ user, url }) => {
|
|
92
|
+
await email.send({
|
|
93
|
+
to: user.email,
|
|
94
|
+
subject: 'Verify your email',
|
|
95
|
+
text: `Click the link to verify your email address:\n\n${url}`,
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return out
|
|
102
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { betterAuth } from 'better-auth'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
import type { TableAccessInput } from './access'
|
|
6
|
+
import type { IdempotencyConfig } from './idempotency'
|
|
7
|
+
import type { RateLimitConfig } from './rate-limit'
|
|
8
|
+
|
|
9
|
+
import { validateEnv, type BaseEnv, type EnvConfigInput } from './env'
|
|
10
|
+
import type { EmailConfigInput } from './email'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
resolveBuckets,
|
|
14
|
+
type ResolvedStorageBuckets,
|
|
15
|
+
type StorageConfigInput,
|
|
16
|
+
} from './storage/buckets'
|
|
17
|
+
|
|
18
|
+
export type BetterAuthConfig = Omit<
|
|
19
|
+
NonNullable<Parameters<typeof betterAuth>[0]>,
|
|
20
|
+
'database'
|
|
21
|
+
>
|
|
22
|
+
|
|
23
|
+
export const BunderstackOptionsSchema = z.object({
|
|
24
|
+
schema: z.record(z.string(), z.unknown()),
|
|
25
|
+
access: z.record(z.string(), z.any()).optional(),
|
|
26
|
+
database: z
|
|
27
|
+
.object({ url: z.string().optional(), authToken: z.string().optional() })
|
|
28
|
+
.optional(),
|
|
29
|
+
auth: z.record(z.string(), z.unknown()).optional(),
|
|
30
|
+
// Loose: bucket access/scope hold functions that can't survive strict zod
|
|
31
|
+
// (mirrors how `access` is loose). Resolution happens in resolveBuckets.
|
|
32
|
+
storage: z.unknown().optional(),
|
|
33
|
+
// Loose: holds zod schemas. Validation happens in validateEnv.
|
|
34
|
+
env: z.unknown().optional(),
|
|
35
|
+
// Loose: provider may be a function/adapter. Resolution happens in createEmail.
|
|
36
|
+
email: z.unknown().optional(),
|
|
37
|
+
// Loose: a tRPC router or builder callback. Resolved in createBunderstack.
|
|
38
|
+
trpc: z.unknown().optional(),
|
|
39
|
+
rateLimit: z
|
|
40
|
+
.union([
|
|
41
|
+
z.boolean(),
|
|
42
|
+
z.object({
|
|
43
|
+
windowMs: z.number().optional(),
|
|
44
|
+
max: z.number().optional(),
|
|
45
|
+
}),
|
|
46
|
+
])
|
|
47
|
+
.optional(),
|
|
48
|
+
idempotency: z
|
|
49
|
+
.union([z.boolean(), z.object({ ttlMs: z.number().optional() })])
|
|
50
|
+
.optional(),
|
|
51
|
+
realtime: z
|
|
52
|
+
.union([
|
|
53
|
+
z.boolean(),
|
|
54
|
+
z.object({
|
|
55
|
+
keepaliveMs: z.number().optional(),
|
|
56
|
+
bufferSize: z.number().optional(),
|
|
57
|
+
redis: z
|
|
58
|
+
.union([
|
|
59
|
+
z.string(),
|
|
60
|
+
z.object({ url: z.string(), token: z.string().optional() }),
|
|
61
|
+
])
|
|
62
|
+
.optional(),
|
|
63
|
+
}),
|
|
64
|
+
])
|
|
65
|
+
.optional(),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export type BunderstackConfig<
|
|
69
|
+
TSchema extends Record<string, unknown>,
|
|
70
|
+
TAccess extends Record<string, TableAccessInput> | undefined =
|
|
71
|
+
| Record<string, TableAccessInput>
|
|
72
|
+
| undefined,
|
|
73
|
+
TStorage extends StorageConfigInput | undefined =
|
|
74
|
+
| StorageConfigInput
|
|
75
|
+
| undefined,
|
|
76
|
+
TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
|
|
77
|
+
> = Omit<
|
|
78
|
+
z.input<typeof BunderstackOptionsSchema>,
|
|
79
|
+
'schema' | 'access' | 'auth' | 'storage' | 'env' | 'email' | 'trpc'
|
|
80
|
+
> & {
|
|
81
|
+
schema: TSchema
|
|
82
|
+
access?: TAccess
|
|
83
|
+
auth?: BetterAuthConfig
|
|
84
|
+
storage?: TStorage
|
|
85
|
+
env?: TEnv
|
|
86
|
+
email?: EmailConfigInput
|
|
87
|
+
// `trpc` is intentionally NOT declared here: createBunderstack intersects
|
|
88
|
+
// its own inference-friendly `trpc` declaration (router | builder callback)
|
|
89
|
+
// so the callback's `t` parameter gets contextual typing.
|
|
90
|
+
rateLimit?: boolean | RateLimitConfig
|
|
91
|
+
idempotency?: boolean | IdempotencyConfig
|
|
92
|
+
realtime?:
|
|
93
|
+
| boolean
|
|
94
|
+
| {
|
|
95
|
+
keepaliveMs?: number
|
|
96
|
+
bufferSize?: number
|
|
97
|
+
redis?: string | { url: string; token?: string }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ResolvedConfig = {
|
|
102
|
+
database: { url: string; authToken?: string }
|
|
103
|
+
auth: BetterAuthConfig
|
|
104
|
+
storage: ResolvedStorageBuckets
|
|
105
|
+
realtime?:
|
|
106
|
+
| boolean
|
|
107
|
+
| {
|
|
108
|
+
keepaliveMs?: number
|
|
109
|
+
bufferSize?: number
|
|
110
|
+
redis?: string | { url: string; token?: string }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
115
|
+
options: BunderstackConfig<TSchema>,
|
|
116
|
+
env?: BaseEnv,
|
|
117
|
+
): ResolvedConfig {
|
|
118
|
+
const parsed = BunderstackOptionsSchema.parse(options)
|
|
119
|
+
// Self-validate when the caller didn't pass a pre-validated env, so
|
|
120
|
+
// resolveConfig stays usable standalone.
|
|
121
|
+
const resolvedEnv =
|
|
122
|
+
env ?? validateEnv(options.env as EnvConfigInput | undefined)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
database: {
|
|
126
|
+
url: parsed.database?.url ?? resolvedEnv.DATABASE_URL,
|
|
127
|
+
authToken: parsed.database?.authToken ?? resolvedEnv.DATABASE_AUTH_TOKEN,
|
|
128
|
+
},
|
|
129
|
+
auth: (() => {
|
|
130
|
+
const authInput = options.auth ?? {}
|
|
131
|
+
return {
|
|
132
|
+
...authInput,
|
|
133
|
+
secret: authInput.secret ?? resolvedEnv.AUTH_SECRET,
|
|
134
|
+
}
|
|
135
|
+
})(),
|
|
136
|
+
storage: resolveBuckets(options.storage),
|
|
137
|
+
realtime: parsed.realtime,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveRealtimeRedisUrl(
|
|
142
|
+
realtime: ResolvedConfig['realtime'],
|
|
143
|
+
env?: BaseEnv,
|
|
144
|
+
): string | undefined {
|
|
145
|
+
const fromConfig =
|
|
146
|
+
typeof realtime === 'object' && realtime.redis
|
|
147
|
+
? typeof realtime.redis === 'string'
|
|
148
|
+
? realtime.redis
|
|
149
|
+
: realtime.redis.url
|
|
150
|
+
: undefined
|
|
151
|
+
return fromConfig ?? env?.REDIS_URL ?? process.env.REDIS_URL ?? undefined
|
|
152
|
+
}
|
package/src/crud.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
+
|
|
3
|
+
import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
|
|
4
|
+
import { Hono } from 'hono'
|
|
5
|
+
|
|
6
|
+
import type { RealtimeBroker } from './realtime/index'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
checkAccess,
|
|
10
|
+
resolveSession,
|
|
11
|
+
rowMatchesScope,
|
|
12
|
+
stampScope,
|
|
13
|
+
sanitizeWriteBody,
|
|
14
|
+
type AccessUser,
|
|
15
|
+
type AuthSessionResolver,
|
|
16
|
+
type CrudOperation,
|
|
17
|
+
type ResolvedAccess,
|
|
18
|
+
type ResolvedTableAccess,
|
|
19
|
+
type ScopeMap,
|
|
20
|
+
} from './access'
|
|
21
|
+
import { ErrorCode, apiError, ListQueryError } from './errors'
|
|
22
|
+
import {
|
|
23
|
+
lookupIdempotency,
|
|
24
|
+
resolveIdempotencyConfig,
|
|
25
|
+
storeIdempotency,
|
|
26
|
+
type IdempotencyConfig,
|
|
27
|
+
} from './idempotency'
|
|
28
|
+
import { executeList, parseListParams } from './list-query'
|
|
29
|
+
import { buildScopeWhere } from './scope'
|
|
30
|
+
|
|
31
|
+
export type CrudRouterOptions = {
|
|
32
|
+
auth?: AuthSessionResolver
|
|
33
|
+
access: ResolvedAccess
|
|
34
|
+
idempotency?: boolean | IdempotencyConfig
|
|
35
|
+
broker?: RealtimeBroker
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tableEntryForName(
|
|
39
|
+
access: ResolvedAccess,
|
|
40
|
+
tableName: string,
|
|
41
|
+
): ResolvedTableAccess | undefined {
|
|
42
|
+
for (const entry of access.values()) {
|
|
43
|
+
if (entry.tableName === tableName) return entry
|
|
44
|
+
}
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function enforce(
|
|
53
|
+
operation: CrudOperation,
|
|
54
|
+
access: ResolvedTableAccess,
|
|
55
|
+
ctx: Parameters<typeof checkAccess>[1],
|
|
56
|
+
) {
|
|
57
|
+
const rule = access[operation]
|
|
58
|
+
const result = await checkAccess(rule, ctx, access.ownerColumn)
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
63
|
+
schema: TSchema,
|
|
64
|
+
db: LibSQLDatabase<TSchema>,
|
|
65
|
+
options: CrudRouterOptions,
|
|
66
|
+
): Hono {
|
|
67
|
+
const router = new Hono()
|
|
68
|
+
const { auth, access, broker } = options
|
|
69
|
+
const idempotency = resolveIdempotencyConfig(options.idempotency)
|
|
70
|
+
|
|
71
|
+
const scopeFor = (
|
|
72
|
+
tableAccess: ResolvedTableAccess,
|
|
73
|
+
ctx: {
|
|
74
|
+
user: AccessUser | null
|
|
75
|
+
session: { activeOrganizationId: string | null } | null
|
|
76
|
+
request: Request
|
|
77
|
+
},
|
|
78
|
+
): ScopeMap | undefined =>
|
|
79
|
+
tableAccess.scope ? tableAccess.scope({ ...ctx }) : undefined
|
|
80
|
+
|
|
81
|
+
for (const table of Object.values(schema)) {
|
|
82
|
+
if (!isTable(table)) continue
|
|
83
|
+
|
|
84
|
+
const name = getTableName(table)
|
|
85
|
+
const tableAccess = tableEntryForName(access, name)
|
|
86
|
+
if (!tableAccess?.enabled) continue
|
|
87
|
+
|
|
88
|
+
const idCol = getTableColumns(table)['id']
|
|
89
|
+
if (!idCol) continue
|
|
90
|
+
|
|
91
|
+
router.get(`/${name}`, async (c) => {
|
|
92
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
93
|
+
auth,
|
|
94
|
+
c.req.raw.headers,
|
|
95
|
+
)
|
|
96
|
+
const session = { activeOrganizationId }
|
|
97
|
+
const denied = await enforce('list', tableAccess, {
|
|
98
|
+
user,
|
|
99
|
+
session,
|
|
100
|
+
request: c.req.raw,
|
|
101
|
+
})
|
|
102
|
+
if (!denied.allowed) {
|
|
103
|
+
return apiError(
|
|
104
|
+
c,
|
|
105
|
+
ErrorCode.FORBIDDEN,
|
|
106
|
+
'Forbidden',
|
|
107
|
+
denied.status === 401 ? 401 : 403,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const params = parseListParams(new URL(c.req.url), tableAccess)
|
|
113
|
+
const scope = scopeFor(tableAccess, {
|
|
114
|
+
user,
|
|
115
|
+
session,
|
|
116
|
+
request: c.req.raw,
|
|
117
|
+
})
|
|
118
|
+
const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
|
|
119
|
+
const result = await executeList(
|
|
120
|
+
db,
|
|
121
|
+
table,
|
|
122
|
+
tableAccess,
|
|
123
|
+
params,
|
|
124
|
+
idCol,
|
|
125
|
+
scopeWhere,
|
|
126
|
+
)
|
|
127
|
+
return c.json(result)
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof ListQueryError) {
|
|
130
|
+
return apiError(c, err.code, err.message, 400, err.details)
|
|
131
|
+
}
|
|
132
|
+
throw err
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
router.get(`/${name}/:id`, async (c) => {
|
|
137
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
138
|
+
auth,
|
|
139
|
+
c.req.raw.headers,
|
|
140
|
+
)
|
|
141
|
+
const session = { activeOrganizationId }
|
|
142
|
+
const rawId = c.req.param('id')
|
|
143
|
+
const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
|
+
const rows = await (db as any)
|
|
146
|
+
.select()
|
|
147
|
+
.from(table)
|
|
148
|
+
.where(eq(idCol as any, id))
|
|
149
|
+
if (!rows[0]) {
|
|
150
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const denied = await enforce('get', tableAccess, {
|
|
154
|
+
user,
|
|
155
|
+
session,
|
|
156
|
+
request: c.req.raw,
|
|
157
|
+
row: rows[0] as Record<string, unknown>,
|
|
158
|
+
})
|
|
159
|
+
if (!denied.allowed) {
|
|
160
|
+
return apiError(
|
|
161
|
+
c,
|
|
162
|
+
ErrorCode.FORBIDDEN,
|
|
163
|
+
'Forbidden',
|
|
164
|
+
denied.status === 401 ? 401 : 403,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
|
|
169
|
+
if (
|
|
170
|
+
scope &&
|
|
171
|
+
!rowMatchesScope(rows[0] as Record<string, unknown>, scope)
|
|
172
|
+
) {
|
|
173
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return c.json(rows[0])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
router.post(`/${name}`, async (c) => {
|
|
180
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
181
|
+
auth,
|
|
182
|
+
c.req.raw.headers,
|
|
183
|
+
)
|
|
184
|
+
const session = { activeOrganizationId }
|
|
185
|
+
const denied = await enforce('create', tableAccess, {
|
|
186
|
+
user,
|
|
187
|
+
session,
|
|
188
|
+
request: c.req.raw,
|
|
189
|
+
})
|
|
190
|
+
if (!denied.allowed) {
|
|
191
|
+
return apiError(
|
|
192
|
+
c,
|
|
193
|
+
ErrorCode.FORBIDDEN,
|
|
194
|
+
'Forbidden',
|
|
195
|
+
denied.status === 401 ? 401 : 403,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rawBody = await c.req.text()
|
|
200
|
+
let body: unknown
|
|
201
|
+
try {
|
|
202
|
+
body = rawBody ? JSON.parse(rawBody) : null
|
|
203
|
+
} catch {
|
|
204
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
|
|
205
|
+
}
|
|
206
|
+
if (!isRecord(body)) {
|
|
207
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const idempotencyKey = c.req.header('Idempotency-Key')?.trim()
|
|
211
|
+
if (idempotency && idempotencyKey) {
|
|
212
|
+
const lookup = await lookupIdempotency(
|
|
213
|
+
db,
|
|
214
|
+
name,
|
|
215
|
+
idempotencyKey,
|
|
216
|
+
rawBody,
|
|
217
|
+
idempotency,
|
|
218
|
+
)
|
|
219
|
+
if (lookup.type === 'conflict') {
|
|
220
|
+
return apiError(
|
|
221
|
+
c,
|
|
222
|
+
ErrorCode.IDEMPOTENCY_CONFLICT,
|
|
223
|
+
'Idempotency key reused with different body',
|
|
224
|
+
409,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
if (lookup.type === 'replay') {
|
|
228
|
+
return new Response(lookup.response, {
|
|
229
|
+
status: lookup.status,
|
|
230
|
+
headers: {
|
|
231
|
+
'Content-Type': 'application/json',
|
|
232
|
+
'Idempotency-Replayed': 'true',
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const values = sanitizeWriteBody(
|
|
239
|
+
body,
|
|
240
|
+
tableAccess,
|
|
241
|
+
'create',
|
|
242
|
+
user?.id ?? null,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
|
|
246
|
+
const stamped = scope ? stampScope(values, scope) : values
|
|
247
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
248
|
+
const rows = await (db as any).insert(table).values(stamped).returning()
|
|
249
|
+
const created = rows[0]
|
|
250
|
+
void broker?.publish(name, 'create', created as Record<string, unknown>)
|
|
251
|
+
|
|
252
|
+
if (idempotency && idempotencyKey) {
|
|
253
|
+
await storeIdempotency(
|
|
254
|
+
db,
|
|
255
|
+
name,
|
|
256
|
+
idempotencyKey,
|
|
257
|
+
rawBody,
|
|
258
|
+
201,
|
|
259
|
+
created,
|
|
260
|
+
idempotency,
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return c.json(created, 201)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
router.patch(`/${name}/:id`, async (c) => {
|
|
268
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
269
|
+
auth,
|
|
270
|
+
c.req.raw.headers,
|
|
271
|
+
)
|
|
272
|
+
const session = { activeOrganizationId }
|
|
273
|
+
const rawId = c.req.param('id')
|
|
274
|
+
const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
|
|
275
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
276
|
+
const existing = await (db as any)
|
|
277
|
+
.select()
|
|
278
|
+
.from(table)
|
|
279
|
+
.where(eq(idCol as any, id))
|
|
280
|
+
if (!existing[0]) {
|
|
281
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
|
|
285
|
+
if (
|
|
286
|
+
scope &&
|
|
287
|
+
!rowMatchesScope(existing[0] as Record<string, unknown>, scope)
|
|
288
|
+
) {
|
|
289
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const denied = await enforce('update', tableAccess, {
|
|
293
|
+
user,
|
|
294
|
+
session,
|
|
295
|
+
request: c.req.raw,
|
|
296
|
+
row: existing[0] as Record<string, unknown>,
|
|
297
|
+
})
|
|
298
|
+
if (!denied.allowed) {
|
|
299
|
+
return apiError(
|
|
300
|
+
c,
|
|
301
|
+
ErrorCode.FORBIDDEN,
|
|
302
|
+
'Forbidden',
|
|
303
|
+
denied.status === 401 ? 401 : 403,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let body: unknown
|
|
308
|
+
try {
|
|
309
|
+
body = await c.req.json()
|
|
310
|
+
} catch {
|
|
311
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
|
|
312
|
+
}
|
|
313
|
+
if (!isRecord(body)) {
|
|
314
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const values = sanitizeWriteBody(
|
|
318
|
+
body,
|
|
319
|
+
tableAccess,
|
|
320
|
+
'update',
|
|
321
|
+
user?.id ?? null,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
325
|
+
const rows = await (db as any)
|
|
326
|
+
.update(table)
|
|
327
|
+
.set(values)
|
|
328
|
+
.where(eq(idCol as any, id))
|
|
329
|
+
.returning()
|
|
330
|
+
if (!rows[0]) {
|
|
331
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
332
|
+
}
|
|
333
|
+
void broker?.publish(name, 'update', rows[0] as Record<string, unknown>)
|
|
334
|
+
return c.json(rows[0])
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
router.delete(`/${name}/:id`, async (c) => {
|
|
338
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
339
|
+
auth,
|
|
340
|
+
c.req.raw.headers,
|
|
341
|
+
)
|
|
342
|
+
const session = { activeOrganizationId }
|
|
343
|
+
const rawId = c.req.param('id')
|
|
344
|
+
const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
|
|
345
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
346
|
+
const existing = await (db as any)
|
|
347
|
+
.select()
|
|
348
|
+
.from(table)
|
|
349
|
+
.where(eq(idCol as any, id))
|
|
350
|
+
if (!existing[0]) {
|
|
351
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const scope = scopeFor(tableAccess, { user, session, request: c.req.raw })
|
|
355
|
+
if (
|
|
356
|
+
scope &&
|
|
357
|
+
!rowMatchesScope(existing[0] as Record<string, unknown>, scope)
|
|
358
|
+
) {
|
|
359
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const denied = await enforce('delete', tableAccess, {
|
|
363
|
+
user,
|
|
364
|
+
session,
|
|
365
|
+
request: c.req.raw,
|
|
366
|
+
row: existing[0] as Record<string, unknown>,
|
|
367
|
+
})
|
|
368
|
+
if (!denied.allowed) {
|
|
369
|
+
return apiError(
|
|
370
|
+
c,
|
|
371
|
+
ErrorCode.FORBIDDEN,
|
|
372
|
+
'Forbidden',
|
|
373
|
+
denied.status === 401 ? 401 : 403,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
378
|
+
await (db as any).delete(table).where(eq(idCol as any, id))
|
|
379
|
+
void broker?.publish(
|
|
380
|
+
name,
|
|
381
|
+
'delete',
|
|
382
|
+
existing[0] as Record<string, unknown>,
|
|
383
|
+
)
|
|
384
|
+
return new Response(null, { status: 204 })
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return router
|
|
389
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createClient } from '@libsql/client'
|
|
2
|
+
import { drizzle } from 'drizzle-orm/libsql'
|
|
3
|
+
|
|
4
|
+
export function createDb<TSchema extends Record<string, unknown>>(
|
|
5
|
+
schema: TSchema,
|
|
6
|
+
cfg: { url: string; authToken?: string },
|
|
7
|
+
) {
|
|
8
|
+
const client = createClient({ url: cfg.url, authToken: cfg.authToken })
|
|
9
|
+
return drizzle(client, { schema })
|
|
10
|
+
}
|