@desplega.ai/agent-swarm 1.119.6 → 1.121.0
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/README.md +2 -2
- package/dist/{actions-qe5zjcbr.js → actions-mwshtkf7.js} +6 -6
- package/dist/{anthropic-messages-qjxzsabf.js → anthropic-messages-4gn944cn.js} +29 -13
- package/dist/{app-31x7k3vj.js → app-skbty91x.js} +4 -4
- package/dist/{assistant-4crt9dve.js → assistant-tg7dhfj8.js} +13 -13
- package/dist/{azure-openai-responses-ntfd05zd.js → azure-openai-responses-79zc0wyy.js} +28 -13
- package/dist/{boot-reembed-8g69ttz4.js → boot-reembed-ntjmeyh2.js} +4 -4
- package/dist/{boot-reembed-q648j733.js → boot-reembed-q5x0xpcp.js} +5 -5
- package/dist/{boot-scrub-logs-sfmzd2hj.js → boot-scrub-logs-5gznsfbe.js} +4 -4
- package/dist/{claude-adapter-9n3nzt65.js → claude-adapter-vxvcym3r.js} +3 -3
- package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-g807894s.js} +2 -2
- package/dist/{cli-bpvgs2y6.js → cli-072t49hf.js} +2123 -1533
- package/dist/{cli-g6qz2kts.js → cli-0df2g86x.js} +336 -18
- package/dist/cli-1728qh70.js +791 -0
- package/dist/{cli-vg1jxfqz.js → cli-3b1ayz83.js} +5 -5
- package/dist/{cli-r93nz80j.js → cli-3fxqwfh3.js} +1 -1
- package/dist/{cli-w6rhc30s.js → cli-3jrdwnn9.js} +20 -5
- package/dist/{cli-pasyk3vc.js → cli-46etyprn.js} +10 -5
- package/dist/{cli-433c0f4b.js → cli-4pfweq4x.js} +8 -8
- package/dist/{cli-2r7s8jhp.js → cli-5c4327dx.js} +7 -1
- package/dist/{cli-4s84mw72.js → cli-5hpvd15x.js} +2 -2
- package/dist/{cli-branks1w.js → cli-6s3ytc5x.js} +1 -1
- package/dist/{cli-da3x3btq.js → cli-7pt9sc83.js} +2 -2
- package/dist/{cli-2gv8ygwr.js → cli-7pzae66m.js} +2 -2
- package/dist/{cli-qs7nb3zn.js → cli-8wrympd7.js} +27 -1
- package/dist/{cli-qw1ccvxm.js → cli-95rbe11h.js} +70 -51
- package/dist/{cli-6rvm3jkk.js → cli-agdrxyex.js} +1 -1
- package/dist/{cli-wr706gez.js → cli-av2s7my4.js} +167 -66
- package/dist/{cli-59jy217p.js → cli-avw9ev3x.js} +55 -21
- package/dist/{cli-fkxxh5d4.js → cli-b2mqkg1h.js} +2 -2
- package/dist/{cli-1arw00dp.js → cli-ccjwgarc.js} +12 -3
- package/dist/{cli-pscrc6tw.js → cli-ch7t5v7n.js} +5 -0
- package/dist/{cli-tghre7g6.js → cli-cw70hymf.js} +1 -0
- package/dist/{cli-n61x2aph.js → cli-dmzv0986.js} +816 -1335
- package/dist/{cli-d1reabed.js → cli-g9vva2qh.js} +2 -2
- package/dist/{cli-ner6af7m.js → cli-gdpx70nc.js} +24048 -23715
- package/dist/{cli-96feg24n.js → cli-ghq3v4c9.js} +2 -2
- package/dist/{cli-wxbnjgce.js → cli-ky8esb8t.js} +3 -3
- package/dist/{cli-gdqdkr3j.js → cli-nf7sadev.js} +2 -2
- package/dist/{cli-h6cf69x5.js → cli-p9w7kb3h.js} +1 -1
- package/dist/{cli-xeatwfbe.js → cli-ry656xem.js} +24 -17
- package/dist/{cli-q8dway4w.js → cli-s8bdcke2.js} +1 -1
- package/dist/cli-s8s499xy.js +81 -0
- package/dist/{cli-sbkwvn5e.js → cli-srjqn7fd.js} +1 -1
- package/dist/cli-tv7vekye.js +367 -0
- package/dist/{cli-gg5bd32n.js → cli-vg3zrw6h.js} +4 -4
- package/dist/{cli-vx0y68jv.js → cli-vp9r4ap4.js} +5 -4
- package/dist/{cli-mg280wsv.js → cli-we156bmz.js} +1 -1
- package/dist/{cli-86fdnerz.js → cli-wwsaj97q.js} +9 -4
- package/dist/{cli-e0y603nn.js → cli-x0e0yvnq.js} +101 -1
- package/dist/{cli-mw3xpgwb.js → cli-x4apc9t3.js} +4 -4
- package/dist/cli.js +26 -21
- package/dist/{codex-adapter-6j2b38tg.js → codex-adapter-mmy741zd.js} +8 -9
- package/dist/{codex-session-runner-vz215wgw.js → codex-session-runner-sazmx9zt.js} +8 -9
- package/dist/{commands-6xr20jbe.js → commands-cxxye5e4.js} +3 -3
- package/dist/{db-1mmvk1z4.js → db-ab1954b7.js} +5 -3
- package/dist/{google-generative-ai-n1d3xt4n.js → google-generative-ai-wwa2ked5.js} +14 -18
- package/dist/{google-vertex-mxst23gy.js → google-vertex-71xmdd7n.js} +14 -18
- package/dist/{handlers-xvrknk8w.js → handlers-fmfe2343.js} +13 -13
- package/dist/{hook-qm7he8wn.js → hook-ygnw3cvt.js} +7 -8
- package/dist/{http-hj08zq67.js → http-kactcc0z.js} +1128 -536
- package/dist/{index-688jph6a.js → index-6erq0wvk.js} +11 -11
- package/dist/{index-gze4fg2w.js → index-g86j2yt5.js} +9 -9
- package/dist/{index-etz7d8md.js → index-gmzs6a2q.js} +10 -10
- package/dist/{index-wj8dfpwh.js → index-hmp82wn0.js} +12 -12
- package/dist/{keepalive-94wn9yxw.js → keepalive-3cvt26dw.js} +18 -14
- package/dist/{lead-rvcqkp11.js → lead-ysf26cjr.js} +31 -32
- package/dist/{maintenance-qhfc4dnp.js → maintenance-9npwptbb.js} +5 -5
- package/dist/{mistral-conversations-ykcg66za.js → mistral-conversations-26ya4yah.js} +17 -13
- package/dist/{oauth-refresh-sweep-rj8wgvsw.js → oauth-refresh-sweep-0r2v76vw.js} +23 -17
- package/dist/{onboard-8bv8q1wc.js → onboard-ddb1674a.js} +2 -2
- package/dist/{openai-codex-responses-zy0907j9.js → openai-codex-responses-6rr2ye0s.js} +78 -38
- package/dist/{openai-completions-xk4p56n9.js → openai-completions-zfa9nkvt.js} +130 -33
- package/dist/{openai-responses-4gv3ca5d.js → openai-responses-9wmdks0g.js} +38 -14
- package/dist/{opencode-adapter-5bwzvbwj.js → opencode-adapter-h6rkxwrk.js} +2 -2
- package/dist/{openrouter-images-4fsrrzes.js → openrouter-images-dgq98729.js} +10 -3
- package/dist/{otel-impl-1kf6fr2m.js → otel-impl-b43bkq8e.js} +4 -4
- package/dist/{pi-messages-zb8v76wx.js → pi-messages-nzjbbz47.js} +4 -6
- package/dist/{pi-mono-adapter-9sg69kg3.js → pi-mono-adapter-ke789738.js} +2328 -1281
- package/dist/{pricing-refresh-e84tar2s.js → pricing-refresh-2rqf114z.js} +5 -5
- package/dist/{rbac-roles-atytq5ch.js → rbac-roles-5wkhc8xv.js} +5 -5
- package/dist/{rbac-roles-pg0zvnwn.js → rbac-roles-6vtafvzb.js} +4 -4
- package/dist/{seed-pricing-xd6nxq7k.js → seed-pricing-23wrb873.js} +4 -4
- package/dist/{setup-4z389yk0.js → setup-6rny4fk6.js} +2 -2
- package/dist/{worker-m61r0t7m.js → worker-yapewe14.js} +31 -32
- package/openapi.json +2671 -1811
- package/package.json +10 -5
- package/plugin/commands/swarm-chat.md +2 -0
- package/plugin/pi-skills/swarm-chat/SKILL.md +2 -0
- package/src/be/connection-bindings-blob-migration.ts +123 -0
- package/src/be/db-queries/mcp-oauth.ts +409 -247
- package/src/be/db-queries/oauth.ts +813 -131
- package/src/be/db.ts +97 -6
- package/src/be/migrations/117_unified_oauth.sql +624 -0
- package/src/be/migrations/119_agent_avatar.sql +7 -0
- package/src/be/migrations/120_task_title.sql +3 -0
- package/src/be/oauth-credential-bindings.ts +48 -14
- package/src/be/oauth-encryption-backfill.ts +84 -0
- package/src/be/oauth-refresh-sweep.ts +38 -23
- package/src/be/script-connections.ts +1130 -163
- package/src/be/script-credential-broker.ts +37 -27
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-capabilities.ts +62 -0
- package/src/be/seed-pricing.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/vendored-openapi.ts +98 -0
- package/src/commands/runner.ts +69 -6
- package/src/http/agents.ts +23 -5
- package/src/http/all-routes.ts +1 -0
- package/src/http/core.ts +8 -5
- package/src/http/index.ts +18 -5
- package/src/http/mcp-bridge.ts +5 -2
- package/src/http/mcp-oauth.ts +103 -101
- package/src/http/oauth-callback.ts +332 -0
- package/src/http/oauth-generic.ts +14 -54
- package/src/http/poll.ts +5 -1
- package/src/http/script-connections.ts +651 -170
- package/src/http/scripts.ts +4 -2
- package/src/http/sessions.ts +1 -1
- package/src/http/tasks.ts +35 -0
- package/src/http/trackers/jira.ts +22 -34
- package/src/http/trackers/linear.ts +21 -36
- package/src/http/x.ts +6 -2
- package/src/jira/app.ts +12 -6
- package/src/jira/oauth.ts +48 -48
- package/src/linear/app.ts +44 -2
- package/src/linear/oauth.ts +12 -37
- package/src/oauth/app-validation.ts +27 -9
- package/src/oauth/ensure-mcp-token.ts +1 -0
- package/src/oauth/ensure-token.ts +331 -108
- package/src/oauth/identity-capture.ts +103 -0
- package/src/oauth/keepalive.ts +14 -9
- package/src/oauth/presets.ts +242 -0
- package/src/oauth/wrapper.ts +197 -76
- package/src/prompts/base-prompt.ts +48 -4
- package/src/prompts/session-templates.ts +13 -1
- package/src/providers/claude-managed-models.ts +8 -0
- package/src/rbac/legacy-policy.ts +2 -1
- package/src/rbac/permissions.ts +7 -3
- package/src/scripts-runtime/credential-broker/broker.ts +51 -8
- package/src/scripts-runtime/credential-broker/fetch-patch.ts +30 -3
- package/src/scripts-runtime/credential-broker/index.ts +1 -1
- package/src/scripts-runtime/credential-broker/types.ts +55 -8
- package/src/scripts-runtime/egress-secrets.ts +6 -2
- package/src/scripts-runtime/eval-harness.ts +2 -2
- package/src/scripts-runtime/executors/types.ts +2 -1
- package/src/scripts-runtime/loader.ts +3 -0
- package/src/scripts-runtime/types/stdlib.d.ts +11 -0
- package/src/server.ts +230 -126
- package/src/tests/agent-avatar-resolution.test.ts +123 -0
- package/src/tests/agents-list-model-display.test.ts +10 -0
- package/src/tests/base-prompt.test.ts +72 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/connection-embedded-auth.test.ts +709 -0
- package/src/tests/context-window.test.ts +1 -0
- package/src/tests/credential-broker.test.ts +123 -125
- package/src/tests/db-queries-oauth.test.ts +126 -1
- package/src/tests/ensure-token.test.ts +44 -2
- package/src/tests/list-endpoint-slimming.test.ts +45 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +20 -1
- package/src/tests/mcp-oauth-queries.test.ts +154 -8
- package/src/tests/oauth-access-token-tool.test.ts +37 -1
- package/src/tests/oauth-callback-flow.test.ts +534 -0
- package/src/tests/oauth-credential-bindings.test.ts +50 -22
- package/src/tests/oauth-presets.test.ts +275 -0
- package/src/tests/oauth-refresh-failure.test.ts +263 -0
- package/src/tests/oauth-refresh-sweep.test.ts +83 -3
- package/src/tests/oauth-wrapper.test.ts +77 -55
- package/src/tests/prompt-template-session.test.ts +6 -5
- package/src/tests/rbac-charact-misc-tools.test.ts +60 -0
- package/src/tests/rbac-engine.test.ts +2 -1
- package/src/tests/script-connections-http.test.ts +356 -11
- package/src/tests/script-connections.test.ts +102 -0
- package/src/tests/sdk-allowlist.test.ts +4 -1
- package/src/tests/seed-capabilities.test.ts +92 -0
- package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
- package/src/tests/sessions.test.ts +46 -0
- package/src/tests/spec-base-url.test.ts +266 -0
- package/src/tests/status.test.ts +3 -4
- package/src/tests/swarm-config-reserved-keys.test.ts +4 -2
- package/src/tests/task-title-route.test.ts +106 -0
- package/src/tests/tool-annotations.test.ts +6 -3
- package/src/tests/tracker-fold.test.ts +412 -0
- package/src/tests/unified-oauth-migration.test.ts +455 -0
- package/src/tests/vendored-openapi.test.ts +263 -0
- package/src/tools/credential-bindings/tool.ts +232 -77
- package/src/tools/oauth-access-token.ts +55 -6
- package/src/tools/script-connections/tool.ts +73 -53
- package/src/tools/swarm-config/set-config.ts +24 -50
- package/src/tools/update-profile.ts +15 -5
- package/src/tracker/types.ts +12 -0
- package/src/types.ts +29 -0
- package/src/utils/context-window.ts +1 -0
- package/src/workflows/executors/swarm-script.ts +6 -2
- package/vendored-openapi/README.md +22 -0
- package/vendored-openapi/github.json +7759 -0
- package/vendored-openapi/gmail.json +393 -0
- package/vendored-openapi/jira.json +3396 -0
- package/vendored-openapi/linear.json +252 -0
- package/vendored-openapi/manifest.json +128 -0
- package/vendored-openapi/slack.json +2895 -0
- package/dist/cli-0pygq9r1.js +0 -429
- package/dist/cli-8hhms4hp.js +0 -393
- package/dist/cli-z9gzq07x.js +0 -27
- package/src/scripts-runtime/credential-broker/store.ts +0 -72
- package/dist/{otel-g299k41b.js → otel-63jr5v38.js} +1 -1
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import { unlink } from "node:fs/promises";
|
|
12
|
+
import {
|
|
13
|
+
createServer as createHttpServer,
|
|
14
|
+
type IncomingMessage,
|
|
15
|
+
type Server,
|
|
16
|
+
type ServerResponse,
|
|
17
|
+
} from "node:http";
|
|
18
|
+
import { closeDb, createAgent, getDb, initDb } from "../be/db";
|
|
19
|
+
import {
|
|
20
|
+
getAuthorizationById,
|
|
21
|
+
getDefaultAuthorizationIdForProvider,
|
|
22
|
+
getOAuthApp,
|
|
23
|
+
getOAuthAppIdByProvider,
|
|
24
|
+
listAuthorizationsForApp,
|
|
25
|
+
upsertAuthorization,
|
|
26
|
+
upsertOAuthApp,
|
|
27
|
+
} from "../be/db-queries/oauth";
|
|
28
|
+
import { handleOAuthCallback } from "../http/oauth-callback";
|
|
29
|
+
import { handleScriptConnections } from "../http/script-connections";
|
|
30
|
+
import { handleJiraTracker } from "../http/trackers/jira";
|
|
31
|
+
import { handleLinearTracker } from "../http/trackers/linear";
|
|
32
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
33
|
+
import { getJiraMetadata } from "../jira/metadata";
|
|
34
|
+
import { getJiraAuthorizationUrl } from "../jira/oauth";
|
|
35
|
+
import { getLinearClient, resetLinearClient } from "../linear/client";
|
|
36
|
+
import { getLinearAuthorizationUrl } from "../linear/oauth";
|
|
37
|
+
import { forceRefreshAuthorizationOrThrow, onAuthorizationRefreshed } from "../oauth/ensure-token";
|
|
38
|
+
|
|
39
|
+
const TEST_DB_PATH = "./test-tracker-fold.sqlite";
|
|
40
|
+
const LEAD_ID = "bbbb9200-0000-4000-8000-000000000001";
|
|
41
|
+
|
|
42
|
+
// ─── Mock provider (token endpoint, rotation-configurable) ───────────────────
|
|
43
|
+
|
|
44
|
+
let providerServer: ReturnType<typeof Bun.serve>;
|
|
45
|
+
let providerBase = "";
|
|
46
|
+
let includeRefreshToken = true; // toggled to exercise rotation strictness
|
|
47
|
+
let cloudIdResolves = true; // toggled to exercise the Jira post-process-failure path
|
|
48
|
+
|
|
49
|
+
// ─── External-URL fetch interceptor ──────────────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// The tracker post-processing hits hardcoded provider URLs (Atlassian
|
|
52
|
+
// accessible-resources, Linear GraphQL/revoke). Intercept ONLY those; delegate
|
|
53
|
+
// everything else (the localhost mock token endpoint) to the real fetch.
|
|
54
|
+
|
|
55
|
+
const originalFetch = globalThis.fetch;
|
|
56
|
+
|
|
57
|
+
function installFetchInterceptor(): void {
|
|
58
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
59
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
60
|
+
if (url === "https://api.atlassian.com/oauth/token/accessible-resources") {
|
|
61
|
+
if (!cloudIdResolves) {
|
|
62
|
+
return new Response("upstream unavailable", { status: 503 });
|
|
63
|
+
}
|
|
64
|
+
return new Response(
|
|
65
|
+
JSON.stringify([{ id: "cloud-xyz", url: "https://acme.atlassian.net", name: "acme" }]),
|
|
66
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (url === "https://api.linear.app/graphql") {
|
|
70
|
+
return Response.json({
|
|
71
|
+
data: { viewer: { id: "bot-user-1", organization: { id: "org-1" } } },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (url === "https://api.linear.app/oauth/revoke") {
|
|
75
|
+
return new Response(null, { status: 200 });
|
|
76
|
+
}
|
|
77
|
+
return originalFetch(input as RequestInfo, init);
|
|
78
|
+
}) as unknown as typeof fetch;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── App-side dispatcher (real route handlers) ───────────────────────────────
|
|
82
|
+
|
|
83
|
+
function appDispatcher(): Server {
|
|
84
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
85
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
86
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
87
|
+
if (await handleLinearTracker(req, res, pathSegments)) return;
|
|
88
|
+
if (await handleJiraTracker(req, res, pathSegments)) return;
|
|
89
|
+
if (await handleOAuthCallback(req, res, pathSegments, queryParams)) return;
|
|
90
|
+
const agentId = (req.headers["x-agent-id"] as string | undefined) ?? undefined;
|
|
91
|
+
if (await handleScriptConnections(req, res, pathSegments, queryParams, agentId)) return;
|
|
92
|
+
res.writeHead(404);
|
|
93
|
+
res.end("not found");
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let appServer: Server;
|
|
98
|
+
let appBase = "";
|
|
99
|
+
|
|
100
|
+
async function listen(server: Server): Promise<number> {
|
|
101
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
102
|
+
const address = server.address();
|
|
103
|
+
if (!address || typeof address === "string") throw new Error("test server did not bind");
|
|
104
|
+
return address.port;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
108
|
+
function setEnv(key: string, value: string): void {
|
|
109
|
+
savedEnv[key] = process.env[key];
|
|
110
|
+
process.env[key] = value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Seed a tracker OAuth app whose token endpoint is the localhost mock. */
|
|
114
|
+
function seedMockTrackerApp(provider: "linear" | "jira", scopeSeparator: string): string {
|
|
115
|
+
upsertOAuthApp(provider, {
|
|
116
|
+
clientId: `${provider}-client`,
|
|
117
|
+
clientSecret: `${provider}-secret`,
|
|
118
|
+
authorizeUrl: `${providerBase}/authorize`,
|
|
119
|
+
tokenUrl: `${providerBase}/token`,
|
|
120
|
+
redirectUri: `${appBase}/api/trackers/${provider}/callback`,
|
|
121
|
+
scopes: "read,write",
|
|
122
|
+
scopeSeparator,
|
|
123
|
+
requiresRefreshTokenRotation: provider === "jira",
|
|
124
|
+
...(provider === "jira" ? { extraParams: { audience: "api.atlassian.com" } } : {}),
|
|
125
|
+
});
|
|
126
|
+
const appId = getOAuthAppIdByProvider(provider);
|
|
127
|
+
if (!appId) throw new Error("seed failed");
|
|
128
|
+
return appId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stateFromAuthorizeUrl(url: string): string {
|
|
132
|
+
const state = new URL(url).searchParams.get("state");
|
|
133
|
+
if (!state) throw new Error("no state on authorize URL");
|
|
134
|
+
return state;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
beforeAll(async () => {
|
|
138
|
+
process.env.MCP_OAUTH_ALLOW_PRIVATE_HOSTS = "true";
|
|
139
|
+
setEnv("LINEAR_CLIENT_ID", "linear-client");
|
|
140
|
+
setEnv("LINEAR_CLIENT_SECRET", "linear-secret");
|
|
141
|
+
setEnv("JIRA_CLIENT_ID", "jira-client");
|
|
142
|
+
setEnv("JIRA_CLIENT_SECRET", "jira-secret");
|
|
143
|
+
delete process.env.LINEAR_DISABLE;
|
|
144
|
+
delete process.env.JIRA_DISABLE;
|
|
145
|
+
|
|
146
|
+
initDb(TEST_DB_PATH);
|
|
147
|
+
createAgent({ id: LEAD_ID, name: "lead", isLead: true, status: "idle" });
|
|
148
|
+
|
|
149
|
+
providerServer = Bun.serve({
|
|
150
|
+
port: 0,
|
|
151
|
+
async fetch(req) {
|
|
152
|
+
const url = new URL(req.url);
|
|
153
|
+
if (url.pathname === "/token") {
|
|
154
|
+
const body: Record<string, unknown> = {
|
|
155
|
+
access_token: `access-${Date.now()}-${Math.random()}`,
|
|
156
|
+
token_type: "Bearer",
|
|
157
|
+
expires_in: 3600,
|
|
158
|
+
scope: "read write",
|
|
159
|
+
};
|
|
160
|
+
if (includeRefreshToken) body.refresh_token = "rotated-refresh-token";
|
|
161
|
+
return Response.json(body);
|
|
162
|
+
}
|
|
163
|
+
return new Response("not found", { status: 404 });
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
providerBase = `http://localhost:${providerServer.port}`;
|
|
167
|
+
|
|
168
|
+
appServer = appDispatcher();
|
|
169
|
+
appBase = `http://localhost:${await listen(appServer)}`;
|
|
170
|
+
|
|
171
|
+
installFetchInterceptor();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterAll(async () => {
|
|
175
|
+
globalThis.fetch = originalFetch;
|
|
176
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
177
|
+
if (value === undefined) delete process.env[key];
|
|
178
|
+
else process.env[key] = value;
|
|
179
|
+
}
|
|
180
|
+
appServer.close();
|
|
181
|
+
await providerServer.stop(true);
|
|
182
|
+
closeDb();
|
|
183
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
184
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
185
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
includeRefreshToken = true;
|
|
190
|
+
cloudIdResolves = true;
|
|
191
|
+
resetLinearClient();
|
|
192
|
+
getDb().query("DELETE FROM oauth_authorizations").run();
|
|
193
|
+
getDb().query("DELETE FROM oauth_pending").run();
|
|
194
|
+
getDb().query("DELETE FROM oauth_apps").run();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(async () => {
|
|
198
|
+
const { resetLinear } = await import("../linear/app");
|
|
199
|
+
const { resetJira } = await import("../jira/app");
|
|
200
|
+
resetLinear();
|
|
201
|
+
resetJira();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("step-8: tracker fold onto the unified OAuth core", () => {
|
|
205
|
+
test("initLinear seeds column-correct linear app (comma separator + keepAlive/actor metadata)", async () => {
|
|
206
|
+
const { initLinear } = await import("../linear/app");
|
|
207
|
+
expect(initLinear()).toBe(true);
|
|
208
|
+
|
|
209
|
+
const app = getOAuthApp("linear");
|
|
210
|
+
expect(app).toBeTruthy();
|
|
211
|
+
expect(app?.scopeSeparator).toBe(",");
|
|
212
|
+
expect(Boolean(app?.requiresRefreshTokenRotation)).toBe(false);
|
|
213
|
+
const metadata = JSON.parse(app?.metadata || "{}");
|
|
214
|
+
expect(metadata.actor).toBe("app");
|
|
215
|
+
// keepAlive opts the (non-rotating) linear row into the keepalive job on a
|
|
216
|
+
// fresh DB where migration 117's consolidated backfill matched no rows.
|
|
217
|
+
expect(metadata.keepAlive === true || metadata.keepAlive === 1).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("initLinear boot seeding preserves foreign metadata keys (idempotent, no clobber)", async () => {
|
|
221
|
+
// A pre-existing row carrying an operator/runtime-added metadata key — now
|
|
222
|
+
// possible since the carve-out removal made the linear row user-manageable.
|
|
223
|
+
upsertOAuthApp("linear", {
|
|
224
|
+
clientId: "pre-existing",
|
|
225
|
+
clientSecret: "pre-existing-secret",
|
|
226
|
+
authorizeUrl: "https://linear.app/oauth/authorize",
|
|
227
|
+
tokenUrl: "https://api.linear.app/oauth/token",
|
|
228
|
+
redirectUri: `${appBase}/api/trackers/linear/callback`,
|
|
229
|
+
scopes: "read",
|
|
230
|
+
metadata: JSON.stringify({ customKey: "keep-me", actor: "stale" }),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const { initLinear } = await import("../linear/app");
|
|
234
|
+
expect(initLinear()).toBe(true);
|
|
235
|
+
|
|
236
|
+
const metadata = JSON.parse(getOAuthApp("linear")?.metadata || "{}");
|
|
237
|
+
// Foreign key survives the re-boot seed…
|
|
238
|
+
expect(metadata.customKey).toBe("keep-me");
|
|
239
|
+
// …and the seeded keys win + are present.
|
|
240
|
+
expect(metadata.actor).toBe("app");
|
|
241
|
+
expect(metadata.keepAlive === true || metadata.keepAlive === 1).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("initJira seeds column-correct jira app (space separator, rotation flag, audience param)", async () => {
|
|
245
|
+
const webhookLifecycle = await import("../jira/webhook-lifecycle");
|
|
246
|
+
// Neutralize the boot webhook-keepalive timer (untracked 10s setTimeout).
|
|
247
|
+
const startSpy = spyOn(webhookLifecycle, "startJiraWebhookKeepalive").mockImplementation(
|
|
248
|
+
() => {},
|
|
249
|
+
);
|
|
250
|
+
try {
|
|
251
|
+
const { initJira } = await import("../jira/app");
|
|
252
|
+
expect(initJira()).toBe(true);
|
|
253
|
+
|
|
254
|
+
const app = getOAuthApp("jira");
|
|
255
|
+
expect(app?.scopeSeparator).toBe(" ");
|
|
256
|
+
expect(Boolean(app?.requiresRefreshTokenRotation)).toBe(true);
|
|
257
|
+
expect(app?.extraParamsJson).toContain("api.atlassian.com");
|
|
258
|
+
} finally {
|
|
259
|
+
startSpy.mockRestore();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("linear authorize→callback wrapper lands tokens on the default authorization", async () => {
|
|
264
|
+
const appId = seedMockTrackerApp("linear", ",");
|
|
265
|
+
|
|
266
|
+
const authorizeUrl = await getLinearAuthorizationUrl();
|
|
267
|
+
expect(authorizeUrl).toBeTruthy();
|
|
268
|
+
// Pending row is keyed flow='tracker'.
|
|
269
|
+
const state = stateFromAuthorizeUrl(authorizeUrl as string);
|
|
270
|
+
const pending = getDb()
|
|
271
|
+
.query("SELECT flow, label FROM oauth_pending WHERE state = ?")
|
|
272
|
+
.get(state) as { flow: string; label: string } | null;
|
|
273
|
+
expect(pending?.flow).toBe("tracker");
|
|
274
|
+
expect(pending?.label).toBe("default");
|
|
275
|
+
|
|
276
|
+
const res = await fetch(
|
|
277
|
+
`${appBase}/api/trackers/linear/callback?code=auth-code&state=${state}`,
|
|
278
|
+
{
|
|
279
|
+
redirect: "manual",
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
expect(res.status).toBe(200);
|
|
283
|
+
|
|
284
|
+
const authorizations = listAuthorizationsForApp(appId);
|
|
285
|
+
expect(authorizations).toHaveLength(1);
|
|
286
|
+
expect(authorizations[0]?.label).toBe("default");
|
|
287
|
+
expect(authorizations[0]?.status).toBe("active");
|
|
288
|
+
expect(authorizations[0]?.accessToken).toContain("access-");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("jira authorize→callback wrapper lands tokens AND resolves cloudId into metadata", async () => {
|
|
292
|
+
const appId = seedMockTrackerApp("jira", " ");
|
|
293
|
+
|
|
294
|
+
const authorizeUrl = await getJiraAuthorizationUrl();
|
|
295
|
+
const state = stateFromAuthorizeUrl(authorizeUrl as string);
|
|
296
|
+
|
|
297
|
+
const res = await fetch(`${appBase}/api/trackers/jira/callback?code=auth-code&state=${state}`, {
|
|
298
|
+
redirect: "manual",
|
|
299
|
+
});
|
|
300
|
+
expect(res.status).toBe(200);
|
|
301
|
+
|
|
302
|
+
const authorizations = listAuthorizationsForApp(appId);
|
|
303
|
+
expect(authorizations).toHaveLength(1);
|
|
304
|
+
expect(authorizations[0]?.status).toBe("active");
|
|
305
|
+
|
|
306
|
+
// cloudId post-processing (deferred from step-4) now runs in the unified
|
|
307
|
+
// flow='tracker' branch.
|
|
308
|
+
const meta = getJiraMetadata();
|
|
309
|
+
expect(meta.cloudId).toBe("cloud-xyz");
|
|
310
|
+
expect(meta.siteUrl).toBe("https://acme.atlassian.net");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("jira callback cloudId-resolution failure surfaces an error but still lands the (half-connected) authorization", async () => {
|
|
314
|
+
const appId = seedMockTrackerApp("jira", " ");
|
|
315
|
+
cloudIdResolves = false; // accessible-resources returns 503
|
|
316
|
+
|
|
317
|
+
const authorizeUrl = await getJiraAuthorizationUrl();
|
|
318
|
+
const state = stateFromAuthorizeUrl(authorizeUrl as string);
|
|
319
|
+
|
|
320
|
+
const res = await fetch(`${appBase}/api/trackers/jira/callback?code=auth-code&state=${state}`, {
|
|
321
|
+
redirect: "manual",
|
|
322
|
+
});
|
|
323
|
+
// The cloudId step is required — its failure surfaces (502 from the unified
|
|
324
|
+
// callback's exchange try/catch), matching the legacy handleJiraCallback.
|
|
325
|
+
expect(res.status).toBe(502);
|
|
326
|
+
|
|
327
|
+
// Tokens were already persisted before cloudId resolution ran, so the
|
|
328
|
+
// authorization is "half-connected": active, but no cloudId. Re-running the
|
|
329
|
+
// OAuth dance (once the provider recovers) resolves it.
|
|
330
|
+
const authorizations = listAuthorizationsForApp(appId);
|
|
331
|
+
expect(authorizations).toHaveLength(1);
|
|
332
|
+
expect(authorizations[0]?.status).toBe("active");
|
|
333
|
+
expect(getJiraMetadata().cloudId).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("jira refresh enforces rotation strictness — missing rotated refresh_token throws + marks refresh-failed", async () => {
|
|
337
|
+
seedMockTrackerApp("jira", " ");
|
|
338
|
+
const appId = getOAuthAppIdByProvider("jira") as string;
|
|
339
|
+
const authorization = upsertAuthorization({
|
|
340
|
+
appId,
|
|
341
|
+
label: "default",
|
|
342
|
+
accessToken: "stale-access",
|
|
343
|
+
refreshToken: "old-refresh",
|
|
344
|
+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
345
|
+
status: "active",
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
includeRefreshToken = false; // provider omits the rotated refresh token
|
|
349
|
+
|
|
350
|
+
await expect(forceRefreshAuthorizationOrThrow(authorization.id)).rejects.toThrow();
|
|
351
|
+
|
|
352
|
+
const after = getAuthorizationById(authorization.id);
|
|
353
|
+
expect(after?.status).toBe("refresh-failed");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("carve-out removed: upserting a 'linear' app via the generic /api/oauth-apps surface succeeds", async () => {
|
|
357
|
+
const res = await fetch(`${appBase}/api/oauth-apps`, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: { "content-type": "application/json", "x-agent-id": LEAD_ID },
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
provider: "linear",
|
|
362
|
+
clientId: "generic-linear-client",
|
|
363
|
+
clientSecret: "generic-linear-secret",
|
|
364
|
+
authorizeUrl: "https://linear.app/oauth/authorize",
|
|
365
|
+
tokenUrl: "https://api.linear.app/oauth/token",
|
|
366
|
+
scopes: ["read", "write"],
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
369
|
+
// Formerly rejected with a 400 "reserved for dedicated tracker OAuth flows".
|
|
370
|
+
expect(res.status).toBe(200);
|
|
371
|
+
const body = (await res.text()).toLowerCase();
|
|
372
|
+
expect(body).not.toContain("reserved");
|
|
373
|
+
expect(getOAuthAppIdByProvider("linear")).toBeTruthy();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("sweep-driven refresh invalidates the cached Linear SDK client + notifies listeners", async () => {
|
|
377
|
+
// initLinear() registers the resetLinearClient refresh listener + seeds the
|
|
378
|
+
// real linear app; override the token endpoint to the localhost mock.
|
|
379
|
+
const { initLinear } = await import("../linear/app");
|
|
380
|
+
expect(initLinear()).toBe(true);
|
|
381
|
+
const appId = seedMockTrackerApp("linear", ",");
|
|
382
|
+
|
|
383
|
+
const authorization = upsertAuthorization({
|
|
384
|
+
appId,
|
|
385
|
+
label: "default",
|
|
386
|
+
accessToken: "initial-access",
|
|
387
|
+
refreshToken: "linear-refresh",
|
|
388
|
+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
389
|
+
status: "active",
|
|
390
|
+
});
|
|
391
|
+
expect(getDefaultAuthorizationIdForProvider("linear")).toBe(authorization.id);
|
|
392
|
+
|
|
393
|
+
// Prime the cached client.
|
|
394
|
+
const before = getLinearClient();
|
|
395
|
+
expect(before).toBeTruthy();
|
|
396
|
+
|
|
397
|
+
// Independently observe the notification mechanism.
|
|
398
|
+
const events: string[] = [];
|
|
399
|
+
const unsubscribe = onAuthorizationRefreshed((e) => events.push(e.provider));
|
|
400
|
+
|
|
401
|
+
// Simulate a background/sweep refresh.
|
|
402
|
+
await forceRefreshAuthorizationOrThrow(authorization.id);
|
|
403
|
+
|
|
404
|
+
unsubscribe();
|
|
405
|
+
|
|
406
|
+
expect(events).toContain("linear");
|
|
407
|
+
// The cached client was reset by initLinear's listener, so a fresh instance
|
|
408
|
+
// (carrying the rotated token) is built on the next read.
|
|
409
|
+
const after = getLinearClient();
|
|
410
|
+
expect(after).not.toBe(before);
|
|
411
|
+
});
|
|
412
|
+
});
|