@agent-team-foundation/first-tree-hub 0.11.3 → 0.11.5
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-D4rdqM2F.mjs → bootstrap-C_K2CKXC.mjs} +5 -13
- package/dist/cli/index.mjs +71 -9
- package/dist/client-D_TRJFZY-LbgJF47t.mjs +4 -0
- package/dist/{client-CLdRbuml-BRtalKpQ.mjs → client-DqdGiggm-NQoGZ2vM.mjs} +3 -3
- package/dist/{dist-BAqGZkco.mjs → dist-CfvCT4E0.mjs} +318 -192
- package/dist/drizzle/0032_organization_settings.sql +36 -0
- package/dist/drizzle/0033_onboarding_dismissed_at.sql +13 -0
- package/dist/drizzle/meta/_journal.json +15 -1
- package/dist/{errors-BmyRwN0Y-Dad3eV8F.mjs → errors-CF5evtJt-B0NTIVPt.mjs} +2 -1
- package/dist/{feishu-Th_-ivJ7.mjs → feishu-DbSvp9UH.mjs} +1 -1
- package/dist/index.mjs +7 -7
- package/dist/{invitation-Dnn5gGGX-DXryyvRG.mjs → invitation-Bg0TRiyx-BsZH4GCS.mjs} +2 -2
- package/dist/invitation-C299fxkP-BR-niZyp.mjs +4 -0
- package/dist/{saas-connect-gcT6Q10z.mjs → saas-connect-CO554S-V.mjs} +1121 -310
- package/dist/web/assets/{index-CD7rTdqm.js → index-B7noAoV-.js} +1 -1
- package/dist/web/assets/index-DPLa60vJ.css +1 -0
- package/dist/web/assets/index-DvGkka4N.js +390 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/client-By1K4VVT-C5K7WZo6.mjs +0 -4
- package/dist/invitation-DWlyNb8x-D3zjZSwI.mjs +0 -4
- package/dist/web/assets/index-43trJLR8.js +0 -388
- package/dist/web/assets/index-fNb_M0nL.css +0 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw8zbkd.mjs";
|
|
2
2
|
import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, f as messageAttrs, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-gw1ODB_o.mjs";
|
|
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-
|
|
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-C_K2CKXC.mjs";
|
|
4
4
|
import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
|
|
5
|
-
import { $ as
|
|
6
|
-
import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-
|
|
7
|
-
import { C as retireClient, D as touchAgent, E as setRuntimeState, O as unbindAgent, S as registerClient, T as setOffline, _ as listClients, a as claimClient, b as markStaleAgents, c as clients, d as getClient, f as getOnlineCount, g as listActiveAgentsPinnedToClient, h as heartbeatInstance, i as bindAgent, k as updateClientCapabilities, l as deriveAuthState, m as heartbeatClient, n as agents, o as cleanupStaleClients, p as getPresence, r as assertClientOwner, s as cleanupStalePresence, t as agentPresence, u as disconnectClient, v as listClientsForOrgAdmin, w as serverInstances, x as members } from "./client-
|
|
5
|
+
import { $ as loginSchema, A as createAgentSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateChatSchema, D as contextTreeSnapshotSchema, Dt as updateTaskStatusSchema, E as connectTokenExchangeSchema, Et as updateOrganizationSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isRedactedEnvValue, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMeChatSchema, N as createMemberSchema, O as createAdapterConfigSchema, Ot as wsAuthFrameSchema, P as createOrgFromMeSchema, Q as listMeChatsQuerySchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as updateAgentSchema, T as clientRegisterSchema, Tt as updateMemberSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as joinByInvitationSchema, Y as isReservedAgentName$1, Z as linkTaskChatSchema, _ as addParticipantSchema, _t as sessionStateMessageSchema, a as AGENT_STATUSES, at as rebindAgentSchema, b as agentBindRequestSchema, bt as updateAdapterConfigSchema, ct as safeRedirectPath, d as TASK_CREATOR_TYPES, dt as sendMessageSchema, et as messageSourceSchema$1, f as TASK_HEALTH_SIGNALS, ft as sendToAgentSchema, g as addMeChatParticipantsSchema, gt as sessionReconcileRequestSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as sessionEventSchema$1, i as AGENT_SOURCES, it as patchOnboardingSchema, j as createChatSchema, k as createAdapterMappingSchema, l as MENTION_REGEX, lt as scanMentionTokens, m as TASK_TERMINAL_STATUSES, mt as sessionEventMessageSchema, n as AGENT_NAME_REGEX$1, nt as onboardingEventSchema, o as AGENT_TYPES, ot as refreshTokenSchema, p as TASK_STATUSES, pt as sessionCompletionMessageSchema, q as isOrgSettingNamespace, r as AGENT_SELECTOR_HEADER$1, rt as paginationQuerySchema, s as AGENT_VISIBILITY, st as runtimeStateMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as notificationQuerySchema, u as ORG_SETTINGS_NAMESPACES$1, ut as selfServiceFeishuBotSchema, v as adminCreateTaskSchema, vt as stripCode, wt as updateClientCapabilitiesSchema, x as agentPinnedMessageSchema$1, xt as updateAgentRuntimeConfigSchema, y as adminUpdateTaskSchema, yt as taskListQuerySchema, z as extractMentions } from "./dist-CfvCT4E0.mjs";
|
|
6
|
+
import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
|
|
7
|
+
import { C as retireClient, D as touchAgent, E as setRuntimeState, O as unbindAgent, S as registerClient, T as setOffline, _ as listClients, a as claimClient, b as markStaleAgents, c as clients, d as getClient, f as getOnlineCount, g as listActiveAgentsPinnedToClient, h as heartbeatInstance, i as bindAgent, k as updateClientCapabilities, l as deriveAuthState, m as heartbeatClient, n as agents, o as cleanupStaleClients, p as getPresence, r as assertClientOwner, s as cleanupStalePresence, t as agentPresence, u as disconnectClient, v as listClientsForOrgAdmin, w as serverInstances, x as members } from "./client-DqdGiggm-NQoGZ2vM.mjs";
|
|
8
8
|
import { n as init_esm, r as trace, t as esm_exports } from "./esm-Ci8E1Gtj.mjs";
|
|
9
|
-
import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-
|
|
9
|
+
import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { ZodError, z } from "zod";
|
|
12
12
|
import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
@@ -16,7 +16,7 @@ import { EventEmitter } from "node:events";
|
|
|
16
16
|
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
17
17
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
18
18
|
import WebSocket from "ws";
|
|
19
|
-
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
19
|
+
import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
20
20
|
import { parse, stringify } from "yaml";
|
|
21
21
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
22
22
|
import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -371,160 +371,6 @@ z.object({
|
|
|
371
371
|
connected: z.boolean(),
|
|
372
372
|
lastActiveAt: z.string().nullable()
|
|
373
373
|
});
|
|
374
|
-
const presenceStatusSchema = z.enum(["online", "offline"]);
|
|
375
|
-
const runtimeStateSchema = z.enum([
|
|
376
|
-
"idle",
|
|
377
|
-
"working",
|
|
378
|
-
"blocked",
|
|
379
|
-
"error"
|
|
380
|
-
]);
|
|
381
|
-
z.enum([
|
|
382
|
-
"active",
|
|
383
|
-
"suspended",
|
|
384
|
-
"evicted"
|
|
385
|
-
]);
|
|
386
|
-
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
387
|
-
const clientSessionStateSchema = z.enum(["active", "suspended"]);
|
|
388
|
-
z.object({
|
|
389
|
-
chatId: z.string().min(1),
|
|
390
|
-
state: clientSessionStateSchema
|
|
391
|
-
});
|
|
392
|
-
z.object({ runtimeState: runtimeStateSchema });
|
|
393
|
-
z.object({
|
|
394
|
-
agentId: z.string().min(1),
|
|
395
|
-
runtimeType: z.string().max(50),
|
|
396
|
-
runtimeVersion: z.string().max(50).optional()
|
|
397
|
-
});
|
|
398
|
-
z.enum([
|
|
399
|
-
"wrong_client",
|
|
400
|
-
"not_owned",
|
|
401
|
-
"agent_suspended",
|
|
402
|
-
"wrong_org",
|
|
403
|
-
"unknown_agent",
|
|
404
|
-
"runtime_provider_mismatch"
|
|
405
|
-
]);
|
|
406
|
-
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
407
|
-
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
408
|
-
z.object({
|
|
409
|
-
agentId: z.string(),
|
|
410
|
-
status: presenceStatusSchema,
|
|
411
|
-
connectedAt: z.string().nullable(),
|
|
412
|
-
lastSeenAt: z.string(),
|
|
413
|
-
clientId: z.string().nullable().optional(),
|
|
414
|
-
runtimeType: z.string().nullable().optional(),
|
|
415
|
-
runtimeVersion: z.string().nullable().optional(),
|
|
416
|
-
runtimeState: runtimeStateSchema.nullable().optional(),
|
|
417
|
-
activeSessions: z.number().int().nullable().optional(),
|
|
418
|
-
totalSessions: z.number().int().nullable().optional(),
|
|
419
|
-
runtimeUpdatedAt: z.string().nullable().optional()
|
|
420
|
-
});
|
|
421
|
-
z.object({
|
|
422
|
-
total: z.number().int(),
|
|
423
|
-
running: z.number().int(),
|
|
424
|
-
byState: z.object({
|
|
425
|
-
idle: z.number().int(),
|
|
426
|
-
working: z.number().int(),
|
|
427
|
-
blocked: z.number().int(),
|
|
428
|
-
error: z.number().int()
|
|
429
|
-
}),
|
|
430
|
-
clients: z.number().int()
|
|
431
|
-
});
|
|
432
|
-
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
433
|
-
const agentTypeSchema = z.enum([
|
|
434
|
-
"human",
|
|
435
|
-
"personal_assistant",
|
|
436
|
-
"autonomous_agent"
|
|
437
|
-
]);
|
|
438
|
-
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
439
|
-
const agentSourceSchema = z.enum(["admin-api", "portal"]);
|
|
440
|
-
z.enum(["active", "suspended"]);
|
|
441
|
-
/**
|
|
442
|
-
* Agent-name rules (see docs/agent-naming-design.md §3.1):
|
|
443
|
-
* - Lowercase ASCII slug, hyphens + underscores allowed.
|
|
444
|
-
* - Must start with alphanumeric: `-` / `_` as first char collide with
|
|
445
|
-
* CLI flag parsing and markdown list syntax.
|
|
446
|
-
* - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
|
|
447
|
-
* @-mentioned in chat. Older rows created under the previous 1–100
|
|
448
|
-
* regex are grandfathered; the tight rule only gates new creates.
|
|
449
|
-
*/
|
|
450
|
-
const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
451
|
-
const RESERVED_AGENT_NAMES_SET = new Set([
|
|
452
|
-
"admin",
|
|
453
|
-
"agent",
|
|
454
|
-
"first-tree",
|
|
455
|
-
"hub",
|
|
456
|
-
"me",
|
|
457
|
-
"null",
|
|
458
|
-
"system",
|
|
459
|
-
"undefined"
|
|
460
|
-
]);
|
|
461
|
-
function isReservedAgentName(name) {
|
|
462
|
-
return RESERVED_AGENT_NAMES_SET.has(name);
|
|
463
|
-
}
|
|
464
|
-
z.object({
|
|
465
|
-
name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
|
|
466
|
-
type: agentTypeSchema,
|
|
467
|
-
displayName: z.string().min(1).max(200).optional(),
|
|
468
|
-
delegateMention: z.string().max(100).optional(),
|
|
469
|
-
organizationId: z.string().min(1).max(100).optional(),
|
|
470
|
-
source: agentSourceSchema.optional(),
|
|
471
|
-
visibility: agentVisibilitySchema.optional(),
|
|
472
|
-
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
473
|
-
managerId: z.string().optional(),
|
|
474
|
-
clientId: z.string().min(1).max(100).optional(),
|
|
475
|
-
runtimeProvider: runtimeProviderSchema.optional()
|
|
476
|
-
});
|
|
477
|
-
z.object({
|
|
478
|
-
type: agentTypeSchema.optional(),
|
|
479
|
-
displayName: z.string().min(1).max(200).optional(),
|
|
480
|
-
delegateMention: z.string().max(100).nullable().optional(),
|
|
481
|
-
visibility: agentVisibilitySchema.optional(),
|
|
482
|
-
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
483
|
-
managerId: z.string().nullable().optional(),
|
|
484
|
-
clientId: z.string().min(1).max(100).nullable().optional()
|
|
485
|
-
});
|
|
486
|
-
z.object({
|
|
487
|
-
clientId: z.string().min(1).max(100),
|
|
488
|
-
runtimeProvider: runtimeProviderSchema,
|
|
489
|
-
force: z.boolean().optional()
|
|
490
|
-
});
|
|
491
|
-
z.object({
|
|
492
|
-
uuid: z.string(),
|
|
493
|
-
name: z.string().nullable(),
|
|
494
|
-
organizationId: z.string(),
|
|
495
|
-
type: agentTypeSchema,
|
|
496
|
-
displayName: z.string(),
|
|
497
|
-
delegateMention: z.string().nullable(),
|
|
498
|
-
inboxId: z.string(),
|
|
499
|
-
status: z.string(),
|
|
500
|
-
source: z.string().nullable().optional(),
|
|
501
|
-
visibility: agentVisibilitySchema,
|
|
502
|
-
metadata: z.record(z.string(), z.unknown()),
|
|
503
|
-
managerId: z.string().nullable(),
|
|
504
|
-
clientId: z.string().nullable(),
|
|
505
|
-
runtimeProvider: runtimeProviderSchema,
|
|
506
|
-
presenceStatus: presenceStatusSchema.optional(),
|
|
507
|
-
createdAt: z.string(),
|
|
508
|
-
updatedAt: z.string()
|
|
509
|
-
});
|
|
510
|
-
z.object({
|
|
511
|
-
repo: z.string().nullable(),
|
|
512
|
-
branch: z.string().nullable()
|
|
513
|
-
});
|
|
514
|
-
/**
|
|
515
|
-
* Server → client WebSocket frame announcing that an agent has just been
|
|
516
|
-
* pinned to the connected client (either created with `clientId` or bound via
|
|
517
|
-
* PATCH NULL → ID). The client can auto-register a local config from this so
|
|
518
|
-
* the operator doesn't have to run `first-tree-hub agent add` manually.
|
|
519
|
-
*/
|
|
520
|
-
const agentPinnedMessageSchema = z.object({
|
|
521
|
-
type: z.literal("agent:pinned"),
|
|
522
|
-
agentId: z.string(),
|
|
523
|
-
name: z.string().nullable(),
|
|
524
|
-
displayName: z.string(),
|
|
525
|
-
agentType: agentTypeSchema,
|
|
526
|
-
runtimeProvider: runtimeProviderSchema
|
|
527
|
-
});
|
|
528
374
|
/**
|
|
529
375
|
* Agent runtime configuration.
|
|
530
376
|
*
|
|
@@ -665,11 +511,31 @@ const agentRuntimeConfigSchema = z.object({
|
|
|
665
511
|
updatedAt: z.string(),
|
|
666
512
|
updatedBy: z.string()
|
|
667
513
|
});
|
|
514
|
+
/**
|
|
515
|
+
* Write-side shape with no `.default()` per field.
|
|
516
|
+
*
|
|
517
|
+
* `agentRuntimeConfigPayloadShape` carries `.default()` on every field for the
|
|
518
|
+
* read path (so legacy DB rows parse cleanly). On the PATCH side those defaults
|
|
519
|
+
* are actively harmful: Zod 4's `.partial()` makes a field optional but keeps
|
|
520
|
+
* the inner `ZodDefault`, so a body like `{ mcpServers: [...] }` parses to a
|
|
521
|
+
* fully-populated patch where the omitted fields are filled with their
|
|
522
|
+
* defaults — the service layer's `patch.x ?? current.x` then sees a truthy
|
|
523
|
+
* default and *replaces* the user's saved value with empty. Mirroring the 5
|
|
524
|
+
* fields here without defaults keeps "field absent" → `undefined` in the
|
|
525
|
+
* parsed patch, which is what the merge logic expects.
|
|
526
|
+
*/
|
|
527
|
+
const agentRuntimeConfigPatchShape = z.object({
|
|
528
|
+
prompt: promptConfigSchema,
|
|
529
|
+
model: z.string(),
|
|
530
|
+
mcpServers: z.array(mcpServerSchema),
|
|
531
|
+
env: z.array(envEntrySchema),
|
|
532
|
+
gitRepos: z.array(gitRepoSchema)
|
|
533
|
+
}).partial();
|
|
668
534
|
z.object({
|
|
669
535
|
expectedVersion: z.number().int().positive(),
|
|
670
|
-
payload:
|
|
536
|
+
payload: agentRuntimeConfigPatchShape
|
|
671
537
|
});
|
|
672
|
-
z.object({ payload:
|
|
538
|
+
z.object({ payload: agentRuntimeConfigPatchShape });
|
|
673
539
|
z.object({
|
|
674
540
|
current: agentRuntimeConfigSchema,
|
|
675
541
|
next: agentRuntimeConfigPayloadSchema,
|
|
@@ -694,6 +560,161 @@ function deriveRepoLocalPath(url) {
|
|
|
694
560
|
if (!trimmed) return "";
|
|
695
561
|
return ((trimmed.split(/[?#]/)[0] ?? "").split(/[/:]/).filter(Boolean).pop() ?? "").replace(/\.git$/i, "");
|
|
696
562
|
}
|
|
563
|
+
const presenceStatusSchema = z.enum(["online", "offline"]);
|
|
564
|
+
const runtimeStateSchema = z.enum([
|
|
565
|
+
"idle",
|
|
566
|
+
"working",
|
|
567
|
+
"blocked",
|
|
568
|
+
"error"
|
|
569
|
+
]);
|
|
570
|
+
z.enum([
|
|
571
|
+
"active",
|
|
572
|
+
"suspended",
|
|
573
|
+
"evicted"
|
|
574
|
+
]);
|
|
575
|
+
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
576
|
+
const clientSessionStateSchema = z.enum(["active", "suspended"]);
|
|
577
|
+
z.object({
|
|
578
|
+
chatId: z.string().min(1),
|
|
579
|
+
state: clientSessionStateSchema
|
|
580
|
+
});
|
|
581
|
+
z.object({ runtimeState: runtimeStateSchema });
|
|
582
|
+
z.object({
|
|
583
|
+
agentId: z.string().min(1),
|
|
584
|
+
runtimeType: z.string().max(50),
|
|
585
|
+
runtimeVersion: z.string().max(50).optional()
|
|
586
|
+
});
|
|
587
|
+
z.enum([
|
|
588
|
+
"wrong_client",
|
|
589
|
+
"not_owned",
|
|
590
|
+
"agent_suspended",
|
|
591
|
+
"wrong_org",
|
|
592
|
+
"unknown_agent",
|
|
593
|
+
"runtime_provider_mismatch"
|
|
594
|
+
]);
|
|
595
|
+
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
596
|
+
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
597
|
+
z.object({
|
|
598
|
+
agentId: z.string(),
|
|
599
|
+
status: presenceStatusSchema,
|
|
600
|
+
connectedAt: z.string().nullable(),
|
|
601
|
+
lastSeenAt: z.string(),
|
|
602
|
+
clientId: z.string().nullable().optional(),
|
|
603
|
+
runtimeType: z.string().nullable().optional(),
|
|
604
|
+
runtimeVersion: z.string().nullable().optional(),
|
|
605
|
+
runtimeState: runtimeStateSchema.nullable().optional(),
|
|
606
|
+
activeSessions: z.number().int().nullable().optional(),
|
|
607
|
+
totalSessions: z.number().int().nullable().optional(),
|
|
608
|
+
runtimeUpdatedAt: z.string().nullable().optional()
|
|
609
|
+
});
|
|
610
|
+
z.object({
|
|
611
|
+
total: z.number().int(),
|
|
612
|
+
running: z.number().int(),
|
|
613
|
+
byState: z.object({
|
|
614
|
+
idle: z.number().int(),
|
|
615
|
+
working: z.number().int(),
|
|
616
|
+
blocked: z.number().int(),
|
|
617
|
+
error: z.number().int()
|
|
618
|
+
}),
|
|
619
|
+
clients: z.number().int()
|
|
620
|
+
});
|
|
621
|
+
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
622
|
+
const agentTypeSchema = z.enum([
|
|
623
|
+
"human",
|
|
624
|
+
"personal_assistant",
|
|
625
|
+
"autonomous_agent"
|
|
626
|
+
]);
|
|
627
|
+
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
628
|
+
const agentSourceSchema = z.enum(["admin-api", "portal"]);
|
|
629
|
+
z.enum(["active", "suspended"]);
|
|
630
|
+
/**
|
|
631
|
+
* Agent-name rules (see docs/agent-naming-design.md §3.1):
|
|
632
|
+
* - Lowercase ASCII slug, hyphens + underscores allowed.
|
|
633
|
+
* - Must start with alphanumeric: `-` / `_` as first char collide with
|
|
634
|
+
* CLI flag parsing and markdown list syntax.
|
|
635
|
+
* - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
|
|
636
|
+
* @-mentioned in chat. Older rows created under the previous 1–100
|
|
637
|
+
* regex are grandfathered; the tight rule only gates new creates.
|
|
638
|
+
*/
|
|
639
|
+
const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
640
|
+
const RESERVED_AGENT_NAMES_SET = new Set([
|
|
641
|
+
"admin",
|
|
642
|
+
"agent",
|
|
643
|
+
"first-tree",
|
|
644
|
+
"hub",
|
|
645
|
+
"me",
|
|
646
|
+
"null",
|
|
647
|
+
"system",
|
|
648
|
+
"undefined"
|
|
649
|
+
]);
|
|
650
|
+
function isReservedAgentName(name) {
|
|
651
|
+
return RESERVED_AGENT_NAMES_SET.has(name);
|
|
652
|
+
}
|
|
653
|
+
z.object({
|
|
654
|
+
name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
|
|
655
|
+
type: agentTypeSchema,
|
|
656
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
657
|
+
delegateMention: z.string().max(100).optional(),
|
|
658
|
+
organizationId: z.string().min(1).max(100).optional(),
|
|
659
|
+
source: agentSourceSchema.optional(),
|
|
660
|
+
visibility: agentVisibilitySchema.optional(),
|
|
661
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
662
|
+
managerId: z.string().optional(),
|
|
663
|
+
clientId: z.string().min(1).max(100).optional(),
|
|
664
|
+
runtimeProvider: runtimeProviderSchema.optional(),
|
|
665
|
+
gitRepos: z.array(gitRepoSchema).optional()
|
|
666
|
+
});
|
|
667
|
+
z.object({
|
|
668
|
+
type: agentTypeSchema.optional(),
|
|
669
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
670
|
+
delegateMention: z.string().max(100).nullable().optional(),
|
|
671
|
+
visibility: agentVisibilitySchema.optional(),
|
|
672
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
673
|
+
managerId: z.string().nullable().optional(),
|
|
674
|
+
clientId: z.string().min(1).max(100).nullable().optional()
|
|
675
|
+
});
|
|
676
|
+
z.object({
|
|
677
|
+
clientId: z.string().min(1).max(100),
|
|
678
|
+
runtimeProvider: runtimeProviderSchema,
|
|
679
|
+
force: z.boolean().optional()
|
|
680
|
+
});
|
|
681
|
+
z.object({
|
|
682
|
+
uuid: z.string(),
|
|
683
|
+
name: z.string().nullable(),
|
|
684
|
+
organizationId: z.string(),
|
|
685
|
+
type: agentTypeSchema,
|
|
686
|
+
displayName: z.string(),
|
|
687
|
+
delegateMention: z.string().nullable(),
|
|
688
|
+
inboxId: z.string(),
|
|
689
|
+
status: z.string(),
|
|
690
|
+
source: z.string().nullable().optional(),
|
|
691
|
+
visibility: agentVisibilitySchema,
|
|
692
|
+
metadata: z.record(z.string(), z.unknown()),
|
|
693
|
+
managerId: z.string().nullable(),
|
|
694
|
+
clientId: z.string().nullable(),
|
|
695
|
+
runtimeProvider: runtimeProviderSchema,
|
|
696
|
+
presenceStatus: presenceStatusSchema.optional(),
|
|
697
|
+
createdAt: z.string(),
|
|
698
|
+
updatedAt: z.string()
|
|
699
|
+
});
|
|
700
|
+
z.object({
|
|
701
|
+
repo: z.string().nullable(),
|
|
702
|
+
branch: z.string().nullable()
|
|
703
|
+
});
|
|
704
|
+
/**
|
|
705
|
+
* Server → client WebSocket frame announcing that an agent has just been
|
|
706
|
+
* pinned to the connected client (either created with `clientId` or bound via
|
|
707
|
+
* PATCH NULL → ID). The client can auto-register a local config from this so
|
|
708
|
+
* the operator doesn't have to run `first-tree-hub agent add` manually.
|
|
709
|
+
*/
|
|
710
|
+
const agentPinnedMessageSchema = z.object({
|
|
711
|
+
type: z.literal("agent:pinned"),
|
|
712
|
+
agentId: z.string(),
|
|
713
|
+
name: z.string().nullable(),
|
|
714
|
+
displayName: z.string(),
|
|
715
|
+
agentType: agentTypeSchema,
|
|
716
|
+
runtimeProvider: runtimeProviderSchema
|
|
717
|
+
});
|
|
697
718
|
z.object({
|
|
698
719
|
username: z.string().min(1),
|
|
699
720
|
password: z.string().min(1)
|
|
@@ -1217,6 +1238,37 @@ z.object({
|
|
|
1217
1238
|
name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1218
1239
|
displayName: z.string().min(1).max(200)
|
|
1219
1240
|
});
|
|
1241
|
+
z.object({ dismissed: z.boolean().optional() });
|
|
1242
|
+
/**
|
|
1243
|
+
* Body for `POST /me/onboarding/events`. The web SPA reports key
|
|
1244
|
+
* milestones so the server can log them as a single funnel-trackable
|
|
1245
|
+
* stream alongside server-emitted events (`team_created`, `dismissed`).
|
|
1246
|
+
*
|
|
1247
|
+
* Server emits:
|
|
1248
|
+
* - `team_created` — at OAuth callback when joinPath === "solo"
|
|
1249
|
+
* - `dismissed` — when PATCH /me/onboarding flips dismissed
|
|
1250
|
+
*
|
|
1251
|
+
* Web reports:
|
|
1252
|
+
* - `team_renamed` — Step 1 user changed the auto-named team
|
|
1253
|
+
* - `agent_created` — Step 2 successfully created the agent
|
|
1254
|
+
* - `tree_chat_started` — Step 3 [Yes, set it up] succeeded
|
|
1255
|
+
* - `tree_intro_dismissed` — Step 3 [I'll do it later] clicked
|
|
1256
|
+
*/
|
|
1257
|
+
const onboardingEventNameSchema = z.enum([
|
|
1258
|
+
"team_renamed",
|
|
1259
|
+
"agent_created",
|
|
1260
|
+
"tree_chat_started",
|
|
1261
|
+
"tree_intro_dismissed"
|
|
1262
|
+
]);
|
|
1263
|
+
z.object({
|
|
1264
|
+
event: onboardingEventNameSchema,
|
|
1265
|
+
attrs: z.record(z.string(), z.union([
|
|
1266
|
+
z.string(),
|
|
1267
|
+
z.number(),
|
|
1268
|
+
z.boolean(),
|
|
1269
|
+
z.null()
|
|
1270
|
+
])).optional()
|
|
1271
|
+
});
|
|
1220
1272
|
z.object({
|
|
1221
1273
|
id: z.string(),
|
|
1222
1274
|
organizationId: z.string(),
|
|
@@ -1285,13 +1337,78 @@ z.object({
|
|
|
1285
1337
|
code: z.string().min(1),
|
|
1286
1338
|
state: z.string().min(1)
|
|
1287
1339
|
});
|
|
1288
|
-
z.object({
|
|
1289
|
-
githubId: z.string().min(1),
|
|
1290
|
-
login: z.string().min(1),
|
|
1291
|
-
email: z.string().email().optional(),
|
|
1292
|
-
displayName: z.string().optional(),
|
|
1293
|
-
next: z.string().max(256).optional()
|
|
1340
|
+
z.object({
|
|
1341
|
+
githubId: z.string().min(1),
|
|
1342
|
+
login: z.string().min(1),
|
|
1343
|
+
email: z.string().email().optional(),
|
|
1344
|
+
displayName: z.string().optional(),
|
|
1345
|
+
next: z.string().max(256).optional()
|
|
1346
|
+
});
|
|
1347
|
+
/**
|
|
1348
|
+
* Per-organization settings — schemas, namespaces, and the registry that
|
|
1349
|
+
* dispatches `(orgId, namespace)` lookups to the right validator.
|
|
1350
|
+
*
|
|
1351
|
+
* Each namespace has three schemas:
|
|
1352
|
+
* - `storage` — what is persisted in `organization_settings.value`. For
|
|
1353
|
+
* namespaces with secrets, the storage schema names the *cipher* field
|
|
1354
|
+
* (e.g. `webhookSecretCipher`); plaintext never touches the row.
|
|
1355
|
+
* - `input` — what the admin API accepts in PUT bodies. For namespaces
|
|
1356
|
+
* with secrets, `webhookSecret` is plaintext; the service layer
|
|
1357
|
+
* encrypts it before merging into storage.
|
|
1358
|
+
* - `output` — what GET returns. Secrets are replaced by a boolean
|
|
1359
|
+
* `…Configured` flag — plaintext is never echoed.
|
|
1360
|
+
*
|
|
1361
|
+
* Adding a new per-org config group:
|
|
1362
|
+
* 1. Define three schemas (storage / input / output).
|
|
1363
|
+
* 2. Add a key to `ORG_SETTINGS_NAMESPACES`.
|
|
1364
|
+
* 3. Done. No DB migration, no new API route.
|
|
1365
|
+
*/
|
|
1366
|
+
const orgContextTreeRepoUrlSchema = z.string().url().refine((value) => {
|
|
1367
|
+
try {
|
|
1368
|
+
return new URL(value).protocol === "https:";
|
|
1369
|
+
} catch {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
}, { message: "Context Tree repo URL must use HTTPS." }).refine((value) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const url = new URL(value);
|
|
1375
|
+
return url.username.length === 0 && url.password.length === 0;
|
|
1376
|
+
} catch {
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
}, { message: "Context Tree repo URL must not include credentials." });
|
|
1380
|
+
const orgContextTreeStorageSchema = z.object({
|
|
1381
|
+
repo: orgContextTreeRepoUrlSchema.optional(),
|
|
1382
|
+
branch: z.string().default("main")
|
|
1383
|
+
});
|
|
1384
|
+
const orgContextTreeInputSchema = z.object({
|
|
1385
|
+
repo: orgContextTreeRepoUrlSchema.nullish(),
|
|
1386
|
+
branch: z.string().min(1).nullish()
|
|
1387
|
+
});
|
|
1388
|
+
const orgContextTreeOutputSchema = z.object({
|
|
1389
|
+
repo: z.string().optional(),
|
|
1390
|
+
branch: z.string().optional()
|
|
1294
1391
|
});
|
|
1392
|
+
const orgGithubIntegrationStorageSchema = z.object({ webhookSecretCipher: z.string().optional() });
|
|
1393
|
+
const orgGithubIntegrationInputSchema = z.object({ webhookSecret: z.string().min(1).nullish() });
|
|
1394
|
+
const orgGithubIntegrationOutputSchema = z.object({
|
|
1395
|
+
webhookSecretConfigured: z.boolean(),
|
|
1396
|
+
webhookUrl: z.string()
|
|
1397
|
+
});
|
|
1398
|
+
const ORG_SETTINGS_NAMESPACES = {
|
|
1399
|
+
context_tree: {
|
|
1400
|
+
storage: orgContextTreeStorageSchema,
|
|
1401
|
+
input: orgContextTreeInputSchema,
|
|
1402
|
+
output: orgContextTreeOutputSchema
|
|
1403
|
+
},
|
|
1404
|
+
github_integration: {
|
|
1405
|
+
storage: orgGithubIntegrationStorageSchema,
|
|
1406
|
+
input: orgGithubIntegrationInputSchema,
|
|
1407
|
+
output: orgGithubIntegrationOutputSchema
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
const ORG_SETTINGS_NAMESPACE_KEYS = Object.keys(ORG_SETTINGS_NAMESPACES);
|
|
1411
|
+
z.enum(ORG_SETTINGS_NAMESPACE_KEYS);
|
|
1295
1412
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1296
1413
|
z.object({
|
|
1297
1414
|
name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/, "Must start with a letter or digit and contain only lowercase alphanumeric and hyphens").refine((v) => !UUID_PATTERN.test(v), "Name must not be a UUID format"),
|
|
@@ -1684,21 +1801,13 @@ defineConfig({
|
|
|
1684
1801
|
refreshTokenExpiry: field(z.string().default("30d"), { env: "FIRST_TREE_HUB_AUTH_REFRESH_TOKEN_EXPIRY" }),
|
|
1685
1802
|
connectTokenExpiry: field(z.string().default("10m"), { env: "FIRST_TREE_HUB_AUTH_CONNECT_TOKEN_EXPIRY" })
|
|
1686
1803
|
},
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
env: "
|
|
1690
|
-
prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
|
|
1691
|
-
}),
|
|
1692
|
-
localPath: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_PATH" }),
|
|
1693
|
-
branch: field(z.string().default("main"))
|
|
1694
|
-
}),
|
|
1695
|
-
github: {
|
|
1696
|
-
webhookSecret: field(z.string().optional(), {
|
|
1697
|
-
env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
|
|
1804
|
+
contextTreeSync: optional({
|
|
1805
|
+
githubToken: field(z.string(), {
|
|
1806
|
+
env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN",
|
|
1698
1807
|
secret: true
|
|
1699
1808
|
}),
|
|
1700
|
-
|
|
1701
|
-
},
|
|
1809
|
+
githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
|
|
1810
|
+
}),
|
|
1702
1811
|
oauth: optional({ github: optional({
|
|
1703
1812
|
clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
|
|
1704
1813
|
clientSecret: field(z.string(), {
|
|
@@ -7820,6 +7929,12 @@ function printResults(results) {
|
|
|
7820
7929
|
}
|
|
7821
7930
|
//#endregion
|
|
7822
7931
|
//#region src/core/migrate.ts
|
|
7932
|
+
function sslOptions$1(url) {
|
|
7933
|
+
try {
|
|
7934
|
+
if (new URL(url).hostname.endsWith(".rds.amazonaws.com")) return { ssl: { rejectUnauthorized: false } };
|
|
7935
|
+
} catch {}
|
|
7936
|
+
return {};
|
|
7937
|
+
}
|
|
7823
7938
|
/**
|
|
7824
7939
|
* Resolve the drizzle migrations directory.
|
|
7825
7940
|
* 1. npm install: embedded at dist/drizzle/ (relative to the built CLI)
|
|
@@ -7853,14 +7968,21 @@ function validateJournalOrder(migrationsFolder) {
|
|
|
7853
7968
|
async function runMigrations(databaseUrl) {
|
|
7854
7969
|
const migrationsFolder = resolveMigrationsFolder();
|
|
7855
7970
|
validateJournalOrder(migrationsFolder);
|
|
7856
|
-
const
|
|
7971
|
+
const ssl = sslOptions$1(databaseUrl);
|
|
7972
|
+
const client = postgres(databaseUrl, {
|
|
7973
|
+
max: 1,
|
|
7974
|
+
...ssl
|
|
7975
|
+
});
|
|
7857
7976
|
const db = drizzle(client);
|
|
7858
7977
|
try {
|
|
7859
7978
|
await migrate(db, { migrationsFolder });
|
|
7860
7979
|
} finally {
|
|
7861
7980
|
await client.end();
|
|
7862
7981
|
}
|
|
7863
|
-
const countClient = postgres(databaseUrl, {
|
|
7982
|
+
const countClient = postgres(databaseUrl, {
|
|
7983
|
+
max: 1,
|
|
7984
|
+
...ssl
|
|
7985
|
+
});
|
|
7864
7986
|
try {
|
|
7865
7987
|
return (await countClient`
|
|
7866
7988
|
SELECT count(*)::int AS count
|
|
@@ -8254,7 +8376,7 @@ async function onboardCreate(args) {
|
|
|
8254
8376
|
}
|
|
8255
8377
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
8256
8378
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
8257
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
8379
|
+
const { bindFeishuBot } = await import("./feishu-DbSvp9UH.mjs").then((n) => n.r);
|
|
8258
8380
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
8259
8381
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
8260
8382
|
else {
|
|
@@ -9467,7 +9589,7 @@ function createFeedbackHandler(config) {
|
|
|
9467
9589
|
return { handle };
|
|
9468
9590
|
}
|
|
9469
9591
|
//#endregion
|
|
9470
|
-
//#region ../server/dist/app
|
|
9592
|
+
//#region ../server/dist/app--DB1keQE.mjs
|
|
9471
9593
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
9472
9594
|
init_esm();
|
|
9473
9595
|
var __defProp = Object.defineProperty;
|
|
@@ -10977,29 +11099,33 @@ async function createAgent(db, data, options = {}) {
|
|
|
10977
11099
|
}
|
|
10978
11100
|
const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
|
|
10979
11101
|
try {
|
|
10980
|
-
|
|
10981
|
-
|
|
10982
|
-
|
|
10983
|
-
|
|
10984
|
-
|
|
10985
|
-
|
|
10986
|
-
|
|
10987
|
-
|
|
10988
|
-
|
|
10989
|
-
|
|
10990
|
-
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
|
|
10995
|
-
|
|
10996
|
-
|
|
10997
|
-
|
|
10998
|
-
|
|
10999
|
-
|
|
11000
|
-
|
|
11001
|
-
|
|
11002
|
-
|
|
11102
|
+
return await db.transaction(async (tx) => {
|
|
11103
|
+
const [row] = await tx.insert(agents).values({
|
|
11104
|
+
uuid,
|
|
11105
|
+
name,
|
|
11106
|
+
organizationId: orgId,
|
|
11107
|
+
type: data.type,
|
|
11108
|
+
displayName: resolvedDisplayName,
|
|
11109
|
+
delegateMention: data.delegateMention ?? null,
|
|
11110
|
+
inboxId,
|
|
11111
|
+
source: data.source ?? null,
|
|
11112
|
+
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
11113
|
+
metadata: data.metadata ?? {},
|
|
11114
|
+
managerId,
|
|
11115
|
+
clientId,
|
|
11116
|
+
runtimeProvider
|
|
11117
|
+
}).returning();
|
|
11118
|
+
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
11119
|
+
const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
|
|
11120
|
+
if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
|
|
11121
|
+
await tx.insert(agentConfigs).values({
|
|
11122
|
+
agentId: row.uuid,
|
|
11123
|
+
version: 1,
|
|
11124
|
+
payload: initialPayload,
|
|
11125
|
+
updatedBy: "system"
|
|
11126
|
+
}).onConflictDoNothing();
|
|
11127
|
+
return row;
|
|
11128
|
+
});
|
|
11003
11129
|
} catch (err) {
|
|
11004
11130
|
if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
|
|
11005
11131
|
throw err;
|
|
@@ -14655,11 +14781,21 @@ const authIdentities = pgTable("auth_identities", {
|
|
|
14655
14781
|
* treats it as a plain string and rejects every password — that's the
|
|
14656
14782
|
* intended behaviour: SaaS users cannot fall back to password login.
|
|
14657
14783
|
*/
|
|
14658
|
-
async function findOrCreateUserFromGithub(db, profile) {
|
|
14659
|
-
const [existing] = await db.select({
|
|
14784
|
+
async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
14785
|
+
const [existing] = await db.select({
|
|
14786
|
+
userId: authIdentities.userId,
|
|
14787
|
+
metadata: authIdentities.metadata
|
|
14788
|
+
}).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
|
|
14660
14789
|
if (existing) {
|
|
14661
|
-
|
|
14662
|
-
|
|
14790
|
+
const patch = {};
|
|
14791
|
+
if (profile.email) patch.email = profile.email;
|
|
14792
|
+
if (opts.encryptedAccessToken) patch.metadata = {
|
|
14793
|
+
...existing.metadata ?? {},
|
|
14794
|
+
accessToken: opts.encryptedAccessToken,
|
|
14795
|
+
login: profile.login
|
|
14796
|
+
};
|
|
14797
|
+
if (Object.keys(patch).length > 0) await db.update(authIdentities).set({
|
|
14798
|
+
...patch,
|
|
14663
14799
|
updatedAt: /* @__PURE__ */ new Date()
|
|
14664
14800
|
}).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId)));
|
|
14665
14801
|
return { userId: existing.userId };
|
|
@@ -14675,6 +14811,8 @@ async function findOrCreateUserFromGithub(db, profile) {
|
|
|
14675
14811
|
displayName: profile.displayName?.trim() || profile.login,
|
|
14676
14812
|
avatarUrl: profile.avatarUrl ?? null
|
|
14677
14813
|
});
|
|
14814
|
+
const metadata = { login: profile.login };
|
|
14815
|
+
if (opts.encryptedAccessToken) metadata.accessToken = opts.encryptedAccessToken;
|
|
14678
14816
|
await tx.insert(authIdentities).values({
|
|
14679
14817
|
id: uuidv7(),
|
|
14680
14818
|
userId,
|
|
@@ -14682,7 +14820,7 @@ async function findOrCreateUserFromGithub(db, profile) {
|
|
|
14682
14820
|
identifier: profile.githubId,
|
|
14683
14821
|
email: profile.email,
|
|
14684
14822
|
verifiedAt: /* @__PURE__ */ new Date(),
|
|
14685
|
-
metadata
|
|
14823
|
+
metadata
|
|
14686
14824
|
});
|
|
14687
14825
|
});
|
|
14688
14826
|
return { userId };
|
|
@@ -14764,13 +14902,57 @@ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
|
14764
14902
|
}
|
|
14765
14903
|
}
|
|
14766
14904
|
return {
|
|
14767
|
-
|
|
14768
|
-
|
|
14769
|
-
|
|
14770
|
-
|
|
14771
|
-
|
|
14905
|
+
profile: {
|
|
14906
|
+
githubId: String(user.id),
|
|
14907
|
+
login: user.login,
|
|
14908
|
+
email,
|
|
14909
|
+
displayName: user.name ?? null,
|
|
14910
|
+
avatarUrl: user.avatar_url ?? null
|
|
14911
|
+
},
|
|
14912
|
+
accessToken: tokenJson.access_token
|
|
14772
14913
|
};
|
|
14773
14914
|
}
|
|
14915
|
+
/**
|
|
14916
|
+
* Thrown when GitHub's API returns a non-2xx for a token-scoped call.
|
|
14917
|
+
* Carries the HTTP status so callers can distinguish auth failures (401 /
|
|
14918
|
+
* 403 — typically a stale token or a missing scope after we expanded to
|
|
14919
|
+
* `repo`) from transient upstream errors.
|
|
14920
|
+
*/
|
|
14921
|
+
var GithubApiError = class extends Error {
|
|
14922
|
+
constructor(status, message) {
|
|
14923
|
+
super(message);
|
|
14924
|
+
this.status = status;
|
|
14925
|
+
this.name = "GithubApiError";
|
|
14926
|
+
}
|
|
14927
|
+
};
|
|
14928
|
+
/**
|
|
14929
|
+
* Fetch the authenticated user's accessible repositories. Used by the
|
|
14930
|
+
* Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
|
|
14931
|
+
*/
|
|
14932
|
+
async function listUserRepos(accessToken, opts = {}) {
|
|
14933
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
14934
|
+
const perPage = opts.perPage ?? 100;
|
|
14935
|
+
const maxPages = opts.maxPages ?? 3;
|
|
14936
|
+
const out = [];
|
|
14937
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
14938
|
+
const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
|
|
14939
|
+
Authorization: `Bearer ${accessToken}`,
|
|
14940
|
+
Accept: "application/vnd.github+json"
|
|
14941
|
+
} });
|
|
14942
|
+
if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
|
|
14943
|
+
const rows = await res.json();
|
|
14944
|
+
for (const r of rows) out.push({
|
|
14945
|
+
fullName: r.full_name,
|
|
14946
|
+
cloneUrl: r.clone_url,
|
|
14947
|
+
htmlUrl: r.html_url,
|
|
14948
|
+
private: r.private,
|
|
14949
|
+
defaultBranch: r.default_branch ?? null,
|
|
14950
|
+
pushedAt: r.pushed_at ?? null
|
|
14951
|
+
});
|
|
14952
|
+
if (rows.length < perPage) break;
|
|
14953
|
+
}
|
|
14954
|
+
return out;
|
|
14955
|
+
}
|
|
14774
14956
|
/** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
|
|
14775
14957
|
async function ensureMembership(db, data) {
|
|
14776
14958
|
const [existing] = await db.select().from(members).where(and(eq(members.userId, data.userId), eq(members.organizationId, data.organizationId))).limit(1);
|
|
@@ -14823,14 +15005,15 @@ function sanitizeAgentName(login) {
|
|
|
14823
15005
|
* - First try: `${login}` (lowercased, sanitized)
|
|
14824
15006
|
* - On collision: append a 4-char hex disambiguator
|
|
14825
15007
|
*
|
|
14826
|
-
*
|
|
14827
|
-
*
|
|
14828
|
-
*
|
|
14829
|
-
*
|
|
15008
|
+
* Default team display name is `${login}'s team` (set by the caller — see
|
|
15009
|
+
* docs/new-user-onboarding-design.md §5.5). Reads as "this is a collective
|
|
15010
|
+
* space" from day one so a later teammate-invite doesn't surface a label
|
|
15011
|
+
* that looks like a private sandbox. Users can rename via Step 1 of the
|
|
15012
|
+
* onboarding flow or Settings.
|
|
14830
15013
|
*/
|
|
14831
15014
|
async function createPersonalTeam(db, input) {
|
|
14832
15015
|
const baseSlug = sanitizeOrgSlug(input.loginSeed);
|
|
14833
|
-
const displayName = input.
|
|
15016
|
+
const displayName = input.teamDisplayName;
|
|
14834
15017
|
const orgId = uuidv7();
|
|
14835
15018
|
return {
|
|
14836
15019
|
organizationId: orgId,
|
|
@@ -15087,7 +15270,7 @@ async function githubOauthRoutes(app) {
|
|
|
15087
15270
|
client_id: oauthCfg.clientId,
|
|
15088
15271
|
redirect_uri: redirectUri,
|
|
15089
15272
|
state: token,
|
|
15090
|
-
scope: "read:user user:email",
|
|
15273
|
+
scope: "read:user user:email repo",
|
|
15091
15274
|
allow_signup: "true"
|
|
15092
15275
|
});
|
|
15093
15276
|
return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
|
@@ -15111,17 +15294,20 @@ async function githubOauthRoutes(app) {
|
|
|
15111
15294
|
}));
|
|
15112
15295
|
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
15113
15296
|
let profile;
|
|
15297
|
+
let accessToken;
|
|
15114
15298
|
try {
|
|
15115
|
-
|
|
15299
|
+
const result = await exchangeCodeForProfile({
|
|
15116
15300
|
clientId: oauthCfg.clientId,
|
|
15117
15301
|
clientSecret: oauthCfg.clientSecret
|
|
15118
15302
|
}, code, redirectUri);
|
|
15303
|
+
profile = result.profile;
|
|
15304
|
+
accessToken = result.accessToken;
|
|
15119
15305
|
} catch (err) {
|
|
15120
15306
|
const msg = err instanceof Error ? err.message : "GitHub exchange failed";
|
|
15121
15307
|
app.log.warn({ err }, "github oauth code exchange failed");
|
|
15122
15308
|
return reply.status(401).send({ error: msg });
|
|
15123
15309
|
}
|
|
15124
|
-
return completeOauthFlow(app, request, reply, profile, next);
|
|
15310
|
+
return completeOauthFlow(app, request, reply, profile, next, accessToken);
|
|
15125
15311
|
});
|
|
15126
15312
|
app.get("/dev-callback", async (request, reply) => {
|
|
15127
15313
|
if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
|
|
@@ -15133,11 +15319,12 @@ async function githubOauthRoutes(app) {
|
|
|
15133
15319
|
email: params.email ?? null,
|
|
15134
15320
|
displayName: params.displayName ?? params.login,
|
|
15135
15321
|
avatarUrl: null
|
|
15136
|
-
}, next);
|
|
15322
|
+
}, next, process.env.DEV_GITHUB_PAT?.trim() || null);
|
|
15137
15323
|
});
|
|
15138
15324
|
}
|
|
15139
|
-
async function completeOauthFlow(app, request, reply, profile, next) {
|
|
15140
|
-
const
|
|
15325
|
+
async function completeOauthFlow(app, request, reply, profile, next, rawAccessToken) {
|
|
15326
|
+
const encryptedAccessToken = rawAccessToken ? encryptValue(rawAccessToken, app.config.secrets.encryptionKey) : void 0;
|
|
15327
|
+
const { userId } = await findOrCreateUserFromGithub(app.db, profile, { encryptedAccessToken });
|
|
15141
15328
|
let joinPath = "returning";
|
|
15142
15329
|
const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
|
|
15143
15330
|
let resolved = false;
|
|
@@ -15163,14 +15350,21 @@ async function completeOauthFlow(app, request, reply, profile, next) {
|
|
|
15163
15350
|
next = "/";
|
|
15164
15351
|
} else if (await pickPrimaryMembership(app.db, userId)) resolved = true;
|
|
15165
15352
|
else {
|
|
15166
|
-
await createPersonalTeam(app.db, {
|
|
15353
|
+
const personal = await createPersonalTeam(app.db, {
|
|
15167
15354
|
userId,
|
|
15168
15355
|
loginSeed: profile.login,
|
|
15356
|
+
teamDisplayName: `${profile.login}'s team`,
|
|
15169
15357
|
userDisplayName: profile.displayName?.trim() || profile.login
|
|
15170
15358
|
});
|
|
15171
15359
|
joinPath = "solo";
|
|
15172
15360
|
resolved = true;
|
|
15173
15361
|
next = "/";
|
|
15362
|
+
app.log.info({
|
|
15363
|
+
event: "onboarding.team_created",
|
|
15364
|
+
userId,
|
|
15365
|
+
organizationId: personal.organizationId,
|
|
15366
|
+
source: "oauth-bootstrap"
|
|
15367
|
+
}, "onboarding funnel: team auto-created at OAuth bootstrap");
|
|
15174
15368
|
}
|
|
15175
15369
|
if (!resolved) return reply.status(500).send({ error: "Failed to resolve membership" });
|
|
15176
15370
|
const tokens = await signTokensForUser(app.config.secrets.jwtSecret, userId, app.config.auth);
|
|
@@ -15218,10 +15412,18 @@ async function authRoutes(app) {
|
|
|
15218
15412
|
return reply.send(result);
|
|
15219
15413
|
});
|
|
15220
15414
|
}
|
|
15221
|
-
async function bootstrapConfigRoutes(
|
|
15222
|
-
/**
|
|
15223
|
-
|
|
15224
|
-
|
|
15415
|
+
async function bootstrapConfigRoutes(_app) {
|
|
15416
|
+
/**
|
|
15417
|
+
* Public endpoint — returns bootstrap prerequisites for CLI auto-discovery.
|
|
15418
|
+
*
|
|
15419
|
+
* `allowedOrg` used to surface here from the global `github.allowedOrg`
|
|
15420
|
+
* config; it is now a per-org setting (see issue #255). A public bootstrap
|
|
15421
|
+
* endpoint can't resolve an org without a caller, so the field is
|
|
15422
|
+
* surfaced as `null` and consumers should fetch the per-org value via
|
|
15423
|
+
* `/api/v1/orgs/:orgId/settings/github_integration` after auth.
|
|
15424
|
+
*/
|
|
15425
|
+
_app.get("/config", async () => {
|
|
15426
|
+
return { allowedOrg: null };
|
|
15225
15427
|
});
|
|
15226
15428
|
}
|
|
15227
15429
|
/** Extract a plain-text summary from a message's JSONB content field.
|
|
@@ -15990,12 +16192,212 @@ async function clientRoutes(app) {
|
|
|
15990
16192
|
});
|
|
15991
16193
|
});
|
|
15992
16194
|
}
|
|
16195
|
+
/**
|
|
16196
|
+
* Per-organization settings, keyed by `(organization_id, namespace)`.
|
|
16197
|
+
*
|
|
16198
|
+
* One row holds an entire group of related config as a JSONB blob — schema
|
|
16199
|
+
* for each namespace lives in `@agent-team-foundation/first-tree-hub-shared`
|
|
16200
|
+
* (`ORG_SETTINGS_NAMESPACES`) and is enforced by the service layer on every
|
|
16201
|
+
* read/write. Adding a new config group means registering a new namespace +
|
|
16202
|
+
* Zod schema in shared; the DB does not change.
|
|
16203
|
+
*
|
|
16204
|
+
* `version` is reserved for future optimistic locking (PUT with If-Match)
|
|
16205
|
+
* and is currently set unconditionally. We keep it on the table from day
|
|
16206
|
+
* one so tightening to compare-and-swap later is a code-only change.
|
|
16207
|
+
*
|
|
16208
|
+
* Sensitive fields inside `value` (e.g. `github_integration.webhookSecret`)
|
|
16209
|
+
* are AES-256-GCM-encrypted at the service layer using `crypto.ts`'s
|
|
16210
|
+
* `encryptValue` / `decryptValue` — same pattern as `adapter_configs`.
|
|
16211
|
+
*/
|
|
16212
|
+
const organizationSettings = pgTable("organization_settings", {
|
|
16213
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
|
16214
|
+
namespace: text("namespace").notNull(),
|
|
16215
|
+
value: jsonb("value").$type().notNull().default({}),
|
|
16216
|
+
version: integer("version").notNull().default(0),
|
|
16217
|
+
updatedBy: text("updated_by").references(() => users.id, { onDelete: "set null" }),
|
|
16218
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
16219
|
+
}, (table) => [primaryKey({ columns: [table.organizationId, table.namespace] }), index("idx_org_settings_namespace").on(table.namespace)]);
|
|
16220
|
+
/**
|
|
16221
|
+
* Per-organization settings, keyed by `(organizationId, namespace)`. The
|
|
16222
|
+
* registry of valid namespaces and their storage / input / output schemas
|
|
16223
|
+
* lives in `@agent-team-foundation/first-tree-hub-shared`.
|
|
16224
|
+
*
|
|
16225
|
+
* Read path: storage row → decrypt secrets → output (mask)
|
|
16226
|
+
* Write path: input → validate → encrypt secrets → merge with current storage → upsert (in tx)
|
|
16227
|
+
*
|
|
16228
|
+
* The generic getter returns the masked output. Callers needing plaintext
|
|
16229
|
+
* for a specific secret use a purpose-built helper (e.g.
|
|
16230
|
+
* `getDecryptedGithubWebhookSecret`) rather than the generic storage shape
|
|
16231
|
+
* — this avoids a `…Cipher` field name silently holding plaintext at
|
|
16232
|
+
* call-sites and limits secret exposure to one explicit code path per
|
|
16233
|
+
* secret. (#4)
|
|
16234
|
+
*/
|
|
16235
|
+
function assertNamespace(ns) {
|
|
16236
|
+
if (!isOrgSettingNamespace(ns)) throw new BadRequestError(`Unknown organization-settings namespace: "${ns}"`);
|
|
16237
|
+
}
|
|
16238
|
+
async function fetchStorageRow(db, orgId, namespace) {
|
|
16239
|
+
const [row] = await db.select({ value: organizationSettings.value }).from(organizationSettings).where(and(eq(organizationSettings.organizationId, orgId), eq(organizationSettings.namespace, namespace))).limit(1);
|
|
16240
|
+
if (!row) return null;
|
|
16241
|
+
return ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse(row.value);
|
|
16242
|
+
}
|
|
16243
|
+
function emptyStorage(namespace) {
|
|
16244
|
+
return ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse({});
|
|
16245
|
+
}
|
|
16246
|
+
function ensureEncrypted(value, encryptionKey) {
|
|
16247
|
+
return isEncryptedValue(value) ? value : encryptValue(value, encryptionKey);
|
|
16248
|
+
}
|
|
16249
|
+
/**
|
|
16250
|
+
* Merge a validated input into the current storage row for a namespace.
|
|
16251
|
+
* Secret fields are encrypted here.
|
|
16252
|
+
*
|
|
16253
|
+
* Input semantics per nullish field:
|
|
16254
|
+
* `undefined` → unchanged
|
|
16255
|
+
* `null` → cleared
|
|
16256
|
+
* value → set / replace (already validated as non-empty by the input schema)
|
|
16257
|
+
*/
|
|
16258
|
+
function applyInputDelta(namespace, current, input, encryptionKey) {
|
|
16259
|
+
if (namespace === "context_tree") {
|
|
16260
|
+
const cur = current;
|
|
16261
|
+
const inp = input;
|
|
16262
|
+
return {
|
|
16263
|
+
repo: inp.repo === void 0 ? cur.repo : inp.repo ?? void 0,
|
|
16264
|
+
branch: inp.branch === void 0 ? cur.branch : inp.branch ?? "main"
|
|
16265
|
+
};
|
|
16266
|
+
}
|
|
16267
|
+
if (namespace === "github_integration") {
|
|
16268
|
+
const cur = current;
|
|
16269
|
+
const inp = input;
|
|
16270
|
+
return { webhookSecretCipher: inp.webhookSecret === void 0 ? cur.webhookSecretCipher : inp.webhookSecret === null ? void 0 : ensureEncrypted(inp.webhookSecret, encryptionKey) };
|
|
16271
|
+
}
|
|
16272
|
+
return namespace;
|
|
16273
|
+
}
|
|
16274
|
+
/**
|
|
16275
|
+
* Project the storage row into the API output for a namespace, masking
|
|
16276
|
+
* any secret fields. `webhookUrl` for `github_integration` is left as an
|
|
16277
|
+
* empty string here — the route layer enriches it with the resolved
|
|
16278
|
+
* `server.publicUrl` (the service stays config-agnostic).
|
|
16279
|
+
*/
|
|
16280
|
+
function toOutput(namespace, storage) {
|
|
16281
|
+
if (namespace === "context_tree") {
|
|
16282
|
+
const s = storage;
|
|
16283
|
+
return {
|
|
16284
|
+
repo: s.repo,
|
|
16285
|
+
branch: s.branch
|
|
16286
|
+
};
|
|
16287
|
+
}
|
|
16288
|
+
if (namespace === "github_integration") {
|
|
16289
|
+
const s = storage;
|
|
16290
|
+
return {
|
|
16291
|
+
webhookSecretConfigured: typeof s.webhookSecretCipher === "string" && s.webhookSecretCipher.length > 0,
|
|
16292
|
+
webhookUrl: ""
|
|
16293
|
+
};
|
|
16294
|
+
}
|
|
16295
|
+
return namespace;
|
|
16296
|
+
}
|
|
16297
|
+
/**
|
|
16298
|
+
* Read a setting masked for the API. Missing rows → namespace defaults
|
|
16299
|
+
* (parse `{}` against the storage schema).
|
|
16300
|
+
*/
|
|
16301
|
+
async function getOrgSetting(db, orgId, namespace) {
|
|
16302
|
+
assertNamespace(namespace);
|
|
16303
|
+
return toOutput(namespace, await fetchStorageRow(db, orgId, namespace) ?? emptyStorage(namespace));
|
|
16304
|
+
}
|
|
16305
|
+
/**
|
|
16306
|
+
* Read the per-org Context Tree binding for server-internal consumers
|
|
16307
|
+
* (`/context-tree/info`, snapshot service). No secrets in this namespace,
|
|
16308
|
+
* so the storage shape is safe to expose directly. Missing row → defaults.
|
|
16309
|
+
*/
|
|
16310
|
+
async function getOrgContextTree(db, orgId) {
|
|
16311
|
+
return await fetchStorageRow(db, orgId, "context_tree") ?? emptyStorage("context_tree");
|
|
16312
|
+
}
|
|
16313
|
+
/**
|
|
16314
|
+
* Decrypt and return the plaintext GitHub webhook secret for an org.
|
|
16315
|
+
* Returns `null` when the org has not configured one. The only intended
|
|
16316
|
+
* caller is the webhook route's signature verifier — the result must
|
|
16317
|
+
* never leak through HTTP responses or logs. (#4)
|
|
16318
|
+
*/
|
|
16319
|
+
async function getDecryptedGithubWebhookSecret(db, orgId, encryptionKey) {
|
|
16320
|
+
const cipher = (await fetchStorageRow(db, orgId, "github_integration"))?.webhookSecretCipher;
|
|
16321
|
+
if (!cipher) return null;
|
|
16322
|
+
return isEncryptedValue(cipher) ? decryptValue(cipher, encryptionKey) : cipher;
|
|
16323
|
+
}
|
|
16324
|
+
/**
|
|
16325
|
+
* Upsert a setting. Returns the masked output of the resulting row.
|
|
16326
|
+
*
|
|
16327
|
+
* The fetch + merge + upsert sequence runs inside a single transaction so
|
|
16328
|
+
* two concurrent admin writes can't both base their delta on the same
|
|
16329
|
+
* pre-image and silently lose each other's fields. Optimistic locking
|
|
16330
|
+
* (the `version` column) remains reserved for a future If-Match flip.
|
|
16331
|
+
* (#6)
|
|
16332
|
+
*/
|
|
16333
|
+
async function putOrgSetting(db, orgId, namespace, rawInput, options) {
|
|
16334
|
+
assertNamespace(namespace);
|
|
16335
|
+
const input = ORG_SETTINGS_NAMESPACES$1[namespace].input.parse(rawInput);
|
|
16336
|
+
return db.transaction(async (tx) => {
|
|
16337
|
+
const txDb = tx;
|
|
16338
|
+
const [org] = await txDb.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
16339
|
+
if (!org) throw new NotFoundError(`Organization "${orgId}" not found`);
|
|
16340
|
+
const merged = applyInputDelta(namespace, await fetchStorageRow(txDb, orgId, namespace) ?? emptyStorage(namespace), input, options.encryptionKey);
|
|
16341
|
+
const validated = ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse(merged);
|
|
16342
|
+
await tx.insert(organizationSettings).values({
|
|
16343
|
+
organizationId: orgId,
|
|
16344
|
+
namespace,
|
|
16345
|
+
value: validated,
|
|
16346
|
+
version: 1,
|
|
16347
|
+
updatedBy: options.updatedBy,
|
|
16348
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
16349
|
+
}).onConflictDoUpdate({
|
|
16350
|
+
target: [organizationSettings.organizationId, organizationSettings.namespace],
|
|
16351
|
+
set: {
|
|
16352
|
+
value: validated,
|
|
16353
|
+
version: sql`${organizationSettings.version} + 1`,
|
|
16354
|
+
updatedBy: options.updatedBy,
|
|
16355
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
16356
|
+
}
|
|
16357
|
+
});
|
|
16358
|
+
return toOutput(namespace, validated);
|
|
16359
|
+
});
|
|
16360
|
+
}
|
|
16361
|
+
/**
|
|
16362
|
+
* Delete a namespace row; subsequent GETs return defaults.
|
|
16363
|
+
*/
|
|
16364
|
+
async function deleteOrgSetting(db, orgId, namespace) {
|
|
16365
|
+
assertNamespace(namespace);
|
|
16366
|
+
await db.delete(organizationSettings).where(and(eq(organizationSettings.organizationId, orgId), eq(organizationSettings.namespace, namespace)));
|
|
16367
|
+
}
|
|
16368
|
+
/**
|
|
16369
|
+
* Resolve the caller's "primary org" — the earliest-joined active
|
|
16370
|
+
* membership for the given user. Used by user-scoped routes that
|
|
16371
|
+
* historically didn't take an `:orgId` (e.g. `/context-tree/info`) so
|
|
16372
|
+
* the SDK call shape doesn't have to change while the per-tenant lookup
|
|
16373
|
+
* still happens correctly.
|
|
16374
|
+
*
|
|
16375
|
+
* Returns `null` for users with no active membership. Tightening to
|
|
16376
|
+
* "explicit org selector" is a future change-once-multi-org-clients-arrive
|
|
16377
|
+
* concern. (#7)
|
|
16378
|
+
*/
|
|
16379
|
+
async function resolveUserPrimaryOrgId(db, userId) {
|
|
16380
|
+
const [row] = await db.select({ organizationId: members.organizationId }).from(members).where(and(eq(members.userId, userId), eq(members.status, "active"))).orderBy(asc(members.createdAt)).limit(1);
|
|
16381
|
+
return row?.organizationId ?? null;
|
|
16382
|
+
}
|
|
15993
16383
|
async function contextTreeInfoRoutes(app) {
|
|
15994
|
-
/**
|
|
15995
|
-
|
|
16384
|
+
/**
|
|
16385
|
+
* Class A — `/api/v1/context-tree/info`. Returns the caller's
|
|
16386
|
+
* organization-scoped Context Tree binding for CLI auto-discovery.
|
|
16387
|
+
* Responds with `{ repo: null, branch: null }` when the user is not in
|
|
16388
|
+
* any org or the org hasn't configured a tree yet.
|
|
16389
|
+
*/
|
|
16390
|
+
app.get("/info", async (request) => {
|
|
16391
|
+
const { userId } = requireUser(request);
|
|
16392
|
+
const orgId = await resolveUserPrimaryOrgId(app.db, userId);
|
|
16393
|
+
if (!orgId) return {
|
|
16394
|
+
repo: null,
|
|
16395
|
+
branch: null
|
|
16396
|
+
};
|
|
16397
|
+
const tree = await getOrgContextTree(app.db, orgId);
|
|
15996
16398
|
return {
|
|
15997
|
-
repo:
|
|
15998
|
-
branch:
|
|
16399
|
+
repo: tree.repo ?? null,
|
|
16400
|
+
branch: tree.branch ?? null
|
|
15999
16401
|
};
|
|
16000
16402
|
});
|
|
16001
16403
|
}
|
|
@@ -16008,8 +16410,11 @@ const MAX_MARKDOWN_FILES = 1e3;
|
|
|
16008
16410
|
const MAX_MARKDOWN_FILE_BYTES = 512 * 1024;
|
|
16009
16411
|
const SNAPSHOT_CACHE_TTL_MS = 3e4;
|
|
16010
16412
|
const GIT_TIMEOUT_MS = 5e3;
|
|
16413
|
+
const GIT_SYNC_TIMEOUT_MS = 12e4;
|
|
16011
16414
|
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
16012
16415
|
const GIT_LOG_RECORD_SEPARATOR = "";
|
|
16416
|
+
const REMOTE_SYNC_TTL_MS = 6e4;
|
|
16417
|
+
const REMOTE_FAILURE_TTL_MS = 3e4;
|
|
16013
16418
|
const CONTEXT_TREE_SNAPSHOT_WINDOWS = {
|
|
16014
16419
|
ONE_DAY: "1d",
|
|
16015
16420
|
SEVEN_DAYS: "7d",
|
|
@@ -16021,10 +16426,14 @@ const WINDOW_DAYS = {
|
|
|
16021
16426
|
"30d": 30
|
|
16022
16427
|
};
|
|
16023
16428
|
const snapshotCache = /* @__PURE__ */ new Map();
|
|
16024
|
-
|
|
16025
|
-
|
|
16026
|
-
|
|
16027
|
-
|
|
16429
|
+
const remoteSyncPromises = /* @__PURE__ */ new Map();
|
|
16430
|
+
const remoteLastSyncedAt = /* @__PURE__ */ new Map();
|
|
16431
|
+
const remoteLastSyncWarnings = /* @__PURE__ */ new Map();
|
|
16432
|
+
const remoteLastFailures = /* @__PURE__ */ new Map();
|
|
16433
|
+
async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WINDOWS.SEVEN_DAYS) {
|
|
16434
|
+
const repo = binding.repo ?? null;
|
|
16435
|
+
const branch = binding.branch ?? null;
|
|
16436
|
+
const resolved = await resolveContextTreeRoot(repo, binding.localPath, branch, binding.githubToken);
|
|
16028
16437
|
if (!resolved.root) return unavailableSnapshot(repo, branch, resolved.reason);
|
|
16029
16438
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
16030
16439
|
try {
|
|
@@ -16034,14 +16443,13 @@ async function getContextTreeSnapshot(config, window = CONTEXT_TREE_SNAPSHOT_WIN
|
|
|
16034
16443
|
"--abbrev-ref",
|
|
16035
16444
|
"HEAD"
|
|
16036
16445
|
]);
|
|
16037
|
-
if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but
|
|
16446
|
+
if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but the configured Context Tree branch is "${branch}".`);
|
|
16038
16447
|
const comparisonBaseCommit = await comparisonBaseForWindow(resolved.root, window);
|
|
16039
16448
|
const cacheKey = snapshotCacheKey(resolved.root, actualBranch ?? branch, headCommit, comparisonBaseCommit, window);
|
|
16040
16449
|
const cached = snapshotCache.get(cacheKey);
|
|
16041
|
-
if (cached && cached.expiresAt > Date.now())
|
|
16042
|
-
|
|
16043
|
-
|
|
16044
|
-
};
|
|
16450
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
16451
|
+
if (!(cached.snapshot.snapshotStatus === "stale" && !resolved.staleReason)) return withSnapshotStatus(cached.snapshot, now, statusWarningFromResolved(resolved.staleReason, null));
|
|
16452
|
+
}
|
|
16045
16453
|
const tree = buildTree(await readMarkdownFiles(resolved.root));
|
|
16046
16454
|
const diffResult = comparisonBaseCommit ? await readDiffEntries(resolved.root, comparisonBaseCommit, headCommit) : {
|
|
16047
16455
|
entries: [],
|
|
@@ -16051,18 +16459,14 @@ async function getContextTreeSnapshot(config, window = CONTEXT_TREE_SNAPSHOT_WIN
|
|
|
16051
16459
|
const nodesWithGhosts = addRemovedGhostNodes(applyChangesToNodes(tree.nodes, changes), changes);
|
|
16052
16460
|
const summary = summarizeChanges(changes);
|
|
16053
16461
|
const updates = buildUpdates(changes, nodesWithGhosts);
|
|
16054
|
-
const statusWarning =
|
|
16462
|
+
const statusWarning = statusWarningFromResolved(resolved.staleReason, diffResult.truncated);
|
|
16055
16463
|
const snapshot = {
|
|
16056
16464
|
repo,
|
|
16057
16465
|
branch: actualBranch ?? branch,
|
|
16058
16466
|
headCommit,
|
|
16059
16467
|
syncedAt: now,
|
|
16060
|
-
snapshotStatus: "active",
|
|
16061
|
-
contextStatus:
|
|
16062
|
-
label: statusWarning ? "Team context needs attention" : "Team context is current",
|
|
16063
|
-
detail: statusWarning ?? "Agents have a synced team context snapshot available.",
|
|
16064
|
-
severity: statusWarning ? "warning" : "ok"
|
|
16065
|
-
},
|
|
16468
|
+
snapshotStatus: statusWarning?.stale ? "stale" : "active",
|
|
16469
|
+
contextStatus: contextStatus(statusWarning),
|
|
16066
16470
|
summary,
|
|
16067
16471
|
updates,
|
|
16068
16472
|
nodes: nodesWithGhosts,
|
|
@@ -16087,31 +16491,249 @@ function snapshotCacheKey(root, branch, headCommit, comparisonBase, window) {
|
|
|
16087
16491
|
window
|
|
16088
16492
|
].join(":");
|
|
16089
16493
|
}
|
|
16090
|
-
function
|
|
16091
|
-
if (
|
|
16494
|
+
function statusWarningFromResolved(staleReason, truncated) {
|
|
16495
|
+
if (staleReason) return {
|
|
16496
|
+
detail: `${staleReason}${truncated ? ` Showing the first ${MAX_DIFF_ENTRIES} changed files.` : ""}`,
|
|
16497
|
+
stale: true
|
|
16498
|
+
};
|
|
16499
|
+
if (truncated) return {
|
|
16500
|
+
detail: `Showing the first ${MAX_DIFF_ENTRIES} changed files.`,
|
|
16501
|
+
stale: false
|
|
16502
|
+
};
|
|
16092
16503
|
return null;
|
|
16093
16504
|
}
|
|
16094
|
-
function resolveContextTreeRoot(repo, localPath) {
|
|
16095
|
-
|
|
16096
|
-
|
|
16505
|
+
async function resolveContextTreeRoot(repo, localPath, branch, githubToken) {
|
|
16506
|
+
if (localPath && localPath.trim().length > 0) {
|
|
16507
|
+
const root = resolveLocalPath(localPath);
|
|
16508
|
+
if (existsSync(root)) return {
|
|
16509
|
+
root,
|
|
16510
|
+
reason: "ok",
|
|
16511
|
+
staleReason: null
|
|
16512
|
+
};
|
|
16513
|
+
return {
|
|
16514
|
+
root: null,
|
|
16515
|
+
reason: `Context Tree checkout not found at ${root}.`,
|
|
16516
|
+
staleReason: null
|
|
16517
|
+
};
|
|
16518
|
+
}
|
|
16519
|
+
if (!repo) return {
|
|
16097
16520
|
root: null,
|
|
16098
|
-
reason: "Context Tree is not configured."
|
|
16521
|
+
reason: "Context Tree is not configured.",
|
|
16522
|
+
staleReason: null
|
|
16099
16523
|
};
|
|
16100
|
-
|
|
16101
|
-
|
|
16524
|
+
if (isRemoteRepo(repo)) {
|
|
16525
|
+
const resolvedBranch = branch ?? "main";
|
|
16526
|
+
if (!isSafeBranchName(resolvedBranch)) return {
|
|
16527
|
+
root: null,
|
|
16528
|
+
reason: `Configured Context Tree branch "${resolvedBranch}" is invalid.`,
|
|
16529
|
+
staleReason: null
|
|
16530
|
+
};
|
|
16531
|
+
try {
|
|
16532
|
+
const materialized = await materializeRemoteContextTree(repo, resolvedBranch, void 0, githubToken);
|
|
16533
|
+
return {
|
|
16534
|
+
root: materialized.root,
|
|
16535
|
+
reason: "ok",
|
|
16536
|
+
staleReason: materialized.staleReason
|
|
16537
|
+
};
|
|
16538
|
+
} catch (error) {
|
|
16539
|
+
return {
|
|
16540
|
+
root: null,
|
|
16541
|
+
reason: `Hub could not sync the configured Context Tree repo. Check repo access and branch "${resolvedBranch}". ${errorMessage(error)}`,
|
|
16542
|
+
staleReason: null
|
|
16543
|
+
};
|
|
16544
|
+
}
|
|
16545
|
+
}
|
|
16546
|
+
const root = resolveLocalPath(repo);
|
|
16102
16547
|
if (existsSync(root)) return {
|
|
16103
16548
|
root,
|
|
16104
|
-
reason: "ok"
|
|
16549
|
+
reason: "ok",
|
|
16550
|
+
staleReason: null
|
|
16105
16551
|
};
|
|
16106
|
-
|
|
16552
|
+
return {
|
|
16107
16553
|
root: null,
|
|
16108
|
-
reason:
|
|
16554
|
+
reason: `Context Tree checkout not found at ${root}.`,
|
|
16555
|
+
staleReason: null
|
|
16556
|
+
};
|
|
16557
|
+
}
|
|
16558
|
+
function resolveLocalPath(value) {
|
|
16559
|
+
const normalized = value.startsWith("file://") ? value.slice(7) : value;
|
|
16560
|
+
return isAbsolute(normalized) ? normalize(normalized) : resolve(process.cwd(), normalized);
|
|
16561
|
+
}
|
|
16562
|
+
function isRemoteRepo(value) {
|
|
16563
|
+
return /^https?:\/\//.test(value) || /^file:\/\//.test(value) || /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value);
|
|
16564
|
+
}
|
|
16565
|
+
function normalizeRemoteRepoUrl(value) {
|
|
16566
|
+
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value)) return `https://github.com/${value}`;
|
|
16567
|
+
return value;
|
|
16568
|
+
}
|
|
16569
|
+
function managedContextTreeCacheRoot() {
|
|
16570
|
+
return join(DEFAULT_DATA_DIR$1, "context-tree-repos");
|
|
16571
|
+
}
|
|
16572
|
+
function managedContextTreePath(repoUrl, branch, cacheRoot = managedContextTreeCacheRoot()) {
|
|
16573
|
+
return join(cacheRoot, createHash("sha256").update(`${repoUrl}\0${branch}`).digest("hex"));
|
|
16574
|
+
}
|
|
16575
|
+
async function materializeRemoteContextTree(repo, branch, cacheRoot = managedContextTreeCacheRoot(), githubToken) {
|
|
16576
|
+
const repoUrl = normalizeRemoteRepoUrl(repo);
|
|
16577
|
+
const root = managedContextTreePath(repoUrl, branch, cacheRoot);
|
|
16578
|
+
const lastSyncedAt = remoteLastSyncedAt.get(root);
|
|
16579
|
+
if (lastSyncedAt && Date.now() - lastSyncedAt < REMOTE_SYNC_TTL_MS && existsSync(join(root, ".git"))) return {
|
|
16580
|
+
root,
|
|
16581
|
+
staleReason: remoteLastSyncWarnings.get(root) ?? null
|
|
16582
|
+
};
|
|
16583
|
+
const lastFailure = remoteLastFailures.get(root);
|
|
16584
|
+
if (lastFailure && Date.now() - lastFailure.failedAt < REMOTE_FAILURE_TTL_MS && !existsSync(join(root, ".git"))) throw new Error(lastFailure.reason);
|
|
16585
|
+
const existing = remoteSyncPromises.get(root);
|
|
16586
|
+
if (existing) return {
|
|
16587
|
+
root,
|
|
16588
|
+
staleReason: (await existing).staleReason
|
|
16589
|
+
};
|
|
16590
|
+
const syncPromise = syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken);
|
|
16591
|
+
remoteSyncPromises.set(root, syncPromise);
|
|
16592
|
+
try {
|
|
16593
|
+
const syncResult = await syncPromise;
|
|
16594
|
+
remoteLastSyncedAt.set(root, Date.now());
|
|
16595
|
+
remoteLastFailures.delete(root);
|
|
16596
|
+
if (syncResult.staleReason) remoteLastSyncWarnings.set(root, syncResult.staleReason);
|
|
16597
|
+
else remoteLastSyncWarnings.delete(root);
|
|
16598
|
+
return {
|
|
16599
|
+
root,
|
|
16600
|
+
staleReason: syncResult.staleReason
|
|
16601
|
+
};
|
|
16602
|
+
} catch (error) {
|
|
16603
|
+
if (!existsSync(join(root, ".git"))) remoteLastFailures.set(root, {
|
|
16604
|
+
failedAt: Date.now(),
|
|
16605
|
+
reason: `Previous Context Tree sync failed recently. ${errorMessage(error)}`
|
|
16606
|
+
});
|
|
16607
|
+
throw error;
|
|
16608
|
+
} finally {
|
|
16609
|
+
remoteSyncPromises.delete(root);
|
|
16610
|
+
}
|
|
16611
|
+
}
|
|
16612
|
+
async function syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken) {
|
|
16613
|
+
await mkdir(cacheRoot, { recursive: true });
|
|
16614
|
+
const env = await gitAuthEnv(repoUrl, cacheRoot, githubToken);
|
|
16615
|
+
if (!existsSync(join(root, ".git"))) {
|
|
16616
|
+
await rm(root, {
|
|
16617
|
+
recursive: true,
|
|
16618
|
+
force: true
|
|
16619
|
+
});
|
|
16620
|
+
await gitOutput(cacheRoot, [
|
|
16621
|
+
"clone",
|
|
16622
|
+
"--branch",
|
|
16623
|
+
branch,
|
|
16624
|
+
"--single-branch",
|
|
16625
|
+
repoUrl,
|
|
16626
|
+
root
|
|
16627
|
+
], {
|
|
16628
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
16629
|
+
env,
|
|
16630
|
+
disableHooks: true
|
|
16631
|
+
});
|
|
16632
|
+
return { staleReason: null };
|
|
16633
|
+
}
|
|
16634
|
+
try {
|
|
16635
|
+
await gitOutput(root, [
|
|
16636
|
+
"remote",
|
|
16637
|
+
"set-url",
|
|
16638
|
+
"origin",
|
|
16639
|
+
repoUrl
|
|
16640
|
+
], {
|
|
16641
|
+
timeout: GIT_TIMEOUT_MS,
|
|
16642
|
+
disableHooks: true
|
|
16643
|
+
});
|
|
16644
|
+
await gitOutput(root, [
|
|
16645
|
+
"fetch",
|
|
16646
|
+
"origin",
|
|
16647
|
+
branch,
|
|
16648
|
+
"--prune"
|
|
16649
|
+
], {
|
|
16650
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
16651
|
+
env,
|
|
16652
|
+
disableHooks: true
|
|
16653
|
+
});
|
|
16654
|
+
await gitOutput(root, [
|
|
16655
|
+
"checkout",
|
|
16656
|
+
"-B",
|
|
16657
|
+
branch,
|
|
16658
|
+
`origin/${branch}`
|
|
16659
|
+
], {
|
|
16660
|
+
timeout: GIT_TIMEOUT_MS,
|
|
16661
|
+
disableHooks: true
|
|
16662
|
+
});
|
|
16663
|
+
return { staleReason: null };
|
|
16664
|
+
} catch (error) {
|
|
16665
|
+
if (existsSync(join(root, ".git"))) return { staleReason: `Showing the last synced Context Tree snapshot because Hub could not refresh the configured repo. ${errorMessage(error)}` };
|
|
16666
|
+
throw error;
|
|
16667
|
+
}
|
|
16668
|
+
}
|
|
16669
|
+
async function gitAuthEnv(repoUrl, cacheRoot, githubToken) {
|
|
16670
|
+
if (!githubToken || !isGithubHttpsRepo(repoUrl)) return void 0;
|
|
16671
|
+
const askpassPath = join(cacheRoot, ".tools", "git-askpass.sh");
|
|
16672
|
+
if (!existsSync(askpassPath)) {
|
|
16673
|
+
await mkdir(dirname(askpassPath), { recursive: true });
|
|
16674
|
+
await writeFile(askpassPath, [
|
|
16675
|
+
"#!/bin/sh",
|
|
16676
|
+
"case \"$1\" in",
|
|
16677
|
+
"*Username*) printf \"%s\\n\" \"$GIT_USERNAME\" ;;",
|
|
16678
|
+
"*) printf \"%s\\n\" \"$GIT_PASSWORD\" ;;",
|
|
16679
|
+
"esac",
|
|
16680
|
+
""
|
|
16681
|
+
].join("\n"), "utf8");
|
|
16682
|
+
await chmod(askpassPath, 448);
|
|
16683
|
+
}
|
|
16684
|
+
return {
|
|
16685
|
+
...process.env,
|
|
16686
|
+
GIT_ASKPASS: askpassPath,
|
|
16687
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
16688
|
+
GIT_USERNAME: "x-access-token",
|
|
16689
|
+
GIT_PASSWORD: githubToken
|
|
16690
|
+
};
|
|
16691
|
+
}
|
|
16692
|
+
function isGithubHttpsRepo(repoUrl) {
|
|
16693
|
+
try {
|
|
16694
|
+
const url = new URL(repoUrl);
|
|
16695
|
+
return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com";
|
|
16696
|
+
} catch {
|
|
16697
|
+
return false;
|
|
16698
|
+
}
|
|
16699
|
+
}
|
|
16700
|
+
function contextStatus(warning) {
|
|
16701
|
+
if (warning?.stale) return {
|
|
16702
|
+
label: "Team context is stale",
|
|
16703
|
+
detail: warning.detail,
|
|
16704
|
+
severity: "warning"
|
|
16705
|
+
};
|
|
16706
|
+
if (warning) return {
|
|
16707
|
+
label: "Team context needs attention",
|
|
16708
|
+
detail: warning.detail,
|
|
16709
|
+
severity: "warning"
|
|
16109
16710
|
};
|
|
16110
16711
|
return {
|
|
16111
|
-
|
|
16112
|
-
|
|
16712
|
+
label: "Team context is current",
|
|
16713
|
+
detail: "Agents have a synced team context snapshot available.",
|
|
16714
|
+
severity: "ok"
|
|
16715
|
+
};
|
|
16716
|
+
}
|
|
16717
|
+
function withSnapshotStatus(snapshot, syncedAt, warning) {
|
|
16718
|
+
return {
|
|
16719
|
+
...snapshot,
|
|
16720
|
+
syncedAt,
|
|
16721
|
+
snapshotStatus: warning?.stale ? "stale" : snapshot.snapshotStatus,
|
|
16722
|
+
contextStatus: warning ? contextStatus(warning) : snapshot.contextStatus
|
|
16113
16723
|
};
|
|
16114
16724
|
}
|
|
16725
|
+
function isSafeBranchName(branch) {
|
|
16726
|
+
if (branch.startsWith("-")) return false;
|
|
16727
|
+
if (branch.includes("..") || branch.includes("@{") || branch.includes("\\")) return false;
|
|
16728
|
+
return /^[A-Za-z0-9._/-]+$/.test(branch);
|
|
16729
|
+
}
|
|
16730
|
+
function errorMessage(error) {
|
|
16731
|
+
if (!(error instanceof Error) || error.message.trim().length === 0) return "";
|
|
16732
|
+
return redactSecret(error.message.trim().split("\n")[0] ?? "");
|
|
16733
|
+
}
|
|
16734
|
+
function redactSecret(message) {
|
|
16735
|
+
return message.replace(/(https?:\/\/)[^/@\s]+@/g, "$1[redacted]@").replace(/\bghp_[A-Za-z0-9_]+/g, "[redacted]").replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "[redacted]");
|
|
16736
|
+
}
|
|
16115
16737
|
function unavailableSnapshot(repo, branch, detail) {
|
|
16116
16738
|
return {
|
|
16117
16739
|
repo,
|
|
@@ -16136,11 +16758,16 @@ function unavailableSnapshot(repo, branch, detail) {
|
|
|
16136
16758
|
changes: []
|
|
16137
16759
|
};
|
|
16138
16760
|
}
|
|
16139
|
-
async function gitOutput(cwd, args) {
|
|
16140
|
-
const { stdout } = await execFileAsync("git",
|
|
16761
|
+
async function gitOutput(cwd, args, options) {
|
|
16762
|
+
const { stdout } = await execFileAsync("git", options?.disableHooks ? [
|
|
16763
|
+
"-c",
|
|
16764
|
+
"core.hooksPath=/dev/null",
|
|
16765
|
+
...args
|
|
16766
|
+
] : args, {
|
|
16141
16767
|
cwd,
|
|
16142
|
-
timeout: GIT_TIMEOUT_MS,
|
|
16143
|
-
maxBuffer: GIT_MAX_BUFFER
|
|
16768
|
+
timeout: options?.timeout ?? GIT_TIMEOUT_MS,
|
|
16769
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
16770
|
+
env: options?.env
|
|
16144
16771
|
});
|
|
16145
16772
|
return stdout.trim();
|
|
16146
16773
|
}
|
|
@@ -16696,10 +17323,42 @@ async function contextTreeSnapshotRoutes(app) {
|
|
|
16696
17323
|
keyGenerator: (request) => request.user?.userId ?? request.ip
|
|
16697
17324
|
} } }, async (request) => {
|
|
16698
17325
|
const query = querySchema.parse(request.query);
|
|
16699
|
-
const
|
|
17326
|
+
const { userId } = requireUser(request);
|
|
17327
|
+
const orgId = await resolveUserPrimaryOrgId(app.db, userId);
|
|
17328
|
+
const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
|
|
17329
|
+
const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
|
|
17330
|
+
const snapshot = await getContextTreeSnapshot({
|
|
17331
|
+
...binding,
|
|
17332
|
+
githubToken
|
|
17333
|
+
}, query.window ?? "7d");
|
|
16700
17334
|
return contextTreeSnapshotSchema.parse(snapshot);
|
|
16701
17335
|
});
|
|
16702
17336
|
}
|
|
17337
|
+
function contextTreeGithubTokenForRepo(repo, syncConfig) {
|
|
17338
|
+
if (!repo || !syncConfig?.githubToken) return void 0;
|
|
17339
|
+
const repoKey = githubRepoKey(repo);
|
|
17340
|
+
if (!repoKey) return void 0;
|
|
17341
|
+
return new Set((syncConfig.githubTokenRepos ?? "").split(",").map((entry) => normalizeGithubRepoKey(entry)).filter((entry) => entry !== null)).has(repoKey) ? syncConfig.githubToken : void 0;
|
|
17342
|
+
}
|
|
17343
|
+
function githubRepoKey(value) {
|
|
17344
|
+
const shorthand = normalizeGithubRepoKey(value);
|
|
17345
|
+
if (shorthand) return shorthand;
|
|
17346
|
+
let url;
|
|
17347
|
+
try {
|
|
17348
|
+
url = new URL(value);
|
|
17349
|
+
} catch {
|
|
17350
|
+
return null;
|
|
17351
|
+
}
|
|
17352
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com") return null;
|
|
17353
|
+
if (url.username || url.password) return null;
|
|
17354
|
+
return normalizeGithubRepoKey(url.pathname.replace(/^\/+/, ""));
|
|
17355
|
+
}
|
|
17356
|
+
function normalizeGithubRepoKey(value) {
|
|
17357
|
+
const trimmed = value.trim().replace(/^\/+/, "").replace(/\.git$/i, "");
|
|
17358
|
+
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
|
|
17359
|
+
if (!match) return null;
|
|
17360
|
+
return `${match[1]?.toLowerCase()}/${match[2]?.toLowerCase()}`;
|
|
17361
|
+
}
|
|
16703
17362
|
/**
|
|
16704
17363
|
* Resolve the client IP for rate-limit attribution.
|
|
16705
17364
|
*
|
|
@@ -16801,7 +17460,7 @@ async function healthzRoutes(app) {
|
|
|
16801
17460
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
16802
17461
|
*/
|
|
16803
17462
|
async function publicInvitationRoutes(app) {
|
|
16804
|
-
const { previewInvitation } = await import("./invitation-
|
|
17463
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-BR-niZyp.mjs");
|
|
16805
17464
|
app.get("/:token/preview", async (request, reply) => {
|
|
16806
17465
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
16807
17466
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -16824,7 +17483,8 @@ async function meRoutes(app) {
|
|
|
16824
17483
|
id: users.id,
|
|
16825
17484
|
username: users.username,
|
|
16826
17485
|
displayName: users.displayName,
|
|
16827
|
-
avatarUrl: users.avatarUrl
|
|
17486
|
+
avatarUrl: users.avatarUrl,
|
|
17487
|
+
onboardingDismissedAt: users.onboardingDismissedAt
|
|
16828
17488
|
}).from(users).where(eq(users.id, userId)).limit(1);
|
|
16829
17489
|
const memberships = await listActiveMemberships(app.db, userId);
|
|
16830
17490
|
const defaultMembership = pickDefaultMembership(memberships.map((m) => ({
|
|
@@ -16839,7 +17499,7 @@ async function meRoutes(app) {
|
|
|
16839
17499
|
if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
|
|
16840
17500
|
}
|
|
16841
17501
|
}
|
|
16842
|
-
const
|
|
17502
|
+
const onboardingStep = await inferOnboardingStep(app, userId);
|
|
16843
17503
|
return {
|
|
16844
17504
|
user: user ?? null,
|
|
16845
17505
|
defaultOrganizationId: defaultOrgId,
|
|
@@ -16850,11 +17510,93 @@ async function meRoutes(app) {
|
|
|
16850
17510
|
role: mb.role,
|
|
16851
17511
|
agentId: mb.agentId
|
|
16852
17512
|
})),
|
|
16853
|
-
|
|
17513
|
+
onboarding: {
|
|
17514
|
+
step: onboardingStep,
|
|
17515
|
+
dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null
|
|
17516
|
+
},
|
|
16854
17517
|
inviteUrl
|
|
16855
17518
|
};
|
|
16856
17519
|
});
|
|
16857
17520
|
/**
|
|
17521
|
+
* PATCH /me/onboarding — currently the only mutable field is
|
|
17522
|
+
* `dismissed`, set when the user clicks `✕` on the onboarding stepper.
|
|
17523
|
+
* Stamping NOW() server-side avoids client-clock skew. Idempotent: a
|
|
17524
|
+
* second PATCH leaves the original timestamp in place.
|
|
17525
|
+
*
|
|
17526
|
+
* See docs/new-user-onboarding-design.md §8.4.
|
|
17527
|
+
*/
|
|
17528
|
+
app.patch("/me/onboarding", async (request, reply) => {
|
|
17529
|
+
const { userId } = requireUser(request);
|
|
17530
|
+
const body = patchOnboardingSchema.parse(request.body);
|
|
17531
|
+
if (body.dismissed === true) {
|
|
17532
|
+
if ((await app.db.update(users).set({ onboardingDismissedAt: /* @__PURE__ */ new Date() }).where(and(eq(users.id, userId), isNull(users.onboardingDismissedAt))).returning({ id: users.id })).length > 0) app.log.info({
|
|
17533
|
+
event: "onboarding.dismissed",
|
|
17534
|
+
userId
|
|
17535
|
+
}, "onboarding funnel: stepper dismissed");
|
|
17536
|
+
} else if (body.dismissed === false) await app.db.update(users).set({ onboardingDismissedAt: null }).where(eq(users.id, userId));
|
|
17537
|
+
const [u] = await app.db.select({ onboardingDismissedAt: users.onboardingDismissedAt }).from(users).where(eq(users.id, userId)).limit(1);
|
|
17538
|
+
return reply.status(200).send({ dismissedAt: u?.onboardingDismissedAt ? u.onboardingDismissedAt.toISOString() : null });
|
|
17539
|
+
});
|
|
17540
|
+
/**
|
|
17541
|
+
* POST /me/onboarding/events — web-side onboarding funnel reporter.
|
|
17542
|
+
* Server-side milestones (`team_created` at OAuth, `dismissed` on PATCH)
|
|
17543
|
+
* are emitted directly; this endpoint surfaces the web-driven ones into
|
|
17544
|
+
* the same log stream so a single funnel query covers the full flow.
|
|
17545
|
+
* Body shape is enum-validated so the server won't log arbitrary names.
|
|
17546
|
+
*
|
|
17547
|
+
* Rate-limited to keep a buggy or hostile authenticated tab from
|
|
17548
|
+
* flooding the log stream. The cap is generous relative to legitimate
|
|
17549
|
+
* funnel traffic (≤ 4 events per onboarding pass).
|
|
17550
|
+
*/
|
|
17551
|
+
app.post("/me/onboarding/events", { config: { rateLimit: {
|
|
17552
|
+
max: 60,
|
|
17553
|
+
timeWindow: "1 minute"
|
|
17554
|
+
} } }, async (request, reply) => {
|
|
17555
|
+
const { userId } = requireUser(request);
|
|
17556
|
+
const body = onboardingEventSchema.parse(request.body);
|
|
17557
|
+
app.log.info({
|
|
17558
|
+
...body.attrs ?? {},
|
|
17559
|
+
event: `onboarding.${body.event}`,
|
|
17560
|
+
userId
|
|
17561
|
+
}, `onboarding funnel: ${body.event}`);
|
|
17562
|
+
return reply.status(204).send();
|
|
17563
|
+
});
|
|
17564
|
+
/**
|
|
17565
|
+
* GET /me/github/repos — list the caller's accessible GitHub repos. Used
|
|
17566
|
+
* by the Step 2 onboarding repo picker. The OAuth access token was
|
|
17567
|
+
* captured at sign-in (encrypted at rest in `auth_identities.metadata`)
|
|
17568
|
+
* so this endpoint avoids a second redirect.
|
|
17569
|
+
*
|
|
17570
|
+
* 503 if the user has no GitHub identity bound or the token wasn't
|
|
17571
|
+
* captured (e.g. dev-callback sign-in or pre-redesign user). The web
|
|
17572
|
+
* client falls back to a "Reconnect GitHub" hint in that case.
|
|
17573
|
+
*/
|
|
17574
|
+
app.get("/me/github/repos", async (request, reply) => {
|
|
17575
|
+
const { userId } = requireUser(request);
|
|
17576
|
+
const [identity] = await app.db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github"))).limit(1);
|
|
17577
|
+
const encrypted = identity?.metadata && typeof identity.metadata === "object" && "accessToken" in identity.metadata ? identity.metadata.accessToken : void 0;
|
|
17578
|
+
if (typeof encrypted !== "string" || !encrypted) return reply.status(503).send({ error: "GitHub access token unavailable — please reconnect your account" });
|
|
17579
|
+
let token;
|
|
17580
|
+
try {
|
|
17581
|
+
token = decryptValue(encrypted, app.config.secrets.encryptionKey);
|
|
17582
|
+
} catch {
|
|
17583
|
+
return reply.status(503).send({ error: "GitHub access token could not be decoded — please reconnect" });
|
|
17584
|
+
}
|
|
17585
|
+
try {
|
|
17586
|
+
return { repos: await listUserRepos(token) };
|
|
17587
|
+
} catch (err) {
|
|
17588
|
+
app.log.warn({
|
|
17589
|
+
err,
|
|
17590
|
+
userId
|
|
17591
|
+
}, "list github repos failed");
|
|
17592
|
+
if (err instanceof GithubApiError && (err.status === 401 || err.status === 403)) return reply.status(403).send({
|
|
17593
|
+
error: "GitHub access token is missing the `repo` scope. Please reconnect your GitHub account.",
|
|
17594
|
+
code: "scope_missing"
|
|
17595
|
+
});
|
|
17596
|
+
return reply.status(502).send({ error: "Couldn't reach GitHub. Try again, or reconnect your GitHub account." });
|
|
17597
|
+
}
|
|
17598
|
+
});
|
|
17599
|
+
/**
|
|
16858
17600
|
* POST /me/connect-tokens — short-lived connect token for the CLI.
|
|
16859
17601
|
* The token now carries only `sub = userId`; the CLI rejoins via
|
|
16860
17602
|
* `exchangeConnectToken` which probes `members` realtime.
|
|
@@ -16898,7 +17640,7 @@ async function meRoutes(app) {
|
|
|
16898
17640
|
*/
|
|
16899
17641
|
app.get("/me/pinned-agents", async (request) => {
|
|
16900
17642
|
const { userId } = requireUser(request);
|
|
16901
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
17643
|
+
const { listMyPinnedAgents } = await import("./client-D_TRJFZY-LbgJF47t.mjs");
|
|
16902
17644
|
return listMyPinnedAgents(app.db, { userId });
|
|
16903
17645
|
});
|
|
16904
17646
|
/**
|
|
@@ -17004,24 +17746,24 @@ async function meRoutes(app) {
|
|
|
17004
17746
|
return reply.status(204).send();
|
|
17005
17747
|
});
|
|
17006
17748
|
/**
|
|
17007
|
-
* GET /me/
|
|
17008
|
-
* full /me payload. Same logic as
|
|
17749
|
+
* GET /me/onboarding-step — bare endpoint for clients that don't want the
|
|
17750
|
+
* full /me payload. Same logic as inferOnboardingStep below.
|
|
17009
17751
|
*/
|
|
17010
|
-
app.get("/me/
|
|
17752
|
+
app.get("/me/onboarding-step", async (request) => {
|
|
17011
17753
|
const { userId } = requireUser(request);
|
|
17012
|
-
return { step: await
|
|
17754
|
+
return { step: await inferOnboardingStep(app, userId) };
|
|
17013
17755
|
});
|
|
17014
17756
|
}
|
|
17015
17757
|
/**
|
|
17016
|
-
* Infer the onboarding
|
|
17758
|
+
* Infer the onboarding step from the *user-level* facts:
|
|
17017
17759
|
* - has at least one client → past "connect"
|
|
17018
17760
|
* - manages at least one non-human active agent (any org) → past "create_agent"
|
|
17019
17761
|
*
|
|
17020
17762
|
* Critically: the join from agents → members → userId means a user with
|
|
17021
|
-
* memberships across multiple orgs has
|
|
17763
|
+
* memberships across multiple orgs has onboarding satisfied as soon as ANY
|
|
17022
17764
|
* org has a non-human agent — matching the user-level mental model.
|
|
17023
17765
|
*/
|
|
17024
|
-
async function
|
|
17766
|
+
async function inferOnboardingStep(app, userId) {
|
|
17025
17767
|
const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(eq(clients.userId, userId)).limit(1);
|
|
17026
17768
|
if (!hasClient) return "connect";
|
|
17027
17769
|
const [hasAgent] = await app.db.select({ uuid: agents.uuid }).from(agents).innerJoin(members, eq(members.id, agents.managerId)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.type, "human"), eq(agents.status, "active"))).limit(1);
|
|
@@ -17621,6 +18363,64 @@ async function orgSessionRoutes(app) {
|
|
|
17621
18363
|
});
|
|
17622
18364
|
});
|
|
17623
18365
|
}
|
|
18366
|
+
/**
|
|
18367
|
+
* Class B — `/api/v1/orgs/:orgId/settings/:namespace`.
|
|
18368
|
+
*
|
|
18369
|
+
* Generic per-org settings surface. The `:namespace` URL parameter is
|
|
18370
|
+
* dispatched against `ORG_SETTINGS_NAMESPACES` (in the shared package);
|
|
18371
|
+
* adding a new config group only requires registering it there — no new
|
|
18372
|
+
* route file.
|
|
18373
|
+
*
|
|
18374
|
+
* All three verbs are admin-only. Even GET, because the masked output
|
|
18375
|
+
* still leaks "configured / not-configured" booleans for secret fields,
|
|
18376
|
+
* which we don't want to expose to non-admin members.
|
|
18377
|
+
*/
|
|
18378
|
+
async function orgSettingsRoutes(app) {
|
|
18379
|
+
app.get("/:namespace", async (request) => {
|
|
18380
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18381
|
+
const namespace = parseNamespace(request.params.namespace);
|
|
18382
|
+
return enrichOutput(namespace, await getOrgSetting(app.db, scope.organizationId, namespace), scope.organizationId, app.config.server.publicUrl);
|
|
18383
|
+
});
|
|
18384
|
+
app.put("/:namespace", { config: { otelRecordBody: true } }, async (request) => {
|
|
18385
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18386
|
+
const namespace = parseNamespace(request.params.namespace);
|
|
18387
|
+
return enrichOutput(namespace, await putOrgSetting(app.db, scope.organizationId, namespace, request.body, {
|
|
18388
|
+
updatedBy: scope.userId,
|
|
18389
|
+
encryptionKey: app.config.secrets.encryptionKey
|
|
18390
|
+
}), scope.organizationId, app.config.server.publicUrl);
|
|
18391
|
+
});
|
|
18392
|
+
app.delete("/:namespace", async (request, reply) => {
|
|
18393
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18394
|
+
const namespace = parseNamespace(request.params.namespace);
|
|
18395
|
+
await deleteOrgSetting(app.db, scope.organizationId, namespace);
|
|
18396
|
+
reply.status(204).send();
|
|
18397
|
+
});
|
|
18398
|
+
}
|
|
18399
|
+
function parseNamespace(raw) {
|
|
18400
|
+
if (!isOrgSettingNamespace(raw)) throw new BadRequestError(`Unknown organization-settings namespace: "${raw}"`);
|
|
18401
|
+
return raw;
|
|
18402
|
+
}
|
|
18403
|
+
/**
|
|
18404
|
+
* Resolve namespace-specific server-config-derived fields. The service
|
|
18405
|
+
* layer stays config-agnostic — namespace knowledge that needs `app.config`
|
|
18406
|
+
* lives here. Currently only `github_integration.webhookUrl` qualifies.
|
|
18407
|
+
*
|
|
18408
|
+
* If `server.publicUrl` is unset on the Hub, `webhookUrl` is left as `""`
|
|
18409
|
+
* so the UI can render a "contact your site administrator" notice rather
|
|
18410
|
+
* than fall back to `window.location.origin` (which is wrong behind a
|
|
18411
|
+
* reverse proxy). (#12)
|
|
18412
|
+
*/
|
|
18413
|
+
function enrichOutput(namespace, out, orgId, publicUrl) {
|
|
18414
|
+
if (namespace === "github_integration") {
|
|
18415
|
+
const o = out;
|
|
18416
|
+
const webhookUrl = publicUrl ? `${publicUrl.replace(/\/+$/, "")}/api/v1/webhooks/github/${orgId}` : "";
|
|
18417
|
+
return {
|
|
18418
|
+
...o,
|
|
18419
|
+
webhookUrl
|
|
18420
|
+
};
|
|
18421
|
+
}
|
|
18422
|
+
return out;
|
|
18423
|
+
}
|
|
17624
18424
|
function dispatch$1(notifier, result) {
|
|
17625
18425
|
if (!result) return;
|
|
17626
18426
|
notifyRecipients(notifier, result.recipients, result.message.id);
|
|
@@ -17920,16 +18720,15 @@ function verifySignature(secret, rawBody, signatureHeader) {
|
|
|
17920
18720
|
const receivedBuf = Buffer.from(signatureHeader, "utf8");
|
|
17921
18721
|
if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) throw new UnauthorizedError("Invalid webhook signature");
|
|
17922
18722
|
}
|
|
17923
|
-
async function ensureGitHubAdapterAgent(db) {
|
|
17924
|
-
const
|
|
17925
|
-
const [existing] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, defaultOrgId), eq(agents.name, GITHUB_ADAPTER_ID))).limit(1);
|
|
18723
|
+
async function ensureGitHubAdapterAgent(db, organizationId) {
|
|
18724
|
+
const [existing] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, organizationId), eq(agents.name, GITHUB_ADAPTER_ID))).limit(1);
|
|
17926
18725
|
if (existing) return existing.uuid;
|
|
17927
18726
|
try {
|
|
17928
18727
|
return (await createAgent(db, {
|
|
17929
18728
|
name: GITHUB_ADAPTER_ID,
|
|
17930
18729
|
type: "autonomous_agent",
|
|
17931
18730
|
displayName: "GitHub Adapter",
|
|
17932
|
-
organizationId
|
|
18731
|
+
organizationId,
|
|
17933
18732
|
metadata: {
|
|
17934
18733
|
source: "github",
|
|
17935
18734
|
managed: true
|
|
@@ -17937,19 +18736,19 @@ async function ensureGitHubAdapterAgent(db) {
|
|
|
17937
18736
|
})).uuid;
|
|
17938
18737
|
} catch (err) {
|
|
17939
18738
|
if (err instanceof ConflictError) {
|
|
17940
|
-
const [created] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId,
|
|
18739
|
+
const [created] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, organizationId), eq(agents.name, GITHUB_ADAPTER_ID))).limit(1);
|
|
17941
18740
|
if (created) return created.uuid;
|
|
17942
18741
|
}
|
|
17943
18742
|
throw err;
|
|
17944
18743
|
}
|
|
17945
18744
|
}
|
|
17946
|
-
async function findTargetAgent(db, repoFullName) {
|
|
18745
|
+
async function findTargetAgent(db, organizationId, repoFullName) {
|
|
17947
18746
|
const allAgents = await db.select({
|
|
17948
18747
|
id: agents.uuid,
|
|
17949
18748
|
name: agents.name,
|
|
17950
18749
|
metadata: agents.metadata,
|
|
17951
18750
|
type: agents.type
|
|
17952
|
-
}).from(agents).where(eq(agents.status, "active"));
|
|
18751
|
+
}).from(agents).where(and(eq(agents.organizationId, organizationId), eq(agents.status, "active")));
|
|
17953
18752
|
for (const agent of allAgents) {
|
|
17954
18753
|
if (agent.name === GITHUB_ADAPTER_ID) continue;
|
|
17955
18754
|
const meta = agent.metadata;
|
|
@@ -17983,14 +18782,14 @@ function extractMentions$1(text) {
|
|
|
17983
18782
|
* For each mentioned user who has delegate_mention configured,
|
|
17984
18783
|
* send a card message from the mentioned user to their delegate.
|
|
17985
18784
|
*/
|
|
17986
|
-
async function routeMentionDelegations(app, mentionedNames, ctx) {
|
|
18785
|
+
async function routeMentionDelegations(app, organizationId, mentionedNames, ctx) {
|
|
17987
18786
|
if (mentionedNames.length === 0) return 0;
|
|
17988
18787
|
const delegates = await app.db.select({
|
|
17989
18788
|
id: agents.uuid,
|
|
17990
18789
|
name: agents.name,
|
|
17991
18790
|
delegateMention: agents.delegateMention,
|
|
17992
18791
|
status: agents.status
|
|
17993
|
-
}).from(agents).where(and(inArray(agents.name, mentionedNames), isNotNull(agents.delegateMention)));
|
|
18792
|
+
}).from(agents).where(and(eq(agents.organizationId, organizationId), inArray(agents.name, mentionedNames), isNotNull(agents.delegateMention)));
|
|
17994
18793
|
let routed = 0;
|
|
17995
18794
|
for (const agent of delegates) {
|
|
17996
18795
|
if (agent.status !== "active" || !agent.delegateMention) continue;
|
|
@@ -18078,13 +18877,14 @@ async function githubWebhookRoutes(app) {
|
|
|
18078
18877
|
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
18079
18878
|
done(null, body);
|
|
18080
18879
|
});
|
|
18081
|
-
const webhookSecret = app.config.github.webhookSecret;
|
|
18082
18880
|
const webhookMax = app.config.rateLimit?.webhookMax ?? 60;
|
|
18083
|
-
app.post("/github", { config: { rateLimit: {
|
|
18881
|
+
app.post("/github/:orgId", { config: { rateLimit: {
|
|
18084
18882
|
max: webhookMax,
|
|
18085
18883
|
timeWindow: "1 minute"
|
|
18086
18884
|
} } }, async (request, reply) => {
|
|
18087
|
-
|
|
18885
|
+
const { orgId } = request.params;
|
|
18886
|
+
const webhookSecret = await getDecryptedGithubWebhookSecret(app.db, orgId, app.config.secrets.encryptionKey);
|
|
18887
|
+
if (!webhookSecret) return reply.status(501).send({ error: "GitHub webhook is not configured for this organization. An admin must set the webhook secret in Team settings." });
|
|
18088
18888
|
const rawBody = request.body;
|
|
18089
18889
|
if (!Buffer.isBuffer(rawBody)) throw new BadRequestError("Expected raw body buffer");
|
|
18090
18890
|
const signatureHeader = request.headers["x-hub-signature-256"];
|
|
@@ -18102,12 +18902,12 @@ async function githubWebhookRoutes(app) {
|
|
|
18102
18902
|
ok: true,
|
|
18103
18903
|
event: "ping"
|
|
18104
18904
|
});
|
|
18105
|
-
if (eventType === "issues") return handleIssuesEvent(app, eventType, payload, reply);
|
|
18106
|
-
if (eventType === "issue_comment") return handleIssueCommentEvent(app, eventType, payload, reply);
|
|
18905
|
+
if (eventType === "issues") return handleIssuesEvent(app, orgId, eventType, payload, reply);
|
|
18906
|
+
if (eventType === "issue_comment") return handleIssueCommentEvent(app, orgId, eventType, payload, reply);
|
|
18107
18907
|
let mentionsRouted = 0;
|
|
18108
18908
|
const allowedActions = MENTION_ACTIONS[eventType];
|
|
18109
18909
|
const action = isRecord(payload) && typeof payload.action === "string" ? payload.action : void 0;
|
|
18110
|
-
if (allowedActions && action && allowedActions.includes(action)) mentionsRouted = await handleMentionDelegation(app, eventType, payload);
|
|
18910
|
+
if (allowedActions && action && allowedActions.includes(action)) mentionsRouted = await handleMentionDelegation(app, orgId, eventType, payload);
|
|
18111
18911
|
return reply.status(200).send({
|
|
18112
18912
|
ok: true,
|
|
18113
18913
|
event: eventType,
|
|
@@ -18261,10 +19061,10 @@ function extractEventContext(eventType, payload) {
|
|
|
18261
19061
|
* Run mention delegation for a given event type and payload.
|
|
18262
19062
|
* Only called after action gating confirms this is a "new content" event.
|
|
18263
19063
|
*/
|
|
18264
|
-
async function handleMentionDelegation(app, eventType, payload) {
|
|
19064
|
+
async function handleMentionDelegation(app, organizationId, eventType, payload) {
|
|
18265
19065
|
const mentions = extractMentions$1(extractEventText(eventType, payload));
|
|
18266
19066
|
const mentionCtx = extractEventContext(eventType, payload);
|
|
18267
|
-
if (mentions.length > 0 && mentionCtx) return routeMentionDelegations(app, mentions, mentionCtx);
|
|
19067
|
+
if (mentions.length > 0 && mentionCtx) return routeMentionDelegations(app, organizationId, mentions, mentionCtx);
|
|
18268
19068
|
return 0;
|
|
18269
19069
|
}
|
|
18270
19070
|
/** Actions that represent new/changed content (worth scanning for @mentions). */
|
|
@@ -18278,9 +19078,9 @@ const MENTION_ACTIONS = {
|
|
|
18278
19078
|
discussion_comment: ["created"],
|
|
18279
19079
|
commit_comment: ["created"]
|
|
18280
19080
|
};
|
|
18281
|
-
async function handleIssuesEvent(app, eventType, payload, reply) {
|
|
19081
|
+
async function handleIssuesEvent(app, organizationId, eventType, payload, reply) {
|
|
18282
19082
|
const data = parseIssuesPayload(payload);
|
|
18283
|
-
if (MENTION_ACTIONS.issues?.includes(data.action)) await handleMentionDelegation(app, eventType, payload);
|
|
19083
|
+
if (MENTION_ACTIONS.issues?.includes(data.action)) await handleMentionDelegation(app, organizationId, eventType, payload);
|
|
18284
19084
|
if (![
|
|
18285
19085
|
"opened",
|
|
18286
19086
|
"edited",
|
|
@@ -18291,7 +19091,7 @@ async function handleIssuesEvent(app, eventType, payload, reply) {
|
|
|
18291
19091
|
action: data.action,
|
|
18292
19092
|
handled: false
|
|
18293
19093
|
});
|
|
18294
|
-
const [senderId, targetAgentId] = await Promise.all([ensureGitHubAdapterAgent(app.db), findTargetAgent(app.db, data.repository.full_name)]);
|
|
19094
|
+
const [senderId, targetAgentId] = await Promise.all([ensureGitHubAdapterAgent(app.db, organizationId), findTargetAgent(app.db, organizationId, data.repository.full_name)]);
|
|
18295
19095
|
if (!targetAgentId) {
|
|
18296
19096
|
log$1.warn({
|
|
18297
19097
|
repo: data.repository.full_name,
|
|
@@ -18337,16 +19137,16 @@ async function handleIssuesEvent(app, eventType, payload, reply) {
|
|
|
18337
19137
|
routed: true
|
|
18338
19138
|
});
|
|
18339
19139
|
}
|
|
18340
|
-
async function handleIssueCommentEvent(app, eventType, payload, reply) {
|
|
19140
|
+
async function handleIssueCommentEvent(app, organizationId, eventType, payload, reply) {
|
|
18341
19141
|
const data = parseIssueCommentPayload(payload);
|
|
18342
|
-
if (MENTION_ACTIONS.issue_comment?.includes(data.action)) await handleMentionDelegation(app, eventType, payload);
|
|
19142
|
+
if (MENTION_ACTIONS.issue_comment?.includes(data.action)) await handleMentionDelegation(app, organizationId, eventType, payload);
|
|
18343
19143
|
if (data.action !== "created") return reply.status(200).send({
|
|
18344
19144
|
ok: true,
|
|
18345
19145
|
event: "issue_comment",
|
|
18346
19146
|
action: data.action,
|
|
18347
19147
|
handled: false
|
|
18348
19148
|
});
|
|
18349
|
-
const [senderId, targetAgentId] = await Promise.all([ensureGitHubAdapterAgent(app.db), findTargetAgent(app.db, data.repository.full_name)]);
|
|
19149
|
+
const [senderId, targetAgentId] = await Promise.all([ensureGitHubAdapterAgent(app.db, organizationId), findTargetAgent(app.db, organizationId, data.repository.full_name)]);
|
|
18350
19150
|
if (!targetAgentId) {
|
|
18351
19151
|
log$1.warn({
|
|
18352
19152
|
repo: data.repository.full_name,
|
|
@@ -18416,6 +19216,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
18416
19216
|
members: () => members,
|
|
18417
19217
|
messages: () => messages,
|
|
18418
19218
|
notifications: () => notifications,
|
|
19219
|
+
organizationSettings: () => organizationSettings,
|
|
18419
19220
|
organizations: () => organizations,
|
|
18420
19221
|
serverInstances: () => serverInstances,
|
|
18421
19222
|
sessionEvents: () => sessionEvents,
|
|
@@ -18424,10 +19225,16 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
18424
19225
|
users: () => users
|
|
18425
19226
|
});
|
|
18426
19227
|
function connectDatabase(url) {
|
|
18427
|
-
const client = postgres(url);
|
|
19228
|
+
const client = postgres(url, sslOptions(url));
|
|
18428
19229
|
const db = drizzle(client, { schema: schema_exports });
|
|
18429
19230
|
return Object.assign(db, { end: () => client.end() });
|
|
18430
19231
|
}
|
|
19232
|
+
function sslOptions(url) {
|
|
19233
|
+
try {
|
|
19234
|
+
if (new URL(url).hostname.endsWith(".rds.amazonaws.com")) return { ssl: { rejectUnauthorized: false } };
|
|
19235
|
+
} catch {}
|
|
19236
|
+
return {};
|
|
19237
|
+
}
|
|
18431
19238
|
/**
|
|
18432
19239
|
* Agent-scoped HTTP authentication hook. Must run **after** userAuthHook
|
|
18433
19240
|
* so `request.user` is populated.
|
|
@@ -19802,7 +20609,10 @@ async function buildApp(config) {
|
|
|
19802
20609
|
const commandVersion = resolveCommandVersion(config.commandVersion);
|
|
19803
20610
|
app.decorate("commandVersion", commandVersion);
|
|
19804
20611
|
app.log.info({ commandVersion }, "Hub server advertising command version");
|
|
19805
|
-
const listenClient = postgres(config.database.url, {
|
|
20612
|
+
const listenClient = postgres(config.database.url, {
|
|
20613
|
+
max: 1,
|
|
20614
|
+
...sslOptions(config.database.url)
|
|
20615
|
+
});
|
|
19806
20616
|
const notifier = createNotifier(listenClient);
|
|
19807
20617
|
await app.register(websocket, { options: { maxPayload: config.ws?.maxPayload ?? 65536 } });
|
|
19808
20618
|
const corsOrigin = config.cors?.origin;
|
|
@@ -19887,9 +20697,9 @@ async function buildApp(config) {
|
|
|
19887
20697
|
await api.register(authRoutes, { prefix: "/auth" });
|
|
19888
20698
|
await api.register(githubOauthRoutes, { prefix: "/auth/github" });
|
|
19889
20699
|
await api.register(publicInvitationRoutes, { prefix: "/invitations" });
|
|
19890
|
-
await api.register(contextTreeInfoRoutes, { prefix: "/context-tree" });
|
|
19891
20700
|
await api.register(bootstrapConfigRoutes, { prefix: "/bootstrap" });
|
|
19892
20701
|
await api.register(userScope("contextTreeScope", async (scope) => {
|
|
20702
|
+
await scope.register(contextTreeInfoRoutes);
|
|
19893
20703
|
await scope.register(contextTreeSnapshotRoutes);
|
|
19894
20704
|
}), { prefix: "/context-tree" });
|
|
19895
20705
|
await api.register(userScope("meRoutesScope", async (scope) => {
|
|
@@ -19910,6 +20720,7 @@ async function buildApp(config) {
|
|
|
19910
20720
|
await scope.register(orgClientRoutes, { prefix: "/clients" });
|
|
19911
20721
|
await scope.register(orgInvitationRoutes, { prefix: "/invitations" });
|
|
19912
20722
|
await scope.register(orgMemberRoutes, { prefix: "/members" });
|
|
20723
|
+
await scope.register(orgSettingsRoutes, { prefix: "/settings" });
|
|
19913
20724
|
}), { prefix: "/orgs/:orgId" });
|
|
19914
20725
|
await api.register(orgWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/orgs/:orgId/ws" });
|
|
19915
20726
|
await api.register(userScope("resourcesScope", async (scope) => {
|