@etus/bhono-app 0.1.6 → 0.1.7

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.
Files changed (61) hide show
  1. package/package.json +3 -2
  2. package/templates/base/.claude/commands/check-skill-rules.md +112 -29
  3. package/templates/base/.claude/commands/linear/implement-issue.md +383 -55
  4. package/templates/base/.claude/commands/ship.md +77 -13
  5. package/templates/base/.claude/hooks/package-lock.json +0 -419
  6. package/templates/base/.claude/hooks/skill-activation-prompt.ts +185 -113
  7. package/templates/base/.claude/hooks/skill-tool-guard.sh +6 -0
  8. package/templates/base/.claude/hooks/skill-tool-guard.ts +198 -0
  9. package/templates/base/.claude/scripts/validate-skill-rules.sh +55 -32
  10. package/templates/base/.claude/settings.json +18 -11
  11. package/templates/base/.claude/skills/skill-rules.json +326 -173
  12. package/templates/base/.env.example +3 -0
  13. package/templates/base/README.md +9 -7
  14. package/templates/base/config/eslint.config.js +1 -0
  15. package/templates/base/config/wrangler.json +16 -17
  16. package/templates/base/docs/SETUP-GUIDE.md +566 -0
  17. package/templates/base/docs/architecture/README.md +162 -8
  18. package/templates/base/docs/architecture/api-catalog.md +575 -0
  19. package/templates/base/docs/architecture/c4-component.md +309 -0
  20. package/templates/base/docs/architecture/c4-container.md +183 -0
  21. package/templates/base/docs/architecture/c4-context.md +106 -0
  22. package/templates/base/docs/architecture/dependencies.md +327 -0
  23. package/templates/base/docs/architecture/tech-debt.md +184 -0
  24. package/templates/base/package.json +20 -15
  25. package/templates/base/scripts/capture-prod-session.ts +2 -2
  26. package/templates/base/scripts/sync-template.sh +104 -0
  27. package/templates/base/src/server/db/sql.ts +24 -4
  28. package/templates/base/src/server/index.ts +1 -0
  29. package/templates/base/src/server/lib/audited-db.ts +10 -10
  30. package/templates/base/src/server/middleware/account.ts +1 -1
  31. package/templates/base/src/server/middleware/auth.ts +11 -11
  32. package/templates/base/src/server/middleware/rate-limit.ts +3 -6
  33. package/templates/base/src/server/routes/auth/handlers.ts +5 -5
  34. package/templates/base/src/server/routes/auth/test-login.ts +9 -9
  35. package/templates/base/src/server/routes/index.ts +9 -0
  36. package/templates/base/src/server/routes/invitations/handlers.ts +6 -6
  37. package/templates/base/src/server/routes/openapi.ts +1 -1
  38. package/templates/base/src/server/services/accounts.ts +9 -9
  39. package/templates/base/src/server/services/audits.ts +12 -12
  40. package/templates/base/src/server/services/auth.ts +15 -15
  41. package/templates/base/src/server/services/invitations.ts +16 -16
  42. package/templates/base/src/server/services/users.ts +13 -13
  43. package/templates/base/src/shared/types/api.ts +66 -198
  44. package/templates/base/tests/e2e/auth.setup.ts +1 -1
  45. package/templates/base/tests/unit/server/auth/guards.test.ts +1 -1
  46. package/templates/base/tests/unit/server/middleware/auth.test.ts +273 -0
  47. package/templates/base/tests/unit/server/routes/auth/handlers.test.ts +111 -0
  48. package/templates/base/tests/unit/server/routes/users/handlers.test.ts +69 -5
  49. package/templates/base/tests/unit/server/services/accounts.test.ts +148 -0
  50. package/templates/base/tests/unit/server/services/audits.test.ts +219 -0
  51. package/templates/base/tests/unit/server/services/auth.test.ts +480 -3
  52. package/templates/base/tests/unit/server/services/invitations.test.ts +178 -0
  53. package/templates/base/tests/unit/server/services/users.test.ts +363 -8
  54. package/templates/base/tests/unit/shared/schemas.test.ts +1 -1
  55. package/templates/base/vite.config.ts +3 -1
  56. package/templates/base/.github/workflows/test.yml +0 -127
  57. package/templates/base/.husky/pre-push +0 -26
  58. package/templates/base/auth-setup-error.png +0 -0
  59. package/templates/base/pnpm-lock.yaml +0 -8052
  60. package/templates/base/tests/e2e/_auth/.gitkeep +0 -0
  61. package/templates/base/tsconfig.tsbuildinfo +0 -1
@@ -16,6 +16,26 @@ export type SqlParams = SqlValue[]
16
16
  export type SqlRow = Record<string, unknown>
17
17
  export type RowMapper<T> = (row: SqlRow) => T
18
18
 
19
+ /**
20
+ * Safely convert an unknown database value to a string.
21
+ * Returns empty string for null/undefined/objects.
22
+ */
23
+ export function toStringValue(value: unknown): string {
24
+ if (typeof value === 'string') return value
25
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
26
+ return ''
27
+ }
28
+
29
+ /**
30
+ * Safely convert an unknown database value to a string or null.
31
+ */
32
+ export function toNullableString(value: unknown): string | null {
33
+ if (value === null || value === undefined) return null
34
+ if (typeof value === 'string') return value
35
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
36
+ return null
37
+ }
38
+
19
39
  function normalizeValue(value: SqlValue): unknown {
20
40
  if (value === undefined) return null
21
41
  if (value instanceof Date) return value.toISOString()
@@ -41,7 +61,7 @@ export async function queryAll<T = SqlRow>(
41
61
  ): Promise<T[]> {
42
62
  const prepared = prepareStatement(db, statement, params)
43
63
  const result = await prepared.all<SqlRow>()
44
- const rows = result.results ?? []
64
+ const rows = result.results
45
65
  return mapper ? rows.map(mapper) : (rows as T[])
46
66
  }
47
67
 
@@ -63,7 +83,7 @@ export async function queryValue<T = unknown>(
63
83
  params?: SqlParams,
64
84
  column?: string
65
85
  ): Promise<T | null> {
66
- const row = await queryOne<SqlRow>(db, statement, params)
86
+ const row = await queryOne(db, statement, params)
67
87
  if (!row) return null
68
88
  if (column) return (row[column] as T) ?? null
69
89
  const firstKey = Object.keys(row)[0]
@@ -75,7 +95,7 @@ export async function execute(
75
95
  db: D1Database,
76
96
  statement: string,
77
97
  params?: SqlParams
78
- ): Promise<D1Result<unknown>> {
98
+ ): Promise<D1Result> {
79
99
  const prepared = prepareStatement(db, statement, params)
80
100
  return prepared.run()
81
101
  }
@@ -88,7 +108,7 @@ export interface BatchStatement {
88
108
  export async function executeBatch(
89
109
  db: D1Database,
90
110
  statements: BatchStatement[]
91
- ): Promise<D1Result<unknown>[]> {
111
+ ): Promise<D1Result[]> {
92
112
  const preparedStatements = statements.map((item) =>
93
113
  prepareStatement(db, item.statement, item.params)
94
114
  )
@@ -65,6 +65,7 @@ app.use('/auth/*', async (c, next) => {
65
65
  const path = c.req.path
66
66
  // Only apply strict rate limit to login-related endpoints (brute force protection)
67
67
  if (path === '/auth/login' || path === '/auth/callback') {
68
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Hono wildcard route typing
68
69
  return loginRateLimiter(c, next)
69
70
  }
70
71
  // Other auth endpoints (/auth/me, /auth/refresh, /auth/logout) use global rate limit
@@ -1,7 +1,7 @@
1
1
  // src/server/lib/audited-db.ts
2
2
  import type { ServiceContext } from '../types'
3
3
  import { logAudit, createChangeDiff } from './audit'
4
- import { execute, queryAll, type SqlParams } from '../db/sql'
4
+ import { execute, queryAll, toStringValue, type SqlParams } from '../db/sql'
5
5
 
6
6
  /**
7
7
  * Base interface for auditable records - requires an id field
@@ -64,7 +64,7 @@ function normalizeRows(
64
64
  }
65
65
  }
66
66
 
67
- const columns = Array.from(columnSet)
67
+ const columns = [...columnSet]
68
68
  if (columns.length === 0) {
69
69
  throw new Error('No columns provided for SQL operation')
70
70
  }
@@ -96,11 +96,11 @@ async function auditedInsertSql<TRecord extends AuditableRecord>(
96
96
  ): Promise<TRecord[]> {
97
97
  const { columns, rows } = normalizeRows(values, options?.columnMap)
98
98
  const sql = buildInsertSql(tableName, columns, rows.length)
99
- const params = rows.flatMap((row) => columns.map((column) => row[column]))
99
+ const params = rows.flatMap((row) => columns.map((column) => row[column])) as SqlParams
100
100
  const results = await queryAll<Record<string, unknown>>(db, sql, params)
101
101
 
102
102
  for (const record of results) {
103
- const entityId = String(record[options?.primaryKey ?? 'id'] ?? '')
103
+ const entityId = toStringValue(record[options?.primaryKey ?? 'id'])
104
104
  await logAudit(db, ctx, tableName, entityId, 'INSERT', record)
105
105
  }
106
106
 
@@ -133,7 +133,7 @@ async function auditedUpdateSql<TRecord extends AuditableRecord>(
133
133
  )
134
134
 
135
135
  const setClause = columns.map((column) => `${column} = ?`).join(', ')
136
- const params = columns.map((column) => mappedValues[column]).concat(where.params ?? [])
136
+ const params = [...columns.map((column) => mappedValues[column]), ...(where.params ?? [])] as SqlParams
137
137
 
138
138
  let results: Record<string, unknown>[] = []
139
139
  try {
@@ -154,12 +154,12 @@ async function auditedUpdateSql<TRecord extends AuditableRecord>(
154
154
  const primaryKey = options?.primaryKey ?? 'id'
155
155
  const oldById = new Map<string, Record<string, unknown>>()
156
156
  for (const record of oldRecords) {
157
- const id = String(record[primaryKey] ?? '')
157
+ const id = toStringValue(record[primaryKey])
158
158
  oldById.set(id, record)
159
159
  }
160
160
 
161
161
  for (const record of results) {
162
- const entityId = String(record[primaryKey] ?? '')
162
+ const entityId = toStringValue(record[primaryKey])
163
163
  const oldData = oldById.get(entityId) ?? {}
164
164
  const diff = createChangeDiff(oldData, record)
165
165
  await logAudit(db, ctx, tableName, entityId, 'UPDATE', diff)
@@ -187,7 +187,7 @@ async function auditedDeleteSql(
187
187
  ...options?.softDeleteColumns,
188
188
  }
189
189
 
190
- const setColumns: Array<[string, unknown]> = [
190
+ const setColumns: [string, unknown][] = [
191
191
  [columns.deletedAt, timestamp],
192
192
  [columns.deletedById, ctx.user.id],
193
193
  [columns.updatedAt, timestamp],
@@ -195,7 +195,7 @@ async function auditedDeleteSql(
195
195
  ]
196
196
 
197
197
  const setClause = setColumns.map(([column]) => `${column} = ?`).join(', ')
198
- const params = setColumns.map(([, value]) => value).concat(where.params ?? [])
198
+ const params = [...setColumns.map(([, value]) => value), ...(where.params ?? [])] as SqlParams
199
199
 
200
200
  let results: Record<string, unknown>[] = []
201
201
  try {
@@ -215,7 +215,7 @@ async function auditedDeleteSql(
215
215
 
216
216
  const primaryKey = options?.primaryKey ?? 'id'
217
217
  for (const record of results) {
218
- const entityId = String(record[primaryKey] ?? '')
218
+ const entityId = toStringValue(record[primaryKey])
219
219
  await logAudit(db, ctx, tableName, entityId, 'DELETE', { deleted: true })
220
220
  }
221
221
  }
@@ -52,7 +52,7 @@ export const accountMiddleware = createMiddleware<HonoEnv>(async (c, next) => {
52
52
 
53
53
  // Set accountId and userRole in context
54
54
  c.set('accountId', accountId)
55
- c.set('userRole', membership.role as Role)
55
+ c.set('userRole', membership.role)
56
56
  c.set('isSystemAdminAccess', false)
57
57
 
58
58
  await next()
@@ -4,7 +4,7 @@ import { verify } from 'hono/jwt'
4
4
  import { HTTPException } from 'hono/http-exception'
5
5
  import type { HonoEnv } from '../types'
6
6
  import { getSession } from '../lib/session'
7
- import { queryOne, type SqlRow } from '../db/sql'
7
+ import { queryOne, toStringValue, toNullableString, type SqlRow } from '../db/sql'
8
8
 
9
9
  interface JWTPayload {
10
10
  sub: string
@@ -58,15 +58,15 @@ function mapUserRow(row: SqlRow) {
58
58
  const deletedAt = row.deletedAt ?? row.deleted_at
59
59
 
60
60
  return {
61
- id: String(row.id ?? ''),
62
- email: String(row.email ?? ''),
63
- name: String(row.name ?? ''),
61
+ id: toStringValue(row.id),
62
+ email: toStringValue(row.email),
63
+ name: toStringValue(row.name),
64
64
  status: row.status === 'inactive' ? 'inactive' : 'active',
65
65
  providerIds: parseProviderIds(providerIds),
66
66
  isSuperAdmin: toBoolean(isSuperAdmin),
67
- createdAt: String(createdAt ?? ''),
68
- updatedAt: String(updatedAt ?? ''),
69
- deletedAt: deletedAt ? String(deletedAt) : null,
67
+ createdAt: toStringValue(createdAt),
68
+ updatedAt: toStringValue(updatedAt),
69
+ deletedAt: toNullableString(deletedAt),
70
70
  }
71
71
  }
72
72
 
@@ -103,7 +103,7 @@ export const sessionAuth = createMiddleware<HonoEnv>(async (c, next) => {
103
103
  })
104
104
  }
105
105
 
106
- const mappedUser = mapUserRow(user as SqlRow)
106
+ const mappedUser = mapUserRow(user)
107
107
 
108
108
  if (mappedUser.status !== 'active') {
109
109
  throw new HTTPException(401, {
@@ -117,7 +117,7 @@ export const sessionAuth = createMiddleware<HonoEnv>(async (c, next) => {
117
117
  email: mappedUser.email,
118
118
  name: mappedUser.name,
119
119
  status: mappedUser.status,
120
- providerIds: mappedUser.providerIds ?? [],
120
+ providerIds: mappedUser.providerIds,
121
121
  isSuperAdmin: mappedUser.isSuperAdmin,
122
122
  createdAt: mappedUser.createdAt,
123
123
  updatedAt: mappedUser.updatedAt,
@@ -185,7 +185,7 @@ export const jwtAuth = createMiddleware<HonoEnv>(async (c, next) => {
185
185
  })
186
186
  }
187
187
 
188
- const mappedUser = mapUserRow(user as SqlRow)
188
+ const mappedUser = mapUserRow(user)
189
189
 
190
190
  if (mappedUser.status !== 'active') {
191
191
  throw new HTTPException(401, {
@@ -199,7 +199,7 @@ export const jwtAuth = createMiddleware<HonoEnv>(async (c, next) => {
199
199
  email: mappedUser.email,
200
200
  name: mappedUser.name,
201
201
  status: mappedUser.status,
202
- providerIds: mappedUser.providerIds ?? [],
202
+ providerIds: mappedUser.providerIds,
203
203
  isSuperAdmin: mappedUser.isSuperAdmin,
204
204
  createdAt: mappedUser.createdAt,
205
205
  updatedAt: mappedUser.updatedAt,
@@ -49,11 +49,8 @@ class RateLimitStore {
49
49
  private cleanupInterval: ReturnType<typeof setInterval> | null = null
50
50
  private lastCleanup = 0
51
51
 
52
- constructor() {
53
- // Don't use setInterval at construction time - Cloudflare Workers
54
- // don't allow async I/O at global scope. Instead, we'll cleanup
55
- // lazily during request processing.
56
- }
52
+ // Note: Don't use setInterval at construction time - Cloudflare Workers
53
+ // don't allow async I/O at global scope. Instead, we cleanup lazily.
57
54
 
58
55
  /**
59
56
  * Start periodic cleanup (call from within a handler, not at global scope)
@@ -244,7 +241,7 @@ export function authRateLimit() {
244
241
  message: 'Too many authentication attempts, please try again later',
245
242
  // Use separate key prefix to not conflict with global rate limit
246
243
  keyGenerator: (c) => {
247
- const ip = c.get('ip') || c.req.header('x-forwarded-for')?.split(',')[0].trim() || 'unknown'
244
+ const ip = c.get('ip') ?? c.req.header('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown'
248
245
  return `auth:${ip}` // Different prefix than global rate limit
249
246
  },
250
247
  })
@@ -61,14 +61,14 @@ export const loginHandler: RouteHandler<typeof loginRoute, HonoEnv> = async (c)
61
61
  export const callbackHandler: RouteHandler<typeof callbackRoute, HonoEnv> = async (c) => {
62
62
  const db = c.get('db')
63
63
  const env = c.env
64
- const authDb = env.DB ?? db
65
- const inviteDb = env.DB ?? db
66
64
  const { code, state } = c.req.valid('query')
67
65
  const ctx = getAuthContext(c)
68
66
 
69
- if (!db || !authDb) {
67
+ const authDb = env.DB ?? db
68
+ if (!authDb) {
70
69
  throw new HTTPException(500, { message: 'Database not initialized' })
71
70
  }
71
+ const inviteDb = authDb
72
72
 
73
73
  // Get stored OAuth state
74
74
  const oauthCookie = getCookie(c, 'oauth_state')
@@ -197,10 +197,10 @@ export const meHandler: RouteHandler<typeof meRoute, HonoEnv> = (c) => {
197
197
  export const inviteHandler: RouteHandler<typeof inviteRoute, HonoEnv> = async (c) => {
198
198
  const db = c.get('db')
199
199
  const env = c.env
200
- const inviteDb = env.DB ?? db
201
200
  const { token } = c.req.valid('param')
202
201
 
203
- if (!db) {
202
+ const inviteDb = env.DB ?? db
203
+ if (!inviteDb) {
204
204
  throw new HTTPException(500, { message: 'Database not initialized' })
205
205
  }
206
206
 
@@ -2,7 +2,7 @@
2
2
  import type { RouteHandler } from '@hono/zod-openapi'
3
3
  import { createRoute, z } from '@hono/zod-openapi'
4
4
  import { HTTPException } from 'hono/http-exception'
5
- import { execute, queryOne, type SqlRow } from '../../db/sql'
5
+ import { execute, queryOne, toStringValue, toNullableString, type SqlRow } from '../../db/sql'
6
6
  import type { UserRecord } from '../../db/records'
7
7
  import { createSession } from '../../lib/session'
8
8
  import type { HonoEnv } from '../../types'
@@ -34,17 +34,17 @@ function toBoolean(value: unknown): boolean {
34
34
 
35
35
  function mapUserRow(row: SqlRow): UserRecord {
36
36
  return {
37
- id: String(row.id ?? ''),
38
- googleId: String(row.googleId ?? row.google_id ?? ''),
39
- email: String(row.email ?? ''),
40
- name: String(row.name ?? ''),
41
- avatarUrl: row.avatarUrl ? String(row.avatarUrl) : null,
37
+ id: toStringValue(row.id),
38
+ googleId: toStringValue(row.googleId ?? row.google_id),
39
+ email: toStringValue(row.email),
40
+ name: toStringValue(row.name),
41
+ avatarUrl: toNullableString(row.avatarUrl),
42
42
  status: row.status === 'inactive' ? 'inactive' : 'active',
43
43
  providerIds: [],
44
44
  isSuperAdmin: toBoolean(row.isSuperAdmin ?? row.is_super_admin),
45
- createdAt: String(row.createdAt ?? row.created_at ?? ''),
46
- updatedAt: String(row.updatedAt ?? row.updated_at ?? ''),
47
- deletedAt: row.deletedAt ? String(row.deletedAt) : null,
45
+ createdAt: toStringValue(row.createdAt ?? row.created_at),
46
+ updatedAt: toStringValue(row.updatedAt ?? row.updated_at),
47
+ deletedAt: toNullableString(row.deletedAt),
48
48
  }
49
49
  }
50
50
 
@@ -51,3 +51,12 @@ api.doc('/doc', openApiConfig)
51
51
  api.get('/swagger', swaggerUI({ url: '/api/doc' }))
52
52
 
53
53
  export { api }
54
+
55
+ // Re-export individual routers for testing
56
+ export { users } from './users'
57
+ export { accounts } from './accounts'
58
+ export { invitationsRouter } from './invitations'
59
+ export { audits } from './audits'
60
+ export { storage } from './storage'
61
+ export { health } from './health'
62
+ export { auth } from './auth'
@@ -32,10 +32,10 @@ export const createInvitationHandler: RouteHandler<typeof createInvitationRoute,
32
32
  const body = c.req.valid('json')
33
33
  const db = c.get('db')
34
34
  const env = c.env
35
- const invitationsDb = env.DB ?? db
36
35
  const ctx = getServiceContext(c)
37
36
 
38
- if (!db) {
37
+ const invitationsDb = env.DB ?? db
38
+ if (!invitationsDb) {
39
39
  throw new HTTPException(500, { message: 'Database not initialized' })
40
40
  }
41
41
 
@@ -46,10 +46,10 @@ export const createInvitationHandler: RouteHandler<typeof createInvitationRoute,
46
46
 
47
47
  export const listInvitationsHandler: RouteHandler<typeof listInvitationsRoute, HonoEnv> = async (c) => {
48
48
  const db = c.get('db')
49
- const invitationsDb = c.env.DB ?? db
50
49
  const ctx = getServiceContext(c)
51
50
 
52
- if (!db) {
51
+ const invitationsDb = c.env.DB ?? db
52
+ if (!invitationsDb) {
53
53
  throw new HTTPException(500, { message: 'Database not initialized' })
54
54
  }
55
55
 
@@ -61,10 +61,10 @@ export const listInvitationsHandler: RouteHandler<typeof listInvitationsRoute, H
61
61
  export const revokeInvitationHandler: RouteHandler<typeof revokeInvitationRoute, HonoEnv> = async (c) => {
62
62
  const { id } = c.req.valid('param')
63
63
  const db = c.get('db')
64
- const invitationsDb = c.env.DB ?? db
65
64
  const ctx = getServiceContext(c)
66
65
 
67
- if (!db) {
66
+ const invitationsDb = c.env.DB ?? db
67
+ if (!invitationsDb) {
68
68
  throw new HTTPException(500, { message: 'Database not initialized' })
69
69
  }
70
70
 
@@ -11,4 +11,4 @@ export const openApiConfig = {
11
11
  { url: 'http://localhost:3000', description: 'Development server' },
12
12
  ],
13
13
  security: [{ SessionCookie: [] }],
14
- } as const
14
+ }
@@ -4,7 +4,7 @@ import { auditedInsert, auditedUpdate, auditedDelete } from '../lib/audited-db'
4
4
  import { createPaginationMeta, calculateOffset } from '../lib/pagination'
5
5
  import { NotFoundError, ConflictError, ForbiddenError } from '../lib/errors'
6
6
  import type { ServiceContext, PaginationQuery, PaginatedResponse, Account } from '../types'
7
- import { queryAll, queryOne, type SqlRow } from '../db/sql'
7
+ import { queryAll, queryOne, toStringValue, toNullableString, type SqlRow, type SqlParams } from '../db/sql'
8
8
 
9
9
  interface CreateAccountInput {
10
10
  name: string
@@ -36,13 +36,13 @@ function mapAccountRow(row: SqlRow): AccountRecord {
36
36
  const deletedAt = row.deletedAt ?? row.deleted_at
37
37
 
38
38
  return {
39
- id: String(row.id ?? ''),
40
- name: String(row.name ?? ''),
41
- description: row.description ? String(row.description) : null,
42
- domain: row.domain ? String(row.domain) : null,
43
- createdAt: String(createdAt ?? ''),
44
- updatedAt: String(updatedAt ?? ''),
45
- deletedAt: deletedAt ? String(deletedAt) : null,
39
+ id: toStringValue(row.id),
40
+ name: toStringValue(row.name),
41
+ description: toNullableString(row.description),
42
+ domain: toNullableString(row.domain),
43
+ createdAt: toStringValue(createdAt),
44
+ updatedAt: toStringValue(updatedAt),
45
+ deletedAt: toNullableString(deletedAt),
46
46
  }
47
47
  }
48
48
 
@@ -65,7 +65,7 @@ async function findAllSql(
65
65
  ): Promise<PaginatedResponse<Account>> {
66
66
  const offset = calculateOffset(pagination.page, pagination.limit)
67
67
  const whereClauses: string[] = ['a.deleted_at IS NULL']
68
- const params: unknown[] = []
68
+ const params: SqlParams = []
69
69
 
70
70
  if (!ctx.user.isSuperAdmin) {
71
71
  whereClauses.push('a.id IN (SELECT account_id FROM user_accounts WHERE user_id = ?)')
@@ -1,7 +1,7 @@
1
1
  // src/server/services/audits.ts
2
2
  import { createPaginationMeta, calculateOffset } from '../lib/pagination'
3
3
  import type { ServiceContext, PaginatedResponse, AuditLog } from '../types'
4
- import { queryAll, queryOne, type SqlRow } from '../db/sql'
4
+ import { queryAll, queryOne, toStringValue, toNullableString, type SqlRow, type SqlParams } from '../db/sql'
5
5
 
6
6
  export interface AuditLogFilters {
7
7
  page: number
@@ -53,17 +53,17 @@ function mapAuditRow(row: SqlRow): AuditLog {
53
53
  const changesValue = row.changes
54
54
 
55
55
  return {
56
- id: String(row.id ?? ''),
57
- transactionId: String(transactionId ?? ''),
58
- accountId: accountId ? String(accountId) : null,
59
- userId: userId ? String(userId) : null,
60
- entity: String(row.entity ?? ''),
61
- entityId: String(entityId ?? ''),
62
- action: String(row.action ?? '') as AuditLog['action'],
56
+ id: toStringValue(row.id),
57
+ transactionId: toStringValue(transactionId),
58
+ accountId: toNullableString(accountId),
59
+ userId: toNullableString(userId),
60
+ entity: toStringValue(row.entity),
61
+ entityId: toStringValue(entityId),
62
+ action: toStringValue(row.action) as AuditLog['action'],
63
63
  changes: parseChanges(changesValue),
64
- ipAddress: ipAddress ? String(ipAddress) : null,
65
- userAgent: userAgent ? String(userAgent) : null,
66
- timestamp: String(row.timestamp ?? ''),
64
+ ipAddress: toNullableString(ipAddress),
65
+ userAgent: toNullableString(userAgent),
66
+ timestamp: toStringValue(row.timestamp),
67
67
  }
68
68
  }
69
69
 
@@ -75,7 +75,7 @@ async function findAllSql(
75
75
  ): Promise<PaginatedResponse<AuditLog>> {
76
76
  const offset = calculateOffset(filters.page, filters.limit)
77
77
  const whereClauses: string[] = []
78
- const params: unknown[] = []
78
+ const params: SqlParams = []
79
79
 
80
80
  // Super-admin can see all logs, non-super-admin only their account
81
81
  if (!ctx.user.isSuperAdmin) {
@@ -6,7 +6,7 @@ import { UnauthorizedError } from '../lib/errors'
6
6
  import { logAuthEvent, type AuthEventContext } from '../lib/audit'
7
7
  import type { GoogleUserInfo, AuthTokens } from '../types/auth'
8
8
  import type { User } from '../types'
9
- import { execute, queryOne, type SqlRow } from '../db/sql'
9
+ import { execute, queryOne, toStringValue, toNullableString, type SqlRow, type SqlParams } from '../db/sql'
10
10
 
11
11
  interface AuthResult {
12
12
  user: User
@@ -60,17 +60,17 @@ function parseProviderIds(value: unknown): string[] {
60
60
 
61
61
  function mapUserRow(row: SqlRow): UserRecord {
62
62
  return {
63
- id: String(row.id ?? ''),
64
- googleId: String(row.googleId ?? ''),
65
- email: String(row.email ?? ''),
66
- name: String(row.name ?? ''),
67
- avatarUrl: row.avatarUrl ? String(row.avatarUrl) : null,
63
+ id: toStringValue(row.id),
64
+ googleId: toStringValue(row.googleId),
65
+ email: toStringValue(row.email),
66
+ name: toStringValue(row.name),
67
+ avatarUrl: toNullableString(row.avatarUrl),
68
68
  status: (row.status === 'inactive' ? 'inactive' : 'active'),
69
69
  providerIds: parseProviderIds(row.providerIds),
70
70
  isSuperAdmin: toBoolean(row.isSuperAdmin),
71
- createdAt: String(row.createdAt ?? ''),
72
- updatedAt: String(row.updatedAt ?? ''),
73
- deletedAt: row.deletedAt ? String(row.deletedAt) : null,
71
+ createdAt: toStringValue(row.createdAt),
72
+ updatedAt: toStringValue(row.updatedAt),
73
+ deletedAt: toNullableString(row.deletedAt),
74
74
  }
75
75
  }
76
76
 
@@ -80,7 +80,7 @@ function toUser(record: UserRecord): User {
80
80
  email: record.email,
81
81
  name: record.name,
82
82
  status: record.status,
83
- providerIds: record.providerIds ?? [],
83
+ providerIds: record.providerIds,
84
84
  isSuperAdmin: record.isSuperAdmin,
85
85
  createdAt: record.createdAt,
86
86
  updatedAt: record.updatedAt,
@@ -121,7 +121,7 @@ async function updateUserSql(
121
121
  if (columns.length === 0) return selectUserById(db, userId)
122
122
 
123
123
  const setClause = columns.map((column) => `${column} = ?`).join(', ')
124
- const params = columns.map((column) => updates[column])
124
+ const params = columns.map((column) => updates[column]) as SqlParams
125
125
  await execute(
126
126
  db,
127
127
  `UPDATE users SET ${setClause} WHERE id = ?`,
@@ -137,7 +137,7 @@ async function insertUserSql(
137
137
  ): Promise<UserRecord | null> {
138
138
  const columns = Object.keys(values)
139
139
  const placeholders = columns.map(() => '?').join(', ')
140
- const params = columns.map((column) => values[column])
140
+ const params = columns.map((column) => values[column]) as SqlParams
141
141
 
142
142
  await execute(
143
143
  db,
@@ -145,7 +145,7 @@ async function insertUserSql(
145
145
  params
146
146
  )
147
147
 
148
- return selectUserById(db, String(values.id ?? ''))
148
+ return selectUserById(db, toStringValue(values.id))
149
149
  }
150
150
 
151
151
  async function findOrCreateUserSql(
@@ -265,8 +265,8 @@ async function refreshAccessTokenSql(
265
265
  throw new UnauthorizedError('Invalid or expired refresh token')
266
266
  }
267
267
 
268
- const userRecord = await selectUserById(db, String(tokenRecord.userId ?? ''))
269
- if (!userRecord || userRecord.status !== 'active') {
268
+ const userRecord = await selectUserById(db, toStringValue(tokenRecord.userId))
269
+ if (userRecord?.status !== 'active') {
270
270
  throw new UnauthorizedError('User not found or inactive')
271
271
  }
272
272
 
@@ -5,7 +5,7 @@ import { ConflictError, NotFoundError, ForbiddenError } from '../lib/errors'
5
5
  import type { Env } from '../env'
6
6
  import { hasMinimumRole, type Role } from '../auth/roles'
7
7
  import type { ServiceContext } from '../types'
8
- import { execute, queryAll, queryOne, type SqlRow } from '../db/sql'
8
+ import { execute, queryAll, queryOne, toStringValue, type SqlRow } from '../db/sql'
9
9
 
10
10
  function generateToken(): string {
11
11
  const array = new Uint8Array(32)
@@ -58,13 +58,13 @@ function mapInvitationRow(row: SqlRow): {
58
58
  const createdAt = row.createdAt ?? row.created_at
59
59
 
60
60
  return {
61
- id: String(row.id ?? ''),
62
- email: String(row.email ?? ''),
63
- role: String(row.role ?? '') as Role,
64
- invitedById: String(invitedById ?? ''),
65
- inviterName: String(inviterName ?? ''),
66
- expiresAt: String(expiresAt ?? ''),
67
- createdAt: String(createdAt ?? ''),
61
+ id: toStringValue(row.id),
62
+ email: toStringValue(row.email),
63
+ role: toStringValue(row.role) as Role,
64
+ invitedById: toStringValue(invitedById),
65
+ inviterName: toStringValue(inviterName),
66
+ expiresAt: toStringValue(expiresAt),
67
+ createdAt: toStringValue(createdAt),
68
68
  }
69
69
  }
70
70
 
@@ -271,14 +271,14 @@ async function getByTokenSql(
271
271
  if (acceptedAt) return null
272
272
 
273
273
  const expiresAtValue = row.expiresAt ?? row.expires_at
274
- if (expiresAtValue && new Date(String(expiresAtValue)) < new Date()) return null
274
+ if (expiresAtValue && new Date(toStringValue(expiresAtValue)) < new Date()) return null
275
275
 
276
276
  return {
277
- id: String(row.id ?? ''),
278
- accountId: String(row.accountId ?? row.account_id ?? ''),
279
- email: String(row.email ?? ''),
280
- role: String(row.role ?? '') as Role,
281
- accountName: String(row.accountName ?? row.account_name ?? ''),
277
+ id: toStringValue(row.id),
278
+ accountId: toStringValue(row.accountId ?? row.account_id),
279
+ email: toStringValue(row.email),
280
+ role: toStringValue(row.role) as Role,
281
+ accountName: toStringValue(row.accountName ?? row.account_name),
282
282
  }
283
283
  }
284
284
 
@@ -298,8 +298,8 @@ async function acceptSql(
298
298
  throw new NotFoundError('Invitation')
299
299
  }
300
300
 
301
- const accountId = String(invitation.accountId ?? invitation.account_id ?? '')
302
- const role = String(invitation.role ?? '') as Role
301
+ const accountId = toStringValue(invitation.accountId ?? invitation.account_id)
302
+ const role = toStringValue(invitation.role) as Role
303
303
 
304
304
  await execute(
305
305
  db,