@agent-team-foundation/first-tree-hub 0.10.13 → 0.10.15
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/{bootstrap-CDeXqhkQ.mjs → bootstrap-DUeYbwm-.mjs} +92 -6
- package/dist/cli/index.mjs +43 -6
- package/dist/{dist-DwbhZyGi.mjs → dist-D6AOiyNg.mjs} +15 -0
- package/dist/{feishu-viiZmwcn.mjs → feishu-DQ1l18Ah.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/{invitation-CBnQyB7o-DLQyW5ek.mjs → invitation-CBnQyB7o-Bulf3Sl7.mjs} +1 -1
- package/dist/{saas-connect-DLVGb8OH.mjs → saas-connect-CebXWFF-.mjs} +429 -274
- package/dist/web/assets/{index-dk86IMMq.js → index-BQda2sqe.js} +1 -1
- package/dist/web/assets/{index-BGMkYsML.js → index-C7yW7sWI.js} +85 -80
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
2
|
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
|
|
3
|
-
import { C as
|
|
4
|
-
import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-
|
|
3
|
+
import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-DUeYbwm-.mjs";
|
|
4
|
+
import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-D6AOiyNg.mjs";
|
|
5
5
|
import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError, z } from "zod";
|
|
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
|
|
|
21
21
|
import * as semver from "semver";
|
|
22
22
|
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
23
23
|
import bcrypt from "bcrypt";
|
|
24
|
-
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
24
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
25
25
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
26
26
|
import postgres from "postgres";
|
|
27
27
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
@@ -742,10 +742,25 @@ z.object({
|
|
|
742
742
|
});
|
|
743
743
|
z.object({ agentId: z.string().min(1) });
|
|
744
744
|
const clientStatusSchema = z.enum(["connected", "disconnected"]);
|
|
745
|
+
/**
|
|
746
|
+
* Auth health channel surfaced to the Web admin dashboard. Computed
|
|
747
|
+
* server-side per request from the row's offline duration vs the
|
|
748
|
+
* configured refresh-token TTL — there is no DB column. See
|
|
749
|
+
* `deriveAuthState` server-side.
|
|
750
|
+
*
|
|
751
|
+
* - `ok` — online, or recently offline (cached refresh token can
|
|
752
|
+
* plausibly still mint access tokens).
|
|
753
|
+
* - `expired` — offline longer than the refresh-token TTL; the client
|
|
754
|
+
* cannot recover on its own. The operator mints a fresh
|
|
755
|
+
* connect token via the Web "+ New Connection" button
|
|
756
|
+
* (or the inline Reconnect button on the row).
|
|
757
|
+
*/
|
|
758
|
+
const clientAuthStateSchema = z.enum(["ok", "expired"]);
|
|
745
759
|
z.object({
|
|
746
760
|
id: z.string(),
|
|
747
761
|
userId: z.string().nullable(),
|
|
748
762
|
status: clientStatusSchema,
|
|
763
|
+
authState: clientAuthStateSchema,
|
|
749
764
|
sdkVersion: z.string().max(50).nullable(),
|
|
750
765
|
hostname: z.string().max(100).nullable(),
|
|
751
766
|
os: z.string().max(50).nullable(),
|
|
@@ -1517,6 +1532,11 @@ defineConfig({
|
|
|
1517
1532
|
secret: true
|
|
1518
1533
|
})
|
|
1519
1534
|
},
|
|
1535
|
+
auth: {
|
|
1536
|
+
accessTokenExpiry: field(z.string().default("30m"), { env: "FIRST_TREE_HUB_AUTH_ACCESS_TOKEN_EXPIRY" }),
|
|
1537
|
+
refreshTokenExpiry: field(z.string().default("30d"), { env: "FIRST_TREE_HUB_AUTH_REFRESH_TOKEN_EXPIRY" }),
|
|
1538
|
+
connectTokenExpiry: field(z.string().default("10m"), { env: "FIRST_TREE_HUB_AUTH_CONNECT_TOKEN_EXPIRY" })
|
|
1539
|
+
},
|
|
1520
1540
|
contextTree: optional({
|
|
1521
1541
|
repo: field(z.string(), {
|
|
1522
1542
|
env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
|
|
@@ -1863,6 +1883,13 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1863
1883
|
/** Fires ~60s before JWT exp so we reconnect with a fresh token first. */
|
|
1864
1884
|
authRefreshTimer = null;
|
|
1865
1885
|
reconnectAttempt = 0;
|
|
1886
|
+
/**
|
|
1887
|
+
* If the most recent refresh attempt was rate-limited (HTTP 429), the
|
|
1888
|
+
* server-suggested wait in ms — consumed by the next `scheduleReconnect`
|
|
1889
|
+
* to floor its delay so we don't keep retrying inside the same 60s
|
|
1890
|
+
* limiter window. Cleared after one use.
|
|
1891
|
+
*/
|
|
1892
|
+
nextReconnectMinDelayMs = 0;
|
|
1866
1893
|
closing = false;
|
|
1867
1894
|
registered = false;
|
|
1868
1895
|
/** Count of `server:welcome` frames received; drives `isReconnect` flag. */
|
|
@@ -2061,7 +2088,6 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2061
2088
|
}, WS_CONNECT_TIMEOUT_MS);
|
|
2062
2089
|
ws.on("open", async () => {
|
|
2063
2090
|
this.ws = ws;
|
|
2064
|
-
this.reconnectAttempt = 0;
|
|
2065
2091
|
this.wsLogger.debug("socket opened, sending auth");
|
|
2066
2092
|
try {
|
|
2067
2093
|
const token = await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
|
|
@@ -2072,7 +2098,16 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2072
2098
|
this.scheduleProactiveAuthRefresh(token);
|
|
2073
2099
|
} catch (err) {
|
|
2074
2100
|
this.authLogger.error({ err }, "failed to obtain access token");
|
|
2075
|
-
|
|
2101
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
2102
|
+
if (e.name === "AuthRefreshFailedError") {
|
|
2103
|
+
this.closing = true;
|
|
2104
|
+
this.emit("auth:fatal", e);
|
|
2105
|
+
} else if (e.name === "AuthRefreshRateLimitedError") {
|
|
2106
|
+
const retryAfterMs = e.retryAfterMs ?? 3e4;
|
|
2107
|
+
this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
|
|
2108
|
+
this.authLogger.warn({ retryAfterMs }, "refresh rate-limited; deferring reconnect");
|
|
2109
|
+
}
|
|
2110
|
+
settle(reject, e);
|
|
2076
2111
|
ws.close();
|
|
2077
2112
|
}
|
|
2078
2113
|
});
|
|
@@ -2168,6 +2203,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2168
2203
|
if (type === "client:registered") {
|
|
2169
2204
|
const isReconnect = this.boundAgents.size > 0 || this.desiredBindings.size > 0;
|
|
2170
2205
|
this.registered = true;
|
|
2206
|
+
this.reconnectAttempt = 0;
|
|
2171
2207
|
this.startHeartbeat();
|
|
2172
2208
|
this.wsLogger.info({ isReconnect }, "registered");
|
|
2173
2209
|
this.emit("connected");
|
|
@@ -2320,12 +2356,21 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2320
2356
|
});
|
|
2321
2357
|
}
|
|
2322
2358
|
scheduleReconnect() {
|
|
2359
|
+
if (this.closing) return;
|
|
2323
2360
|
this.reconnectAttempt++;
|
|
2324
2361
|
this.emit("reconnecting", this.reconnectAttempt);
|
|
2325
|
-
const
|
|
2362
|
+
const exponential = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
|
|
2363
|
+
const floor = this.nextReconnectMinDelayMs;
|
|
2364
|
+
this.nextReconnectMinDelayMs = 0;
|
|
2365
|
+
let delay = Math.max(exponential, floor);
|
|
2366
|
+
if (floor > 0) {
|
|
2367
|
+
const jitter = delay * .2 * (Math.random() * 2 - 1);
|
|
2368
|
+
delay = Math.max(0, Math.round(delay + jitter));
|
|
2369
|
+
}
|
|
2326
2370
|
this.wsLogger.debug({
|
|
2327
2371
|
attempt: this.reconnectAttempt,
|
|
2328
|
-
delayMs: delay
|
|
2372
|
+
delayMs: delay,
|
|
2373
|
+
floorMs: floor || void 0
|
|
2329
2374
|
}, "scheduling reconnect");
|
|
2330
2375
|
this.reconnectTimer = setTimeout(() => {
|
|
2331
2376
|
this.reconnectTimer = null;
|
|
@@ -2375,6 +2420,18 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2375
2420
|
* before the server's scheduleAuthExpiry timer fires. Short-lived tokens
|
|
2376
2421
|
* (exp <= lead window) skip the proactive reconnect entirely — we let the
|
|
2377
2422
|
* server push `auth:expired` and handle that path.
|
|
2423
|
+
*
|
|
2424
|
+
* Order is "refresh-then-close", not "close-then-let-reconnect-refresh".
|
|
2425
|
+
* The earlier shape relied on the new connection's open handler to do the
|
|
2426
|
+
* `/auth/refresh` HTTP, which forced ≥1s of WS downtime per cycle even on
|
|
2427
|
+
* the happy path (one base reconnect delay + the refresh round-trip) and
|
|
2428
|
+
* compounded badly under 429: every retry attempt also closed/reopened the
|
|
2429
|
+
* WS, holding the agent offline for 15-20s while the limiter cooled down.
|
|
2430
|
+
* Refreshing first lets us swap the new token onto a still-open WS with no
|
|
2431
|
+
* observable disconnect when the refresh succeeds; the original close-and-
|
|
2432
|
+
* reconnect flow only runs on failure as a last-ditch fallback (it'll hit
|
|
2433
|
+
* the same 429 on its next retry, but at least the Retry-After floor is
|
|
2434
|
+
* now wired up so we don't pile attempts inside the same window).
|
|
2378
2435
|
*/
|
|
2379
2436
|
scheduleProactiveAuthRefresh(token) {
|
|
2380
2437
|
this.clearAuthRefreshTimer();
|
|
@@ -2384,12 +2441,29 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2384
2441
|
if (delay <= 0) return;
|
|
2385
2442
|
this.authLogger.debug({ delayMs: delay }, "scheduled proactive auth refresh");
|
|
2386
2443
|
this.authRefreshTimer = setTimeout(() => {
|
|
2387
|
-
this.
|
|
2388
|
-
if (this.closing) return;
|
|
2389
|
-
this.authLogger.info("triggering proactive auth refresh");
|
|
2390
|
-
this.ws?.close(1e3, "proactive auth refresh");
|
|
2444
|
+
this.runProactiveAuthRefresh();
|
|
2391
2445
|
}, delay);
|
|
2392
2446
|
}
|
|
2447
|
+
async runProactiveAuthRefresh() {
|
|
2448
|
+
this.authRefreshTimer = null;
|
|
2449
|
+
if (this.closing) return;
|
|
2450
|
+
this.authLogger.info("triggering proactive auth refresh");
|
|
2451
|
+
try {
|
|
2452
|
+
await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
|
|
2453
|
+
} catch (err) {
|
|
2454
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
2455
|
+
if (e.name === "AuthRefreshRateLimitedError") {
|
|
2456
|
+
const retryAfterMs = e.retryAfterMs ?? 3e4;
|
|
2457
|
+
this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
|
|
2458
|
+
this.authLogger.warn({ retryAfterMs }, "proactive refresh rate-limited; deferring reconnect");
|
|
2459
|
+
} else if (e.name === "AuthRefreshFailedError") {
|
|
2460
|
+
this.closing = true;
|
|
2461
|
+
this.emit("auth:fatal", e);
|
|
2462
|
+
return;
|
|
2463
|
+
} else this.authLogger.warn({ err: e }, "proactive refresh failed; falling back to reconnect path");
|
|
2464
|
+
}
|
|
2465
|
+
this.ws?.close(1e3, "proactive auth refresh");
|
|
2466
|
+
}
|
|
2393
2467
|
};
|
|
2394
2468
|
/** Built-in handler registry. Populated by handler modules. */
|
|
2395
2469
|
const HANDLER_REGISTRY = /* @__PURE__ */ new Map();
|
|
@@ -6267,6 +6341,14 @@ var ClientRuntime = class {
|
|
|
6267
6341
|
this.connection.on("auth:expired", () => {
|
|
6268
6342
|
print.status("⚠️", "access token expired — reconnecting after refresh...");
|
|
6269
6343
|
});
|
|
6344
|
+
this.connection.on("auth:fatal", (err) => {
|
|
6345
|
+
print.blank();
|
|
6346
|
+
print.status("✗", "auth expired — service is shutting down to break the reconnect loop.");
|
|
6347
|
+
print.status("", err.message);
|
|
6348
|
+
print.status("", "Recovery: get a new connect token from your Hub's Web admin");
|
|
6349
|
+
print.status("", " (Computers → + New Connection), then re-run the command shown.");
|
|
6350
|
+
process.exit(75);
|
|
6351
|
+
});
|
|
6270
6352
|
this.connection.on("error", (err) => {
|
|
6271
6353
|
print.status("⚠️", `client connection error: ${err.message}`);
|
|
6272
6354
|
});
|
|
@@ -8064,7 +8146,7 @@ async function onboardCreate(args) {
|
|
|
8064
8146
|
}
|
|
8065
8147
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
8066
8148
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
8067
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
8149
|
+
const { bindFeishuBot } = await import("./feishu-DQ1l18Ah.mjs").then((n) => n.r);
|
|
8068
8150
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
8069
8151
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
8070
8152
|
else {
|
|
@@ -9044,7 +9126,7 @@ function createFeedbackHandler(config) {
|
|
|
9044
9126
|
return { handle };
|
|
9045
9127
|
}
|
|
9046
9128
|
//#endregion
|
|
9047
|
-
//#region ../server/dist/app-
|
|
9129
|
+
//#region ../server/dist/app-DFDhctwC.mjs
|
|
9048
9130
|
var __defProp = Object.defineProperty;
|
|
9049
9131
|
var __exportAll = (all, no_symbols) => {
|
|
9050
9132
|
let target = {};
|
|
@@ -9183,7 +9265,32 @@ function requireMember(request) {
|
|
|
9183
9265
|
* - Manageability distinguishes roles: admin can manage all, member only their own.
|
|
9184
9266
|
* - All conditions include organizationId scoping to prevent cross-org access.
|
|
9185
9267
|
*/
|
|
9186
|
-
/**
|
|
9268
|
+
/**
|
|
9269
|
+
* Extract MemberScope from an authenticated request. Single definition, used by all routes.
|
|
9270
|
+
*
|
|
9271
|
+
* **Org-scoped admin routes must NOT use the result of this directly for
|
|
9272
|
+
* data filtering.** The returned scope is keyed to the JWT default member,
|
|
9273
|
+
* which is whatever org `auth.login` happened to pick at issuance time —
|
|
9274
|
+
* not the org the user has selected in the dropdown (`localStorage.
|
|
9275
|
+
* selectedOrganizationId`). Pair every `memberScope(request)` in
|
|
9276
|
+
* `packages/server/src/api/admin/*.ts` with one of:
|
|
9277
|
+
*
|
|
9278
|
+
* - `resolveAdminScope(db, request, baseScope, request.query.organizationId)`
|
|
9279
|
+
* for routes that accept `?organizationId=` and want a uniformly rotated
|
|
9280
|
+
* scope (every field keyed to the target org).
|
|
9281
|
+
* - `requireMemberInOrg(db, request, orgId)` when only the membership
|
|
9282
|
+
* `(memberId, role, agentId)` is needed for a specific org (e.g. the
|
|
9283
|
+
* creator HUMAN in chat-create).
|
|
9284
|
+
* - `assertCanManage(db, scope, agentUuid)` / `assertAgentVisible(db, scope, agentUuid)`
|
|
9285
|
+
* for routes operating on a single agent — both already rebind authority
|
|
9286
|
+
* to the agent's own org.
|
|
9287
|
+
*
|
|
9288
|
+
* `__tests__/admin-routes-org-scope-invariant.test.ts` pins this rule via a
|
|
9289
|
+
* grep over the admin route directory. If you intentionally land an admin
|
|
9290
|
+
* read/write route that targets only the JWT default org (no
|
|
9291
|
+
* cross-org switch awareness), update the whitelist there with a comment
|
|
9292
|
+
* explaining why.
|
|
9293
|
+
*/
|
|
9187
9294
|
function memberScope(request) {
|
|
9188
9295
|
const m = requireMember(request);
|
|
9189
9296
|
return {
|
|
@@ -9300,12 +9407,14 @@ async function requireMemberInOrg(db, request, orgId) {
|
|
|
9300
9407
|
const m = requireMember(request);
|
|
9301
9408
|
const [row] = await db.select({
|
|
9302
9409
|
id: members.id,
|
|
9303
|
-
role: members.role
|
|
9410
|
+
role: members.role,
|
|
9411
|
+
agentId: members.agentId
|
|
9304
9412
|
}).from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, orgId), eq(members.status, "active"))).limit(1);
|
|
9305
9413
|
if (!row) throw new ForbiddenError("Not an active member of the target organization");
|
|
9306
9414
|
return {
|
|
9307
9415
|
memberId: row.id,
|
|
9308
|
-
role: row.role
|
|
9416
|
+
role: row.role,
|
|
9417
|
+
agentId: row.agentId
|
|
9309
9418
|
};
|
|
9310
9419
|
}
|
|
9311
9420
|
/**
|
|
@@ -9330,6 +9439,7 @@ async function resolveAdminScope(db, request, scope, requestedOrganizationId) {
|
|
|
9330
9439
|
return {
|
|
9331
9440
|
...scope,
|
|
9332
9441
|
memberId: probe.memberId,
|
|
9442
|
+
humanAgentId: probe.agentId,
|
|
9333
9443
|
organizationId: requestedOrganizationId,
|
|
9334
9444
|
role: probe.role
|
|
9335
9445
|
};
|
|
@@ -11087,6 +11197,29 @@ async function listClientsForOrgAdmin(db, orgId) {
|
|
|
11087
11197
|
metadata: clients.metadata
|
|
11088
11198
|
}).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
|
|
11089
11199
|
}
|
|
11200
|
+
/**
|
|
11201
|
+
* Infer whether the client's locally-cached refresh token can plausibly
|
|
11202
|
+
* still mint access tokens. Used by the Web admin dashboard to render an
|
|
11203
|
+
* "AUTH EXPIRED" pill on rows whose offline duration has exceeded the
|
|
11204
|
+
* server's configured refresh-token TTL.
|
|
11205
|
+
*
|
|
11206
|
+
* Uses `lastSeenAt` (not `connectedAt`) because a healthy long-lived
|
|
11207
|
+
* client slides the refresh token continuously, so the absolute connect
|
|
11208
|
+
* time is no proxy for liveness. `lastSeenAt` is updated on register,
|
|
11209
|
+
* heartbeat, and the final disconnect — it lower-bounds the issue time
|
|
11210
|
+
* of the refresh token the client most likely still holds.
|
|
11211
|
+
*
|
|
11212
|
+
* Pure function, no DB access; the column-less design means there's no
|
|
11213
|
+
* server-side revocation path yet — every "expired" decision is purely
|
|
11214
|
+
* time-based. If we ever want admin-driven revocation, add a column
|
|
11215
|
+
* back and OR its value into this function.
|
|
11216
|
+
*/
|
|
11217
|
+
function deriveAuthState(row, refreshTokenExpirySeconds) {
|
|
11218
|
+
if (row.status === "disconnected") {
|
|
11219
|
+
if (Date.now() - row.lastSeenAt.getTime() > refreshTokenExpirySeconds * 1e3) return "expired";
|
|
11220
|
+
}
|
|
11221
|
+
return "ok";
|
|
11222
|
+
}
|
|
11090
11223
|
async function attachAgentCounts(db, rows) {
|
|
11091
11224
|
const counts = await db.select({
|
|
11092
11225
|
clientId: agents.clientId,
|
|
@@ -11231,6 +11364,22 @@ function removeClientConnection(clientId, ws) {
|
|
|
11231
11364
|
clientConnections.delete(clientId);
|
|
11232
11365
|
return agentIds;
|
|
11233
11366
|
}
|
|
11367
|
+
/**
|
|
11368
|
+
* Was `ws` the socket currently registered as `clientId`'s active connection
|
|
11369
|
+
* at the time of the call? Used by ws-client.ts's `socket.on("close")` to
|
|
11370
|
+
* decide whether to write `clients.status='disconnected'` to the DB — when a
|
|
11371
|
+
* fast reconnect happens, the new socket has already swapped itself in via
|
|
11372
|
+
* `setClientConnection`, so the old socket's late-arriving onClose must NOT
|
|
11373
|
+
* stamp the row back to disconnected.
|
|
11374
|
+
*
|
|
11375
|
+
* The check is "this socket equals the registered ws", not "this socket is
|
|
11376
|
+
* still OPEN" — the close handler runs precisely when the socket is no
|
|
11377
|
+
* longer OPEN, but the in-memory entry might still legitimately point at
|
|
11378
|
+
* us if no new connection has taken over yet.
|
|
11379
|
+
*/
|
|
11380
|
+
function isActiveClientConnection(clientId, ws) {
|
|
11381
|
+
return clientConnections.get(clientId)?.ws === ws;
|
|
11382
|
+
}
|
|
11234
11383
|
/** Send a message to a client's WebSocket. Returns true if delivered. */
|
|
11235
11384
|
function sendToClient(clientId, message) {
|
|
11236
11385
|
const entry = clientConnections.get(clientId);
|
|
@@ -12025,13 +12174,23 @@ async function adminAgentRoutes(app) {
|
|
|
12025
12174
|
connection
|
|
12026
12175
|
});
|
|
12027
12176
|
});
|
|
12028
|
-
/** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent
|
|
12177
|
+
/** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent.
|
|
12178
|
+
*
|
|
12179
|
+
* The chat creator is the user's HUMAN agent in the *target agent's* org,
|
|
12180
|
+
* not in the JWT default org. Otherwise a multi-org user creating a chat
|
|
12181
|
+
* with an agent outside their JWT default org gets `createChat`'s
|
|
12182
|
+
* cross-organization guard ("Cross-organization chat not allowed: …"),
|
|
12183
|
+
* even though the human is a member of the target agent's org.
|
|
12184
|
+
* Symptom hit by the inline onboarding flow when the user creates a new
|
|
12185
|
+
* agent in a non-default org and the auto-chat step fires.
|
|
12186
|
+
*/
|
|
12029
12187
|
app.post("/:uuid/chats", async (request, reply) => {
|
|
12030
12188
|
const { uuid: targetAgentId } = request.params;
|
|
12031
12189
|
const scope = memberScope(request);
|
|
12032
12190
|
await assertAgentVisible(app.db, scope, targetAgentId);
|
|
12033
|
-
const
|
|
12034
|
-
const
|
|
12191
|
+
const targetAgent = await getAgent(app.db, targetAgentId);
|
|
12192
|
+
const probe = await requireMemberInOrg(app.db, request, targetAgent.organizationId);
|
|
12193
|
+
const result = await createChat(app.db, probe.agentId, {
|
|
12035
12194
|
type: "direct",
|
|
12036
12195
|
participantIds: [targetAgentId]
|
|
12037
12196
|
});
|
|
@@ -12353,6 +12512,167 @@ async function adminChatRoutes(app) {
|
|
|
12353
12512
|
});
|
|
12354
12513
|
});
|
|
12355
12514
|
}
|
|
12515
|
+
/** In-memory set of consumed connect token JTIs. Entries auto-expire after 10 minutes. */
|
|
12516
|
+
const consumedConnectJtis = /* @__PURE__ */ new Map();
|
|
12517
|
+
const CONNECT_JTI_TTL_MS = 6e5;
|
|
12518
|
+
async function signToken(secret, payload, expiry) {
|
|
12519
|
+
return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(randomUUID()).setExpirationTime(expiry).sign(secret);
|
|
12520
|
+
}
|
|
12521
|
+
/**
|
|
12522
|
+
* Convert an `ms`-style expiry string (e.g. `"30m"`, `"30d"`, `"1w"`) to
|
|
12523
|
+
* seconds. Used to surface the connect token's `expiresIn` in the API
|
|
12524
|
+
* response. Mirrors the subset of `jose.setExpirationTime` we use; falls
|
|
12525
|
+
* through with a clear error on malformed config so a typo in the env var
|
|
12526
|
+
* surfaces at boot, not days later when the first token expires.
|
|
12527
|
+
*/
|
|
12528
|
+
function expiryToSeconds(expiry) {
|
|
12529
|
+
const m = /^(\d+)\s*(s|m|h|d|w)$/.exec(expiry.trim());
|
|
12530
|
+
if (!m) throw new Error(`Invalid expiry "${expiry}" — expected forms like "30s", "10m", "2h", "30d", "1w".`);
|
|
12531
|
+
return Number(m[1]) * {
|
|
12532
|
+
s: 1,
|
|
12533
|
+
m: 60,
|
|
12534
|
+
h: 3600,
|
|
12535
|
+
d: 86400,
|
|
12536
|
+
w: 604800
|
|
12537
|
+
}[m[2]];
|
|
12538
|
+
}
|
|
12539
|
+
/**
|
|
12540
|
+
* Sign an `(access, refresh)` pair for the given member. Used by both the
|
|
12541
|
+
* legacy username/password login path and the SaaS GitHub OAuth callback,
|
|
12542
|
+
* so the issuance shape stays in one place.
|
|
12543
|
+
*/
|
|
12544
|
+
async function signTokensForMember(jwtSecretKey, member, expiries) {
|
|
12545
|
+
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
12546
|
+
const tokenBase = {
|
|
12547
|
+
sub: member.userId,
|
|
12548
|
+
memberId: member.memberId,
|
|
12549
|
+
organizationId: member.organizationId,
|
|
12550
|
+
role: member.role
|
|
12551
|
+
};
|
|
12552
|
+
return {
|
|
12553
|
+
accessToken: await signToken(secret, {
|
|
12554
|
+
...tokenBase,
|
|
12555
|
+
type: "access"
|
|
12556
|
+
}, expiries.accessTokenExpiry),
|
|
12557
|
+
refreshToken: await signToken(secret, {
|
|
12558
|
+
...tokenBase,
|
|
12559
|
+
type: "refresh"
|
|
12560
|
+
}, expiries.refreshTokenExpiry)
|
|
12561
|
+
};
|
|
12562
|
+
}
|
|
12563
|
+
async function login(db, username, password, jwtSecretKey, expiries) {
|
|
12564
|
+
const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
|
12565
|
+
if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
|
|
12566
|
+
if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
|
|
12567
|
+
const [member] = await db.select().from(members).where(and(eq(members.userId, user.id), eq(members.status, "active"))).orderBy(desc(members.createdAt), desc(members.id)).limit(1);
|
|
12568
|
+
if (!member) throw new UnauthorizedError("No organization membership found");
|
|
12569
|
+
const tokens = await signTokensForMember(jwtSecretKey, {
|
|
12570
|
+
userId: user.id,
|
|
12571
|
+
memberId: member.id,
|
|
12572
|
+
organizationId: member.organizationId,
|
|
12573
|
+
role: member.role
|
|
12574
|
+
}, expiries);
|
|
12575
|
+
await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
|
|
12576
|
+
return tokens;
|
|
12577
|
+
}
|
|
12578
|
+
/**
|
|
12579
|
+
* Refresh an access token. Sliding-window: the response also carries a
|
|
12580
|
+
* fresh refresh token whose lifetime restarts from now, so an actively-used
|
|
12581
|
+
* client never hits the absolute `refreshTokenExpiry`. The previously
|
|
12582
|
+
* issued refresh token remains valid until its own `exp` — we deliberately
|
|
12583
|
+
* do **not** maintain a server-side jti revocation set. Same-process
|
|
12584
|
+
* concurrent refreshes therefore both succeed; the surviving refresh token
|
|
12585
|
+
* is whichever one the client persists last. The alternative (server-side
|
|
12586
|
+
* invalidation) is more defensive against token theft but introduces races
|
|
12587
|
+
* across systemd-supervised restarts and reconnect storms — a tradeoff
|
|
12588
|
+
* we're not paying for here.
|
|
12589
|
+
*/
|
|
12590
|
+
async function refreshAccessToken(db, refreshToken, jwtSecretKey, expiries) {
|
|
12591
|
+
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
12592
|
+
let payload;
|
|
12593
|
+
try {
|
|
12594
|
+
const { payload: p } = await jwtVerify(refreshToken, secret);
|
|
12595
|
+
payload = p;
|
|
12596
|
+
} catch {
|
|
12597
|
+
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
12598
|
+
}
|
|
12599
|
+
if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
|
|
12600
|
+
const [user] = await db.select({
|
|
12601
|
+
id: users.id,
|
|
12602
|
+
status: users.status
|
|
12603
|
+
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
12604
|
+
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
12605
|
+
const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
|
|
12606
|
+
if (!member) throw new UnauthorizedError("Membership not found");
|
|
12607
|
+
return signTokensForMember(jwtSecretKey, {
|
|
12608
|
+
userId: user.id,
|
|
12609
|
+
memberId: member.id,
|
|
12610
|
+
organizationId: member.organizationId,
|
|
12611
|
+
role: member.role
|
|
12612
|
+
}, expiries);
|
|
12613
|
+
}
|
|
12614
|
+
/**
|
|
12615
|
+
* Generate a short-lived connect token for CLI authentication.
|
|
12616
|
+
* The connect token carries the member's identity and can be exchanged
|
|
12617
|
+
* for full access+refresh tokens via exchangeConnectToken().
|
|
12618
|
+
*
|
|
12619
|
+
* `iss` (when supplied) is stamped into the JWT so the CLI can derive
|
|
12620
|
+
* the hub URL with no additional argument. Production servers must
|
|
12621
|
+
* always pass it; dev callers may omit and the CLI will require an
|
|
12622
|
+
* explicit `--server-url` (legacy form).
|
|
12623
|
+
*/
|
|
12624
|
+
async function generateConnectToken(member, jwtSecretKey, expiries, iss) {
|
|
12625
|
+
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
12626
|
+
const jti = randomUUID();
|
|
12627
|
+
const builder = new SignJWT({
|
|
12628
|
+
sub: member.userId,
|
|
12629
|
+
memberId: member.memberId,
|
|
12630
|
+
organizationId: member.organizationId,
|
|
12631
|
+
role: member.role,
|
|
12632
|
+
type: "connect"
|
|
12633
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(expiries.connectTokenExpiry);
|
|
12634
|
+
if (iss) builder.setIssuer(iss);
|
|
12635
|
+
return {
|
|
12636
|
+
token: await builder.sign(secret),
|
|
12637
|
+
expiresIn: expiryToSeconds(expiries.connectTokenExpiry)
|
|
12638
|
+
};
|
|
12639
|
+
}
|
|
12640
|
+
/**
|
|
12641
|
+
* Exchange a connect token for full access+refresh tokens.
|
|
12642
|
+
* Validates the connect token, verifies the user is still active,
|
|
12643
|
+
* and issues a fresh token pair.
|
|
12644
|
+
*/
|
|
12645
|
+
async function exchangeConnectToken(db, connectToken, jwtSecretKey, expiries) {
|
|
12646
|
+
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
12647
|
+
let payload;
|
|
12648
|
+
try {
|
|
12649
|
+
const { payload: p } = await jwtVerify(connectToken, secret);
|
|
12650
|
+
payload = p;
|
|
12651
|
+
} catch {
|
|
12652
|
+
throw new UnauthorizedError("Invalid or expired connect token");
|
|
12653
|
+
}
|
|
12654
|
+
if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
|
|
12655
|
+
const jti = payload.jti;
|
|
12656
|
+
if (jti) {
|
|
12657
|
+
if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
|
|
12658
|
+
consumedConnectJtis.set(jti, Date.now());
|
|
12659
|
+
const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
|
|
12660
|
+
for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
|
|
12661
|
+
}
|
|
12662
|
+
const [user] = await db.select({
|
|
12663
|
+
id: users.id,
|
|
12664
|
+
status: users.status
|
|
12665
|
+
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
12666
|
+
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
12667
|
+
const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
|
|
12668
|
+
if (!member) throw new UnauthorizedError("Membership not found");
|
|
12669
|
+
return signTokensForMember(jwtSecretKey, {
|
|
12670
|
+
userId: user.id,
|
|
12671
|
+
memberId: member.id,
|
|
12672
|
+
organizationId: member.organizationId,
|
|
12673
|
+
role: member.role
|
|
12674
|
+
}, expiries);
|
|
12675
|
+
}
|
|
12356
12676
|
/** Serialize a Date to ISO string, or null. */
|
|
12357
12677
|
function serializeDate(d) {
|
|
12358
12678
|
return d ? d.toISOString() : null;
|
|
@@ -12362,13 +12682,16 @@ async function adminClientRoutes(app) {
|
|
|
12362
12682
|
app.get("/", async (request) => {
|
|
12363
12683
|
const scope = memberScope(request);
|
|
12364
12684
|
const { organizationId } = listClientsQuerySchema.parse(request.query);
|
|
12365
|
-
|
|
12685
|
+
const clients = organizationId ? await (async () => {
|
|
12366
12686
|
if ((await requireMemberInOrg(app.db, request, organizationId)).role !== "admin") throw new ForbiddenError("Admin role required");
|
|
12367
12687
|
return listClientsForOrgAdmin(app.db, organizationId);
|
|
12368
|
-
})() : await listClients(app.db, { userId: scope.userId })
|
|
12688
|
+
})() : await listClients(app.db, { userId: scope.userId });
|
|
12689
|
+
const refreshExpirySeconds = expiryToSeconds(app.config.auth.refreshTokenExpiry);
|
|
12690
|
+
return clients.map((c) => ({
|
|
12369
12691
|
id: c.id,
|
|
12370
12692
|
userId: c.userId,
|
|
12371
12693
|
status: c.status,
|
|
12694
|
+
authState: deriveAuthState(c, refreshExpirySeconds),
|
|
12372
12695
|
sdkVersion: c.sdkVersion,
|
|
12373
12696
|
hostname: c.hostname,
|
|
12374
12697
|
os: c.os,
|
|
@@ -12395,10 +12718,12 @@ async function adminClientRoutes(app) {
|
|
|
12395
12718
|
if (!client) throw new Error("unreachable: client missing after owner check");
|
|
12396
12719
|
const metadata = client.metadata ?? {};
|
|
12397
12720
|
const capabilities = metadata.capabilities && typeof metadata.capabilities === "object" ? metadata.capabilities : {};
|
|
12721
|
+
const refreshExpirySeconds = expiryToSeconds(app.config.auth.refreshTokenExpiry);
|
|
12398
12722
|
return {
|
|
12399
12723
|
id: client.id,
|
|
12400
12724
|
userId: client.userId,
|
|
12401
12725
|
status: client.status,
|
|
12726
|
+
authState: deriveAuthState(client, refreshExpirySeconds),
|
|
12402
12727
|
sdkVersion: client.sdkVersion,
|
|
12403
12728
|
hostname: client.hostname,
|
|
12404
12729
|
os: client.os,
|
|
@@ -12428,9 +12753,12 @@ async function adminClientRoutes(app) {
|
|
|
12428
12753
|
return reply.status(204).send();
|
|
12429
12754
|
});
|
|
12430
12755
|
}
|
|
12756
|
+
const activityQuerySchema = z.object({ organizationId: z.string().min(1).optional() });
|
|
12431
12757
|
async function adminActivityRoutes(app) {
|
|
12432
12758
|
app.get("/", async (request) => {
|
|
12433
|
-
const
|
|
12759
|
+
const baseScope = memberScope(request);
|
|
12760
|
+
const { organizationId } = activityQuerySchema.parse(request.query);
|
|
12761
|
+
const scope = await resolveAdminScope(app.db, request, baseScope, organizationId);
|
|
12434
12762
|
const overview = await getActivityOverview(app.db);
|
|
12435
12763
|
const runningAgents = await listAgentsWithRuntime(app.db, scope);
|
|
12436
12764
|
return {
|
|
@@ -14142,18 +14470,11 @@ async function pollInbox(db, inboxId, limit) {
|
|
|
14142
14470
|
}
|
|
14143
14471
|
async function pollInboxInner(db, inboxId, limit) {
|
|
14144
14472
|
return db.transaction(async (tx) => {
|
|
14145
|
-
|
|
14146
|
-
|
|
14147
|
-
|
|
14148
|
-
|
|
14149
|
-
|
|
14150
|
-
WHERE inbox_id = ${inboxId} AND status = 'pending' AND notify = true
|
|
14151
|
-
ORDER BY created_at
|
|
14152
|
-
LIMIT ${limit}
|
|
14153
|
-
FOR UPDATE SKIP LOCKED
|
|
14154
|
-
)
|
|
14155
|
-
RETURNING *
|
|
14156
|
-
`));
|
|
14473
|
+
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(limit).for("update", { skipLocked: true });
|
|
14474
|
+
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
14475
|
+
status: "delivered",
|
|
14476
|
+
deliveredAt: /* @__PURE__ */ new Date()
|
|
14477
|
+
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
14157
14478
|
});
|
|
14158
14479
|
}
|
|
14159
14480
|
/**
|
|
@@ -14166,7 +14487,7 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
14166
14487
|
* hub-inbox-ws-data-plane §3.2 risk #1).
|
|
14167
14488
|
*
|
|
14168
14489
|
* Steps:
|
|
14169
|
-
* 1. Sort by `
|
|
14490
|
+
* 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
|
|
14170
14491
|
* 2. For each trigger, collect silent context & bulk-ack stale silent rows.
|
|
14171
14492
|
* 3. Fetch the trigger messages.
|
|
14172
14493
|
* 4. Build wire payloads via the single dispatcher.
|
|
@@ -14175,16 +14496,16 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
14175
14496
|
*/
|
|
14176
14497
|
async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
|
|
14177
14498
|
if (claimed.length === 0) return [];
|
|
14178
|
-
claimed.sort((a, b) => a.
|
|
14499
|
+
claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
14179
14500
|
const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
|
|
14180
|
-
const messageIds = claimed.map((e) => e.
|
|
14501
|
+
const messageIds = claimed.map((e) => e.messageId);
|
|
14181
14502
|
const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
|
|
14182
14503
|
const msgMap = new Map(msgs.map((m) => [m.id, m]));
|
|
14183
14504
|
const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
|
|
14184
|
-
const msg = msgMap.get(entry.
|
|
14185
|
-
if (!msg) throw new Error(`Unexpected: message ${entry.
|
|
14505
|
+
const msg = msgMap.get(entry.messageId);
|
|
14506
|
+
if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
|
|
14186
14507
|
return {
|
|
14187
|
-
entryChatId: entry.
|
|
14508
|
+
entryChatId: entry.chatId,
|
|
14188
14509
|
precedingMessages: precedingByEntryId.get(entry.id) ?? [],
|
|
14189
14510
|
message: {
|
|
14190
14511
|
id: msg.id,
|
|
@@ -14205,15 +14526,15 @@ async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
|
|
|
14205
14526
|
const payload = payloads[idx];
|
|
14206
14527
|
if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
|
|
14207
14528
|
return {
|
|
14208
|
-
id:
|
|
14209
|
-
inboxId: entry.
|
|
14210
|
-
messageId: entry.
|
|
14211
|
-
chatId: entry.
|
|
14529
|
+
id: entry.id,
|
|
14530
|
+
inboxId: entry.inboxId,
|
|
14531
|
+
messageId: entry.messageId,
|
|
14532
|
+
chatId: entry.chatId,
|
|
14212
14533
|
status: entry.status,
|
|
14213
|
-
retryCount: entry.
|
|
14214
|
-
createdAt: entry.
|
|
14215
|
-
deliveredAt: entry.
|
|
14216
|
-
ackedAt: entry.
|
|
14534
|
+
retryCount: entry.retryCount,
|
|
14535
|
+
createdAt: entry.createdAt.toISOString(),
|
|
14536
|
+
deliveredAt: entry.deliveredAt?.toISOString() ?? null,
|
|
14537
|
+
ackedAt: entry.ackedAt?.toISOString() ?? null,
|
|
14217
14538
|
message: payload
|
|
14218
14539
|
};
|
|
14219
14540
|
});
|
|
@@ -14248,21 +14569,11 @@ async function claimAndBuildForPush(db, inboxId, messageId) {
|
|
|
14248
14569
|
"inbox.id": inboxId,
|
|
14249
14570
|
"message.id": messageId
|
|
14250
14571
|
}, () => db.transaction(async (tx) => {
|
|
14251
|
-
|
|
14252
|
-
|
|
14253
|
-
|
|
14254
|
-
|
|
14255
|
-
|
|
14256
|
-
WHERE inbox_id = ${inboxId}
|
|
14257
|
-
AND message_id = ${messageId}
|
|
14258
|
-
AND status = 'pending'
|
|
14259
|
-
AND notify = true
|
|
14260
|
-
ORDER BY created_at
|
|
14261
|
-
LIMIT ${PUSH_CLAIM_BATCH_LIMIT}
|
|
14262
|
-
FOR UPDATE SKIP LOCKED
|
|
14263
|
-
)
|
|
14264
|
-
RETURNING *
|
|
14265
|
-
`));
|
|
14572
|
+
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.messageId, messageId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(PUSH_CLAIM_BATCH_LIMIT).for("update", { skipLocked: true });
|
|
14573
|
+
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
14574
|
+
status: "delivered",
|
|
14575
|
+
deliveredAt: /* @__PURE__ */ new Date()
|
|
14576
|
+
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
14266
14577
|
}));
|
|
14267
14578
|
}
|
|
14268
14579
|
/**
|
|
@@ -14285,7 +14596,7 @@ async function claimBacklogForPush(db, inboxId, limit) {
|
|
|
14285
14596
|
* `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
|
|
14286
14597
|
*
|
|
14287
14598
|
* Side effect: bulk-ack ALL silent pending rows in each chat with
|
|
14288
|
-
*
|
|
14599
|
+
* createdAt < latest_trigger.createdAt — including ones that fell outside
|
|
14289
14600
|
* the window/cap. Otherwise stale silent rows would accumulate and re-load
|
|
14290
14601
|
* on every poll.
|
|
14291
14602
|
*/
|
|
@@ -14293,51 +14604,41 @@ async function collectPrecedingContext(tx, inboxId, triggers) {
|
|
|
14293
14604
|
const result = /* @__PURE__ */ new Map();
|
|
14294
14605
|
const byChat = /* @__PURE__ */ new Map();
|
|
14295
14606
|
for (const t of triggers) {
|
|
14296
|
-
if (t.
|
|
14297
|
-
const list = byChat.get(t.
|
|
14607
|
+
if (t.chatId === null) continue;
|
|
14608
|
+
const list = byChat.get(t.chatId) ?? [];
|
|
14298
14609
|
list.push(t);
|
|
14299
|
-
byChat.set(t.
|
|
14610
|
+
byChat.set(t.chatId, list);
|
|
14300
14611
|
}
|
|
14301
14612
|
for (const [chatId, chatTriggers] of byChat) {
|
|
14302
|
-
chatTriggers.sort((a, b) => a.
|
|
14613
|
+
chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
14303
14614
|
let prevCreatedAt = null;
|
|
14304
14615
|
for (const trigger of chatTriggers) {
|
|
14305
|
-
const preceding = (await tx.
|
|
14306
|
-
|
|
14307
|
-
|
|
14308
|
-
|
|
14309
|
-
|
|
14310
|
-
|
|
14311
|
-
|
|
14312
|
-
|
|
14313
|
-
|
|
14314
|
-
|
|
14315
|
-
|
|
14316
|
-
|
|
14317
|
-
|
|
14318
|
-
LIMIT ${50}
|
|
14319
|
-
FOR UPDATE OF ie SKIP LOCKED
|
|
14320
|
-
`)).map((r) => ({
|
|
14321
|
-
id: r.message_id,
|
|
14322
|
-
senderId: r.sender_id,
|
|
14616
|
+
const preceding = (await tx.select({
|
|
14617
|
+
messageId: messages.id,
|
|
14618
|
+
senderId: messages.senderId,
|
|
14619
|
+
format: messages.format,
|
|
14620
|
+
content: messages.content,
|
|
14621
|
+
metadata: messages.metadata,
|
|
14622
|
+
createdAt: messages.createdAt
|
|
14623
|
+
}).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(inboxEntries.createdAt)).limit(50).for("update", {
|
|
14624
|
+
of: inboxEntries,
|
|
14625
|
+
skipLocked: true
|
|
14626
|
+
})).map((r) => ({
|
|
14627
|
+
id: r.messageId,
|
|
14628
|
+
senderId: r.senderId,
|
|
14323
14629
|
format: r.format,
|
|
14324
14630
|
content: r.content,
|
|
14325
14631
|
metadata: r.metadata ?? {},
|
|
14326
|
-
createdAt: r.
|
|
14632
|
+
createdAt: r.createdAt.toISOString()
|
|
14327
14633
|
})).reverse();
|
|
14328
14634
|
result.set(trigger.id, preceding);
|
|
14329
|
-
prevCreatedAt = trigger.
|
|
14635
|
+
prevCreatedAt = trigger.createdAt;
|
|
14330
14636
|
}
|
|
14331
14637
|
const latestTrigger = chatTriggers[chatTriggers.length - 1];
|
|
14332
|
-
if (latestTrigger) await tx.
|
|
14333
|
-
|
|
14334
|
-
|
|
14335
|
-
|
|
14336
|
-
AND chat_id = ${chatId}
|
|
14337
|
-
AND status = 'pending'
|
|
14338
|
-
AND notify = false
|
|
14339
|
-
AND created_at < ${latestTrigger.created_at}
|
|
14340
|
-
`);
|
|
14638
|
+
if (latestTrigger) await tx.update(inboxEntries).set({
|
|
14639
|
+
status: "acked",
|
|
14640
|
+
ackedAt: /* @__PURE__ */ new Date()
|
|
14641
|
+
}).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
|
|
14341
14642
|
}
|
|
14342
14643
|
return result;
|
|
14343
14644
|
}
|
|
@@ -14380,23 +14681,14 @@ async function renewEntry(db, entryId, inboxId) {
|
|
|
14380
14681
|
return entry;
|
|
14381
14682
|
}
|
|
14382
14683
|
async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
|
|
14383
|
-
const
|
|
14384
|
-
|
|
14385
|
-
|
|
14386
|
-
|
|
14387
|
-
|
|
14388
|
-
RETURNING id
|
|
14389
|
-
`);
|
|
14390
|
-
const failedResult = await db.execute(sql`
|
|
14391
|
-
UPDATE inbox_entries SET status = 'failed'
|
|
14392
|
-
WHERE status = 'delivered'
|
|
14393
|
-
AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
|
|
14394
|
-
AND retry_count >= ${maxRetries}
|
|
14395
|
-
RETURNING id
|
|
14396
|
-
`);
|
|
14684
|
+
const reset = await db.update(inboxEntries).set({
|
|
14685
|
+
status: "pending",
|
|
14686
|
+
retryCount: sql`${inboxEntries.retryCount} + 1`
|
|
14687
|
+
}).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
14688
|
+
const failed = await db.update(inboxEntries).set({ status: "failed" }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, gte(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
14397
14689
|
return {
|
|
14398
|
-
reset:
|
|
14399
|
-
failed:
|
|
14690
|
+
reset: reset.length,
|
|
14691
|
+
failed: failed.length
|
|
14400
14692
|
};
|
|
14401
14693
|
}
|
|
14402
14694
|
/** Default age (30 days) past which silent rows that no notify-true delivery
|
|
@@ -14415,7 +14707,7 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
|
|
|
14415
14707
|
* `(inbox_id, message_id, chat_id)` means leaving them around blocks
|
|
14416
14708
|
* legitimate retries with the same key.
|
|
14417
14709
|
*
|
|
14418
|
-
* 2. `notify=false AND status='pending' AND
|
|
14710
|
+
* 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
|
|
14419
14711
|
* stale silent rows that no trigger ever caught up with. After 30
|
|
14420
14712
|
* days they're useless as preceding context (the @mention almost
|
|
14421
14713
|
* certainly already happened or the chat went dormant).
|
|
@@ -14424,22 +14716,11 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
|
|
|
14424
14716
|
* can log meaningful counts.
|
|
14425
14717
|
*/
|
|
14426
14718
|
async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
|
|
14427
|
-
const
|
|
14428
|
-
|
|
14429
|
-
WHERE notify = false
|
|
14430
|
-
AND status = 'acked'
|
|
14431
|
-
RETURNING id
|
|
14432
|
-
`);
|
|
14433
|
-
const staleResult = await db.execute(sql`
|
|
14434
|
-
DELETE FROM inbox_entries
|
|
14435
|
-
WHERE notify = false
|
|
14436
|
-
AND status = 'pending'
|
|
14437
|
-
AND created_at < NOW() - make_interval(secs => ${maxAgeSeconds})
|
|
14438
|
-
RETURNING id
|
|
14439
|
-
`);
|
|
14719
|
+
const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
|
|
14720
|
+
const stalePendingDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "pending"), sql`${inboxEntries.createdAt} < NOW() - make_interval(secs => ${maxAgeSeconds})`)).returning({ id: inboxEntries.id });
|
|
14440
14721
|
return {
|
|
14441
|
-
ackedDeleted:
|
|
14442
|
-
stalePendingDeleted:
|
|
14722
|
+
ackedDeleted: ackedDeleted.length,
|
|
14723
|
+
stalePendingDeleted: stalePendingDeleted.length
|
|
14443
14724
|
};
|
|
14444
14725
|
}
|
|
14445
14726
|
async function agentInboxRoutes(app) {
|
|
@@ -15317,8 +15598,9 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
15317
15598
|
}
|
|
15318
15599
|
boundAgents.clear();
|
|
15319
15600
|
if (clientId) {
|
|
15601
|
+
const stillActive = isActiveClientConnection(clientId, socket);
|
|
15320
15602
|
removeClientConnection(clientId, socket);
|
|
15321
|
-
try {
|
|
15603
|
+
if (stillActive) try {
|
|
15322
15604
|
await disconnectClient(app.db, clientId);
|
|
15323
15605
|
} catch {}
|
|
15324
15606
|
}
|
|
@@ -15326,141 +15608,6 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
15326
15608
|
});
|
|
15327
15609
|
};
|
|
15328
15610
|
}
|
|
15329
|
-
const ACCESS_TOKEN_EXPIRY = "30m";
|
|
15330
|
-
const REFRESH_TOKEN_EXPIRY = "7d";
|
|
15331
|
-
const CONNECT_TOKEN_EXPIRY = "10m";
|
|
15332
|
-
/** In-memory set of consumed connect token JTIs. Entries auto-expire after 10 minutes. */
|
|
15333
|
-
const consumedConnectJtis = /* @__PURE__ */ new Map();
|
|
15334
|
-
const CONNECT_JTI_TTL_MS = 6e5;
|
|
15335
|
-
async function signToken(secret, payload, expiry) {
|
|
15336
|
-
return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiry).sign(secret);
|
|
15337
|
-
}
|
|
15338
|
-
/**
|
|
15339
|
-
* Sign an `(access, refresh)` pair for the given member. Used by both the
|
|
15340
|
-
* legacy username/password login path and the SaaS GitHub OAuth callback,
|
|
15341
|
-
* so the issuance shape stays in one place.
|
|
15342
|
-
*/
|
|
15343
|
-
async function signTokensForMember(jwtSecretKey, member) {
|
|
15344
|
-
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
15345
|
-
const tokenBase = {
|
|
15346
|
-
sub: member.userId,
|
|
15347
|
-
memberId: member.memberId,
|
|
15348
|
-
organizationId: member.organizationId,
|
|
15349
|
-
role: member.role
|
|
15350
|
-
};
|
|
15351
|
-
return {
|
|
15352
|
-
accessToken: await signToken(secret, {
|
|
15353
|
-
...tokenBase,
|
|
15354
|
-
type: "access"
|
|
15355
|
-
}, ACCESS_TOKEN_EXPIRY),
|
|
15356
|
-
refreshToken: await signToken(secret, {
|
|
15357
|
-
...tokenBase,
|
|
15358
|
-
type: "refresh"
|
|
15359
|
-
}, REFRESH_TOKEN_EXPIRY)
|
|
15360
|
-
};
|
|
15361
|
-
}
|
|
15362
|
-
async function login(db, username, password, jwtSecretKey) {
|
|
15363
|
-
const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
|
15364
|
-
if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
|
|
15365
|
-
if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
|
|
15366
|
-
const [member] = await db.select().from(members).where(and(eq(members.userId, user.id), eq(members.status, "active"))).orderBy(desc(members.createdAt), desc(members.id)).limit(1);
|
|
15367
|
-
if (!member) throw new UnauthorizedError("No organization membership found");
|
|
15368
|
-
const tokens = await signTokensForMember(jwtSecretKey, {
|
|
15369
|
-
userId: user.id,
|
|
15370
|
-
memberId: member.id,
|
|
15371
|
-
organizationId: member.organizationId,
|
|
15372
|
-
role: member.role
|
|
15373
|
-
});
|
|
15374
|
-
await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
|
|
15375
|
-
return tokens;
|
|
15376
|
-
}
|
|
15377
|
-
async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
|
|
15378
|
-
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
15379
|
-
let payload;
|
|
15380
|
-
try {
|
|
15381
|
-
const { payload: p } = await jwtVerify(refreshToken, secret);
|
|
15382
|
-
payload = p;
|
|
15383
|
-
} catch {
|
|
15384
|
-
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
15385
|
-
}
|
|
15386
|
-
if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
|
|
15387
|
-
const [user] = await db.select({
|
|
15388
|
-
id: users.id,
|
|
15389
|
-
status: users.status
|
|
15390
|
-
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
15391
|
-
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
15392
|
-
const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
|
|
15393
|
-
if (!member) throw new UnauthorizedError("Membership not found");
|
|
15394
|
-
return { accessToken: await signToken(secret, {
|
|
15395
|
-
sub: user.id,
|
|
15396
|
-
memberId: member.id,
|
|
15397
|
-
organizationId: member.organizationId,
|
|
15398
|
-
role: member.role,
|
|
15399
|
-
type: "access"
|
|
15400
|
-
}, ACCESS_TOKEN_EXPIRY) };
|
|
15401
|
-
}
|
|
15402
|
-
/**
|
|
15403
|
-
* Generate a short-lived connect token for CLI authentication.
|
|
15404
|
-
* The connect token carries the member's identity and can be exchanged
|
|
15405
|
-
* for full access+refresh tokens via exchangeConnectToken().
|
|
15406
|
-
*
|
|
15407
|
-
* `iss` (when supplied) is stamped into the JWT so the CLI can derive
|
|
15408
|
-
* the hub URL with no additional argument. Production servers must
|
|
15409
|
-
* always pass it; dev callers may omit and the CLI will require an
|
|
15410
|
-
* explicit `--server-url` (legacy form).
|
|
15411
|
-
*/
|
|
15412
|
-
async function generateConnectToken(member, jwtSecretKey, iss) {
|
|
15413
|
-
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
15414
|
-
const jti = randomUUID();
|
|
15415
|
-
const builder = new SignJWT({
|
|
15416
|
-
sub: member.userId,
|
|
15417
|
-
memberId: member.memberId,
|
|
15418
|
-
organizationId: member.organizationId,
|
|
15419
|
-
role: member.role,
|
|
15420
|
-
type: "connect"
|
|
15421
|
-
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY);
|
|
15422
|
-
if (iss) builder.setIssuer(iss);
|
|
15423
|
-
return {
|
|
15424
|
-
token: await builder.sign(secret),
|
|
15425
|
-
expiresIn: 600
|
|
15426
|
-
};
|
|
15427
|
-
}
|
|
15428
|
-
/**
|
|
15429
|
-
* Exchange a connect token for full access+refresh tokens.
|
|
15430
|
-
* Validates the connect token, verifies the user is still active,
|
|
15431
|
-
* and issues a fresh token pair.
|
|
15432
|
-
*/
|
|
15433
|
-
async function exchangeConnectToken(db, connectToken, jwtSecretKey) {
|
|
15434
|
-
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
15435
|
-
let payload;
|
|
15436
|
-
try {
|
|
15437
|
-
const { payload: p } = await jwtVerify(connectToken, secret);
|
|
15438
|
-
payload = p;
|
|
15439
|
-
} catch {
|
|
15440
|
-
throw new UnauthorizedError("Invalid or expired connect token");
|
|
15441
|
-
}
|
|
15442
|
-
if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
|
|
15443
|
-
const jti = payload.jti;
|
|
15444
|
-
if (jti) {
|
|
15445
|
-
if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
|
|
15446
|
-
consumedConnectJtis.set(jti, Date.now());
|
|
15447
|
-
const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
|
|
15448
|
-
for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
|
|
15449
|
-
}
|
|
15450
|
-
const [user] = await db.select({
|
|
15451
|
-
id: users.id,
|
|
15452
|
-
status: users.status
|
|
15453
|
-
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
15454
|
-
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
15455
|
-
const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
|
|
15456
|
-
if (!member) throw new UnauthorizedError("Membership not found");
|
|
15457
|
-
return signTokensForMember(jwtSecretKey, {
|
|
15458
|
-
userId: user.id,
|
|
15459
|
-
memberId: member.id,
|
|
15460
|
-
organizationId: member.organizationId,
|
|
15461
|
-
role: member.role
|
|
15462
|
-
});
|
|
15463
|
-
}
|
|
15464
15611
|
/**
|
|
15465
15612
|
* Third-party / local auth identities for a user. Models "how does this user
|
|
15466
15613
|
* prove they are who they say they are". A single user MAY have multiple
|
|
@@ -16045,7 +16192,7 @@ async function completeOauthFlow(app, request, reply, profile, next) {
|
|
|
16045
16192
|
memberId: memberInfo.memberId,
|
|
16046
16193
|
organizationId: memberInfo.organizationId,
|
|
16047
16194
|
role: memberInfo.role
|
|
16048
|
-
});
|
|
16195
|
+
}, app.config.auth);
|
|
16049
16196
|
const fragment = new URLSearchParams({
|
|
16050
16197
|
access: tokens.accessToken,
|
|
16051
16198
|
refresh: tokens.refreshToken,
|
|
@@ -16061,7 +16208,7 @@ async function authRoutes(app) {
|
|
|
16061
16208
|
timeWindow: "1 minute"
|
|
16062
16209
|
} } }, async (request, reply) => {
|
|
16063
16210
|
const body = loginSchema.parse(request.body);
|
|
16064
|
-
const result = await login(app.db, body.username, body.password, app.config.secrets.jwtSecret);
|
|
16211
|
+
const result = await login(app.db, body.username, body.password, app.config.secrets.jwtSecret, app.config.auth);
|
|
16065
16212
|
return reply.send(result);
|
|
16066
16213
|
});
|
|
16067
16214
|
app.post("/refresh", { config: { rateLimit: {
|
|
@@ -16069,7 +16216,7 @@ async function authRoutes(app) {
|
|
|
16069
16216
|
timeWindow: "1 minute"
|
|
16070
16217
|
} } }, async (request, reply) => {
|
|
16071
16218
|
const body = refreshTokenSchema.parse(request.body);
|
|
16072
|
-
const result = await refreshAccessToken(app.db, body.refreshToken, app.config.secrets.jwtSecret);
|
|
16219
|
+
const result = await refreshAccessToken(app.db, body.refreshToken, app.config.secrets.jwtSecret, app.config.auth);
|
|
16073
16220
|
return reply.send(result);
|
|
16074
16221
|
});
|
|
16075
16222
|
app.post("/connect-token", { config: { rateLimit: {
|
|
@@ -16077,7 +16224,7 @@ async function authRoutes(app) {
|
|
|
16077
16224
|
timeWindow: "1 minute"
|
|
16078
16225
|
} } }, async (request, reply) => {
|
|
16079
16226
|
const body = connectTokenExchangeSchema.parse(request.body);
|
|
16080
|
-
const result = await exchangeConnectToken(app.db, body.token, app.config.secrets.jwtSecret);
|
|
16227
|
+
const result = await exchangeConnectToken(app.db, body.token, app.config.secrets.jwtSecret, app.config.auth);
|
|
16081
16228
|
return reply.send(result);
|
|
16082
16229
|
});
|
|
16083
16230
|
}
|
|
@@ -16288,7 +16435,7 @@ async function meRoutes(app) {
|
|
|
16288
16435
|
memberId: m.memberId,
|
|
16289
16436
|
organizationId: m.organizationId,
|
|
16290
16437
|
role: m.role
|
|
16291
|
-
}, app.config.secrets.jwtSecret, issuer);
|
|
16438
|
+
}, app.config.secrets.jwtSecret, app.config.auth, issuer);
|
|
16292
16439
|
return {
|
|
16293
16440
|
token,
|
|
16294
16441
|
expiresIn,
|
|
@@ -16338,7 +16485,7 @@ async function meRoutes(app) {
|
|
|
16338
16485
|
memberId: created.memberId,
|
|
16339
16486
|
organizationId: created.organizationId,
|
|
16340
16487
|
role: "admin"
|
|
16341
|
-
});
|
|
16488
|
+
}, app.config.auth);
|
|
16342
16489
|
return reply.status(201).send({
|
|
16343
16490
|
organization: {
|
|
16344
16491
|
id: created.organizationId,
|
|
@@ -16380,7 +16527,7 @@ async function meRoutes(app) {
|
|
|
16380
16527
|
memberId: member.id,
|
|
16381
16528
|
organizationId: member.organizationId,
|
|
16382
16529
|
role: member.role
|
|
16383
|
-
});
|
|
16530
|
+
}, app.config.auth);
|
|
16384
16531
|
return reply.status(200).send({
|
|
16385
16532
|
organizationId: member.organizationId,
|
|
16386
16533
|
memberId: member.id,
|
|
@@ -16426,7 +16573,7 @@ async function inferWizardStep(app, m) {
|
|
|
16426
16573
|
* landing page.
|
|
16427
16574
|
*/
|
|
16428
16575
|
async function publicInvitePreviewRoute(app) {
|
|
16429
|
-
const { previewInvitation } = await import("./invitation-CBnQyB7o-
|
|
16576
|
+
const { previewInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
|
|
16430
16577
|
app.get("/:token/preview", async (request, reply) => {
|
|
16431
16578
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
16432
16579
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -16456,7 +16603,7 @@ async function adminInvitationRoutes(app) {
|
|
|
16456
16603
|
const m = requireMember(request);
|
|
16457
16604
|
if (m.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
16458
16605
|
if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
|
|
16459
|
-
const { rotateInvitation } = await import("./invitation-CBnQyB7o-
|
|
16606
|
+
const { rotateInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
|
|
16460
16607
|
const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
|
|
16461
16608
|
return {
|
|
16462
16609
|
id: inv.id,
|
|
@@ -18331,6 +18478,14 @@ function resolveCommandVersion$1(injected) {
|
|
|
18331
18478
|
return "0.0.0";
|
|
18332
18479
|
}
|
|
18333
18480
|
async function buildApp(config) {
|
|
18481
|
+
try {
|
|
18482
|
+
expiryToSeconds(config.auth.accessTokenExpiry);
|
|
18483
|
+
expiryToSeconds(config.auth.refreshTokenExpiry);
|
|
18484
|
+
expiryToSeconds(config.auth.connectTokenExpiry);
|
|
18485
|
+
} catch (err) {
|
|
18486
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
18487
|
+
throw new Error(`${msg} — check FIRST_TREE_HUB_AUTH_*_EXPIRY env vars (got access=${config.auth.accessTokenExpiry}, refresh=${config.auth.refreshTokenExpiry}, connect=${config.auth.connectTokenExpiry}).`);
|
|
18488
|
+
}
|
|
18334
18489
|
applyLoggerConfig({
|
|
18335
18490
|
level: config.observability.logging.level,
|
|
18336
18491
|
format: config.observability.logging.format,
|