@data-club/ai-hub-server 0.0.1

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 (248) hide show
  1. package/dist/auth/argon2.d.ts +72 -0
  2. package/dist/auth/argon2.d.ts.map +1 -0
  3. package/dist/auth/argon2.js +145 -0
  4. package/dist/auth/argon2.js.map +1 -0
  5. package/dist/auth/encryption.d.ts +52 -0
  6. package/dist/auth/encryption.d.ts.map +1 -0
  7. package/dist/auth/encryption.js +98 -0
  8. package/dist/auth/encryption.js.map +1 -0
  9. package/dist/auth/jwt.d.ts +133 -0
  10. package/dist/auth/jwt.d.ts.map +1 -0
  11. package/dist/auth/jwt.js +147 -0
  12. package/dist/auth/jwt.js.map +1 -0
  13. package/dist/auth/middleware.d.ts +101 -0
  14. package/dist/auth/middleware.d.ts.map +1 -0
  15. package/dist/auth/middleware.js +161 -0
  16. package/dist/auth/middleware.js.map +1 -0
  17. package/dist/auth/passwordPolicy.d.ts +72 -0
  18. package/dist/auth/passwordPolicy.d.ts.map +1 -0
  19. package/dist/auth/passwordPolicy.js +88 -0
  20. package/dist/auth/passwordPolicy.js.map +1 -0
  21. package/dist/auth/roleRules.d.ts +200 -0
  22. package/dist/auth/roleRules.d.ts.map +1 -0
  23. package/dist/auth/roleRules.js +365 -0
  24. package/dist/auth/roleRules.js.map +1 -0
  25. package/dist/auth/userView.d.ts +55 -0
  26. package/dist/auth/userView.d.ts.map +1 -0
  27. package/dist/auth/userView.js +77 -0
  28. package/dist/auth/userView.js.map +1 -0
  29. package/dist/bootstrap.d.ts +104 -0
  30. package/dist/bootstrap.d.ts.map +1 -0
  31. package/dist/bootstrap.js +160 -0
  32. package/dist/bootstrap.js.map +1 -0
  33. package/dist/db/connection.d.ts +39 -0
  34. package/dist/db/connection.d.ts.map +1 -0
  35. package/dist/db/connection.js +62 -0
  36. package/dist/db/connection.js.map +1 -0
  37. package/dist/db/migrate.d.ts +31 -0
  38. package/dist/db/migrate.d.ts.map +1 -0
  39. package/dist/db/migrate.js +197 -0
  40. package/dist/db/migrate.js.map +1 -0
  41. package/dist/db/schema.d.ts +1902 -0
  42. package/dist/db/schema.d.ts.map +1 -0
  43. package/dist/db/schema.js +662 -0
  44. package/dist/db/schema.js.map +1 -0
  45. package/dist/db/seed.d.ts +92 -0
  46. package/dist/db/seed.d.ts.map +1 -0
  47. package/dist/db/seed.js +160 -0
  48. package/dist/db/seed.js.map +1 -0
  49. package/dist/handlers/fetch.d.ts +89 -0
  50. package/dist/handlers/fetch.d.ts.map +1 -0
  51. package/dist/handlers/fetch.js +173 -0
  52. package/dist/handlers/fetch.js.map +1 -0
  53. package/dist/handlers/nextjs.d.ts +40 -0
  54. package/dist/handlers/nextjs.d.ts.map +1 -0
  55. package/dist/handlers/nextjs.js +42 -0
  56. package/dist/handlers/nextjs.js.map +1 -0
  57. package/dist/handlers/responses.d.ts +46 -0
  58. package/dist/handlers/responses.d.ts.map +1 -0
  59. package/dist/handlers/responses.js +74 -0
  60. package/dist/handlers/responses.js.map +1 -0
  61. package/dist/handlers/routeTable.d.ts +21 -0
  62. package/dist/handlers/routeTable.d.ts.map +1 -0
  63. package/dist/handlers/routeTable.js +133 -0
  64. package/dist/handlers/routeTable.js.map +1 -0
  65. package/dist/handlers/router.d.ts +37 -0
  66. package/dist/handlers/router.d.ts.map +1 -0
  67. package/dist/handlers/router.js +82 -0
  68. package/dist/handlers/router.js.map +1 -0
  69. package/dist/handlers/sse.d.ts +38 -0
  70. package/dist/handlers/sse.d.ts.map +1 -0
  71. package/dist/handlers/sse.js +52 -0
  72. package/dist/handlers/sse.js.map +1 -0
  73. package/dist/handlers/types.d.ts +96 -0
  74. package/dist/handlers/types.d.ts.map +1 -0
  75. package/dist/handlers/types.js +14 -0
  76. package/dist/handlers/types.js.map +1 -0
  77. package/dist/index.d.ts +25 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +24 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/letta/client.d.ts +355 -0
  82. package/dist/letta/client.d.ts.map +1 -0
  83. package/dist/letta/client.js +529 -0
  84. package/dist/letta/client.js.map +1 -0
  85. package/dist/letta/eventTranslator.d.ts +166 -0
  86. package/dist/letta/eventTranslator.d.ts.map +1 -0
  87. package/dist/letta/eventTranslator.js +532 -0
  88. package/dist/letta/eventTranslator.js.map +1 -0
  89. package/dist/letta/index.d.ts +13 -0
  90. package/dist/letta/index.d.ts.map +1 -0
  91. package/dist/letta/index.js +12 -0
  92. package/dist/letta/index.js.map +1 -0
  93. package/dist/letta/modelSettings.d.ts +176 -0
  94. package/dist/letta/modelSettings.d.ts.map +1 -0
  95. package/dist/letta/modelSettings.js +314 -0
  96. package/dist/letta/modelSettings.js.map +1 -0
  97. package/dist/letta/promptWrapping.d.ts +141 -0
  98. package/dist/letta/promptWrapping.d.ts.map +1 -0
  99. package/dist/letta/promptWrapping.js +213 -0
  100. package/dist/letta/promptWrapping.js.map +1 -0
  101. package/dist/letta/sandbox.d.ts +100 -0
  102. package/dist/letta/sandbox.d.ts.map +1 -0
  103. package/dist/letta/sandbox.js +151 -0
  104. package/dist/letta/sandbox.js.map +1 -0
  105. package/dist/letta/settingsFileSeed.d.ts +146 -0
  106. package/dist/letta/settingsFileSeed.d.ts.map +1 -0
  107. package/dist/letta/settingsFileSeed.js +177 -0
  108. package/dist/letta/settingsFileSeed.js.map +1 -0
  109. package/dist/letta/subprocessThrottle.d.ts +151 -0
  110. package/dist/letta/subprocessThrottle.d.ts.map +1 -0
  111. package/dist/letta/subprocessThrottle.js +195 -0
  112. package/dist/letta/subprocessThrottle.js.map +1 -0
  113. package/dist/letta/tpmRecovery.d.ts +202 -0
  114. package/dist/letta/tpmRecovery.d.ts.map +1 -0
  115. package/dist/letta/tpmRecovery.js +314 -0
  116. package/dist/letta/tpmRecovery.js.map +1 -0
  117. package/dist/letta/types.d.ts +54 -0
  118. package/dist/letta/types.d.ts.map +1 -0
  119. package/dist/letta/types.js +16 -0
  120. package/dist/letta/types.js.map +1 -0
  121. package/dist/observability/logger.d.ts +48 -0
  122. package/dist/observability/logger.d.ts.map +1 -0
  123. package/dist/observability/logger.js +81 -0
  124. package/dist/observability/logger.js.map +1 -0
  125. package/dist/observability/metrics.d.ts +53 -0
  126. package/dist/observability/metrics.d.ts.map +1 -0
  127. package/dist/observability/metrics.js +92 -0
  128. package/dist/observability/metrics.js.map +1 -0
  129. package/dist/routes/admin/agents.d.ts +63 -0
  130. package/dist/routes/admin/agents.d.ts.map +1 -0
  131. package/dist/routes/admin/agents.js +461 -0
  132. package/dist/routes/admin/agents.js.map +1 -0
  133. package/dist/routes/admin/rotatingMessages.d.ts +51 -0
  134. package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
  135. package/dist/routes/admin/rotatingMessages.js +292 -0
  136. package/dist/routes/admin/rotatingMessages.js.map +1 -0
  137. package/dist/routes/admin/sessions.d.ts +61 -0
  138. package/dist/routes/admin/sessions.d.ts.map +1 -0
  139. package/dist/routes/admin/sessions.js +232 -0
  140. package/dist/routes/admin/sessions.js.map +1 -0
  141. package/dist/routes/admin/settings.d.ts +110 -0
  142. package/dist/routes/admin/settings.d.ts.map +1 -0
  143. package/dist/routes/admin/settings.js +401 -0
  144. package/dist/routes/admin/settings.js.map +1 -0
  145. package/dist/routes/admin/users.d.ts +115 -0
  146. package/dist/routes/admin/users.d.ts.map +1 -0
  147. package/dist/routes/admin/users.js +374 -0
  148. package/dist/routes/admin/users.js.map +1 -0
  149. package/dist/routes/agents.d.ts +30 -0
  150. package/dist/routes/agents.d.ts.map +1 -0
  151. package/dist/routes/agents.js +55 -0
  152. package/dist/routes/agents.js.map +1 -0
  153. package/dist/routes/auth.d.ts +121 -0
  154. package/dist/routes/auth.d.ts.map +1 -0
  155. package/dist/routes/auth.js +422 -0
  156. package/dist/routes/auth.js.map +1 -0
  157. package/dist/routes/chat.d.ts +97 -0
  158. package/dist/routes/chat.d.ts.map +1 -0
  159. package/dist/routes/chat.js +484 -0
  160. package/dist/routes/chat.js.map +1 -0
  161. package/dist/routes/featureToggles.d.ts +34 -0
  162. package/dist/routes/featureToggles.d.ts.map +1 -0
  163. package/dist/routes/featureToggles.js +47 -0
  164. package/dist/routes/featureToggles.js.map +1 -0
  165. package/dist/routes/health.d.ts +24 -0
  166. package/dist/routes/health.d.ts.map +1 -0
  167. package/dist/routes/health.js +37 -0
  168. package/dist/routes/health.js.map +1 -0
  169. package/dist/routes/metrics.d.ts +35 -0
  170. package/dist/routes/metrics.d.ts.map +1 -0
  171. package/dist/routes/metrics.js +106 -0
  172. package/dist/routes/metrics.js.map +1 -0
  173. package/dist/routes/sessions.d.ts +161 -0
  174. package/dist/routes/sessions.d.ts.map +1 -0
  175. package/dist/routes/sessions.js +1501 -0
  176. package/dist/routes/sessions.js.map +1 -0
  177. package/dist/routes/stubs.d.ts +25 -0
  178. package/dist/routes/stubs.d.ts.map +1 -0
  179. package/dist/routes/stubs.js +25 -0
  180. package/dist/routes/stubs.js.map +1 -0
  181. package/dist/state/cron.d.ts +58 -0
  182. package/dist/state/cron.d.ts.map +1 -0
  183. package/dist/state/cron.js +160 -0
  184. package/dist/state/cron.js.map +1 -0
  185. package/dist/state/rotationRunner.d.ts +96 -0
  186. package/dist/state/rotationRunner.d.ts.map +1 -0
  187. package/dist/state/rotationRunner.js +246 -0
  188. package/dist/state/rotationRunner.js.map +1 -0
  189. package/dist/state/rotationScheduler.d.ts +76 -0
  190. package/dist/state/rotationScheduler.d.ts.map +1 -0
  191. package/dist/state/rotationScheduler.js +124 -0
  192. package/dist/state/rotationScheduler.js.map +1 -0
  193. package/dist/state/turnRegistry.d.ts +95 -0
  194. package/dist/state/turnRegistry.d.ts.map +1 -0
  195. package/dist/state/turnRegistry.js +149 -0
  196. package/dist/state/turnRegistry.js.map +1 -0
  197. package/dist/state/turnRunner.d.ts +134 -0
  198. package/dist/state/turnRunner.d.ts.map +1 -0
  199. package/dist/state/turnRunner.js +476 -0
  200. package/dist/state/turnRunner.js.map +1 -0
  201. package/package.json +47 -0
  202. package/src/auth/argon2.ts +152 -0
  203. package/src/auth/encryption.ts +110 -0
  204. package/src/auth/jwt.ts +217 -0
  205. package/src/auth/middleware.ts +250 -0
  206. package/src/auth/passwordPolicy.ts +99 -0
  207. package/src/auth/roleRules.ts +497 -0
  208. package/src/auth/userView.ts +88 -0
  209. package/src/bootstrap.ts +254 -0
  210. package/src/db/connection.ts +75 -0
  211. package/src/db/migrate.ts +212 -0
  212. package/src/db/schema.ts +750 -0
  213. package/src/db/seed.ts +236 -0
  214. package/src/handlers/fetch.ts +251 -0
  215. package/src/handlers/nextjs.ts +49 -0
  216. package/src/handlers/responses.ts +95 -0
  217. package/src/handlers/routeTable.ts +188 -0
  218. package/src/handlers/router.ts +96 -0
  219. package/src/handlers/sse.ts +55 -0
  220. package/src/handlers/types.ts +104 -0
  221. package/src/index.ts +60 -0
  222. package/src/letta/client.ts +860 -0
  223. package/src/letta/eventTranslator.ts +667 -0
  224. package/src/letta/index.ts +46 -0
  225. package/src/letta/lettaCliShim.mjs +40 -0
  226. package/src/letta/sandbox.ts +167 -0
  227. package/src/letta/settingsFileSeed.ts +254 -0
  228. package/src/letta/subprocessThrottle.ts +251 -0
  229. package/src/letta/types.ts +65 -0
  230. package/src/observability/logger.ts +103 -0
  231. package/src/observability/metrics.ts +114 -0
  232. package/src/routes/admin/agents.ts +574 -0
  233. package/src/routes/admin/rotatingMessages.ts +351 -0
  234. package/src/routes/admin/sessions.ts +283 -0
  235. package/src/routes/admin/settings.ts +463 -0
  236. package/src/routes/admin/users.ts +455 -0
  237. package/src/routes/agents.ts +66 -0
  238. package/src/routes/auth.ts +513 -0
  239. package/src/routes/chat.ts +534 -0
  240. package/src/routes/featureToggles.ts +52 -0
  241. package/src/routes/health.ts +44 -0
  242. package/src/routes/metrics.ts +111 -0
  243. package/src/routes/sessions.ts +1878 -0
  244. package/src/state/cron.ts +179 -0
  245. package/src/state/rotationRunner.ts +326 -0
  246. package/src/state/rotationScheduler.ts +164 -0
  247. package/src/state/turnRegistry.ts +210 -0
  248. package/src/state/turnRunner.ts +588 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Role-enforcement middleware: `protectedRoute({...}, handler)`.
3
+ *
4
+ * Every protected endpoint runs through this exact §8.6 order:
5
+ *
6
+ * 1. Pull the bearer token from `Authorization: Bearer <token>` (401 on miss).
7
+ * 2. Load the signing key from `admin_setting.service_token_signing_key`
8
+ * (500 if missing — should not happen after the first-boot seed).
9
+ * 3. `verifyServiceToken(key, token)` (401 on invalid signature, expiry,
10
+ * issuer, or claim shape).
11
+ * 4. Reject password-change-only tokens unless the route opts into them
12
+ * (401 — same envelope as 1/3 so no scope-leakage).
13
+ * 5. Load the user row by `sub` (401 if the user was deleted after the
14
+ * token was minted — defensive).
15
+ * 6. For session tokens, if `requiredRole` is set and the token's role
16
+ * claim doesn't satisfy it, return 403 with `forbidden`. The role check
17
+ * uses the **JWT claim**, not the row — by spec design (§8.6 step 3):
18
+ * rotating someone's role does not change their existing token (24 h max
19
+ * impact), and the system invalidates everything in one cut via the
20
+ * signing-key rotation.
21
+ * 7. Invoke the wrapped handler with the loaded user + the discriminator
22
+ * so it knows what kind of token authenticated the call.
23
+ *
24
+ * Shield + min-count invariants are NOT enforced here — that's the job of
25
+ * the per-handler `apply*InTx` helpers (`auth/roleRules.ts`), each of which
26
+ * re-reads target state inside a SQLite transaction so two simultaneous
27
+ * mutations cannot race.
28
+ *
29
+ * The middleware lives at one boundary so a future external-IdP feature
30
+ * (§6.7 "Future direction", §17 question 5) can slot a parallel
31
+ * `verifyExternalToken` in alongside without touching every handler.
32
+ *
33
+ * @see SPECIFICATIONS.md §6.7 — auth model
34
+ * @see SPECIFICATIONS.md §8.6 — endpoint enforcement order
35
+ * @see SPECIFICATIONS.md §6.7 — single-function-boundary seam
36
+ */
37
+
38
+ import { eq } from "drizzle-orm";
39
+
40
+ import type { AIHubRole } from "@data-club/ai-hub";
41
+
42
+ import { adminSetting, user } from "../db/schema.js";
43
+ import type { UserRow } from "../db/schema.js";
44
+ import { forbidden, internalError, unauthorized } from "../handlers/responses.js";
45
+ import type { AIHubServerContext, RouteHandler } from "../handlers/types.js";
46
+
47
+ import { hasRoleAtLeast } from "./roleRules.js";
48
+ import type {
49
+ PasswordChangeTokenClaims,
50
+ SessionTokenClaims,
51
+ } from "./jwt.js";
52
+ import { verifyServiceToken } from "./jwt.js";
53
+
54
+ /**
55
+ * Auth context handed to a protected handler. Carries both the loaded user
56
+ * row (so the handler doesn't re-query) and a discriminated kind so the
57
+ * handler can branch when it needs to (the `/auth/change-password` handler is
58
+ * the only one in v1 that cares — both token kinds reach it).
59
+ */
60
+ export interface AuthContext {
61
+ /**
62
+ * The loaded user row. `auth.role` mirrors `claims.role` for session tokens
63
+ * but is read from the row for password-change-only tokens (which carry no
64
+ * role claim). Handlers should use the JWT-derived `role` for matrix checks
65
+ * and the row for anything else.
66
+ */
67
+ user: UserRow;
68
+ /**
69
+ * Role to use for matrix-style checks. For session tokens this is the JWT
70
+ * claim (per §8.6 step 3 — frozen at mint time). For password-change-only
71
+ * tokens this is `user.role` from the row; the change-only token only
72
+ * reaches `/auth/change-password`, which doesn't read this field.
73
+ */
74
+ role: AIHubRole;
75
+ /** Which kind of token authenticated the request. */
76
+ tokenKind: "session" | "password_change";
77
+ /**
78
+ * The raw verified claims, in case a handler wants to log them or branch
79
+ * on something the role/user/tokenKind don't expose.
80
+ */
81
+ claims: SessionTokenClaims | PasswordChangeTokenClaims;
82
+ }
83
+
84
+ /**
85
+ * Options accepted by `protectedRoute`. Both fields default to "session
86
+ * token required, any role." Endpoints that need more (e.g. role gate or
87
+ * password-change-only-token acceptance) set the appropriate field.
88
+ */
89
+ export interface ProtectedRouteOptions {
90
+ /**
91
+ * Minimum role the caller must have. Default: any authenticated session
92
+ * (no role check). The matrix check uses the JWT's `role` claim, not the
93
+ * current row state — see file-level comment.
94
+ */
95
+ requiredRole?: AIHubRole;
96
+ /**
97
+ * `true` if the endpoint also accepts password-change-only tokens. Only
98
+ * `/auth/change-password` sets this in v1. Default: false (every other
99
+ * endpoint rejects change-only tokens with 401).
100
+ */
101
+ allowPasswordChangeToken?: boolean;
102
+ }
103
+
104
+ /** A handler that has been authenticated by `protectedRoute`. */
105
+ export type AuthenticatedHandler = (
106
+ req: Request,
107
+ ctx: AIHubServerContext,
108
+ auth: AuthContext,
109
+ params: Readonly<Record<string, string>>,
110
+ ) => Promise<Response>;
111
+
112
+ /**
113
+ * Wraps a handler with the §8.6 enforcement order. Returns a plain
114
+ * `RouteHandler` that the route table mounts as if the auth check didn't
115
+ * exist.
116
+ *
117
+ * @param opts - per-route auth options.
118
+ * @param handler - the wrapped handler. Called only when auth succeeds.
119
+ * @returns a `RouteHandler` that runs auth, then delegates to `handler`.
120
+ */
121
+ export function protectedRoute(
122
+ opts: ProtectedRouteOptions,
123
+ handler: AuthenticatedHandler,
124
+ ): RouteHandler {
125
+ return async (req, ctx, params) => {
126
+ // 1. Extract bearer token.
127
+ const token = extractBearer(req.headers.get("authorization"));
128
+ if (!token) return unauthorized();
129
+
130
+ // 2. Load signing key from the singleton settings row.
131
+ const settings = ctx.db
132
+ .select()
133
+ .from(adminSetting)
134
+ .where(eq(adminSetting.id, 1))
135
+ .get();
136
+ if (!settings?.serviceTokenSigningKey) {
137
+ ctx.logger.error(
138
+ { event: "missing_signing_key" },
139
+ "admin_setting.service_token_signing_key is empty; cannot verify token.",
140
+ );
141
+ return internalError("Server misconfigured");
142
+ }
143
+
144
+ // 3. Verify token signature + issuer + expiry + claim shape.
145
+ const verified = await verifyServiceToken(
146
+ settings.serviceTokenSigningKey,
147
+ token,
148
+ );
149
+ if (verified.kind === "invalid") return unauthorized();
150
+
151
+ // 4. Reject password-change-only tokens unless this route opts in.
152
+ if (verified.kind === "password_change" && !opts.allowPasswordChangeToken) {
153
+ return unauthorized();
154
+ }
155
+
156
+ // 5. Load user row by sub. The row may be missing if the user was deleted
157
+ // between mint and verify; a 401 (rather than 404) keeps the response
158
+ // indistinguishable from an invalid-token response — no enumeration.
159
+ const subject = verified.claims.sub;
160
+ if (!subject) return unauthorized();
161
+ const row = ctx.db.select().from(user).where(eq(user.id, subject)).get();
162
+ if (!row) return unauthorized();
163
+
164
+ // 5b. Session-invalidation gate — for **session tokens only**, reject
165
+ // JWTs issued before the user's last password mutation. This is
166
+ // what makes admin-driven password resets (and voluntary changes)
167
+ // sign out the affected user's other devices immediately rather
168
+ // than waiting for the 24h JWT expiry. Change-only tokens are
169
+ // allowed through; the change-password handler itself bumps
170
+ // `passwordChangedAt` AND mints a fresh session token in one go,
171
+ // so the user can still complete the forced-change flow.
172
+ //
173
+ // Comparison runs in **second precision** because jose's `iat`
174
+ // truncates to whole seconds — comparing higher precision would
175
+ // occasionally reject a JWT minted in the same second as the
176
+ // password mutation.
177
+ if (verified.kind === "session" && row.passwordChangedAt) {
178
+ const iatSeconds = verified.claims.iat;
179
+ const pwdChangedSeconds = Math.floor(
180
+ Date.parse(row.passwordChangedAt) / 1000,
181
+ );
182
+ if (
183
+ typeof iatSeconds === "number" &&
184
+ Number.isFinite(pwdChangedSeconds) &&
185
+ iatSeconds < pwdChangedSeconds
186
+ ) {
187
+ ctx.logger.info(
188
+ {
189
+ user_id: row.id,
190
+ jwt_iat: iatSeconds,
191
+ password_changed_at: row.passwordChangedAt,
192
+ event: "session_invalidated_by_password_change",
193
+ },
194
+ "Rejecting session token issued before last password change.",
195
+ );
196
+ return unauthorized();
197
+ }
198
+ }
199
+
200
+ // 6. Role check — drives off the **DB row's current role** rather than
201
+ // the JWT claim's role. The JWT signature still proves identity
202
+ // (we trust `sub`), but ACL decisions read the freshest source so:
203
+ //
204
+ // - **Promotions take effect immediately** — a user just promoted
205
+ // to admin can use admin endpoints without logging out + back in.
206
+ // - **Demotions revoke access immediately** — a demoted admin
207
+ // loses admin endpoints right away rather than retaining them
208
+ // until their 24 h JWT expires.
209
+ //
210
+ // The spec's §8.6 step 3 ("Extract the role claim") describes the
211
+ // procedural shape but the security property it cares about
212
+ // ("the user can't escalate privileges via a tampered token") is
213
+ // preserved here — DB role can't be tampered with by the client.
214
+ //
215
+ // Change-only tokens fall through with `row.role` too, so the same
216
+ // code path handles both kinds.
217
+ const effectiveRole: AIHubRole = row.role;
218
+ if (verified.kind === "session" && opts.requiredRole) {
219
+ if (!hasRoleAtLeast(effectiveRole, opts.requiredRole)) {
220
+ return forbidden(
221
+ `Requires role at least ${opts.requiredRole}.`,
222
+ );
223
+ }
224
+ }
225
+
226
+ // 7. Invoke the wrapped handler.
227
+ const auth: AuthContext = {
228
+ user: row,
229
+ role: effectiveRole,
230
+ tokenKind: verified.kind,
231
+ claims: verified.claims,
232
+ };
233
+ return handler(req, ctx, auth, params ?? {});
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Parses the `Authorization` header value, returning the bearer token or
239
+ * `null` when the header is missing or malformed.
240
+ *
241
+ * @param value - the raw header value (or `null` if absent).
242
+ * @returns the bearer token, or `null`.
243
+ */
244
+ function extractBearer(value: string | null): string | null {
245
+ if (!value) return null;
246
+ const m = value.match(/^Bearer\s+(.+)$/i);
247
+ if (!m) return null;
248
+ const token = m[1]?.trim() ?? "";
249
+ return token.length > 0 ? token : null;
250
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Password complexity policy (§8.5).
3
+ *
4
+ * Every new password supplied to `/auth/change-password` must satisfy two
5
+ * checks:
6
+ *
7
+ * 1. **Minimum length** — `PASSWORD_MIN_LENGTH` (8) characters.
8
+ * 2. **Complexity** — at least **2 of these 3 categories** must appear in
9
+ * the password:
10
+ * - letters (any Unicode letter, `\p{L}`)
11
+ * - digits (any Unicode number, `\p{N}`)
12
+ * - special characters (anything that's neither letter nor digit nor
13
+ * whitespace — symbols, punctuation, etc.)
14
+ *
15
+ * Examples:
16
+ *
17
+ * - `"12345678"` — only digits → **rejected**.
18
+ * - `"abcdefgh"` — only letters → **rejected**.
19
+ * - `"!!!!!!!!"` — only special → **rejected**.
20
+ * - `"abcd1234"` — letters + digits → **accepted**.
21
+ * - `"password!"` — letters + special → **accepted**.
22
+ * - `"1234!@#$"` — digits + special → **accepted**.
23
+ * - `"Aa1!Aa1!"` — all three → **accepted**.
24
+ *
25
+ * Whitespace does not count as "special" because allowing whitespace to
26
+ * tick the box would let `"password "` pass; we want at least one
27
+ * meaningfully non-letter character.
28
+ *
29
+ * This module is the single source of truth — the spec-mandated rule is
30
+ * defined here and consumed by handlers. Any future relaxation or
31
+ * tightening happens in one place.
32
+ *
33
+ * @see SPECIFICATIONS.md §8.5 — password requirements
34
+ */
35
+
36
+ /** Minimum length for a new password (§8.5). */
37
+ export const PASSWORD_MIN_LENGTH = 8;
38
+
39
+ /** Minimum distinct character categories required (§8.5). */
40
+ export const PASSWORD_REQUIRED_CATEGORIES = 2;
41
+
42
+ /** Result of `validatePasswordPolicy`. */
43
+ export type PasswordPolicyResult =
44
+ | { ok: true }
45
+ | { ok: false; code: PasswordPolicyErrorCode; message: string };
46
+
47
+ /** Machine-readable codes the validator emits. */
48
+ export type PasswordPolicyErrorCode =
49
+ | "password_too_short"
50
+ | "password_too_simple";
51
+
52
+ /**
53
+ * Validates `password` against the §8.5 policy. Returns a discriminated
54
+ * result the handler maps directly into a 400 response.
55
+ *
56
+ * @param password - the candidate plaintext password.
57
+ * @returns `{ ok: true }` on pass, `{ ok: false, code, message }` on fail.
58
+ */
59
+ export function validatePasswordPolicy(password: string): PasswordPolicyResult {
60
+ if (password.length < PASSWORD_MIN_LENGTH) {
61
+ return {
62
+ ok: false,
63
+ code: "password_too_short",
64
+ message: `New password must be at least ${PASSWORD_MIN_LENGTH} characters.`,
65
+ };
66
+ }
67
+ const categories = countCharacterCategories(password);
68
+ if (categories < PASSWORD_REQUIRED_CATEGORIES) {
69
+ return {
70
+ ok: false,
71
+ code: "password_too_simple",
72
+ message:
73
+ "New password must contain at least two of these three: letters, digits, special characters.",
74
+ };
75
+ }
76
+ return { ok: true };
77
+ }
78
+
79
+ /**
80
+ * Counts how many of the three categories the password matches.
81
+ *
82
+ * Categories are mutually exclusive:
83
+ *
84
+ * - letter: any Unicode letter (`\p{L}`)
85
+ * - digit: any Unicode number (`\p{N}`)
86
+ * - special: everything else that is **not** whitespace (`\s`)
87
+ *
88
+ * Whitespace contributes to neither.
89
+ *
90
+ * @param password - the candidate plaintext password.
91
+ * @returns the count in `[0, 3]`.
92
+ */
93
+ export function countCharacterCategories(password: string): number {
94
+ let count = 0;
95
+ if (/\p{L}/u.test(password)) count++;
96
+ if (/\p{N}/u.test(password)) count++;
97
+ if (/[^\p{L}\p{N}\s]/u.test(password)) count++;
98
+ return count;
99
+ }