@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.
@@ -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-D4rdqM2F.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-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 notificationQuerySchema, A as createChatSchema, B as githubDevCallbackQuerySchema, Ct as updateTaskStatusSchema, D as createAdapterConfigSchema, E as contextTreeSnapshotSchema, F as defaultRuntimeConfigPayload, G as inboxPollQuerySchema, H as imageInlineContentSchema, I as delegateFeishuUserSchema, J as joinByInvitationSchema, K as isRedactedEnvValue, L as dryRunAgentRuntimeConfigSchema, M as createMemberSchema, N as createOrgFromMeSchema, O as createAdapterMappingSchema, P as createTaskSchema, Q as messageSourceSchema$1, R as extractMentions, S as agentTypeSchema$1, St as updateOrganizationSchema, T as connectTokenExchangeSchema, U as inboxAckFrameSchema, V as githubStartQuerySchema, W as inboxDeliverFrameSchema$1, X as listMeChatsQuerySchema, Y as linkTaskChatSchema, Z as loginSchema, _ as adminCreateTaskSchema, _t as updateAgentRuntimeConfigSchema, a as AGENT_STATUSES, at as scanMentionTokens, b as agentPinnedMessageSchema$1, bt as updateClientCapabilitiesSchema, ct as sendToAgentSchema, d as TASK_HEALTH_SIGNALS, dt as sessionEventSchema$1, et as paginationQuerySchema, f as TASK_STATUSES, ft as sessionReconcileRequestSchema, g as addParticipantSchema, gt as updateAdapterConfigSchema, h as addMeChatParticipantsSchema, ht as taskListQuerySchema, i as AGENT_SOURCES, it as safeRedirectPath, j as createMeChatSchema, k as createAgentSchema, l as MENTION_REGEX, lt as sessionCompletionMessageSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as stripCode, n as AGENT_NAME_REGEX$1, nt as refreshTokenSchema, o as AGENT_TYPES, ot as selfServiceFeishuBotSchema, p as TASK_TERMINAL_STATUSES, pt as sessionStateMessageSchema, q as isReservedAgentName$1, r as AGENT_SELECTOR_HEADER$1, rt as runtimeStateMessageSchema, s as AGENT_VISIBILITY, st as sendMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as rebindAgentSchema, u as TASK_CREATOR_TYPES, ut as sessionEventMessageSchema, v as adminUpdateTaskSchema, vt as updateAgentSchema, w as clientRegisterSchema, wt as wsAuthFrameSchema, x as agentRuntimeConfigPayloadSchema$1, xt as updateMemberSchema, y as agentBindRequestSchema, yt as updateChatSchema, z as githubCallbackQuerySchema } from "./dist-BAqGZkco.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-BmyRwN0Y-Dad3eV8F.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-CLdRbuml-BRtalKpQ.mjs";
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-Dnn5gGGX-DXryyvRG.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-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: agentRuntimeConfigPayloadShape.partial()
536
+ payload: agentRuntimeConfigPatchShape
671
537
  });
672
- z.object({ payload: agentRuntimeConfigPayloadShape.partial() });
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
- contextTree: optional({
1688
- repo: field(z.string().optional(), {
1689
- env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
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
- allowedOrg: field(z.string().optional(), { env: "FIRST_TREE_HUB_GITHUB_ALLOWED_ORG" })
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 client = postgres(databaseUrl, { max: 1 });
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, { max: 1 });
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-Th_-ivJ7.mjs").then((n) => n.r);
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-EvpSNDM6.mjs
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
- const [agent] = await db.insert(agents).values({
10981
- uuid,
10982
- name,
10983
- organizationId: orgId,
10984
- type: data.type,
10985
- displayName: resolvedDisplayName,
10986
- delegateMention: data.delegateMention ?? null,
10987
- inboxId,
10988
- source: data.source ?? null,
10989
- visibility: data.visibility ?? defaultVisibility(data.type),
10990
- metadata: data.metadata ?? {},
10991
- managerId,
10992
- clientId,
10993
- runtimeProvider
10994
- }).returning();
10995
- if (!agent) throw new Error("Unexpected: INSERT RETURNING produced no row");
10996
- await db.insert(agentConfigs).values({
10997
- agentId: agent.uuid,
10998
- version: 1,
10999
- payload: defaultRuntimeConfigPayload(runtimeProvider),
11000
- updatedBy: "system"
11001
- }).onConflictDoNothing();
11002
- return agent;
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({ userId: authIdentities.userId }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
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
- if (profile.email) await db.update(authIdentities).set({
14662
- email: profile.email,
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: { login: profile.login }
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
- githubId: String(user.id),
14768
- login: user.login,
14769
- email,
14770
- displayName: user.name ?? null,
14771
- avatarUrl: user.avatar_url ?? null
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
- * Display name is the user's GitHub real name (or login as fallback). No
14827
- * "Personal Team" suffix the user might invite teammates later, and we
14828
- * don't want a label that reads like a private sandbox to be the team name
14829
- * other members see. Users rename freely via Settings.
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.userDisplayName;
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
- profile = await exchangeCodeForProfile({
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 { userId } = await findOrCreateUserFromGithub(app.db, profile);
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(app) {
15222
- /** Public endpoint — returns bootstrap prerequisites for CLI auto-discovery. */
15223
- app.get("/config", async () => {
15224
- return { allowedOrg: app.config.github.allowedOrg ?? null };
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
- /** Public endpoint — returns Context Tree repo metadata for CLI auto-discovery. */
15995
- app.get("/info", async () => {
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: app.config.contextTree?.repo ?? null,
15998
- branch: app.config.contextTree?.branch ?? null
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
- async function getContextTreeSnapshot(config, window = CONTEXT_TREE_SNAPSHOT_WINDOWS.SEVEN_DAYS) {
16025
- const repo = config.contextTree?.repo ?? null;
16026
- const branch = config.contextTree?.branch ?? null;
16027
- const resolved = resolveContextTreeRoot(repo, config.contextTree?.localPath);
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 server config expects "${branch}".`);
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()) return {
16042
- ...cached.snapshot,
16043
- syncedAt: now
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 = contextStatusWarning(diffResult.truncated);
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 contextStatusWarning(truncated) {
16091
- if (truncated) return `Showing the first ${MAX_DIFF_ENTRIES} changed files.`;
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
- const candidate = localPath && localPath.trim().length > 0 ? localPath : repo;
16096
- if (!candidate) return {
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
- const normalized = candidate.startsWith("file://") ? candidate.slice(7) : candidate;
16101
- const root = isAbsolute(normalized) ? normalize(normalized) : resolve(process.cwd(), normalized);
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
- if (/^https?:\/\//.test(normalized) || /^[^/]+\/[^/]+$/.test(normalized)) return {
16552
+ return {
16107
16553
  root: null,
16108
- reason: "Context Tree repo is configured as a remote URL. Set FIRST_TREE_HUB_CONTEXT_TREE_PATH to a readable local checkout for this version."
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
- root: null,
16112
- reason: `Context Tree checkout not found at ${root}.`
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", args, {
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 snapshot = await getContextTreeSnapshot(app.config, query.window ?? "7d");
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-DWlyNb8x-D3zjZSwI.mjs");
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 wizardStep = await inferWizardStep(app, userId);
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
- wizard: { step: wizardStep },
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-By1K4VVT-C5K7WZo6.mjs");
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/wizard-step — bare endpoint for clients that don't want the
17008
- * full /me payload. Same logic as inferWizardStep below.
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/wizard-step", async (request) => {
17752
+ app.get("/me/onboarding-step", async (request) => {
17011
17753
  const { userId } = requireUser(request);
17012
- return { step: await inferWizardStep(app, userId) };
17754
+ return { step: await inferOnboardingStep(app, userId) };
17013
17755
  });
17014
17756
  }
17015
17757
  /**
17016
- * Infer the onboarding wizard step from the *user-level* facts:
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 the wizard satisfied as soon as ANY
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 inferWizardStep(app, userId) {
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 defaultOrgId = await resolveDefaultOrgId(db);
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: defaultOrgId,
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, defaultOrgId), eq(agents.name, GITHUB_ADAPTER_ID))).limit(1);
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
- if (!webhookSecret) return reply.status(501).send({ error: "GitHub webhook is not configured. Set FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET to enable." });
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, { max: 1 });
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) => {