@agent-team-foundation/first-tree-hub 0.12.4 → 0.12.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{bootstrap-C_K2CKXC.mjs → bootstrap-BCZC1ki6.mjs} +15 -5
- package/dist/cli/index.mjs +16 -14
- package/dist/{client-D1TDiik_-NV_lkhfI.mjs → client-B89AKi3Q-DAyGdQSq.mjs} +182 -86
- package/dist/{client-0RrgrMjR-CylTJGEb.mjs → client-GOgUQxVe-Dqk9oZf9.mjs} +2 -2
- package/dist/{dist-CMhywpXB.mjs → dist-xP6NpdMp.mjs} +146 -8
- package/dist/drizzle/0036_github_entity_chat_mappings.sql +47 -0
- package/dist/drizzle/0037_github_app_installations.sql +52 -0
- package/dist/drizzle/meta/_journal.json +14 -0
- package/dist/{feishu-tkZS0vvL.mjs → feishu-CsfadBKa.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/{invitation-C299fxkP-CZRV665C.mjs → invitation-C299fxkP-DFBBuUcj.mjs} +1 -1
- package/dist/{saas-connect-S71rG182.mjs → saas-connect-RCN8zL5e.mjs} +1657 -411
- package/dist/web/assets/{index-RNegidl2.js → index-BHNq2Nl1.js} +1 -1
- package/dist/web/assets/index-BaLvRwAX.js +416 -0
- package/dist/web/assets/index-BdW7weV1.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BG9RRx2e.js +0 -401
- package/dist/web/assets/index-CbOOQaWp.css +0 -1
|
@@ -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-xP6NpdMp.mjs";
|
|
6
6
|
import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
|
|
7
7
|
import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
|
|
8
|
-
import { $ as
|
|
8
|
+
import { $ as messages, A as getOnlineCount, B as listActiveAgentsPinnedToClient, C as ensureCanJoin, D as getCachedAudience, E as getActivityOverview, F as invalidateChatAudience, G as listChatsForMember, H as listAgentsWithRuntime, I as joinAsParticipant, J as listMessages, K as listClients, L as joinChat, M as heartbeatClient, N as heartbeatInstance, O as getChatDetail, P as inboxEntries, Q as members, R as leaveAsParticipant, S as editMessage, T as findOrCreateDirectChat, U as listChatParticipantsWithNames, V as listAgentsManagedByUser, W as listChats, X as markStaleAgents, Z as markSupersededByChat, _ as clients, _t as touchAgent, a as agentVisibilityCondition, at as registerChatMessageDispatcher, b as deriveAuthState, bt as upsertSessionState, c as assertParticipant, ct as resetActivity, d as chatParticipants, dt as sendMessage, et as notifyRecipients, f as chatSubscriptions, ft as sendToAgent$1, g as cleanupStalePresence, gt as submitAnswer, h as cleanupStaleClients, ht as setRuntimeState, i as agentPresence, it as recomputeWatchersForMember, j as getPresence, k as getClient, l as bindAgent, lt as resolveChatMembership, m as claimClient, mt as setOffline, n as addParticipant, nt as recomputeChatWatchers, o as agents, ot as registerClient, p as chats, pt as serverInstances, q as listClientsForOrgAdmin, r as agentChatSessions, rt as recomputeWatchersForAgent, s as assertClientOwner, st as removeParticipant, t as addChatParticipants, tt as pendingQuestions, u as changeChatType, ut as retireClient, v as createChat, vt as unbindAgent, w as ensureParticipant$1, x as disconnectClient, y as createNotifier, yt as updateClientCapabilities, z as leaveChat } from "./client-B89AKi3Q-DAyGdQSq.mjs";
|
|
9
9
|
import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { ZodError, z } from "zod";
|
|
@@ -29,8 +29,8 @@ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne,
|
|
|
29
29
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
30
30
|
import postgres from "postgres";
|
|
31
31
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
32
|
-
import { boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
|
|
33
|
-
import { SignJWT, jwtVerify } from "jose";
|
|
32
|
+
import { bigint, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
|
|
33
|
+
import { SignJWT, importPKCS8, jwtVerify } from "jose";
|
|
34
34
|
import cors from "@fastify/cors";
|
|
35
35
|
import rateLimit from "@fastify/rate-limit";
|
|
36
36
|
import fastifyStatic from "@fastify/static";
|
|
@@ -730,6 +730,30 @@ z.object({
|
|
|
730
730
|
expiresIn: z.number(),
|
|
731
731
|
command: z.string()
|
|
732
732
|
});
|
|
733
|
+
const githubEntityTypeSchema = z.enum([
|
|
734
|
+
"issue",
|
|
735
|
+
"pull_request",
|
|
736
|
+
"discussion",
|
|
737
|
+
"commit"
|
|
738
|
+
]);
|
|
739
|
+
const githubChatMetadataSchema = z.object({
|
|
740
|
+
source: z.literal("github"),
|
|
741
|
+
entityType: githubEntityTypeSchema,
|
|
742
|
+
entityKey: z.string().min(1),
|
|
743
|
+
entityUrl: z.string().url().optional()
|
|
744
|
+
});
|
|
745
|
+
const feishuChatMetadataSchema = z.object({
|
|
746
|
+
source: z.literal("feishu"),
|
|
747
|
+
externalChannelId: z.string().min(1)
|
|
748
|
+
});
|
|
749
|
+
const chatMetadataSchema = z.discriminatedUnion("source", [githubChatMetadataSchema, feishuChatMetadataSchema]);
|
|
750
|
+
/**
|
|
751
|
+
* `createChat` callers may not set metadata at all (admin-created group chats,
|
|
752
|
+
* me-chats, …), so the input schema accepts either an empty object or one of
|
|
753
|
+
* the typed variants. The empty `{}` arm is `.strict()` so a caller cannot
|
|
754
|
+
* sneak through `{ source: "github" }` without the required fields.
|
|
755
|
+
*/
|
|
756
|
+
const optionalChatMetadataSchema = z.union([z.object({}).strict(), chatMetadataSchema]);
|
|
733
757
|
const chatTypeSchema = z.enum([
|
|
734
758
|
"direct",
|
|
735
759
|
"group",
|
|
@@ -739,7 +763,7 @@ z.object({
|
|
|
739
763
|
type: chatTypeSchema,
|
|
740
764
|
topic: z.string().max(500).optional(),
|
|
741
765
|
participantIds: z.array(z.string()).min(1),
|
|
742
|
-
metadata:
|
|
766
|
+
metadata: optionalChatMetadataSchema.optional()
|
|
743
767
|
});
|
|
744
768
|
const chatParticipantSchema = z.object({
|
|
745
769
|
agentId: z.string(),
|
|
@@ -767,10 +791,7 @@ z.object({
|
|
|
767
791
|
firstMessagePreview: z.string().nullable()
|
|
768
792
|
});
|
|
769
793
|
z.object({ topic: z.string().trim().max(500).nullable() });
|
|
770
|
-
z.object({
|
|
771
|
-
agentId: z.string().min(1),
|
|
772
|
-
mode: z.enum(["full", "mention_only"]).default("full")
|
|
773
|
-
});
|
|
794
|
+
z.object({ agentId: z.string().min(1) });
|
|
774
795
|
z.object({ agentId: z.string().min(1) });
|
|
775
796
|
const clientStatusSchema = z.enum(["connected", "disconnected"]);
|
|
776
797
|
/**
|
|
@@ -954,6 +975,45 @@ z.object({
|
|
|
954
975
|
edges: z.array(contextTreeEdgeSchema),
|
|
955
976
|
changes: z.array(contextTreeChangeSchema)
|
|
956
977
|
});
|
|
978
|
+
const githubAccountTypeSchema = z.enum(["User", "Organization"]);
|
|
979
|
+
const githubPermissionLevelSchema = z.enum([
|
|
980
|
+
"read",
|
|
981
|
+
"write",
|
|
982
|
+
"admin"
|
|
983
|
+
]);
|
|
984
|
+
/**
|
|
985
|
+
* `installation.permissions` blob from GitHub. Key is the permission name
|
|
986
|
+
* (`contents`, `pull_requests`, `issues`, `members`, …) — we keep this as a
|
|
987
|
+
* free-form `z.record` because GitHub adds new permission keys over time
|
|
988
|
+
* and we don't want a Hub-side `app_id` upgrade just to surface a new key
|
|
989
|
+
* in the integrations panel.
|
|
990
|
+
*/
|
|
991
|
+
const githubAppInstallationPermissionsSchema = z.record(z.string(), githubPermissionLevelSchema);
|
|
992
|
+
/**
|
|
993
|
+
* Subscribed event-name list, e.g. `["issues", "pull_request", "push"]`.
|
|
994
|
+
* Free-form for the same forward-compat reason as `permissions`.
|
|
995
|
+
*/
|
|
996
|
+
const githubAppInstallationEventsSchema = z.array(z.string());
|
|
997
|
+
z.object({
|
|
998
|
+
login: z.string().optional(),
|
|
999
|
+
accessToken: z.string().optional(),
|
|
1000
|
+
accessTokenExpiresAt: z.string().datetime({ offset: true }).optional(),
|
|
1001
|
+
refreshToken: z.string().optional(),
|
|
1002
|
+
refreshTokenExpiresAt: z.string().datetime({ offset: true }).optional()
|
|
1003
|
+
});
|
|
1004
|
+
z.object({ installationId: z.number().int().positive() });
|
|
1005
|
+
z.object({
|
|
1006
|
+
installationId: z.number().int().positive(),
|
|
1007
|
+
accountType: githubAccountTypeSchema,
|
|
1008
|
+
accountLogin: z.string(),
|
|
1009
|
+
accountGithubId: z.number().int().positive(),
|
|
1010
|
+
permissions: githubAppInstallationPermissionsSchema,
|
|
1011
|
+
events: githubAppInstallationEventsSchema,
|
|
1012
|
+
suspended: z.boolean(),
|
|
1013
|
+
manageUrl: z.string().url(),
|
|
1014
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
1015
|
+
updatedAt: z.string().datetime({ offset: true })
|
|
1016
|
+
});
|
|
957
1017
|
/**
|
|
958
1018
|
* MIME types the web + client image paths recognise. Kept in sync with
|
|
959
1019
|
* Claude's vision API (see packages/client/src/handlers/claude-code.ts).
|
|
@@ -1334,14 +1394,24 @@ z.object({
|
|
|
1334
1394
|
z.object({ next: z.string().max(256).optional() });
|
|
1335
1395
|
z.object({
|
|
1336
1396
|
code: z.string().min(1),
|
|
1337
|
-
state: z.string().min(1)
|
|
1397
|
+
state: z.string().min(1),
|
|
1398
|
+
installation_id: z.string().regex(/^\d+$/).optional(),
|
|
1399
|
+
setup_action: z.enum([
|
|
1400
|
+
"install",
|
|
1401
|
+
"update",
|
|
1402
|
+
"request"
|
|
1403
|
+
]).optional()
|
|
1338
1404
|
});
|
|
1339
1405
|
z.object({
|
|
1340
1406
|
githubId: z.string().min(1),
|
|
1341
1407
|
login: z.string().min(1),
|
|
1342
1408
|
email: z.string().email().optional(),
|
|
1343
1409
|
displayName: z.string().optional(),
|
|
1344
|
-
next: z.string().max(256).optional()
|
|
1410
|
+
next: z.string().max(256).optional(),
|
|
1411
|
+
installationId: z.string().regex(/^\d+$/).optional(),
|
|
1412
|
+
installationAccountType: z.enum(["User", "Organization"]).optional(),
|
|
1413
|
+
installationAccountLogin: z.string().min(1).optional(),
|
|
1414
|
+
installationAccountGithubId: z.string().regex(/^\d+$/).optional()
|
|
1345
1415
|
});
|
|
1346
1416
|
/**
|
|
1347
1417
|
* Per-organization settings — schemas, namespaces, and the registry that
|
|
@@ -1836,12 +1906,22 @@ defineConfig({
|
|
|
1836
1906
|
}),
|
|
1837
1907
|
githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
|
|
1838
1908
|
}),
|
|
1839
|
-
oauth: optional({
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1909
|
+
oauth: optional({ githubApp: optional({
|
|
1910
|
+
appId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_ID" }),
|
|
1911
|
+
clientId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID" }),
|
|
1912
|
+
clientSecret: field(z.string().min(1), {
|
|
1913
|
+
env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_SECRET",
|
|
1843
1914
|
secret: true
|
|
1844
|
-
})
|
|
1915
|
+
}),
|
|
1916
|
+
privateKeyPem: field(z.string().min(1), {
|
|
1917
|
+
env: "FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY",
|
|
1918
|
+
secret: true
|
|
1919
|
+
}),
|
|
1920
|
+
webhookSecret: field(z.string().min(1), {
|
|
1921
|
+
env: "FIRST_TREE_HUB_GITHUB_APP_WEBHOOK_SECRET",
|
|
1922
|
+
secret: true
|
|
1923
|
+
}),
|
|
1924
|
+
slug: field(z.string().min(1).optional(), { env: "FIRST_TREE_HUB_GITHUB_APP_SLUG" })
|
|
1845
1925
|
}) }),
|
|
1846
1926
|
cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
|
|
1847
1927
|
trustProxy: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_TRUST_PROXY" }),
|
|
@@ -1942,6 +2022,74 @@ async function writeImage(params) {
|
|
|
1942
2022
|
return path;
|
|
1943
2023
|
}
|
|
1944
2024
|
const FETCH_TIMEOUT_MS = 15e3;
|
|
2025
|
+
/**
|
|
2026
|
+
* Node-level error codes (undici / DNS / TCP) treated as transient by the
|
|
2027
|
+
* `doFetch` retry layer. The set covers the failure modes that a *brief*
|
|
2028
|
+
* network blip can produce mid-request:
|
|
2029
|
+
*
|
|
2030
|
+
* - `ECONNRESET` — TCP RST mid-stream (commonly a keep-alive idle
|
|
2031
|
+
* connection closed by the peer and reused before we
|
|
2032
|
+
* noticed)
|
|
2033
|
+
* - `ETIMEDOUT` — kernel-level connect/read timeout (peer slow, not
|
|
2034
|
+
* absent)
|
|
2035
|
+
* - `ENETUNREACH` — transient routing-table flap (local network reload,
|
|
2036
|
+
* wifi roam)
|
|
2037
|
+
* - `EAI_AGAIN` — DNS resolver returned a temporary failure; the
|
|
2038
|
+
* resolver itself tells us to retry
|
|
2039
|
+
* - `UND_ERR_SOCKET` — undici's internal socket-level error, wrapping the
|
|
2040
|
+
* above when the request happens through its
|
|
2041
|
+
* connection pool
|
|
2042
|
+
*
|
|
2043
|
+
* Deliberately **not** retried at this layer:
|
|
2044
|
+
* - `ECONNREFUSED` — peer is reachable but refusing; retrying immediately
|
|
2045
|
+
* won't fix anything, and the caller's higher-level
|
|
2046
|
+
* reconnect logic is the right response
|
|
2047
|
+
* - `ENOTFOUND` — DNS reports the host doesn't exist (typo or rotated
|
|
2048
|
+
* record); a 1s retry isn't going to materialise the
|
|
2049
|
+
* record
|
|
2050
|
+
* - other 4xx-class HTTP statuses — handled by the caller, never reach
|
|
2051
|
+
* this set
|
|
2052
|
+
*/
|
|
2053
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
2054
|
+
"ECONNRESET",
|
|
2055
|
+
"ETIMEDOUT",
|
|
2056
|
+
"ENETUNREACH",
|
|
2057
|
+
"EAI_AGAIN",
|
|
2058
|
+
"UND_ERR_SOCKET"
|
|
2059
|
+
]);
|
|
2060
|
+
function sleep$1(ms) {
|
|
2061
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Decide whether an error thrown by `fetch()` represents a transient
|
|
2065
|
+
* network-layer failure that the caller should retry.
|
|
2066
|
+
*
|
|
2067
|
+
* Walks the `cause` chain (undici nests the real reason one level deep via
|
|
2068
|
+
* `TypeError("fetch failed").cause`) and checks each link for:
|
|
2069
|
+
* - `message` containing `"fetch failed"` (undici's signature)
|
|
2070
|
+
* - `name === "AbortError"` (our 15s `AbortSignal.timeout`)
|
|
2071
|
+
* - `code` ∈ `RETRYABLE_NETWORK_CODES`
|
|
2072
|
+
*
|
|
2073
|
+
* `unknown` input is intentional: this function is the gatekeeper for the
|
|
2074
|
+
* retry decision in `doFetch`'s catch block, where TS sees `unknown`.
|
|
2075
|
+
*
|
|
2076
|
+
* Depth is bounded (~5) to defend against the pathological case of a
|
|
2077
|
+
* self-referencing cause chain.
|
|
2078
|
+
*/
|
|
2079
|
+
function isTransientNetworkError(err) {
|
|
2080
|
+
let current = err;
|
|
2081
|
+
let depth = 0;
|
|
2082
|
+
while (current !== null && current !== void 0 && depth < 5) {
|
|
2083
|
+
if (typeof current !== "object") return false;
|
|
2084
|
+
const obj = current;
|
|
2085
|
+
if (typeof obj.message === "string" && obj.message.includes("fetch failed")) return true;
|
|
2086
|
+
if (obj.name === "AbortError") return true;
|
|
2087
|
+
if (typeof obj.code === "string" && RETRYABLE_NETWORK_CODES.has(obj.code)) return true;
|
|
2088
|
+
current = obj.cause;
|
|
2089
|
+
depth++;
|
|
2090
|
+
}
|
|
2091
|
+
return false;
|
|
2092
|
+
}
|
|
1945
2093
|
var FirstTreeHubSDK = class {
|
|
1946
2094
|
_baseUrl;
|
|
1947
2095
|
getAccessToken;
|
|
@@ -2063,7 +2211,58 @@ var FirstTreeHubSDK = class {
|
|
|
2063
2211
|
if (!response.ok) throw await this.toSdkError(response);
|
|
2064
2212
|
return await response.json();
|
|
2065
2213
|
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Retry transient network-layer failures and HTTP 5xx with a fixed backoff
|
|
2216
|
+
* schedule. Short-term fix for the chat-visible `Result forward failed:
|
|
2217
|
+
* fetch failed` errors (see docs/sdk-fetch-retry-design.md): undici's
|
|
2218
|
+
* "fetch failed" / `AbortError` / `ECONNRESET`-class errors and any 5xx
|
|
2219
|
+
* response trigger up to two retries with `[0, 500ms, 1000ms]` spacing,
|
|
2220
|
+
* adding at most ~1.5s of latency beyond the per-attempt 15s timeout.
|
|
2221
|
+
*
|
|
2222
|
+
* 4xx responses and non-network exceptions are returned/thrown unchanged
|
|
2223
|
+
* — they indicate a deterministic failure that retrying cannot fix.
|
|
2224
|
+
*
|
|
2225
|
+
* Idempotency caveat: `sendMessage` is not natively idempotent, so a
|
|
2226
|
+
* `fetch failed` from a request the server actually committed will
|
|
2227
|
+
* produce a duplicate message on retry. The design accepts this for now
|
|
2228
|
+
* (small window, low rate, mitigated long-term by an Outbox pattern with
|
|
2229
|
+
* client-generated UUIDs).
|
|
2230
|
+
*
|
|
2231
|
+
* The retry signature and externally-visible behaviour match `doFetch`'s
|
|
2232
|
+
* pre-retry contract: callers see the same Response on success or the
|
|
2233
|
+
* same error type on terminal failure.
|
|
2234
|
+
*/
|
|
2066
2235
|
async doFetch(path, init) {
|
|
2236
|
+
const delays = [
|
|
2237
|
+
0,
|
|
2238
|
+
500,
|
|
2239
|
+
1e3
|
|
2240
|
+
];
|
|
2241
|
+
let lastErr;
|
|
2242
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
2243
|
+
const delay = delays[attempt];
|
|
2244
|
+
if (delay !== void 0 && delay > 0) await sleep$1(delay);
|
|
2245
|
+
try {
|
|
2246
|
+
const response = await this.doFetchOnce(path, init);
|
|
2247
|
+
const isLastAttempt = attempt === delays.length - 1;
|
|
2248
|
+
if (response.status >= 500 && !isLastAttempt) {
|
|
2249
|
+
console.warn(`sdk: retry attempt=${attempt + 1} reason=http-${response.status} path=${path}`);
|
|
2250
|
+
lastErr = /* @__PURE__ */ new Error(`HTTP ${response.status}`);
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
return response;
|
|
2254
|
+
} catch (err) {
|
|
2255
|
+
lastErr = err;
|
|
2256
|
+
if (!isTransientNetworkError(err)) throw err;
|
|
2257
|
+
if (!(attempt === delays.length - 1)) {
|
|
2258
|
+
const reason = err instanceof Error ? err.name === "AbortError" ? "timeout" : err.message.slice(0, 60) : "unknown";
|
|
2259
|
+
console.warn(`sdk: retry attempt=${attempt + 1} reason=${reason} path=${path}`);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
throw lastErr;
|
|
2264
|
+
}
|
|
2265
|
+
async doFetchOnce(path, init) {
|
|
2067
2266
|
const url = `${this._baseUrl}${path}`;
|
|
2068
2267
|
const headers = { Authorization: `Bearer ${await this.getAccessToken()}` };
|
|
2069
2268
|
if (this._agentId) headers[AGENT_SELECTOR_HEADER] = this._agentId;
|
|
@@ -3031,7 +3230,8 @@ function generateToolsDoc() {
|
|
|
3031
3230
|
You are running inside **Agent Hub**, a messaging platform for agent teams.
|
|
3032
3231
|
|
|
3033
3232
|
- Messages from other team members arrive as your prompt input
|
|
3034
|
-
- Each message includes a \`[From:
|
|
3233
|
+
- Each message includes a \`[From: <agent-name>]\` header — that name is also
|
|
3234
|
+
what you pass back to \`agent send\` to reply to or address that agent
|
|
3035
3235
|
- **Your final text response is automatically delivered** to the chat — just respond normally
|
|
3036
3236
|
- For **proactive communication** (sending to other agents, other chats, or structured data),
|
|
3037
3237
|
use the \`first-tree-hub\` CLI below
|
|
@@ -3046,8 +3246,8 @@ These are injected automatically when the agent process starts:
|
|
|
3046
3246
|
|----------|-------------|
|
|
3047
3247
|
| \`FIRST_TREE_HUB_SERVER_URL\` | Server address for API calls |
|
|
3048
3248
|
| \`FIRST_TREE_HUB_ACCESS_TOKEN\` | User member access JWT (short-lived) |
|
|
3049
|
-
| \`FIRST_TREE_HUB_AGENT_ID\` |
|
|
3050
|
-
| \`FIRST_TREE_HUB_CHAT_ID\` |
|
|
3249
|
+
| \`FIRST_TREE_HUB_AGENT_ID\` | YOUR own agent UUID. The CLI reads it to identify you as the sender — never pass it as a \`send\` target. |
|
|
3250
|
+
| \`FIRST_TREE_HUB_CHAT_ID\` | The chat this session is currently bound to. The CLI uses it to route messages — you don't need to pass it manually. |
|
|
3051
3251
|
|
|
3052
3252
|
The \`first-tree-hub\` CLI reads these automatically — no extra setup needed.
|
|
3053
3253
|
|
|
@@ -3057,13 +3257,18 @@ Use the \`first-tree-hub agent send\` CLI — it reads the env vars above and
|
|
|
3057
3257
|
attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
|
|
3058
3258
|
|
|
3059
3259
|
\`\`\`bash
|
|
3060
|
-
# Send to another agent —
|
|
3061
|
-
#
|
|
3260
|
+
# Send to another agent — first positional argument is the recipient's NAME
|
|
3261
|
+
# (NOT a uuid; uuids in chat history / participant lists are not accepted).
|
|
3062
3262
|
# Run \`first-tree-hub agent list\` to see available names.
|
|
3263
|
+
#
|
|
3264
|
+
# Routing: if the recipient is a participant of your current chat (typically
|
|
3265
|
+
# the case in a group chat where someone @-mentioned you to talk to them),
|
|
3266
|
+
# the message stays in that chat. Otherwise it falls back to a direct chat
|
|
3267
|
+
# between you and the recipient. You don't need to think about which.
|
|
3063
3268
|
first-tree-hub agent send <agentName> "your message"
|
|
3064
3269
|
|
|
3065
|
-
# Send
|
|
3066
|
-
#
|
|
3270
|
+
# Send into a specific chat by id — use this only when you explicitly want
|
|
3271
|
+
# to address a chat your current session is NOT bound to.
|
|
3067
3272
|
first-tree-hub agent send --chat <chatId> "your message"
|
|
3068
3273
|
|
|
3069
3274
|
# Send markdown (default format is text)
|
|
@@ -6922,6 +7127,36 @@ function resolveReplyToFromEnv(env, override) {
|
|
|
6922
7127
|
replyToChat: override.replyToChat ?? (envComplete ? envChatId : void 0)
|
|
6923
7128
|
};
|
|
6924
7129
|
}
|
|
7130
|
+
function resolveSenderName(input) {
|
|
7131
|
+
const { override, envAgentId, agents } = input;
|
|
7132
|
+
if (agents.size === 0) return { kind: "none" };
|
|
7133
|
+
if (override) return {
|
|
7134
|
+
kind: "ok",
|
|
7135
|
+
name: override
|
|
7136
|
+
};
|
|
7137
|
+
if (envAgentId) {
|
|
7138
|
+
for (const [name, cfg] of agents) if (cfg.agentId === envAgentId) return {
|
|
7139
|
+
kind: "ok",
|
|
7140
|
+
name
|
|
7141
|
+
};
|
|
7142
|
+
return {
|
|
7143
|
+
kind: "envMismatch",
|
|
7144
|
+
envAgentId,
|
|
7145
|
+
available: [...agents.keys()]
|
|
7146
|
+
};
|
|
7147
|
+
}
|
|
7148
|
+
if (agents.size === 1) {
|
|
7149
|
+
const [only] = [...agents.keys()];
|
|
7150
|
+
if (only) return {
|
|
7151
|
+
kind: "ok",
|
|
7152
|
+
name: only
|
|
7153
|
+
};
|
|
7154
|
+
}
|
|
7155
|
+
return {
|
|
7156
|
+
kind: "ambiguous",
|
|
7157
|
+
available: [...agents.keys()]
|
|
7158
|
+
};
|
|
7159
|
+
}
|
|
6925
7160
|
//#endregion
|
|
6926
7161
|
//#region src/core/admin.ts
|
|
6927
7162
|
/**
|
|
@@ -9091,7 +9326,7 @@ async function onboardCreate(args) {
|
|
|
9091
9326
|
}
|
|
9092
9327
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
9093
9328
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
9094
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9329
|
+
const { bindFeishuBot } = await import("./feishu-CsfadBKa.mjs").then((n) => n.r);
|
|
9095
9330
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
9096
9331
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
9097
9332
|
else {
|
|
@@ -10304,7 +10539,7 @@ function createFeedbackHandler(config) {
|
|
|
10304
10539
|
return { handle };
|
|
10305
10540
|
}
|
|
10306
10541
|
//#endregion
|
|
10307
|
-
//#region ../server/dist/app-
|
|
10542
|
+
//#region ../server/dist/app-DFZ1LKZa.mjs
|
|
10308
10543
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
10309
10544
|
init_esm();
|
|
10310
10545
|
var __defProp = Object.defineProperty;
|
|
@@ -10689,7 +10924,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
10689
10924
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
10690
10925
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10691
10926
|
}
|
|
10692
|
-
const log$
|
|
10927
|
+
const log$6 = createLogger$1("Adapters");
|
|
10693
10928
|
function parseId(raw) {
|
|
10694
10929
|
const id = Number(raw);
|
|
10695
10930
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -10715,7 +10950,7 @@ async function adapterRoutes(app) {
|
|
|
10715
10950
|
const existing = await getAdapterConfig(app.db, id);
|
|
10716
10951
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10717
10952
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
10718
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10953
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after update"));
|
|
10719
10954
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10720
10955
|
return {
|
|
10721
10956
|
...config,
|
|
@@ -10729,7 +10964,7 @@ async function adapterRoutes(app) {
|
|
|
10729
10964
|
const existing = await getAdapterConfig(app.db, id);
|
|
10730
10965
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10731
10966
|
await deleteAdapterConfig(app.db, id);
|
|
10732
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10967
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after delete"));
|
|
10733
10968
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10734
10969
|
return reply.status(204).send();
|
|
10735
10970
|
});
|
|
@@ -10745,6 +10980,7 @@ function requireAgent(request) {
|
|
|
10745
10980
|
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
10746
10981
|
return agent;
|
|
10747
10982
|
}
|
|
10983
|
+
const log$5 = createLogger$1("AgentChatsRoute");
|
|
10748
10984
|
function serializeChat(chat) {
|
|
10749
10985
|
return {
|
|
10750
10986
|
...chat,
|
|
@@ -10806,6 +11042,15 @@ async function agentChatRoutes(app) {
|
|
|
10806
11042
|
});
|
|
10807
11043
|
app.post("/:chatId/participants", async (request, reply) => {
|
|
10808
11044
|
const identity = requireAgent(request);
|
|
11045
|
+
if (request.body !== null && typeof request.body === "object" && "mode" in request.body) {
|
|
11046
|
+
log$5.warn({
|
|
11047
|
+
code: "MODE_FIELD_DEPRECATED",
|
|
11048
|
+
chatId: request.params.chatId,
|
|
11049
|
+
senderAgentId: identity.uuid,
|
|
11050
|
+
userAgent: request.headers["user-agent"] ?? "unknown"
|
|
11051
|
+
}, "Rejected: addParticipant body contains deprecated `mode` field");
|
|
11052
|
+
return reply.status(400).send({ error: "MODE_FIELD_DEPRECATED: the `mode` field is no longer accepted. Participant mode is derived server-side from chat type + agent type. Remove this field from your request." });
|
|
11053
|
+
}
|
|
10809
11054
|
const body = addParticipantSchema.parse(request.body);
|
|
10810
11055
|
const participants = await addParticipant(app.db, request.params.chatId, identity.uuid, body);
|
|
10811
11056
|
return reply.status(201).send(participants.map((p) => ({
|
|
@@ -11470,34 +11715,28 @@ async function findOrCreateChatForChannel(db, data) {
|
|
|
11470
11715
|
return db.transaction(async (tx) => {
|
|
11471
11716
|
const [botAgent] = await tx.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, data.botAgentId)).limit(1);
|
|
11472
11717
|
const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId(db);
|
|
11718
|
+
const metadata = chatMetadataSchema$1.parse({
|
|
11719
|
+
source: data.platform,
|
|
11720
|
+
externalChannelId: data.externalChannelId
|
|
11721
|
+
});
|
|
11473
11722
|
await tx.insert(chats).values({
|
|
11474
11723
|
id: chatId,
|
|
11475
11724
|
organizationId: orgId,
|
|
11476
11725
|
type: internalType,
|
|
11477
11726
|
topic: data.topic ?? null,
|
|
11478
11727
|
lifecyclePolicy: "adapter_managed",
|
|
11479
|
-
metadata
|
|
11480
|
-
source: data.platform,
|
|
11481
|
-
externalChannelId: data.externalChannelId
|
|
11482
|
-
}
|
|
11728
|
+
metadata
|
|
11483
11729
|
});
|
|
11484
|
-
|
|
11485
|
-
chatId,
|
|
11730
|
+
await addChatParticipants(tx, chatId, data.botAgentId === data.senderAgentId ? [{
|
|
11486
11731
|
agentId: data.botAgentId,
|
|
11487
|
-
role: "member"
|
|
11488
|
-
mode: "full"
|
|
11732
|
+
role: "member"
|
|
11489
11733
|
}] : [{
|
|
11490
|
-
chatId,
|
|
11491
11734
|
agentId: data.botAgentId,
|
|
11492
|
-
role: "member"
|
|
11493
|
-
mode: "full"
|
|
11735
|
+
role: "member"
|
|
11494
11736
|
}, {
|
|
11495
|
-
chatId,
|
|
11496
11737
|
agentId: data.senderAgentId,
|
|
11497
|
-
role: "member"
|
|
11498
|
-
|
|
11499
|
-
}];
|
|
11500
|
-
await tx.insert(chatParticipants).values(participants);
|
|
11738
|
+
role: "member"
|
|
11739
|
+
}]);
|
|
11501
11740
|
await tx.insert(adapterChatMappings).values({
|
|
11502
11741
|
platform: data.platform,
|
|
11503
11742
|
externalChannelId: data.externalChannelId,
|
|
@@ -11508,14 +11747,18 @@ async function findOrCreateChatForChannel(db, data) {
|
|
|
11508
11747
|
return chatId;
|
|
11509
11748
|
});
|
|
11510
11749
|
}
|
|
11511
|
-
/**
|
|
11750
|
+
/**
|
|
11751
|
+
* Ensure an agent is a participant of a chat (no-op if already). Mode is
|
|
11752
|
+
* derived via the canonical entrypoint — pre-fix this also wrote `mode:`
|
|
11753
|
+
* implicitly via schema default `'full'`, which is wrong for non-human
|
|
11754
|
+
* agents in a group chat (the bug §1.1 of the Phase 1 design doc fixes).
|
|
11755
|
+
*/
|
|
11512
11756
|
async function ensureParticipant(db, chatId, agentId) {
|
|
11513
11757
|
const [exists] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
11514
|
-
if (!exists) await db
|
|
11515
|
-
chatId,
|
|
11758
|
+
if (!exists) await addChatParticipants(db, chatId, [{
|
|
11516
11759
|
agentId,
|
|
11517
11760
|
role: "member"
|
|
11518
|
-
}
|
|
11761
|
+
}], { onConflictDoNothing: true });
|
|
11519
11762
|
}
|
|
11520
11763
|
/** Store a cross-reference between internal and external message. */
|
|
11521
11764
|
async function createMessageReference(db, data) {
|
|
@@ -13684,6 +13927,12 @@ const authIdentities = pgTable("auth_identities", {
|
|
|
13684
13927
|
* 32-byte random string. The bcrypt comparison in `authService.login`
|
|
13685
13928
|
* treats it as a plain string and rejects every password — that's the
|
|
13686
13929
|
* intended behaviour: SaaS users cannot fall back to password login.
|
|
13930
|
+
*
|
|
13931
|
+
* `tokens` carries the full App user-to-server bundle when called from the
|
|
13932
|
+
* App OAuth callback, or just `encryptedAccessToken` when called from the
|
|
13933
|
+
* legacy OAuth callback or `dev-callback`. Missing fields are simply not
|
|
13934
|
+
* written — service code that reads `metadata` MUST tolerate the legacy
|
|
13935
|
+
* shape (only `accessToken`), per `auth-identities.ts` jsdoc.
|
|
13687
13936
|
*/
|
|
13688
13937
|
async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
13689
13938
|
const [existing] = await db.select({
|
|
@@ -13693,10 +13942,10 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
|
13693
13942
|
if (existing) {
|
|
13694
13943
|
const patch = {};
|
|
13695
13944
|
if (profile.email) patch.email = profile.email;
|
|
13696
|
-
|
|
13945
|
+
const tokenPatch = buildTokenMetadataPatch(profile, opts);
|
|
13946
|
+
if (tokenPatch) patch.metadata = {
|
|
13697
13947
|
...existing.metadata ?? {},
|
|
13698
|
-
|
|
13699
|
-
login: profile.login
|
|
13948
|
+
...tokenPatch
|
|
13700
13949
|
};
|
|
13701
13950
|
if (Object.keys(patch).length > 0) await db.update(authIdentities).set({
|
|
13702
13951
|
...patch,
|
|
@@ -13716,7 +13965,7 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
|
13716
13965
|
avatarUrl: profile.avatarUrl ?? null
|
|
13717
13966
|
});
|
|
13718
13967
|
const metadata = { login: profile.login };
|
|
13719
|
-
|
|
13968
|
+
Object.assign(metadata, buildTokenMetadataPatch(profile, opts) ?? {});
|
|
13720
13969
|
await tx.insert(authIdentities).values({
|
|
13721
13970
|
id: uuidv7(),
|
|
13722
13971
|
userId,
|
|
@@ -13729,8 +13978,50 @@ async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
|
13729
13978
|
});
|
|
13730
13979
|
return { userId };
|
|
13731
13980
|
}
|
|
13981
|
+
/**
|
|
13982
|
+
* Decrypt the GitHub user-to-server access token persisted on this user's
|
|
13983
|
+
* `auth_identities.metadata`, or `null` when there's no GitHub identity,
|
|
13984
|
+
* no captured token (e.g. `dev-callback` sign-in), or the ciphertext can't
|
|
13985
|
+
* be decoded.
|
|
13986
|
+
*
|
|
13987
|
+
* Does NOT refresh an expired App token — callers that need a guaranteed-
|
|
13988
|
+
* fresh token (the Step 2 repo picker) do the refresh dance inline; callers
|
|
13989
|
+
* that just want a best-effort identity check (the manual install-claim
|
|
13990
|
+
* endpoint) tolerate a stale token failing the downstream GitHub call.
|
|
13991
|
+
*/
|
|
13992
|
+
async function getStoredGithubAccessToken(db, userId, encryptionKey) {
|
|
13993
|
+
const [identity] = await db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.userId, userId))).limit(1);
|
|
13994
|
+
const meta = identity?.metadata && typeof identity.metadata === "object" ? identity.metadata : null;
|
|
13995
|
+
const encrypted = meta && typeof meta.accessToken === "string" && meta.accessToken ? meta.accessToken : null;
|
|
13996
|
+
if (!encrypted) return null;
|
|
13997
|
+
try {
|
|
13998
|
+
return decryptValue(encrypted, encryptionKey);
|
|
13999
|
+
} catch {
|
|
14000
|
+
return null;
|
|
14001
|
+
}
|
|
14002
|
+
}
|
|
14003
|
+
/**
|
|
14004
|
+
* Pluck the token-related keys out of `opts` into the shape that lands on
|
|
14005
|
+
* `auth_identities.metadata`. Returns `null` when no token data was
|
|
14006
|
+
* supplied so the caller can skip the metadata write entirely.
|
|
14007
|
+
*
|
|
14008
|
+
* `login` is always refreshed when any token field is touched, so a user
|
|
14009
|
+
* who renames their GitHub account gets the new login snapshot on next
|
|
14010
|
+
* sign-in.
|
|
14011
|
+
*/
|
|
14012
|
+
function buildTokenMetadataPatch(profile, opts) {
|
|
14013
|
+
if (!opts.encryptedAccessToken) return null;
|
|
14014
|
+
const patch = {
|
|
14015
|
+
login: profile.login,
|
|
14016
|
+
accessToken: opts.encryptedAccessToken
|
|
14017
|
+
};
|
|
14018
|
+
if (opts.accessTokenExpiresAt) patch.accessTokenExpiresAt = opts.accessTokenExpiresAt;
|
|
14019
|
+
if (opts.encryptedRefreshToken) patch.refreshToken = opts.encryptedRefreshToken;
|
|
14020
|
+
if (opts.refreshTokenExpiresAt) patch.refreshTokenExpiresAt = opts.refreshTokenExpiresAt;
|
|
14021
|
+
return patch;
|
|
14022
|
+
}
|
|
13732
14023
|
/** Postgres `unique_violation` SQLSTATE — emitted when a UNIQUE constraint trips. */
|
|
13733
|
-
const PG_UNIQUE_VIOLATION$
|
|
14024
|
+
const PG_UNIQUE_VIOLATION$2 = "23505";
|
|
13734
14025
|
/**
|
|
13735
14026
|
* Pick a candidate username, attempt the caller's INSERT in a transaction,
|
|
13736
14027
|
* and retry under a fresh disambiguator if the UNIQUE(users.username)
|
|
@@ -13749,7 +14040,7 @@ async function insertWithUsernameRetry(db, base, insert) {
|
|
|
13749
14040
|
});
|
|
13750
14041
|
return;
|
|
13751
14042
|
} catch (err) {
|
|
13752
|
-
if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$
|
|
14043
|
+
if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$2) throw err;
|
|
13753
14044
|
candidate = `${base}-${randomBytes(2).toString("hex")}`;
|
|
13754
14045
|
}
|
|
13755
14046
|
candidate = `${base}-${uuidv7().slice(0, 12)}`;
|
|
@@ -13757,47 +14048,297 @@ async function insertWithUsernameRetry(db, base, insert) {
|
|
|
13757
14048
|
await insert(tx, candidate);
|
|
13758
14049
|
});
|
|
13759
14050
|
}
|
|
13760
|
-
const TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
13761
|
-
const USER_URL = "https://api.github.com/user";
|
|
13762
|
-
const USER_EMAILS_URL = "https://api.github.com/user/emails";
|
|
13763
14051
|
/**
|
|
13764
|
-
*
|
|
14052
|
+
* GitHub App service helpers. Two surfaces that ride on top of an App's
|
|
14053
|
+
* private key + OAuth client credentials:
|
|
14054
|
+
*
|
|
14055
|
+
* App-private-key (server-to-server):
|
|
14056
|
+
* - `createAppJwt` — short-lived (≤10min) JWT identifying
|
|
14057
|
+
* Hub-as-this-App to GitHub.
|
|
14058
|
+
* - `mintInstallationToken` — exchange the App JWT for a per-installation
|
|
14059
|
+
* token (~1h TTL). Used when Hub acts as the
|
|
14060
|
+
* App on a tenant's repos (Phase 4 identity
|
|
14061
|
+
* convergence — not yet wired into request
|
|
14062
|
+
* paths).
|
|
14063
|
+
*
|
|
14064
|
+
* App-OAuth (user-to-server, replaces the legacy OAuth App flow):
|
|
14065
|
+
* - `buildAppAuthorizeUrl` — the start URL for the combined OAuth
|
|
14066
|
+
* + install flow (design doc D1).
|
|
14067
|
+
* - `exchangeCodeForAppUserProfile` — callback-side token exchange that
|
|
14068
|
+
* returns the user's profile, the
|
|
14069
|
+
* access + refresh tokens, and their
|
|
14070
|
+
* absolute expiries.
|
|
14071
|
+
* - `refreshAppUserToken` — slide an expiring access token by
|
|
14072
|
+
* trading in its refresh token.
|
|
14073
|
+
*
|
|
14074
|
+
* Design context: `docs/github-app-design-zh.md` §3 ("one installation,
|
|
14075
|
+
* three capabilities") + §5.4 ("services/github-app.ts").
|
|
14076
|
+
*
|
|
14077
|
+
* Stateless by construction: no DB / config singletons. Callers thread
|
|
14078
|
+
* credentials in explicitly so the module is trivially safe under
|
|
14079
|
+
* concurrent request handlers.
|
|
14080
|
+
*/
|
|
14081
|
+
const APP_JWT_ALG = "RS256";
|
|
14082
|
+
/**
|
|
14083
|
+
* GitHub rejects App JWTs past 10 minutes; we ride 9 minutes so callers
|
|
14084
|
+
* don't trip the upper bound from clock skew. Generous-but-safe — the JWT
|
|
14085
|
+
* is cheap to mint (one RS256 signature) so caching is unnecessary.
|
|
14086
|
+
*/
|
|
14087
|
+
const APP_JWT_EXPIRY = "9m";
|
|
14088
|
+
/**
|
|
14089
|
+
* GitHub allows a small backdated `iat` to absorb clock skew on the
|
|
14090
|
+
* caller's side; the docs recommend 60 seconds. We mirror that.
|
|
14091
|
+
*/
|
|
14092
|
+
const APP_JWT_IAT_SKEW_SECONDS = 60;
|
|
14093
|
+
const APP_INSTALLATION_URL = (id) => `https://api.github.com/app/installations/${id}`;
|
|
14094
|
+
const OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
14095
|
+
const OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
|
|
14096
|
+
const USER_API_URL = "https://api.github.com/user";
|
|
14097
|
+
const USER_EMAILS_API_URL = "https://api.github.com/user/emails";
|
|
14098
|
+
const USER_INSTALLATIONS_API_URL = "https://api.github.com/user/installations";
|
|
14099
|
+
/**
|
|
14100
|
+
* Errors from any GitHub API call this module makes. Carries the HTTP
|
|
14101
|
+
* status so route layers can disambiguate auth/permission failures (401 /
|
|
14102
|
+
* 403 / 404) from transient upstream errors (5xx / network).
|
|
14103
|
+
*
|
|
14104
|
+
* Distinct from `github-oauth.ts`'s `GithubApiError` because the App API
|
|
14105
|
+
* surface is a different concern (App-private-key vs. OAuth client
|
|
14106
|
+
* credentials) and we want logs / metrics to tell them apart at a glance.
|
|
14107
|
+
*/
|
|
14108
|
+
var GithubAppApiError = class extends Error {
|
|
14109
|
+
constructor(status, message) {
|
|
14110
|
+
super(message);
|
|
14111
|
+
this.status = status;
|
|
14112
|
+
this.name = "GithubAppApiError";
|
|
14113
|
+
}
|
|
14114
|
+
};
|
|
14115
|
+
/**
|
|
14116
|
+
* Mint an App JWT. RS256-signed; identifies Hub-as-this-App to GitHub for
|
|
14117
|
+
* the next ~9 minutes. Use this directly for `/app/...` endpoints and as
|
|
14118
|
+
* the input to `mintInstallationToken` for `/installation/...` endpoints.
|
|
14119
|
+
*
|
|
14120
|
+
* The PEM is imported on every call. The cost is negligible (microseconds)
|
|
14121
|
+
* and avoids a global mutable cache — keeps this module trivially safe to
|
|
14122
|
+
* call from parallel request handlers without locking. If profiling ever
|
|
14123
|
+
* shows this is a hotspot, add a per-key memoization at the caller side.
|
|
14124
|
+
*/
|
|
14125
|
+
async function createAppJwt(creds) {
|
|
14126
|
+
const key = await importPKCS8(creds.privateKeyPem, APP_JWT_ALG);
|
|
14127
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
14128
|
+
return new SignJWT({}).setProtectedHeader({ alg: APP_JWT_ALG }).setIssuer(creds.appId).setIssuedAt(now - APP_JWT_IAT_SKEW_SECONDS).setExpirationTime(APP_JWT_EXPIRY).sign(key);
|
|
14129
|
+
}
|
|
14130
|
+
/**
|
|
14131
|
+
* Fetch the installation metadata. Used by the OAuth callback path to
|
|
14132
|
+
* resolve the bare `installation_id` query param into a full row before
|
|
14133
|
+
* UPSERTing into `github_app_installations` — so the callback doesn't
|
|
14134
|
+
* depend on the `installation: created` webhook arriving first
|
|
14135
|
+
* (delivery order between callback and webhook is not guaranteed).
|
|
14136
|
+
*
|
|
14137
|
+
* The webhook handler skips this call entirely because the payload it
|
|
14138
|
+
* receives already carries the same shape.
|
|
14139
|
+
*/
|
|
14140
|
+
async function fetchInstallation(appJwt, installationId, opts = {}) {
|
|
14141
|
+
const res = await (opts.fetcher ?? fetch)(APP_INSTALLATION_URL(installationId), { headers: {
|
|
14142
|
+
Authorization: `Bearer ${appJwt}`,
|
|
14143
|
+
Accept: "application/vnd.github+json",
|
|
14144
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
14145
|
+
} });
|
|
14146
|
+
if (!res.ok) throw new GithubAppApiError(res.status, `GitHub App installation fetch failed (${res.status})`);
|
|
14147
|
+
const body = await res.json();
|
|
14148
|
+
return {
|
|
14149
|
+
id: body.id,
|
|
14150
|
+
accountType: body.account.type,
|
|
14151
|
+
accountLogin: body.account.login,
|
|
14152
|
+
accountGithubId: body.account.id,
|
|
14153
|
+
permissions: body.permissions ?? {},
|
|
14154
|
+
events: body.events ?? [],
|
|
14155
|
+
suspendedAt: body.suspended_at ?? null
|
|
14156
|
+
};
|
|
14157
|
+
}
|
|
14158
|
+
/**
|
|
14159
|
+
* List the installation IDs the authenticated GitHub user can administer.
|
|
14160
|
+
* Wraps `GET /user/installations` — GitHub's documented "what installs is
|
|
14161
|
+
* this user allowed to touch" endpoint. Covers both User-type installs
|
|
14162
|
+
* (the user owns the personal account) and Organization-type installs
|
|
14163
|
+
* (the user has admin rights on the org).
|
|
14164
|
+
*
|
|
14165
|
+
* Critical security primitive: the OAuth callback uses this to verify
|
|
14166
|
+
* that an `installation_id` query parameter — which arrives over an
|
|
14167
|
+
* insecure channel (the user's browser address bar) — actually belongs
|
|
14168
|
+
* to the authenticated user. Without this check, any signed-in user
|
|
14169
|
+
* could attach an arbitrary installation_id to their callback URL and
|
|
14170
|
+
* bind another team's installation to their own Hub org (installation
|
|
14171
|
+
* IDs are NOT secrets — they appear in webhook URLs, GitHub-side
|
|
14172
|
+
* Settings pages, and the install dialog's post-install redirect).
|
|
14173
|
+
*
|
|
14174
|
+
* Returns the bare ID set so callers can do O(1) membership checks. The
|
|
14175
|
+
* full installation metadata is fetched separately via `fetchInstallation`
|
|
14176
|
+
* once authorization has cleared.
|
|
14177
|
+
*/
|
|
14178
|
+
async function listUserAccessibleInstallationIds(userAccessToken, opts = {}) {
|
|
14179
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
14180
|
+
const perPage = opts.perPage ?? 100;
|
|
14181
|
+
const maxPages = opts.maxPages ?? 5;
|
|
14182
|
+
const out = /* @__PURE__ */ new Set();
|
|
14183
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
14184
|
+
const res = await fetcher(`${USER_INSTALLATIONS_API_URL}?per_page=${perPage}&page=${page}`, { headers: {
|
|
14185
|
+
Authorization: `Bearer ${userAccessToken}`,
|
|
14186
|
+
Accept: "application/vnd.github+json",
|
|
14187
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
14188
|
+
} });
|
|
14189
|
+
if (!res.ok) throw new GithubAppApiError(res.status, `GitHub /user/installations failed (${res.status})`);
|
|
14190
|
+
const installs = (await res.json()).installations ?? [];
|
|
14191
|
+
for (const inst of installs) if (typeof inst.id === "number") out.add(inst.id);
|
|
14192
|
+
if (installs.length < perPage) break;
|
|
14193
|
+
}
|
|
14194
|
+
return out;
|
|
14195
|
+
}
|
|
14196
|
+
/**
|
|
14197
|
+
* Trade an expiring user-to-server access token for a fresh pair using
|
|
14198
|
+
* its refresh token. Thrown on:
|
|
14199
|
+
* - Network / 5xx — `GithubAppApiError(status, …)`
|
|
14200
|
+
* - 4xx with no JSON body — `GithubAppApiError(status, …)`
|
|
14201
|
+
* - 200 with `error` field — `GithubAppApiError(401, …)` (GitHub returns
|
|
14202
|
+
* 200 OK for an unusable refresh token but signals the error in the
|
|
14203
|
+
* body; we normalize to 401 so route layers can map to "re-login")
|
|
13765
14204
|
*
|
|
13766
|
-
*
|
|
13767
|
-
*
|
|
13768
|
-
* the
|
|
13769
|
-
*
|
|
13770
|
-
|
|
13771
|
-
|
|
13772
|
-
*/
|
|
13773
|
-
async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
14205
|
+
* Designed to be called from the OAuth callback / token-refresh path
|
|
14206
|
+
* landing in PR-C. The caller decrypts the stored refresh token, hands
|
|
14207
|
+
* the plaintext in here, encrypts the returned pair, and writes both
|
|
14208
|
+
* tokens + expiries back to `auth_identities.metadata`.
|
|
14209
|
+
*/
|
|
14210
|
+
async function refreshAppUserToken(clientId, clientSecret, refreshToken, opts = {}) {
|
|
13774
14211
|
const fetcher = opts.fetcher ?? fetch;
|
|
13775
|
-
const
|
|
14212
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
14213
|
+
const res = await fetcher(OAUTH_TOKEN_URL, {
|
|
14214
|
+
method: "POST",
|
|
14215
|
+
headers: {
|
|
14216
|
+
Accept: "application/json",
|
|
14217
|
+
"Content-Type": "application/json"
|
|
14218
|
+
},
|
|
14219
|
+
body: JSON.stringify({
|
|
14220
|
+
client_id: clientId,
|
|
14221
|
+
client_secret: clientSecret,
|
|
14222
|
+
grant_type: "refresh_token",
|
|
14223
|
+
refresh_token: refreshToken
|
|
14224
|
+
})
|
|
14225
|
+
});
|
|
14226
|
+
if (!res.ok) throw new GithubAppApiError(res.status, `GitHub user-token refresh failed (${res.status})`);
|
|
14227
|
+
const body = await res.json();
|
|
14228
|
+
if (body.error || !body.access_token || !body.refresh_token) throw new GithubAppApiError(401, `GitHub user-token refresh rejected: ${body.error_description ?? body.error ?? "missing access_token / refresh_token"}`);
|
|
14229
|
+
if (typeof body.expires_in !== "number" || typeof body.refresh_token_expires_in !== "number") throw new GithubAppApiError(500, "GitHub user-token refresh response missing expires_in fields — App likely has user-token expiration disabled");
|
|
14230
|
+
const issuedAt = now();
|
|
14231
|
+
const accessExpiresAt = new Date(issuedAt.getTime() + body.expires_in * 1e3);
|
|
14232
|
+
const refreshExpiresAt = new Date(issuedAt.getTime() + body.refresh_token_expires_in * 1e3);
|
|
14233
|
+
return {
|
|
14234
|
+
accessToken: body.access_token,
|
|
14235
|
+
accessTokenExpiresAt: accessExpiresAt.toISOString(),
|
|
14236
|
+
refreshToken: body.refresh_token,
|
|
14237
|
+
refreshTokenExpiresAt: refreshExpiresAt.toISOString(),
|
|
14238
|
+
scope: body.scope ?? ""
|
|
14239
|
+
};
|
|
14240
|
+
}
|
|
14241
|
+
/**
|
|
14242
|
+
* Build the App's combined OAuth + install authorization URL. Per design
|
|
14243
|
+
* doc D1 ("login → install in one redirect"), this is the SAME endpoint
|
|
14244
|
+
* GitHub uses for both flows when the App has "Request user authorization
|
|
14245
|
+
* (OAuth) during installation" enabled — first install lands the user on
|
|
14246
|
+
* the install dialog → consents → GitHub bounces back to `redirect_uri`
|
|
14247
|
+
* with both `code` (OAuth) and `installation_id` (the new install).
|
|
14248
|
+
* Returning users skip the install dialog and just receive `code`.
|
|
14249
|
+
*
|
|
14250
|
+
* `state` is the signed JWT minted by `oauth-state.ts` — same CSRF defense
|
|
14251
|
+
* as the legacy OAuth flow.
|
|
14252
|
+
*
|
|
14253
|
+
* Permissions are NOT in the URL — the App declares them once in its
|
|
14254
|
+
* GitHub-side settings (design doc D0b) and the install dialog renders
|
|
14255
|
+
* them automatically. Asking again in the URL would let an attacker
|
|
14256
|
+
* craft a downgrade prompt.
|
|
14257
|
+
*/
|
|
14258
|
+
function buildAppAuthorizeUrl(opts) {
|
|
14259
|
+
const url = new URL(OAUTH_AUTHORIZE_URL);
|
|
14260
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
14261
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
14262
|
+
url.searchParams.set("state", opts.state);
|
|
14263
|
+
url.searchParams.set("allow_signup", "true");
|
|
14264
|
+
return url.toString();
|
|
14265
|
+
}
|
|
14266
|
+
const APP_INSTALL_URL = (slug) => `https://github.com/apps/${encodeURIComponent(slug)}/installations/new`;
|
|
14267
|
+
/**
|
|
14268
|
+
* Build the App's `installations/new` URL — the one that actually surfaces
|
|
14269
|
+
* GitHub's install dialog (repo picker + permission review). Distinct from
|
|
14270
|
+
* `buildAppAuthorizeUrl`:
|
|
14271
|
+
*
|
|
14272
|
+
* - `authorize` (login URL) → for a user who already has the App
|
|
14273
|
+
* installed, returns `code`. For one who DOESN'T, it only triggers
|
|
14274
|
+
* OAuth consent — no install dialog, no `installation_id` ever comes
|
|
14275
|
+
* back. So "Install on GitHub" CTAs that point at `authorize` silently
|
|
14276
|
+
* never produce an install (codex P1-1).
|
|
14277
|
+
* - `installations/new` → always shows the install picker. After the
|
|
14278
|
+
* user confirms, GitHub redirects to the App's configured callback /
|
|
14279
|
+
* setup URL with `installation_id` and (because the App has "Request
|
|
14280
|
+
* user authorization (OAuth) during installation" enabled, D1) also
|
|
14281
|
+
* `code` + the `state` we threaded through here.
|
|
14282
|
+
*
|
|
14283
|
+
* `state` is the same signed JWT minted by `oauth-state.ts` — GitHub
|
|
14284
|
+
* round-trips it on the post-install redirect, so the callback can verify
|
|
14285
|
+
* CSRF + recover `next` + (codex P1-3) the target org to bind to.
|
|
14286
|
+
*/
|
|
14287
|
+
function buildAppInstallUrl(opts) {
|
|
14288
|
+
const url = new URL(APP_INSTALL_URL(opts.appSlug));
|
|
14289
|
+
url.searchParams.set("state", opts.state);
|
|
14290
|
+
return url.toString();
|
|
14291
|
+
}
|
|
14292
|
+
/**
|
|
14293
|
+
* App-flavoured `exchangeCodeForProfile`: trade the callback `code` for
|
|
14294
|
+
* the user's profile + a full token pair (access + refresh + expiries).
|
|
14295
|
+
*
|
|
14296
|
+
* Why this exists alongside `github-oauth.ts.exchangeCodeForProfile`:
|
|
14297
|
+
* - Same endpoint (`/login/oauth/access_token`) but with App
|
|
14298
|
+
* client_id/secret instead of OAuth App credentials.
|
|
14299
|
+
* - Response carries `refresh_token` + `expires_in` +
|
|
14300
|
+
* `refresh_token_expires_in` (8h / 6mo TTLs) that the OAuth-only
|
|
14301
|
+
* version doesn't return.
|
|
14302
|
+
* - The token-rotation semantics (`refresh_token` will be reissued on
|
|
14303
|
+
* every refresh) mean the caller MUST persist all four fields, not
|
|
14304
|
+
* just `accessToken`.
|
|
14305
|
+
*
|
|
14306
|
+
* The OAuth-only helper stays put for the brief window between this
|
|
14307
|
+
* commit and the OAuth-flow rewrite; D3 cutover deletes it outright.
|
|
14308
|
+
*/
|
|
14309
|
+
async function exchangeCodeForAppUserProfile(opts, callOpts = {}) {
|
|
14310
|
+
const fetcher = callOpts.fetcher ?? fetch;
|
|
14311
|
+
const now = callOpts.now ?? (() => /* @__PURE__ */ new Date());
|
|
14312
|
+
const tokenRes = await fetcher(OAUTH_TOKEN_URL, {
|
|
13776
14313
|
method: "POST",
|
|
13777
14314
|
headers: {
|
|
13778
14315
|
Accept: "application/json",
|
|
13779
14316
|
"Content-Type": "application/json"
|
|
13780
14317
|
},
|
|
13781
14318
|
body: JSON.stringify({
|
|
13782
|
-
client_id:
|
|
13783
|
-
client_secret:
|
|
13784
|
-
code,
|
|
13785
|
-
redirect_uri: redirectUri
|
|
14319
|
+
client_id: opts.clientId,
|
|
14320
|
+
client_secret: opts.clientSecret,
|
|
14321
|
+
code: opts.code,
|
|
14322
|
+
redirect_uri: opts.redirectUri
|
|
13786
14323
|
})
|
|
13787
14324
|
});
|
|
13788
|
-
if (!tokenRes.ok) throw new
|
|
13789
|
-
const
|
|
13790
|
-
if (!
|
|
13791
|
-
|
|
13792
|
-
|
|
14325
|
+
if (!tokenRes.ok) throw new GithubAppApiError(tokenRes.status, `GitHub App user-token exchange failed (${tokenRes.status})`);
|
|
14326
|
+
const body = await tokenRes.json();
|
|
14327
|
+
if (body.error || !body.access_token || !body.refresh_token) throw new GithubAppApiError(401, `GitHub App user-token exchange rejected: ${body.error_description ?? body.error ?? "missing access_token / refresh_token"}`);
|
|
14328
|
+
if (typeof body.expires_in !== "number" || typeof body.refresh_token_expires_in !== "number") throw new GithubAppApiError(500, "GitHub App user-token exchange missing expires_in — App must have user-token expiration enabled");
|
|
14329
|
+
const issuedAt = now();
|
|
14330
|
+
const accessExpiresAt = new Date(issuedAt.getTime() + body.expires_in * 1e3);
|
|
14331
|
+
const refreshExpiresAt = new Date(issuedAt.getTime() + body.refresh_token_expires_in * 1e3);
|
|
14332
|
+
const userRes = await fetcher(USER_API_URL, { headers: {
|
|
14333
|
+
Authorization: `Bearer ${body.access_token}`,
|
|
13793
14334
|
Accept: "application/vnd.github+json"
|
|
13794
14335
|
} });
|
|
13795
|
-
if (!userRes.ok) throw new
|
|
14336
|
+
if (!userRes.ok) throw new GithubAppApiError(userRes.status, `GitHub /user fetch failed (${userRes.status})`);
|
|
13796
14337
|
const user = await userRes.json();
|
|
13797
14338
|
let email = user.email ?? null;
|
|
13798
14339
|
if (!email) {
|
|
13799
|
-
const emailsRes = await fetcher(
|
|
13800
|
-
Authorization: `Bearer ${
|
|
14340
|
+
const emailsRes = await fetcher(USER_EMAILS_API_URL, { headers: {
|
|
14341
|
+
Authorization: `Bearer ${body.access_token}`,
|
|
13801
14342
|
Accept: "application/vnd.github+json"
|
|
13802
14343
|
} });
|
|
13803
14344
|
if (emailsRes.ok) {
|
|
@@ -13813,49 +14354,186 @@ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
|
13813
14354
|
displayName: user.name ?? null,
|
|
13814
14355
|
avatarUrl: user.avatar_url ?? null
|
|
13815
14356
|
},
|
|
13816
|
-
accessToken:
|
|
14357
|
+
accessToken: body.access_token,
|
|
14358
|
+
accessTokenExpiresAt: accessExpiresAt.toISOString(),
|
|
14359
|
+
refreshToken: body.refresh_token,
|
|
14360
|
+
refreshTokenExpiresAt: refreshExpiresAt.toISOString(),
|
|
14361
|
+
scope: body.scope ?? "",
|
|
14362
|
+
installationId: opts.installationId
|
|
13817
14363
|
};
|
|
13818
14364
|
}
|
|
13819
14365
|
/**
|
|
13820
|
-
*
|
|
13821
|
-
*
|
|
13822
|
-
*
|
|
13823
|
-
*
|
|
14366
|
+
* GitHub App installation records — one row per (GitHub account → Hub team)
|
|
14367
|
+
* binding. Replaces the per-repo OAuth + webhook-secret model that lived in
|
|
14368
|
+
* `organization_settings.github_integration.webhookSecretCipher`.
|
|
14369
|
+
*
|
|
14370
|
+
* One installation simultaneously unlocks three capabilities (see design
|
|
14371
|
+
* doc `docs/github-app-design-zh.md` §3):
|
|
14372
|
+
* 1. User OAuth (user-to-server access + refresh tokens) — persisted on
|
|
14373
|
+
* `auth_identities.metadata` for the signing-in user, not here.
|
|
14374
|
+
* 2. Webhook stream — `installation_id` resolves the inbound webhook to
|
|
14375
|
+
* the bound Hub org by joining on this table.
|
|
14376
|
+
* 3. Installation token (server-to-server) — minted on demand from the
|
|
14377
|
+
* App private key; not persisted (1h TTL, cheap to re-issue).
|
|
14378
|
+
*
|
|
14379
|
+
* The (GitHub account ↔ Hub team) binding is 1:1 (D2 / §8 Q1). The
|
|
14380
|
+
* `hub_organization_id` UNIQUE constraint enforces that; the column is
|
|
14381
|
+
* nullable solely to accommodate the install-callback handler inserting
|
|
14382
|
+
* the row before the owning Hub team exists (fresh-signup flow). Once a
|
|
14383
|
+
* binding exists it never moves — re-installing the App on the same GitHub
|
|
14384
|
+
* account UPDATEs this row by `installation_id`.
|
|
14385
|
+
*
|
|
14386
|
+
* ON DELETE SET NULL on `hub_organization_id` rather than CASCADE because
|
|
14387
|
+
* the GitHub-side installation still exists upstream when a Hub team is
|
|
14388
|
+
* deleted — keeping the row lets a future re-binding flow recover without
|
|
14389
|
+
* a re-install dance.
|
|
13824
14390
|
*/
|
|
13825
|
-
|
|
13826
|
-
|
|
13827
|
-
|
|
13828
|
-
|
|
13829
|
-
|
|
13830
|
-
}
|
|
13831
|
-
}
|
|
14391
|
+
const githubAppInstallations = pgTable("github_app_installations", {
|
|
14392
|
+
id: text("id").primaryKey(),
|
|
14393
|
+
installationId: bigint("installation_id", { mode: "number" }).notNull(),
|
|
14394
|
+
accountType: text("account_type").$type().notNull(),
|
|
14395
|
+
accountLogin: text("account_login").notNull(),
|
|
14396
|
+
accountGithubId: bigint("account_github_id", { mode: "number" }).notNull(),
|
|
14397
|
+
hubOrganizationId: text("hub_organization_id").references(() => organizations.id, { onDelete: "set null" }),
|
|
14398
|
+
permissions: jsonb("permissions").$type().notNull(),
|
|
14399
|
+
events: jsonb("events").$type().notNull(),
|
|
14400
|
+
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
|
14401
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
14402
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
14403
|
+
}, (table) => [
|
|
14404
|
+
uniqueIndex("uq_github_app_installations_installation_id").on(table.installationId),
|
|
14405
|
+
uniqueIndex("uq_github_app_installations_hub_org").on(table.hubOrganizationId),
|
|
14406
|
+
index("idx_github_app_installations_account").on(table.accountGithubId),
|
|
14407
|
+
check("ck_github_app_installations_account_type", sql`${table.accountType} IN ('User', 'Organization')`)
|
|
14408
|
+
]);
|
|
14409
|
+
/** Postgres `unique_violation` SQLSTATE — emitted on UNIQUE constraint trips. */
|
|
14410
|
+
const PG_UNIQUE_VIOLATION$1 = "23505";
|
|
14411
|
+
function isUniqueViolation(err) {
|
|
14412
|
+
return (err?.code ?? err?.cause?.code) === PG_UNIQUE_VIOLATION$1;
|
|
14413
|
+
}
|
|
13832
14414
|
/**
|
|
13833
|
-
*
|
|
13834
|
-
*
|
|
14415
|
+
* UPSERT by `installation_id`. INSERTs a new row when the installation
|
|
14416
|
+
* is unseen; UPDATEs the metadata fields on re-install / permission
|
|
14417
|
+
* change / event-subscription change.
|
|
14418
|
+
*
|
|
14419
|
+
* Does NOT touch `hub_organization_id` on UPDATE — that column is
|
|
14420
|
+
* managed by `bindInstallationToOrg`. Otherwise a webhook arriving
|
|
14421
|
+
* after a manual rebind could clobber the binding back to null.
|
|
13835
14422
|
*/
|
|
13836
|
-
async function
|
|
13837
|
-
const
|
|
13838
|
-
const
|
|
13839
|
-
const
|
|
13840
|
-
|
|
13841
|
-
|
|
13842
|
-
|
|
13843
|
-
|
|
13844
|
-
|
|
13845
|
-
|
|
13846
|
-
|
|
13847
|
-
|
|
13848
|
-
|
|
13849
|
-
|
|
13850
|
-
|
|
13851
|
-
|
|
13852
|
-
|
|
13853
|
-
|
|
13854
|
-
|
|
13855
|
-
|
|
13856
|
-
|
|
14423
|
+
async function upsertInstallationFromMetadata(db, input) {
|
|
14424
|
+
const now = /* @__PURE__ */ new Date();
|
|
14425
|
+
const suspendedAt = input.installation.suspendedAt ? new Date(input.installation.suspendedAt) : null;
|
|
14426
|
+
const values = {
|
|
14427
|
+
id: uuidv7(),
|
|
14428
|
+
installationId: input.installation.id,
|
|
14429
|
+
accountType: input.installation.accountType,
|
|
14430
|
+
accountLogin: input.installation.accountLogin,
|
|
14431
|
+
accountGithubId: input.installation.accountGithubId,
|
|
14432
|
+
hubOrganizationId: input.hubOrganizationId ?? null,
|
|
14433
|
+
permissions: input.installation.permissions,
|
|
14434
|
+
events: input.installation.events,
|
|
14435
|
+
suspendedAt,
|
|
14436
|
+
createdAt: now,
|
|
14437
|
+
updatedAt: now
|
|
14438
|
+
};
|
|
14439
|
+
const [row] = await db.insert(githubAppInstallations).values(values).onConflictDoUpdate({
|
|
14440
|
+
target: githubAppInstallations.installationId,
|
|
14441
|
+
set: {
|
|
14442
|
+
accountType: values.accountType,
|
|
14443
|
+
accountLogin: values.accountLogin,
|
|
14444
|
+
accountGithubId: values.accountGithubId,
|
|
14445
|
+
permissions: values.permissions,
|
|
14446
|
+
events: values.events,
|
|
14447
|
+
suspendedAt: values.suspendedAt,
|
|
14448
|
+
updatedAt: now
|
|
14449
|
+
}
|
|
14450
|
+
}).returning();
|
|
14451
|
+
if (!row) throw new Error("upsertInstallationFromMetadata: INSERT returned no row");
|
|
14452
|
+
return row;
|
|
14453
|
+
}
|
|
14454
|
+
/**
|
|
14455
|
+
* Bind an installation to a Hub team. Idempotent: re-binding to the same
|
|
14456
|
+
* org is a no-op at the row level.
|
|
14457
|
+
*
|
|
14458
|
+
* Race-safe (codex P0-3): the previous SELECT-then-UPDATE implementation
|
|
14459
|
+
* had a TOCTOU window — two concurrent callbacks for the same unbound
|
|
14460
|
+
* installation but different Hub orgs could both see `hubOrganizationId
|
|
14461
|
+
* IS NULL`, both pass the in-memory validation, and then the second
|
|
14462
|
+
* UPDATE would silently rebind. This implementation:
|
|
14463
|
+
*
|
|
14464
|
+
* 1. Runs a conditional UPDATE: WHERE installation_id = $1 AND
|
|
14465
|
+
* (hub_organization_id IS NULL OR hub_organization_id = $2).
|
|
14466
|
+
* Postgres serializes the rowlock so the second concurrent caller
|
|
14467
|
+
* sees the freshly-set value and the WHERE clause filters it out
|
|
14468
|
+
* — the UPDATE matches 0 rows for the loser.
|
|
14469
|
+
* 2. On 0 rows updated, SELECTs the current row to decide which
|
|
14470
|
+
* structured error to throw (not-found vs. already-bound-elsewhere).
|
|
14471
|
+
* 3. Catches the 23505 path that fires when two ROWS get rebound to
|
|
14472
|
+
* the SAME hub_organization_id (covers the case where org A
|
|
14473
|
+
* already has installation X bound and a different callback tries
|
|
14474
|
+
* to bind installation Y to org A — the UPDATE on Y succeeds the
|
|
14475
|
+
* WHERE filter but violates UNIQUE(hub_organization_id)).
|
|
14476
|
+
* Surfaces as a clean ConflictError instead of a 23505 leaking
|
|
14477
|
+
* through the route layer.
|
|
14478
|
+
*
|
|
14479
|
+
* Throws:
|
|
14480
|
+
* - NotFoundError if no installation row exists with installationId.
|
|
14481
|
+
* - ConflictError if (a) the installation is already bound to a
|
|
14482
|
+
* different Hub team (D2 1:1), or (b) the target Hub team is
|
|
14483
|
+
* already bound to a different installation.
|
|
14484
|
+
*
|
|
14485
|
+
* Returns `true` on any successful UPDATE — fresh bind and idempotent
|
|
14486
|
+
* re-bind both succeed identically and we don't pay the extra SELECT to
|
|
14487
|
+
* tell them apart. The boolean exists for forward-compat with callers
|
|
14488
|
+
* that may want to surface a "freshly bound" log line; today both paths
|
|
14489
|
+
* leave the row in the same state, so the value is advisory.
|
|
14490
|
+
*/
|
|
14491
|
+
async function bindInstallationToOrg(db, installationId, hubOrganizationId) {
|
|
14492
|
+
let updatedCount;
|
|
14493
|
+
try {
|
|
14494
|
+
updatedCount = (await db.update(githubAppInstallations).set({
|
|
14495
|
+
hubOrganizationId,
|
|
14496
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
14497
|
+
}).where(and(eq(githubAppInstallations.installationId, installationId), or(isNull(githubAppInstallations.hubOrganizationId), eq(githubAppInstallations.hubOrganizationId, hubOrganizationId)))).returning({ id: githubAppInstallations.id })).length;
|
|
14498
|
+
} catch (err) {
|
|
14499
|
+
if (isUniqueViolation(err)) throw new ConflictError("Hub team is already bound to a different GitHub installation. Uninstall the existing one from GitHub first, or transfer the binding from Settings.");
|
|
14500
|
+
throw err;
|
|
13857
14501
|
}
|
|
13858
|
-
|
|
14502
|
+
if (updatedCount === 0) {
|
|
14503
|
+
const [row] = await db.select({ hubOrganizationId: githubAppInstallations.hubOrganizationId }).from(githubAppInstallations).where(eq(githubAppInstallations.installationId, installationId)).limit(1);
|
|
14504
|
+
if (!row) throw new NotFoundError(`No installation row for installation_id=${installationId}`);
|
|
14505
|
+
throw new ConflictError(`Installation ${installationId} is already bound to a different Hub team — refusing to rebind (D2 1:1).`);
|
|
14506
|
+
}
|
|
14507
|
+
return true;
|
|
14508
|
+
}
|
|
14509
|
+
/**
|
|
14510
|
+
* Lookup the installation bound to a Hub team. Used by Settings →
|
|
14511
|
+
* Integrations to render the connected-account panel. Returns null when
|
|
14512
|
+
* no install is bound.
|
|
14513
|
+
*
|
|
14514
|
+
* `LIMIT 1` is belt-and-braces — UNIQUE(hub_organization_id) already
|
|
14515
|
+
* guarantees at most one row.
|
|
14516
|
+
*/
|
|
14517
|
+
async function findInstallationByOrg(db, hubOrganizationId) {
|
|
14518
|
+
const [row] = await db.select().from(githubAppInstallations).where(eq(githubAppInstallations.hubOrganizationId, hubOrganizationId)).limit(1);
|
|
14519
|
+
return row ?? null;
|
|
14520
|
+
}
|
|
14521
|
+
/**
|
|
14522
|
+
* List the installations for a GitHub account that aren't bound to any Hub
|
|
14523
|
+
* team yet. Newest-first.
|
|
14524
|
+
*
|
|
14525
|
+
* The orphan-recovery path (codex P1-5 + H1): if the OAuth callback's
|
|
14526
|
+
* `upsertInstallationFromMetadata` lands but the follow-up
|
|
14527
|
+
* `bindInstallationToOrg` fails (transient DB error, a racing invite that
|
|
14528
|
+
* errors out, …), the row sits unbound forever — GitHub only puts
|
|
14529
|
+
* `installation_id` in the redirect on the *initial* install, so a later
|
|
14530
|
+
* sign-in never re-attempts the bind. On every subsequent sign-in we sweep
|
|
14531
|
+
* for unbound rows whose `accountGithubId` matches the user's own GitHub
|
|
14532
|
+
* account and auto-claim the single one (and surface a manual "Claim
|
|
14533
|
+
* install" button when there are several).
|
|
14534
|
+
*/
|
|
14535
|
+
async function findUnboundInstallationsByAccount(db, accountGithubId) {
|
|
14536
|
+
return db.select().from(githubAppInstallations).where(and(eq(githubAppInstallations.accountGithubId, accountGithubId), isNull(githubAppInstallations.hubOrganizationId))).orderBy(desc(githubAppInstallations.createdAt));
|
|
13859
14537
|
}
|
|
13860
14538
|
/** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
|
|
13861
14539
|
async function ensureMembership(db, data) {
|
|
@@ -13987,6 +14665,22 @@ async function pickPrimaryMembership(db, userId) {
|
|
|
13987
14665
|
return (await listActiveMemberships(db, userId))[0] ?? null;
|
|
13988
14666
|
}
|
|
13989
14667
|
/**
|
|
14668
|
+
* Look up a user's ACTIVE membership in a specific org. Returns null when
|
|
14669
|
+
* the user isn't a member there (or their row is soft-deleted `left`).
|
|
14670
|
+
*
|
|
14671
|
+
* Used by the OAuth callback to re-check that a `targetOrganizationId`
|
|
14672
|
+
* carried in the signed state still names an org the user can administer
|
|
14673
|
+
* before binding a GitHub App installation to it (codex P1-3) — the state
|
|
14674
|
+
* JWT lives ~10min, long enough for a membership to be revoked.
|
|
14675
|
+
*/
|
|
14676
|
+
async function findActiveMembership(db, userId, organizationId) {
|
|
14677
|
+
const [row] = await db.select({
|
|
14678
|
+
memberId: members.id,
|
|
14679
|
+
role: members.role
|
|
14680
|
+
}).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
|
|
14681
|
+
return row ?? null;
|
|
14682
|
+
}
|
|
14683
|
+
/**
|
|
13990
14684
|
* Mark `members.status='left'` for the given member. v1 simplification:
|
|
13991
14685
|
* no "must transfer admin" check — the proposal accepts the trade-off
|
|
13992
14686
|
* (last admin allowed to leave, leaves an orphan team) and the cleanup is
|
|
@@ -14060,14 +14754,16 @@ const OAUTH_STATE_COOKIE = "oauth_state_nonce";
|
|
|
14060
14754
|
* Sign a fresh state token + return the matching cookie nonce. Caller is
|
|
14061
14755
|
* responsible for setting the cookie (HttpOnly + Secure in prod).
|
|
14062
14756
|
*/
|
|
14063
|
-
async function signOAuthState(jwtSecret, next) {
|
|
14757
|
+
async function signOAuthState(jwtSecret, next, opts = {}) {
|
|
14064
14758
|
const nonce = randomBytes(NONCE_BYTES).toString("base64url");
|
|
14065
14759
|
const secret = new TextEncoder().encode(jwtSecret);
|
|
14760
|
+
const claims = {
|
|
14761
|
+
nonce,
|
|
14762
|
+
next
|
|
14763
|
+
};
|
|
14764
|
+
if (opts.targetOrganizationId) claims.targetOrganizationId = opts.targetOrganizationId;
|
|
14066
14765
|
return {
|
|
14067
|
-
token: await new SignJWT({
|
|
14068
|
-
nonce,
|
|
14069
|
-
next
|
|
14070
|
-
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
|
|
14766
|
+
token: await new SignJWT({ ...claims }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
|
|
14071
14767
|
nonce
|
|
14072
14768
|
};
|
|
14073
14769
|
}
|
|
@@ -14091,8 +14787,12 @@ async function verifyOAuthState(jwtSecret, token, cookieNonce) {
|
|
|
14091
14787
|
throw new Error("Invalid or expired OAuth state");
|
|
14092
14788
|
}
|
|
14093
14789
|
if (typeof payload.nonce !== "string" || typeof payload.next !== "string") throw new Error("OAuth state payload malformed");
|
|
14790
|
+
if (payload.targetOrganizationId !== void 0 && typeof payload.targetOrganizationId !== "string") throw new Error("OAuth state payload malformed");
|
|
14094
14791
|
if (!cookieNonce || cookieNonce !== payload.nonce) throw new Error("OAuth state nonce / cookie mismatch");
|
|
14095
|
-
return {
|
|
14792
|
+
return {
|
|
14793
|
+
next: payload.next,
|
|
14794
|
+
...payload.targetOrganizationId ? { targetOrganizationId: payload.targetOrganizationId } : {}
|
|
14795
|
+
};
|
|
14096
14796
|
}
|
|
14097
14797
|
/**
|
|
14098
14798
|
* Resolve the hub's public-facing base URL.
|
|
@@ -14144,7 +14844,15 @@ function buildCookie(opts) {
|
|
|
14144
14844
|
return parts.join("; ");
|
|
14145
14845
|
}
|
|
14146
14846
|
/**
|
|
14147
|
-
* GitHub
|
|
14847
|
+
* GitHub sign-in surface. All routes are public (no member JWT required).
|
|
14848
|
+
*
|
|
14849
|
+
* Single flow post-D3 cutover: the GitHub App authorize URL drives both
|
|
14850
|
+
* sign-in and install (D1). Returning users with an existing install
|
|
14851
|
+
* skip the install dialog and just get `code + state`; first-time
|
|
14852
|
+
* installers get `code + state + installation_id`. The legacy OAuth-App
|
|
14853
|
+
* path that lived alongside this until D3 has been removed.
|
|
14854
|
+
*
|
|
14855
|
+
* `dev-callback` bypasses GitHub entirely; gated to non-production.
|
|
14148
14856
|
*
|
|
14149
14857
|
* Routes:
|
|
14150
14858
|
* - GET /auth/github/start — sign state JWT + cookie + 302 to GitHub
|
|
@@ -14152,15 +14860,15 @@ function buildCookie(opts) {
|
|
|
14152
14860
|
* - GET /auth/github/dev-callback — dev-only stub (no GitHub round-trip)
|
|
14153
14861
|
*/
|
|
14154
14862
|
async function githubOauthRoutes(app) {
|
|
14155
|
-
const
|
|
14156
|
-
if (!
|
|
14863
|
+
const appCfg = app.config.oauth?.githubApp;
|
|
14864
|
+
if (!appCfg) app.log.info("GitHub App not configured — /auth/github/start will return 503. Set FIRST_TREE_HUB_GITHUB_APP_* to enable.");
|
|
14157
14865
|
app.get("/start", { config: { rateLimit: {
|
|
14158
14866
|
max: 20,
|
|
14159
14867
|
timeWindow: "1 minute"
|
|
14160
14868
|
} } }, async (request, reply) => {
|
|
14161
14869
|
const { next } = githubStartQuerySchema.parse(request.query);
|
|
14162
14870
|
const safeNext = safeRedirectPath(next ?? null);
|
|
14163
|
-
if (!
|
|
14871
|
+
if (!appCfg) return reply.status(503).send({ error: "GitHub App is not configured on this hub" });
|
|
14164
14872
|
const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, safeNext);
|
|
14165
14873
|
const isProd = process.env.NODE_ENV === "production";
|
|
14166
14874
|
reply.header("Set-Cookie", buildCookie({
|
|
@@ -14170,22 +14878,22 @@ async function githubOauthRoutes(app) {
|
|
|
14170
14878
|
secure: isProd
|
|
14171
14879
|
}));
|
|
14172
14880
|
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
14173
|
-
|
|
14174
|
-
|
|
14175
|
-
|
|
14176
|
-
state: token
|
|
14177
|
-
|
|
14178
|
-
allow_signup: "true"
|
|
14179
|
-
});
|
|
14180
|
-
return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
|
14881
|
+
return reply.redirect(buildAppAuthorizeUrl({
|
|
14882
|
+
clientId: appCfg.clientId,
|
|
14883
|
+
redirectUri,
|
|
14884
|
+
state: token
|
|
14885
|
+
}), 302);
|
|
14181
14886
|
});
|
|
14182
14887
|
app.get("/callback", async (request, reply) => {
|
|
14183
|
-
if (!
|
|
14184
|
-
const { code, state } = githubCallbackQuerySchema.parse(request.query);
|
|
14888
|
+
if (!appCfg) return reply.status(503).send({ error: "GitHub App is not configured on this hub" });
|
|
14889
|
+
const { code, state, installation_id: installationIdRaw } = githubCallbackQuerySchema.parse(request.query);
|
|
14185
14890
|
const cookieNonce = parseCookieHeader(request.headers.cookie, OAUTH_STATE_COOKIE);
|
|
14186
14891
|
let next;
|
|
14892
|
+
let targetOrganizationId = null;
|
|
14187
14893
|
try {
|
|
14188
|
-
|
|
14894
|
+
const verified = await verifyOAuthState(app.config.secrets.jwtSecret, state, cookieNonce);
|
|
14895
|
+
next = verified.next;
|
|
14896
|
+
targetOrganizationId = verified.targetOrganizationId ?? null;
|
|
14189
14897
|
} catch (err) {
|
|
14190
14898
|
const msg = err instanceof Error ? err.message : "OAuth state rejected";
|
|
14191
14899
|
return reply.status(401).send({ error: msg });
|
|
@@ -14198,40 +14906,128 @@ async function githubOauthRoutes(app) {
|
|
|
14198
14906
|
}));
|
|
14199
14907
|
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
14200
14908
|
let profile;
|
|
14201
|
-
let
|
|
14909
|
+
let tokens;
|
|
14910
|
+
let plaintextUserAccessToken;
|
|
14911
|
+
let installationId = null;
|
|
14202
14912
|
try {
|
|
14203
|
-
const result = await
|
|
14204
|
-
clientId:
|
|
14205
|
-
clientSecret:
|
|
14206
|
-
|
|
14913
|
+
const result = await exchangeCodeForAppUserProfile({
|
|
14914
|
+
clientId: appCfg.clientId,
|
|
14915
|
+
clientSecret: appCfg.clientSecret,
|
|
14916
|
+
code,
|
|
14917
|
+
redirectUri,
|
|
14918
|
+
installationId: installationIdRaw ? Number(installationIdRaw) : null
|
|
14919
|
+
});
|
|
14207
14920
|
profile = result.profile;
|
|
14208
|
-
|
|
14921
|
+
plaintextUserAccessToken = result.accessToken;
|
|
14922
|
+
tokens = {
|
|
14923
|
+
encryptedAccessToken: encryptValue(result.accessToken, app.config.secrets.encryptionKey),
|
|
14924
|
+
accessTokenExpiresAt: result.accessTokenExpiresAt,
|
|
14925
|
+
encryptedRefreshToken: encryptValue(result.refreshToken, app.config.secrets.encryptionKey),
|
|
14926
|
+
refreshTokenExpiresAt: result.refreshTokenExpiresAt
|
|
14927
|
+
};
|
|
14928
|
+
installationId = result.installationId;
|
|
14209
14929
|
} catch (err) {
|
|
14210
14930
|
const msg = err instanceof Error ? err.message : "GitHub exchange failed";
|
|
14211
|
-
app.log.warn({ err }, "github
|
|
14931
|
+
app.log.warn({ err }, "github sign-in code exchange failed");
|
|
14212
14932
|
return reply.status(401).send({ error: msg });
|
|
14213
14933
|
}
|
|
14214
|
-
|
|
14934
|
+
if (installationId !== null) try {
|
|
14935
|
+
const allowedIds = await listUserAccessibleInstallationIds(plaintextUserAccessToken);
|
|
14936
|
+
if (!allowedIds.has(installationId)) {
|
|
14937
|
+
app.log.warn({
|
|
14938
|
+
event: "github_app.installation_id_unauthorized",
|
|
14939
|
+
installationId,
|
|
14940
|
+
githubId: profile.githubId,
|
|
14941
|
+
allowedCount: allowedIds.size
|
|
14942
|
+
}, "callback installation_id is not in /user/installations — refusing to bind (attempted hijack?)");
|
|
14943
|
+
installationId = null;
|
|
14944
|
+
}
|
|
14945
|
+
} catch (err) {
|
|
14946
|
+
app.log.warn({
|
|
14947
|
+
err,
|
|
14948
|
+
installationId,
|
|
14949
|
+
githubId: profile.githubId
|
|
14950
|
+
}, "github app /user/installations check failed — refusing to bind to be safe");
|
|
14951
|
+
installationId = null;
|
|
14952
|
+
}
|
|
14953
|
+
if (installationId !== null && appCfg) try {
|
|
14954
|
+
const installation = await fetchInstallation(await createAppJwt({
|
|
14955
|
+
appId: appCfg.appId,
|
|
14956
|
+
privateKeyPem: appCfg.privateKeyPem
|
|
14957
|
+
}), installationId);
|
|
14958
|
+
await upsertInstallationFromMetadata(app.db, { installation });
|
|
14959
|
+
} catch (err) {
|
|
14960
|
+
app.log.warn({
|
|
14961
|
+
err,
|
|
14962
|
+
installationId,
|
|
14963
|
+
githubId: profile.githubId
|
|
14964
|
+
}, "github app install fetch/upsert failed — clearing installation_id, user can retry from Settings");
|
|
14965
|
+
installationId = null;
|
|
14966
|
+
}
|
|
14967
|
+
return completeOauthFlow(app, request, reply, profile, next, tokens, installationId, targetOrganizationId);
|
|
14215
14968
|
});
|
|
14216
14969
|
app.get("/dev-callback", async (request, reply) => {
|
|
14217
14970
|
if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
|
|
14971
|
+
const devCallbackOptIn = process.env.FIRST_TREE_HUB_DEV_CALLBACK_ENABLED;
|
|
14972
|
+
if (devCallbackOptIn !== "1" && devCallbackOptIn !== "true") {
|
|
14973
|
+
app.log.info({ url: request.url }, "dev-callback request refused — FIRST_TREE_HUB_DEV_CALLBACK_ENABLED is not set");
|
|
14974
|
+
return reply.status(404).send({ error: "Not found" });
|
|
14975
|
+
}
|
|
14218
14976
|
const params = githubDevCallbackQuerySchema.parse(request.query);
|
|
14219
14977
|
const next = safeRedirectPath(params.next ?? null);
|
|
14220
|
-
|
|
14978
|
+
const profile = {
|
|
14221
14979
|
githubId: params.githubId,
|
|
14222
14980
|
login: params.login,
|
|
14223
14981
|
email: params.email ?? null,
|
|
14224
14982
|
displayName: params.displayName ?? params.login,
|
|
14225
14983
|
avatarUrl: null
|
|
14226
|
-
}
|
|
14227
|
-
|
|
14984
|
+
};
|
|
14985
|
+
const devPat = process.env.DEV_GITHUB_PAT?.trim() || null;
|
|
14986
|
+
const tokens = devPat ? { encryptedAccessToken: encryptValue(devPat, app.config.secrets.encryptionKey) } : {};
|
|
14987
|
+
let devInstallationId = null;
|
|
14988
|
+
if (params.installationId) {
|
|
14989
|
+
devInstallationId = Number(params.installationId);
|
|
14990
|
+
try {
|
|
14991
|
+
await upsertInstallationFromMetadata(app.db, { installation: {
|
|
14992
|
+
id: devInstallationId,
|
|
14993
|
+
accountType: params.installationAccountType ?? "User",
|
|
14994
|
+
accountLogin: params.installationAccountLogin ?? params.login,
|
|
14995
|
+
accountGithubId: Number(params.installationAccountGithubId ?? params.githubId),
|
|
14996
|
+
permissions: {
|
|
14997
|
+
contents: "write",
|
|
14998
|
+
pull_requests: "write",
|
|
14999
|
+
issues: "read",
|
|
15000
|
+
metadata: "read",
|
|
15001
|
+
members: "read"
|
|
15002
|
+
},
|
|
15003
|
+
events: [
|
|
15004
|
+
"issues",
|
|
15005
|
+
"issue_comment",
|
|
15006
|
+
"pull_request",
|
|
15007
|
+
"pull_request_review",
|
|
15008
|
+
"push",
|
|
15009
|
+
"installation",
|
|
15010
|
+
"installation_repositories",
|
|
15011
|
+
"member"
|
|
15012
|
+
],
|
|
15013
|
+
suspendedAt: null
|
|
15014
|
+
} });
|
|
15015
|
+
} catch (err) {
|
|
15016
|
+
app.log.warn({
|
|
15017
|
+
err,
|
|
15018
|
+
installationId: devInstallationId
|
|
15019
|
+
}, "dev-callback installation stub upsert failed");
|
|
15020
|
+
}
|
|
15021
|
+
}
|
|
15022
|
+
return completeOauthFlow(app, request, reply, profile, next, tokens, devInstallationId, null);
|
|
15023
|
+
});
|
|
14228
15024
|
}
|
|
14229
|
-
async function completeOauthFlow(app, request, reply, profile, next,
|
|
14230
|
-
const
|
|
14231
|
-
const { userId } = await findOrCreateUserFromGithub(app.db, profile, { encryptedAccessToken });
|
|
15025
|
+
async function completeOauthFlow(app, request, reply, profile, next, oauthTokens, installationId, targetOrganizationId) {
|
|
15026
|
+
const { userId } = await findOrCreateUserFromGithub(app.db, profile, oauthTokens);
|
|
14232
15027
|
let joinPath = "returning";
|
|
14233
15028
|
const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
|
|
14234
15029
|
let resolved = false;
|
|
15030
|
+
let resolvedOrganizationId = null;
|
|
14235
15031
|
if (inviteMatch?.[1]) {
|
|
14236
15032
|
const token = inviteMatch[1];
|
|
14237
15033
|
const inv = await findActiveByToken(app.db, token);
|
|
@@ -14251,24 +15047,69 @@ async function completeOauthFlow(app, request, reply, profile, next, rawAccessTo
|
|
|
14251
15047
|
});
|
|
14252
15048
|
joinPath = "invite";
|
|
14253
15049
|
resolved = true;
|
|
15050
|
+
resolvedOrganizationId = inv.organizationId;
|
|
14254
15051
|
next = "/";
|
|
14255
|
-
} else if (
|
|
14256
|
-
|
|
14257
|
-
|
|
14258
|
-
userId,
|
|
14259
|
-
loginSeed: profile.login,
|
|
14260
|
-
teamDisplayName: `${profile.login}'s team`,
|
|
14261
|
-
userDisplayName: profile.displayName?.trim() || profile.login
|
|
14262
|
-
});
|
|
14263
|
-
joinPath = "solo";
|
|
15052
|
+
} else if (targetOrganizationId) {
|
|
15053
|
+
const membership = await findActiveMembership(app.db, userId, targetOrganizationId);
|
|
15054
|
+
if (!membership || membership.role !== "admin") return reply.status(403).send({ error: "Not an admin of the organization this installation targets" });
|
|
14264
15055
|
resolved = true;
|
|
14265
|
-
|
|
14266
|
-
|
|
14267
|
-
|
|
14268
|
-
|
|
14269
|
-
|
|
14270
|
-
|
|
14271
|
-
}
|
|
15056
|
+
resolvedOrganizationId = targetOrganizationId;
|
|
15057
|
+
} else {
|
|
15058
|
+
const primary = await pickPrimaryMembership(app.db, userId);
|
|
15059
|
+
if (primary) {
|
|
15060
|
+
resolved = true;
|
|
15061
|
+
resolvedOrganizationId = primary.organizationId;
|
|
15062
|
+
} else {
|
|
15063
|
+
const personal = await createPersonalTeam(app.db, {
|
|
15064
|
+
userId,
|
|
15065
|
+
loginSeed: profile.login,
|
|
15066
|
+
teamDisplayName: `${profile.login}'s team`,
|
|
15067
|
+
userDisplayName: profile.displayName?.trim() || profile.login
|
|
15068
|
+
});
|
|
15069
|
+
joinPath = "solo";
|
|
15070
|
+
resolved = true;
|
|
15071
|
+
resolvedOrganizationId = personal.organizationId;
|
|
15072
|
+
next = "/";
|
|
15073
|
+
app.log.info({
|
|
15074
|
+
event: "onboarding.team_created",
|
|
15075
|
+
userId,
|
|
15076
|
+
organizationId: personal.organizationId,
|
|
15077
|
+
source: "oauth-bootstrap"
|
|
15078
|
+
}, "onboarding funnel: team auto-created at OAuth bootstrap");
|
|
15079
|
+
}
|
|
15080
|
+
}
|
|
15081
|
+
if (installationId !== null && resolvedOrganizationId) try {
|
|
15082
|
+
await bindInstallationToOrg(app.db, installationId, resolvedOrganizationId);
|
|
15083
|
+
} catch (err) {
|
|
15084
|
+
app.log.warn({
|
|
15085
|
+
err,
|
|
15086
|
+
installationId,
|
|
15087
|
+
hubOrganizationId: resolvedOrganizationId,
|
|
15088
|
+
userId
|
|
15089
|
+
}, "github app install bind-to-org failed — sign-in continues; reconcile in Settings");
|
|
15090
|
+
}
|
|
15091
|
+
if (resolvedOrganizationId) try {
|
|
15092
|
+
const orphans = await findUnboundInstallationsByAccount(app.db, Number(profile.githubId));
|
|
15093
|
+
if (orphans.length === 1) {
|
|
15094
|
+
const orphan = orphans[0];
|
|
15095
|
+
if (orphan) await bindInstallationToOrg(app.db, orphan.installationId, resolvedOrganizationId).catch((err) => {
|
|
15096
|
+
app.log.warn({
|
|
15097
|
+
err,
|
|
15098
|
+
installationId: orphan.installationId,
|
|
15099
|
+
hubOrganizationId: resolvedOrganizationId,
|
|
15100
|
+
userId
|
|
15101
|
+
}, "orphan install reclaim failed — operator can retry via POST /claim (UI tracked in #318)");
|
|
15102
|
+
});
|
|
15103
|
+
} else if (orphans.length > 1) app.log.info({
|
|
15104
|
+
count: orphans.length,
|
|
15105
|
+
accountGithubId: Number(profile.githubId),
|
|
15106
|
+
userId
|
|
15107
|
+
}, "multiple unbound installs match this account — skipping auto-claim; operator must POST /claim to pick (UI #318)");
|
|
15108
|
+
} catch (err) {
|
|
15109
|
+
app.log.warn({
|
|
15110
|
+
err,
|
|
15111
|
+
userId
|
|
15112
|
+
}, "orphan install reclaim sweep failed");
|
|
14272
15113
|
}
|
|
14273
15114
|
if (!resolved) return reply.status(500).send({ error: "Failed to resolve membership" });
|
|
14274
15115
|
const tokens = await signTokensForUser(app.config.secrets.jwtSecret, userId, app.config.auth);
|
|
@@ -14727,7 +15568,6 @@ async function createMeChat(db, humanAgentId, organizationId, body) {
|
|
|
14727
15568
|
const crossOrg = found.filter((a) => a.organizationId !== organizationId);
|
|
14728
15569
|
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
14729
15570
|
const chatType = distinctIds.length === 1 ? "direct" : "group";
|
|
14730
|
-
const isDirectAgentOnly = chatType === "direct" && found.every((a) => a.type !== "human");
|
|
14731
15571
|
const chatId = randomUUID();
|
|
14732
15572
|
const topic = body.topic ?? null;
|
|
14733
15573
|
await db.transaction(async (tx) => {
|
|
@@ -14737,11 +15577,9 @@ async function createMeChat(db, humanAgentId, organizationId, body) {
|
|
|
14737
15577
|
type: chatType,
|
|
14738
15578
|
topic
|
|
14739
15579
|
});
|
|
14740
|
-
await tx
|
|
14741
|
-
chatId,
|
|
15580
|
+
await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
|
|
14742
15581
|
agentId,
|
|
14743
|
-
role: agentId === humanAgentId ? "owner" : "member"
|
|
14744
|
-
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
15582
|
+
role: agentId === humanAgentId ? "owner" : "member"
|
|
14745
15583
|
})));
|
|
14746
15584
|
await recomputeChatWatchers(tx, chatId);
|
|
14747
15585
|
});
|
|
@@ -14779,37 +15617,24 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
|
|
|
14779
15617
|
await recomputeChatWatchers(tx, chatId);
|
|
14780
15618
|
return;
|
|
14781
15619
|
}
|
|
14782
|
-
|
|
14783
|
-
const isAlreadyGroup = chat.type === "group";
|
|
14784
|
-
const isGroupAfter = isUpgradingToGroup || isAlreadyGroup;
|
|
14785
|
-
if (isUpgradingToGroup) {
|
|
14786
|
-
await tx.update(chats).set({
|
|
14787
|
-
type: "group",
|
|
14788
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
14789
|
-
}).where(eq(chats.id, chatId));
|
|
14790
|
-
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);
|
|
14791
|
-
if (nonHumanIds.length > 0) await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, nonHumanIds)));
|
|
14792
|
-
}
|
|
15620
|
+
if (existing.length + toInsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
|
|
14793
15621
|
const carriedRows = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, toInsert))).returning({
|
|
14794
15622
|
agentId: chatSubscriptions.agentId,
|
|
14795
15623
|
lastReadAt: chatSubscriptions.lastReadAt,
|
|
14796
15624
|
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
14797
15625
|
});
|
|
14798
15626
|
const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
|
|
14799
|
-
|
|
14800
|
-
await tx.insert(chatParticipants).values(toInsert.map((agentId) => {
|
|
14801
|
-
const agentType = typeByAgent.get(agentId);
|
|
14802
|
-
const mode = isGroupAfter && agentType !== "human" ? "mention_only" : "full";
|
|
15627
|
+
await addChatParticipants(tx, chatId, toInsert.map((agentId) => {
|
|
14803
15628
|
const carried = carriedByAgent.get(agentId);
|
|
14804
15629
|
return {
|
|
14805
|
-
chatId,
|
|
14806
15630
|
agentId,
|
|
14807
15631
|
role: "member",
|
|
14808
|
-
|
|
14809
|
-
|
|
14810
|
-
|
|
15632
|
+
carriedReadState: carried ? {
|
|
15633
|
+
lastReadAt: carried.lastReadAt,
|
|
15634
|
+
unreadMentionCount: carried.unreadMentionCount
|
|
15635
|
+
} : void 0
|
|
14811
15636
|
};
|
|
14812
|
-
})
|
|
15637
|
+
}), { onConflictDoNothing: true });
|
|
14813
15638
|
await recomputeChatWatchers(tx, chatId);
|
|
14814
15639
|
});
|
|
14815
15640
|
invalidateChatAudience(chatId);
|
|
@@ -16403,7 +17228,7 @@ async function healthzRoutes(app) {
|
|
|
16403
17228
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
16404
17229
|
*/
|
|
16405
17230
|
async function publicInvitationRoutes(app) {
|
|
16406
|
-
const { previewInvitation } = await import("./invitation-C299fxkP-
|
|
17231
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-DFBBuUcj.mjs");
|
|
16407
17232
|
app.get("/:token/preview", async (request, reply) => {
|
|
16408
17233
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
16409
17234
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -16411,6 +17236,52 @@ async function publicInvitationRoutes(app) {
|
|
|
16411
17236
|
});
|
|
16412
17237
|
}
|
|
16413
17238
|
/**
|
|
17239
|
+
* Thrown when GitHub's API returns a non-2xx for a token-scoped call.
|
|
17240
|
+
* Carries the HTTP status so callers can distinguish auth failures (401 /
|
|
17241
|
+
* 403 — typically a stale token or a missing scope) from transient upstream
|
|
17242
|
+
* errors.
|
|
17243
|
+
*/
|
|
17244
|
+
var GithubApiError = class extends Error {
|
|
17245
|
+
constructor(status, message) {
|
|
17246
|
+
super(message);
|
|
17247
|
+
this.status = status;
|
|
17248
|
+
this.name = "GithubApiError";
|
|
17249
|
+
}
|
|
17250
|
+
};
|
|
17251
|
+
/**
|
|
17252
|
+
* Fetch the authenticated user's accessible repositories. Used by the
|
|
17253
|
+
* Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
|
|
17254
|
+
*
|
|
17255
|
+
* Takes a Bearer-style access token — works the same way whether that
|
|
17256
|
+
* token is a legacy OAuth grant (single-scope `repo`) or an App
|
|
17257
|
+
* user-to-server token (scope is whatever the App declared on its
|
|
17258
|
+
* settings page). The picker has no business distinguishing them.
|
|
17259
|
+
*/
|
|
17260
|
+
async function listUserRepos(accessToken, opts = {}) {
|
|
17261
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
17262
|
+
const perPage = opts.perPage ?? 100;
|
|
17263
|
+
const maxPages = opts.maxPages ?? 3;
|
|
17264
|
+
const out = [];
|
|
17265
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
17266
|
+
const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
|
|
17267
|
+
Authorization: `Bearer ${accessToken}`,
|
|
17268
|
+
Accept: "application/vnd.github+json"
|
|
17269
|
+
} });
|
|
17270
|
+
if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
|
|
17271
|
+
const rows = await res.json();
|
|
17272
|
+
for (const r of rows) out.push({
|
|
17273
|
+
fullName: r.full_name,
|
|
17274
|
+
cloneUrl: r.clone_url,
|
|
17275
|
+
htmlUrl: r.html_url,
|
|
17276
|
+
private: r.private,
|
|
17277
|
+
defaultBranch: r.default_branch ?? null,
|
|
17278
|
+
pushedAt: r.pushed_at ?? null
|
|
17279
|
+
});
|
|
17280
|
+
if (rows.length < perPage) break;
|
|
17281
|
+
}
|
|
17282
|
+
return out;
|
|
17283
|
+
}
|
|
17284
|
+
/**
|
|
16414
17285
|
* `/me` and self-service organization routes (Class A — User-scoped).
|
|
16415
17286
|
* Mounted under `requireUser` so the JWT only needs `sub = userId`.
|
|
16416
17287
|
*
|
|
@@ -16513,11 +17384,19 @@ async function meRoutes(app) {
|
|
|
16513
17384
|
* 503 if the user has no GitHub identity bound or the token wasn't
|
|
16514
17385
|
* captured (e.g. dev-callback sign-in or pre-redesign user). The web
|
|
16515
17386
|
* client falls back to a "Reconnect GitHub" hint in that case.
|
|
17387
|
+
*
|
|
17388
|
+
* codex P1-4: GitHub App user-to-server tokens have an ~8h TTL. If
|
|
17389
|
+
* the stored `accessTokenExpiresAt` is past (or within a 60-second
|
|
17390
|
+
* buffer of expiring), trade in the persisted refresh token for a
|
|
17391
|
+
* fresh pair before calling GitHub. Legacy rows without expiry
|
|
17392
|
+
* fields fall through unchanged — the never-expiring OAuth-App token
|
|
17393
|
+
* still works as-is.
|
|
16516
17394
|
*/
|
|
16517
17395
|
app.get("/me/github/repos", async (request, reply) => {
|
|
16518
17396
|
const { userId } = requireUser(request);
|
|
16519
17397
|
const [identity] = await app.db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github"))).limit(1);
|
|
16520
|
-
const
|
|
17398
|
+
const metadata = identity?.metadata && typeof identity.metadata === "object" ? identity.metadata : null;
|
|
17399
|
+
const encrypted = metadata && "accessToken" in metadata ? metadata.accessToken : void 0;
|
|
16521
17400
|
if (typeof encrypted !== "string" || !encrypted) return reply.status(503).send({ error: "GitHub access token unavailable — please reconnect your account" });
|
|
16522
17401
|
let token;
|
|
16523
17402
|
try {
|
|
@@ -16525,6 +17404,38 @@ async function meRoutes(app) {
|
|
|
16525
17404
|
} catch {
|
|
16526
17405
|
return reply.status(503).send({ error: "GitHub access token could not be decoded — please reconnect" });
|
|
16527
17406
|
}
|
|
17407
|
+
const appCfg = app.config.oauth?.githubApp;
|
|
17408
|
+
const expiresAtRaw = metadata && "accessTokenExpiresAt" in metadata ? metadata.accessTokenExpiresAt : void 0;
|
|
17409
|
+
const encryptedRefresh = metadata && "refreshToken" in metadata ? metadata.refreshToken : void 0;
|
|
17410
|
+
if (typeof expiresAtRaw === "string" && typeof encryptedRefresh === "string" && encryptedRefresh && appCfg) {
|
|
17411
|
+
const expiresAt = Date.parse(expiresAtRaw);
|
|
17412
|
+
if (!Number.isNaN(expiresAt) && expiresAt - 6e4 <= Date.now()) try {
|
|
17413
|
+
const refreshPlain = decryptValue(encryptedRefresh, app.config.secrets.encryptionKey);
|
|
17414
|
+
const refreshed = await refreshAppUserToken(appCfg.clientId, appCfg.clientSecret, refreshPlain);
|
|
17415
|
+
const nextMetadata = {
|
|
17416
|
+
...metadata ?? {},
|
|
17417
|
+
accessToken: encryptValue(refreshed.accessToken, app.config.secrets.encryptionKey),
|
|
17418
|
+
accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
|
|
17419
|
+
refreshToken: encryptValue(refreshed.refreshToken, app.config.secrets.encryptionKey),
|
|
17420
|
+
refreshTokenExpiresAt: refreshed.refreshTokenExpiresAt
|
|
17421
|
+
};
|
|
17422
|
+
await app.db.update(authIdentities).set({
|
|
17423
|
+
metadata: nextMetadata,
|
|
17424
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
17425
|
+
}).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github")));
|
|
17426
|
+
token = refreshed.accessToken;
|
|
17427
|
+
} catch (err) {
|
|
17428
|
+
app.log.warn({
|
|
17429
|
+
err,
|
|
17430
|
+
userId
|
|
17431
|
+
}, "github app user-token refresh failed");
|
|
17432
|
+
if ((err instanceof GithubAppApiError ? err.status : 503) === 401) return reply.status(403).send({
|
|
17433
|
+
error: "Your GitHub session has expired. Please sign in again.",
|
|
17434
|
+
code: "refresh_failed"
|
|
17435
|
+
});
|
|
17436
|
+
return reply.status(503).send({ error: "Couldn't refresh GitHub credentials. Try again, or reconnect your GitHub account." });
|
|
17437
|
+
}
|
|
17438
|
+
}
|
|
16528
17439
|
try {
|
|
16529
17440
|
return { repos: await listUserRepos(token) };
|
|
16530
17441
|
} catch (err) {
|
|
@@ -16583,7 +17494,7 @@ async function meRoutes(app) {
|
|
|
16583
17494
|
*/
|
|
16584
17495
|
app.get("/me/pinned-agents", async (request) => {
|
|
16585
17496
|
const { userId } = requireUser(request);
|
|
16586
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
17497
|
+
const { listMyPinnedAgents } = await import("./client-GOgUQxVe-Dqk9oZf9.mjs");
|
|
16587
17498
|
return listMyPinnedAgents(app.db, { userId });
|
|
16588
17499
|
});
|
|
16589
17500
|
/**
|
|
@@ -17086,6 +17997,94 @@ function orgIdParam(params) {
|
|
|
17086
17997
|
return typeof orgId === "string" ? orgId : null;
|
|
17087
17998
|
}
|
|
17088
17999
|
/**
|
|
18000
|
+
* Where the post-install OAuth callback lands the user once the install
|
|
18001
|
+
* dialog is done — back on the Settings → GitHub panel so it can
|
|
18002
|
+
* re-render with the now-bound installation. The callback resolves the
|
|
18003
|
+
* actual destination from the signed state JWT, not from a query param,
|
|
18004
|
+
* so this is tamper-proof.
|
|
18005
|
+
*/
|
|
18006
|
+
const POST_INSTALL_NEXT = "/settings/github";
|
|
18007
|
+
/**
|
|
18008
|
+
* Class B — `/api/v1/orgs/:orgId/github-app-installation`.
|
|
18009
|
+
*
|
|
18010
|
+
* Read-only admin view of the GitHub App installation bound to this Hub
|
|
18011
|
+
* team. Powers the Settings → Integrations panel. 404 when no install is
|
|
18012
|
+
* bound (the panel renders the "Install on GitHub" prompt in that case).
|
|
18013
|
+
*
|
|
18014
|
+
* Distinct from `/orgs/:orgId/settings/:namespace` because installations
|
|
18015
|
+
* aren't editable through the same PUT/DELETE shape — the row's lifecycle
|
|
18016
|
+
* is driven by GitHub events (install / uninstall / suspend) and the
|
|
18017
|
+
* OAuth callback. The Settings panel surfaces it for visibility but the
|
|
18018
|
+
* write path is upstream.
|
|
18019
|
+
*
|
|
18020
|
+
* Admin-only: the installation block exposes account-level metadata
|
|
18021
|
+
* (login, permissions, events) that a regular member doesn't need.
|
|
18022
|
+
* Mirrors the readPolicy="admin" choice for `github_integration` in the
|
|
18023
|
+
* legacy settings.
|
|
18024
|
+
*/
|
|
18025
|
+
async function orgGithubAppRoutes(app) {
|
|
18026
|
+
app.get("/", async (request) => {
|
|
18027
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18028
|
+
const row = await findInstallationByOrg(app.db, scope.organizationId);
|
|
18029
|
+
if (!row) throw new NotFoundError("No GitHub App installation is bound to this team");
|
|
18030
|
+
const accountType = row.accountType;
|
|
18031
|
+
const manageUrl = accountType === "Organization" ? `https://github.com/organizations/${encodeURIComponent(row.accountLogin)}/settings/installations/${row.installationId}` : `https://github.com/settings/installations/${row.installationId}`;
|
|
18032
|
+
return {
|
|
18033
|
+
installationId: row.installationId,
|
|
18034
|
+
accountType,
|
|
18035
|
+
accountLogin: row.accountLogin,
|
|
18036
|
+
accountGithubId: row.accountGithubId,
|
|
18037
|
+
permissions: row.permissions,
|
|
18038
|
+
events: row.events,
|
|
18039
|
+
suspended: row.suspendedAt !== null,
|
|
18040
|
+
manageUrl,
|
|
18041
|
+
createdAt: row.createdAt.toISOString(),
|
|
18042
|
+
updatedAt: row.updatedAt.toISOString()
|
|
18043
|
+
};
|
|
18044
|
+
});
|
|
18045
|
+
app.get("/install-url", async (request, reply) => {
|
|
18046
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18047
|
+
const appCfg = app.config.oauth?.githubApp;
|
|
18048
|
+
if (!appCfg?.slug) return reply.status(503).send({ error: "GitHub App install URL is unavailable — FIRST_TREE_HUB_GITHUB_APP_SLUG is not configured." });
|
|
18049
|
+
const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, POST_INSTALL_NEXT, { targetOrganizationId: scope.organizationId });
|
|
18050
|
+
reply.header("Set-Cookie", buildCookie({
|
|
18051
|
+
name: OAUTH_STATE_COOKIE,
|
|
18052
|
+
value: nonce,
|
|
18053
|
+
maxAge: 600,
|
|
18054
|
+
secure: process.env.NODE_ENV === "production"
|
|
18055
|
+
}));
|
|
18056
|
+
return { installUrl: buildAppInstallUrl({
|
|
18057
|
+
appSlug: appCfg.slug,
|
|
18058
|
+
state: token
|
|
18059
|
+
}) };
|
|
18060
|
+
});
|
|
18061
|
+
app.post("/claim", async (request) => {
|
|
18062
|
+
const scope = await requireOrgAdmin(request, app.db);
|
|
18063
|
+
const { installationId } = githubAppInstallationClaimBodySchema.parse(request.body);
|
|
18064
|
+
const githubToken = await getStoredGithubAccessToken(app.db, scope.userId, app.config.secrets.encryptionKey);
|
|
18065
|
+
if (!githubToken) throw new ForbiddenError("No GitHub access token on file — sign in with GitHub again before claiming an install");
|
|
18066
|
+
let accessible;
|
|
18067
|
+
try {
|
|
18068
|
+
accessible = await listUserAccessibleInstallationIds(githubToken);
|
|
18069
|
+
} catch (err) {
|
|
18070
|
+
if ((err instanceof GithubAppApiError ? err.status : 0) === 401) throw new ForbiddenError("Your GitHub session has expired — sign in with GitHub again, then retry the claim");
|
|
18071
|
+
app.log.warn({
|
|
18072
|
+
err,
|
|
18073
|
+
installationId,
|
|
18074
|
+
userId: scope.userId
|
|
18075
|
+
}, "claim: /user/installations check failed");
|
|
18076
|
+
throw new ForbiddenError("Couldn't verify GitHub access for this installation — try again in a moment");
|
|
18077
|
+
}
|
|
18078
|
+
if (!accessible.has(installationId)) throw new ForbiddenError("You don't administer this installation on GitHub");
|
|
18079
|
+
await bindInstallationToOrg(app.db, installationId, scope.organizationId);
|
|
18080
|
+
return {
|
|
18081
|
+
installationId,
|
|
18082
|
+
organizationId: scope.organizationId,
|
|
18083
|
+
bound: true
|
|
18084
|
+
};
|
|
18085
|
+
});
|
|
18086
|
+
}
|
|
18087
|
+
/**
|
|
17089
18088
|
* Class B — `/api/v1/orgs/:orgId` itself: read & rename the org row.
|
|
17090
18089
|
* Replaces the deleted `/admin/organizations/:id` GET/PATCH pair.
|
|
17091
18090
|
*/
|
|
@@ -17616,58 +18615,379 @@ async function sessionRoutes(app) {
|
|
|
17616
18615
|
});
|
|
17617
18616
|
});
|
|
17618
18617
|
}
|
|
17619
|
-
|
|
17620
|
-
|
|
17621
|
-
function verifySignature(secret, rawBody, signatureHeader) {
|
|
17622
|
-
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
17623
|
-
const expectedBuf = Buffer.from(expected, "utf8");
|
|
17624
|
-
const receivedBuf = Buffer.from(signatureHeader, "utf8");
|
|
17625
|
-
if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) throw new UnauthorizedError("Invalid webhook signature");
|
|
18618
|
+
function isRecord(value) {
|
|
18619
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17626
18620
|
}
|
|
17627
|
-
|
|
17628
|
-
|
|
17629
|
-
if (
|
|
17630
|
-
|
|
17631
|
-
|
|
17632
|
-
|
|
17633
|
-
|
|
17634
|
-
|
|
17635
|
-
|
|
17636
|
-
|
|
17637
|
-
|
|
17638
|
-
|
|
17639
|
-
|
|
17640
|
-
|
|
17641
|
-
|
|
17642
|
-
|
|
17643
|
-
|
|
17644
|
-
|
|
18621
|
+
/** Pull `repository.full_name` ("owner/repo") from a webhook payload, or null. */
|
|
18622
|
+
function repoFullName(payload) {
|
|
18623
|
+
if (!isRecord(payload)) return null;
|
|
18624
|
+
const repo = isRecord(payload.repository) ? payload.repository : null;
|
|
18625
|
+
return typeof repo?.full_name === "string" && repo.full_name.length > 0 ? repo.full_name : null;
|
|
18626
|
+
}
|
|
18627
|
+
function readNumber(value) {
|
|
18628
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
18629
|
+
}
|
|
18630
|
+
function readString(value) {
|
|
18631
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
18632
|
+
}
|
|
18633
|
+
/**
|
|
18634
|
+
* Resolve the entity that a GitHub webhook event belongs to.
|
|
18635
|
+
*
|
|
18636
|
+
* Returns `null` when the event isn't a clustering candidate (event type
|
|
18637
|
+
* outside the §4.1 "core" list, malformed payload). Caller is expected to
|
|
18638
|
+
* skip such events.
|
|
18639
|
+
*
|
|
18640
|
+
* Notes
|
|
18641
|
+
* - `commit_comment` falls back to a `commit` entity keyed on `<repo>@<sha>`
|
|
18642
|
+
* when no associated PR is in the payload — the design hedges on "optionally
|
|
18643
|
+
* resolve to a PR", but doing so requires an extra GitHub API call which we
|
|
18644
|
+
* defer to Phase 1+.
|
|
18645
|
+
*/
|
|
18646
|
+
function extractEventEntity(eventType, payload) {
|
|
18647
|
+
if (!isRecord(payload)) return null;
|
|
18648
|
+
const repo = repoFullName(payload);
|
|
18649
|
+
if (!repo) return null;
|
|
18650
|
+
switch (eventType) {
|
|
18651
|
+
case "issues":
|
|
18652
|
+
case "issue_comment": {
|
|
18653
|
+
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
18654
|
+
const number = readNumber(issue?.number);
|
|
18655
|
+
if (number === null) return null;
|
|
18656
|
+
return {
|
|
18657
|
+
type: "issue",
|
|
18658
|
+
key: `${repo}#${number}`,
|
|
18659
|
+
title: readString(issue?.title) ?? void 0,
|
|
18660
|
+
url: readString(issue?.html_url) ?? void 0
|
|
18661
|
+
};
|
|
17645
18662
|
}
|
|
17646
|
-
|
|
18663
|
+
case "pull_request":
|
|
18664
|
+
case "pull_request_review":
|
|
18665
|
+
case "pull_request_review_comment": {
|
|
18666
|
+
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
18667
|
+
const number = readNumber(pr?.number);
|
|
18668
|
+
if (number === null) return null;
|
|
18669
|
+
return {
|
|
18670
|
+
type: "pull_request",
|
|
18671
|
+
key: `${repo}#${number}`,
|
|
18672
|
+
title: readString(pr?.title) ?? void 0,
|
|
18673
|
+
url: readString(pr?.html_url) ?? void 0
|
|
18674
|
+
};
|
|
18675
|
+
}
|
|
18676
|
+
case "discussion":
|
|
18677
|
+
case "discussion_comment": {
|
|
18678
|
+
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
18679
|
+
const number = readNumber(disc?.number);
|
|
18680
|
+
if (number === null) return null;
|
|
18681
|
+
return {
|
|
18682
|
+
type: "discussion",
|
|
18683
|
+
key: `${repo}#discussion-${number}`,
|
|
18684
|
+
title: readString(disc?.title) ?? void 0,
|
|
18685
|
+
url: readString(disc?.html_url) ?? void 0
|
|
18686
|
+
};
|
|
18687
|
+
}
|
|
18688
|
+
case "commit_comment": {
|
|
18689
|
+
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
18690
|
+
const sha = readString(comment?.commit_id);
|
|
18691
|
+
if (!sha) return null;
|
|
18692
|
+
return {
|
|
18693
|
+
type: "commit",
|
|
18694
|
+
key: `${repo}@${sha}`,
|
|
18695
|
+
url: readString(comment?.html_url) ?? void 0
|
|
18696
|
+
};
|
|
18697
|
+
}
|
|
18698
|
+
default: return null;
|
|
17647
18699
|
}
|
|
17648
18700
|
}
|
|
17649
|
-
|
|
17650
|
-
|
|
17651
|
-
|
|
17652
|
-
|
|
17653
|
-
|
|
17654
|
-
|
|
17655
|
-
|
|
17656
|
-
|
|
17657
|
-
|
|
17658
|
-
|
|
17659
|
-
|
|
17660
|
-
|
|
17661
|
-
|
|
17662
|
-
|
|
17663
|
-
|
|
17664
|
-
|
|
17665
|
-
|
|
18701
|
+
/**
|
|
18702
|
+
* Closing-keyword regex from
|
|
18703
|
+
* https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue
|
|
18704
|
+
* — `close[sd]? | fix(es|ed)? | resolve[sd]?`. Cross-repo `org/repo#N` is
|
|
18705
|
+
* deliberately excluded (out of scope for Phase 0; see §4.5).
|
|
18706
|
+
*/
|
|
18707
|
+
const FIXES_KEYWORDS_RE = /\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\s+#(\d+)\b/gi;
|
|
18708
|
+
/**
|
|
18709
|
+
* Parse `Fixes #N` / `Closes #N` / `Resolves #N` references out of a PR body.
|
|
18710
|
+
* Returns ordered, deduplicated entity references for issues in the same repo
|
|
18711
|
+
* (cross-repo refs ignored per §4.5).
|
|
18712
|
+
*
|
|
18713
|
+
* Caller is expected to pass `repoFullName` so we can build the entity key.
|
|
18714
|
+
*/
|
|
18715
|
+
function parseFixesRefs(text, repoFullName) {
|
|
18716
|
+
if (!text) return [];
|
|
18717
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18718
|
+
const out = [];
|
|
18719
|
+
for (const match of text.matchAll(FIXES_KEYWORDS_RE)) {
|
|
18720
|
+
const num = match[1];
|
|
18721
|
+
if (!num) continue;
|
|
18722
|
+
const key = `${repoFullName}#${num}`;
|
|
18723
|
+
if (seen.has(key)) continue;
|
|
18724
|
+
seen.add(key);
|
|
18725
|
+
out.push({
|
|
18726
|
+
type: "issue",
|
|
18727
|
+
key
|
|
18728
|
+
});
|
|
17666
18729
|
}
|
|
17667
|
-
return
|
|
18730
|
+
return out;
|
|
17668
18731
|
}
|
|
17669
|
-
|
|
17670
|
-
|
|
18732
|
+
/**
|
|
18733
|
+
* Pick a chat-title prefix from (entity, eventType, action).
|
|
18734
|
+
*
|
|
18735
|
+
* PR review-flow events (`pull_request.review_requested`,
|
|
18736
|
+
* `pull_request_review.*`, `pull_request_review_comment.*`) collapse into a
|
|
18737
|
+
* single "PR Review" prefix so a chat first-touched by a review event is
|
|
18738
|
+
* visibly distinct from one first-touched by `pull_request.opened`. Everything
|
|
18739
|
+
* else just renders the entity type.
|
|
18740
|
+
*
|
|
18741
|
+
* Note: chat titles are written once at chat creation (see
|
|
18742
|
+
* `github-entity-chat.ts::createEntityChat`) — subsequent events for the same
|
|
18743
|
+
* entity reuse the existing title even if their (event, action) maps to a
|
|
18744
|
+
* different prefix. This matches the "entity is the container" semantic.
|
|
18745
|
+
*/
|
|
18746
|
+
function entityTitlePrefix(entity, eventType, action) {
|
|
18747
|
+
if (eventType === "pull_request" && action === "review_requested") return "PR Review";
|
|
18748
|
+
if (eventType === "pull_request_review") return "PR Review";
|
|
18749
|
+
if (eventType === "pull_request_review_comment") return "PR Review";
|
|
18750
|
+
switch (entity.type) {
|
|
18751
|
+
case "issue": return "Issue";
|
|
18752
|
+
case "pull_request": return "PR";
|
|
18753
|
+
case "discussion": return "Discussion";
|
|
18754
|
+
case "commit": return "Commit";
|
|
18755
|
+
}
|
|
18756
|
+
}
|
|
18757
|
+
/**
|
|
18758
|
+
* Strip the leading `owner/` segment from an entity key so the chat title
|
|
18759
|
+
* stays compact. `owner/repo#42` → `repo#42`; `owner/repo@abc1234` →
|
|
18760
|
+
* `repo@abc1234`. The full `owner/repo#N` form is still used as the
|
|
18761
|
+
* clustering primary key (`github_entity_chat_mappings.entity_key`); only the
|
|
18762
|
+
* display string is shortened.
|
|
18763
|
+
*/
|
|
18764
|
+
function shortEntityKey(key) {
|
|
18765
|
+
const slash = key.indexOf("/");
|
|
18766
|
+
return slash === -1 ? key : key.slice(slash + 1);
|
|
18767
|
+
}
|
|
18768
|
+
/**
|
|
18769
|
+
* Render a chat topic from an entity. Used as the chat title; kept short so
|
|
18770
|
+
* the chat-list row doesn't truncate aggressively.
|
|
18771
|
+
*
|
|
18772
|
+
* formatEntityTitle({ type: "pull_request", key: "owner/repo#307", title: "Improve overview" }, "pull_request", "opened")
|
|
18773
|
+
* → "PR repo#307: Improve overview"
|
|
18774
|
+
* formatEntityTitle(<same>, "pull_request", "review_requested")
|
|
18775
|
+
* → "PR Review repo#307: Improve overview"
|
|
18776
|
+
*/
|
|
18777
|
+
function formatEntityTitle(entity, eventType, action) {
|
|
18778
|
+
const head = `${entityTitlePrefix(entity, eventType, action)} ${shortEntityKey(entity.key)}`;
|
|
18779
|
+
if (entity.title && entity.title.length > 0) return `${head}: ${entity.title}`;
|
|
18780
|
+
return head;
|
|
18781
|
+
}
|
|
18782
|
+
const SILENT_EVENT_TYPES = new Set([
|
|
18783
|
+
"workflow_run",
|
|
18784
|
+
"workflow_job",
|
|
18785
|
+
"check_run",
|
|
18786
|
+
"check_suite",
|
|
18787
|
+
"status",
|
|
18788
|
+
"push",
|
|
18789
|
+
"create",
|
|
18790
|
+
"delete",
|
|
18791
|
+
"fork",
|
|
18792
|
+
"watch",
|
|
18793
|
+
"release",
|
|
18794
|
+
"label",
|
|
18795
|
+
"label_created",
|
|
18796
|
+
"label_deleted",
|
|
18797
|
+
"reaction",
|
|
18798
|
+
"member",
|
|
18799
|
+
"membership",
|
|
18800
|
+
"team",
|
|
18801
|
+
"team_add",
|
|
18802
|
+
"organization",
|
|
18803
|
+
"org_block",
|
|
18804
|
+
"project",
|
|
18805
|
+
"project_card",
|
|
18806
|
+
"project_column"
|
|
18807
|
+
]);
|
|
18808
|
+
/**
|
|
18809
|
+
* Per-event-type action-level filters. Frequent low-signal actions that would
|
|
18810
|
+
* otherwise spam an entity chat. `synchronize` (PR branch push) is the most
|
|
18811
|
+
* common offender — it fires on every commit push to a PR branch and never
|
|
18812
|
+
* carries new conversation.
|
|
18813
|
+
*/
|
|
18814
|
+
const SILENT_ACTIONS = {
|
|
18815
|
+
issues: new Set([
|
|
18816
|
+
"labeled",
|
|
18817
|
+
"unlabeled",
|
|
18818
|
+
"milestoned",
|
|
18819
|
+
"demilestoned",
|
|
18820
|
+
"pinned",
|
|
18821
|
+
"unpinned"
|
|
18822
|
+
]),
|
|
18823
|
+
pull_request: new Set([
|
|
18824
|
+
"labeled",
|
|
18825
|
+
"unlabeled",
|
|
18826
|
+
"auto_merge_enabled",
|
|
18827
|
+
"auto_merge_disabled",
|
|
18828
|
+
"synchronize"
|
|
18829
|
+
])
|
|
18830
|
+
};
|
|
18831
|
+
/** True iff the event should be silently 200-OKed without further routing. */
|
|
18832
|
+
function shouldSilent(eventType, payload) {
|
|
18833
|
+
if (SILENT_EVENT_TYPES.has(eventType)) return true;
|
|
18834
|
+
if (!isRecord(payload)) return false;
|
|
18835
|
+
if (readString((isRecord(payload.sender) ? payload.sender : null)?.type) === "Bot") return true;
|
|
18836
|
+
const action = readString(payload.action);
|
|
18837
|
+
if (!action) return false;
|
|
18838
|
+
return SILENT_ACTIONS[eventType]?.has(action) ?? false;
|
|
18839
|
+
}
|
|
18840
|
+
/**
|
|
18841
|
+
* GitHub-specific webhook entity → chat clustering (Phase 0).
|
|
18842
|
+
*
|
|
18843
|
+
* Each `(organization, human_agent, delegate_agent, entity)` tuple resolves to
|
|
18844
|
+
* exactly one chat. Future external sources (Linear, Slack, …) get their own
|
|
18845
|
+
* tables — their entity models differ enough that a generic table would slip
|
|
18846
|
+
* back into untyped jsonb.
|
|
18847
|
+
*
|
|
18848
|
+
* `bound_via` distinguishes the first-touch row (`direct`) from a row written
|
|
18849
|
+
* by the `Fixes #N` linker (`fixes_link`). Routing logic ignores the
|
|
18850
|
+
* distinction; it exists for audit and future strategy tweaks.
|
|
18851
|
+
*/
|
|
18852
|
+
const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
|
|
18853
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
18854
|
+
humanAgentId: text("human_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
18855
|
+
delegateAgentId: text("delegate_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
18856
|
+
entityType: text("entity_type").notNull(),
|
|
18857
|
+
entityKey: text("entity_key").notNull(),
|
|
18858
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
18859
|
+
boundAt: timestamp("bound_at", { withTimezone: true }).notNull().defaultNow(),
|
|
18860
|
+
boundVia: text("bound_via").notNull()
|
|
18861
|
+
}, (table) => [primaryKey({ columns: [
|
|
18862
|
+
table.organizationId,
|
|
18863
|
+
table.humanAgentId,
|
|
18864
|
+
table.delegateAgentId,
|
|
18865
|
+
table.entityType,
|
|
18866
|
+
table.entityKey
|
|
18867
|
+
] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
|
|
18868
|
+
/**
|
|
18869
|
+
* Resolve which chat a GitHub event for (human, delegate, entity) belongs to.
|
|
18870
|
+
*
|
|
18871
|
+
* Three-step strategy from docs/webhook-routing-design.md §4.4:
|
|
18872
|
+
* a. Direct hit — entity already bound; reuse that chat.
|
|
18873
|
+
* b. Fixes-link — any related entity (parsed from `Fixes #N` in a PR body)
|
|
18874
|
+
* already bound; write a `fixes_link` row for this entity pointing at
|
|
18875
|
+
* the same chat, return it.
|
|
18876
|
+
* c. Miss — create a fresh chat via the canonical `createChat` entrypoint
|
|
18877
|
+
* and write a `direct` mapping row.
|
|
18878
|
+
*
|
|
18879
|
+
* Concurrent webhook deliveries for a never-before-seen entity race on (c);
|
|
18880
|
+
* the composite primary key + ON CONFLICT DO NOTHING ensures only one row
|
|
18881
|
+
* survives. The losing caller falls back to a re-read so the chat stays
|
|
18882
|
+
* unique.
|
|
18883
|
+
*/
|
|
18884
|
+
async function resolveTargetChat(db, params) {
|
|
18885
|
+
const { organizationId, humanAgentId, delegateAgentId, entity, relatedEntities, eventType, action } = params;
|
|
18886
|
+
const direct = await lookupMapping(db, organizationId, humanAgentId, delegateAgentId, entity);
|
|
18887
|
+
if (direct) return {
|
|
18888
|
+
chatId: direct.chatId,
|
|
18889
|
+
created: false,
|
|
18890
|
+
boundVia: direct.boundVia
|
|
18891
|
+
};
|
|
18892
|
+
for (const ref of relatedEntities) {
|
|
18893
|
+
const linked = await lookupMapping(db, organizationId, humanAgentId, delegateAgentId, ref);
|
|
18894
|
+
if (!linked) continue;
|
|
18895
|
+
const inserted = await insertMappingIfAbsent(db, {
|
|
18896
|
+
organizationId,
|
|
18897
|
+
humanAgentId,
|
|
18898
|
+
delegateAgentId,
|
|
18899
|
+
entity,
|
|
18900
|
+
chatId: linked.chatId,
|
|
18901
|
+
boundVia: "fixes_link"
|
|
18902
|
+
});
|
|
18903
|
+
return {
|
|
18904
|
+
chatId: inserted.chatId,
|
|
18905
|
+
created: false,
|
|
18906
|
+
boundVia: inserted.boundVia
|
|
18907
|
+
};
|
|
18908
|
+
}
|
|
18909
|
+
const chat = await createEntityChat(db, humanAgentId, delegateAgentId, entity, eventType, action);
|
|
18910
|
+
const inserted = await insertMappingIfAbsent(db, {
|
|
18911
|
+
organizationId,
|
|
18912
|
+
humanAgentId,
|
|
18913
|
+
delegateAgentId,
|
|
18914
|
+
entity,
|
|
18915
|
+
chatId: chat.id,
|
|
18916
|
+
boundVia: "direct"
|
|
18917
|
+
});
|
|
18918
|
+
return {
|
|
18919
|
+
chatId: inserted.chatId,
|
|
18920
|
+
created: inserted.chatId === chat.id,
|
|
18921
|
+
boundVia: inserted.boundVia
|
|
18922
|
+
};
|
|
18923
|
+
}
|
|
18924
|
+
async function lookupMapping(db, organizationId, humanAgentId, delegateAgentId, entity) {
|
|
18925
|
+
const [row] = await db.select({
|
|
18926
|
+
chatId: githubEntityChatMappings.chatId,
|
|
18927
|
+
boundVia: githubEntityChatMappings.boundVia
|
|
18928
|
+
}).from(githubEntityChatMappings).where(and(eq(githubEntityChatMappings.organizationId, organizationId), eq(githubEntityChatMappings.humanAgentId, humanAgentId), eq(githubEntityChatMappings.delegateAgentId, delegateAgentId), eq(githubEntityChatMappings.entityType, entity.type), eq(githubEntityChatMappings.entityKey, entity.key))).limit(1);
|
|
18929
|
+
if (!row) return null;
|
|
18930
|
+
return {
|
|
18931
|
+
chatId: row.chatId,
|
|
18932
|
+
boundVia: row.boundVia === "fixes_link" ? "fixes_link" : "direct"
|
|
18933
|
+
};
|
|
18934
|
+
}
|
|
18935
|
+
async function insertMappingIfAbsent(db, params) {
|
|
18936
|
+
const [inserted] = await db.insert(githubEntityChatMappings).values({
|
|
18937
|
+
organizationId: params.organizationId,
|
|
18938
|
+
humanAgentId: params.humanAgentId,
|
|
18939
|
+
delegateAgentId: params.delegateAgentId,
|
|
18940
|
+
entityType: params.entity.type,
|
|
18941
|
+
entityKey: params.entity.key,
|
|
18942
|
+
chatId: params.chatId,
|
|
18943
|
+
boundVia: params.boundVia
|
|
18944
|
+
}).onConflictDoNothing({ target: [
|
|
18945
|
+
githubEntityChatMappings.organizationId,
|
|
18946
|
+
githubEntityChatMappings.humanAgentId,
|
|
18947
|
+
githubEntityChatMappings.delegateAgentId,
|
|
18948
|
+
githubEntityChatMappings.entityType,
|
|
18949
|
+
githubEntityChatMappings.entityKey
|
|
18950
|
+
] }).returning({
|
|
18951
|
+
chatId: githubEntityChatMappings.chatId,
|
|
18952
|
+
boundVia: githubEntityChatMappings.boundVia
|
|
18953
|
+
});
|
|
18954
|
+
if (inserted) return {
|
|
18955
|
+
chatId: inserted.chatId,
|
|
18956
|
+
boundVia: inserted.boundVia === "fixes_link" ? "fixes_link" : "direct"
|
|
18957
|
+
};
|
|
18958
|
+
const winner = await lookupMapping(db, params.organizationId, params.humanAgentId, params.delegateAgentId, params.entity);
|
|
18959
|
+
if (!winner) throw new Error("Unexpected: mapping insert conflicted but row not visible on re-read");
|
|
18960
|
+
return winner;
|
|
18961
|
+
}
|
|
18962
|
+
/**
|
|
18963
|
+
* Create a fresh chat for a (human, delegate, entity) tuple. Goes through the
|
|
18964
|
+
* canonical `createChat` so:
|
|
18965
|
+
* - cross-org participants are rejected (BadRequestError)
|
|
18966
|
+
* - direct agent-only chats automatically get `mode=mention_only`
|
|
18967
|
+
* - watcher rows are recomputed
|
|
18968
|
+
* - a future addParticipant call would upgrade the chat to `group` via
|
|
18969
|
+
* the server's `changeChatType` service instead of raw INSERT shortcuts
|
|
18970
|
+
*/
|
|
18971
|
+
async function createEntityChat(db, humanAgentId, delegateAgentId, entity, eventType, action) {
|
|
18972
|
+
const metadata = chatMetadataSchema$1.parse({
|
|
18973
|
+
source: "github",
|
|
18974
|
+
entityType: entity.type,
|
|
18975
|
+
entityKey: entity.key,
|
|
18976
|
+
...entity.url ? { entityUrl: entity.url } : {}
|
|
18977
|
+
});
|
|
18978
|
+
return { id: (await createChat(db, humanAgentId, {
|
|
18979
|
+
type: "direct",
|
|
18980
|
+
participantIds: [delegateAgentId],
|
|
18981
|
+
topic: formatEntityTitle(entity, eventType, action),
|
|
18982
|
+
metadata
|
|
18983
|
+
})).id };
|
|
18984
|
+
}
|
|
18985
|
+
const log$1 = createLogger$1("GithubWebhook");
|
|
18986
|
+
function verifySignature(secret, rawBody, signatureHeader) {
|
|
18987
|
+
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
18988
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
18989
|
+
const receivedBuf = Buffer.from(signatureHeader, "utf8");
|
|
18990
|
+
if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) throw new UnauthorizedError("Invalid webhook signature");
|
|
17671
18991
|
}
|
|
17672
18992
|
/** Extract unique @mentions from text. Returns lowercase usernames.
|
|
17673
18993
|
* Excludes email patterns (user@example.com) and team mentions (@org/team). */
|
|
@@ -17708,10 +19028,18 @@ function evaluateDelegateTarget(target, sourceOrgId) {
|
|
|
17708
19028
|
}
|
|
17709
19029
|
/**
|
|
17710
19030
|
* Route @mentions to delegate agents.
|
|
17711
|
-
*
|
|
17712
|
-
*
|
|
19031
|
+
*
|
|
19032
|
+
* For each mentioned GitHub user who maps to an agent with `delegate_mention`
|
|
19033
|
+
* configured, resolve which chat the event belongs to (via §4.4's
|
|
19034
|
+
* entity-clustering rules) and post a card from the human-bound agent to its
|
|
19035
|
+
* delegate.
|
|
19036
|
+
*
|
|
19037
|
+
* The entity argument is the §4.2 entity for the current event; `relatedRefs`
|
|
19038
|
+
* is the parsed `Fixes #N` list (empty for non-PR events). Both are
|
|
19039
|
+
* pre-computed by the caller so the heavy parsing doesn't run once per
|
|
19040
|
+
* mention.
|
|
17713
19041
|
*/
|
|
17714
|
-
async function routeMentionDelegations(app, organizationId, mentionedNames, ctx) {
|
|
19042
|
+
async function routeMentionDelegations(app, organizationId, mentionedNames, ctx, entity, relatedRefs) {
|
|
17715
19043
|
if (mentionedNames.length === 0) return 0;
|
|
17716
19044
|
const delegates = await app.db.select({
|
|
17717
19045
|
id: agents.uuid,
|
|
@@ -17740,8 +19068,24 @@ async function routeMentionDelegations(app, organizationId, mentionedNames, ctx)
|
|
|
17740
19068
|
continue;
|
|
17741
19069
|
}
|
|
17742
19070
|
try {
|
|
17743
|
-
const
|
|
17744
|
-
|
|
19071
|
+
const resolved = await resolveTargetChat(app.db, {
|
|
19072
|
+
organizationId,
|
|
19073
|
+
humanAgentId: agent.id,
|
|
19074
|
+
delegateAgentId: agent.delegateMention,
|
|
19075
|
+
entity,
|
|
19076
|
+
relatedEntities: relatedRefs,
|
|
19077
|
+
eventType: ctx.event,
|
|
19078
|
+
action: ctx.action ?? ""
|
|
19079
|
+
});
|
|
19080
|
+
log$1.info({
|
|
19081
|
+
chatId: resolved.chatId,
|
|
19082
|
+
entityType: entity.type,
|
|
19083
|
+
entityKey: entity.key,
|
|
19084
|
+
boundVia: resolved.boundVia,
|
|
19085
|
+
created: resolved.created,
|
|
19086
|
+
humanAgent: agent.name
|
|
19087
|
+
}, "resolved entity chat");
|
|
19088
|
+
const { message: msg, recipients } = await sendMessage(app.db, resolved.chatId, agent.id, {
|
|
17745
19089
|
format: "card",
|
|
17746
19090
|
content: {
|
|
17747
19091
|
type: "github_mention",
|
|
@@ -17752,13 +19096,20 @@ async function routeMentionDelegations(app, organizationId, mentionedNames, ctx)
|
|
|
17752
19096
|
sender: ctx.sender,
|
|
17753
19097
|
title: ctx.title,
|
|
17754
19098
|
body: ctx.body,
|
|
17755
|
-
url: ctx.url
|
|
19099
|
+
url: ctx.url,
|
|
19100
|
+
entity: {
|
|
19101
|
+
type: entity.type,
|
|
19102
|
+
key: entity.key,
|
|
19103
|
+
url: entity.url ?? null
|
|
19104
|
+
}
|
|
17756
19105
|
},
|
|
17757
19106
|
metadata: {
|
|
17758
19107
|
source: "github",
|
|
17759
19108
|
event: "mention_delegation",
|
|
17760
19109
|
mentionedUser: agent.name,
|
|
17761
|
-
action: ctx.action
|
|
19110
|
+
action: ctx.action,
|
|
19111
|
+
entityType: entity.type,
|
|
19112
|
+
entityKey: entity.key
|
|
17762
19113
|
}
|
|
17763
19114
|
});
|
|
17764
19115
|
notifyRecipients(app.notifier, recipients, msg.id);
|
|
@@ -17773,43 +19124,6 @@ async function routeMentionDelegations(app, organizationId, mentionedNames, ctx)
|
|
|
17773
19124
|
}
|
|
17774
19125
|
return routed;
|
|
17775
19126
|
}
|
|
17776
|
-
function parseIssuesPayload(body) {
|
|
17777
|
-
if (!isRecord(body)) throw new BadRequestError("Invalid payload: expected object");
|
|
17778
|
-
if (typeof body.action !== "string") throw new BadRequestError("Invalid payload: missing action");
|
|
17779
|
-
if (!isRecord(body.issue)) throw new BadRequestError("Invalid payload: missing issue");
|
|
17780
|
-
if (!isRecord(body.repository)) throw new BadRequestError("Invalid payload: missing repository");
|
|
17781
|
-
if (!isRecord(body.sender)) throw new BadRequestError("Invalid payload: missing sender");
|
|
17782
|
-
const issue = body.issue;
|
|
17783
|
-
const labels = Array.isArray(issue.labels) ? issue.labels.filter((l) => isRecord(l) && typeof l.name === "string") : [];
|
|
17784
|
-
return {
|
|
17785
|
-
action: body.action,
|
|
17786
|
-
issue: {
|
|
17787
|
-
number: typeof issue.number === "number" ? issue.number : 0,
|
|
17788
|
-
title: typeof issue.title === "string" ? issue.title : "",
|
|
17789
|
-
body: typeof issue.body === "string" ? issue.body : null,
|
|
17790
|
-
html_url: typeof issue.html_url === "string" ? issue.html_url : "",
|
|
17791
|
-
labels,
|
|
17792
|
-
state: typeof issue.state === "string" ? issue.state : "open"
|
|
17793
|
-
},
|
|
17794
|
-
repository: { full_name: typeof body.repository.full_name === "string" ? body.repository.full_name : "" },
|
|
17795
|
-
sender: { login: typeof body.sender.login === "string" ? body.sender.login : "" }
|
|
17796
|
-
};
|
|
17797
|
-
}
|
|
17798
|
-
function parseIssueCommentPayload(body) {
|
|
17799
|
-
const base = parseIssuesPayload(body);
|
|
17800
|
-
if (!isRecord(body)) throw new BadRequestError("Invalid payload: expected object");
|
|
17801
|
-
if (!isRecord(body.comment)) throw new BadRequestError("Invalid payload: missing comment");
|
|
17802
|
-
const comment = body.comment;
|
|
17803
|
-
const commentUser = isRecord(comment.user) ? comment.user : { login: "" };
|
|
17804
|
-
return {
|
|
17805
|
-
...base,
|
|
17806
|
-
comment: {
|
|
17807
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
17808
|
-
html_url: typeof comment.html_url === "string" ? comment.html_url : "",
|
|
17809
|
-
user: { login: typeof commentUser.login === "string" ? commentUser.login : "" }
|
|
17810
|
-
}
|
|
17811
|
-
};
|
|
17812
|
-
}
|
|
17813
19127
|
async function githubWebhookRoutes(app) {
|
|
17814
19128
|
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
17815
19129
|
done(null, body);
|
|
@@ -17839,6 +19153,11 @@ async function githubWebhookRoutes(app) {
|
|
|
17839
19153
|
ok: true,
|
|
17840
19154
|
event: "ping"
|
|
17841
19155
|
});
|
|
19156
|
+
if (shouldSilent(eventType, payload)) return reply.status(200).send({
|
|
19157
|
+
ok: true,
|
|
19158
|
+
event: eventType,
|
|
19159
|
+
silent: true
|
|
19160
|
+
});
|
|
17842
19161
|
const deliveryHeader = request.headers["x-github-delivery"];
|
|
17843
19162
|
const deliveryId = typeof deliveryHeader === "string" && deliveryHeader.length > 0 ? deliveryHeader : null;
|
|
17844
19163
|
if (deliveryId) {
|
|
@@ -17855,16 +19174,17 @@ async function githubWebhookRoutes(app) {
|
|
|
17855
19174
|
}
|
|
17856
19175
|
}
|
|
17857
19176
|
try {
|
|
17858
|
-
if (eventType === "issues") return await handleIssuesEvent(app, orgId, eventType, payload, reply);
|
|
17859
|
-
if (eventType === "issue_comment") return await handleIssueCommentEvent(app, orgId, eventType, payload, reply);
|
|
17860
|
-
let mentionsRouted = 0;
|
|
17861
|
-
const allowedActions = MENTION_ACTIONS[eventType];
|
|
17862
19177
|
const action = isRecord(payload) && typeof payload.action === "string" ? payload.action : void 0;
|
|
17863
|
-
|
|
19178
|
+
const allowedActions = MENTION_ACTIONS[eventType];
|
|
19179
|
+
if (!allowedActions || !action || !allowedActions.includes(action)) return reply.status(200).send({
|
|
19180
|
+
ok: true,
|
|
19181
|
+
event: eventType,
|
|
19182
|
+
handled: false
|
|
19183
|
+
});
|
|
19184
|
+
const mentionsRouted = await handleMentionDelegation(app, orgId, eventType, payload);
|
|
17864
19185
|
return reply.status(200).send({
|
|
17865
19186
|
ok: true,
|
|
17866
19187
|
event: eventType,
|
|
17867
|
-
handled: mentionsRouted > 0,
|
|
17868
19188
|
mentionsRouted
|
|
17869
19189
|
});
|
|
17870
19190
|
} catch (err) {
|
|
@@ -18036,9 +19356,15 @@ async function handleMentionDelegation(app, organizationId, eventType, payload)
|
|
|
18036
19356
|
const textMentions = extractMentions$1(extractEventText(eventType, payload));
|
|
18037
19357
|
const structuralMentions = extractStructuralMentions(eventType, payload);
|
|
18038
19358
|
const mentions = [...new Set([...textMentions, ...structuralMentions])];
|
|
18039
|
-
|
|
18040
|
-
|
|
18041
|
-
return 0;
|
|
19359
|
+
if (mentions.length === 0) return 0;
|
|
19360
|
+
const ctx = extractEventContext(eventType, payload);
|
|
19361
|
+
if (!ctx) return 0;
|
|
19362
|
+
const entity = extractEventEntity(eventType, payload);
|
|
19363
|
+
if (!entity) {
|
|
19364
|
+
log$1.warn({ eventType }, "mention extracted but no entity resolvable; skipping fan-out");
|
|
19365
|
+
return 0;
|
|
19366
|
+
}
|
|
19367
|
+
return routeMentionDelegations(app, organizationId, mentions, ctx, entity, eventType === "pull_request" && ctx.repository.length > 0 ? parseFixesRefs(ctx.body, ctx.repository) : []);
|
|
18042
19368
|
}
|
|
18043
19369
|
/** Actions that represent new/changed content (worth scanning for @mentions).
|
|
18044
19370
|
* Note: `pull_request.review_requested` doesn't carry an @mention in any
|
|
@@ -18059,123 +19385,39 @@ const MENTION_ACTIONS = {
|
|
|
18059
19385
|
discussion_comment: ["created"],
|
|
18060
19386
|
commit_comment: ["created"]
|
|
18061
19387
|
};
|
|
18062
|
-
|
|
18063
|
-
|
|
18064
|
-
|
|
18065
|
-
|
|
18066
|
-
|
|
18067
|
-
|
|
18068
|
-
|
|
18069
|
-
|
|
18070
|
-
|
|
18071
|
-
|
|
18072
|
-
|
|
18073
|
-
|
|
18074
|
-
|
|
18075
|
-
|
|
18076
|
-
|
|
18077
|
-
|
|
18078
|
-
|
|
18079
|
-
|
|
18080
|
-
|
|
18081
|
-
|
|
18082
|
-
|
|
18083
|
-
|
|
18084
|
-
|
|
18085
|
-
|
|
18086
|
-
|
|
18087
|
-
|
|
18088
|
-
|
|
18089
|
-
|
|
18090
|
-
action: data.action,
|
|
18091
|
-
issue: {
|
|
18092
|
-
number: data.issue.number,
|
|
18093
|
-
title: data.issue.title,
|
|
18094
|
-
body: data.issue.body,
|
|
18095
|
-
url: data.issue.html_url,
|
|
18096
|
-
labels: data.issue.labels.map((l) => l.name),
|
|
18097
|
-
state: data.issue.state
|
|
18098
|
-
},
|
|
18099
|
-
repository: data.repository.full_name,
|
|
18100
|
-
sender: data.sender.login
|
|
18101
|
-
};
|
|
18102
|
-
const metadata = {
|
|
18103
|
-
source: "github",
|
|
18104
|
-
event: "issues",
|
|
18105
|
-
action: data.action
|
|
18106
|
-
};
|
|
18107
|
-
const chat = await findOrCreateDirectChat(app.db, senderId, targetAgentId);
|
|
18108
|
-
const { message: msg, recipients } = await sendMessage(app.db, chat.id, senderId, {
|
|
18109
|
-
format: "card",
|
|
18110
|
-
content,
|
|
18111
|
-
metadata
|
|
18112
|
-
});
|
|
18113
|
-
notifyRecipients(app.notifier, recipients, msg.id);
|
|
18114
|
-
return reply.status(200).send({
|
|
18115
|
-
ok: true,
|
|
18116
|
-
event: "issues",
|
|
18117
|
-
action: data.action,
|
|
18118
|
-
routed: true
|
|
18119
|
-
});
|
|
18120
|
-
}
|
|
18121
|
-
async function handleIssueCommentEvent(app, organizationId, eventType, payload, reply) {
|
|
18122
|
-
const data = parseIssueCommentPayload(payload);
|
|
18123
|
-
if (MENTION_ACTIONS.issue_comment?.includes(data.action)) await handleMentionDelegation(app, organizationId, eventType, payload);
|
|
18124
|
-
if (data.action !== "created") return reply.status(200).send({
|
|
18125
|
-
ok: true,
|
|
18126
|
-
event: "issue_comment",
|
|
18127
|
-
action: data.action,
|
|
18128
|
-
handled: false
|
|
18129
|
-
});
|
|
18130
|
-
const [senderId, targetAgentId] = await Promise.all([ensureGitHubAdapterAgent(app.db, organizationId), findTargetAgent(app.db, organizationId, data.repository.full_name)]);
|
|
18131
|
-
if (!targetAgentId) {
|
|
18132
|
-
log$1.warn({
|
|
18133
|
-
repo: data.repository.full_name,
|
|
18134
|
-
event: "issue_comment"
|
|
18135
|
-
}, "no target agent found for GitHub event");
|
|
18136
|
-
return reply.status(200).send({
|
|
18137
|
-
ok: true,
|
|
18138
|
-
event: "issue_comment",
|
|
18139
|
-
action: data.action,
|
|
18140
|
-
routed: false
|
|
18141
|
-
});
|
|
18142
|
-
}
|
|
18143
|
-
const content = {
|
|
18144
|
-
type: "github_issue_comment",
|
|
18145
|
-
action: data.action,
|
|
18146
|
-
issue: {
|
|
18147
|
-
number: data.issue.number,
|
|
18148
|
-
title: data.issue.title,
|
|
18149
|
-
url: data.issue.html_url,
|
|
18150
|
-
labels: data.issue.labels.map((l) => l.name),
|
|
18151
|
-
state: data.issue.state
|
|
18152
|
-
},
|
|
18153
|
-
comment: {
|
|
18154
|
-
body: data.comment.body,
|
|
18155
|
-
url: data.comment.html_url,
|
|
18156
|
-
author: data.comment.user.login
|
|
18157
|
-
},
|
|
18158
|
-
repository: data.repository.full_name,
|
|
18159
|
-
sender: data.sender.login
|
|
18160
|
-
};
|
|
18161
|
-
const metadata = {
|
|
18162
|
-
source: "github",
|
|
18163
|
-
event: "issue_comment",
|
|
18164
|
-
action: data.action
|
|
19388
|
+
/**
|
|
19389
|
+
* Boot-time configuration sanity checks. Called from `buildApp` so BOTH
|
|
19390
|
+
* server entry points are covered:
|
|
19391
|
+
* - `packages/server/src/index.ts` (the standalone bin)
|
|
19392
|
+
* - `packages/command/src/core/server.ts` (the CLI `server start` path)
|
|
19393
|
+
*
|
|
19394
|
+
* The previous incarnation lived in `index.ts` and was only run by the
|
|
19395
|
+
* standalone bin — the CLI path could boot a misconfigured prod with no
|
|
19396
|
+
* surface complaint (codex P1-8).
|
|
19397
|
+
*
|
|
19398
|
+
* Throws on misconfiguration; never returns a value.
|
|
19399
|
+
*/
|
|
19400
|
+
function assertBootConfigValid(config) {
|
|
19401
|
+
assertProductionRequiresPublicUrl(config);
|
|
19402
|
+
assertGithubAppConfigComplete(config);
|
|
19403
|
+
}
|
|
19404
|
+
function assertProductionRequiresPublicUrl(config) {
|
|
19405
|
+
if (process.env.NODE_ENV === "production" && !config.server.publicUrl) throw new Error("FIRST_TREE_HUB_PUBLIC_URL is required in production — set the public-facing hub URL.");
|
|
19406
|
+
}
|
|
19407
|
+
function assertGithubAppConfigComplete(config) {
|
|
19408
|
+
const ghApp = config.oauth?.githubApp;
|
|
19409
|
+
if (!ghApp) return;
|
|
19410
|
+
const required = {
|
|
19411
|
+
FIRST_TREE_HUB_GITHUB_APP_ID: ghApp.appId,
|
|
19412
|
+
FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID: ghApp.clientId,
|
|
19413
|
+
FIRST_TREE_HUB_GITHUB_APP_CLIENT_SECRET: ghApp.clientSecret,
|
|
19414
|
+
FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY: ghApp.privateKeyPem,
|
|
19415
|
+
FIRST_TREE_HUB_GITHUB_APP_WEBHOOK_SECRET: ghApp.webhookSecret
|
|
18165
19416
|
};
|
|
18166
|
-
const
|
|
18167
|
-
|
|
18168
|
-
|
|
18169
|
-
|
|
18170
|
-
metadata
|
|
18171
|
-
});
|
|
18172
|
-
notifyRecipients(app.notifier, recipients, msg.id);
|
|
18173
|
-
return reply.status(200).send({
|
|
18174
|
-
ok: true,
|
|
18175
|
-
event: "issue_comment",
|
|
18176
|
-
action: data.action,
|
|
18177
|
-
routed: true
|
|
18178
|
-
});
|
|
19417
|
+
const missing = Object.entries(required).filter(([, v]) => !v || v.trim().length === 0).map(([k]) => k);
|
|
19418
|
+
if (missing.length > 0 && missing.length < Object.keys(required).length) throw new Error(`GitHub App is half-configured — missing env vars: ${missing.join(", ")}. Set all five or none.`);
|
|
19419
|
+
if (missing.length === Object.keys(required).length) throw new Error("GitHub App env block is present but every value is empty — unset the FIRST_TREE_HUB_GITHUB_APP_* vars to disable App sign-in.");
|
|
19420
|
+
if (ghApp.privateKeyPem && !ghApp.privateKeyPem.includes("-----BEGIN PRIVATE KEY-----")) throw new Error("FIRST_TREE_HUB_GITHUB_APP_PRIVATE_KEY does not look like a PKCS#8 PEM — expected `-----BEGIN PRIVATE KEY-----` header. If the value came from a single-line env file, replace literal `\\n` with real newlines.");
|
|
18179
19421
|
}
|
|
18180
19422
|
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
18181
19423
|
adapterAgentMappings: () => adapterAgentMappings,
|
|
@@ -18191,6 +19433,8 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
18191
19433
|
chatSubscriptions: () => chatSubscriptions,
|
|
18192
19434
|
chats: () => chats,
|
|
18193
19435
|
clients: () => clients,
|
|
19436
|
+
githubAppInstallations: () => githubAppInstallations,
|
|
19437
|
+
githubEntityChatMappings: () => githubEntityChatMappings,
|
|
18194
19438
|
inboxEntries: () => inboxEntries,
|
|
18195
19439
|
invitationRedemptions: () => invitationRedemptions,
|
|
18196
19440
|
invitations: () => invitations,
|
|
@@ -19524,6 +20768,7 @@ async function buildApp(config) {
|
|
|
19524
20768
|
const msg = err instanceof Error ? err.message : String(err);
|
|
19525
20769
|
throw new Error(`${msg} — check FIRST_TREE_HUB_AUTH_*_EXPIRY env vars (got access=${config.auth.accessTokenExpiry}, refresh=${config.auth.refreshTokenExpiry}, connect=${config.auth.connectTokenExpiry}).`);
|
|
19526
20770
|
}
|
|
20771
|
+
assertBootConfigValid(config);
|
|
19527
20772
|
applyLoggerConfig({
|
|
19528
20773
|
level: config.observability.logging.level,
|
|
19529
20774
|
format: config.observability.logging.format,
|
|
@@ -19700,6 +20945,7 @@ async function buildApp(config) {
|
|
|
19700
20945
|
await scope.register(orgInvitationRoutes, { prefix: "/invitations" });
|
|
19701
20946
|
await scope.register(orgMemberRoutes, { prefix: "/members" });
|
|
19702
20947
|
await scope.register(orgSettingsRoutes, { prefix: "/settings" });
|
|
20948
|
+
await scope.register(orgGithubAppRoutes, { prefix: "/github-app-installation" });
|
|
19703
20949
|
await scope.register(orgContextTreeSnapshotRoutes, { prefix: "/context-tree" });
|
|
19704
20950
|
}), { prefix: "/orgs/:orgId" });
|
|
19705
20951
|
await api.register(orgWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/orgs/:orgId/ws" });
|
|
@@ -20330,4 +21576,4 @@ function registerSaaSConnectCommand(program) {
|
|
|
20330
21576
|
});
|
|
20331
21577
|
}
|
|
20332
21578
|
//#endregion
|
|
20333
|
-
export { formatStaleReason as $, checkDocker as A, isServiceSupported as B, createApiNameResolver as C, checkBackgroundService as D, checkAgentConfigs as E, checkWebSocket as F, uninstallClientService as G, restartClientService as H, printResults as I, stopPostgres as J, ensurePostgres as K, reconcileAgentConfigs as L, checkServerConfig as M, checkServerHealth as N, checkClientConfig as O, checkServerReachable as P, findStaleAliases as Q, getClientServiceStatus as R, runHomeMigration as S, runMigrations as T, startClientService as U, resolveCliInvocation as V, stopClientService as W, handleClientOrgMismatch as X, ClientRuntime as Y, rotateClientIdWithBackup as Z, formatCheckReport as _, declineUpdate as a,
|
|
21579
|
+
export { formatStaleReason as $, checkDocker as A, isServiceSupported as B, createApiNameResolver as C, checkBackgroundService as D, checkAgentConfigs as E, checkWebSocket as F, uninstallClientService as G, restartClientService as H, printResults as I, stopPostgres as J, ensurePostgres as K, reconcileAgentConfigs as L, checkServerConfig as M, checkServerHealth as N, checkClientConfig as O, checkServerReachable as P, findStaleAliases as Q, getClientServiceStatus as R, runHomeMigration as S, runMigrations as T, startClientService as U, resolveCliInvocation as V, stopClientService as W, handleClientOrgMismatch as X, ClientRuntime as Y, rotateClientIdWithBackup as Z, formatCheckReport as _, declineUpdate as a, fail as at, onboardCreate as b, detectInstallMode as c, ClientUserMismatchError as ct, startServer as d, SessionRegistry as dt, removeLocalAgent as et, reconcileLocalRuntimeProviders as f, cleanWorkspaces as ft, promptMissingFields as g, promptAddAgent as h, configureClientLoggerForService as ht, createExecuteUpdate as i, resolveSenderName as it, checkNodeVersion as j, checkDatabase as k, fetchLatestVersion as l, FirstTreeHubSDK as lt, isInteractive as m, applyClientLoggerConfig as mt, deriveHubUrlFromToken as n, hasUser as nt, promptUpdate as o, success as ot, uploadClientCapabilities as p, probeCapabilities as pt, isDockerAvailable as q, registerSaaSConnectCommand as r, resolveReplyToFromEnv as rt, PACKAGE_NAME as s, ClientOrgMismatchError as st, HubUrlDerivationError as t, createOwner as tt, installGlobalLatest as u, SdkError as ut, loadOnboardState as v, migrateLocalAgentDirs as w, saveOnboardState as x, onboardCheck as y, installClientService as z };
|