@agent-team-foundation/first-tree-hub 0.12.5 → 0.12.6

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,11 +1,11 @@
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, 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-BcW9HgkG.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";
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-BCZC1ki6.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 refreshTokenSchema, A as dryRunAgentRuntimeConfigSchema, B as isRedactedEnvValue, C as createAgentSchema, D as createOrgFromMeSchema, E as createMemberSchema, F as imageInlineContentSchema, G as messageSourceSchema$1, H as joinByInvitationSchema, I as inboxAckFrameSchema, J as paginationQuerySchema, K as notificationQuerySchema, L as inboxDeliverFrameSchema$1, M as githubCallbackQuerySchema, N as githubDevCallbackQuerySchema, O as defaultRuntimeConfigPayload, P as githubStartQuerySchema, Q as rebindAgentSchema, R as inboxPollQuerySchema, S as createAdapterMappingSchema, T as createMeChatSchema, U as listMeChatsQuerySchema, V as isReservedAgentName$1, W as loginSchema, Y as patchOnboardingSchema, _t as updateClientCapabilitiesSchema, a as AGENT_VISIBILITY, at as sendToAgentSchema, b as contextTreeSnapshotSchema, bt as wsAuthFrameSchema, c as ORG_SETTINGS_NAMESPACES$1, ct as sessionEventSchema$1, d as addParticipantSchema, dt as stripCode, et as runtimeStateMessageSchema, f as agentBindRequestSchema, ft as submitQuestionAnswerSchema, g as chatMetadataSchema$1, gt as updateChatSchema, h as agentTypeSchema$1, ht as updateAgentSchema, i as AGENT_STATUSES, it as sendMessageSchema, k as delegateFeishuUserSchema, l as WS_AUTH_FRAME_TIMEOUT_MS, lt as sessionReconcileRequestSchema, m as agentRuntimeConfigPayloadSchema$1, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, ot as sessionCompletionMessageSchema, p as agentPinnedMessageSchema$1, pt as updateAdapterConfigSchema, q as onboardingEventSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as MENTION_REGEX, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as addMeChatParticipantsSchema, ut as sessionStateMessageSchema, v as clientRegisterSchema, vt as updateMemberSchema, w as createChatSchema, x as createAdapterConfigSchema, y as connectTokenExchangeSchema, yt as updateOrganizationSchema, z as isOrgSettingNamespace } from "./dist-BwPlBZWi.mjs";
5
+ import { A as delegateFeishuUserSchema, B as inboxPollQuerySchema, C as createAgentSchema, D as createOrgFromMeSchema, E as createMemberSchema, F as githubDevCallbackQuerySchema, G as listMeChatsQuerySchema, H as isRedactedEnvValue, I as githubStartQuerySchema, J as notificationQuerySchema, K as loginSchema, L as imageInlineContentSchema, N as githubAppInstallationClaimBodySchema, P as githubCallbackQuerySchema, R as inboxAckFrameSchema, S as createAdapterMappingSchema, St as wsAuthFrameSchema, T as createMeChatSchema, U as isReservedAgentName$1, V as isOrgSettingNamespace, W as joinByInvitationSchema, X as paginationQuerySchema, Y as onboardingEventSchema, Z as patchOnboardingSchema, _t as updateAgentSchema, a as AGENT_VISIBILITY, at as selfServiceFeishuBotSchema, b as contextTreeSnapshotSchema, bt as updateMemberSchema, c as ORG_SETTINGS_NAMESPACES$1, ct as sessionCompletionMessageSchema, d as addParticipantSchema, dt as sessionReconcileRequestSchema, et as rebindAgentSchema, f as agentBindRequestSchema, ft as sessionStateMessageSchema, g as chatMetadataSchema$1, gt as updateAgentRuntimeConfigSchema, h as agentTypeSchema$1, ht as updateAdapterConfigSchema, i as AGENT_STATUSES, j as dryRunAgentRuntimeConfigSchema, k as defaultRuntimeConfigPayload, l as WS_AUTH_FRAME_TIMEOUT_MS, lt as sessionEventMessageSchema, m as agentRuntimeConfigPayloadSchema$1, mt as submitQuestionAnswerSchema, n as AGENT_NAME_REGEX$1, nt as runtimeStateMessageSchema, ot as sendMessageSchema, p as agentPinnedMessageSchema$1, pt as stripCode, q as messageSourceSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as safeRedirectPath, s as MENTION_REGEX, st as sendToAgentSchema, t as AGENT_BIND_REJECT_REASONS, tt as refreshTokenSchema, u as addMeChatParticipantsSchema, ut as sessionEventSchema$1, v as clientRegisterSchema, vt as updateChatSchema, w as createChatSchema, x as createAdapterConfigSchema, xt as updateOrganizationSchema, y as connectTokenExchangeSchema, yt as updateClientCapabilitiesSchema, z as inboxDeliverFrameSchema$1 } from "./dist-xP6NpdMp.mjs";
6
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
7
  import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
8
- import { $ as pendingQuestions, A as heartbeatClient, B as listAgentsWithRuntime, C as findOrCreateDirectChat, D as getClient, E as getChatDetail, F as joinChat, G as listClientsForOrgAdmin, H as listChats, I as leaveAsParticipant, J as markStaleAgents, K as listMessages, L as leaveChat, M as inboxEntries, N as invalidateChatAudience, O as getOnlineCount, P as joinAsParticipant, Q as notifyRecipients, R as listActiveAgentsPinnedToClient, S as ensureParticipant$1, T as getCachedAudience, U as listChatsForMember, V as listChatParticipantsWithNames, W as listClients, X as members, Y as markSupersededByChat, Z as messages, _ as createNotifier, _t as updateClientCapabilities, a as agents, at as removeParticipant, b as editMessage, c as bindAgent, ct as retireClient, d as chats, dt as serverInstances, et as recomputeChatWatchers, f as claimClient, ft as setOffline, g as createChat, gt as unbindAgent, h as clients, ht as touchAgent, i as agentVisibilityCondition, it as registerClient, j as heartbeatInstance, k as getPresence, l as chatParticipants, lt as sendMessage, m as cleanupStalePresence, mt as submitAnswer, n as agentChatSessions, nt as recomputeWatchersForMember, o as assertClientOwner, ot as resetActivity, p as cleanupStaleClients, pt as setRuntimeState, r as agentPresence, rt as registerChatMessageDispatcher, s as assertParticipant, st as resolveChatMembership, t as addParticipant, tt as recomputeWatchersForAgent, u as chatSubscriptions, ut as sendToAgent$1, v as deriveAuthState, vt as upsertSessionState, w as getActivityOverview, x as ensureCanJoin, y as disconnectClient, z as listAgentsManagedByUser } from "./client-DL5vHhvQ-CnYGq2x-.mjs";
8
+ import { $ as messages, A as getOnlineCount, B as listActiveAgentsPinnedToClient, C as ensureCanJoin, D as getCachedAudience, E as getActivityOverview, F as invalidateChatAudience, G as listChatsForMember, H as listAgentsWithRuntime, I as joinAsParticipant, J as listMessages, K as listClients, L as joinChat, M as heartbeatClient, N as heartbeatInstance, O as getChatDetail, P as inboxEntries, Q as members, R as leaveAsParticipant, S as editMessage, T as findOrCreateDirectChat, U as listChatParticipantsWithNames, V as listAgentsManagedByUser, W as listChats, X as markStaleAgents, Z as markSupersededByChat, _ as clients, _t as touchAgent, a as agentVisibilityCondition, at as registerChatMessageDispatcher, b as deriveAuthState, bt as upsertSessionState, c as assertParticipant, ct as resetActivity, d as chatParticipants, dt as sendMessage, et as notifyRecipients, f as chatSubscriptions, ft as sendToAgent$1, g as cleanupStalePresence, gt as submitAnswer, h as cleanupStaleClients, ht as setRuntimeState, i as agentPresence, it as recomputeWatchersForMember, j as getPresence, k as getClient, l as bindAgent, lt as resolveChatMembership, m as claimClient, mt as setOffline, n as addParticipant, nt as recomputeChatWatchers, o as agents, ot as registerClient, p as chats, pt as serverInstances, q as listClientsForOrgAdmin, r as agentChatSessions, rt as recomputeWatchersForAgent, s as assertClientOwner, st as removeParticipant, t as addChatParticipants, tt as pendingQuestions, u as changeChatType, ut as retireClient, v as createChat, vt as unbindAgent, w as ensureParticipant$1, x as disconnectClient, y as createNotifier, yt as updateClientCapabilities, z as leaveChat } from "./client-B89AKi3Q-DAyGdQSq.mjs";
9
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";
@@ -29,8 +29,8 @@ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne,
29
29
  import { drizzle } from "drizzle-orm/postgres-js";
30
30
  import postgres from "postgres";
31
31
  import { migrate } from "drizzle-orm/postgres-js/migrator";
32
- import { boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
33
- import { SignJWT, jwtVerify } from "jose";
32
+ import { bigint, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
33
+ import { SignJWT, importPKCS8, jwtVerify } from "jose";
34
34
  import cors from "@fastify/cors";
35
35
  import rateLimit from "@fastify/rate-limit";
36
36
  import fastifyStatic from "@fastify/static";
@@ -791,10 +791,7 @@ z.object({
791
791
  firstMessagePreview: z.string().nullable()
792
792
  });
793
793
  z.object({ topic: z.string().trim().max(500).nullable() });
794
- z.object({
795
- agentId: z.string().min(1),
796
- mode: z.enum(["full", "mention_only"]).default("full")
797
- });
794
+ z.object({ agentId: z.string().min(1) });
798
795
  z.object({ agentId: z.string().min(1) });
799
796
  const clientStatusSchema = z.enum(["connected", "disconnected"]);
800
797
  /**
@@ -978,6 +975,45 @@ z.object({
978
975
  edges: z.array(contextTreeEdgeSchema),
979
976
  changes: z.array(contextTreeChangeSchema)
980
977
  });
978
+ const githubAccountTypeSchema = z.enum(["User", "Organization"]);
979
+ const githubPermissionLevelSchema = z.enum([
980
+ "read",
981
+ "write",
982
+ "admin"
983
+ ]);
984
+ /**
985
+ * `installation.permissions` blob from GitHub. Key is the permission name
986
+ * (`contents`, `pull_requests`, `issues`, `members`, …) — we keep this as a
987
+ * free-form `z.record` because GitHub adds new permission keys over time
988
+ * and we don't want a Hub-side `app_id` upgrade just to surface a new key
989
+ * in the integrations panel.
990
+ */
991
+ const githubAppInstallationPermissionsSchema = z.record(z.string(), githubPermissionLevelSchema);
992
+ /**
993
+ * Subscribed event-name list, e.g. `["issues", "pull_request", "push"]`.
994
+ * Free-form for the same forward-compat reason as `permissions`.
995
+ */
996
+ const githubAppInstallationEventsSchema = z.array(z.string());
997
+ z.object({
998
+ login: z.string().optional(),
999
+ accessToken: z.string().optional(),
1000
+ accessTokenExpiresAt: z.string().datetime({ offset: true }).optional(),
1001
+ refreshToken: z.string().optional(),
1002
+ refreshTokenExpiresAt: z.string().datetime({ offset: true }).optional()
1003
+ });
1004
+ z.object({ installationId: z.number().int().positive() });
1005
+ z.object({
1006
+ installationId: z.number().int().positive(),
1007
+ accountType: githubAccountTypeSchema,
1008
+ accountLogin: z.string(),
1009
+ accountGithubId: z.number().int().positive(),
1010
+ permissions: githubAppInstallationPermissionsSchema,
1011
+ events: githubAppInstallationEventsSchema,
1012
+ suspended: z.boolean(),
1013
+ manageUrl: z.string().url(),
1014
+ createdAt: z.string().datetime({ offset: true }),
1015
+ updatedAt: z.string().datetime({ offset: true })
1016
+ });
981
1017
  /**
982
1018
  * MIME types the web + client image paths recognise. Kept in sync with
983
1019
  * Claude's vision API (see packages/client/src/handlers/claude-code.ts).
@@ -1358,14 +1394,24 @@ z.object({
1358
1394
  z.object({ next: z.string().max(256).optional() });
1359
1395
  z.object({
1360
1396
  code: z.string().min(1),
1361
- state: z.string().min(1)
1397
+ state: z.string().min(1),
1398
+ installation_id: z.string().regex(/^\d+$/).optional(),
1399
+ setup_action: z.enum([
1400
+ "install",
1401
+ "update",
1402
+ "request"
1403
+ ]).optional()
1362
1404
  });
1363
1405
  z.object({
1364
1406
  githubId: z.string().min(1),
1365
1407
  login: z.string().min(1),
1366
1408
  email: z.string().email().optional(),
1367
1409
  displayName: z.string().optional(),
1368
- next: z.string().max(256).optional()
1410
+ next: z.string().max(256).optional(),
1411
+ installationId: z.string().regex(/^\d+$/).optional(),
1412
+ installationAccountType: z.enum(["User", "Organization"]).optional(),
1413
+ installationAccountLogin: z.string().min(1).optional(),
1414
+ installationAccountGithubId: z.string().regex(/^\d+$/).optional()
1369
1415
  });
1370
1416
  /**
1371
1417
  * Per-organization settings — schemas, namespaces, and the registry that
@@ -1860,12 +1906,22 @@ defineConfig({
1860
1906
  }),
1861
1907
  githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
1862
1908
  }),
1863
- oauth: optional({ github: optional({
1864
- clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
1865
- clientSecret: field(z.string(), {
1866
- env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
1909
+ oauth: optional({ githubApp: optional({
1910
+ appId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_ID" }),
1911
+ clientId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID" }),
1912
+ clientSecret: field(z.string().min(1), {
1913
+ env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_SECRET",
1867
1914
  secret: true
1868
- })
1915
+ }),
1916
+ privateKeyPem: field(z.string().min(1), {
1917
+ env: "FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY",
1918
+ secret: true
1919
+ }),
1920
+ webhookSecret: field(z.string().min(1), {
1921
+ env: "FIRST_TREE_HUB_GITHUB_APP_WEBHOOK_SECRET",
1922
+ secret: true
1923
+ }),
1924
+ slug: field(z.string().min(1).optional(), { env: "FIRST_TREE_HUB_GITHUB_APP_SLUG" })
1869
1925
  }) }),
1870
1926
  cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1871
1927
  trustProxy: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_TRUST_PROXY" }),
@@ -1966,6 +2022,74 @@ async function writeImage(params) {
1966
2022
  return path;
1967
2023
  }
1968
2024
  const FETCH_TIMEOUT_MS = 15e3;
2025
+ /**
2026
+ * Node-level error codes (undici / DNS / TCP) treated as transient by the
2027
+ * `doFetch` retry layer. The set covers the failure modes that a *brief*
2028
+ * network blip can produce mid-request:
2029
+ *
2030
+ * - `ECONNRESET` — TCP RST mid-stream (commonly a keep-alive idle
2031
+ * connection closed by the peer and reused before we
2032
+ * noticed)
2033
+ * - `ETIMEDOUT` — kernel-level connect/read timeout (peer slow, not
2034
+ * absent)
2035
+ * - `ENETUNREACH` — transient routing-table flap (local network reload,
2036
+ * wifi roam)
2037
+ * - `EAI_AGAIN` — DNS resolver returned a temporary failure; the
2038
+ * resolver itself tells us to retry
2039
+ * - `UND_ERR_SOCKET` — undici's internal socket-level error, wrapping the
2040
+ * above when the request happens through its
2041
+ * connection pool
2042
+ *
2043
+ * Deliberately **not** retried at this layer:
2044
+ * - `ECONNREFUSED` — peer is reachable but refusing; retrying immediately
2045
+ * won't fix anything, and the caller's higher-level
2046
+ * reconnect logic is the right response
2047
+ * - `ENOTFOUND` — DNS reports the host doesn't exist (typo or rotated
2048
+ * record); a 1s retry isn't going to materialise the
2049
+ * record
2050
+ * - other 4xx-class HTTP statuses — handled by the caller, never reach
2051
+ * this set
2052
+ */
2053
+ const RETRYABLE_NETWORK_CODES = new Set([
2054
+ "ECONNRESET",
2055
+ "ETIMEDOUT",
2056
+ "ENETUNREACH",
2057
+ "EAI_AGAIN",
2058
+ "UND_ERR_SOCKET"
2059
+ ]);
2060
+ function sleep$1(ms) {
2061
+ return new Promise((resolve) => setTimeout(resolve, ms));
2062
+ }
2063
+ /**
2064
+ * Decide whether an error thrown by `fetch()` represents a transient
2065
+ * network-layer failure that the caller should retry.
2066
+ *
2067
+ * Walks the `cause` chain (undici nests the real reason one level deep via
2068
+ * `TypeError("fetch failed").cause`) and checks each link for:
2069
+ * - `message` containing `"fetch failed"` (undici's signature)
2070
+ * - `name === "AbortError"` (our 15s `AbortSignal.timeout`)
2071
+ * - `code` ∈ `RETRYABLE_NETWORK_CODES`
2072
+ *
2073
+ * `unknown` input is intentional: this function is the gatekeeper for the
2074
+ * retry decision in `doFetch`'s catch block, where TS sees `unknown`.
2075
+ *
2076
+ * Depth is bounded (~5) to defend against the pathological case of a
2077
+ * self-referencing cause chain.
2078
+ */
2079
+ function isTransientNetworkError(err) {
2080
+ let current = err;
2081
+ let depth = 0;
2082
+ while (current !== null && current !== void 0 && depth < 5) {
2083
+ if (typeof current !== "object") return false;
2084
+ const obj = current;
2085
+ if (typeof obj.message === "string" && obj.message.includes("fetch failed")) return true;
2086
+ if (obj.name === "AbortError") return true;
2087
+ if (typeof obj.code === "string" && RETRYABLE_NETWORK_CODES.has(obj.code)) return true;
2088
+ current = obj.cause;
2089
+ depth++;
2090
+ }
2091
+ return false;
2092
+ }
1969
2093
  var FirstTreeHubSDK = class {
1970
2094
  _baseUrl;
1971
2095
  getAccessToken;
@@ -2087,7 +2211,58 @@ var FirstTreeHubSDK = class {
2087
2211
  if (!response.ok) throw await this.toSdkError(response);
2088
2212
  return await response.json();
2089
2213
  }
2214
+ /**
2215
+ * Retry transient network-layer failures and HTTP 5xx with a fixed backoff
2216
+ * schedule. Short-term fix for the chat-visible `Result forward failed:
2217
+ * fetch failed` errors (see docs/sdk-fetch-retry-design.md): undici's
2218
+ * "fetch failed" / `AbortError` / `ECONNRESET`-class errors and any 5xx
2219
+ * response trigger up to two retries with `[0, 500ms, 1000ms]` spacing,
2220
+ * adding at most ~1.5s of latency beyond the per-attempt 15s timeout.
2221
+ *
2222
+ * 4xx responses and non-network exceptions are returned/thrown unchanged
2223
+ * — they indicate a deterministic failure that retrying cannot fix.
2224
+ *
2225
+ * Idempotency caveat: `sendMessage` is not natively idempotent, so a
2226
+ * `fetch failed` from a request the server actually committed will
2227
+ * produce a duplicate message on retry. The design accepts this for now
2228
+ * (small window, low rate, mitigated long-term by an Outbox pattern with
2229
+ * client-generated UUIDs).
2230
+ *
2231
+ * The retry signature and externally-visible behaviour match `doFetch`'s
2232
+ * pre-retry contract: callers see the same Response on success or the
2233
+ * same error type on terminal failure.
2234
+ */
2090
2235
  async doFetch(path, init) {
2236
+ const delays = [
2237
+ 0,
2238
+ 500,
2239
+ 1e3
2240
+ ];
2241
+ let lastErr;
2242
+ for (let attempt = 0; attempt < delays.length; attempt++) {
2243
+ const delay = delays[attempt];
2244
+ if (delay !== void 0 && delay > 0) await sleep$1(delay);
2245
+ try {
2246
+ const response = await this.doFetchOnce(path, init);
2247
+ const isLastAttempt = attempt === delays.length - 1;
2248
+ if (response.status >= 500 && !isLastAttempt) {
2249
+ console.warn(`sdk: retry attempt=${attempt + 1} reason=http-${response.status} path=${path}`);
2250
+ lastErr = /* @__PURE__ */ new Error(`HTTP ${response.status}`);
2251
+ continue;
2252
+ }
2253
+ return response;
2254
+ } catch (err) {
2255
+ lastErr = err;
2256
+ if (!isTransientNetworkError(err)) throw err;
2257
+ if (!(attempt === delays.length - 1)) {
2258
+ const reason = err instanceof Error ? err.name === "AbortError" ? "timeout" : err.message.slice(0, 60) : "unknown";
2259
+ console.warn(`sdk: retry attempt=${attempt + 1} reason=${reason} path=${path}`);
2260
+ }
2261
+ }
2262
+ }
2263
+ throw lastErr;
2264
+ }
2265
+ async doFetchOnce(path, init) {
2091
2266
  const url = `${this._baseUrl}${path}`;
2092
2267
  const headers = { Authorization: `Bearer ${await this.getAccessToken()}` };
2093
2268
  if (this._agentId) headers[AGENT_SELECTOR_HEADER] = this._agentId;
@@ -9151,7 +9326,7 @@ async function onboardCreate(args) {
9151
9326
  }
9152
9327
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9153
9328
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9154
- const { bindFeishuBot } = await import("./feishu-CKGzIamp.mjs").then((n) => n.r);
9329
+ const { bindFeishuBot } = await import("./feishu-CsfadBKa.mjs").then((n) => n.r);
9155
9330
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9156
9331
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9157
9332
  else {
@@ -10364,7 +10539,7 @@ function createFeedbackHandler(config) {
10364
10539
  return { handle };
10365
10540
  }
10366
10541
  //#endregion
10367
- //#region ../server/dist/app-kJNM9Cf1.mjs
10542
+ //#region ../server/dist/app-DFZ1LKZa.mjs
10368
10543
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10369
10544
  init_esm();
10370
10545
  var __defProp = Object.defineProperty;
@@ -10749,7 +10924,7 @@ async function deleteAdapterConfig(db, id) {
10749
10924
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
10750
10925
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
10751
10926
  }
10752
- const log$5 = createLogger$1("Adapters");
10927
+ const log$6 = createLogger$1("Adapters");
10753
10928
  function parseId(raw) {
10754
10929
  const id = Number(raw);
10755
10930
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
@@ -10775,7 +10950,7 @@ async function adapterRoutes(app) {
10775
10950
  const existing = await getAdapterConfig(app.db, id);
10776
10951
  await assertAgentManageableByUser(app.db, userId, existing.agentId);
10777
10952
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
10778
- app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
10953
+ app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after update"));
10779
10954
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10780
10955
  return {
10781
10956
  ...config,
@@ -10789,7 +10964,7 @@ async function adapterRoutes(app) {
10789
10964
  const existing = await getAdapterConfig(app.db, id);
10790
10965
  await assertAgentManageableByUser(app.db, userId, existing.agentId);
10791
10966
  await deleteAdapterConfig(app.db, id);
10792
- app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
10967
+ app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after delete"));
10793
10968
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
10794
10969
  return reply.status(204).send();
10795
10970
  });
@@ -10805,6 +10980,7 @@ function requireAgent(request) {
10805
10980
  if (!agent) throw new UnauthorizedError("Agent authentication required");
10806
10981
  return agent;
10807
10982
  }
10983
+ const log$5 = createLogger$1("AgentChatsRoute");
10808
10984
  function serializeChat(chat) {
10809
10985
  return {
10810
10986
  ...chat,
@@ -10866,6 +11042,15 @@ async function agentChatRoutes(app) {
10866
11042
  });
10867
11043
  app.post("/:chatId/participants", async (request, reply) => {
10868
11044
  const identity = requireAgent(request);
11045
+ if (request.body !== null && typeof request.body === "object" && "mode" in request.body) {
11046
+ log$5.warn({
11047
+ code: "MODE_FIELD_DEPRECATED",
11048
+ chatId: request.params.chatId,
11049
+ senderAgentId: identity.uuid,
11050
+ userAgent: request.headers["user-agent"] ?? "unknown"
11051
+ }, "Rejected: addParticipant body contains deprecated `mode` field");
11052
+ return reply.status(400).send({ error: "MODE_FIELD_DEPRECATED: the `mode` field is no longer accepted. Participant mode is derived server-side from chat type + agent type. Remove this field from your request." });
11053
+ }
10869
11054
  const body = addParticipantSchema.parse(request.body);
10870
11055
  const participants = await addParticipant(app.db, request.params.chatId, identity.uuid, body);
10871
11056
  return reply.status(201).send(participants.map((p) => ({
@@ -11542,23 +11727,16 @@ async function findOrCreateChatForChannel(db, data) {
11542
11727
  lifecyclePolicy: "adapter_managed",
11543
11728
  metadata
11544
11729
  });
11545
- const participants = data.botAgentId === data.senderAgentId ? [{
11546
- chatId,
11730
+ await addChatParticipants(tx, chatId, data.botAgentId === data.senderAgentId ? [{
11547
11731
  agentId: data.botAgentId,
11548
- role: "member",
11549
- mode: "full"
11732
+ role: "member"
11550
11733
  }] : [{
11551
- chatId,
11552
11734
  agentId: data.botAgentId,
11553
- role: "member",
11554
- mode: "full"
11735
+ role: "member"
11555
11736
  }, {
11556
- chatId,
11557
11737
  agentId: data.senderAgentId,
11558
- role: "member",
11559
- mode: "full"
11560
- }];
11561
- await tx.insert(chatParticipants).values(participants);
11738
+ role: "member"
11739
+ }]);
11562
11740
  await tx.insert(adapterChatMappings).values({
11563
11741
  platform: data.platform,
11564
11742
  externalChannelId: data.externalChannelId,
@@ -11569,14 +11747,18 @@ async function findOrCreateChatForChannel(db, data) {
11569
11747
  return chatId;
11570
11748
  });
11571
11749
  }
11572
- /** Ensure an agent is a participant of a chat (no-op if already). */
11750
+ /**
11751
+ * Ensure an agent is a participant of a chat (no-op if already). Mode is
11752
+ * derived via the canonical entrypoint — pre-fix this also wrote `mode:`
11753
+ * implicitly via schema default `'full'`, which is wrong for non-human
11754
+ * agents in a group chat (the bug §1.1 of the Phase 1 design doc fixes).
11755
+ */
11573
11756
  async function ensureParticipant(db, chatId, agentId) {
11574
11757
  const [exists] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
11575
- if (!exists) await db.insert(chatParticipants).values({
11576
- chatId,
11758
+ if (!exists) await addChatParticipants(db, chatId, [{
11577
11759
  agentId,
11578
11760
  role: "member"
11579
- }).onConflictDoNothing();
11761
+ }], { onConflictDoNothing: true });
11580
11762
  }
11581
11763
  /** Store a cross-reference between internal and external message. */
11582
11764
  async function createMessageReference(db, data) {
@@ -13745,6 +13927,12 @@ const authIdentities = pgTable("auth_identities", {
13745
13927
  * 32-byte random string. The bcrypt comparison in `authService.login`
13746
13928
  * treats it as a plain string and rejects every password — that's the
13747
13929
  * intended behaviour: SaaS users cannot fall back to password login.
13930
+ *
13931
+ * `tokens` carries the full App user-to-server bundle when called from the
13932
+ * App OAuth callback, or just `encryptedAccessToken` when called from the
13933
+ * legacy OAuth callback or `dev-callback`. Missing fields are simply not
13934
+ * written — service code that reads `metadata` MUST tolerate the legacy
13935
+ * shape (only `accessToken`), per `auth-identities.ts` jsdoc.
13748
13936
  */
13749
13937
  async function findOrCreateUserFromGithub(db, profile, opts = {}) {
13750
13938
  const [existing] = await db.select({
@@ -13754,10 +13942,10 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
13754
13942
  if (existing) {
13755
13943
  const patch = {};
13756
13944
  if (profile.email) patch.email = profile.email;
13757
- if (opts.encryptedAccessToken) patch.metadata = {
13945
+ const tokenPatch = buildTokenMetadataPatch(profile, opts);
13946
+ if (tokenPatch) patch.metadata = {
13758
13947
  ...existing.metadata ?? {},
13759
- accessToken: opts.encryptedAccessToken,
13760
- login: profile.login
13948
+ ...tokenPatch
13761
13949
  };
13762
13950
  if (Object.keys(patch).length > 0) await db.update(authIdentities).set({
13763
13951
  ...patch,
@@ -13777,7 +13965,7 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
13777
13965
  avatarUrl: profile.avatarUrl ?? null
13778
13966
  });
13779
13967
  const metadata = { login: profile.login };
13780
- if (opts.encryptedAccessToken) metadata.accessToken = opts.encryptedAccessToken;
13968
+ Object.assign(metadata, buildTokenMetadataPatch(profile, opts) ?? {});
13781
13969
  await tx.insert(authIdentities).values({
13782
13970
  id: uuidv7(),
13783
13971
  userId,
@@ -13790,8 +13978,50 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
13790
13978
  });
13791
13979
  return { userId };
13792
13980
  }
13981
+ /**
13982
+ * Decrypt the GitHub user-to-server access token persisted on this user's
13983
+ * `auth_identities.metadata`, or `null` when there's no GitHub identity,
13984
+ * no captured token (e.g. `dev-callback` sign-in), or the ciphertext can't
13985
+ * be decoded.
13986
+ *
13987
+ * Does NOT refresh an expired App token — callers that need a guaranteed-
13988
+ * fresh token (the Step 2 repo picker) do the refresh dance inline; callers
13989
+ * that just want a best-effort identity check (the manual install-claim
13990
+ * endpoint) tolerate a stale token failing the downstream GitHub call.
13991
+ */
13992
+ async function getStoredGithubAccessToken(db, userId, encryptionKey) {
13993
+ const [identity] = await db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.userId, userId))).limit(1);
13994
+ const meta = identity?.metadata && typeof identity.metadata === "object" ? identity.metadata : null;
13995
+ const encrypted = meta && typeof meta.accessToken === "string" && meta.accessToken ? meta.accessToken : null;
13996
+ if (!encrypted) return null;
13997
+ try {
13998
+ return decryptValue(encrypted, encryptionKey);
13999
+ } catch {
14000
+ return null;
14001
+ }
14002
+ }
14003
+ /**
14004
+ * Pluck the token-related keys out of `opts` into the shape that lands on
14005
+ * `auth_identities.metadata`. Returns `null` when no token data was
14006
+ * supplied so the caller can skip the metadata write entirely.
14007
+ *
14008
+ * `login` is always refreshed when any token field is touched, so a user
14009
+ * who renames their GitHub account gets the new login snapshot on next
14010
+ * sign-in.
14011
+ */
14012
+ function buildTokenMetadataPatch(profile, opts) {
14013
+ if (!opts.encryptedAccessToken) return null;
14014
+ const patch = {
14015
+ login: profile.login,
14016
+ accessToken: opts.encryptedAccessToken
14017
+ };
14018
+ if (opts.accessTokenExpiresAt) patch.accessTokenExpiresAt = opts.accessTokenExpiresAt;
14019
+ if (opts.encryptedRefreshToken) patch.refreshToken = opts.encryptedRefreshToken;
14020
+ if (opts.refreshTokenExpiresAt) patch.refreshTokenExpiresAt = opts.refreshTokenExpiresAt;
14021
+ return patch;
14022
+ }
13793
14023
  /** Postgres `unique_violation` SQLSTATE — emitted when a UNIQUE constraint trips. */
13794
- const PG_UNIQUE_VIOLATION$1 = "23505";
14024
+ const PG_UNIQUE_VIOLATION$2 = "23505";
13795
14025
  /**
13796
14026
  * Pick a candidate username, attempt the caller's INSERT in a transaction,
13797
14027
  * and retry under a fresh disambiguator if the UNIQUE(users.username)
@@ -13810,7 +14040,7 @@ async function insertWithUsernameRetry(db, base, insert) {
13810
14040
  });
13811
14041
  return;
13812
14042
  } catch (err) {
13813
- if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$1) throw err;
14043
+ if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$2) throw err;
13814
14044
  candidate = `${base}-${randomBytes(2).toString("hex")}`;
13815
14045
  }
13816
14046
  candidate = `${base}-${uuidv7().slice(0, 12)}`;
@@ -13818,47 +14048,297 @@ async function insertWithUsernameRetry(db, base, insert) {
13818
14048
  await insert(tx, candidate);
13819
14049
  });
13820
14050
  }
13821
- const TOKEN_URL = "https://github.com/login/oauth/access_token";
13822
- const USER_URL = "https://api.github.com/user";
13823
- const USER_EMAILS_URL = "https://api.github.com/user/emails";
13824
14051
  /**
13825
- * Exchange an OAuth code for an access token + fetch the user profile.
14052
+ * GitHub App service helpers. Two surfaces that ride on top of an App's
14053
+ * private key + OAuth client credentials:
14054
+ *
14055
+ * App-private-key (server-to-server):
14056
+ * - `createAppJwt` — short-lived (≤10min) JWT identifying
14057
+ * Hub-as-this-App to GitHub.
14058
+ * - `mintInstallationToken` — exchange the App JWT for a per-installation
14059
+ * token (~1h TTL). Used when Hub acts as the
14060
+ * App on a tenant's repos (Phase 4 identity
14061
+ * convergence — not yet wired into request
14062
+ * paths).
14063
+ *
14064
+ * App-OAuth (user-to-server, replaces the legacy OAuth App flow):
14065
+ * - `buildAppAuthorizeUrl` — the start URL for the combined OAuth
14066
+ * + install flow (design doc D1).
14067
+ * - `exchangeCodeForAppUserProfile` — callback-side token exchange that
14068
+ * returns the user's profile, the
14069
+ * access + refresh tokens, and their
14070
+ * absolute expiries.
14071
+ * - `refreshAppUserToken` — slide an expiring access token by
14072
+ * trading in its refresh token.
14073
+ *
14074
+ * Design context: `docs/github-app-design-zh.md` §3 ("one installation,
14075
+ * three capabilities") + §5.4 ("services/github-app.ts").
14076
+ *
14077
+ * Stateless by construction: no DB / config singletons. Callers thread
14078
+ * credentials in explicitly so the module is trivially safe under
14079
+ * concurrent request handlers.
14080
+ */
14081
+ const APP_JWT_ALG = "RS256";
14082
+ /**
14083
+ * GitHub rejects App JWTs past 10 minutes; we ride 9 minutes so callers
14084
+ * don't trip the upper bound from clock skew. Generous-but-safe — the JWT
14085
+ * is cheap to mint (one RS256 signature) so caching is unnecessary.
14086
+ */
14087
+ const APP_JWT_EXPIRY = "9m";
14088
+ /**
14089
+ * GitHub allows a small backdated `iat` to absorb clock skew on the
14090
+ * caller's side; the docs recommend 60 seconds. We mirror that.
14091
+ */
14092
+ const APP_JWT_IAT_SKEW_SECONDS = 60;
14093
+ const APP_INSTALLATION_URL = (id) => `https://api.github.com/app/installations/${id}`;
14094
+ const OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token";
14095
+ const OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
14096
+ const USER_API_URL = "https://api.github.com/user";
14097
+ const USER_EMAILS_API_URL = "https://api.github.com/user/emails";
14098
+ const USER_INSTALLATIONS_API_URL = "https://api.github.com/user/installations";
14099
+ /**
14100
+ * Errors from any GitHub API call this module makes. Carries the HTTP
14101
+ * status so route layers can disambiguate auth/permission failures (401 /
14102
+ * 403 / 404) from transient upstream errors (5xx / network).
14103
+ *
14104
+ * Distinct from `github-oauth.ts`'s `GithubApiError` because the App API
14105
+ * surface is a different concern (App-private-key vs. OAuth client
14106
+ * credentials) and we want logs / metrics to tell them apart at a glance.
14107
+ */
14108
+ var GithubAppApiError = class extends Error {
14109
+ constructor(status, message) {
14110
+ super(message);
14111
+ this.status = status;
14112
+ this.name = "GithubAppApiError";
14113
+ }
14114
+ };
14115
+ /**
14116
+ * Mint an App JWT. RS256-signed; identifies Hub-as-this-App to GitHub for
14117
+ * the next ~9 minutes. Use this directly for `/app/...` endpoints and as
14118
+ * the input to `mintInstallationToken` for `/installation/...` endpoints.
14119
+ *
14120
+ * The PEM is imported on every call. The cost is negligible (microseconds)
14121
+ * and avoids a global mutable cache — keeps this module trivially safe to
14122
+ * call from parallel request handlers without locking. If profiling ever
14123
+ * shows this is a hotspot, add a per-key memoization at the caller side.
14124
+ */
14125
+ async function createAppJwt(creds) {
14126
+ const key = await importPKCS8(creds.privateKeyPem, APP_JWT_ALG);
14127
+ const now = Math.floor(Date.now() / 1e3);
14128
+ return new SignJWT({}).setProtectedHeader({ alg: APP_JWT_ALG }).setIssuer(creds.appId).setIssuedAt(now - APP_JWT_IAT_SKEW_SECONDS).setExpirationTime(APP_JWT_EXPIRY).sign(key);
14129
+ }
14130
+ /**
14131
+ * Fetch the installation metadata. Used by the OAuth callback path to
14132
+ * resolve the bare `installation_id` query param into a full row before
14133
+ * UPSERTing into `github_app_installations` — so the callback doesn't
14134
+ * depend on the `installation: created` webhook arriving first
14135
+ * (delivery order between callback and webhook is not guaranteed).
14136
+ *
14137
+ * The webhook handler skips this call entirely because the payload it
14138
+ * receives already carries the same shape.
14139
+ */
14140
+ async function fetchInstallation(appJwt, installationId, opts = {}) {
14141
+ const res = await (opts.fetcher ?? fetch)(APP_INSTALLATION_URL(installationId), { headers: {
14142
+ Authorization: `Bearer ${appJwt}`,
14143
+ Accept: "application/vnd.github+json",
14144
+ "X-GitHub-Api-Version": "2022-11-28"
14145
+ } });
14146
+ if (!res.ok) throw new GithubAppApiError(res.status, `GitHub App installation fetch failed (${res.status})`);
14147
+ const body = await res.json();
14148
+ return {
14149
+ id: body.id,
14150
+ accountType: body.account.type,
14151
+ accountLogin: body.account.login,
14152
+ accountGithubId: body.account.id,
14153
+ permissions: body.permissions ?? {},
14154
+ events: body.events ?? [],
14155
+ suspendedAt: body.suspended_at ?? null
14156
+ };
14157
+ }
14158
+ /**
14159
+ * List the installation IDs the authenticated GitHub user can administer.
14160
+ * Wraps `GET /user/installations` — GitHub's documented "what installs is
14161
+ * this user allowed to touch" endpoint. Covers both User-type installs
14162
+ * (the user owns the personal account) and Organization-type installs
14163
+ * (the user has admin rights on the org).
14164
+ *
14165
+ * Critical security primitive: the OAuth callback uses this to verify
14166
+ * that an `installation_id` query parameter — which arrives over an
14167
+ * insecure channel (the user's browser address bar) — actually belongs
14168
+ * to the authenticated user. Without this check, any signed-in user
14169
+ * could attach an arbitrary installation_id to their callback URL and
14170
+ * bind another team's installation to their own Hub org (installation
14171
+ * IDs are NOT secrets — they appear in webhook URLs, GitHub-side
14172
+ * Settings pages, and the install dialog's post-install redirect).
14173
+ *
14174
+ * Returns the bare ID set so callers can do O(1) membership checks. The
14175
+ * full installation metadata is fetched separately via `fetchInstallation`
14176
+ * once authorization has cleared.
14177
+ */
14178
+ async function listUserAccessibleInstallationIds(userAccessToken, opts = {}) {
14179
+ const fetcher = opts.fetcher ?? fetch;
14180
+ const perPage = opts.perPage ?? 100;
14181
+ const maxPages = opts.maxPages ?? 5;
14182
+ const out = /* @__PURE__ */ new Set();
14183
+ for (let page = 1; page <= maxPages; page++) {
14184
+ const res = await fetcher(`${USER_INSTALLATIONS_API_URL}?per_page=${perPage}&page=${page}`, { headers: {
14185
+ Authorization: `Bearer ${userAccessToken}`,
14186
+ Accept: "application/vnd.github+json",
14187
+ "X-GitHub-Api-Version": "2022-11-28"
14188
+ } });
14189
+ if (!res.ok) throw new GithubAppApiError(res.status, `GitHub /user/installations failed (${res.status})`);
14190
+ const installs = (await res.json()).installations ?? [];
14191
+ for (const inst of installs) if (typeof inst.id === "number") out.add(inst.id);
14192
+ if (installs.length < perPage) break;
14193
+ }
14194
+ return out;
14195
+ }
14196
+ /**
14197
+ * Trade an expiring user-to-server access token for a fresh pair using
14198
+ * its refresh token. Thrown on:
14199
+ * - Network / 5xx — `GithubAppApiError(status, …)`
14200
+ * - 4xx with no JSON body — `GithubAppApiError(status, …)`
14201
+ * - 200 with `error` field — `GithubAppApiError(401, …)` (GitHub returns
14202
+ * 200 OK for an unusable refresh token but signals the error in the
14203
+ * body; we normalize to 401 so route layers can map to "re-login")
13826
14204
  *
13827
- * The default `fetch` is overridable via `opts.fetcher` so tests can mock
13828
- * the GitHub round-trip without standing up a fake server. The contract
13829
- * the test fake must honor:
13830
- * - First call: POST `${TOKEN_URL}` returns `{ access_token: string }`
13831
- * - Then GET `${USER_URL}` with `Authorization: Bearer …`
13832
- * - Then GET `${USER_EMAILS_URL}` (only if `/user` returned no email)
13833
- */
13834
- async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
14205
+ * Designed to be called from the OAuth callback / token-refresh path
14206
+ * landing in PR-C. The caller decrypts the stored refresh token, hands
14207
+ * the plaintext in here, encrypts the returned pair, and writes both
14208
+ * tokens + expiries back to `auth_identities.metadata`.
14209
+ */
14210
+ async function refreshAppUserToken(clientId, clientSecret, refreshToken, opts = {}) {
13835
14211
  const fetcher = opts.fetcher ?? fetch;
13836
- const tokenRes = await fetcher(TOKEN_URL, {
14212
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
14213
+ const res = await fetcher(OAUTH_TOKEN_URL, {
13837
14214
  method: "POST",
13838
14215
  headers: {
13839
14216
  Accept: "application/json",
13840
14217
  "Content-Type": "application/json"
13841
14218
  },
13842
14219
  body: JSON.stringify({
13843
- client_id: config.clientId,
13844
- client_secret: config.clientSecret,
13845
- code,
13846
- redirect_uri: redirectUri
14220
+ client_id: clientId,
14221
+ client_secret: clientSecret,
14222
+ grant_type: "refresh_token",
14223
+ refresh_token: refreshToken
13847
14224
  })
13848
14225
  });
13849
- if (!tokenRes.ok) throw new Error(`GitHub token exchange failed (${tokenRes.status})`);
13850
- const tokenJson = await tokenRes.json();
13851
- if (!tokenJson.access_token) throw new Error(tokenJson.error ?? "GitHub token exchange returned no access_token");
13852
- const userRes = await fetcher(USER_URL, { headers: {
13853
- Authorization: `Bearer ${tokenJson.access_token}`,
14226
+ if (!res.ok) throw new GithubAppApiError(res.status, `GitHub user-token refresh failed (${res.status})`);
14227
+ const body = await res.json();
14228
+ if (body.error || !body.access_token || !body.refresh_token) throw new GithubAppApiError(401, `GitHub user-token refresh rejected: ${body.error_description ?? body.error ?? "missing access_token / refresh_token"}`);
14229
+ if (typeof body.expires_in !== "number" || typeof body.refresh_token_expires_in !== "number") throw new GithubAppApiError(500, "GitHub user-token refresh response missing expires_in fields — App likely has user-token expiration disabled");
14230
+ const issuedAt = now();
14231
+ const accessExpiresAt = new Date(issuedAt.getTime() + body.expires_in * 1e3);
14232
+ const refreshExpiresAt = new Date(issuedAt.getTime() + body.refresh_token_expires_in * 1e3);
14233
+ return {
14234
+ accessToken: body.access_token,
14235
+ accessTokenExpiresAt: accessExpiresAt.toISOString(),
14236
+ refreshToken: body.refresh_token,
14237
+ refreshTokenExpiresAt: refreshExpiresAt.toISOString(),
14238
+ scope: body.scope ?? ""
14239
+ };
14240
+ }
14241
+ /**
14242
+ * Build the App's combined OAuth + install authorization URL. Per design
14243
+ * doc D1 ("login → install in one redirect"), this is the SAME endpoint
14244
+ * GitHub uses for both flows when the App has "Request user authorization
14245
+ * (OAuth) during installation" enabled — first install lands the user on
14246
+ * the install dialog → consents → GitHub bounces back to `redirect_uri`
14247
+ * with both `code` (OAuth) and `installation_id` (the new install).
14248
+ * Returning users skip the install dialog and just receive `code`.
14249
+ *
14250
+ * `state` is the signed JWT minted by `oauth-state.ts` — same CSRF defense
14251
+ * as the legacy OAuth flow.
14252
+ *
14253
+ * Permissions are NOT in the URL — the App declares them once in its
14254
+ * GitHub-side settings (design doc D0b) and the install dialog renders
14255
+ * them automatically. Asking again in the URL would let an attacker
14256
+ * craft a downgrade prompt.
14257
+ */
14258
+ function buildAppAuthorizeUrl(opts) {
14259
+ const url = new URL(OAUTH_AUTHORIZE_URL);
14260
+ url.searchParams.set("client_id", opts.clientId);
14261
+ url.searchParams.set("redirect_uri", opts.redirectUri);
14262
+ url.searchParams.set("state", opts.state);
14263
+ url.searchParams.set("allow_signup", "true");
14264
+ return url.toString();
14265
+ }
14266
+ const APP_INSTALL_URL = (slug) => `https://github.com/apps/${encodeURIComponent(slug)}/installations/new`;
14267
+ /**
14268
+ * Build the App's `installations/new` URL — the one that actually surfaces
14269
+ * GitHub's install dialog (repo picker + permission review). Distinct from
14270
+ * `buildAppAuthorizeUrl`:
14271
+ *
14272
+ * - `authorize` (login URL) → for a user who already has the App
14273
+ * installed, returns `code`. For one who DOESN'T, it only triggers
14274
+ * OAuth consent — no install dialog, no `installation_id` ever comes
14275
+ * back. So "Install on GitHub" CTAs that point at `authorize` silently
14276
+ * never produce an install (codex P1-1).
14277
+ * - `installations/new` → always shows the install picker. After the
14278
+ * user confirms, GitHub redirects to the App's configured callback /
14279
+ * setup URL with `installation_id` and (because the App has "Request
14280
+ * user authorization (OAuth) during installation" enabled, D1) also
14281
+ * `code` + the `state` we threaded through here.
14282
+ *
14283
+ * `state` is the same signed JWT minted by `oauth-state.ts` — GitHub
14284
+ * round-trips it on the post-install redirect, so the callback can verify
14285
+ * CSRF + recover `next` + (codex P1-3) the target org to bind to.
14286
+ */
14287
+ function buildAppInstallUrl(opts) {
14288
+ const url = new URL(APP_INSTALL_URL(opts.appSlug));
14289
+ url.searchParams.set("state", opts.state);
14290
+ return url.toString();
14291
+ }
14292
+ /**
14293
+ * App-flavoured `exchangeCodeForProfile`: trade the callback `code` for
14294
+ * the user's profile + a full token pair (access + refresh + expiries).
14295
+ *
14296
+ * Why this exists alongside `github-oauth.ts.exchangeCodeForProfile`:
14297
+ * - Same endpoint (`/login/oauth/access_token`) but with App
14298
+ * client_id/secret instead of OAuth App credentials.
14299
+ * - Response carries `refresh_token` + `expires_in` +
14300
+ * `refresh_token_expires_in` (8h / 6mo TTLs) that the OAuth-only
14301
+ * version doesn't return.
14302
+ * - The token-rotation semantics (`refresh_token` will be reissued on
14303
+ * every refresh) mean the caller MUST persist all four fields, not
14304
+ * just `accessToken`.
14305
+ *
14306
+ * The OAuth-only helper stays put for the brief window between this
14307
+ * commit and the OAuth-flow rewrite; D3 cutover deletes it outright.
14308
+ */
14309
+ async function exchangeCodeForAppUserProfile(opts, callOpts = {}) {
14310
+ const fetcher = callOpts.fetcher ?? fetch;
14311
+ const now = callOpts.now ?? (() => /* @__PURE__ */ new Date());
14312
+ const tokenRes = await fetcher(OAUTH_TOKEN_URL, {
14313
+ method: "POST",
14314
+ headers: {
14315
+ Accept: "application/json",
14316
+ "Content-Type": "application/json"
14317
+ },
14318
+ body: JSON.stringify({
14319
+ client_id: opts.clientId,
14320
+ client_secret: opts.clientSecret,
14321
+ code: opts.code,
14322
+ redirect_uri: opts.redirectUri
14323
+ })
14324
+ });
14325
+ if (!tokenRes.ok) throw new GithubAppApiError(tokenRes.status, `GitHub App user-token exchange failed (${tokenRes.status})`);
14326
+ const body = await tokenRes.json();
14327
+ if (body.error || !body.access_token || !body.refresh_token) throw new GithubAppApiError(401, `GitHub App user-token exchange rejected: ${body.error_description ?? body.error ?? "missing access_token / refresh_token"}`);
14328
+ if (typeof body.expires_in !== "number" || typeof body.refresh_token_expires_in !== "number") throw new GithubAppApiError(500, "GitHub App user-token exchange missing expires_in — App must have user-token expiration enabled");
14329
+ const issuedAt = now();
14330
+ const accessExpiresAt = new Date(issuedAt.getTime() + body.expires_in * 1e3);
14331
+ const refreshExpiresAt = new Date(issuedAt.getTime() + body.refresh_token_expires_in * 1e3);
14332
+ const userRes = await fetcher(USER_API_URL, { headers: {
14333
+ Authorization: `Bearer ${body.access_token}`,
13854
14334
  Accept: "application/vnd.github+json"
13855
14335
  } });
13856
- if (!userRes.ok) throw new Error(`GitHub user fetch failed (${userRes.status})`);
14336
+ if (!userRes.ok) throw new GithubAppApiError(userRes.status, `GitHub /user fetch failed (${userRes.status})`);
13857
14337
  const user = await userRes.json();
13858
14338
  let email = user.email ?? null;
13859
14339
  if (!email) {
13860
- const emailsRes = await fetcher(USER_EMAILS_URL, { headers: {
13861
- Authorization: `Bearer ${tokenJson.access_token}`,
14340
+ const emailsRes = await fetcher(USER_EMAILS_API_URL, { headers: {
14341
+ Authorization: `Bearer ${body.access_token}`,
13862
14342
  Accept: "application/vnd.github+json"
13863
14343
  } });
13864
14344
  if (emailsRes.ok) {
@@ -13874,49 +14354,186 @@ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
13874
14354
  displayName: user.name ?? null,
13875
14355
  avatarUrl: user.avatar_url ?? null
13876
14356
  },
13877
- accessToken: tokenJson.access_token
14357
+ accessToken: body.access_token,
14358
+ accessTokenExpiresAt: accessExpiresAt.toISOString(),
14359
+ refreshToken: body.refresh_token,
14360
+ refreshTokenExpiresAt: refreshExpiresAt.toISOString(),
14361
+ scope: body.scope ?? "",
14362
+ installationId: opts.installationId
13878
14363
  };
13879
14364
  }
13880
14365
  /**
13881
- * Thrown when GitHub's API returns a non-2xx for a token-scoped call.
13882
- * Carries the HTTP status so callers can distinguish auth failures (401 /
13883
- * 403 — typically a stale token or a missing scope after we expanded to
13884
- * `repo`) from transient upstream errors.
14366
+ * GitHub App installation records one row per (GitHub account → Hub team)
14367
+ * binding. Replaces the per-repo OAuth + webhook-secret model that lived in
14368
+ * `organization_settings.github_integration.webhookSecretCipher`.
14369
+ *
14370
+ * One installation simultaneously unlocks three capabilities (see design
14371
+ * doc `docs/github-app-design-zh.md` §3):
14372
+ * 1. User OAuth (user-to-server access + refresh tokens) — persisted on
14373
+ * `auth_identities.metadata` for the signing-in user, not here.
14374
+ * 2. Webhook stream — `installation_id` resolves the inbound webhook to
14375
+ * the bound Hub org by joining on this table.
14376
+ * 3. Installation token (server-to-server) — minted on demand from the
14377
+ * App private key; not persisted (1h TTL, cheap to re-issue).
14378
+ *
14379
+ * The (GitHub account ↔ Hub team) binding is 1:1 (D2 / §8 Q1). The
14380
+ * `hub_organization_id` UNIQUE constraint enforces that; the column is
14381
+ * nullable solely to accommodate the install-callback handler inserting
14382
+ * the row before the owning Hub team exists (fresh-signup flow). Once a
14383
+ * binding exists it never moves — re-installing the App on the same GitHub
14384
+ * account UPDATEs this row by `installation_id`.
14385
+ *
14386
+ * ON DELETE SET NULL on `hub_organization_id` rather than CASCADE because
14387
+ * the GitHub-side installation still exists upstream when a Hub team is
14388
+ * deleted — keeping the row lets a future re-binding flow recover without
14389
+ * a re-install dance.
13885
14390
  */
13886
- var GithubApiError = class extends Error {
13887
- constructor(status, message) {
13888
- super(message);
13889
- this.status = status;
13890
- this.name = "GithubApiError";
13891
- }
13892
- };
14391
+ const githubAppInstallations = pgTable("github_app_installations", {
14392
+ id: text("id").primaryKey(),
14393
+ installationId: bigint("installation_id", { mode: "number" }).notNull(),
14394
+ accountType: text("account_type").$type().notNull(),
14395
+ accountLogin: text("account_login").notNull(),
14396
+ accountGithubId: bigint("account_github_id", { mode: "number" }).notNull(),
14397
+ hubOrganizationId: text("hub_organization_id").references(() => organizations.id, { onDelete: "set null" }),
14398
+ permissions: jsonb("permissions").$type().notNull(),
14399
+ events: jsonb("events").$type().notNull(),
14400
+ suspendedAt: timestamp("suspended_at", { withTimezone: true }),
14401
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
14402
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
14403
+ }, (table) => [
14404
+ uniqueIndex("uq_github_app_installations_installation_id").on(table.installationId),
14405
+ uniqueIndex("uq_github_app_installations_hub_org").on(table.hubOrganizationId),
14406
+ index("idx_github_app_installations_account").on(table.accountGithubId),
14407
+ check("ck_github_app_installations_account_type", sql`${table.accountType} IN ('User', 'Organization')`)
14408
+ ]);
14409
+ /** Postgres `unique_violation` SQLSTATE — emitted on UNIQUE constraint trips. */
14410
+ const PG_UNIQUE_VIOLATION$1 = "23505";
14411
+ function isUniqueViolation(err) {
14412
+ return (err?.code ?? err?.cause?.code) === PG_UNIQUE_VIOLATION$1;
14413
+ }
13893
14414
  /**
13894
- * Fetch the authenticated user's accessible repositories. Used by the
13895
- * Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
14415
+ * UPSERT by `installation_id`. INSERTs a new row when the installation
14416
+ * is unseen; UPDATEs the metadata fields on re-install / permission
14417
+ * change / event-subscription change.
14418
+ *
14419
+ * Does NOT touch `hub_organization_id` on UPDATE — that column is
14420
+ * managed by `bindInstallationToOrg`. Otherwise a webhook arriving
14421
+ * after a manual rebind could clobber the binding back to null.
13896
14422
  */
13897
- async function listUserRepos(accessToken, opts = {}) {
13898
- const fetcher = opts.fetcher ?? fetch;
13899
- const perPage = opts.perPage ?? 100;
13900
- const maxPages = opts.maxPages ?? 3;
13901
- const out = [];
13902
- for (let page = 1; page <= maxPages; page++) {
13903
- const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
13904
- Authorization: `Bearer ${accessToken}`,
13905
- Accept: "application/vnd.github+json"
13906
- } });
13907
- if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
13908
- const rows = await res.json();
13909
- for (const r of rows) out.push({
13910
- fullName: r.full_name,
13911
- cloneUrl: r.clone_url,
13912
- htmlUrl: r.html_url,
13913
- private: r.private,
13914
- defaultBranch: r.default_branch ?? null,
13915
- pushedAt: r.pushed_at ?? null
13916
- });
13917
- if (rows.length < perPage) break;
14423
+ async function upsertInstallationFromMetadata(db, input) {
14424
+ const now = /* @__PURE__ */ new Date();
14425
+ const suspendedAt = input.installation.suspendedAt ? new Date(input.installation.suspendedAt) : null;
14426
+ const values = {
14427
+ id: uuidv7(),
14428
+ installationId: input.installation.id,
14429
+ accountType: input.installation.accountType,
14430
+ accountLogin: input.installation.accountLogin,
14431
+ accountGithubId: input.installation.accountGithubId,
14432
+ hubOrganizationId: input.hubOrganizationId ?? null,
14433
+ permissions: input.installation.permissions,
14434
+ events: input.installation.events,
14435
+ suspendedAt,
14436
+ createdAt: now,
14437
+ updatedAt: now
14438
+ };
14439
+ const [row] = await db.insert(githubAppInstallations).values(values).onConflictDoUpdate({
14440
+ target: githubAppInstallations.installationId,
14441
+ set: {
14442
+ accountType: values.accountType,
14443
+ accountLogin: values.accountLogin,
14444
+ accountGithubId: values.accountGithubId,
14445
+ permissions: values.permissions,
14446
+ events: values.events,
14447
+ suspendedAt: values.suspendedAt,
14448
+ updatedAt: now
14449
+ }
14450
+ }).returning();
14451
+ if (!row) throw new Error("upsertInstallationFromMetadata: INSERT returned no row");
14452
+ return row;
14453
+ }
14454
+ /**
14455
+ * Bind an installation to a Hub team. Idempotent: re-binding to the same
14456
+ * org is a no-op at the row level.
14457
+ *
14458
+ * Race-safe (codex P0-3): the previous SELECT-then-UPDATE implementation
14459
+ * had a TOCTOU window — two concurrent callbacks for the same unbound
14460
+ * installation but different Hub orgs could both see `hubOrganizationId
14461
+ * IS NULL`, both pass the in-memory validation, and then the second
14462
+ * UPDATE would silently rebind. This implementation:
14463
+ *
14464
+ * 1. Runs a conditional UPDATE: WHERE installation_id = $1 AND
14465
+ * (hub_organization_id IS NULL OR hub_organization_id = $2).
14466
+ * Postgres serializes the rowlock so the second concurrent caller
14467
+ * sees the freshly-set value and the WHERE clause filters it out
14468
+ * — the UPDATE matches 0 rows for the loser.
14469
+ * 2. On 0 rows updated, SELECTs the current row to decide which
14470
+ * structured error to throw (not-found vs. already-bound-elsewhere).
14471
+ * 3. Catches the 23505 path that fires when two ROWS get rebound to
14472
+ * the SAME hub_organization_id (covers the case where org A
14473
+ * already has installation X bound and a different callback tries
14474
+ * to bind installation Y to org A — the UPDATE on Y succeeds the
14475
+ * WHERE filter but violates UNIQUE(hub_organization_id)).
14476
+ * Surfaces as a clean ConflictError instead of a 23505 leaking
14477
+ * through the route layer.
14478
+ *
14479
+ * Throws:
14480
+ * - NotFoundError if no installation row exists with installationId.
14481
+ * - ConflictError if (a) the installation is already bound to a
14482
+ * different Hub team (D2 1:1), or (b) the target Hub team is
14483
+ * already bound to a different installation.
14484
+ *
14485
+ * Returns `true` on any successful UPDATE — fresh bind and idempotent
14486
+ * re-bind both succeed identically and we don't pay the extra SELECT to
14487
+ * tell them apart. The boolean exists for forward-compat with callers
14488
+ * that may want to surface a "freshly bound" log line; today both paths
14489
+ * leave the row in the same state, so the value is advisory.
14490
+ */
14491
+ async function bindInstallationToOrg(db, installationId, hubOrganizationId) {
14492
+ let updatedCount;
14493
+ try {
14494
+ updatedCount = (await db.update(githubAppInstallations).set({
14495
+ hubOrganizationId,
14496
+ updatedAt: /* @__PURE__ */ new Date()
14497
+ }).where(and(eq(githubAppInstallations.installationId, installationId), or(isNull(githubAppInstallations.hubOrganizationId), eq(githubAppInstallations.hubOrganizationId, hubOrganizationId)))).returning({ id: githubAppInstallations.id })).length;
14498
+ } catch (err) {
14499
+ if (isUniqueViolation(err)) throw new ConflictError("Hub team is already bound to a different GitHub installation. Uninstall the existing one from GitHub first, or transfer the binding from Settings.");
14500
+ throw err;
13918
14501
  }
13919
- return out;
14502
+ if (updatedCount === 0) {
14503
+ const [row] = await db.select({ hubOrganizationId: githubAppInstallations.hubOrganizationId }).from(githubAppInstallations).where(eq(githubAppInstallations.installationId, installationId)).limit(1);
14504
+ if (!row) throw new NotFoundError(`No installation row for installation_id=${installationId}`);
14505
+ throw new ConflictError(`Installation ${installationId} is already bound to a different Hub team — refusing to rebind (D2 1:1).`);
14506
+ }
14507
+ return true;
14508
+ }
14509
+ /**
14510
+ * Lookup the installation bound to a Hub team. Used by Settings →
14511
+ * Integrations to render the connected-account panel. Returns null when
14512
+ * no install is bound.
14513
+ *
14514
+ * `LIMIT 1` is belt-and-braces — UNIQUE(hub_organization_id) already
14515
+ * guarantees at most one row.
14516
+ */
14517
+ async function findInstallationByOrg(db, hubOrganizationId) {
14518
+ const [row] = await db.select().from(githubAppInstallations).where(eq(githubAppInstallations.hubOrganizationId, hubOrganizationId)).limit(1);
14519
+ return row ?? null;
14520
+ }
14521
+ /**
14522
+ * List the installations for a GitHub account that aren't bound to any Hub
14523
+ * team yet. Newest-first.
14524
+ *
14525
+ * The orphan-recovery path (codex P1-5 + H1): if the OAuth callback's
14526
+ * `upsertInstallationFromMetadata` lands but the follow-up
14527
+ * `bindInstallationToOrg` fails (transient DB error, a racing invite that
14528
+ * errors out, …), the row sits unbound forever — GitHub only puts
14529
+ * `installation_id` in the redirect on the *initial* install, so a later
14530
+ * sign-in never re-attempts the bind. On every subsequent sign-in we sweep
14531
+ * for unbound rows whose `accountGithubId` matches the user's own GitHub
14532
+ * account and auto-claim the single one (and surface a manual "Claim
14533
+ * install" button when there are several).
14534
+ */
14535
+ async function findUnboundInstallationsByAccount(db, accountGithubId) {
14536
+ return db.select().from(githubAppInstallations).where(and(eq(githubAppInstallations.accountGithubId, accountGithubId), isNull(githubAppInstallations.hubOrganizationId))).orderBy(desc(githubAppInstallations.createdAt));
13920
14537
  }
13921
14538
  /** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
13922
14539
  async function ensureMembership(db, data) {
@@ -14048,6 +14665,22 @@ async function pickPrimaryMembership(db, userId) {
14048
14665
  return (await listActiveMemberships(db, userId))[0] ?? null;
14049
14666
  }
14050
14667
  /**
14668
+ * Look up a user's ACTIVE membership in a specific org. Returns null when
14669
+ * the user isn't a member there (or their row is soft-deleted `left`).
14670
+ *
14671
+ * Used by the OAuth callback to re-check that a `targetOrganizationId`
14672
+ * carried in the signed state still names an org the user can administer
14673
+ * before binding a GitHub App installation to it (codex P1-3) — the state
14674
+ * JWT lives ~10min, long enough for a membership to be revoked.
14675
+ */
14676
+ async function findActiveMembership(db, userId, organizationId) {
14677
+ const [row] = await db.select({
14678
+ memberId: members.id,
14679
+ role: members.role
14680
+ }).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
14681
+ return row ?? null;
14682
+ }
14683
+ /**
14051
14684
  * Mark `members.status='left'` for the given member. v1 simplification:
14052
14685
  * no "must transfer admin" check — the proposal accepts the trade-off
14053
14686
  * (last admin allowed to leave, leaves an orphan team) and the cleanup is
@@ -14121,14 +14754,16 @@ const OAUTH_STATE_COOKIE = "oauth_state_nonce";
14121
14754
  * Sign a fresh state token + return the matching cookie nonce. Caller is
14122
14755
  * responsible for setting the cookie (HttpOnly + Secure in prod).
14123
14756
  */
14124
- async function signOAuthState(jwtSecret, next) {
14757
+ async function signOAuthState(jwtSecret, next, opts = {}) {
14125
14758
  const nonce = randomBytes(NONCE_BYTES).toString("base64url");
14126
14759
  const secret = new TextEncoder().encode(jwtSecret);
14760
+ const claims = {
14761
+ nonce,
14762
+ next
14763
+ };
14764
+ if (opts.targetOrganizationId) claims.targetOrganizationId = opts.targetOrganizationId;
14127
14765
  return {
14128
- token: await new SignJWT({
14129
- nonce,
14130
- next
14131
- }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
14766
+ token: await new SignJWT({ ...claims }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
14132
14767
  nonce
14133
14768
  };
14134
14769
  }
@@ -14152,8 +14787,12 @@ async function verifyOAuthState(jwtSecret, token, cookieNonce) {
14152
14787
  throw new Error("Invalid or expired OAuth state");
14153
14788
  }
14154
14789
  if (typeof payload.nonce !== "string" || typeof payload.next !== "string") throw new Error("OAuth state payload malformed");
14790
+ if (payload.targetOrganizationId !== void 0 && typeof payload.targetOrganizationId !== "string") throw new Error("OAuth state payload malformed");
14155
14791
  if (!cookieNonce || cookieNonce !== payload.nonce) throw new Error("OAuth state nonce / cookie mismatch");
14156
- return { next: payload.next };
14792
+ return {
14793
+ next: payload.next,
14794
+ ...payload.targetOrganizationId ? { targetOrganizationId: payload.targetOrganizationId } : {}
14795
+ };
14157
14796
  }
14158
14797
  /**
14159
14798
  * Resolve the hub's public-facing base URL.
@@ -14205,7 +14844,15 @@ function buildCookie(opts) {
14205
14844
  return parts.join("; ");
14206
14845
  }
14207
14846
  /**
14208
- * GitHub OAuth surface. All routes are public (no member JWT required).
14847
+ * GitHub sign-in surface. All routes are public (no member JWT required).
14848
+ *
14849
+ * Single flow post-D3 cutover: the GitHub App authorize URL drives both
14850
+ * sign-in and install (D1). Returning users with an existing install
14851
+ * skip the install dialog and just get `code + state`; first-time
14852
+ * installers get `code + state + installation_id`. The legacy OAuth-App
14853
+ * path that lived alongside this until D3 has been removed.
14854
+ *
14855
+ * `dev-callback` bypasses GitHub entirely; gated to non-production.
14209
14856
  *
14210
14857
  * Routes:
14211
14858
  * - GET /auth/github/start — sign state JWT + cookie + 302 to GitHub
@@ -14213,15 +14860,15 @@ function buildCookie(opts) {
14213
14860
  * - GET /auth/github/dev-callback — dev-only stub (no GitHub round-trip)
14214
14861
  */
14215
14862
  async function githubOauthRoutes(app) {
14216
- const oauthCfg = app.config.oauth?.github;
14217
- if (!oauthCfg) app.log.info("GitHub OAuth not configured — /auth/github/start will return 503. Set FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID/_SECRET to enable.");
14863
+ const appCfg = app.config.oauth?.githubApp;
14864
+ if (!appCfg) app.log.info("GitHub App not configured — /auth/github/start will return 503. Set FIRST_TREE_HUB_GITHUB_APP_* to enable.");
14218
14865
  app.get("/start", { config: { rateLimit: {
14219
14866
  max: 20,
14220
14867
  timeWindow: "1 minute"
14221
14868
  } } }, async (request, reply) => {
14222
14869
  const { next } = githubStartQuerySchema.parse(request.query);
14223
14870
  const safeNext = safeRedirectPath(next ?? null);
14224
- if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
14871
+ if (!appCfg) return reply.status(503).send({ error: "GitHub App is not configured on this hub" });
14225
14872
  const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, safeNext);
14226
14873
  const isProd = process.env.NODE_ENV === "production";
14227
14874
  reply.header("Set-Cookie", buildCookie({
@@ -14231,22 +14878,22 @@ async function githubOauthRoutes(app) {
14231
14878
  secure: isProd
14232
14879
  }));
14233
14880
  const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
14234
- const params = new URLSearchParams({
14235
- client_id: oauthCfg.clientId,
14236
- redirect_uri: redirectUri,
14237
- state: token,
14238
- scope: "read:user user:email repo",
14239
- allow_signup: "true"
14240
- });
14241
- return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
14881
+ return reply.redirect(buildAppAuthorizeUrl({
14882
+ clientId: appCfg.clientId,
14883
+ redirectUri,
14884
+ state: token
14885
+ }), 302);
14242
14886
  });
14243
14887
  app.get("/callback", async (request, reply) => {
14244
- if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
14245
- const { code, state } = githubCallbackQuerySchema.parse(request.query);
14888
+ if (!appCfg) return reply.status(503).send({ error: "GitHub App is not configured on this hub" });
14889
+ const { code, state, installation_id: installationIdRaw } = githubCallbackQuerySchema.parse(request.query);
14246
14890
  const cookieNonce = parseCookieHeader(request.headers.cookie, OAUTH_STATE_COOKIE);
14247
14891
  let next;
14892
+ let targetOrganizationId = null;
14248
14893
  try {
14249
- next = (await verifyOAuthState(app.config.secrets.jwtSecret, state, cookieNonce)).next;
14894
+ const verified = await verifyOAuthState(app.config.secrets.jwtSecret, state, cookieNonce);
14895
+ next = verified.next;
14896
+ targetOrganizationId = verified.targetOrganizationId ?? null;
14250
14897
  } catch (err) {
14251
14898
  const msg = err instanceof Error ? err.message : "OAuth state rejected";
14252
14899
  return reply.status(401).send({ error: msg });
@@ -14259,40 +14906,128 @@ async function githubOauthRoutes(app) {
14259
14906
  }));
14260
14907
  const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
14261
14908
  let profile;
14262
- let accessToken;
14909
+ let tokens;
14910
+ let plaintextUserAccessToken;
14911
+ let installationId = null;
14263
14912
  try {
14264
- const result = await exchangeCodeForProfile({
14265
- clientId: oauthCfg.clientId,
14266
- clientSecret: oauthCfg.clientSecret
14267
- }, code, redirectUri);
14913
+ const result = await exchangeCodeForAppUserProfile({
14914
+ clientId: appCfg.clientId,
14915
+ clientSecret: appCfg.clientSecret,
14916
+ code,
14917
+ redirectUri,
14918
+ installationId: installationIdRaw ? Number(installationIdRaw) : null
14919
+ });
14268
14920
  profile = result.profile;
14269
- accessToken = result.accessToken;
14921
+ plaintextUserAccessToken = result.accessToken;
14922
+ tokens = {
14923
+ encryptedAccessToken: encryptValue(result.accessToken, app.config.secrets.encryptionKey),
14924
+ accessTokenExpiresAt: result.accessTokenExpiresAt,
14925
+ encryptedRefreshToken: encryptValue(result.refreshToken, app.config.secrets.encryptionKey),
14926
+ refreshTokenExpiresAt: result.refreshTokenExpiresAt
14927
+ };
14928
+ installationId = result.installationId;
14270
14929
  } catch (err) {
14271
14930
  const msg = err instanceof Error ? err.message : "GitHub exchange failed";
14272
- app.log.warn({ err }, "github oauth code exchange failed");
14931
+ app.log.warn({ err }, "github sign-in code exchange failed");
14273
14932
  return reply.status(401).send({ error: msg });
14274
14933
  }
14275
- return completeOauthFlow(app, request, reply, profile, next, accessToken);
14934
+ if (installationId !== null) try {
14935
+ const allowedIds = await listUserAccessibleInstallationIds(plaintextUserAccessToken);
14936
+ if (!allowedIds.has(installationId)) {
14937
+ app.log.warn({
14938
+ event: "github_app.installation_id_unauthorized",
14939
+ installationId,
14940
+ githubId: profile.githubId,
14941
+ allowedCount: allowedIds.size
14942
+ }, "callback installation_id is not in /user/installations — refusing to bind (attempted hijack?)");
14943
+ installationId = null;
14944
+ }
14945
+ } catch (err) {
14946
+ app.log.warn({
14947
+ err,
14948
+ installationId,
14949
+ githubId: profile.githubId
14950
+ }, "github app /user/installations check failed — refusing to bind to be safe");
14951
+ installationId = null;
14952
+ }
14953
+ if (installationId !== null && appCfg) try {
14954
+ const installation = await fetchInstallation(await createAppJwt({
14955
+ appId: appCfg.appId,
14956
+ privateKeyPem: appCfg.privateKeyPem
14957
+ }), installationId);
14958
+ await upsertInstallationFromMetadata(app.db, { installation });
14959
+ } catch (err) {
14960
+ app.log.warn({
14961
+ err,
14962
+ installationId,
14963
+ githubId: profile.githubId
14964
+ }, "github app install fetch/upsert failed — clearing installation_id, user can retry from Settings");
14965
+ installationId = null;
14966
+ }
14967
+ return completeOauthFlow(app, request, reply, profile, next, tokens, installationId, targetOrganizationId);
14276
14968
  });
14277
14969
  app.get("/dev-callback", async (request, reply) => {
14278
14970
  if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
14971
+ const devCallbackOptIn = process.env.FIRST_TREE_HUB_DEV_CALLBACK_ENABLED;
14972
+ if (devCallbackOptIn !== "1" && devCallbackOptIn !== "true") {
14973
+ app.log.info({ url: request.url }, "dev-callback request refused — FIRST_TREE_HUB_DEV_CALLBACK_ENABLED is not set");
14974
+ return reply.status(404).send({ error: "Not found" });
14975
+ }
14279
14976
  const params = githubDevCallbackQuerySchema.parse(request.query);
14280
14977
  const next = safeRedirectPath(params.next ?? null);
14281
- return completeOauthFlow(app, request, reply, {
14978
+ const profile = {
14282
14979
  githubId: params.githubId,
14283
14980
  login: params.login,
14284
14981
  email: params.email ?? null,
14285
14982
  displayName: params.displayName ?? params.login,
14286
14983
  avatarUrl: null
14287
- }, next, process.env.DEV_GITHUB_PAT?.trim() || null);
14984
+ };
14985
+ const devPat = process.env.DEV_GITHUB_PAT?.trim() || null;
14986
+ const tokens = devPat ? { encryptedAccessToken: encryptValue(devPat, app.config.secrets.encryptionKey) } : {};
14987
+ let devInstallationId = null;
14988
+ if (params.installationId) {
14989
+ devInstallationId = Number(params.installationId);
14990
+ try {
14991
+ await upsertInstallationFromMetadata(app.db, { installation: {
14992
+ id: devInstallationId,
14993
+ accountType: params.installationAccountType ?? "User",
14994
+ accountLogin: params.installationAccountLogin ?? params.login,
14995
+ accountGithubId: Number(params.installationAccountGithubId ?? params.githubId),
14996
+ permissions: {
14997
+ contents: "write",
14998
+ pull_requests: "write",
14999
+ issues: "read",
15000
+ metadata: "read",
15001
+ members: "read"
15002
+ },
15003
+ events: [
15004
+ "issues",
15005
+ "issue_comment",
15006
+ "pull_request",
15007
+ "pull_request_review",
15008
+ "push",
15009
+ "installation",
15010
+ "installation_repositories",
15011
+ "member"
15012
+ ],
15013
+ suspendedAt: null
15014
+ } });
15015
+ } catch (err) {
15016
+ app.log.warn({
15017
+ err,
15018
+ installationId: devInstallationId
15019
+ }, "dev-callback installation stub upsert failed");
15020
+ }
15021
+ }
15022
+ return completeOauthFlow(app, request, reply, profile, next, tokens, devInstallationId, null);
14288
15023
  });
14289
15024
  }
14290
- async function completeOauthFlow(app, request, reply, profile, next, rawAccessToken) {
14291
- const encryptedAccessToken = rawAccessToken ? encryptValue(rawAccessToken, app.config.secrets.encryptionKey) : void 0;
14292
- const { userId } = await findOrCreateUserFromGithub(app.db, profile, { encryptedAccessToken });
15025
+ async function completeOauthFlow(app, request, reply, profile, next, oauthTokens, installationId, targetOrganizationId) {
15026
+ const { userId } = await findOrCreateUserFromGithub(app.db, profile, oauthTokens);
14293
15027
  let joinPath = "returning";
14294
15028
  const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
14295
15029
  let resolved = false;
15030
+ let resolvedOrganizationId = null;
14296
15031
  if (inviteMatch?.[1]) {
14297
15032
  const token = inviteMatch[1];
14298
15033
  const inv = await findActiveByToken(app.db, token);
@@ -14312,24 +15047,69 @@ async function completeOauthFlow(app, request, reply, profile, next, rawAccessTo
14312
15047
  });
14313
15048
  joinPath = "invite";
14314
15049
  resolved = true;
15050
+ resolvedOrganizationId = inv.organizationId;
14315
15051
  next = "/";
14316
- } else if (await pickPrimaryMembership(app.db, userId)) resolved = true;
14317
- else {
14318
- const personal = await createPersonalTeam(app.db, {
14319
- userId,
14320
- loginSeed: profile.login,
14321
- teamDisplayName: `${profile.login}'s team`,
14322
- userDisplayName: profile.displayName?.trim() || profile.login
14323
- });
14324
- joinPath = "solo";
15052
+ } else if (targetOrganizationId) {
15053
+ const membership = await findActiveMembership(app.db, userId, targetOrganizationId);
15054
+ if (!membership || membership.role !== "admin") return reply.status(403).send({ error: "Not an admin of the organization this installation targets" });
14325
15055
  resolved = true;
14326
- next = "/";
14327
- app.log.info({
14328
- event: "onboarding.team_created",
14329
- userId,
14330
- organizationId: personal.organizationId,
14331
- source: "oauth-bootstrap"
14332
- }, "onboarding funnel: team auto-created at OAuth bootstrap");
15056
+ resolvedOrganizationId = targetOrganizationId;
15057
+ } else {
15058
+ const primary = await pickPrimaryMembership(app.db, userId);
15059
+ if (primary) {
15060
+ resolved = true;
15061
+ resolvedOrganizationId = primary.organizationId;
15062
+ } else {
15063
+ const personal = await createPersonalTeam(app.db, {
15064
+ userId,
15065
+ loginSeed: profile.login,
15066
+ teamDisplayName: `${profile.login}'s team`,
15067
+ userDisplayName: profile.displayName?.trim() || profile.login
15068
+ });
15069
+ joinPath = "solo";
15070
+ resolved = true;
15071
+ resolvedOrganizationId = personal.organizationId;
15072
+ next = "/";
15073
+ app.log.info({
15074
+ event: "onboarding.team_created",
15075
+ userId,
15076
+ organizationId: personal.organizationId,
15077
+ source: "oauth-bootstrap"
15078
+ }, "onboarding funnel: team auto-created at OAuth bootstrap");
15079
+ }
15080
+ }
15081
+ if (installationId !== null && resolvedOrganizationId) try {
15082
+ await bindInstallationToOrg(app.db, installationId, resolvedOrganizationId);
15083
+ } catch (err) {
15084
+ app.log.warn({
15085
+ err,
15086
+ installationId,
15087
+ hubOrganizationId: resolvedOrganizationId,
15088
+ userId
15089
+ }, "github app install bind-to-org failed — sign-in continues; reconcile in Settings");
15090
+ }
15091
+ if (resolvedOrganizationId) try {
15092
+ const orphans = await findUnboundInstallationsByAccount(app.db, Number(profile.githubId));
15093
+ if (orphans.length === 1) {
15094
+ const orphan = orphans[0];
15095
+ if (orphan) await bindInstallationToOrg(app.db, orphan.installationId, resolvedOrganizationId).catch((err) => {
15096
+ app.log.warn({
15097
+ err,
15098
+ installationId: orphan.installationId,
15099
+ hubOrganizationId: resolvedOrganizationId,
15100
+ userId
15101
+ }, "orphan install reclaim failed — operator can retry via POST /claim (UI tracked in #318)");
15102
+ });
15103
+ } else if (orphans.length > 1) app.log.info({
15104
+ count: orphans.length,
15105
+ accountGithubId: Number(profile.githubId),
15106
+ userId
15107
+ }, "multiple unbound installs match this account — skipping auto-claim; operator must POST /claim to pick (UI #318)");
15108
+ } catch (err) {
15109
+ app.log.warn({
15110
+ err,
15111
+ userId
15112
+ }, "orphan install reclaim sweep failed");
14333
15113
  }
14334
15114
  if (!resolved) return reply.status(500).send({ error: "Failed to resolve membership" });
14335
15115
  const tokens = await signTokensForUser(app.config.secrets.jwtSecret, userId, app.config.auth);
@@ -14788,7 +15568,6 @@ async function createMeChat(db, humanAgentId, organizationId, body) {
14788
15568
  const crossOrg = found.filter((a) => a.organizationId !== organizationId);
14789
15569
  if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
14790
15570
  const chatType = distinctIds.length === 1 ? "direct" : "group";
14791
- const isDirectAgentOnly = chatType === "direct" && found.every((a) => a.type !== "human");
14792
15571
  const chatId = randomUUID();
14793
15572
  const topic = body.topic ?? null;
14794
15573
  await db.transaction(async (tx) => {
@@ -14798,11 +15577,9 @@ async function createMeChat(db, humanAgentId, organizationId, body) {
14798
15577
  type: chatType,
14799
15578
  topic
14800
15579
  });
14801
- await tx.insert(chatParticipants).values(allIds.map((agentId) => ({
14802
- chatId,
15580
+ await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
14803
15581
  agentId,
14804
- role: agentId === humanAgentId ? "owner" : "member",
14805
- ...isDirectAgentOnly ? { mode: "mention_only" } : {}
15582
+ role: agentId === humanAgentId ? "owner" : "member"
14806
15583
  })));
14807
15584
  await recomputeChatWatchers(tx, chatId);
14808
15585
  });
@@ -14840,37 +15617,24 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
14840
15617
  await recomputeChatWatchers(tx, chatId);
14841
15618
  return;
14842
15619
  }
14843
- const isUpgradingToGroup = existing.length + toInsert.length >= 3 && chat.type === "direct";
14844
- const isAlreadyGroup = chat.type === "group";
14845
- const isGroupAfter = isUpgradingToGroup || isAlreadyGroup;
14846
- if (isUpgradingToGroup) {
14847
- await tx.update(chats).set({
14848
- type: "group",
14849
- updatedAt: /* @__PURE__ */ new Date()
14850
- }).where(eq(chats.id, chatId));
14851
- const nonHumanIds = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existing.map((e) => e.agentId)), sql`${agents.type} <> 'human'`))).map((a) => a.uuid);
14852
- if (nonHumanIds.length > 0) await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, nonHumanIds)));
14853
- }
15620
+ if (existing.length + toInsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
14854
15621
  const carriedRows = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, toInsert))).returning({
14855
15622
  agentId: chatSubscriptions.agentId,
14856
15623
  lastReadAt: chatSubscriptions.lastReadAt,
14857
15624
  unreadMentionCount: chatSubscriptions.unreadMentionCount
14858
15625
  });
14859
15626
  const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
14860
- const typeByAgent = new Map(found.map((a) => [a.uuid, a.type]));
14861
- await tx.insert(chatParticipants).values(toInsert.map((agentId) => {
14862
- const agentType = typeByAgent.get(agentId);
14863
- const mode = isGroupAfter && agentType !== "human" ? "mention_only" : "full";
15627
+ await addChatParticipants(tx, chatId, toInsert.map((agentId) => {
14864
15628
  const carried = carriedByAgent.get(agentId);
14865
15629
  return {
14866
- chatId,
14867
15630
  agentId,
14868
15631
  role: "member",
14869
- mode,
14870
- lastReadAt: carried?.lastReadAt ?? null,
14871
- unreadMentionCount: carried?.unreadMentionCount ?? 0
15632
+ carriedReadState: carried ? {
15633
+ lastReadAt: carried.lastReadAt,
15634
+ unreadMentionCount: carried.unreadMentionCount
15635
+ } : void 0
14872
15636
  };
14873
- })).onConflictDoNothing();
15637
+ }), { onConflictDoNothing: true });
14874
15638
  await recomputeChatWatchers(tx, chatId);
14875
15639
  });
14876
15640
  invalidateChatAudience(chatId);
@@ -16464,7 +17228,7 @@ async function healthzRoutes(app) {
16464
17228
  * `api/orgs/invitations.ts` (Class B, admin-gated).
16465
17229
  */
16466
17230
  async function publicInvitationRoutes(app) {
16467
- const { previewInvitation } = await import("./invitation-C299fxkP-Dts66QTU.mjs");
17231
+ const { previewInvitation } = await import("./invitation-C299fxkP-DFBBuUcj.mjs");
16468
17232
  app.get("/:token/preview", async (request, reply) => {
16469
17233
  if (!request.params.token) throw new UnauthorizedError("Token required");
16470
17234
  const preview = await previewInvitation(app.db, request.params.token);
@@ -16472,6 +17236,52 @@ async function publicInvitationRoutes(app) {
16472
17236
  });
16473
17237
  }
16474
17238
  /**
17239
+ * Thrown when GitHub's API returns a non-2xx for a token-scoped call.
17240
+ * Carries the HTTP status so callers can distinguish auth failures (401 /
17241
+ * 403 — typically a stale token or a missing scope) from transient upstream
17242
+ * errors.
17243
+ */
17244
+ var GithubApiError = class extends Error {
17245
+ constructor(status, message) {
17246
+ super(message);
17247
+ this.status = status;
17248
+ this.name = "GithubApiError";
17249
+ }
17250
+ };
17251
+ /**
17252
+ * Fetch the authenticated user's accessible repositories. Used by the
17253
+ * Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
17254
+ *
17255
+ * Takes a Bearer-style access token — works the same way whether that
17256
+ * token is a legacy OAuth grant (single-scope `repo`) or an App
17257
+ * user-to-server token (scope is whatever the App declared on its
17258
+ * settings page). The picker has no business distinguishing them.
17259
+ */
17260
+ async function listUserRepos(accessToken, opts = {}) {
17261
+ const fetcher = opts.fetcher ?? fetch;
17262
+ const perPage = opts.perPage ?? 100;
17263
+ const maxPages = opts.maxPages ?? 3;
17264
+ const out = [];
17265
+ for (let page = 1; page <= maxPages; page++) {
17266
+ const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
17267
+ Authorization: `Bearer ${accessToken}`,
17268
+ Accept: "application/vnd.github+json"
17269
+ } });
17270
+ if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
17271
+ const rows = await res.json();
17272
+ for (const r of rows) out.push({
17273
+ fullName: r.full_name,
17274
+ cloneUrl: r.clone_url,
17275
+ htmlUrl: r.html_url,
17276
+ private: r.private,
17277
+ defaultBranch: r.default_branch ?? null,
17278
+ pushedAt: r.pushed_at ?? null
17279
+ });
17280
+ if (rows.length < perPage) break;
17281
+ }
17282
+ return out;
17283
+ }
17284
+ /**
16475
17285
  * `/me` and self-service organization routes (Class A — User-scoped).
16476
17286
  * Mounted under `requireUser` so the JWT only needs `sub = userId`.
16477
17287
  *
@@ -16574,11 +17384,19 @@ async function meRoutes(app) {
16574
17384
  * 503 if the user has no GitHub identity bound or the token wasn't
16575
17385
  * captured (e.g. dev-callback sign-in or pre-redesign user). The web
16576
17386
  * client falls back to a "Reconnect GitHub" hint in that case.
17387
+ *
17388
+ * codex P1-4: GitHub App user-to-server tokens have an ~8h TTL. If
17389
+ * the stored `accessTokenExpiresAt` is past (or within a 60-second
17390
+ * buffer of expiring), trade in the persisted refresh token for a
17391
+ * fresh pair before calling GitHub. Legacy rows without expiry
17392
+ * fields fall through unchanged — the never-expiring OAuth-App token
17393
+ * still works as-is.
16577
17394
  */
16578
17395
  app.get("/me/github/repos", async (request, reply) => {
16579
17396
  const { userId } = requireUser(request);
16580
17397
  const [identity] = await app.db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github"))).limit(1);
16581
- const encrypted = identity?.metadata && typeof identity.metadata === "object" && "accessToken" in identity.metadata ? identity.metadata.accessToken : void 0;
17398
+ const metadata = identity?.metadata && typeof identity.metadata === "object" ? identity.metadata : null;
17399
+ const encrypted = metadata && "accessToken" in metadata ? metadata.accessToken : void 0;
16582
17400
  if (typeof encrypted !== "string" || !encrypted) return reply.status(503).send({ error: "GitHub access token unavailable — please reconnect your account" });
16583
17401
  let token;
16584
17402
  try {
@@ -16586,6 +17404,38 @@ async function meRoutes(app) {
16586
17404
  } catch {
16587
17405
  return reply.status(503).send({ error: "GitHub access token could not be decoded — please reconnect" });
16588
17406
  }
17407
+ const appCfg = app.config.oauth?.githubApp;
17408
+ const expiresAtRaw = metadata && "accessTokenExpiresAt" in metadata ? metadata.accessTokenExpiresAt : void 0;
17409
+ const encryptedRefresh = metadata && "refreshToken" in metadata ? metadata.refreshToken : void 0;
17410
+ if (typeof expiresAtRaw === "string" && typeof encryptedRefresh === "string" && encryptedRefresh && appCfg) {
17411
+ const expiresAt = Date.parse(expiresAtRaw);
17412
+ if (!Number.isNaN(expiresAt) && expiresAt - 6e4 <= Date.now()) try {
17413
+ const refreshPlain = decryptValue(encryptedRefresh, app.config.secrets.encryptionKey);
17414
+ const refreshed = await refreshAppUserToken(appCfg.clientId, appCfg.clientSecret, refreshPlain);
17415
+ const nextMetadata = {
17416
+ ...metadata ?? {},
17417
+ accessToken: encryptValue(refreshed.accessToken, app.config.secrets.encryptionKey),
17418
+ accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
17419
+ refreshToken: encryptValue(refreshed.refreshToken, app.config.secrets.encryptionKey),
17420
+ refreshTokenExpiresAt: refreshed.refreshTokenExpiresAt
17421
+ };
17422
+ await app.db.update(authIdentities).set({
17423
+ metadata: nextMetadata,
17424
+ updatedAt: /* @__PURE__ */ new Date()
17425
+ }).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github")));
17426
+ token = refreshed.accessToken;
17427
+ } catch (err) {
17428
+ app.log.warn({
17429
+ err,
17430
+ userId
17431
+ }, "github app user-token refresh failed");
17432
+ if ((err instanceof GithubAppApiError ? err.status : 503) === 401) return reply.status(403).send({
17433
+ error: "Your GitHub session has expired. Please sign in again.",
17434
+ code: "refresh_failed"
17435
+ });
17436
+ return reply.status(503).send({ error: "Couldn't refresh GitHub credentials. Try again, or reconnect your GitHub account." });
17437
+ }
17438
+ }
16589
17439
  try {
16590
17440
  return { repos: await listUserRepos(token) };
16591
17441
  } catch (err) {
@@ -16644,7 +17494,7 @@ async function meRoutes(app) {
16644
17494
  */
16645
17495
  app.get("/me/pinned-agents", async (request) => {
16646
17496
  const { userId } = requireUser(request);
16647
- const { listMyPinnedAgents } = await import("./client-DSM_opoz-BH5eegXb.mjs");
17497
+ const { listMyPinnedAgents } = await import("./client-GOgUQxVe-Dqk9oZf9.mjs");
16648
17498
  return listMyPinnedAgents(app.db, { userId });
16649
17499
  });
16650
17500
  /**
@@ -17147,6 +17997,94 @@ function orgIdParam(params) {
17147
17997
  return typeof orgId === "string" ? orgId : null;
17148
17998
  }
17149
17999
  /**
18000
+ * Where the post-install OAuth callback lands the user once the install
18001
+ * dialog is done — back on the Settings → GitHub panel so it can
18002
+ * re-render with the now-bound installation. The callback resolves the
18003
+ * actual destination from the signed state JWT, not from a query param,
18004
+ * so this is tamper-proof.
18005
+ */
18006
+ const POST_INSTALL_NEXT = "/settings/github";
18007
+ /**
18008
+ * Class B — `/api/v1/orgs/:orgId/github-app-installation`.
18009
+ *
18010
+ * Read-only admin view of the GitHub App installation bound to this Hub
18011
+ * team. Powers the Settings → Integrations panel. 404 when no install is
18012
+ * bound (the panel renders the "Install on GitHub" prompt in that case).
18013
+ *
18014
+ * Distinct from `/orgs/:orgId/settings/:namespace` because installations
18015
+ * aren't editable through the same PUT/DELETE shape — the row's lifecycle
18016
+ * is driven by GitHub events (install / uninstall / suspend) and the
18017
+ * OAuth callback. The Settings panel surfaces it for visibility but the
18018
+ * write path is upstream.
18019
+ *
18020
+ * Admin-only: the installation block exposes account-level metadata
18021
+ * (login, permissions, events) that a regular member doesn't need.
18022
+ * Mirrors the readPolicy="admin" choice for `github_integration` in the
18023
+ * legacy settings.
18024
+ */
18025
+ async function orgGithubAppRoutes(app) {
18026
+ app.get("/", async (request) => {
18027
+ const scope = await requireOrgAdmin(request, app.db);
18028
+ const row = await findInstallationByOrg(app.db, scope.organizationId);
18029
+ if (!row) throw new NotFoundError("No GitHub App installation is bound to this team");
18030
+ const accountType = row.accountType;
18031
+ const manageUrl = accountType === "Organization" ? `https://github.com/organizations/${encodeURIComponent(row.accountLogin)}/settings/installations/${row.installationId}` : `https://github.com/settings/installations/${row.installationId}`;
18032
+ return {
18033
+ installationId: row.installationId,
18034
+ accountType,
18035
+ accountLogin: row.accountLogin,
18036
+ accountGithubId: row.accountGithubId,
18037
+ permissions: row.permissions,
18038
+ events: row.events,
18039
+ suspended: row.suspendedAt !== null,
18040
+ manageUrl,
18041
+ createdAt: row.createdAt.toISOString(),
18042
+ updatedAt: row.updatedAt.toISOString()
18043
+ };
18044
+ });
18045
+ app.get("/install-url", async (request, reply) => {
18046
+ const scope = await requireOrgAdmin(request, app.db);
18047
+ const appCfg = app.config.oauth?.githubApp;
18048
+ if (!appCfg?.slug) return reply.status(503).send({ error: "GitHub App install URL is unavailable — FIRST_TREE_HUB_GITHUB_APP_SLUG is not configured." });
18049
+ const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, POST_INSTALL_NEXT, { targetOrganizationId: scope.organizationId });
18050
+ reply.header("Set-Cookie", buildCookie({
18051
+ name: OAUTH_STATE_COOKIE,
18052
+ value: nonce,
18053
+ maxAge: 600,
18054
+ secure: process.env.NODE_ENV === "production"
18055
+ }));
18056
+ return { installUrl: buildAppInstallUrl({
18057
+ appSlug: appCfg.slug,
18058
+ state: token
18059
+ }) };
18060
+ });
18061
+ app.post("/claim", async (request) => {
18062
+ const scope = await requireOrgAdmin(request, app.db);
18063
+ const { installationId } = githubAppInstallationClaimBodySchema.parse(request.body);
18064
+ const githubToken = await getStoredGithubAccessToken(app.db, scope.userId, app.config.secrets.encryptionKey);
18065
+ if (!githubToken) throw new ForbiddenError("No GitHub access token on file — sign in with GitHub again before claiming an install");
18066
+ let accessible;
18067
+ try {
18068
+ accessible = await listUserAccessibleInstallationIds(githubToken);
18069
+ } catch (err) {
18070
+ if ((err instanceof GithubAppApiError ? err.status : 0) === 401) throw new ForbiddenError("Your GitHub session has expired — sign in with GitHub again, then retry the claim");
18071
+ app.log.warn({
18072
+ err,
18073
+ installationId,
18074
+ userId: scope.userId
18075
+ }, "claim: /user/installations check failed");
18076
+ throw new ForbiddenError("Couldn't verify GitHub access for this installation — try again in a moment");
18077
+ }
18078
+ if (!accessible.has(installationId)) throw new ForbiddenError("You don't administer this installation on GitHub");
18079
+ await bindInstallationToOrg(app.db, installationId, scope.organizationId);
18080
+ return {
18081
+ installationId,
18082
+ organizationId: scope.organizationId,
18083
+ bound: true
18084
+ };
18085
+ });
18086
+ }
18087
+ /**
17150
18088
  * Class B — `/api/v1/orgs/:orgId` itself: read & rename the org row.
17151
18089
  * Replaces the deleted `/admin/organizations/:id` GET/PATCH pair.
17152
18090
  */
@@ -18028,7 +18966,7 @@ async function insertMappingIfAbsent(db, params) {
18028
18966
  * - direct agent-only chats automatically get `mode=mention_only`
18029
18967
  * - watcher rows are recomputed
18030
18968
  * - a future addParticipant call would upgrade the chat to `group` via
18031
- * `maybeUpgradeDirectToGroup` instead of raw INSERT shortcuts
18969
+ * the server's `changeChatType` service instead of raw INSERT shortcuts
18032
18970
  */
18033
18971
  async function createEntityChat(db, humanAgentId, delegateAgentId, entity, eventType, action) {
18034
18972
  const metadata = chatMetadataSchema$1.parse({
@@ -18447,6 +19385,40 @@ const MENTION_ACTIONS = {
18447
19385
  discussion_comment: ["created"],
18448
19386
  commit_comment: ["created"]
18449
19387
  };
19388
+ /**
19389
+ * Boot-time configuration sanity checks. Called from `buildApp` so BOTH
19390
+ * server entry points are covered:
19391
+ * - `packages/server/src/index.ts` (the standalone bin)
19392
+ * - `packages/command/src/core/server.ts` (the CLI `server start` path)
19393
+ *
19394
+ * The previous incarnation lived in `index.ts` and was only run by the
19395
+ * standalone bin — the CLI path could boot a misconfigured prod with no
19396
+ * surface complaint (codex P1-8).
19397
+ *
19398
+ * Throws on misconfiguration; never returns a value.
19399
+ */
19400
+ function assertBootConfigValid(config) {
19401
+ assertProductionRequiresPublicUrl(config);
19402
+ assertGithubAppConfigComplete(config);
19403
+ }
19404
+ function assertProductionRequiresPublicUrl(config) {
19405
+ if (process.env.NODE_ENV === "production" && !config.server.publicUrl) throw new Error("FIRST_TREE_HUB_PUBLIC_URL is required in production — set the public-facing hub URL.");
19406
+ }
19407
+ function assertGithubAppConfigComplete(config) {
19408
+ const ghApp = config.oauth?.githubApp;
19409
+ if (!ghApp) return;
19410
+ const required = {
19411
+ FIRST_TREE_HUB_GITHUB_APP_ID: ghApp.appId,
19412
+ FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID: ghApp.clientId,
19413
+ FIRST_TREE_HUB_GITHUB_APP_CLIENT_SECRET: ghApp.clientSecret,
19414
+ FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY: ghApp.privateKeyPem,
19415
+ FIRST_TREE_HUB_GITHUB_APP_WEBHOOK_SECRET: ghApp.webhookSecret
19416
+ };
19417
+ const missing = Object.entries(required).filter(([, v]) => !v || v.trim().length === 0).map(([k]) => k);
19418
+ if (missing.length > 0 && missing.length < Object.keys(required).length) throw new Error(`GitHub App is half-configured — missing env vars: ${missing.join(", ")}. Set all five or none.`);
19419
+ if (missing.length === Object.keys(required).length) throw new Error("GitHub App env block is present but every value is empty — unset the FIRST_TREE_HUB_GITHUB_APP_* vars to disable App sign-in.");
19420
+ if (ghApp.privateKeyPem && !ghApp.privateKeyPem.includes("-----BEGIN PRIVATE KEY-----")) throw new Error("FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY does not look like a PKCS#8 PEM — expected `-----BEGIN PRIVATE KEY-----` header. If the value came from a single-line env file, replace literal `\\n` with real newlines.");
19421
+ }
18450
19422
  var schema_exports = /* @__PURE__ */ __exportAll({
18451
19423
  adapterAgentMappings: () => adapterAgentMappings,
18452
19424
  adapterChatMappings: () => adapterChatMappings,
@@ -18461,6 +19433,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
18461
19433
  chatSubscriptions: () => chatSubscriptions,
18462
19434
  chats: () => chats,
18463
19435
  clients: () => clients,
19436
+ githubAppInstallations: () => githubAppInstallations,
18464
19437
  githubEntityChatMappings: () => githubEntityChatMappings,
18465
19438
  inboxEntries: () => inboxEntries,
18466
19439
  invitationRedemptions: () => invitationRedemptions,
@@ -19795,6 +20768,7 @@ async function buildApp(config) {
19795
20768
  const msg = err instanceof Error ? err.message : String(err);
19796
20769
  throw new Error(`${msg} — check FIRST_TREE_HUB_AUTH_*_EXPIRY env vars (got access=${config.auth.accessTokenExpiry}, refresh=${config.auth.refreshTokenExpiry}, connect=${config.auth.connectTokenExpiry}).`);
19797
20770
  }
20771
+ assertBootConfigValid(config);
19798
20772
  applyLoggerConfig({
19799
20773
  level: config.observability.logging.level,
19800
20774
  format: config.observability.logging.format,
@@ -19971,6 +20945,7 @@ async function buildApp(config) {
19971
20945
  await scope.register(orgInvitationRoutes, { prefix: "/invitations" });
19972
20946
  await scope.register(orgMemberRoutes, { prefix: "/members" });
19973
20947
  await scope.register(orgSettingsRoutes, { prefix: "/settings" });
20948
+ await scope.register(orgGithubAppRoutes, { prefix: "/github-app-installation" });
19974
20949
  await scope.register(orgContextTreeSnapshotRoutes, { prefix: "/context-tree" });
19975
20950
  }), { prefix: "/orgs/:orgId" });
19976
20951
  await api.register(orgWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/orgs/:orgId/ws" });