@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.
- package/dist/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- 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
|
+
}
|