schematic-pg 0.1.7 → 0.1.10

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 (78) hide show
  1. package/README.md +237 -970
  2. package/dist/api/auth/jwt-crypto.d.ts +10 -0
  3. package/dist/api/auth/jwt-crypto.js +61 -0
  4. package/dist/api/auth/jwt-resolver.js +2 -27
  5. package/dist/api/auth/password/config.d.ts +32 -0
  6. package/dist/api/auth/password/config.js +35 -0
  7. package/dist/api/auth/password/errors.d.ts +8 -0
  8. package/dist/api/auth/password/errors.js +14 -0
  9. package/dist/api/auth/password/index.d.ts +3 -0
  10. package/dist/api/auth/password/index.js +3 -0
  11. package/dist/api/auth/password/password.d.ts +14 -0
  12. package/dist/api/auth/password/password.js +46 -0
  13. package/dist/api/auth/routes.d.ts +23 -0
  14. package/dist/api/auth/routes.js +118 -0
  15. package/dist/api/auth/token/config.d.ts +10 -0
  16. package/dist/api/auth/token/config.js +43 -0
  17. package/dist/api/auth/token/errors.d.ts +6 -0
  18. package/dist/api/auth/token/errors.js +12 -0
  19. package/dist/api/auth/token/index.d.ts +3 -0
  20. package/dist/api/auth/token/index.js +3 -0
  21. package/dist/api/auth/token/token.d.ts +11 -0
  22. package/dist/api/auth/token/token.js +31 -0
  23. package/dist/api/hooks/define.d.ts +10 -0
  24. package/dist/api/hooks/define.js +3 -0
  25. package/dist/api/hooks/index.d.ts +4 -0
  26. package/dist/api/hooks/index.js +2 -0
  27. package/dist/api/hooks/registry.d.ts +8 -0
  28. package/dist/api/hooks/registry.js +94 -0
  29. package/dist/api/hooks/types.d.ts +48 -0
  30. package/dist/api/hooks/types.js +1 -0
  31. package/dist/api/middleware/errors.js +11 -0
  32. package/dist/api-generator/app-generator.js +3 -0
  33. package/dist/api-generator/hook-scanner.d.ts +11 -0
  34. package/dist/api-generator/hook-scanner.js +36 -0
  35. package/dist/api-generator/hooks-generator.d.ts +2 -0
  36. package/dist/api-generator/hooks-generator.js +25 -0
  37. package/dist/api-generator/index.d.ts +2 -0
  38. package/dist/api-generator/index.js +7 -1
  39. package/dist/api-generator/route-generator.d.ts +3 -2
  40. package/dist/api-generator/route-generator.js +85 -64
  41. package/dist/cli/dev.js +5 -36
  42. package/dist/cli/generate.js +1 -0
  43. package/dist/cli/hooks.d.ts +6 -0
  44. package/dist/cli/hooks.js +85 -0
  45. package/dist/cli/init.js +9 -2
  46. package/dist/cli/paths.d.ts +1 -0
  47. package/dist/cli/paths.js +1 -0
  48. package/dist/cli/server.d.ts +5 -0
  49. package/dist/cli/server.js +60 -0
  50. package/dist/cli/start.d.ts +7 -0
  51. package/dist/cli/start.js +35 -0
  52. package/dist/cli/templates/agents.md +290 -0
  53. package/dist/cli/templates.d.ts +6 -3
  54. package/dist/cli/templates.js +58 -6
  55. package/dist/cli/wait-for-database.js +1 -1
  56. package/dist/cli.js +10 -0
  57. package/dist/db/db-client-generator.js +20 -2
  58. package/dist/db/include/executor.d.ts +2 -2
  59. package/dist/db/include/executor.js +7 -7
  60. package/dist/db/include/json-agg.d.ts +2 -2
  61. package/dist/db/include/json-agg.js +4 -4
  62. package/dist/db/include/load.d.ts +3 -3
  63. package/dist/db/include/load.js +8 -8
  64. package/dist/db/index.d.ts +4 -0
  65. package/dist/db/index.js +2 -0
  66. package/dist/db/model-client.d.ts +2 -2
  67. package/dist/db/model-client.js +3 -3
  68. package/dist/db/queryable.d.ts +5 -0
  69. package/dist/db/queryable.js +1 -0
  70. package/dist/db/raw.d.ts +22 -0
  71. package/dist/db/raw.js +35 -0
  72. package/dist/db/transaction.d.ts +2 -0
  73. package/dist/db/transaction.js +24 -0
  74. package/dist/routes/auth.d.ts +3 -0
  75. package/dist/routes/auth.js +5 -0
  76. package/dist/types/generated-db.stub.d.ts +5 -1
  77. package/dist/types/generated-db.stub.js +8 -1
  78. package/package.json +11 -4
@@ -0,0 +1,10 @@
1
+ /** HS256 only — matches createJwtResolver / createTokenService. */
2
+ export declare const JWT_ALGORITHM = "HS256";
3
+ export declare function base64UrlEncode(value: string | Buffer): string;
4
+ export declare function base64UrlDecode(value: string): Buffer;
5
+ export declare function signHs256Jwt(payload: Record<string, unknown>, secret: string): string;
6
+ /**
7
+ * Verify HS256 signature and optional exp/nbf.
8
+ * Tokens without exp/nbf remain valid (backward compatible with older test JWTs).
9
+ */
10
+ export declare function verifyHs256Jwt(token: string, secret: string, nowSeconds?: number): Record<string, unknown>;
@@ -0,0 +1,61 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { UnauthorizedError } from './errors.js';
3
+ /** HS256 only — matches createJwtResolver / createTokenService. */
4
+ export const JWT_ALGORITHM = 'HS256';
5
+ export function base64UrlEncode(value) {
6
+ const buffer = typeof value === 'string' ? Buffer.from(value, 'utf8') : value;
7
+ return buffer.toString('base64url');
8
+ }
9
+ export function base64UrlDecode(value) {
10
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
11
+ const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
12
+ return Buffer.from(normalized + padding, 'base64');
13
+ }
14
+ export function signHs256Jwt(payload, secret) {
15
+ const header = base64UrlEncode(JSON.stringify({ alg: JWT_ALGORITHM, typ: 'JWT' }));
16
+ const body = base64UrlEncode(JSON.stringify(payload));
17
+ const signingInput = `${header}.${body}`;
18
+ const signature = createHmac('sha256', secret).update(signingInput).digest('base64url');
19
+ return `${signingInput}.${signature}`;
20
+ }
21
+ /**
22
+ * Verify HS256 signature and optional exp/nbf.
23
+ * Tokens without exp/nbf remain valid (backward compatible with older test JWTs).
24
+ */
25
+ export function verifyHs256Jwt(token, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
26
+ const parts = token.split('.');
27
+ if (parts.length !== 3) {
28
+ throw new UnauthorizedError('Invalid JWT format');
29
+ }
30
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
31
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
32
+ const expectedSignature = createHmac('sha256', secret).update(signingInput).digest();
33
+ const actualSignature = base64UrlDecode(encodedSignature);
34
+ if (expectedSignature.length !== actualSignature.length ||
35
+ !timingSafeEqual(expectedSignature, actualSignature)) {
36
+ throw new UnauthorizedError('Invalid JWT signature');
37
+ }
38
+ const header = JSON.parse(base64UrlDecode(encodedHeader).toString('utf8'));
39
+ if (header.alg !== JWT_ALGORITHM) {
40
+ throw new UnauthorizedError(`Unsupported JWT algorithm "${header.alg ?? 'unknown'}"`);
41
+ }
42
+ const payload = JSON.parse(base64UrlDecode(encodedPayload).toString('utf8'));
43
+ assertJwtTimeClaims(payload, nowSeconds);
44
+ return payload;
45
+ }
46
+ function assertJwtTimeClaims(payload, nowSeconds) {
47
+ const exp = payload.exp;
48
+ if (exp !== undefined && exp !== null) {
49
+ const expSeconds = Number(exp);
50
+ if (!Number.isFinite(expSeconds) || nowSeconds >= expSeconds) {
51
+ throw new UnauthorizedError('JWT has expired');
52
+ }
53
+ }
54
+ const nbf = payload.nbf;
55
+ if (nbf !== undefined && nbf !== null) {
56
+ const nbfSeconds = Number(nbf);
57
+ if (!Number.isFinite(nbfSeconds) || nowSeconds < nbfSeconds) {
58
+ throw new UnauthorizedError('JWT is not yet valid');
59
+ }
60
+ }
61
+ }
@@ -1,9 +1,8 @@
1
- import { createHmac, timingSafeEqual } from 'node:crypto';
2
1
  import { UnauthorizedError } from './errors.js';
2
+ import { verifyHs256Jwt } from './jwt-crypto.js';
3
3
  const BEARER_PREFIX = 'Bearer ';
4
4
  const DEFAULT_ROLE_CLAIM = 'role';
5
5
  const DEFAULT_USER_ID_CLAIM = 'sub';
6
- const JWT_ALGORITHM = 'HS256';
7
6
  export function createJwtResolver(options = {}) {
8
7
  const roleClaim = options.roleClaim ?? process.env.JWT_ROLE_CLAIM ?? DEFAULT_ROLE_CLAIM;
9
8
  const userIdClaim = options.userIdClaim ?? process.env.JWT_USER_ID_CLAIM ?? DEFAULT_USER_ID_CLAIM;
@@ -20,7 +19,7 @@ export function createJwtResolver(options = {}) {
20
19
  if (!secret) {
21
20
  throw new UnauthorizedError('JWT_SECRET is not configured');
22
21
  }
23
- const payload = verifyJwt(token, secret);
22
+ const payload = verifyHs256Jwt(token, secret);
24
23
  const role = String(payload[roleClaim] ?? 'PUBLIC');
25
24
  const userId = payload[userIdClaim];
26
25
  if (userId === undefined || userId === null || userId === '') {
@@ -33,27 +32,3 @@ export function createJwtResolver(options = {}) {
33
32
  return { role, user };
34
33
  };
35
34
  }
36
- function verifyJwt(token, secret) {
37
- const parts = token.split('.');
38
- if (parts.length !== 3) {
39
- throw new UnauthorizedError('Invalid JWT format');
40
- }
41
- const [encodedHeader, encodedPayload, encodedSignature] = parts;
42
- const signingInput = `${encodedHeader}.${encodedPayload}`;
43
- const expectedSignature = createHmac('sha256', secret).update(signingInput).digest();
44
- const actualSignature = base64UrlDecode(encodedSignature);
45
- if (expectedSignature.length !== actualSignature.length ||
46
- !timingSafeEqual(expectedSignature, actualSignature)) {
47
- throw new UnauthorizedError('Invalid JWT signature');
48
- }
49
- const header = JSON.parse(base64UrlDecode(encodedHeader).toString('utf8'));
50
- if (header.alg !== JWT_ALGORITHM) {
51
- throw new UnauthorizedError(`Unsupported JWT algorithm "${header.alg ?? 'unknown'}"`);
52
- }
53
- return JSON.parse(base64UrlDecode(encodedPayload).toString('utf8'));
54
- }
55
- function base64UrlDecode(value) {
56
- const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
57
- const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
58
- return Buffer.from(normalized + padding, 'base64');
59
- }
@@ -0,0 +1,32 @@
1
+ import { argon2id } from 'argon2';
2
+ /**
3
+ * Argon2id (hybrid) resists both GPU/ASIC cracking and side-channel leaks better
4
+ * than pure argon2i/argon2d for password storage (OWASP Password Storage Cheat Sheet).
5
+ * Version 19 (0x13) is the current Argon2 RFC reference version.
6
+ */
7
+ export declare const ARGON2_VERSION = 19;
8
+ /**
9
+ * Single source of truth for hash parameters. Do not scatter these literals.
10
+ * memoryCost 65536 = 64 MiB; timeCost 3 ≈ interactive login cost.
11
+ */
12
+ export declare const DEFAULT_PASSWORD_CONFIG: {
13
+ readonly memoryCost: 65536;
14
+ readonly timeCost: 3;
15
+ readonly parallelism: number;
16
+ readonly type: 2;
17
+ readonly version: 19;
18
+ };
19
+ export type PasswordConfig = {
20
+ memoryCost: number;
21
+ timeCost: number;
22
+ parallelism: number;
23
+ type: typeof argon2id;
24
+ version: number;
25
+ };
26
+ /** Pepper is application-side secret; never persisted with the hash. */
27
+ export declare function resolvePepper(env?: NodeJS.ProcessEnv): string;
28
+ /**
29
+ * Bind password to the app by appending pepper before hash/verify.
30
+ * Compromise of the hash DB alone is insufficient without AUTH_PEPPER.
31
+ */
32
+ export declare function applyPepper(password: string, pepper: string): string;
@@ -0,0 +1,35 @@
1
+ import { availableParallelism } from 'node:os';
2
+ import { argon2id } from 'argon2';
3
+ import { MissingAuthPepperError } from './errors.js';
4
+ /**
5
+ * Argon2id (hybrid) resists both GPU/ASIC cracking and side-channel leaks better
6
+ * than pure argon2i/argon2d for password storage (OWASP Password Storage Cheat Sheet).
7
+ * Version 19 (0x13) is the current Argon2 RFC reference version.
8
+ */
9
+ export const ARGON2_VERSION = 19;
10
+ /**
11
+ * Single source of truth for hash parameters. Do not scatter these literals.
12
+ * memoryCost 65536 = 64 MiB; timeCost 3 ≈ interactive login cost.
13
+ */
14
+ export const DEFAULT_PASSWORD_CONFIG = {
15
+ memoryCost: 65_536,
16
+ timeCost: 3,
17
+ parallelism: availableParallelism(),
18
+ type: argon2id,
19
+ version: ARGON2_VERSION,
20
+ };
21
+ /** Pepper is application-side secret; never persisted with the hash. */
22
+ export function resolvePepper(env = process.env) {
23
+ const pepper = env.AUTH_PEPPER;
24
+ if (pepper === undefined || pepper === null || pepper === '') {
25
+ throw new MissingAuthPepperError();
26
+ }
27
+ return pepper;
28
+ }
29
+ /**
30
+ * Bind password to the app by appending pepper before hash/verify.
31
+ * Compromise of the hash DB alone is insufficient without AUTH_PEPPER.
32
+ */
33
+ export function applyPepper(password, pepper) {
34
+ return password + pepper;
35
+ }
@@ -0,0 +1,8 @@
1
+ /** Raised when AUTH_PEPPER is missing. Pepper must never be stored in the DB. */
2
+ export declare class MissingAuthPepperError extends Error {
3
+ constructor(message?: string);
4
+ }
5
+ /** Raised when the password input is empty, null, undefined, or not a string. */
6
+ export declare class InvalidPasswordInputError extends Error {
7
+ constructor(message?: string);
8
+ }
@@ -0,0 +1,14 @@
1
+ /** Raised when AUTH_PEPPER is missing. Pepper must never be stored in the DB. */
2
+ export class MissingAuthPepperError extends Error {
3
+ constructor(message = 'AUTH_PEPPER is not configured') {
4
+ super(message);
5
+ this.name = 'MissingAuthPepperError';
6
+ }
7
+ }
8
+ /** Raised when the password input is empty, null, undefined, or not a string. */
9
+ export class InvalidPasswordInputError extends Error {
10
+ constructor(message = 'Password must be a non-empty string') {
11
+ super(message);
12
+ this.name = 'InvalidPasswordInputError';
13
+ }
14
+ }
@@ -0,0 +1,3 @@
1
+ export { ARGON2_VERSION, DEFAULT_PASSWORD_CONFIG, applyPepper, resolvePepper, type PasswordConfig, } from './config.js';
2
+ export { InvalidPasswordInputError, MissingAuthPepperError } from './errors.js';
3
+ export { createPasswordService, passwordService, type PasswordService, } from './password.js';
@@ -0,0 +1,3 @@
1
+ export { ARGON2_VERSION, DEFAULT_PASSWORD_CONFIG, applyPepper, resolvePepper, } from './config.js';
2
+ export { InvalidPasswordInputError, MissingAuthPepperError } from './errors.js';
3
+ export { createPasswordService, passwordService, } from './password.js';
@@ -0,0 +1,14 @@
1
+ import { type PasswordConfig } from './config.js';
2
+ export interface PasswordService {
3
+ hashPassword(password: string): Promise<string>;
4
+ verifyPassword(password: string, encodedHash: string): Promise<boolean>;
5
+ needsRehash(encodedHash: string): boolean;
6
+ }
7
+ /**
8
+ * Factory for the password service.
9
+ * Uses Argon2id with automatic salt; output is the full `$argon2id$…` encoded string
10
+ * (algo, version, params, salt, hash) so verify never needs a separate salt column.
11
+ */
12
+ export declare function createPasswordService(config?: PasswordConfig): PasswordService;
13
+ /** Default singleton — import as `passwordService` in app code. */
14
+ export declare const passwordService: PasswordService;
@@ -0,0 +1,46 @@
1
+ import { hash as argon2Hash, needsRehash as argon2NeedsRehash, verify as argon2Verify } from 'argon2';
2
+ import { applyPepper, DEFAULT_PASSWORD_CONFIG, resolvePepper, } from './config.js';
3
+ import { InvalidPasswordInputError } from './errors.js';
4
+ function assertPasswordInput(password) {
5
+ if (typeof password !== 'string' || password.length === 0) {
6
+ throw new InvalidPasswordInputError();
7
+ }
8
+ }
9
+ /**
10
+ * Factory for the password service.
11
+ * Uses Argon2id with automatic salt; output is the full `$argon2id$…` encoded string
12
+ * (algo, version, params, salt, hash) so verify never needs a separate salt column.
13
+ */
14
+ export function createPasswordService(config = DEFAULT_PASSWORD_CONFIG) {
15
+ const hashOptions = {
16
+ memoryCost: config.memoryCost,
17
+ timeCost: config.timeCost,
18
+ parallelism: config.parallelism,
19
+ type: config.type,
20
+ version: config.version,
21
+ };
22
+ return {
23
+ async hashPassword(password) {
24
+ assertPasswordInput(password);
25
+ const pepper = resolvePepper();
26
+ // Never log password, pepper, or the resulting hash.
27
+ return argon2Hash(applyPepper(password, pepper), hashOptions);
28
+ },
29
+ async verifyPassword(password, encodedHash) {
30
+ assertPasswordInput(password);
31
+ const pepper = resolvePepper();
32
+ // Argon2 verify is constant-time for the crypto compare; no manual string compare.
33
+ return argon2Verify(encodedHash, applyPepper(password, pepper));
34
+ },
35
+ needsRehash(encodedHash) {
36
+ return argon2NeedsRehash(encodedHash, {
37
+ memoryCost: config.memoryCost,
38
+ timeCost: config.timeCost,
39
+ parallelism: config.parallelism,
40
+ version: config.version,
41
+ });
42
+ },
43
+ };
44
+ }
45
+ /** Default singleton — import as `passwordService` in app code. */
46
+ export const passwordService = createPasswordService();
@@ -0,0 +1,23 @@
1
+ import { Hono } from 'hono';
2
+ import type { AppEnv } from '../types.js';
3
+ import { type PasswordService } from './password/index.js';
4
+ import { type TokenService } from './token/index.js';
5
+ export interface CreateAuthRouterOptions {
6
+ userModel?: string;
7
+ emailField?: string;
8
+ passwordHashField?: string;
9
+ roleField?: string;
10
+ nameField?: string;
11
+ defaultRole?: string;
12
+ omitFields?: string[];
13
+ /** Merged into create payloads (e.g. required schema defaults like `{ balance: 0 }`). */
14
+ defaultCreateFields?: Record<string, unknown>;
15
+ passwordService?: PasswordService;
16
+ tokenService?: TokenService;
17
+ }
18
+ /**
19
+ * Reusable auth router: POST /register, POST /login, GET /me.
20
+ * Mount via custom routes (src/routes/auth.ts → /auth).
21
+ * Speaks to the DB client directly — does not go through model @policy.
22
+ */
23
+ export declare function createAuthRouter(options?: CreateAuthRouterOptions): Hono<AppEnv>;
@@ -0,0 +1,118 @@
1
+ import { Hono } from 'hono';
2
+ import { z } from 'zod';
3
+ import { UnauthorizedError } from './errors.js';
4
+ import { createPasswordService } from './password/index.js';
5
+ import { createTokenService } from './token/index.js';
6
+ import { omitFields } from '../utils/omit-fields.js';
7
+ import { validateJson } from '../middleware/validate.js';
8
+ import { PUBLIC_ROLE } from './types.js';
9
+ const DEFAULT_USER_MODEL = 'user';
10
+ const DEFAULT_EMAIL_FIELD = 'email';
11
+ const DEFAULT_PASSWORD_HASH_FIELD = 'passwordHash';
12
+ const DEFAULT_ROLE_FIELD = 'role';
13
+ const DEFAULT_NAME_FIELD = 'name';
14
+ const DEFAULT_ROLE = 'USER';
15
+ const DUMMY_PASSWORD = '__schematic-pg-timing-dummy__';
16
+ function resolveUserModel(db, modelKey) {
17
+ const model = db[modelKey];
18
+ if (!model || typeof model !== 'object') {
19
+ throw new Error(`Auth user model "${modelKey}" not found on db client`);
20
+ }
21
+ return model;
22
+ }
23
+ function asUserRecord(row) {
24
+ if (typeof row.id !== 'string' && typeof row.id !== 'number') {
25
+ throw new Error('User row is missing id');
26
+ }
27
+ return row;
28
+ }
29
+ /**
30
+ * Reusable auth router: POST /register, POST /login, GET /me.
31
+ * Mount via custom routes (src/routes/auth.ts → /auth).
32
+ * Speaks to the DB client directly — does not go through model @policy.
33
+ */
34
+ export function createAuthRouter(options = {}) {
35
+ const userModel = options.userModel ?? DEFAULT_USER_MODEL;
36
+ const emailField = options.emailField ?? DEFAULT_EMAIL_FIELD;
37
+ const passwordHashField = options.passwordHashField ?? DEFAULT_PASSWORD_HASH_FIELD;
38
+ const roleField = options.roleField ?? DEFAULT_ROLE_FIELD;
39
+ const nameField = options.nameField ?? DEFAULT_NAME_FIELD;
40
+ const defaultRole = options.defaultRole ?? DEFAULT_ROLE;
41
+ const fieldsToOmit = options.omitFields ?? [passwordHashField];
42
+ const defaultCreateFields = options.defaultCreateFields ?? {};
43
+ const passwordService = options.passwordService ?? createPasswordService();
44
+ const tokenService = options.tokenService ?? createTokenService();
45
+ let dummyHashPromise = null;
46
+ function getDummyHash() {
47
+ if (!dummyHashPromise) {
48
+ dummyHashPromise = passwordService.hashPassword(DUMMY_PASSWORD);
49
+ }
50
+ return dummyHashPromise;
51
+ }
52
+ const registerSchema = z.object({
53
+ email: z.email(),
54
+ password: z.string().min(1),
55
+ name: z.string().min(1).optional(),
56
+ });
57
+ const loginSchema = z.object({
58
+ email: z.email(),
59
+ password: z.string().min(1),
60
+ });
61
+ const router = new Hono();
62
+ router.post('/register', validateJson(registerSchema), async (c) => {
63
+ const db = c.get('db');
64
+ const body = c.req.valid('json');
65
+ const users = resolveUserModel(db, userModel);
66
+ const passwordHash = await passwordService.hashPassword(body.password);
67
+ const createData = {
68
+ ...defaultCreateFields,
69
+ [emailField]: body.email,
70
+ [passwordHashField]: passwordHash,
71
+ [roleField]: defaultRole,
72
+ };
73
+ if (body.name !== undefined) {
74
+ createData[nameField] = body.name;
75
+ }
76
+ const row = asUserRecord(await users.create(createData));
77
+ const role = String(row[roleField] ?? defaultRole);
78
+ const token = tokenService.signAccessToken({ userId: String(row.id), role });
79
+ return c.json({
80
+ token,
81
+ user: omitFields(row, fieldsToOmit),
82
+ }, 201);
83
+ });
84
+ router.post('/login', validateJson(loginSchema), async (c) => {
85
+ const db = c.get('db');
86
+ const body = c.req.valid('json');
87
+ const users = resolveUserModel(db, userModel);
88
+ const row = await users.findFirst({ where: { [emailField]: body.email } });
89
+ const storedHash = row && typeof row[passwordHashField] === 'string'
90
+ ? row[passwordHashField]
91
+ : await getDummyHash();
92
+ // Always verify to reduce user-enumeration timing differences.
93
+ const valid = await passwordService.verifyPassword(body.password, storedHash);
94
+ if (!row || !valid) {
95
+ // Uniform message — do not reveal whether email exists.
96
+ throw new UnauthorizedError('Invalid email or password');
97
+ }
98
+ let user = asUserRecord(row);
99
+ if (passwordService.needsRehash(storedHash)) {
100
+ const newHash = await passwordService.hashPassword(body.password);
101
+ user = asUserRecord(await users.update({
102
+ where: { id: user.id },
103
+ data: { [passwordHashField]: newHash },
104
+ }));
105
+ }
106
+ const role = String(user[roleField] ?? defaultRole);
107
+ const token = tokenService.signAccessToken({ userId: String(user.id), role });
108
+ return c.json({
109
+ token,
110
+ user: omitFields(user, fieldsToOmit),
111
+ });
112
+ });
113
+ router.get('/me', (c) => {
114
+ const auth = c.get('auth');
115
+ return c.json(auth ?? { role: PUBLIC_ROLE });
116
+ });
117
+ return router;
118
+ }
@@ -0,0 +1,10 @@
1
+ /** Default access-token lifetime when AUTH_ACCESS_TOKEN_TTL is unset (1 hour). */
2
+ export declare const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 3600;
3
+ export interface TokenConfig {
4
+ secret?: string;
5
+ ttlSeconds: number;
6
+ roleClaim: string;
7
+ userIdClaim: string;
8
+ }
9
+ export declare function parseTtlSeconds(value: string | undefined, defaultSeconds?: number): number;
10
+ export declare function resolveTokenConfig(overrides?: Partial<TokenConfig>): TokenConfig;
@@ -0,0 +1,43 @@
1
+ import { InvalidTokenTtlError } from './errors.js';
2
+ /** Default access-token lifetime when AUTH_ACCESS_TOKEN_TTL is unset (1 hour). */
3
+ export const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 3_600;
4
+ const DEFAULT_ROLE_CLAIM = 'role';
5
+ const DEFAULT_USER_ID_CLAIM = 'sub';
6
+ const TTL_UNIT_SECONDS = {
7
+ s: 1,
8
+ m: 60,
9
+ h: 3_600,
10
+ d: 86_400,
11
+ };
12
+ export function parseTtlSeconds(value, defaultSeconds = DEFAULT_ACCESS_TOKEN_TTL_SECONDS) {
13
+ if (value === undefined || value === null || value === '') {
14
+ return defaultSeconds;
15
+ }
16
+ if (/^\d+$/.test(value)) {
17
+ const seconds = Number(value);
18
+ if (seconds <= 0) {
19
+ throw new InvalidTokenTtlError(`AUTH_ACCESS_TOKEN_TTL must be positive, got "${value}"`);
20
+ }
21
+ return seconds;
22
+ }
23
+ const match = /^(\d+)([smhd])$/i.exec(value.trim());
24
+ if (!match) {
25
+ throw new InvalidTokenTtlError(`AUTH_ACCESS_TOKEN_TTL must be seconds or a duration like 15m/1h, got "${value}"`);
26
+ }
27
+ const amount = Number(match[1]);
28
+ const unit = match[2].toLowerCase();
29
+ const multiplier = TTL_UNIT_SECONDS[unit];
30
+ const seconds = amount * multiplier;
31
+ if (seconds <= 0) {
32
+ throw new InvalidTokenTtlError(`AUTH_ACCESS_TOKEN_TTL must be positive, got "${value}"`);
33
+ }
34
+ return seconds;
35
+ }
36
+ export function resolveTokenConfig(overrides = {}) {
37
+ return {
38
+ secret: overrides.secret ?? process.env.JWT_SECRET,
39
+ ttlSeconds: overrides.ttlSeconds ?? parseTtlSeconds(process.env.AUTH_ACCESS_TOKEN_TTL),
40
+ roleClaim: overrides.roleClaim ?? process.env.JWT_ROLE_CLAIM ?? DEFAULT_ROLE_CLAIM,
41
+ userIdClaim: overrides.userIdClaim ?? process.env.JWT_USER_ID_CLAIM ?? DEFAULT_USER_ID_CLAIM,
42
+ };
43
+ }
@@ -0,0 +1,6 @@
1
+ export declare class MissingJwtSecretError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ export declare class InvalidTokenTtlError extends Error {
5
+ constructor(message?: string);
6
+ }
@@ -0,0 +1,12 @@
1
+ export class MissingJwtSecretError extends Error {
2
+ constructor(message = 'JWT_SECRET is not configured') {
3
+ super(message);
4
+ this.name = 'MissingJwtSecretError';
5
+ }
6
+ }
7
+ export class InvalidTokenTtlError extends Error {
8
+ constructor(message = 'Invalid AUTH_ACCESS_TOKEN_TTL value') {
9
+ super(message);
10
+ this.name = 'InvalidTokenTtlError';
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_ACCESS_TOKEN_TTL_SECONDS, parseTtlSeconds, resolveTokenConfig, type TokenConfig, } from './config.js';
2
+ export { InvalidTokenTtlError, MissingJwtSecretError } from './errors.js';
3
+ export { createTokenService, type AccessTokenClaims, type TokenService, } from './token.js';
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_ACCESS_TOKEN_TTL_SECONDS, parseTtlSeconds, resolveTokenConfig, } from './config.js';
2
+ export { InvalidTokenTtlError, MissingJwtSecretError } from './errors.js';
3
+ export { createTokenService, } from './token.js';
@@ -0,0 +1,11 @@
1
+ import { type TokenConfig } from './config.js';
2
+ export interface AccessTokenClaims {
3
+ userId: string;
4
+ role: string;
5
+ [key: string]: unknown;
6
+ }
7
+ export interface TokenService {
8
+ signAccessToken(claims: AccessTokenClaims): string;
9
+ verifyAccessToken(token: string): Record<string, unknown>;
10
+ }
11
+ export declare function createTokenService(overrides?: Partial<TokenConfig>): TokenService;
@@ -0,0 +1,31 @@
1
+ import { signHs256Jwt, verifyHs256Jwt } from '../jwt-crypto.js';
2
+ import { resolveTokenConfig } from './config.js';
3
+ import { MissingJwtSecretError } from './errors.js';
4
+ export function createTokenService(overrides = {}) {
5
+ const config = resolveTokenConfig(overrides);
6
+ return {
7
+ signAccessToken(claims) {
8
+ const secret = config.secret;
9
+ if (!secret) {
10
+ throw new MissingJwtSecretError();
11
+ }
12
+ const { userId, role, ...extra } = claims;
13
+ const nowSeconds = Math.floor(Date.now() / 1000);
14
+ const payload = {
15
+ ...extra,
16
+ [config.userIdClaim]: userId,
17
+ [config.roleClaim]: role,
18
+ iat: nowSeconds,
19
+ exp: nowSeconds + config.ttlSeconds,
20
+ };
21
+ return signHs256Jwt(payload, secret);
22
+ },
23
+ verifyAccessToken(token) {
24
+ const secret = config.secret;
25
+ if (!secret) {
26
+ throw new MissingJwtSecretError();
27
+ }
28
+ return verifyHs256Jwt(token, secret);
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,10 @@
1
+ import type { AfterHook, BeforeHook, ModelHooks } from './types.js';
2
+ export interface TypedModelHooks<TRow, TCreate, TUpdate> {
3
+ beforeCreate?: BeforeHook | BeforeHook[];
4
+ afterCreate?: AfterHook | AfterHook[];
5
+ beforeUpdate?: BeforeHook | BeforeHook[];
6
+ afterUpdate?: AfterHook | AfterHook[];
7
+ beforeDelete?: BeforeHook | BeforeHook[];
8
+ afterDelete?: AfterHook | AfterHook[];
9
+ }
10
+ export declare function defineHooks<TRow, TCreate, TUpdate>(hooks: TypedModelHooks<TRow, TCreate, TUpdate>): ModelHooks;
@@ -0,0 +1,3 @@
1
+ export function defineHooks(hooks) {
2
+ return hooks;
3
+ }
@@ -0,0 +1,4 @@
1
+ export { defineHooks } from './define.js';
2
+ export type { TypedModelHooks } from './define.js';
3
+ export { cancelledResponse, configureHooks, createHookContext, runAfterHooks, runBeforeHooks, } from './registry.js';
4
+ export type { AfterHook, AfterHookContext, BeforeHook, BeforeHookContext, BeforeHookNext, BeforeHookResult, CreateHookContextInput, HookOperation, HookRegistry, ModelHooks, } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { defineHooks } from './define.js';
2
+ export { cancelledResponse, configureHooks, createHookContext, runAfterHooks, runBeforeHooks, } from './registry.js';
@@ -0,0 +1,8 @@
1
+ import type { Context } from 'hono';
2
+ import type { AppEnv } from '../types.js';
3
+ import type { AfterHookContext, BeforeHookContext, BeforeHookResult, CreateHookContextInput, HookOperation, HookRegistry } from './types.js';
4
+ export declare function configureHooks(next: HookRegistry): void;
5
+ export declare function createHookContext(init: CreateHookContextInput): BeforeHookContext;
6
+ export declare function cancelledResponse(c: Context<AppEnv>): Response;
7
+ export declare function runBeforeHooks(model: string, operation: HookOperation, ctx: BeforeHookContext): Promise<BeforeHookResult>;
8
+ export declare function runAfterHooks(model: string, operation: HookOperation, ctx: AfterHookContext): Promise<void>;