@agent-team-foundation/first-tree-hub 0.12.5 → 0.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{bootstrap-C_K2CKXC.mjs → bootstrap-BCZC1ki6.mjs} +15 -5
- package/dist/cli/index.mjs +7 -7
- package/dist/{client-DSM_opoz-BH5eegXb.mjs → client-93HZWg84-MIPzQD9A.mjs} +2 -2
- package/dist/{client-DL5vHhvQ-CnYGq2x-.mjs → client-h5l7mi0m-OEX7MOBg.mjs} +163 -85
- package/dist/{dist-BwPlBZWi.mjs → dist-CTkhS6p5.mjs} +128 -9
- package/dist/drizzle/0037_github_app_installations.sql +52 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/{feishu-CKGzIamp.mjs → feishu-DJm0EaZP.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/{invitation-C299fxkP-Dts66QTU.mjs → invitation-C299fxkP-jQiGR5fl.mjs} +1 -1
- package/dist/{saas-connect-DYjvx5yr.mjs → saas-connect-CY2NxeKx.mjs} +1274 -202
- package/dist/web/assets/index-BKbK8BhK.css +1 -0
- package/dist/web/assets/index-BNM-YSSu.js +421 -0
- package/dist/web/assets/index-JGwkYWtM.js +11 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BXDLOc-s.js +0 -406
- package/dist/web/assets/index-CbOOQaWp.css +0 -1
- package/dist/web/assets/index-Dyo6TAWC.js +0 -16
|
@@ -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-
|
|
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 {
|
|
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-CTkhS6p5.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
|
|
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-h5l7mi0m-OEX7MOBg.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";
|
|
@@ -570,10 +570,15 @@ const runtimeStateSchema = z.enum([
|
|
|
570
570
|
z.enum([
|
|
571
571
|
"active",
|
|
572
572
|
"suspended",
|
|
573
|
-
"evicted"
|
|
573
|
+
"evicted",
|
|
574
|
+
"errored"
|
|
574
575
|
]);
|
|
575
576
|
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
576
|
-
const clientSessionStateSchema = z.enum([
|
|
577
|
+
const clientSessionStateSchema = z.enum([
|
|
578
|
+
"active",
|
|
579
|
+
"suspended",
|
|
580
|
+
"errored"
|
|
581
|
+
]);
|
|
577
582
|
z.object({
|
|
578
583
|
chatId: z.string().min(1),
|
|
579
584
|
state: clientSessionStateSchema
|
|
@@ -791,10 +796,7 @@ z.object({
|
|
|
791
796
|
firstMessagePreview: z.string().nullable()
|
|
792
797
|
});
|
|
793
798
|
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
|
-
});
|
|
799
|
+
z.object({ agentId: z.string().min(1) });
|
|
798
800
|
z.object({ agentId: z.string().min(1) });
|
|
799
801
|
const clientStatusSchema = z.enum(["connected", "disconnected"]);
|
|
800
802
|
/**
|
|
@@ -978,6 +980,45 @@ z.object({
|
|
|
978
980
|
edges: z.array(contextTreeEdgeSchema),
|
|
979
981
|
changes: z.array(contextTreeChangeSchema)
|
|
980
982
|
});
|
|
983
|
+
const githubAccountTypeSchema = z.enum(["User", "Organization"]);
|
|
984
|
+
const githubPermissionLevelSchema = z.enum([
|
|
985
|
+
"read",
|
|
986
|
+
"write",
|
|
987
|
+
"admin"
|
|
988
|
+
]);
|
|
989
|
+
/**
|
|
990
|
+
* `installation.permissions` blob from GitHub. Key is the permission name
|
|
991
|
+
* (`contents`, `pull_requests`, `issues`, `members`, …) — we keep this as a
|
|
992
|
+
* free-form `z.record` because GitHub adds new permission keys over time
|
|
993
|
+
* and we don't want a Hub-side `app_id` upgrade just to surface a new key
|
|
994
|
+
* in the integrations panel.
|
|
995
|
+
*/
|
|
996
|
+
const githubAppInstallationPermissionsSchema = z.record(z.string(), githubPermissionLevelSchema);
|
|
997
|
+
/**
|
|
998
|
+
* Subscribed event-name list, e.g. `["issues", "pull_request", "push"]`.
|
|
999
|
+
* Free-form for the same forward-compat reason as `permissions`.
|
|
1000
|
+
*/
|
|
1001
|
+
const githubAppInstallationEventsSchema = z.array(z.string());
|
|
1002
|
+
z.object({
|
|
1003
|
+
login: z.string().optional(),
|
|
1004
|
+
accessToken: z.string().optional(),
|
|
1005
|
+
accessTokenExpiresAt: z.string().datetime({ offset: true }).optional(),
|
|
1006
|
+
refreshToken: z.string().optional(),
|
|
1007
|
+
refreshTokenExpiresAt: z.string().datetime({ offset: true }).optional()
|
|
1008
|
+
});
|
|
1009
|
+
z.object({ installationId: z.number().int().positive() });
|
|
1010
|
+
z.object({
|
|
1011
|
+
installationId: z.number().int().positive(),
|
|
1012
|
+
accountType: githubAccountTypeSchema,
|
|
1013
|
+
accountLogin: z.string(),
|
|
1014
|
+
accountGithubId: z.number().int().positive(),
|
|
1015
|
+
permissions: githubAppInstallationPermissionsSchema,
|
|
1016
|
+
events: githubAppInstallationEventsSchema,
|
|
1017
|
+
suspended: z.boolean(),
|
|
1018
|
+
manageUrl: z.string().url(),
|
|
1019
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
1020
|
+
updatedAt: z.string().datetime({ offset: true })
|
|
1021
|
+
});
|
|
981
1022
|
/**
|
|
982
1023
|
* MIME types the web + client image paths recognise. Kept in sync with
|
|
983
1024
|
* Claude's vision API (see packages/client/src/handlers/claude-code.ts).
|
|
@@ -1358,14 +1399,24 @@ z.object({
|
|
|
1358
1399
|
z.object({ next: z.string().max(256).optional() });
|
|
1359
1400
|
z.object({
|
|
1360
1401
|
code: z.string().min(1),
|
|
1361
|
-
state: z.string().min(1)
|
|
1402
|
+
state: z.string().min(1),
|
|
1403
|
+
installation_id: z.string().regex(/^\d+$/).optional(),
|
|
1404
|
+
setup_action: z.enum([
|
|
1405
|
+
"install",
|
|
1406
|
+
"update",
|
|
1407
|
+
"request"
|
|
1408
|
+
]).optional()
|
|
1362
1409
|
});
|
|
1363
1410
|
z.object({
|
|
1364
1411
|
githubId: z.string().min(1),
|
|
1365
1412
|
login: z.string().min(1),
|
|
1366
1413
|
email: z.string().email().optional(),
|
|
1367
1414
|
displayName: z.string().optional(),
|
|
1368
|
-
next: z.string().max(256).optional()
|
|
1415
|
+
next: z.string().max(256).optional(),
|
|
1416
|
+
installationId: z.string().regex(/^\d+$/).optional(),
|
|
1417
|
+
installationAccountType: z.enum(["User", "Organization"]).optional(),
|
|
1418
|
+
installationAccountLogin: z.string().min(1).optional(),
|
|
1419
|
+
installationAccountGithubId: z.string().regex(/^\d+$/).optional()
|
|
1369
1420
|
});
|
|
1370
1421
|
/**
|
|
1371
1422
|
* Per-organization settings — schemas, namespaces, and the registry that
|
|
@@ -1860,12 +1911,22 @@ defineConfig({
|
|
|
1860
1911
|
}),
|
|
1861
1912
|
githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
|
|
1862
1913
|
}),
|
|
1863
|
-
oauth: optional({
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1914
|
+
oauth: optional({ githubApp: optional({
|
|
1915
|
+
appId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_ID" }),
|
|
1916
|
+
clientId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID" }),
|
|
1917
|
+
clientSecret: field(z.string().min(1), {
|
|
1918
|
+
env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_SECRET",
|
|
1867
1919
|
secret: true
|
|
1868
|
-
})
|
|
1920
|
+
}),
|
|
1921
|
+
privateKeyPem: field(z.string().min(1), {
|
|
1922
|
+
env: "FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY",
|
|
1923
|
+
secret: true
|
|
1924
|
+
}),
|
|
1925
|
+
webhookSecret: field(z.string().min(1), {
|
|
1926
|
+
env: "FIRST_TREE_HUB_GITHUB_APP_WEBHOOK_SECRET",
|
|
1927
|
+
secret: true
|
|
1928
|
+
}),
|
|
1929
|
+
slug: field(z.string().min(1).optional(), { env: "FIRST_TREE_HUB_GITHUB_APP_SLUG" })
|
|
1869
1930
|
}) }),
|
|
1870
1931
|
cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
|
|
1871
1932
|
trustProxy: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_TRUST_PROXY" }),
|
|
@@ -1966,6 +2027,74 @@ async function writeImage(params) {
|
|
|
1966
2027
|
return path;
|
|
1967
2028
|
}
|
|
1968
2029
|
const FETCH_TIMEOUT_MS = 15e3;
|
|
2030
|
+
/**
|
|
2031
|
+
* Node-level error codes (undici / DNS / TCP) treated as transient by the
|
|
2032
|
+
* `doFetch` retry layer. The set covers the failure modes that a *brief*
|
|
2033
|
+
* network blip can produce mid-request:
|
|
2034
|
+
*
|
|
2035
|
+
* - `ECONNRESET` — TCP RST mid-stream (commonly a keep-alive idle
|
|
2036
|
+
* connection closed by the peer and reused before we
|
|
2037
|
+
* noticed)
|
|
2038
|
+
* - `ETIMEDOUT` — kernel-level connect/read timeout (peer slow, not
|
|
2039
|
+
* absent)
|
|
2040
|
+
* - `ENETUNREACH` — transient routing-table flap (local network reload,
|
|
2041
|
+
* wifi roam)
|
|
2042
|
+
* - `EAI_AGAIN` — DNS resolver returned a temporary failure; the
|
|
2043
|
+
* resolver itself tells us to retry
|
|
2044
|
+
* - `UND_ERR_SOCKET` — undici's internal socket-level error, wrapping the
|
|
2045
|
+
* above when the request happens through its
|
|
2046
|
+
* connection pool
|
|
2047
|
+
*
|
|
2048
|
+
* Deliberately **not** retried at this layer:
|
|
2049
|
+
* - `ECONNREFUSED` — peer is reachable but refusing; retrying immediately
|
|
2050
|
+
* won't fix anything, and the caller's higher-level
|
|
2051
|
+
* reconnect logic is the right response
|
|
2052
|
+
* - `ENOTFOUND` — DNS reports the host doesn't exist (typo or rotated
|
|
2053
|
+
* record); a 1s retry isn't going to materialise the
|
|
2054
|
+
* record
|
|
2055
|
+
* - other 4xx-class HTTP statuses — handled by the caller, never reach
|
|
2056
|
+
* this set
|
|
2057
|
+
*/
|
|
2058
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
2059
|
+
"ECONNRESET",
|
|
2060
|
+
"ETIMEDOUT",
|
|
2061
|
+
"ENETUNREACH",
|
|
2062
|
+
"EAI_AGAIN",
|
|
2063
|
+
"UND_ERR_SOCKET"
|
|
2064
|
+
]);
|
|
2065
|
+
function sleep$1(ms) {
|
|
2066
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Decide whether an error thrown by `fetch()` represents a transient
|
|
2070
|
+
* network-layer failure that the caller should retry.
|
|
2071
|
+
*
|
|
2072
|
+
* Walks the `cause` chain (undici nests the real reason one level deep via
|
|
2073
|
+
* `TypeError("fetch failed").cause`) and checks each link for:
|
|
2074
|
+
* - `message` containing `"fetch failed"` (undici's signature)
|
|
2075
|
+
* - `name === "AbortError"` (our 15s `AbortSignal.timeout`)
|
|
2076
|
+
* - `code` ∈ `RETRYABLE_NETWORK_CODES`
|
|
2077
|
+
*
|
|
2078
|
+
* `unknown` input is intentional: this function is the gatekeeper for the
|
|
2079
|
+
* retry decision in `doFetch`'s catch block, where TS sees `unknown`.
|
|
2080
|
+
*
|
|
2081
|
+
* Depth is bounded (~5) to defend against the pathological case of a
|
|
2082
|
+
* self-referencing cause chain.
|
|
2083
|
+
*/
|
|
2084
|
+
function isTransientNetworkError(err) {
|
|
2085
|
+
let current = err;
|
|
2086
|
+
let depth = 0;
|
|
2087
|
+
while (current !== null && current !== void 0 && depth < 5) {
|
|
2088
|
+
if (typeof current !== "object") return false;
|
|
2089
|
+
const obj = current;
|
|
2090
|
+
if (typeof obj.message === "string" && obj.message.includes("fetch failed")) return true;
|
|
2091
|
+
if (obj.name === "AbortError") return true;
|
|
2092
|
+
if (typeof obj.code === "string" && RETRYABLE_NETWORK_CODES.has(obj.code)) return true;
|
|
2093
|
+
current = obj.cause;
|
|
2094
|
+
depth++;
|
|
2095
|
+
}
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
1969
2098
|
var FirstTreeHubSDK = class {
|
|
1970
2099
|
_baseUrl;
|
|
1971
2100
|
getAccessToken;
|
|
@@ -2087,7 +2216,58 @@ var FirstTreeHubSDK = class {
|
|
|
2087
2216
|
if (!response.ok) throw await this.toSdkError(response);
|
|
2088
2217
|
return await response.json();
|
|
2089
2218
|
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Retry transient network-layer failures and HTTP 5xx with a fixed backoff
|
|
2221
|
+
* schedule. Short-term fix for the chat-visible `Result forward failed:
|
|
2222
|
+
* fetch failed` errors (see docs/sdk-fetch-retry-design.md): undici's
|
|
2223
|
+
* "fetch failed" / `AbortError` / `ECONNRESET`-class errors and any 5xx
|
|
2224
|
+
* response trigger up to two retries with `[0, 500ms, 1000ms]` spacing,
|
|
2225
|
+
* adding at most ~1.5s of latency beyond the per-attempt 15s timeout.
|
|
2226
|
+
*
|
|
2227
|
+
* 4xx responses and non-network exceptions are returned/thrown unchanged
|
|
2228
|
+
* — they indicate a deterministic failure that retrying cannot fix.
|
|
2229
|
+
*
|
|
2230
|
+
* Idempotency caveat: `sendMessage` is not natively idempotent, so a
|
|
2231
|
+
* `fetch failed` from a request the server actually committed will
|
|
2232
|
+
* produce a duplicate message on retry. The design accepts this for now
|
|
2233
|
+
* (small window, low rate, mitigated long-term by an Outbox pattern with
|
|
2234
|
+
* client-generated UUIDs).
|
|
2235
|
+
*
|
|
2236
|
+
* The retry signature and externally-visible behaviour match `doFetch`'s
|
|
2237
|
+
* pre-retry contract: callers see the same Response on success or the
|
|
2238
|
+
* same error type on terminal failure.
|
|
2239
|
+
*/
|
|
2090
2240
|
async doFetch(path, init) {
|
|
2241
|
+
const delays = [
|
|
2242
|
+
0,
|
|
2243
|
+
500,
|
|
2244
|
+
1e3
|
|
2245
|
+
];
|
|
2246
|
+
let lastErr;
|
|
2247
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
2248
|
+
const delay = delays[attempt];
|
|
2249
|
+
if (delay !== void 0 && delay > 0) await sleep$1(delay);
|
|
2250
|
+
try {
|
|
2251
|
+
const response = await this.doFetchOnce(path, init);
|
|
2252
|
+
const isLastAttempt = attempt === delays.length - 1;
|
|
2253
|
+
if (response.status >= 500 && !isLastAttempt) {
|
|
2254
|
+
console.warn(`sdk: retry attempt=${attempt + 1} reason=http-${response.status} path=${path}`);
|
|
2255
|
+
lastErr = /* @__PURE__ */ new Error(`HTTP ${response.status}`);
|
|
2256
|
+
continue;
|
|
2257
|
+
}
|
|
2258
|
+
return response;
|
|
2259
|
+
} catch (err) {
|
|
2260
|
+
lastErr = err;
|
|
2261
|
+
if (!isTransientNetworkError(err)) throw err;
|
|
2262
|
+
if (!(attempt === delays.length - 1)) {
|
|
2263
|
+
const reason = err instanceof Error ? err.name === "AbortError" ? "timeout" : err.message.slice(0, 60) : "unknown";
|
|
2264
|
+
console.warn(`sdk: retry attempt=${attempt + 1} reason=${reason} path=${path}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
throw lastErr;
|
|
2269
|
+
}
|
|
2270
|
+
async doFetchOnce(path, init) {
|
|
2091
2271
|
const url = `${this._baseUrl}${path}`;
|
|
2092
2272
|
const headers = { Authorization: `Bearer ${await this.getAccessToken()}` };
|
|
2093
2273
|
if (this._agentId) headers[AGENT_SELECTOR_HEADER] = this._agentId;
|
|
@@ -3178,8 +3358,26 @@ function normalizePath(rawPath) {
|
|
|
3178
3358
|
function shortHash(input) {
|
|
3179
3359
|
return createHash("sha256").update(input).digest("hex").slice(0, 8);
|
|
3180
3360
|
}
|
|
3181
|
-
|
|
3182
|
-
|
|
3361
|
+
/**
|
|
3362
|
+
* Branch name a session's worktree attaches to. The hash inputs include the
|
|
3363
|
+
* agent dimension because `(chat, url)` alone is not unique: two agents that
|
|
3364
|
+
* share a chat each open their own worktree at
|
|
3365
|
+
* `<workspaces>/<agent>/<chatId>/...`, and git refuses to point two worktrees
|
|
3366
|
+
* at the same branch (`fatal: '<branch>' is already used by worktree at …`).
|
|
3367
|
+
* Hash inputs are joined with `:` so `(chatA, agentB)` cannot collide with
|
|
3368
|
+
* `(chatAB, "")`.
|
|
3369
|
+
*
|
|
3370
|
+
* The caller picks `agentName`. Prefer the operator-stable name
|
|
3371
|
+
* (`config.yaml::agents.<name>`); fall back to `agent.agentId` (a UUID,
|
|
3372
|
+
* globally unique) when the stable name isn't available. Anything stable
|
|
3373
|
+
* across `start` and `resume` for the same `(agent, chat)` pair will do —
|
|
3374
|
+
* the contract is "no collision with a peer agent in the same chat", not
|
|
3375
|
+
* "human-readable in the branch name".
|
|
3376
|
+
*
|
|
3377
|
+
* See docs/workspace-session-branch-collision-fix-design.md §3.2.
|
|
3378
|
+
*/
|
|
3379
|
+
function deriveSessionBranchName(sessionKey, agentName, url) {
|
|
3380
|
+
return `${SESSION_BRANCH_PREFIX}-${shortHash(`${sessionKey}:${agentName}`)}-${shortHash(url)}`;
|
|
3183
3381
|
}
|
|
3184
3382
|
/**
|
|
3185
3383
|
* A value is SHA-like when it's a 7–40 character hex string. Used to decide
|
|
@@ -3591,12 +3789,12 @@ function createGitMirrorManager(opts) {
|
|
|
3591
3789
|
}
|
|
3592
3790
|
});
|
|
3593
3791
|
},
|
|
3594
|
-
createWorktree({ url, ref, targetPath, sessionKey }) {
|
|
3792
|
+
createWorktree({ url, ref, targetPath, sessionKey, agentName }) {
|
|
3595
3793
|
return withUrlLock(url, async () => {
|
|
3596
3794
|
const mirror = mirrorDir(url);
|
|
3597
3795
|
if (!existsSync(join(mirror, "HEAD"))) throw new GitMirrorError(`Cannot create worktree — no mirror exists for "${url}"`);
|
|
3598
3796
|
const absTarget = resolve(targetPath);
|
|
3599
|
-
const branchName = deriveSessionBranchName(sessionKey, url);
|
|
3797
|
+
const branchName = deriveSessionBranchName(sessionKey, agentName, url);
|
|
3600
3798
|
if (existsSync(absTarget) && !isHubManagedWorktree(absTarget)) {
|
|
3601
3799
|
log?.warn({
|
|
3602
3800
|
gitUrl: url,
|
|
@@ -3937,15 +4135,53 @@ var InputController = class {
|
|
|
3937
4135
|
};
|
|
3938
4136
|
const DEFAULT_WORKSPACE_TTL_MS = 10080 * 60 * 1e3;
|
|
3939
4137
|
/**
|
|
4138
|
+
* Sentinel that flags "stage-2 of session bootstrap (git worktree
|
|
4139
|
+
* materialisation) completed successfully". Distinct from the
|
|
4140
|
+
* `FIRST_TREE_WORKSPACE_MARKER` (`.first-tree-workspace`) which is the
|
|
4141
|
+
* "agent workspace boundary" — Codex's `project_root_markers` uses that one
|
|
4142
|
+
* to stop walking up the filesystem when looking for `AGENTS.md`. Splitting
|
|
4143
|
+
* the two so the boundary marker can be written eagerly (stage 1) while the
|
|
4144
|
+
* completion sentinel only appears after stage 2 lets `acquireWorkspace`
|
|
4145
|
+
* detect half-baked directories from a previous failed start and self-heal.
|
|
4146
|
+
*
|
|
4147
|
+
* See docs/workspace-session-branch-collision-fix-design.md §3.4.
|
|
4148
|
+
*/
|
|
4149
|
+
const INIT_COMPLETE_SENTINEL_REL = join(".agent", "init-complete");
|
|
4150
|
+
/**
|
|
3940
4151
|
* Acquire a per-chat workspace directory.
|
|
3941
|
-
*
|
|
4152
|
+
*
|
|
4153
|
+
* Healing rule: if the directory exists AND carries the boundary marker
|
|
4154
|
+
* (`.first-tree-workspace`, written in stage 1) AND is missing the
|
|
4155
|
+
* completion sentinel (`.agent/init-complete`, written after stage 2), the
|
|
4156
|
+
* previous session start crashed between the two writes — wipe it so the
|
|
4157
|
+
* fresh start gets a clean slate. The boundary marker alone (without the
|
|
4158
|
+
* sentinel) is the unambiguous shape of a half-baked workspace: only stage 1
|
|
4159
|
+
* writes it, and only stage 2 writes the sentinel.
|
|
3942
4160
|
*/
|
|
3943
4161
|
function acquireWorkspace(workspaceRoot, chatId) {
|
|
3944
4162
|
const dir = join(workspaceRoot, chatId);
|
|
4163
|
+
if (existsSync(dir) && existsSync(join(dir, ".first-tree-workspace")) && !existsSync(join(dir, INIT_COMPLETE_SENTINEL_REL))) rmSync(dir, {
|
|
4164
|
+
recursive: true,
|
|
4165
|
+
force: true
|
|
4166
|
+
});
|
|
3945
4167
|
mkdirSync(dir, { recursive: true });
|
|
3946
4168
|
return dir;
|
|
3947
4169
|
}
|
|
3948
4170
|
/**
|
|
4171
|
+
* Write the stage-2 completion sentinel. Callers must invoke this AFTER all
|
|
4172
|
+
* pre-handler-spawn setup (workspace bootstrap, git worktrees, first-tree
|
|
4173
|
+
* integration) succeeded, so a process that crashes earlier leaves a
|
|
4174
|
+
* half-baked workspace the next acquireWorkspace can heal.
|
|
4175
|
+
*/
|
|
4176
|
+
function markWorkspaceInitComplete(workspaceCwd) {
|
|
4177
|
+
const path = join(workspaceCwd, INIT_COMPLETE_SENTINEL_REL);
|
|
4178
|
+
mkdirSync(join(workspaceCwd, ".agent"), { recursive: true });
|
|
4179
|
+
writeFileSync(path, JSON.stringify({
|
|
4180
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4181
|
+
schemaVersion: 1
|
|
4182
|
+
}), "utf-8");
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
3949
4185
|
* Clean stale workspace directories for an agent.
|
|
3950
4186
|
*
|
|
3951
4187
|
* A workspace is considered stale when:
|
|
@@ -4689,12 +4925,13 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4689
4925
|
const mirror = await gitMirrorManager.ensureMirror(repo.url);
|
|
4690
4926
|
if (mirror.cloned) sessionCtx.log(`Git: cloned ${repo.url} in ${mirror.elapsedMs}ms`);
|
|
4691
4927
|
await gitMirrorManager.fetchMirror(repo.url);
|
|
4928
|
+
const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
|
|
4692
4929
|
if (existsSync(targetPath) && isHubWorktreeMarker(targetPath)) {
|
|
4693
4930
|
sessionCtx.log(`Git: reusing existing worktree at ${localPath}`);
|
|
4694
4931
|
ownedWorktrees.push({
|
|
4695
4932
|
url: repo.url,
|
|
4696
4933
|
path: targetPath,
|
|
4697
|
-
branchName: deriveSessionBranchName(sessionCtx.chatId, repo.url)
|
|
4934
|
+
branchName: deriveSessionBranchName(sessionCtx.chatId, branchAgentKey, repo.url)
|
|
4698
4935
|
});
|
|
4699
4936
|
continue;
|
|
4700
4937
|
}
|
|
@@ -4702,7 +4939,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4702
4939
|
url: repo.url,
|
|
4703
4940
|
ref: repo.ref,
|
|
4704
4941
|
targetPath,
|
|
4705
|
-
sessionKey: sessionCtx.chatId
|
|
4942
|
+
sessionKey: sessionCtx.chatId,
|
|
4943
|
+
agentName: branchAgentKey
|
|
4706
4944
|
});
|
|
4707
4945
|
ownedWorktrees.push({
|
|
4708
4946
|
url: repo.url,
|
|
@@ -4751,6 +4989,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4751
4989
|
runBootstrap(cwd, sessionCtx);
|
|
4752
4990
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
4753
4991
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
4992
|
+
markWorkspaceInitComplete(cwd);
|
|
4754
4993
|
sessionCtx.log(`Starting session (${claudeSessionId}), cwd=${cwd}, permissionMode=${config.permissionMode ?? "bypassPermissions"}`);
|
|
4755
4994
|
spawnQuery(claudeSessionId, sessionCtx);
|
|
4756
4995
|
const sdkMsg = await toSDKUserMessage(message, sessionCtx, claudeSessionId);
|
|
@@ -4763,9 +5002,10 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4763
5002
|
claudeSessionId = sessionId;
|
|
4764
5003
|
retryCount = 0;
|
|
4765
5004
|
cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
4766
|
-
if (!existsSync(join(cwd,
|
|
5005
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) runBootstrap(cwd, sessionCtx);
|
|
4767
5006
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
4768
5007
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
5008
|
+
markWorkspaceInitComplete(cwd);
|
|
4769
5009
|
sessionCtx.log(`Resuming session (${sessionId}), cwd=${cwd}`);
|
|
4770
5010
|
spawnQuery(sessionId, sessionCtx, sessionId);
|
|
4771
5011
|
if (message) inputController?.push(await toSDKUserMessage(message, sessionCtx, sessionId));
|
|
@@ -4982,8 +5222,9 @@ const createCodexHandler = (config) => {
|
|
|
4982
5222
|
function toCodexInput(message, sessionCtx) {
|
|
4983
5223
|
return sessionCtx.formatInboundContent(message).then((text) => text);
|
|
4984
5224
|
}
|
|
4985
|
-
async function prepareGitWorktrees(payload, workspaceCwd,
|
|
5225
|
+
async function prepareGitWorktrees(payload, workspaceCwd, sessionCtx) {
|
|
4986
5226
|
if (!gitMirrorManager) return;
|
|
5227
|
+
const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
|
|
4987
5228
|
for (const repo of payload.gitRepos) {
|
|
4988
5229
|
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
4989
5230
|
if (!localPath) continue;
|
|
@@ -4996,7 +5237,8 @@ const createCodexHandler = (config) => {
|
|
|
4996
5237
|
url: repo.url,
|
|
4997
5238
|
ref: repo.ref,
|
|
4998
5239
|
targetPath,
|
|
4999
|
-
sessionKey: chatId
|
|
5240
|
+
sessionKey: sessionCtx.chatId,
|
|
5241
|
+
agentName: branchAgentKey
|
|
5000
5242
|
});
|
|
5001
5243
|
ownedWorktrees.push({
|
|
5002
5244
|
url: repo.url,
|
|
@@ -5238,7 +5480,8 @@ const createCodexHandler = (config) => {
|
|
|
5238
5480
|
}
|
|
5239
5481
|
});
|
|
5240
5482
|
ensureFirstTreeBinding(cwd, sessionCtx);
|
|
5241
|
-
await prepareGitWorktrees(payload, cwd, sessionCtx
|
|
5483
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx);
|
|
5484
|
+
markWorkspaceInitComplete(cwd);
|
|
5242
5485
|
codex = new Codex({
|
|
5243
5486
|
env: buildEnv(sessionCtx),
|
|
5244
5487
|
config: buildCodexConfig(payload)
|
|
@@ -5262,7 +5505,7 @@ const createCodexHandler = (config) => {
|
|
|
5262
5505
|
env: [],
|
|
5263
5506
|
gitRepos: []
|
|
5264
5507
|
};
|
|
5265
|
-
if (!existsSync(join(cwd,
|
|
5508
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) {
|
|
5266
5509
|
bootstrapWorkspace({
|
|
5267
5510
|
workspacePath: cwd,
|
|
5268
5511
|
identity: sessionCtx.agent,
|
|
@@ -5276,7 +5519,8 @@ const createCodexHandler = (config) => {
|
|
|
5276
5519
|
});
|
|
5277
5520
|
ensureFirstTreeBinding(cwd, sessionCtx);
|
|
5278
5521
|
}
|
|
5279
|
-
await prepareGitWorktrees(payload, cwd, sessionCtx
|
|
5522
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx);
|
|
5523
|
+
markWorkspaceInitComplete(cwd);
|
|
5280
5524
|
codex = new Codex({
|
|
5281
5525
|
env: buildEnv(sessionCtx),
|
|
5282
5526
|
config: buildCodexConfig(payload)
|
|
@@ -5936,11 +6180,24 @@ var SessionManager = class {
|
|
|
5936
6180
|
this.persistRegistry();
|
|
5937
6181
|
this.notifySessionState(chatId, "active");
|
|
5938
6182
|
} catch (err) {
|
|
6183
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
6184
|
+
const phase = evicted ? "resume" : "start";
|
|
5939
6185
|
this.config.log.error({
|
|
5940
6186
|
chatId,
|
|
5941
6187
|
err,
|
|
5942
|
-
phase
|
|
6188
|
+
phase
|
|
5943
6189
|
}, "session start/resume failed");
|
|
6190
|
+
this.notifySessionState(chatId, "errored");
|
|
6191
|
+
try {
|
|
6192
|
+
const preview = errMsg.slice(0, 800);
|
|
6193
|
+
const userMsg = `⚠️ Session ${phase} failed (${this.config.agentIdentity.displayName ?? this.config.agentIdentity.agentId}): ${preview}`;
|
|
6194
|
+
await ctx.forwardResult(userMsg);
|
|
6195
|
+
} catch (forwardErr) {
|
|
6196
|
+
this.config.log.warn({
|
|
6197
|
+
chatId,
|
|
6198
|
+
forwardErr
|
|
6199
|
+
}, "session error forward failed");
|
|
6200
|
+
}
|
|
5944
6201
|
this.sessions.delete(chatId);
|
|
5945
6202
|
this.sessionRuntimeStates.delete(chatId);
|
|
5946
6203
|
this.recomputeRuntimeState();
|
|
@@ -5972,11 +6229,25 @@ var SessionManager = class {
|
|
|
5972
6229
|
this.persistRegistry();
|
|
5973
6230
|
this.notifySessionState(entry.chatId, "active");
|
|
5974
6231
|
} catch (err) {
|
|
5975
|
-
|
|
6232
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
6233
|
+
this.config.log.error({
|
|
5976
6234
|
chatId: entry.chatId,
|
|
5977
6235
|
err
|
|
5978
6236
|
}, "resume failed");
|
|
5979
|
-
entry.
|
|
6237
|
+
this.notifySessionState(entry.chatId, "errored");
|
|
6238
|
+
try {
|
|
6239
|
+
const preview = errMsg.slice(0, 800);
|
|
6240
|
+
const userMsg = `⚠️ Session resume failed (${this.config.agentIdentity.displayName ?? this.config.agentIdentity.agentId}): ${preview}`;
|
|
6241
|
+
await ctx.forwardResult(userMsg);
|
|
6242
|
+
} catch (forwardErr) {
|
|
6243
|
+
this.config.log.warn({
|
|
6244
|
+
chatId: entry.chatId,
|
|
6245
|
+
forwardErr
|
|
6246
|
+
}, "session error forward failed");
|
|
6247
|
+
}
|
|
6248
|
+
this.sessions.delete(entry.chatId);
|
|
6249
|
+
this.sessionRuntimeStates.delete(entry.chatId);
|
|
6250
|
+
this.recomputeRuntimeState();
|
|
5980
6251
|
this._activeCount--;
|
|
5981
6252
|
}
|
|
5982
6253
|
}
|
|
@@ -9151,7 +9422,7 @@ async function onboardCreate(args) {
|
|
|
9151
9422
|
}
|
|
9152
9423
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
9153
9424
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
9154
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9425
|
+
const { bindFeishuBot } = await import("./feishu-DJm0EaZP.mjs").then((n) => n.r);
|
|
9155
9426
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
9156
9427
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
9157
9428
|
else {
|
|
@@ -10364,7 +10635,7 @@ function createFeedbackHandler(config) {
|
|
|
10364
10635
|
return { handle };
|
|
10365
10636
|
}
|
|
10366
10637
|
//#endregion
|
|
10367
|
-
//#region ../server/dist/app-
|
|
10638
|
+
//#region ../server/dist/app-BGjMcVXo.mjs
|
|
10368
10639
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
10369
10640
|
init_esm();
|
|
10370
10641
|
var __defProp = Object.defineProperty;
|
|
@@ -10749,7 +11020,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
10749
11020
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
10750
11021
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10751
11022
|
}
|
|
10752
|
-
const log$
|
|
11023
|
+
const log$6 = createLogger$1("Adapters");
|
|
10753
11024
|
function parseId(raw) {
|
|
10754
11025
|
const id = Number(raw);
|
|
10755
11026
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -10775,7 +11046,7 @@ async function adapterRoutes(app) {
|
|
|
10775
11046
|
const existing = await getAdapterConfig(app.db, id);
|
|
10776
11047
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10777
11048
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
10778
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11049
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after update"));
|
|
10779
11050
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10780
11051
|
return {
|
|
10781
11052
|
...config,
|
|
@@ -10789,7 +11060,7 @@ async function adapterRoutes(app) {
|
|
|
10789
11060
|
const existing = await getAdapterConfig(app.db, id);
|
|
10790
11061
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10791
11062
|
await deleteAdapterConfig(app.db, id);
|
|
10792
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11063
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after delete"));
|
|
10793
11064
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10794
11065
|
return reply.status(204).send();
|
|
10795
11066
|
});
|
|
@@ -10805,6 +11076,7 @@ function requireAgent(request) {
|
|
|
10805
11076
|
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
10806
11077
|
return agent;
|
|
10807
11078
|
}
|
|
11079
|
+
const log$5 = createLogger$1("AgentChatsRoute");
|
|
10808
11080
|
function serializeChat(chat) {
|
|
10809
11081
|
return {
|
|
10810
11082
|
...chat,
|
|
@@ -10866,6 +11138,15 @@ async function agentChatRoutes(app) {
|
|
|
10866
11138
|
});
|
|
10867
11139
|
app.post("/:chatId/participants", async (request, reply) => {
|
|
10868
11140
|
const identity = requireAgent(request);
|
|
11141
|
+
if (request.body !== null && typeof request.body === "object" && "mode" in request.body) {
|
|
11142
|
+
log$5.warn({
|
|
11143
|
+
code: "MODE_FIELD_DEPRECATED",
|
|
11144
|
+
chatId: request.params.chatId,
|
|
11145
|
+
senderAgentId: identity.uuid,
|
|
11146
|
+
userAgent: request.headers["user-agent"] ?? "unknown"
|
|
11147
|
+
}, "Rejected: addParticipant body contains deprecated `mode` field");
|
|
11148
|
+
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." });
|
|
11149
|
+
}
|
|
10869
11150
|
const body = addParticipantSchema.parse(request.body);
|
|
10870
11151
|
const participants = await addParticipant(app.db, request.params.chatId, identity.uuid, body);
|
|
10871
11152
|
return reply.status(201).send(participants.map((p) => ({
|
|
@@ -11542,23 +11823,16 @@ async function findOrCreateChatForChannel(db, data) {
|
|
|
11542
11823
|
lifecyclePolicy: "adapter_managed",
|
|
11543
11824
|
metadata
|
|
11544
11825
|
});
|
|
11545
|
-
|
|
11546
|
-
chatId,
|
|
11826
|
+
await addChatParticipants(tx, chatId, data.botAgentId === data.senderAgentId ? [{
|
|
11547
11827
|
agentId: data.botAgentId,
|
|
11548
|
-
role: "member"
|
|
11549
|
-
mode: "full"
|
|
11828
|
+
role: "member"
|
|
11550
11829
|
}] : [{
|
|
11551
|
-
chatId,
|
|
11552
11830
|
agentId: data.botAgentId,
|
|
11553
|
-
role: "member"
|
|
11554
|
-
mode: "full"
|
|
11831
|
+
role: "member"
|
|
11555
11832
|
}, {
|
|
11556
|
-
chatId,
|
|
11557
11833
|
agentId: data.senderAgentId,
|
|
11558
|
-
role: "member"
|
|
11559
|
-
|
|
11560
|
-
}];
|
|
11561
|
-
await tx.insert(chatParticipants).values(participants);
|
|
11834
|
+
role: "member"
|
|
11835
|
+
}]);
|
|
11562
11836
|
await tx.insert(adapterChatMappings).values({
|
|
11563
11837
|
platform: data.platform,
|
|
11564
11838
|
externalChannelId: data.externalChannelId,
|
|
@@ -11569,14 +11843,18 @@ async function findOrCreateChatForChannel(db, data) {
|
|
|
11569
11843
|
return chatId;
|
|
11570
11844
|
});
|
|
11571
11845
|
}
|
|
11572
|
-
/**
|
|
11846
|
+
/**
|
|
11847
|
+
* Ensure an agent is a participant of a chat (no-op if already). Mode is
|
|
11848
|
+
* derived via the canonical entrypoint — pre-fix this also wrote `mode:`
|
|
11849
|
+
* implicitly via schema default `'full'`, which is wrong for non-human
|
|
11850
|
+
* agents in a group chat (the bug §1.1 of the Phase 1 design doc fixes).
|
|
11851
|
+
*/
|
|
11573
11852
|
async function ensureParticipant(db, chatId, agentId) {
|
|
11574
11853
|
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
|
|
11576
|
-
chatId,
|
|
11854
|
+
if (!exists) await addChatParticipants(db, chatId, [{
|
|
11577
11855
|
agentId,
|
|
11578
11856
|
role: "member"
|
|
11579
|
-
}
|
|
11857
|
+
}], { onConflictDoNothing: true });
|
|
11580
11858
|
}
|
|
11581
11859
|
/** Store a cross-reference between internal and external message. */
|
|
11582
11860
|
async function createMessageReference(db, data) {
|
|
@@ -13745,6 +14023,12 @@ const authIdentities = pgTable("auth_identities", {
|
|
|
13745
14023
|
* 32-byte random string. The bcrypt comparison in `authService.login`
|
|
13746
14024
|
* treats it as a plain string and rejects every password — that's the
|
|
13747
14025
|
* intended behaviour: SaaS users cannot fall back to password login.
|
|
14026
|
+
*
|
|
14027
|
+
* `tokens` carries the full App user-to-server bundle when called from the
|
|
14028
|
+
* App OAuth callback, or just `encryptedAccessToken` when called from the
|
|
14029
|
+
* legacy OAuth callback or `dev-callback`. Missing fields are simply not
|
|
14030
|
+
* written — service code that reads `metadata` MUST tolerate the legacy
|
|
14031
|
+
* shape (only `accessToken`), per `auth-identities.ts` jsdoc.
|
|
13748
14032
|
*/
|
|
13749
14033
|
async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
13750
14034
|
const [existing] = await db.select({
|
|
@@ -13754,10 +14038,10 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
|
13754
14038
|
if (existing) {
|
|
13755
14039
|
const patch = {};
|
|
13756
14040
|
if (profile.email) patch.email = profile.email;
|
|
13757
|
-
|
|
14041
|
+
const tokenPatch = buildTokenMetadataPatch(profile, opts);
|
|
14042
|
+
if (tokenPatch) patch.metadata = {
|
|
13758
14043
|
...existing.metadata ?? {},
|
|
13759
|
-
|
|
13760
|
-
login: profile.login
|
|
14044
|
+
...tokenPatch
|
|
13761
14045
|
};
|
|
13762
14046
|
if (Object.keys(patch).length > 0) await db.update(authIdentities).set({
|
|
13763
14047
|
...patch,
|
|
@@ -13777,7 +14061,7 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
|
13777
14061
|
avatarUrl: profile.avatarUrl ?? null
|
|
13778
14062
|
});
|
|
13779
14063
|
const metadata = { login: profile.login };
|
|
13780
|
-
|
|
14064
|
+
Object.assign(metadata, buildTokenMetadataPatch(profile, opts) ?? {});
|
|
13781
14065
|
await tx.insert(authIdentities).values({
|
|
13782
14066
|
id: uuidv7(),
|
|
13783
14067
|
userId,
|
|
@@ -13790,8 +14074,50 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
|
13790
14074
|
});
|
|
13791
14075
|
return { userId };
|
|
13792
14076
|
}
|
|
14077
|
+
/**
|
|
14078
|
+
* Decrypt the GitHub user-to-server access token persisted on this user's
|
|
14079
|
+
* `auth_identities.metadata`, or `null` when there's no GitHub identity,
|
|
14080
|
+
* no captured token (e.g. `dev-callback` sign-in), or the ciphertext can't
|
|
14081
|
+
* be decoded.
|
|
14082
|
+
*
|
|
14083
|
+
* Does NOT refresh an expired App token — callers that need a guaranteed-
|
|
14084
|
+
* fresh token (the Step 2 repo picker) do the refresh dance inline; callers
|
|
14085
|
+
* that just want a best-effort identity check (the manual install-claim
|
|
14086
|
+
* endpoint) tolerate a stale token failing the downstream GitHub call.
|
|
14087
|
+
*/
|
|
14088
|
+
async function getStoredGithubAccessToken(db, userId, encryptionKey) {
|
|
14089
|
+
const [identity] = await db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.userId, userId))).limit(1);
|
|
14090
|
+
const meta = identity?.metadata && typeof identity.metadata === "object" ? identity.metadata : null;
|
|
14091
|
+
const encrypted = meta && typeof meta.accessToken === "string" && meta.accessToken ? meta.accessToken : null;
|
|
14092
|
+
if (!encrypted) return null;
|
|
14093
|
+
try {
|
|
14094
|
+
return decryptValue(encrypted, encryptionKey);
|
|
14095
|
+
} catch {
|
|
14096
|
+
return null;
|
|
14097
|
+
}
|
|
14098
|
+
}
|
|
14099
|
+
/**
|
|
14100
|
+
* Pluck the token-related keys out of `opts` into the shape that lands on
|
|
14101
|
+
* `auth_identities.metadata`. Returns `null` when no token data was
|
|
14102
|
+
* supplied so the caller can skip the metadata write entirely.
|
|
14103
|
+
*
|
|
14104
|
+
* `login` is always refreshed when any token field is touched, so a user
|
|
14105
|
+
* who renames their GitHub account gets the new login snapshot on next
|
|
14106
|
+
* sign-in.
|
|
14107
|
+
*/
|
|
14108
|
+
function buildTokenMetadataPatch(profile, opts) {
|
|
14109
|
+
if (!opts.encryptedAccessToken) return null;
|
|
14110
|
+
const patch = {
|
|
14111
|
+
login: profile.login,
|
|
14112
|
+
accessToken: opts.encryptedAccessToken
|
|
14113
|
+
};
|
|
14114
|
+
if (opts.accessTokenExpiresAt) patch.accessTokenExpiresAt = opts.accessTokenExpiresAt;
|
|
14115
|
+
if (opts.encryptedRefreshToken) patch.refreshToken = opts.encryptedRefreshToken;
|
|
14116
|
+
if (opts.refreshTokenExpiresAt) patch.refreshTokenExpiresAt = opts.refreshTokenExpiresAt;
|
|
14117
|
+
return patch;
|
|
14118
|
+
}
|
|
13793
14119
|
/** Postgres `unique_violation` SQLSTATE — emitted when a UNIQUE constraint trips. */
|
|
13794
|
-
const PG_UNIQUE_VIOLATION$
|
|
14120
|
+
const PG_UNIQUE_VIOLATION$2 = "23505";
|
|
13795
14121
|
/**
|
|
13796
14122
|
* Pick a candidate username, attempt the caller's INSERT in a transaction,
|
|
13797
14123
|
* and retry under a fresh disambiguator if the UNIQUE(users.username)
|
|
@@ -13810,7 +14136,7 @@ async function insertWithUsernameRetry(db, base, insert) {
|
|
|
13810
14136
|
});
|
|
13811
14137
|
return;
|
|
13812
14138
|
} catch (err) {
|
|
13813
|
-
if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$
|
|
14139
|
+
if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$2) throw err;
|
|
13814
14140
|
candidate = `${base}-${randomBytes(2).toString("hex")}`;
|
|
13815
14141
|
}
|
|
13816
14142
|
candidate = `${base}-${uuidv7().slice(0, 12)}`;
|
|
@@ -13818,47 +14144,297 @@ async function insertWithUsernameRetry(db, base, insert) {
|
|
|
13818
14144
|
await insert(tx, candidate);
|
|
13819
14145
|
});
|
|
13820
14146
|
}
|
|
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
14147
|
/**
|
|
13825
|
-
*
|
|
14148
|
+
* GitHub App service helpers. Two surfaces that ride on top of an App's
|
|
14149
|
+
* private key + OAuth client credentials:
|
|
14150
|
+
*
|
|
14151
|
+
* App-private-key (server-to-server):
|
|
14152
|
+
* - `createAppJwt` — short-lived (≤10min) JWT identifying
|
|
14153
|
+
* Hub-as-this-App to GitHub.
|
|
14154
|
+
* - `mintInstallationToken` — exchange the App JWT for a per-installation
|
|
14155
|
+
* token (~1h TTL). Used when Hub acts as the
|
|
14156
|
+
* App on a tenant's repos (Phase 4 identity
|
|
14157
|
+
* convergence — not yet wired into request
|
|
14158
|
+
* paths).
|
|
14159
|
+
*
|
|
14160
|
+
* App-OAuth (user-to-server, replaces the legacy OAuth App flow):
|
|
14161
|
+
* - `buildAppAuthorizeUrl` — the start URL for the combined OAuth
|
|
14162
|
+
* + install flow (design doc D1).
|
|
14163
|
+
* - `exchangeCodeForAppUserProfile` — callback-side token exchange that
|
|
14164
|
+
* returns the user's profile, the
|
|
14165
|
+
* access + refresh tokens, and their
|
|
14166
|
+
* absolute expiries.
|
|
14167
|
+
* - `refreshAppUserToken` — slide an expiring access token by
|
|
14168
|
+
* trading in its refresh token.
|
|
14169
|
+
*
|
|
14170
|
+
* Design context: `docs/github-app-design-zh.md` §3 ("one installation,
|
|
14171
|
+
* three capabilities") + §5.4 ("services/github-app.ts").
|
|
14172
|
+
*
|
|
14173
|
+
* Stateless by construction: no DB / config singletons. Callers thread
|
|
14174
|
+
* credentials in explicitly so the module is trivially safe under
|
|
14175
|
+
* concurrent request handlers.
|
|
14176
|
+
*/
|
|
14177
|
+
const APP_JWT_ALG = "RS256";
|
|
14178
|
+
/**
|
|
14179
|
+
* GitHub rejects App JWTs past 10 minutes; we ride 9 minutes so callers
|
|
14180
|
+
* don't trip the upper bound from clock skew. Generous-but-safe — the JWT
|
|
14181
|
+
* is cheap to mint (one RS256 signature) so caching is unnecessary.
|
|
14182
|
+
*/
|
|
14183
|
+
const APP_JWT_EXPIRY = "9m";
|
|
14184
|
+
/**
|
|
14185
|
+
* GitHub allows a small backdated `iat` to absorb clock skew on the
|
|
14186
|
+
* caller's side; the docs recommend 60 seconds. We mirror that.
|
|
14187
|
+
*/
|
|
14188
|
+
const APP_JWT_IAT_SKEW_SECONDS = 60;
|
|
14189
|
+
const APP_INSTALLATION_URL = (id) => `https://api.github.com/app/installations/${id}`;
|
|
14190
|
+
const OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
14191
|
+
const OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
|
|
14192
|
+
const USER_API_URL = "https://api.github.com/user";
|
|
14193
|
+
const USER_EMAILS_API_URL = "https://api.github.com/user/emails";
|
|
14194
|
+
const USER_INSTALLATIONS_API_URL = "https://api.github.com/user/installations";
|
|
14195
|
+
/**
|
|
14196
|
+
* Errors from any GitHub API call this module makes. Carries the HTTP
|
|
14197
|
+
* status so route layers can disambiguate auth/permission failures (401 /
|
|
14198
|
+
* 403 / 404) from transient upstream errors (5xx / network).
|
|
14199
|
+
*
|
|
14200
|
+
* Distinct from `github-oauth.ts`'s `GithubApiError` because the App API
|
|
14201
|
+
* surface is a different concern (App-private-key vs. OAuth client
|
|
14202
|
+
* credentials) and we want logs / metrics to tell them apart at a glance.
|
|
14203
|
+
*/
|
|
14204
|
+
var GithubAppApiError = class extends Error {
|
|
14205
|
+
constructor(status, message) {
|
|
14206
|
+
super(message);
|
|
14207
|
+
this.status = status;
|
|
14208
|
+
this.name = "GithubAppApiError";
|
|
14209
|
+
}
|
|
14210
|
+
};
|
|
14211
|
+
/**
|
|
14212
|
+
* Mint an App JWT. RS256-signed; identifies Hub-as-this-App to GitHub for
|
|
14213
|
+
* the next ~9 minutes. Use this directly for `/app/...` endpoints and as
|
|
14214
|
+
* the input to `mintInstallationToken` for `/installation/...` endpoints.
|
|
14215
|
+
*
|
|
14216
|
+
* The PEM is imported on every call. The cost is negligible (microseconds)
|
|
14217
|
+
* and avoids a global mutable cache — keeps this module trivially safe to
|
|
14218
|
+
* call from parallel request handlers without locking. If profiling ever
|
|
14219
|
+
* shows this is a hotspot, add a per-key memoization at the caller side.
|
|
14220
|
+
*/
|
|
14221
|
+
async function createAppJwt(creds) {
|
|
14222
|
+
const key = await importPKCS8(creds.privateKeyPem, APP_JWT_ALG);
|
|
14223
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
14224
|
+
return new SignJWT({}).setProtectedHeader({ alg: APP_JWT_ALG }).setIssuer(creds.appId).setIssuedAt(now - APP_JWT_IAT_SKEW_SECONDS).setExpirationTime(APP_JWT_EXPIRY).sign(key);
|
|
14225
|
+
}
|
|
14226
|
+
/**
|
|
14227
|
+
* Fetch the installation metadata. Used by the OAuth callback path to
|
|
14228
|
+
* resolve the bare `installation_id` query param into a full row before
|
|
14229
|
+
* UPSERTing into `github_app_installations` — so the callback doesn't
|
|
14230
|
+
* depend on the `installation: created` webhook arriving first
|
|
14231
|
+
* (delivery order between callback and webhook is not guaranteed).
|
|
14232
|
+
*
|
|
14233
|
+
* The webhook handler skips this call entirely because the payload it
|
|
14234
|
+
* receives already carries the same shape.
|
|
14235
|
+
*/
|
|
14236
|
+
async function fetchInstallation(appJwt, installationId, opts = {}) {
|
|
14237
|
+
const res = await (opts.fetcher ?? fetch)(APP_INSTALLATION_URL(installationId), { headers: {
|
|
14238
|
+
Authorization: `Bearer ${appJwt}`,
|
|
14239
|
+
Accept: "application/vnd.github+json",
|
|
14240
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
14241
|
+
} });
|
|
14242
|
+
if (!res.ok) throw new GithubAppApiError(res.status, `GitHub App installation fetch failed (${res.status})`);
|
|
14243
|
+
const body = await res.json();
|
|
14244
|
+
return {
|
|
14245
|
+
id: body.id,
|
|
14246
|
+
accountType: body.account.type,
|
|
14247
|
+
accountLogin: body.account.login,
|
|
14248
|
+
accountGithubId: body.account.id,
|
|
14249
|
+
permissions: body.permissions ?? {},
|
|
14250
|
+
events: body.events ?? [],
|
|
14251
|
+
suspendedAt: body.suspended_at ?? null
|
|
14252
|
+
};
|
|
14253
|
+
}
|
|
14254
|
+
/**
|
|
14255
|
+
* List the installation IDs the authenticated GitHub user can administer.
|
|
14256
|
+
* Wraps `GET /user/installations` — GitHub's documented "what installs is
|
|
14257
|
+
* this user allowed to touch" endpoint. Covers both User-type installs
|
|
14258
|
+
* (the user owns the personal account) and Organization-type installs
|
|
14259
|
+
* (the user has admin rights on the org).
|
|
14260
|
+
*
|
|
14261
|
+
* Critical security primitive: the OAuth callback uses this to verify
|
|
14262
|
+
* that an `installation_id` query parameter — which arrives over an
|
|
14263
|
+
* insecure channel (the user's browser address bar) — actually belongs
|
|
14264
|
+
* to the authenticated user. Without this check, any signed-in user
|
|
14265
|
+
* could attach an arbitrary installation_id to their callback URL and
|
|
14266
|
+
* bind another team's installation to their own Hub org (installation
|
|
14267
|
+
* IDs are NOT secrets — they appear in webhook URLs, GitHub-side
|
|
14268
|
+
* Settings pages, and the install dialog's post-install redirect).
|
|
13826
14269
|
*
|
|
13827
|
-
*
|
|
13828
|
-
*
|
|
13829
|
-
*
|
|
13830
|
-
|
|
13831
|
-
|
|
13832
|
-
* - Then GET `${USER_EMAILS_URL}` (only if `/user` returned no email)
|
|
13833
|
-
*/
|
|
13834
|
-
async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
14270
|
+
* Returns the bare ID set so callers can do O(1) membership checks. The
|
|
14271
|
+
* full installation metadata is fetched separately via `fetchInstallation`
|
|
14272
|
+
* once authorization has cleared.
|
|
14273
|
+
*/
|
|
14274
|
+
async function listUserAccessibleInstallationIds(userAccessToken, opts = {}) {
|
|
13835
14275
|
const fetcher = opts.fetcher ?? fetch;
|
|
13836
|
-
const
|
|
14276
|
+
const perPage = opts.perPage ?? 100;
|
|
14277
|
+
const maxPages = opts.maxPages ?? 5;
|
|
14278
|
+
const out = /* @__PURE__ */ new Set();
|
|
14279
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
14280
|
+
const res = await fetcher(`${USER_INSTALLATIONS_API_URL}?per_page=${perPage}&page=${page}`, { headers: {
|
|
14281
|
+
Authorization: `Bearer ${userAccessToken}`,
|
|
14282
|
+
Accept: "application/vnd.github+json",
|
|
14283
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
14284
|
+
} });
|
|
14285
|
+
if (!res.ok) throw new GithubAppApiError(res.status, `GitHub /user/installations failed (${res.status})`);
|
|
14286
|
+
const installs = (await res.json()).installations ?? [];
|
|
14287
|
+
for (const inst of installs) if (typeof inst.id === "number") out.add(inst.id);
|
|
14288
|
+
if (installs.length < perPage) break;
|
|
14289
|
+
}
|
|
14290
|
+
return out;
|
|
14291
|
+
}
|
|
14292
|
+
/**
|
|
14293
|
+
* Trade an expiring user-to-server access token for a fresh pair using
|
|
14294
|
+
* its refresh token. Thrown on:
|
|
14295
|
+
* - Network / 5xx — `GithubAppApiError(status, …)`
|
|
14296
|
+
* - 4xx with no JSON body — `GithubAppApiError(status, …)`
|
|
14297
|
+
* - 200 with `error` field — `GithubAppApiError(401, …)` (GitHub returns
|
|
14298
|
+
* 200 OK for an unusable refresh token but signals the error in the
|
|
14299
|
+
* body; we normalize to 401 so route layers can map to "re-login")
|
|
14300
|
+
*
|
|
14301
|
+
* Designed to be called from the OAuth callback / token-refresh path
|
|
14302
|
+
* landing in PR-C. The caller decrypts the stored refresh token, hands
|
|
14303
|
+
* the plaintext in here, encrypts the returned pair, and writes both
|
|
14304
|
+
* tokens + expiries back to `auth_identities.metadata`.
|
|
14305
|
+
*/
|
|
14306
|
+
async function refreshAppUserToken(clientId, clientSecret, refreshToken, opts = {}) {
|
|
14307
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
14308
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
14309
|
+
const res = await fetcher(OAUTH_TOKEN_URL, {
|
|
13837
14310
|
method: "POST",
|
|
13838
14311
|
headers: {
|
|
13839
14312
|
Accept: "application/json",
|
|
13840
14313
|
"Content-Type": "application/json"
|
|
13841
14314
|
},
|
|
13842
14315
|
body: JSON.stringify({
|
|
13843
|
-
client_id:
|
|
13844
|
-
client_secret:
|
|
13845
|
-
|
|
13846
|
-
|
|
14316
|
+
client_id: clientId,
|
|
14317
|
+
client_secret: clientSecret,
|
|
14318
|
+
grant_type: "refresh_token",
|
|
14319
|
+
refresh_token: refreshToken
|
|
13847
14320
|
})
|
|
13848
14321
|
});
|
|
13849
|
-
if (!
|
|
13850
|
-
const
|
|
13851
|
-
if (!
|
|
13852
|
-
|
|
13853
|
-
|
|
14322
|
+
if (!res.ok) throw new GithubAppApiError(res.status, `GitHub user-token refresh failed (${res.status})`);
|
|
14323
|
+
const body = await res.json();
|
|
14324
|
+
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"}`);
|
|
14325
|
+
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");
|
|
14326
|
+
const issuedAt = now();
|
|
14327
|
+
const accessExpiresAt = new Date(issuedAt.getTime() + body.expires_in * 1e3);
|
|
14328
|
+
const refreshExpiresAt = new Date(issuedAt.getTime() + body.refresh_token_expires_in * 1e3);
|
|
14329
|
+
return {
|
|
14330
|
+
accessToken: body.access_token,
|
|
14331
|
+
accessTokenExpiresAt: accessExpiresAt.toISOString(),
|
|
14332
|
+
refreshToken: body.refresh_token,
|
|
14333
|
+
refreshTokenExpiresAt: refreshExpiresAt.toISOString(),
|
|
14334
|
+
scope: body.scope ?? ""
|
|
14335
|
+
};
|
|
14336
|
+
}
|
|
14337
|
+
/**
|
|
14338
|
+
* Build the App's combined OAuth + install authorization URL. Per design
|
|
14339
|
+
* doc D1 ("login → install in one redirect"), this is the SAME endpoint
|
|
14340
|
+
* GitHub uses for both flows when the App has "Request user authorization
|
|
14341
|
+
* (OAuth) during installation" enabled — first install lands the user on
|
|
14342
|
+
* the install dialog → consents → GitHub bounces back to `redirect_uri`
|
|
14343
|
+
* with both `code` (OAuth) and `installation_id` (the new install).
|
|
14344
|
+
* Returning users skip the install dialog and just receive `code`.
|
|
14345
|
+
*
|
|
14346
|
+
* `state` is the signed JWT minted by `oauth-state.ts` — same CSRF defense
|
|
14347
|
+
* as the legacy OAuth flow.
|
|
14348
|
+
*
|
|
14349
|
+
* Permissions are NOT in the URL — the App declares them once in its
|
|
14350
|
+
* GitHub-side settings (design doc D0b) and the install dialog renders
|
|
14351
|
+
* them automatically. Asking again in the URL would let an attacker
|
|
14352
|
+
* craft a downgrade prompt.
|
|
14353
|
+
*/
|
|
14354
|
+
function buildAppAuthorizeUrl(opts) {
|
|
14355
|
+
const url = new URL(OAUTH_AUTHORIZE_URL);
|
|
14356
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
14357
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
14358
|
+
url.searchParams.set("state", opts.state);
|
|
14359
|
+
url.searchParams.set("allow_signup", "true");
|
|
14360
|
+
return url.toString();
|
|
14361
|
+
}
|
|
14362
|
+
const APP_INSTALL_URL = (slug) => `https://github.com/apps/${encodeURIComponent(slug)}/installations/new`;
|
|
14363
|
+
/**
|
|
14364
|
+
* Build the App's `installations/new` URL — the one that actually surfaces
|
|
14365
|
+
* GitHub's install dialog (repo picker + permission review). Distinct from
|
|
14366
|
+
* `buildAppAuthorizeUrl`:
|
|
14367
|
+
*
|
|
14368
|
+
* - `authorize` (login URL) → for a user who already has the App
|
|
14369
|
+
* installed, returns `code`. For one who DOESN'T, it only triggers
|
|
14370
|
+
* OAuth consent — no install dialog, no `installation_id` ever comes
|
|
14371
|
+
* back. So "Install on GitHub" CTAs that point at `authorize` silently
|
|
14372
|
+
* never produce an install (codex P1-1).
|
|
14373
|
+
* - `installations/new` → always shows the install picker. After the
|
|
14374
|
+
* user confirms, GitHub redirects to the App's configured callback /
|
|
14375
|
+
* setup URL with `installation_id` and (because the App has "Request
|
|
14376
|
+
* user authorization (OAuth) during installation" enabled, D1) also
|
|
14377
|
+
* `code` + the `state` we threaded through here.
|
|
14378
|
+
*
|
|
14379
|
+
* `state` is the same signed JWT minted by `oauth-state.ts` — GitHub
|
|
14380
|
+
* round-trips it on the post-install redirect, so the callback can verify
|
|
14381
|
+
* CSRF + recover `next` + (codex P1-3) the target org to bind to.
|
|
14382
|
+
*/
|
|
14383
|
+
function buildAppInstallUrl(opts) {
|
|
14384
|
+
const url = new URL(APP_INSTALL_URL(opts.appSlug));
|
|
14385
|
+
url.searchParams.set("state", opts.state);
|
|
14386
|
+
return url.toString();
|
|
14387
|
+
}
|
|
14388
|
+
/**
|
|
14389
|
+
* App-flavoured `exchangeCodeForProfile`: trade the callback `code` for
|
|
14390
|
+
* the user's profile + a full token pair (access + refresh + expiries).
|
|
14391
|
+
*
|
|
14392
|
+
* Why this exists alongside `github-oauth.ts.exchangeCodeForProfile`:
|
|
14393
|
+
* - Same endpoint (`/login/oauth/access_token`) but with App
|
|
14394
|
+
* client_id/secret instead of OAuth App credentials.
|
|
14395
|
+
* - Response carries `refresh_token` + `expires_in` +
|
|
14396
|
+
* `refresh_token_expires_in` (8h / 6mo TTLs) that the OAuth-only
|
|
14397
|
+
* version doesn't return.
|
|
14398
|
+
* - The token-rotation semantics (`refresh_token` will be reissued on
|
|
14399
|
+
* every refresh) mean the caller MUST persist all four fields, not
|
|
14400
|
+
* just `accessToken`.
|
|
14401
|
+
*
|
|
14402
|
+
* The OAuth-only helper stays put for the brief window between this
|
|
14403
|
+
* commit and the OAuth-flow rewrite; D3 cutover deletes it outright.
|
|
14404
|
+
*/
|
|
14405
|
+
async function exchangeCodeForAppUserProfile(opts, callOpts = {}) {
|
|
14406
|
+
const fetcher = callOpts.fetcher ?? fetch;
|
|
14407
|
+
const now = callOpts.now ?? (() => /* @__PURE__ */ new Date());
|
|
14408
|
+
const tokenRes = await fetcher(OAUTH_TOKEN_URL, {
|
|
14409
|
+
method: "POST",
|
|
14410
|
+
headers: {
|
|
14411
|
+
Accept: "application/json",
|
|
14412
|
+
"Content-Type": "application/json"
|
|
14413
|
+
},
|
|
14414
|
+
body: JSON.stringify({
|
|
14415
|
+
client_id: opts.clientId,
|
|
14416
|
+
client_secret: opts.clientSecret,
|
|
14417
|
+
code: opts.code,
|
|
14418
|
+
redirect_uri: opts.redirectUri
|
|
14419
|
+
})
|
|
14420
|
+
});
|
|
14421
|
+
if (!tokenRes.ok) throw new GithubAppApiError(tokenRes.status, `GitHub App user-token exchange failed (${tokenRes.status})`);
|
|
14422
|
+
const body = await tokenRes.json();
|
|
14423
|
+
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"}`);
|
|
14424
|
+
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");
|
|
14425
|
+
const issuedAt = now();
|
|
14426
|
+
const accessExpiresAt = new Date(issuedAt.getTime() + body.expires_in * 1e3);
|
|
14427
|
+
const refreshExpiresAt = new Date(issuedAt.getTime() + body.refresh_token_expires_in * 1e3);
|
|
14428
|
+
const userRes = await fetcher(USER_API_URL, { headers: {
|
|
14429
|
+
Authorization: `Bearer ${body.access_token}`,
|
|
13854
14430
|
Accept: "application/vnd.github+json"
|
|
13855
14431
|
} });
|
|
13856
|
-
if (!userRes.ok) throw new
|
|
14432
|
+
if (!userRes.ok) throw new GithubAppApiError(userRes.status, `GitHub /user fetch failed (${userRes.status})`);
|
|
13857
14433
|
const user = await userRes.json();
|
|
13858
14434
|
let email = user.email ?? null;
|
|
13859
14435
|
if (!email) {
|
|
13860
|
-
const emailsRes = await fetcher(
|
|
13861
|
-
Authorization: `Bearer ${
|
|
14436
|
+
const emailsRes = await fetcher(USER_EMAILS_API_URL, { headers: {
|
|
14437
|
+
Authorization: `Bearer ${body.access_token}`,
|
|
13862
14438
|
Accept: "application/vnd.github+json"
|
|
13863
14439
|
} });
|
|
13864
14440
|
if (emailsRes.ok) {
|
|
@@ -13874,49 +14450,186 @@ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
|
13874
14450
|
displayName: user.name ?? null,
|
|
13875
14451
|
avatarUrl: user.avatar_url ?? null
|
|
13876
14452
|
},
|
|
13877
|
-
accessToken:
|
|
14453
|
+
accessToken: body.access_token,
|
|
14454
|
+
accessTokenExpiresAt: accessExpiresAt.toISOString(),
|
|
14455
|
+
refreshToken: body.refresh_token,
|
|
14456
|
+
refreshTokenExpiresAt: refreshExpiresAt.toISOString(),
|
|
14457
|
+
scope: body.scope ?? "",
|
|
14458
|
+
installationId: opts.installationId
|
|
13878
14459
|
};
|
|
13879
14460
|
}
|
|
13880
14461
|
/**
|
|
13881
|
-
*
|
|
13882
|
-
*
|
|
13883
|
-
*
|
|
13884
|
-
*
|
|
14462
|
+
* GitHub App installation records — one row per (GitHub account → Hub team)
|
|
14463
|
+
* binding. Replaces the per-repo OAuth + webhook-secret model that lived in
|
|
14464
|
+
* `organization_settings.github_integration.webhookSecretCipher`.
|
|
14465
|
+
*
|
|
14466
|
+
* One installation simultaneously unlocks three capabilities (see design
|
|
14467
|
+
* doc `docs/github-app-design-zh.md` §3):
|
|
14468
|
+
* 1. User OAuth (user-to-server access + refresh tokens) — persisted on
|
|
14469
|
+
* `auth_identities.metadata` for the signing-in user, not here.
|
|
14470
|
+
* 2. Webhook stream — `installation_id` resolves the inbound webhook to
|
|
14471
|
+
* the bound Hub org by joining on this table.
|
|
14472
|
+
* 3. Installation token (server-to-server) — minted on demand from the
|
|
14473
|
+
* App private key; not persisted (1h TTL, cheap to re-issue).
|
|
14474
|
+
*
|
|
14475
|
+
* The (GitHub account ↔ Hub team) binding is 1:1 (D2 / §8 Q1). The
|
|
14476
|
+
* `hub_organization_id` UNIQUE constraint enforces that; the column is
|
|
14477
|
+
* nullable solely to accommodate the install-callback handler inserting
|
|
14478
|
+
* the row before the owning Hub team exists (fresh-signup flow). Once a
|
|
14479
|
+
* binding exists it never moves — re-installing the App on the same GitHub
|
|
14480
|
+
* account UPDATEs this row by `installation_id`.
|
|
14481
|
+
*
|
|
14482
|
+
* ON DELETE SET NULL on `hub_organization_id` rather than CASCADE because
|
|
14483
|
+
* the GitHub-side installation still exists upstream when a Hub team is
|
|
14484
|
+
* deleted — keeping the row lets a future re-binding flow recover without
|
|
14485
|
+
* a re-install dance.
|
|
13885
14486
|
*/
|
|
13886
|
-
|
|
13887
|
-
|
|
13888
|
-
|
|
13889
|
-
|
|
13890
|
-
|
|
13891
|
-
}
|
|
13892
|
-
}
|
|
14487
|
+
const githubAppInstallations = pgTable("github_app_installations", {
|
|
14488
|
+
id: text("id").primaryKey(),
|
|
14489
|
+
installationId: bigint("installation_id", { mode: "number" }).notNull(),
|
|
14490
|
+
accountType: text("account_type").$type().notNull(),
|
|
14491
|
+
accountLogin: text("account_login").notNull(),
|
|
14492
|
+
accountGithubId: bigint("account_github_id", { mode: "number" }).notNull(),
|
|
14493
|
+
hubOrganizationId: text("hub_organization_id").references(() => organizations.id, { onDelete: "set null" }),
|
|
14494
|
+
permissions: jsonb("permissions").$type().notNull(),
|
|
14495
|
+
events: jsonb("events").$type().notNull(),
|
|
14496
|
+
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
|
14497
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
14498
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
14499
|
+
}, (table) => [
|
|
14500
|
+
uniqueIndex("uq_github_app_installations_installation_id").on(table.installationId),
|
|
14501
|
+
uniqueIndex("uq_github_app_installations_hub_org").on(table.hubOrganizationId),
|
|
14502
|
+
index("idx_github_app_installations_account").on(table.accountGithubId),
|
|
14503
|
+
check("ck_github_app_installations_account_type", sql`${table.accountType} IN ('User', 'Organization')`)
|
|
14504
|
+
]);
|
|
14505
|
+
/** Postgres `unique_violation` SQLSTATE — emitted on UNIQUE constraint trips. */
|
|
14506
|
+
const PG_UNIQUE_VIOLATION$1 = "23505";
|
|
14507
|
+
function isUniqueViolation(err) {
|
|
14508
|
+
return (err?.code ?? err?.cause?.code) === PG_UNIQUE_VIOLATION$1;
|
|
14509
|
+
}
|
|
13893
14510
|
/**
|
|
13894
|
-
*
|
|
13895
|
-
*
|
|
14511
|
+
* UPSERT by `installation_id`. INSERTs a new row when the installation
|
|
14512
|
+
* is unseen; UPDATEs the metadata fields on re-install / permission
|
|
14513
|
+
* change / event-subscription change.
|
|
14514
|
+
*
|
|
14515
|
+
* Does NOT touch `hub_organization_id` on UPDATE — that column is
|
|
14516
|
+
* managed by `bindInstallationToOrg`. Otherwise a webhook arriving
|
|
14517
|
+
* after a manual rebind could clobber the binding back to null.
|
|
13896
14518
|
*/
|
|
13897
|
-
async function
|
|
13898
|
-
const
|
|
13899
|
-
const
|
|
13900
|
-
const
|
|
13901
|
-
|
|
13902
|
-
|
|
13903
|
-
|
|
13904
|
-
|
|
13905
|
-
|
|
13906
|
-
|
|
13907
|
-
|
|
13908
|
-
|
|
13909
|
-
|
|
13910
|
-
|
|
13911
|
-
|
|
13912
|
-
|
|
13913
|
-
|
|
13914
|
-
|
|
13915
|
-
|
|
13916
|
-
|
|
13917
|
-
|
|
14519
|
+
async function upsertInstallationFromMetadata(db, input) {
|
|
14520
|
+
const now = /* @__PURE__ */ new Date();
|
|
14521
|
+
const suspendedAt = input.installation.suspendedAt ? new Date(input.installation.suspendedAt) : null;
|
|
14522
|
+
const values = {
|
|
14523
|
+
id: uuidv7(),
|
|
14524
|
+
installationId: input.installation.id,
|
|
14525
|
+
accountType: input.installation.accountType,
|
|
14526
|
+
accountLogin: input.installation.accountLogin,
|
|
14527
|
+
accountGithubId: input.installation.accountGithubId,
|
|
14528
|
+
hubOrganizationId: input.hubOrganizationId ?? null,
|
|
14529
|
+
permissions: input.installation.permissions,
|
|
14530
|
+
events: input.installation.events,
|
|
14531
|
+
suspendedAt,
|
|
14532
|
+
createdAt: now,
|
|
14533
|
+
updatedAt: now
|
|
14534
|
+
};
|
|
14535
|
+
const [row] = await db.insert(githubAppInstallations).values(values).onConflictDoUpdate({
|
|
14536
|
+
target: githubAppInstallations.installationId,
|
|
14537
|
+
set: {
|
|
14538
|
+
accountType: values.accountType,
|
|
14539
|
+
accountLogin: values.accountLogin,
|
|
14540
|
+
accountGithubId: values.accountGithubId,
|
|
14541
|
+
permissions: values.permissions,
|
|
14542
|
+
events: values.events,
|
|
14543
|
+
suspendedAt: values.suspendedAt,
|
|
14544
|
+
updatedAt: now
|
|
14545
|
+
}
|
|
14546
|
+
}).returning();
|
|
14547
|
+
if (!row) throw new Error("upsertInstallationFromMetadata: INSERT returned no row");
|
|
14548
|
+
return row;
|
|
14549
|
+
}
|
|
14550
|
+
/**
|
|
14551
|
+
* Bind an installation to a Hub team. Idempotent: re-binding to the same
|
|
14552
|
+
* org is a no-op at the row level.
|
|
14553
|
+
*
|
|
14554
|
+
* Race-safe (codex P0-3): the previous SELECT-then-UPDATE implementation
|
|
14555
|
+
* had a TOCTOU window — two concurrent callbacks for the same unbound
|
|
14556
|
+
* installation but different Hub orgs could both see `hubOrganizationId
|
|
14557
|
+
* IS NULL`, both pass the in-memory validation, and then the second
|
|
14558
|
+
* UPDATE would silently rebind. This implementation:
|
|
14559
|
+
*
|
|
14560
|
+
* 1. Runs a conditional UPDATE: WHERE installation_id = $1 AND
|
|
14561
|
+
* (hub_organization_id IS NULL OR hub_organization_id = $2).
|
|
14562
|
+
* Postgres serializes the rowlock so the second concurrent caller
|
|
14563
|
+
* sees the freshly-set value and the WHERE clause filters it out
|
|
14564
|
+
* — the UPDATE matches 0 rows for the loser.
|
|
14565
|
+
* 2. On 0 rows updated, SELECTs the current row to decide which
|
|
14566
|
+
* structured error to throw (not-found vs. already-bound-elsewhere).
|
|
14567
|
+
* 3. Catches the 23505 path that fires when two ROWS get rebound to
|
|
14568
|
+
* the SAME hub_organization_id (covers the case where org A
|
|
14569
|
+
* already has installation X bound and a different callback tries
|
|
14570
|
+
* to bind installation Y to org A — the UPDATE on Y succeeds the
|
|
14571
|
+
* WHERE filter but violates UNIQUE(hub_organization_id)).
|
|
14572
|
+
* Surfaces as a clean ConflictError instead of a 23505 leaking
|
|
14573
|
+
* through the route layer.
|
|
14574
|
+
*
|
|
14575
|
+
* Throws:
|
|
14576
|
+
* - NotFoundError if no installation row exists with installationId.
|
|
14577
|
+
* - ConflictError if (a) the installation is already bound to a
|
|
14578
|
+
* different Hub team (D2 1:1), or (b) the target Hub team is
|
|
14579
|
+
* already bound to a different installation.
|
|
14580
|
+
*
|
|
14581
|
+
* Returns `true` on any successful UPDATE — fresh bind and idempotent
|
|
14582
|
+
* re-bind both succeed identically and we don't pay the extra SELECT to
|
|
14583
|
+
* tell them apart. The boolean exists for forward-compat with callers
|
|
14584
|
+
* that may want to surface a "freshly bound" log line; today both paths
|
|
14585
|
+
* leave the row in the same state, so the value is advisory.
|
|
14586
|
+
*/
|
|
14587
|
+
async function bindInstallationToOrg(db, installationId, hubOrganizationId) {
|
|
14588
|
+
let updatedCount;
|
|
14589
|
+
try {
|
|
14590
|
+
updatedCount = (await db.update(githubAppInstallations).set({
|
|
14591
|
+
hubOrganizationId,
|
|
14592
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
14593
|
+
}).where(and(eq(githubAppInstallations.installationId, installationId), or(isNull(githubAppInstallations.hubOrganizationId), eq(githubAppInstallations.hubOrganizationId, hubOrganizationId)))).returning({ id: githubAppInstallations.id })).length;
|
|
14594
|
+
} catch (err) {
|
|
14595
|
+
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.");
|
|
14596
|
+
throw err;
|
|
13918
14597
|
}
|
|
13919
|
-
|
|
14598
|
+
if (updatedCount === 0) {
|
|
14599
|
+
const [row] = await db.select({ hubOrganizationId: githubAppInstallations.hubOrganizationId }).from(githubAppInstallations).where(eq(githubAppInstallations.installationId, installationId)).limit(1);
|
|
14600
|
+
if (!row) throw new NotFoundError(`No installation row for installation_id=${installationId}`);
|
|
14601
|
+
throw new ConflictError(`Installation ${installationId} is already bound to a different Hub team — refusing to rebind (D2 1:1).`);
|
|
14602
|
+
}
|
|
14603
|
+
return true;
|
|
14604
|
+
}
|
|
14605
|
+
/**
|
|
14606
|
+
* Lookup the installation bound to a Hub team. Used by Settings →
|
|
14607
|
+
* Integrations to render the connected-account panel. Returns null when
|
|
14608
|
+
* no install is bound.
|
|
14609
|
+
*
|
|
14610
|
+
* `LIMIT 1` is belt-and-braces — UNIQUE(hub_organization_id) already
|
|
14611
|
+
* guarantees at most one row.
|
|
14612
|
+
*/
|
|
14613
|
+
async function findInstallationByOrg(db, hubOrganizationId) {
|
|
14614
|
+
const [row] = await db.select().from(githubAppInstallations).where(eq(githubAppInstallations.hubOrganizationId, hubOrganizationId)).limit(1);
|
|
14615
|
+
return row ?? null;
|
|
14616
|
+
}
|
|
14617
|
+
/**
|
|
14618
|
+
* List the installations for a GitHub account that aren't bound to any Hub
|
|
14619
|
+
* team yet. Newest-first.
|
|
14620
|
+
*
|
|
14621
|
+
* The orphan-recovery path (codex P1-5 + H1): if the OAuth callback's
|
|
14622
|
+
* `upsertInstallationFromMetadata` lands but the follow-up
|
|
14623
|
+
* `bindInstallationToOrg` fails (transient DB error, a racing invite that
|
|
14624
|
+
* errors out, …), the row sits unbound forever — GitHub only puts
|
|
14625
|
+
* `installation_id` in the redirect on the *initial* install, so a later
|
|
14626
|
+
* sign-in never re-attempts the bind. On every subsequent sign-in we sweep
|
|
14627
|
+
* for unbound rows whose `accountGithubId` matches the user's own GitHub
|
|
14628
|
+
* account and auto-claim the single one (and surface a manual "Claim
|
|
14629
|
+
* install" button when there are several).
|
|
14630
|
+
*/
|
|
14631
|
+
async function findUnboundInstallationsByAccount(db, accountGithubId) {
|
|
14632
|
+
return db.select().from(githubAppInstallations).where(and(eq(githubAppInstallations.accountGithubId, accountGithubId), isNull(githubAppInstallations.hubOrganizationId))).orderBy(desc(githubAppInstallations.createdAt));
|
|
13920
14633
|
}
|
|
13921
14634
|
/** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
|
|
13922
14635
|
async function ensureMembership(db, data) {
|
|
@@ -14048,6 +14761,22 @@ async function pickPrimaryMembership(db, userId) {
|
|
|
14048
14761
|
return (await listActiveMemberships(db, userId))[0] ?? null;
|
|
14049
14762
|
}
|
|
14050
14763
|
/**
|
|
14764
|
+
* Look up a user's ACTIVE membership in a specific org. Returns null when
|
|
14765
|
+
* the user isn't a member there (or their row is soft-deleted `left`).
|
|
14766
|
+
*
|
|
14767
|
+
* Used by the OAuth callback to re-check that a `targetOrganizationId`
|
|
14768
|
+
* carried in the signed state still names an org the user can administer
|
|
14769
|
+
* before binding a GitHub App installation to it (codex P1-3) — the state
|
|
14770
|
+
* JWT lives ~10min, long enough for a membership to be revoked.
|
|
14771
|
+
*/
|
|
14772
|
+
async function findActiveMembership(db, userId, organizationId) {
|
|
14773
|
+
const [row] = await db.select({
|
|
14774
|
+
memberId: members.id,
|
|
14775
|
+
role: members.role
|
|
14776
|
+
}).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
|
|
14777
|
+
return row ?? null;
|
|
14778
|
+
}
|
|
14779
|
+
/**
|
|
14051
14780
|
* Mark `members.status='left'` for the given member. v1 simplification:
|
|
14052
14781
|
* no "must transfer admin" check — the proposal accepts the trade-off
|
|
14053
14782
|
* (last admin allowed to leave, leaves an orphan team) and the cleanup is
|
|
@@ -14121,14 +14850,16 @@ const OAUTH_STATE_COOKIE = "oauth_state_nonce";
|
|
|
14121
14850
|
* Sign a fresh state token + return the matching cookie nonce. Caller is
|
|
14122
14851
|
* responsible for setting the cookie (HttpOnly + Secure in prod).
|
|
14123
14852
|
*/
|
|
14124
|
-
async function signOAuthState(jwtSecret, next) {
|
|
14853
|
+
async function signOAuthState(jwtSecret, next, opts = {}) {
|
|
14125
14854
|
const nonce = randomBytes(NONCE_BYTES).toString("base64url");
|
|
14126
14855
|
const secret = new TextEncoder().encode(jwtSecret);
|
|
14856
|
+
const claims = {
|
|
14857
|
+
nonce,
|
|
14858
|
+
next
|
|
14859
|
+
};
|
|
14860
|
+
if (opts.targetOrganizationId) claims.targetOrganizationId = opts.targetOrganizationId;
|
|
14127
14861
|
return {
|
|
14128
|
-
token: await new SignJWT({
|
|
14129
|
-
nonce,
|
|
14130
|
-
next
|
|
14131
|
-
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
|
|
14862
|
+
token: await new SignJWT({ ...claims }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
|
|
14132
14863
|
nonce
|
|
14133
14864
|
};
|
|
14134
14865
|
}
|
|
@@ -14152,8 +14883,12 @@ async function verifyOAuthState(jwtSecret, token, cookieNonce) {
|
|
|
14152
14883
|
throw new Error("Invalid or expired OAuth state");
|
|
14153
14884
|
}
|
|
14154
14885
|
if (typeof payload.nonce !== "string" || typeof payload.next !== "string") throw new Error("OAuth state payload malformed");
|
|
14886
|
+
if (payload.targetOrganizationId !== void 0 && typeof payload.targetOrganizationId !== "string") throw new Error("OAuth state payload malformed");
|
|
14155
14887
|
if (!cookieNonce || cookieNonce !== payload.nonce) throw new Error("OAuth state nonce / cookie mismatch");
|
|
14156
|
-
return {
|
|
14888
|
+
return {
|
|
14889
|
+
next: payload.next,
|
|
14890
|
+
...payload.targetOrganizationId ? { targetOrganizationId: payload.targetOrganizationId } : {}
|
|
14891
|
+
};
|
|
14157
14892
|
}
|
|
14158
14893
|
/**
|
|
14159
14894
|
* Resolve the hub's public-facing base URL.
|
|
@@ -14205,7 +14940,15 @@ function buildCookie(opts) {
|
|
|
14205
14940
|
return parts.join("; ");
|
|
14206
14941
|
}
|
|
14207
14942
|
/**
|
|
14208
|
-
* GitHub
|
|
14943
|
+
* GitHub sign-in surface. All routes are public (no member JWT required).
|
|
14944
|
+
*
|
|
14945
|
+
* Single flow post-D3 cutover: the GitHub App authorize URL drives both
|
|
14946
|
+
* sign-in and install (D1). Returning users with an existing install
|
|
14947
|
+
* skip the install dialog and just get `code + state`; first-time
|
|
14948
|
+
* installers get `code + state + installation_id`. The legacy OAuth-App
|
|
14949
|
+
* path that lived alongside this until D3 has been removed.
|
|
14950
|
+
*
|
|
14951
|
+
* `dev-callback` bypasses GitHub entirely; gated to non-production.
|
|
14209
14952
|
*
|
|
14210
14953
|
* Routes:
|
|
14211
14954
|
* - GET /auth/github/start — sign state JWT + cookie + 302 to GitHub
|
|
@@ -14213,15 +14956,15 @@ function buildCookie(opts) {
|
|
|
14213
14956
|
* - GET /auth/github/dev-callback — dev-only stub (no GitHub round-trip)
|
|
14214
14957
|
*/
|
|
14215
14958
|
async function githubOauthRoutes(app) {
|
|
14216
|
-
const
|
|
14217
|
-
if (!
|
|
14959
|
+
const appCfg = app.config.oauth?.githubApp;
|
|
14960
|
+
if (!appCfg) app.log.info("GitHub App not configured — /auth/github/start will return 503. Set FIRST_TREE_HUB_GITHUB_APP_* to enable.");
|
|
14218
14961
|
app.get("/start", { config: { rateLimit: {
|
|
14219
14962
|
max: 20,
|
|
14220
14963
|
timeWindow: "1 minute"
|
|
14221
14964
|
} } }, async (request, reply) => {
|
|
14222
14965
|
const { next } = githubStartQuerySchema.parse(request.query);
|
|
14223
14966
|
const safeNext = safeRedirectPath(next ?? null);
|
|
14224
|
-
if (!
|
|
14967
|
+
if (!appCfg) return reply.status(503).send({ error: "GitHub App is not configured on this hub" });
|
|
14225
14968
|
const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, safeNext);
|
|
14226
14969
|
const isProd = process.env.NODE_ENV === "production";
|
|
14227
14970
|
reply.header("Set-Cookie", buildCookie({
|
|
@@ -14231,22 +14974,22 @@ async function githubOauthRoutes(app) {
|
|
|
14231
14974
|
secure: isProd
|
|
14232
14975
|
}));
|
|
14233
14976
|
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
14234
|
-
|
|
14235
|
-
|
|
14236
|
-
|
|
14237
|
-
state: token
|
|
14238
|
-
|
|
14239
|
-
allow_signup: "true"
|
|
14240
|
-
});
|
|
14241
|
-
return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
|
14977
|
+
return reply.redirect(buildAppAuthorizeUrl({
|
|
14978
|
+
clientId: appCfg.clientId,
|
|
14979
|
+
redirectUri,
|
|
14980
|
+
state: token
|
|
14981
|
+
}), 302);
|
|
14242
14982
|
});
|
|
14243
14983
|
app.get("/callback", async (request, reply) => {
|
|
14244
|
-
if (!
|
|
14245
|
-
const { code, state } = githubCallbackQuerySchema.parse(request.query);
|
|
14984
|
+
if (!appCfg) return reply.status(503).send({ error: "GitHub App is not configured on this hub" });
|
|
14985
|
+
const { code, state, installation_id: installationIdRaw } = githubCallbackQuerySchema.parse(request.query);
|
|
14246
14986
|
const cookieNonce = parseCookieHeader(request.headers.cookie, OAUTH_STATE_COOKIE);
|
|
14247
14987
|
let next;
|
|
14988
|
+
let targetOrganizationId = null;
|
|
14248
14989
|
try {
|
|
14249
|
-
|
|
14990
|
+
const verified = await verifyOAuthState(app.config.secrets.jwtSecret, state, cookieNonce);
|
|
14991
|
+
next = verified.next;
|
|
14992
|
+
targetOrganizationId = verified.targetOrganizationId ?? null;
|
|
14250
14993
|
} catch (err) {
|
|
14251
14994
|
const msg = err instanceof Error ? err.message : "OAuth state rejected";
|
|
14252
14995
|
return reply.status(401).send({ error: msg });
|
|
@@ -14259,40 +15002,128 @@ async function githubOauthRoutes(app) {
|
|
|
14259
15002
|
}));
|
|
14260
15003
|
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
14261
15004
|
let profile;
|
|
14262
|
-
let
|
|
15005
|
+
let tokens;
|
|
15006
|
+
let plaintextUserAccessToken;
|
|
15007
|
+
let installationId = null;
|
|
14263
15008
|
try {
|
|
14264
|
-
const result = await
|
|
14265
|
-
clientId:
|
|
14266
|
-
clientSecret:
|
|
14267
|
-
|
|
15009
|
+
const result = await exchangeCodeForAppUserProfile({
|
|
15010
|
+
clientId: appCfg.clientId,
|
|
15011
|
+
clientSecret: appCfg.clientSecret,
|
|
15012
|
+
code,
|
|
15013
|
+
redirectUri,
|
|
15014
|
+
installationId: installationIdRaw ? Number(installationIdRaw) : null
|
|
15015
|
+
});
|
|
14268
15016
|
profile = result.profile;
|
|
14269
|
-
|
|
15017
|
+
plaintextUserAccessToken = result.accessToken;
|
|
15018
|
+
tokens = {
|
|
15019
|
+
encryptedAccessToken: encryptValue(result.accessToken, app.config.secrets.encryptionKey),
|
|
15020
|
+
accessTokenExpiresAt: result.accessTokenExpiresAt,
|
|
15021
|
+
encryptedRefreshToken: encryptValue(result.refreshToken, app.config.secrets.encryptionKey),
|
|
15022
|
+
refreshTokenExpiresAt: result.refreshTokenExpiresAt
|
|
15023
|
+
};
|
|
15024
|
+
installationId = result.installationId;
|
|
14270
15025
|
} catch (err) {
|
|
14271
15026
|
const msg = err instanceof Error ? err.message : "GitHub exchange failed";
|
|
14272
|
-
app.log.warn({ err }, "github
|
|
15027
|
+
app.log.warn({ err }, "github sign-in code exchange failed");
|
|
14273
15028
|
return reply.status(401).send({ error: msg });
|
|
14274
15029
|
}
|
|
14275
|
-
|
|
15030
|
+
if (installationId !== null) try {
|
|
15031
|
+
const allowedIds = await listUserAccessibleInstallationIds(plaintextUserAccessToken);
|
|
15032
|
+
if (!allowedIds.has(installationId)) {
|
|
15033
|
+
app.log.warn({
|
|
15034
|
+
event: "github_app.installation_id_unauthorized",
|
|
15035
|
+
installationId,
|
|
15036
|
+
githubId: profile.githubId,
|
|
15037
|
+
allowedCount: allowedIds.size
|
|
15038
|
+
}, "callback installation_id is not in /user/installations — refusing to bind (attempted hijack?)");
|
|
15039
|
+
installationId = null;
|
|
15040
|
+
}
|
|
15041
|
+
} catch (err) {
|
|
15042
|
+
app.log.warn({
|
|
15043
|
+
err,
|
|
15044
|
+
installationId,
|
|
15045
|
+
githubId: profile.githubId
|
|
15046
|
+
}, "github app /user/installations check failed — refusing to bind to be safe");
|
|
15047
|
+
installationId = null;
|
|
15048
|
+
}
|
|
15049
|
+
if (installationId !== null && appCfg) try {
|
|
15050
|
+
const installation = await fetchInstallation(await createAppJwt({
|
|
15051
|
+
appId: appCfg.appId,
|
|
15052
|
+
privateKeyPem: appCfg.privateKeyPem
|
|
15053
|
+
}), installationId);
|
|
15054
|
+
await upsertInstallationFromMetadata(app.db, { installation });
|
|
15055
|
+
} catch (err) {
|
|
15056
|
+
app.log.warn({
|
|
15057
|
+
err,
|
|
15058
|
+
installationId,
|
|
15059
|
+
githubId: profile.githubId
|
|
15060
|
+
}, "github app install fetch/upsert failed — clearing installation_id, user can retry from Settings");
|
|
15061
|
+
installationId = null;
|
|
15062
|
+
}
|
|
15063
|
+
return completeOauthFlow(app, request, reply, profile, next, tokens, installationId, targetOrganizationId);
|
|
14276
15064
|
});
|
|
14277
15065
|
app.get("/dev-callback", async (request, reply) => {
|
|
14278
15066
|
if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
|
|
15067
|
+
const devCallbackOptIn = process.env.FIRST_TREE_HUB_DEV_CALLBACK_ENABLED;
|
|
15068
|
+
if (devCallbackOptIn !== "1" && devCallbackOptIn !== "true") {
|
|
15069
|
+
app.log.info({ url: request.url }, "dev-callback request refused — FIRST_TREE_HUB_DEV_CALLBACK_ENABLED is not set");
|
|
15070
|
+
return reply.status(404).send({ error: "Not found" });
|
|
15071
|
+
}
|
|
14279
15072
|
const params = githubDevCallbackQuerySchema.parse(request.query);
|
|
14280
15073
|
const next = safeRedirectPath(params.next ?? null);
|
|
14281
|
-
|
|
15074
|
+
const profile = {
|
|
14282
15075
|
githubId: params.githubId,
|
|
14283
15076
|
login: params.login,
|
|
14284
15077
|
email: params.email ?? null,
|
|
14285
15078
|
displayName: params.displayName ?? params.login,
|
|
14286
15079
|
avatarUrl: null
|
|
14287
|
-
}
|
|
15080
|
+
};
|
|
15081
|
+
const devPat = process.env.DEV_GITHUB_PAT?.trim() || null;
|
|
15082
|
+
const tokens = devPat ? { encryptedAccessToken: encryptValue(devPat, app.config.secrets.encryptionKey) } : {};
|
|
15083
|
+
let devInstallationId = null;
|
|
15084
|
+
if (params.installationId) {
|
|
15085
|
+
devInstallationId = Number(params.installationId);
|
|
15086
|
+
try {
|
|
15087
|
+
await upsertInstallationFromMetadata(app.db, { installation: {
|
|
15088
|
+
id: devInstallationId,
|
|
15089
|
+
accountType: params.installationAccountType ?? "User",
|
|
15090
|
+
accountLogin: params.installationAccountLogin ?? params.login,
|
|
15091
|
+
accountGithubId: Number(params.installationAccountGithubId ?? params.githubId),
|
|
15092
|
+
permissions: {
|
|
15093
|
+
contents: "write",
|
|
15094
|
+
pull_requests: "write",
|
|
15095
|
+
issues: "read",
|
|
15096
|
+
metadata: "read",
|
|
15097
|
+
members: "read"
|
|
15098
|
+
},
|
|
15099
|
+
events: [
|
|
15100
|
+
"issues",
|
|
15101
|
+
"issue_comment",
|
|
15102
|
+
"pull_request",
|
|
15103
|
+
"pull_request_review",
|
|
15104
|
+
"push",
|
|
15105
|
+
"installation",
|
|
15106
|
+
"installation_repositories",
|
|
15107
|
+
"member"
|
|
15108
|
+
],
|
|
15109
|
+
suspendedAt: null
|
|
15110
|
+
} });
|
|
15111
|
+
} catch (err) {
|
|
15112
|
+
app.log.warn({
|
|
15113
|
+
err,
|
|
15114
|
+
installationId: devInstallationId
|
|
15115
|
+
}, "dev-callback installation stub upsert failed");
|
|
15116
|
+
}
|
|
15117
|
+
}
|
|
15118
|
+
return completeOauthFlow(app, request, reply, profile, next, tokens, devInstallationId, null);
|
|
14288
15119
|
});
|
|
14289
15120
|
}
|
|
14290
|
-
async function completeOauthFlow(app, request, reply, profile, next,
|
|
14291
|
-
const
|
|
14292
|
-
const { userId } = await findOrCreateUserFromGithub(app.db, profile, { encryptedAccessToken });
|
|
15121
|
+
async function completeOauthFlow(app, request, reply, profile, next, oauthTokens, installationId, targetOrganizationId) {
|
|
15122
|
+
const { userId } = await findOrCreateUserFromGithub(app.db, profile, oauthTokens);
|
|
14293
15123
|
let joinPath = "returning";
|
|
14294
15124
|
const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
|
|
14295
15125
|
let resolved = false;
|
|
15126
|
+
let resolvedOrganizationId = null;
|
|
14296
15127
|
if (inviteMatch?.[1]) {
|
|
14297
15128
|
const token = inviteMatch[1];
|
|
14298
15129
|
const inv = await findActiveByToken(app.db, token);
|
|
@@ -14312,24 +15143,69 @@ async function completeOauthFlow(app, request, reply, profile, next, rawAccessTo
|
|
|
14312
15143
|
});
|
|
14313
15144
|
joinPath = "invite";
|
|
14314
15145
|
resolved = true;
|
|
15146
|
+
resolvedOrganizationId = inv.organizationId;
|
|
14315
15147
|
next = "/";
|
|
14316
|
-
} else if (
|
|
14317
|
-
|
|
14318
|
-
|
|
14319
|
-
userId,
|
|
14320
|
-
loginSeed: profile.login,
|
|
14321
|
-
teamDisplayName: `${profile.login}'s team`,
|
|
14322
|
-
userDisplayName: profile.displayName?.trim() || profile.login
|
|
14323
|
-
});
|
|
14324
|
-
joinPath = "solo";
|
|
15148
|
+
} else if (targetOrganizationId) {
|
|
15149
|
+
const membership = await findActiveMembership(app.db, userId, targetOrganizationId);
|
|
15150
|
+
if (!membership || membership.role !== "admin") return reply.status(403).send({ error: "Not an admin of the organization this installation targets" });
|
|
14325
15151
|
resolved = true;
|
|
14326
|
-
|
|
14327
|
-
|
|
14328
|
-
|
|
14329
|
-
|
|
14330
|
-
|
|
14331
|
-
|
|
14332
|
-
}
|
|
15152
|
+
resolvedOrganizationId = targetOrganizationId;
|
|
15153
|
+
} else {
|
|
15154
|
+
const primary = await pickPrimaryMembership(app.db, userId);
|
|
15155
|
+
if (primary) {
|
|
15156
|
+
resolved = true;
|
|
15157
|
+
resolvedOrganizationId = primary.organizationId;
|
|
15158
|
+
} else {
|
|
15159
|
+
const personal = await createPersonalTeam(app.db, {
|
|
15160
|
+
userId,
|
|
15161
|
+
loginSeed: profile.login,
|
|
15162
|
+
teamDisplayName: `${profile.login}'s team`,
|
|
15163
|
+
userDisplayName: profile.displayName?.trim() || profile.login
|
|
15164
|
+
});
|
|
15165
|
+
joinPath = "solo";
|
|
15166
|
+
resolved = true;
|
|
15167
|
+
resolvedOrganizationId = personal.organizationId;
|
|
15168
|
+
next = "/";
|
|
15169
|
+
app.log.info({
|
|
15170
|
+
event: "onboarding.team_created",
|
|
15171
|
+
userId,
|
|
15172
|
+
organizationId: personal.organizationId,
|
|
15173
|
+
source: "oauth-bootstrap"
|
|
15174
|
+
}, "onboarding funnel: team auto-created at OAuth bootstrap");
|
|
15175
|
+
}
|
|
15176
|
+
}
|
|
15177
|
+
if (installationId !== null && resolvedOrganizationId) try {
|
|
15178
|
+
await bindInstallationToOrg(app.db, installationId, resolvedOrganizationId);
|
|
15179
|
+
} catch (err) {
|
|
15180
|
+
app.log.warn({
|
|
15181
|
+
err,
|
|
15182
|
+
installationId,
|
|
15183
|
+
hubOrganizationId: resolvedOrganizationId,
|
|
15184
|
+
userId
|
|
15185
|
+
}, "github app install bind-to-org failed — sign-in continues; reconcile in Settings");
|
|
15186
|
+
}
|
|
15187
|
+
if (resolvedOrganizationId) try {
|
|
15188
|
+
const orphans = await findUnboundInstallationsByAccount(app.db, Number(profile.githubId));
|
|
15189
|
+
if (orphans.length === 1) {
|
|
15190
|
+
const orphan = orphans[0];
|
|
15191
|
+
if (orphan) await bindInstallationToOrg(app.db, orphan.installationId, resolvedOrganizationId).catch((err) => {
|
|
15192
|
+
app.log.warn({
|
|
15193
|
+
err,
|
|
15194
|
+
installationId: orphan.installationId,
|
|
15195
|
+
hubOrganizationId: resolvedOrganizationId,
|
|
15196
|
+
userId
|
|
15197
|
+
}, "orphan install reclaim failed — operator can retry via POST /claim (UI tracked in #318)");
|
|
15198
|
+
});
|
|
15199
|
+
} else if (orphans.length > 1) app.log.info({
|
|
15200
|
+
count: orphans.length,
|
|
15201
|
+
accountGithubId: Number(profile.githubId),
|
|
15202
|
+
userId
|
|
15203
|
+
}, "multiple unbound installs match this account — skipping auto-claim; operator must POST /claim to pick (UI #318)");
|
|
15204
|
+
} catch (err) {
|
|
15205
|
+
app.log.warn({
|
|
15206
|
+
err,
|
|
15207
|
+
userId
|
|
15208
|
+
}, "orphan install reclaim sweep failed");
|
|
14333
15209
|
}
|
|
14334
15210
|
if (!resolved) return reply.status(500).send({ error: "Failed to resolve membership" });
|
|
14335
15211
|
const tokens = await signTokensForUser(app.config.secrets.jwtSecret, userId, app.config.auth);
|
|
@@ -14788,7 +15664,6 @@ async function createMeChat(db, humanAgentId, organizationId, body) {
|
|
|
14788
15664
|
const crossOrg = found.filter((a) => a.organizationId !== organizationId);
|
|
14789
15665
|
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
14790
15666
|
const chatType = distinctIds.length === 1 ? "direct" : "group";
|
|
14791
|
-
const isDirectAgentOnly = chatType === "direct" && found.every((a) => a.type !== "human");
|
|
14792
15667
|
const chatId = randomUUID();
|
|
14793
15668
|
const topic = body.topic ?? null;
|
|
14794
15669
|
await db.transaction(async (tx) => {
|
|
@@ -14798,11 +15673,9 @@ async function createMeChat(db, humanAgentId, organizationId, body) {
|
|
|
14798
15673
|
type: chatType,
|
|
14799
15674
|
topic
|
|
14800
15675
|
});
|
|
14801
|
-
await tx
|
|
14802
|
-
chatId,
|
|
15676
|
+
await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
|
|
14803
15677
|
agentId,
|
|
14804
|
-
role: agentId === humanAgentId ? "owner" : "member"
|
|
14805
|
-
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
15678
|
+
role: agentId === humanAgentId ? "owner" : "member"
|
|
14806
15679
|
})));
|
|
14807
15680
|
await recomputeChatWatchers(tx, chatId);
|
|
14808
15681
|
});
|
|
@@ -14840,37 +15713,24 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
|
|
|
14840
15713
|
await recomputeChatWatchers(tx, chatId);
|
|
14841
15714
|
return;
|
|
14842
15715
|
}
|
|
14843
|
-
|
|
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
|
-
}
|
|
15716
|
+
if (existing.length + toInsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
|
|
14854
15717
|
const carriedRows = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, toInsert))).returning({
|
|
14855
15718
|
agentId: chatSubscriptions.agentId,
|
|
14856
15719
|
lastReadAt: chatSubscriptions.lastReadAt,
|
|
14857
15720
|
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
14858
15721
|
});
|
|
14859
15722
|
const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
|
|
14860
|
-
|
|
14861
|
-
await tx.insert(chatParticipants).values(toInsert.map((agentId) => {
|
|
14862
|
-
const agentType = typeByAgent.get(agentId);
|
|
14863
|
-
const mode = isGroupAfter && agentType !== "human" ? "mention_only" : "full";
|
|
15723
|
+
await addChatParticipants(tx, chatId, toInsert.map((agentId) => {
|
|
14864
15724
|
const carried = carriedByAgent.get(agentId);
|
|
14865
15725
|
return {
|
|
14866
|
-
chatId,
|
|
14867
15726
|
agentId,
|
|
14868
15727
|
role: "member",
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
15728
|
+
carriedReadState: carried ? {
|
|
15729
|
+
lastReadAt: carried.lastReadAt,
|
|
15730
|
+
unreadMentionCount: carried.unreadMentionCount
|
|
15731
|
+
} : void 0
|
|
14872
15732
|
};
|
|
14873
|
-
})
|
|
15733
|
+
}), { onConflictDoNothing: true });
|
|
14874
15734
|
await recomputeChatWatchers(tx, chatId);
|
|
14875
15735
|
});
|
|
14876
15736
|
invalidateChatAudience(chatId);
|
|
@@ -16464,7 +17324,7 @@ async function healthzRoutes(app) {
|
|
|
16464
17324
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
16465
17325
|
*/
|
|
16466
17326
|
async function publicInvitationRoutes(app) {
|
|
16467
|
-
const { previewInvitation } = await import("./invitation-C299fxkP-
|
|
17327
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-jQiGR5fl.mjs");
|
|
16468
17328
|
app.get("/:token/preview", async (request, reply) => {
|
|
16469
17329
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
16470
17330
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -16472,6 +17332,52 @@ async function publicInvitationRoutes(app) {
|
|
|
16472
17332
|
});
|
|
16473
17333
|
}
|
|
16474
17334
|
/**
|
|
17335
|
+
* Thrown when GitHub's API returns a non-2xx for a token-scoped call.
|
|
17336
|
+
* Carries the HTTP status so callers can distinguish auth failures (401 /
|
|
17337
|
+
* 403 — typically a stale token or a missing scope) from transient upstream
|
|
17338
|
+
* errors.
|
|
17339
|
+
*/
|
|
17340
|
+
var GithubApiError = class extends Error {
|
|
17341
|
+
constructor(status, message) {
|
|
17342
|
+
super(message);
|
|
17343
|
+
this.status = status;
|
|
17344
|
+
this.name = "GithubApiError";
|
|
17345
|
+
}
|
|
17346
|
+
};
|
|
17347
|
+
/**
|
|
17348
|
+
* Fetch the authenticated user's accessible repositories. Used by the
|
|
17349
|
+
* Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
|
|
17350
|
+
*
|
|
17351
|
+
* Takes a Bearer-style access token — works the same way whether that
|
|
17352
|
+
* token is a legacy OAuth grant (single-scope `repo`) or an App
|
|
17353
|
+
* user-to-server token (scope is whatever the App declared on its
|
|
17354
|
+
* settings page). The picker has no business distinguishing them.
|
|
17355
|
+
*/
|
|
17356
|
+
async function listUserRepos(accessToken, opts = {}) {
|
|
17357
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
17358
|
+
const perPage = opts.perPage ?? 100;
|
|
17359
|
+
const maxPages = opts.maxPages ?? 3;
|
|
17360
|
+
const out = [];
|
|
17361
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
17362
|
+
const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
|
|
17363
|
+
Authorization: `Bearer ${accessToken}`,
|
|
17364
|
+
Accept: "application/vnd.github+json"
|
|
17365
|
+
} });
|
|
17366
|
+
if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
|
|
17367
|
+
const rows = await res.json();
|
|
17368
|
+
for (const r of rows) out.push({
|
|
17369
|
+
fullName: r.full_name,
|
|
17370
|
+
cloneUrl: r.clone_url,
|
|
17371
|
+
htmlUrl: r.html_url,
|
|
17372
|
+
private: r.private,
|
|
17373
|
+
defaultBranch: r.default_branch ?? null,
|
|
17374
|
+
pushedAt: r.pushed_at ?? null
|
|
17375
|
+
});
|
|
17376
|
+
if (rows.length < perPage) break;
|
|
17377
|
+
}
|
|
17378
|
+
return out;
|
|
17379
|
+
}
|
|
17380
|
+
/**
|
|
16475
17381
|
* `/me` and self-service organization routes (Class A — User-scoped).
|
|
16476
17382
|
* Mounted under `requireUser` so the JWT only needs `sub = userId`.
|
|
16477
17383
|
*
|
|
@@ -16574,11 +17480,19 @@ async function meRoutes(app) {
|
|
|
16574
17480
|
* 503 if the user has no GitHub identity bound or the token wasn't
|
|
16575
17481
|
* captured (e.g. dev-callback sign-in or pre-redesign user). The web
|
|
16576
17482
|
* client falls back to a "Reconnect GitHub" hint in that case.
|
|
17483
|
+
*
|
|
17484
|
+
* codex P1-4: GitHub App user-to-server tokens have an ~8h TTL. If
|
|
17485
|
+
* the stored `accessTokenExpiresAt` is past (or within a 60-second
|
|
17486
|
+
* buffer of expiring), trade in the persisted refresh token for a
|
|
17487
|
+
* fresh pair before calling GitHub. Legacy rows without expiry
|
|
17488
|
+
* fields fall through unchanged — the never-expiring OAuth-App token
|
|
17489
|
+
* still works as-is.
|
|
16577
17490
|
*/
|
|
16578
17491
|
app.get("/me/github/repos", async (request, reply) => {
|
|
16579
17492
|
const { userId } = requireUser(request);
|
|
16580
17493
|
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
|
|
17494
|
+
const metadata = identity?.metadata && typeof identity.metadata === "object" ? identity.metadata : null;
|
|
17495
|
+
const encrypted = metadata && "accessToken" in metadata ? metadata.accessToken : void 0;
|
|
16582
17496
|
if (typeof encrypted !== "string" || !encrypted) return reply.status(503).send({ error: "GitHub access token unavailable — please reconnect your account" });
|
|
16583
17497
|
let token;
|
|
16584
17498
|
try {
|
|
@@ -16586,6 +17500,38 @@ async function meRoutes(app) {
|
|
|
16586
17500
|
} catch {
|
|
16587
17501
|
return reply.status(503).send({ error: "GitHub access token could not be decoded — please reconnect" });
|
|
16588
17502
|
}
|
|
17503
|
+
const appCfg = app.config.oauth?.githubApp;
|
|
17504
|
+
const expiresAtRaw = metadata && "accessTokenExpiresAt" in metadata ? metadata.accessTokenExpiresAt : void 0;
|
|
17505
|
+
const encryptedRefresh = metadata && "refreshToken" in metadata ? metadata.refreshToken : void 0;
|
|
17506
|
+
if (typeof expiresAtRaw === "string" && typeof encryptedRefresh === "string" && encryptedRefresh && appCfg) {
|
|
17507
|
+
const expiresAt = Date.parse(expiresAtRaw);
|
|
17508
|
+
if (!Number.isNaN(expiresAt) && expiresAt - 6e4 <= Date.now()) try {
|
|
17509
|
+
const refreshPlain = decryptValue(encryptedRefresh, app.config.secrets.encryptionKey);
|
|
17510
|
+
const refreshed = await refreshAppUserToken(appCfg.clientId, appCfg.clientSecret, refreshPlain);
|
|
17511
|
+
const nextMetadata = {
|
|
17512
|
+
...metadata ?? {},
|
|
17513
|
+
accessToken: encryptValue(refreshed.accessToken, app.config.secrets.encryptionKey),
|
|
17514
|
+
accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
|
|
17515
|
+
refreshToken: encryptValue(refreshed.refreshToken, app.config.secrets.encryptionKey),
|
|
17516
|
+
refreshTokenExpiresAt: refreshed.refreshTokenExpiresAt
|
|
17517
|
+
};
|
|
17518
|
+
await app.db.update(authIdentities).set({
|
|
17519
|
+
metadata: nextMetadata,
|
|
17520
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
17521
|
+
}).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github")));
|
|
17522
|
+
token = refreshed.accessToken;
|
|
17523
|
+
} catch (err) {
|
|
17524
|
+
app.log.warn({
|
|
17525
|
+
err,
|
|
17526
|
+
userId
|
|
17527
|
+
}, "github app user-token refresh failed");
|
|
17528
|
+
if ((err instanceof GithubAppApiError ? err.status : 503) === 401) return reply.status(403).send({
|
|
17529
|
+
error: "Your GitHub session has expired. Please sign in again.",
|
|
17530
|
+
code: "refresh_failed"
|
|
17531
|
+
});
|
|
17532
|
+
return reply.status(503).send({ error: "Couldn't refresh GitHub credentials. Try again, or reconnect your GitHub account." });
|
|
17533
|
+
}
|
|
17534
|
+
}
|
|
16589
17535
|
try {
|
|
16590
17536
|
return { repos: await listUserRepos(token) };
|
|
16591
17537
|
} catch (err) {
|
|
@@ -16644,7 +17590,7 @@ async function meRoutes(app) {
|
|
|
16644
17590
|
*/
|
|
16645
17591
|
app.get("/me/pinned-agents", async (request) => {
|
|
16646
17592
|
const { userId } = requireUser(request);
|
|
16647
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
17593
|
+
const { listMyPinnedAgents } = await import("./client-93HZWg84-MIPzQD9A.mjs");
|
|
16648
17594
|
return listMyPinnedAgents(app.db, { userId });
|
|
16649
17595
|
});
|
|
16650
17596
|
/**
|
|
@@ -16830,7 +17776,8 @@ async function orgActivityRoutes(app) {
|
|
|
16830
17776
|
activeSessions: a.activeSessions,
|
|
16831
17777
|
totalSessions: a.totalSessions,
|
|
16832
17778
|
runtimeUpdatedAt: a.runtimeUpdatedAt?.toISOString() ?? null,
|
|
16833
|
-
type: "type" in a ? a.type : null
|
|
17779
|
+
type: "type" in a ? a.type : null,
|
|
17780
|
+
managedByMe: "managerId" in a ? a.managerId === scope.memberId : false
|
|
16834
17781
|
}))
|
|
16835
17782
|
};
|
|
16836
17783
|
});
|
|
@@ -17147,6 +18094,94 @@ function orgIdParam(params) {
|
|
|
17147
18094
|
return typeof orgId === "string" ? orgId : null;
|
|
17148
18095
|
}
|
|
17149
18096
|
/**
|
|
18097
|
+
* Where the post-install OAuth callback lands the user once the install
|
|
18098
|
+
* dialog is done — back on the Settings → GitHub panel so it can
|
|
18099
|
+
* re-render with the now-bound installation. The callback resolves the
|
|
18100
|
+
* actual destination from the signed state JWT, not from a query param,
|
|
18101
|
+
* so this is tamper-proof.
|
|
18102
|
+
*/
|
|
18103
|
+
const POST_INSTALL_NEXT = "/settings/github";
|
|
18104
|
+
/**
|
|
18105
|
+
* Class B — `/api/v1/orgs/:orgId/github-app-installation`.
|
|
18106
|
+
*
|
|
18107
|
+
* Read-only admin view of the GitHub App installation bound to this Hub
|
|
18108
|
+
* team. Powers the Settings → Integrations panel. 404 when no install is
|
|
18109
|
+
* bound (the panel renders the "Install on GitHub" prompt in that case).
|
|
18110
|
+
*
|
|
18111
|
+
* Distinct from `/orgs/:orgId/settings/:namespace` because installations
|
|
18112
|
+
* aren't editable through the same PUT/DELETE shape — the row's lifecycle
|
|
18113
|
+
* is driven by GitHub events (install / uninstall / suspend) and the
|
|
18114
|
+
* OAuth callback. The Settings panel surfaces it for visibility but the
|
|
18115
|
+
* write path is upstream.
|
|
18116
|
+
*
|
|
18117
|
+
* Admin-only: the installation block exposes account-level metadata
|
|
18118
|
+
* (login, permissions, events) that a regular member doesn't need.
|
|
18119
|
+
* Mirrors the readPolicy="admin" choice for `github_integration` in the
|
|
18120
|
+
* legacy settings.
|
|
18121
|
+
*/
|
|
18122
|
+
async function orgGithubAppRoutes(app) {
|
|
18123
|
+
app.get("/", async (request) => {
|
|
18124
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18125
|
+
const row = await findInstallationByOrg(app.db, scope.organizationId);
|
|
18126
|
+
if (!row) throw new NotFoundError("No GitHub App installation is bound to this team");
|
|
18127
|
+
const accountType = row.accountType;
|
|
18128
|
+
const manageUrl = accountType === "Organization" ? `https://github.com/organizations/${encodeURIComponent(row.accountLogin)}/settings/installations/${row.installationId}` : `https://github.com/settings/installations/${row.installationId}`;
|
|
18129
|
+
return {
|
|
18130
|
+
installationId: row.installationId,
|
|
18131
|
+
accountType,
|
|
18132
|
+
accountLogin: row.accountLogin,
|
|
18133
|
+
accountGithubId: row.accountGithubId,
|
|
18134
|
+
permissions: row.permissions,
|
|
18135
|
+
events: row.events,
|
|
18136
|
+
suspended: row.suspendedAt !== null,
|
|
18137
|
+
manageUrl,
|
|
18138
|
+
createdAt: row.createdAt.toISOString(),
|
|
18139
|
+
updatedAt: row.updatedAt.toISOString()
|
|
18140
|
+
};
|
|
18141
|
+
});
|
|
18142
|
+
app.get("/install-url", async (request, reply) => {
|
|
18143
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18144
|
+
const appCfg = app.config.oauth?.githubApp;
|
|
18145
|
+
if (!appCfg?.slug) return reply.status(503).send({ error: "GitHub App install URL is unavailable — FIRST_TREE_HUB_GITHUB_APP_SLUG is not configured." });
|
|
18146
|
+
const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, POST_INSTALL_NEXT, { targetOrganizationId: scope.organizationId });
|
|
18147
|
+
reply.header("Set-Cookie", buildCookie({
|
|
18148
|
+
name: OAUTH_STATE_COOKIE,
|
|
18149
|
+
value: nonce,
|
|
18150
|
+
maxAge: 600,
|
|
18151
|
+
secure: process.env.NODE_ENV === "production"
|
|
18152
|
+
}));
|
|
18153
|
+
return { installUrl: buildAppInstallUrl({
|
|
18154
|
+
appSlug: appCfg.slug,
|
|
18155
|
+
state: token
|
|
18156
|
+
}) };
|
|
18157
|
+
});
|
|
18158
|
+
app.post("/claim", async (request) => {
|
|
18159
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18160
|
+
const { installationId } = githubAppInstallationClaimBodySchema.parse(request.body);
|
|
18161
|
+
const githubToken = await getStoredGithubAccessToken(app.db, scope.userId, app.config.secrets.encryptionKey);
|
|
18162
|
+
if (!githubToken) throw new ForbiddenError("No GitHub access token on file — sign in with GitHub again before claiming an install");
|
|
18163
|
+
let accessible;
|
|
18164
|
+
try {
|
|
18165
|
+
accessible = await listUserAccessibleInstallationIds(githubToken);
|
|
18166
|
+
} catch (err) {
|
|
18167
|
+
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");
|
|
18168
|
+
app.log.warn({
|
|
18169
|
+
err,
|
|
18170
|
+
installationId,
|
|
18171
|
+
userId: scope.userId
|
|
18172
|
+
}, "claim: /user/installations check failed");
|
|
18173
|
+
throw new ForbiddenError("Couldn't verify GitHub access for this installation — try again in a moment");
|
|
18174
|
+
}
|
|
18175
|
+
if (!accessible.has(installationId)) throw new ForbiddenError("You don't administer this installation on GitHub");
|
|
18176
|
+
await bindInstallationToOrg(app.db, installationId, scope.organizationId);
|
|
18177
|
+
return {
|
|
18178
|
+
installationId,
|
|
18179
|
+
organizationId: scope.organizationId,
|
|
18180
|
+
bound: true
|
|
18181
|
+
};
|
|
18182
|
+
});
|
|
18183
|
+
}
|
|
18184
|
+
/**
|
|
17150
18185
|
* Class B — `/api/v1/orgs/:orgId` itself: read & rename the org row.
|
|
17151
18186
|
* Replaces the deleted `/admin/organizations/:id` GET/PATCH pair.
|
|
17152
18187
|
*/
|
|
@@ -18028,7 +19063,7 @@ async function insertMappingIfAbsent(db, params) {
|
|
|
18028
19063
|
* - direct agent-only chats automatically get `mode=mention_only`
|
|
18029
19064
|
* - watcher rows are recomputed
|
|
18030
19065
|
* - a future addParticipant call would upgrade the chat to `group` via
|
|
18031
|
-
* `
|
|
19066
|
+
* the server's `changeChatType` service instead of raw INSERT shortcuts
|
|
18032
19067
|
*/
|
|
18033
19068
|
async function createEntityChat(db, humanAgentId, delegateAgentId, entity, eventType, action) {
|
|
18034
19069
|
const metadata = chatMetadataSchema$1.parse({
|
|
@@ -18447,6 +19482,40 @@ const MENTION_ACTIONS = {
|
|
|
18447
19482
|
discussion_comment: ["created"],
|
|
18448
19483
|
commit_comment: ["created"]
|
|
18449
19484
|
};
|
|
19485
|
+
/**
|
|
19486
|
+
* Boot-time configuration sanity checks. Called from `buildApp` so BOTH
|
|
19487
|
+
* server entry points are covered:
|
|
19488
|
+
* - `packages/server/src/index.ts` (the standalone bin)
|
|
19489
|
+
* - `packages/command/src/core/server.ts` (the CLI `server start` path)
|
|
19490
|
+
*
|
|
19491
|
+
* The previous incarnation lived in `index.ts` and was only run by the
|
|
19492
|
+
* standalone bin — the CLI path could boot a misconfigured prod with no
|
|
19493
|
+
* surface complaint (codex P1-8).
|
|
19494
|
+
*
|
|
19495
|
+
* Throws on misconfiguration; never returns a value.
|
|
19496
|
+
*/
|
|
19497
|
+
function assertBootConfigValid(config) {
|
|
19498
|
+
assertProductionRequiresPublicUrl(config);
|
|
19499
|
+
assertGithubAppConfigComplete(config);
|
|
19500
|
+
}
|
|
19501
|
+
function assertProductionRequiresPublicUrl(config) {
|
|
19502
|
+
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.");
|
|
19503
|
+
}
|
|
19504
|
+
function assertGithubAppConfigComplete(config) {
|
|
19505
|
+
const ghApp = config.oauth?.githubApp;
|
|
19506
|
+
if (!ghApp) return;
|
|
19507
|
+
const required = {
|
|
19508
|
+
FIRST_TREE_HUB_GITHUB_APP_ID: ghApp.appId,
|
|
19509
|
+
FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID: ghApp.clientId,
|
|
19510
|
+
FIRST_TREE_HUB_GITHUB_APP_CLIENT_SECRET: ghApp.clientSecret,
|
|
19511
|
+
FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY: ghApp.privateKeyPem,
|
|
19512
|
+
FIRST_TREE_HUB_GITHUB_APP_WEBHOOK_SECRET: ghApp.webhookSecret
|
|
19513
|
+
};
|
|
19514
|
+
const missing = Object.entries(required).filter(([, v]) => !v || v.trim().length === 0).map(([k]) => k);
|
|
19515
|
+
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.`);
|
|
19516
|
+
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.");
|
|
19517
|
+
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.");
|
|
19518
|
+
}
|
|
18450
19519
|
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
18451
19520
|
adapterAgentMappings: () => adapterAgentMappings,
|
|
18452
19521
|
adapterChatMappings: () => adapterChatMappings,
|
|
@@ -18461,6 +19530,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
18461
19530
|
chatSubscriptions: () => chatSubscriptions,
|
|
18462
19531
|
chats: () => chats,
|
|
18463
19532
|
clients: () => clients,
|
|
19533
|
+
githubAppInstallations: () => githubAppInstallations,
|
|
18464
19534
|
githubEntityChatMappings: () => githubEntityChatMappings,
|
|
18465
19535
|
inboxEntries: () => inboxEntries,
|
|
18466
19536
|
invitationRedemptions: () => invitationRedemptions,
|
|
@@ -19795,6 +20865,7 @@ async function buildApp(config) {
|
|
|
19795
20865
|
const msg = err instanceof Error ? err.message : String(err);
|
|
19796
20866
|
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
20867
|
}
|
|
20868
|
+
assertBootConfigValid(config);
|
|
19798
20869
|
applyLoggerConfig({
|
|
19799
20870
|
level: config.observability.logging.level,
|
|
19800
20871
|
format: config.observability.logging.format,
|
|
@@ -19971,6 +21042,7 @@ async function buildApp(config) {
|
|
|
19971
21042
|
await scope.register(orgInvitationRoutes, { prefix: "/invitations" });
|
|
19972
21043
|
await scope.register(orgMemberRoutes, { prefix: "/members" });
|
|
19973
21044
|
await scope.register(orgSettingsRoutes, { prefix: "/settings" });
|
|
21045
|
+
await scope.register(orgGithubAppRoutes, { prefix: "/github-app-installation" });
|
|
19974
21046
|
await scope.register(orgContextTreeSnapshotRoutes, { prefix: "/context-tree" });
|
|
19975
21047
|
}), { prefix: "/orgs/:orgId" });
|
|
19976
21048
|
await api.register(orgWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/orgs/:orgId/ws" });
|