chati-dev 4.5.28 → 4.5.29
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/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +117 -12
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstatSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { installationAuthorityStateDirectory } from './installation-authority.js';
|
|
5
|
+
import { persistImmutableInstallationFile, readSafeInstallationStorageFile } from './catalog-client.js';
|
|
6
|
+
import { assertClickUpOAuthRedirect, CLICKUP_MCP_RESOURCE } from './clickup-oauth.js';
|
|
7
|
+
|
|
8
|
+
const MAX_BYTES = 64 * 1024;
|
|
9
|
+
const ID = /^clickup-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
10
|
+
function fail(code) { throw Object.assign(new Error('ClickUp private connection storage rejected the operation'), { code }); }
|
|
11
|
+
function hasControlOrSpace(value) {
|
|
12
|
+
return [...value].some((character) => character.codePointAt(0) <= 32 || character.codePointAt(0) === 127);
|
|
13
|
+
}
|
|
14
|
+
function exact(value, keys) {
|
|
15
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
+
&& Object.getOwnPropertySymbols(value).length === 0
|
|
17
|
+
&& Object.values(Object.getOwnPropertyDescriptors(value)).every((descriptor) => Object.hasOwn(descriptor, 'value'))
|
|
18
|
+
&& Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
19
|
+
}
|
|
20
|
+
function validate(value) {
|
|
21
|
+
if (!exact(value, ['schema_version', 'connection_id', 'client', 'credentials', 'destination', 'created_at'])
|
|
22
|
+
|| value.schema_version !== 1 || !ID.test(value.connection_id || '') || !Number.isFinite(Date.parse(value.created_at))) fail('CLICKUP_CONNECTION_INVALID');
|
|
23
|
+
const { client, credentials, destination } = value;
|
|
24
|
+
if (!exact(client, ['client_id', 'redirect_uri', 'issuer', 'resource'])
|
|
25
|
+
|| typeof client.client_id !== 'string' || !client.client_id || client.client_id.length > 1024 || hasControlOrSpace(client.client_id)
|
|
26
|
+
|| client.issuer !== 'https://mcp.clickup.com' || client.resource !== CLICKUP_MCP_RESOURCE) fail('CLICKUP_CONNECTION_INVALID');
|
|
27
|
+
assertClickUpOAuthRedirect(client.redirect_uri);
|
|
28
|
+
if (!exact(destination, ['workspace_id', 'list_id'])
|
|
29
|
+
|| !['workspace_id', 'list_id'].every((key) => typeof destination[key] === 'string' && /^[0-9]{1,32}$/.test(destination[key]))) fail('CLICKUP_CONNECTION_INVALID');
|
|
30
|
+
const fields = ['token_type', 'access_token', 'scope', 'expires_at'];
|
|
31
|
+
if (Object.hasOwn(credentials || {}, 'refresh_token')) fields.push('refresh_token');
|
|
32
|
+
if (!exact(credentials, fields) || credentials.token_type !== 'Bearer' || credentials.scope !== 'read write'
|
|
33
|
+
|| typeof credentials.access_token !== 'string' || credentials.access_token.length > 16384 || !/^[A-Za-z0-9._~+/-]+=*$/.test(credentials.access_token)
|
|
34
|
+
|| !Number.isFinite(Date.parse(credentials.expires_at))
|
|
35
|
+
|| (credentials.refresh_token !== undefined && (typeof credentials.refresh_token !== 'string' || !credentials.refresh_token
|
|
36
|
+
|| credentials.refresh_token.length > 16384 || hasControlOrSpace(credentials.refresh_token)))) fail('CLICKUP_CONNECTION_INVALID');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function directory(authorityDirectory) {
|
|
40
|
+
// Same OS-account-derived private root already masked from Chati children.
|
|
41
|
+
// No project path, caller HOME, provider token store, or Keychain is used.
|
|
42
|
+
const root = installationAuthorityStateDirectory(authorityDirectory);
|
|
43
|
+
const target = join(root, 'clickup-connections');
|
|
44
|
+
for (const path of [dirname(root), root, target]) {
|
|
45
|
+
let stat;
|
|
46
|
+
try { stat = lstatSync(path); } catch (error) { if (error?.code === 'ENOENT') continue; fail('CLICKUP_CONNECTION_STORAGE_UNSAFE'); }
|
|
47
|
+
const forbidden = path === dirname(root) ? 0o022 : 0o077;
|
|
48
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & forbidden) !== 0
|
|
49
|
+
|| (typeof process.geteuid === 'function' && stat.uid !== process.geteuid())) fail('CLICKUP_CONNECTION_STORAGE_UNSAFE');
|
|
50
|
+
}
|
|
51
|
+
return target;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Stores only an explicitly obtained connection. No refresh, global provider
|
|
55
|
+
* reconfiguration, external request, receipt signing or destination expansion.
|
|
56
|
+
* The returned summary is safe to display; the persisted file contains secrets.
|
|
57
|
+
*/
|
|
58
|
+
export function saveClickUpConnection({ client, credentials, destination, authorityDirectory, clock = Date.now } = {}) {
|
|
59
|
+
const now = clock();
|
|
60
|
+
if (!Number.isSafeInteger(now) || now < 0 || now > 8640000000000000) fail('CLICKUP_CONNECTION_INVALID');
|
|
61
|
+
const connection = { schema_version: 1, connection_id: `clickup-${randomUUID()}`, client, credentials, destination, created_at: new Date(now).toISOString() };
|
|
62
|
+
validate(connection);
|
|
63
|
+
if (Date.parse(credentials.expires_at) <= now) fail('CLICKUP_CONNECTION_EXPIRED');
|
|
64
|
+
const path = join(directory(authorityDirectory), `${connection.connection_id}.json`);
|
|
65
|
+
try {
|
|
66
|
+
persistImmutableInstallationFile(path, `${JSON.stringify(connection)}\n`, { maxBytes: MAX_BYTES, label: 'ClickUp private connection' });
|
|
67
|
+
// Check the actual writer's file and folder modes, not only requested modes.
|
|
68
|
+
loadClickUpConnection({ connectionId: connection.connection_id, authorityDirectory, clock });
|
|
69
|
+
} catch { fail('CLICKUP_CONNECTION_STORAGE_UNSAFE'); }
|
|
70
|
+
return Object.freeze({ connection_id: connection.connection_id, destination: Object.freeze({ ...destination }),
|
|
71
|
+
expires_at: credentials.expires_at, credential_storage: 'host_private_authority', refreshed: false });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Internal credential reader. Never serialize the result to a CLI transcript.
|
|
75
|
+
* A stored destination is a required bridge input, not upstream list-scoped OAuth.
|
|
76
|
+
*/
|
|
77
|
+
export function loadClickUpConnection({ connectionId, authorityDirectory, clock = Date.now } = {}) {
|
|
78
|
+
if (typeof connectionId !== 'string' || !ID.test(connectionId)) fail('CLICKUP_CONNECTION_INVALID');
|
|
79
|
+
let source;
|
|
80
|
+
try {
|
|
81
|
+
source = readSafeInstallationStorageFile(join(directory(authorityDirectory), `${connectionId}.json`),
|
|
82
|
+
{ maxBytes: MAX_BYTES, label: 'ClickUp private connection' });
|
|
83
|
+
} catch { fail('CLICKUP_CONNECTION_STORAGE_UNSAFE'); }
|
|
84
|
+
if ((source.stat.mode & 0o077n) !== 0n || (typeof process.geteuid === 'function' && source.stat.uid !== BigInt(process.geteuid()))) fail('CLICKUP_CONNECTION_STORAGE_UNSAFE');
|
|
85
|
+
let value;
|
|
86
|
+
try { value = JSON.parse(source.content); } catch { fail('CLICKUP_CONNECTION_INVALID'); }
|
|
87
|
+
validate(value);
|
|
88
|
+
if (value.connection_id !== connectionId) fail('CLICKUP_CONNECTION_INVALID');
|
|
89
|
+
const now = clock();
|
|
90
|
+
if (!Number.isSafeInteger(now) || now < 0 || Date.parse(value.credentials.expires_at) <= now) fail('CLICKUP_CONNECTION_EXPIRED');
|
|
91
|
+
return Object.freeze({ ...value, client: Object.freeze(value.client), credentials: Object.freeze(value.credentials), destination: Object.freeze(value.destination) });
|
|
92
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { loadClickUpConnection } from './clickup-connection-storage.js';
|
|
2
|
+
import { CLICKUP_MCP_RESOURCE } from './clickup-oauth.js';
|
|
3
|
+
|
|
4
|
+
const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
|
5
|
+
const MAX_BYTES = 1024 * 1024;
|
|
6
|
+
const TOOLS = new Set(['clickup_get_list', 'clickup_get_task', 'clickup_create_task', 'clickup_update_task', 'clickup_filter_tasks']);
|
|
7
|
+
const diagnostics = new WeakMap();
|
|
8
|
+
export function clickUpMcpFailureDiagnostic(error) {
|
|
9
|
+
return diagnostics.get(error) || Object.freeze({ code: 'CLICKUP_MCP_FAILURE_UNKNOWN' });
|
|
10
|
+
}
|
|
11
|
+
function fail(code, httpStatus) {
|
|
12
|
+
const error = Object.assign(new Error('ClickUp MCP operation failed; no automatic retry was made'), { code });
|
|
13
|
+
diagnostics.set(error, Object.freeze({ code, ...(Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599
|
|
14
|
+
? { http_status: httpStatus } : {}) }));
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
18
|
+
|
|
19
|
+
/** Private, finite transport for the user's own explicit OAuth connection.
|
|
20
|
+
* This is not a certified observer or a delivery command. Its caller must bind
|
|
21
|
+
* task ownership, canonical payload and outbox admission BEFORE any mutation.
|
|
22
|
+
* No token getter, alternate endpoint, arbitrary tool or automatic retry.
|
|
23
|
+
*/
|
|
24
|
+
export async function openClickUpMcpClient({ connectionId, authorityDirectory, clock = Date.now, fetchImpl = globalThis.fetch } = {}) {
|
|
25
|
+
const connection = loadClickUpConnection({ connectionId, authorityDirectory, clock });
|
|
26
|
+
const destination = connection.destination;
|
|
27
|
+
let session;
|
|
28
|
+
let protocol;
|
|
29
|
+
let nextId = 0;
|
|
30
|
+
let closed = false;
|
|
31
|
+
const headers = () => ({ Authorization: `Bearer ${connection.credentials.access_token}`,
|
|
32
|
+
...(session ? { 'MCP-Session-Id': session } : {}), ...(protocol ? { 'MCP-Protocol-Version': protocol } : {}) });
|
|
33
|
+
|
|
34
|
+
async function rpc(method, params, notification = false) {
|
|
35
|
+
const now = clock();
|
|
36
|
+
if (closed || !Number.isSafeInteger(now) || now < 0 || now >= Date.parse(connection.credentials.expires_at)) fail('CLICKUP_MCP_CONNECTION_UNAVAILABLE');
|
|
37
|
+
const id = notification ? undefined : ++nextId;
|
|
38
|
+
const body = JSON.stringify({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }), ...(notification ? {} : { id }) });
|
|
39
|
+
if (Buffer.byteLength(body) > MAX_BYTES) fail('CLICKUP_MCP_REQUEST_TOO_LARGE');
|
|
40
|
+
let response;
|
|
41
|
+
let reader;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetchImpl(CLICKUP_MCP_RESOURCE, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
44
|
+
headers: { ...headers(), 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, body });
|
|
45
|
+
if (!response.ok) fail('CLICKUP_MCP_HTTP_REJECTED', response.status);
|
|
46
|
+
if (method === 'initialize') {
|
|
47
|
+
const value = response.headers.get('mcp-session-id');
|
|
48
|
+
if (value && (value.length > 4096 || !/^[\x21-\x7e]+$/.test(value))) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
49
|
+
session = value;
|
|
50
|
+
}
|
|
51
|
+
if (notification) {
|
|
52
|
+
if (response.status !== 202) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const type = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
56
|
+
if (!['application/json', 'text/event-stream'].includes(type)
|
|
57
|
+
|| Number(response.headers.get('content-length')) > MAX_BYTES) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
58
|
+
reader = response.body?.getReader();
|
|
59
|
+
if (!reader) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
60
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
61
|
+
let buffer = ''; let bytes = 0;
|
|
62
|
+
const result = (message) => {
|
|
63
|
+
if (!object(message) || message.jsonrpc !== '2.0' || message.id !== id
|
|
64
|
+
|| Object.hasOwn(message, 'error') || !Object.hasOwn(message, 'result')) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
65
|
+
return message.result;
|
|
66
|
+
};
|
|
67
|
+
for (;;) {
|
|
68
|
+
const chunk = await reader.read();
|
|
69
|
+
if (chunk.done) break;
|
|
70
|
+
bytes += chunk.value.byteLength;
|
|
71
|
+
if (bytes > MAX_BYTES) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
72
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
73
|
+
if (type === 'text/event-stream') {
|
|
74
|
+
// Keep a trailing CR until the next chunk; CRLF may span reads.
|
|
75
|
+
buffer = buffer.replace(/\r\n/g, '\n');
|
|
76
|
+
let end;
|
|
77
|
+
while ((end = buffer.indexOf('\n\n')) !== -1) {
|
|
78
|
+
const event = buffer.slice(0, end); buffer = buffer.slice(end + 2);
|
|
79
|
+
const data = event.split('\n').filter(line => line.startsWith('data:'))
|
|
80
|
+
.map(line => line.slice(5).replace(/^ /, '')).join('\n');
|
|
81
|
+
if (!data) continue;
|
|
82
|
+
const message = JSON.parse(data);
|
|
83
|
+
if (message?.id === id) return result(message);
|
|
84
|
+
// Notifications are harmless; never execute a server request.
|
|
85
|
+
if (!object(message) || message.jsonrpc !== '2.0' || Object.hasOwn(message, 'id')) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
buffer += decoder.decode();
|
|
90
|
+
if (type === 'text/event-stream') fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
91
|
+
return result(JSON.parse(buffer));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (diagnostics.has(error)) throw error;
|
|
94
|
+
return fail('CLICKUP_MCP_REQUEST_FAILED');
|
|
95
|
+
} finally {
|
|
96
|
+
try { if (reader) await reader.cancel(); else await response?.body?.cancel(); } catch { /* never expose remote failures */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function close() {
|
|
101
|
+
if (closed) return;
|
|
102
|
+
closed = true;
|
|
103
|
+
if (!session) return;
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetchImpl(CLICKUP_MCP_RESOURCE, { method: 'DELETE', redirect: 'error',
|
|
106
|
+
signal: AbortSignal.timeout(5000), headers: headers() });
|
|
107
|
+
await response.body?.cancel();
|
|
108
|
+
} catch { /* local handle is closed even if the finite remote session expires later */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const initialized = await rpc('initialize', { protocolVersion: PROTOCOLS[0], capabilities: {}, clientInfo: { name: 'chati-dev', version: '4.5.28' } });
|
|
113
|
+
if (!object(initialized) || !PROTOCOLS.includes(initialized.protocolVersion) || !object(initialized.capabilities?.tools)) fail('CLICKUP_MCP_NEGOTIATION_FAILED');
|
|
114
|
+
protocol = initialized.protocolVersion;
|
|
115
|
+
await rpc('notifications/initialized', undefined, true);
|
|
116
|
+
} catch (error) { await close(); throw error; }
|
|
117
|
+
|
|
118
|
+
return Object.freeze({
|
|
119
|
+
destination, protocol,
|
|
120
|
+
async listTools() {
|
|
121
|
+
const tools = []; const cursors = new Set();
|
|
122
|
+
let cursor;
|
|
123
|
+
do {
|
|
124
|
+
const page = await rpc('tools/list', cursor === undefined ? {} : { cursor });
|
|
125
|
+
if (!object(page) || !Array.isArray(page.tools) || page.tools.length > 300) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
126
|
+
tools.push(...page.tools);
|
|
127
|
+
if (tools.length > 300 || tools.some(tool => !object(tool) || typeof tool.name !== 'string' || !object(tool.inputSchema))
|
|
128
|
+
|| new Set(tools.map(tool => tool.name)).size !== tools.length) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
129
|
+
cursor = page.nextCursor;
|
|
130
|
+
if (cursor !== undefined) {
|
|
131
|
+
if (typeof cursor !== 'string' || !cursor || cursor.length > 4096 || cursors.has(cursor) || cursors.size >= 4) fail('CLICKUP_MCP_RESPONSE_INVALID');
|
|
132
|
+
cursors.add(cursor);
|
|
133
|
+
}
|
|
134
|
+
} while (cursor !== undefined);
|
|
135
|
+
return tools;
|
|
136
|
+
},
|
|
137
|
+
async callTool(name, args) {
|
|
138
|
+
if (!TOOLS.has(name) || !object(args)) fail('CLICKUP_MCP_TOOL_NOT_ALLOWED');
|
|
139
|
+
// Copy before awaits and override no scope silently. Foreign fields remain
|
|
140
|
+
// subject to the discovered schema at the certified business-layer caller.
|
|
141
|
+
let input;
|
|
142
|
+
try { input = JSON.parse(JSON.stringify(args)); } catch { fail('CLICKUP_MCP_TOOL_NOT_ALLOWED'); }
|
|
143
|
+
if (!object(input)) fail('CLICKUP_MCP_TOOL_NOT_ALLOWED');
|
|
144
|
+
if (input.workspace_id !== undefined && input.workspace_id !== destination.workspace_id) fail('CLICKUP_MCP_DESTINATION_MISMATCH');
|
|
145
|
+
if (['clickup_get_list', 'clickup_create_task'].includes(name) && input.list_id !== destination.list_id) fail('CLICKUP_MCP_DESTINATION_MISMATCH');
|
|
146
|
+
if (input.list_name !== undefined) fail('CLICKUP_MCP_DESTINATION_MISMATCH');
|
|
147
|
+
if (name === 'clickup_filter_tasks' && (JSON.stringify(input.list_ids) !== JSON.stringify([destination.list_id])
|
|
148
|
+
|| input.folder_ids !== undefined || input.space_ids !== undefined)) fail('CLICKUP_MCP_DESTINATION_MISMATCH');
|
|
149
|
+
if (['clickup_get_task', 'clickup_update_task'].includes(name) && (typeof input.task_id !== 'string'
|
|
150
|
+
|| !/^[A-Za-z0-9_-]{1,128}$/.test(input.task_id))) fail('CLICKUP_MCP_TOOL_NOT_ALLOWED');
|
|
151
|
+
return rpc('tools/call', { name, arguments: { ...input, workspace_id: destination.workspace_id } });
|
|
152
|
+
},
|
|
153
|
+
close,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { CLICKUP_OAUTH_REQUEST_TIMEOUT_MS, inheritClickUpOAuthFailureDiagnostic } from './clickup-oauth.js';
|
|
3
|
+
|
|
4
|
+
const MAX_URL_LENGTH = 16 * 1024;
|
|
5
|
+
const INVALID_CALLBACK_CODES = new Set(['CLICKUP_OAUTH_CALLBACK_INVALID']);
|
|
6
|
+
|
|
7
|
+
function failure(code, message) { return Object.assign(new Error(message), { code }); }
|
|
8
|
+
|
|
9
|
+
/** Finite local callback, not a public service. It accepts one authorization.
|
|
10
|
+
* The browser gets only fixed text, never the query, code, tokens or raw errors.
|
|
11
|
+
* On a remote host, any loopback forwarding must be authorized separately.
|
|
12
|
+
*/
|
|
13
|
+
export async function listenForClickUpOAuth({ port = 0, timeoutMs = 10 * 60_000 } = {}) {
|
|
14
|
+
if (!Number.isInteger(port) || (port !== 0 && (port < 1024 || port > 65535))
|
|
15
|
+
|| !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 60_000) {
|
|
16
|
+
throw failure('CLICKUP_OAUTH_LISTENER_INVALID', 'ClickUp callback requires a finite loopback listener');
|
|
17
|
+
}
|
|
18
|
+
let authorization;
|
|
19
|
+
let settled = false;
|
|
20
|
+
let resolveResult;
|
|
21
|
+
let rejectResult;
|
|
22
|
+
let timer;
|
|
23
|
+
const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
|
|
24
|
+
// A user can decline before the caller starts awaiting the promise.
|
|
25
|
+
result.catch(() => {});
|
|
26
|
+
const server = createServer({ maxHeaderSize: MAX_URL_LENGTH, requestTimeout: 5000, headersTimeout: 5000 });
|
|
27
|
+
server.maxConnections = 16;
|
|
28
|
+
server.maxRequestsPerSocket = 1;
|
|
29
|
+
server.setTimeout(5000, (socket) => socket.destroy());
|
|
30
|
+
const finish = (error, credentials) => {
|
|
31
|
+
if (settled) return;
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
timer = undefined;
|
|
35
|
+
server.close();
|
|
36
|
+
if (error) rejectResult(error);
|
|
37
|
+
else resolveResult(credentials);
|
|
38
|
+
};
|
|
39
|
+
server.on('error', () => finish(failure('CLICKUP_OAUTH_LISTENER_FAILED', 'ClickUp callback listener failed')));
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const onError = () => reject(failure('CLICKUP_OAUTH_LISTENER_UNAVAILABLE', 'ClickUp loopback callback port is unavailable'));
|
|
42
|
+
server.once('error', onError);
|
|
43
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => { server.off('error', onError); resolve(); });
|
|
44
|
+
});
|
|
45
|
+
const authority = `127.0.0.1:${server.address().port}`;
|
|
46
|
+
const redirectUri = `http://${authority}/oauth/callback`;
|
|
47
|
+
server.on('request', async (request, response) => {
|
|
48
|
+
const reply = (status, message) => {
|
|
49
|
+
response.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store',
|
|
50
|
+
'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
|
51
|
+
'X-Content-Type-Options': 'nosniff', Connection: 'close' });
|
|
52
|
+
response.end(message);
|
|
53
|
+
};
|
|
54
|
+
if (settled || !authorization || request.method !== 'GET' || request.headers.host !== authority
|
|
55
|
+
|| typeof request.url !== 'string' || request.url.length > MAX_URL_LENGTH || !request.url.startsWith('/oauth/callback?')
|
|
56
|
+
|| (request.headers.origin !== undefined && request.headers.origin !== 'https://mcp.clickup.com')) {
|
|
57
|
+
reply(400, 'Invalid CHATI.DEV authorization callback.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Headers are complete. Do not disconnect the browser at the 5s idle limit
|
|
61
|
+
// while its valid token exchange is still within the upstream 15s deadline.
|
|
62
|
+
// This socket remains bounded, as does the whole authorization listener.
|
|
63
|
+
response.setTimeout(CLICKUP_OAUTH_REQUEST_TIMEOUT_MS + 5000, () => request.socket.destroy());
|
|
64
|
+
try {
|
|
65
|
+
const credentials = await authorization.exchange(`http://${authority}${request.url}`);
|
|
66
|
+
reply(200, 'ClickUp authorized for this CHATI.DEV connection. You may close this tab.');
|
|
67
|
+
finish(null, credentials);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (INVALID_CALLBACK_CODES.has(error?.code) || error?.code === 'CLICKUP_OAUTH_ALREADY_USED') {
|
|
70
|
+
// A duplicate callback must not cancel the legitimate in-flight exchange.
|
|
71
|
+
reply(400, 'Invalid or already used CHATI.DEV authorization callback.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
reply(400, 'ClickUp connection was not completed. Return to CHATI.DEV for its status.');
|
|
75
|
+
finish(inheritClickUpOAuthFailureDiagnostic(
|
|
76
|
+
failure('CLICKUP_OAUTH_CONNECTION_FAILED', 'ClickUp connection was denied, expired or its token exchange failed'), error));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
finish(failure('CLICKUP_OAUTH_LISTENER_EXPIRED', 'ClickUp authorization callback expired'));
|
|
81
|
+
server.closeAllConnections();
|
|
82
|
+
}, timeoutMs);
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
redirect_uri: redirectUri, result,
|
|
85
|
+
attach(value) {
|
|
86
|
+
if (settled || authorization || value?.redirect_uri !== redirectUri || typeof value.exchange !== 'function') {
|
|
87
|
+
throw failure('CLICKUP_OAUTH_LISTENER_BINDING_INVALID', 'ClickUp callback must bind exactly one matching authorization');
|
|
88
|
+
}
|
|
89
|
+
authorization = value;
|
|
90
|
+
},
|
|
91
|
+
close() {
|
|
92
|
+
finish(failure('CLICKUP_OAUTH_CONNECTION_CANCELLED', 'ClickUp connection was cancelled'));
|
|
93
|
+
server.closeAllConnections();
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// This client is intentionally restricted to the official ClickUp MCP service.
|
|
4
|
+
// Metadata must not turn an authorization exchange into a caller-selected POST.
|
|
5
|
+
export const CLICKUP_MCP_RESOURCE = 'https://mcp.clickup.com/mcp';
|
|
6
|
+
const ISSUER = 'https://mcp.clickup.com';
|
|
7
|
+
const SCOPES = 'read write';
|
|
8
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
9
|
+
export const CLICKUP_OAUTH_REQUEST_TIMEOUT_MS = 15_000;
|
|
10
|
+
const AUTHORIZATION_LIFETIME_MS = 10 * 60_000;
|
|
11
|
+
const MAX_LOCAL_TOKEN_LIFETIME_SECONDS = 366 * 86400;
|
|
12
|
+
const REDIRECT_PATH = '/oauth/callback';
|
|
13
|
+
const failureDiagnostics = new WeakMap();
|
|
14
|
+
const UNKNOWN_FAILURE = Object.freeze({ code: 'CLICKUP_OAUTH_FAILURE_UNKNOWN' });
|
|
15
|
+
const OAUTH_ERROR_CODES = new Set(['invalid_grant', 'invalid_client', 'invalid_request', 'unauthorized_client',
|
|
16
|
+
'unsupported_grant_type', 'invalid_scope', 'server_error', 'temporarily_unavailable', 'access_denied']);
|
|
17
|
+
|
|
18
|
+
/** Only diagnostics constructed here are trusted; never serialize foreign errors. */
|
|
19
|
+
export function clickUpOAuthFailureDiagnostic(error) {
|
|
20
|
+
return failureDiagnostics.get(error) || UNKNOWN_FAILURE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Carry only locally constructed metadata across a fixed-message wrapper. */
|
|
24
|
+
export function inheritClickUpOAuthFailureDiagnostic(target, source) {
|
|
25
|
+
failureDiagnostics.set(target, clickUpOAuthFailureDiagnostic(source));
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fail(code, message, diagnostic = {}) {
|
|
30
|
+
const error = Object.assign(new Error(message), { code });
|
|
31
|
+
failureDiagnostics.set(error, Object.freeze({ code, ...diagnostic }));
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
35
|
+
function text(value, max = 8192) {
|
|
36
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max
|
|
37
|
+
&& ![...value].some((character) => character.codePointAt(0) <= 32 || character.codePointAt(0) === 127);
|
|
38
|
+
}
|
|
39
|
+
function includes(array, value) { return Array.isArray(array) && array.includes(value); }
|
|
40
|
+
|
|
41
|
+
function readWriteScope(value) {
|
|
42
|
+
if (typeof value !== 'string') return false;
|
|
43
|
+
const parts = value.split(' ');
|
|
44
|
+
return parts.length === 2 && new Set(parts).size === 2 && includes(parts, 'read') && includes(parts, 'write');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertClickUpOAuthRedirect(value) {
|
|
48
|
+
let url;
|
|
49
|
+
try { url = new URL(value); } catch { fail('CLICKUP_OAUTH_REDIRECT_INVALID', 'ClickUp OAuth requires an exact loopback callback'); }
|
|
50
|
+
if (url.href !== value || url.protocol !== 'http:' || url.hostname !== '127.0.0.1'
|
|
51
|
+
|| !/^\d+$/.test(url.port) || Number(url.port) < 1024
|
|
52
|
+
|| url.pathname !== REDIRECT_PATH || url.username || url.password || url.search || url.hash) {
|
|
53
|
+
fail('CLICKUP_OAUTH_REDIRECT_INVALID', 'ClickUp OAuth requires an exact loopback callback');
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function requestJson(url, init, fetchImpl) {
|
|
59
|
+
let response;
|
|
60
|
+
let reader;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetchImpl(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(CLICKUP_OAUTH_REQUEST_TIMEOUT_MS) });
|
|
63
|
+
const httpDiagnostic = Number.isInteger(response.status) && response.status >= 100 && response.status <= 599
|
|
64
|
+
? { http_status: response.status } : {};
|
|
65
|
+
if (!/^application\/json(?:;|$)/i.test(response.headers.get('content-type') || '')) {
|
|
66
|
+
fail('CLICKUP_OAUTH_HTTP_REJECTED', 'ClickUp OAuth endpoint rejected the request', httpDiagnostic);
|
|
67
|
+
}
|
|
68
|
+
if (Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) {
|
|
69
|
+
fail('CLICKUP_OAUTH_RESPONSE_INVALID', 'ClickUp OAuth response exceeded its size limit');
|
|
70
|
+
}
|
|
71
|
+
reader = response.body?.getReader();
|
|
72
|
+
if (!reader) fail('CLICKUP_OAUTH_RESPONSE_INVALID', 'ClickUp OAuth response was empty');
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
while (true) {
|
|
76
|
+
const chunk = await reader.read();
|
|
77
|
+
if (chunk.done) break;
|
|
78
|
+
bytes += chunk.value.byteLength;
|
|
79
|
+
if (bytes > MAX_RESPONSE_BYTES) fail('CLICKUP_OAUTH_RESPONSE_INVALID', 'ClickUp OAuth response exceeded its size limit');
|
|
80
|
+
chunks.push(chunk.value);
|
|
81
|
+
}
|
|
82
|
+
let result;
|
|
83
|
+
try { result = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
84
|
+
catch { fail('CLICKUP_OAUTH_RESPONSE_INVALID', 'ClickUp OAuth response was not valid JSON'); }
|
|
85
|
+
if (!object(result)) fail('CLICKUP_OAUTH_RESPONSE_INVALID', 'ClickUp OAuth response was not an object');
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
fail('CLICKUP_OAUTH_HTTP_REJECTED', 'ClickUp OAuth endpoint rejected the request', {
|
|
88
|
+
...httpDiagnostic, oauth_error: OAUTH_ERROR_CODES.has(result.error) ? result.error : 'unrecognized',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// Remote bodies, Location headers and thrown fetch errors may contain codes
|
|
94
|
+
// or credentials. No external exception message/cause crosses this boundary.
|
|
95
|
+
if (failureDiagnostics.has(error)) throw error;
|
|
96
|
+
return fail('CLICKUP_OAUTH_NETWORK_FAILED', 'ClickUp OAuth request failed or timed out; no automatic retry was made');
|
|
97
|
+
} finally {
|
|
98
|
+
try {
|
|
99
|
+
if (reader) await reader.cancel();
|
|
100
|
+
else if (response?.body) await response.body.cancel();
|
|
101
|
+
} catch { /* best-effort cancellation, never disclose a remote error */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Public metadata only. This does not register a client or read credentials. */
|
|
106
|
+
export async function discoverClickUpOAuth({ fetchImpl = globalThis.fetch } = {}) {
|
|
107
|
+
const resource = await requestJson(`${ISSUER}/.well-known/oauth-protected-resource/mcp`, { headers: { Accept: 'application/json' } }, fetchImpl);
|
|
108
|
+
if (resource.resource !== CLICKUP_MCP_RESOURCE || !Array.isArray(resource.authorization_servers)
|
|
109
|
+
|| resource.authorization_servers.length !== 1 || resource.authorization_servers[0] !== ISSUER
|
|
110
|
+
|| !includes(resource.scopes_supported, 'read') || !includes(resource.scopes_supported, 'write')) {
|
|
111
|
+
fail('CLICKUP_OAUTH_METADATA_INVALID', 'ClickUp protected-resource metadata does not match the pinned service');
|
|
112
|
+
}
|
|
113
|
+
const server = await requestJson(`${ISSUER}/.well-known/oauth-authorization-server`, { headers: { Accept: 'application/json' } }, fetchImpl);
|
|
114
|
+
if (server.issuer !== ISSUER || server.authorization_endpoint !== `${ISSUER}/oauth/authorize`
|
|
115
|
+
|| server.token_endpoint !== `${ISSUER}/oauth/token` || server.registration_endpoint !== `${ISSUER}/oauth/register`
|
|
116
|
+
|| !includes(server.code_challenge_methods_supported, 'S256')
|
|
117
|
+
|| !includes(server.response_types_supported, 'code')
|
|
118
|
+
|| !includes(server.grant_types_supported, 'authorization_code')
|
|
119
|
+
|| !includes(server.token_endpoint_auth_methods_supported, 'none')) {
|
|
120
|
+
fail('CLICKUP_OAUTH_METADATA_INVALID', 'ClickUp authorization metadata does not support the pinned public PKCE client');
|
|
121
|
+
}
|
|
122
|
+
return Object.freeze({ resource: CLICKUP_MCP_RESOURCE, issuer: ISSUER, scope: SCOPES });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Explicit external registration. Never falls back to a borrowed client ID. */
|
|
126
|
+
export async function registerClickUpOAuthClient({ redirectUri, fetchImpl = globalThis.fetch } = {}) {
|
|
127
|
+
assertClickUpOAuthRedirect(redirectUri);
|
|
128
|
+
await discoverClickUpOAuth({ fetchImpl });
|
|
129
|
+
const result = await requestJson(`${ISSUER}/oauth/register`, {
|
|
130
|
+
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ client_name: 'CHATI.DEV local ClickUp connection', redirect_uris: [redirectUri],
|
|
132
|
+
grant_types: ['authorization_code'], response_types: ['code'],
|
|
133
|
+
token_endpoint_auth_method: 'none', scope: SCOPES }),
|
|
134
|
+
}, fetchImpl);
|
|
135
|
+
if (!text(result.client_id, 1024) || result.token_endpoint_auth_method !== 'none'
|
|
136
|
+
|| !Array.isArray(result.redirect_uris) || result.redirect_uris.length !== 1 || result.redirect_uris[0] !== redirectUri
|
|
137
|
+
|| Object.hasOwn(result, 'client_secret') || (result.scope !== undefined && !readWriteScope(result.scope))) {
|
|
138
|
+
fail('CLICKUP_OAUTH_CLIENT_INVALID', 'ClickUp registration did not return the requested public client binding');
|
|
139
|
+
}
|
|
140
|
+
return Object.freeze({ client_id: result.client_id, redirect_uri: redirectUri, issuer: ISSUER, resource: CLICKUP_MCP_RESOURCE });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertClient(client) {
|
|
144
|
+
if (!object(client) || client.issuer !== ISSUER || client.resource !== CLICKUP_MCP_RESOURCE || !text(client.client_id, 1024)) {
|
|
145
|
+
fail('CLICKUP_OAUTH_CLIENT_INVALID', 'ClickUp OAuth client binding is invalid');
|
|
146
|
+
}
|
|
147
|
+
assertClickUpOAuthRedirect(client.redirect_uri);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tokenResult(result, now) {
|
|
151
|
+
const bearer = (value) => typeof value === 'string' && value.length > 0 && value.length <= 16384 && /^[A-Za-z0-9._~+/-]+=*$/.test(value);
|
|
152
|
+
const expiryState = result.expires_in === undefined ? 'missing'
|
|
153
|
+
: !Number.isSafeInteger(result.expires_in) ? 'not_integer'
|
|
154
|
+
: result.expires_in < 1 ? 'nonpositive' : 'valid';
|
|
155
|
+
const invalidFields = [];
|
|
156
|
+
if (typeof result.token_type !== 'string' || result.token_type.toLowerCase() !== 'bearer') invalidFields.push('token_type');
|
|
157
|
+
if (!bearer(result.access_token)) invalidFields.push('access_token');
|
|
158
|
+
if (expiryState !== 'valid') invalidFields.push('expires_in');
|
|
159
|
+
if (result.refresh_token !== undefined && !text(result.refresh_token, 16384)) invalidFields.push('refresh_token');
|
|
160
|
+
if (result.scope !== undefined && !readWriteScope(result.scope)) invalidFields.push('scope');
|
|
161
|
+
if (invalidFields.length) {
|
|
162
|
+
fail('CLICKUP_OAUTH_TOKEN_INVALID', 'ClickUp token response does not match the requested authorization', {
|
|
163
|
+
invalid_fields: Object.freeze(invalidFields), expiry_state: expiryState,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// RFC6749 defines expires_in as a duration, not a maximum permitted lifetime.
|
|
167
|
+
// Bound OUR use of a long-lived token instead of rejecting it. Cap before
|
|
168
|
+
// multiplication to avoid overflow; never extend a shorter upstream lifetime.
|
|
169
|
+
const lifetimeSeconds = Math.min(result.expires_in, MAX_LOCAL_TOKEN_LIFETIME_SECONDS);
|
|
170
|
+
return Object.freeze({ token_type: 'Bearer', access_token: result.access_token,
|
|
171
|
+
...(result.refresh_token === undefined ? {} : { refresh_token: result.refresh_token }),
|
|
172
|
+
scope: SCOPES, expires_at: new Date(now + lifetimeSeconds * 1000).toISOString() });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Ephemeral authorization. PKCE verifier/code are never returned or logged.
|
|
176
|
+
* The caller must keep returned credentials outside projects and transcripts.
|
|
177
|
+
* There is no CLI surface accepting a model-supplied token or callback result.
|
|
178
|
+
*/
|
|
179
|
+
export function createClickUpOAuthAuthorization({ client, fetchImpl = globalThis.fetch, clock = Date.now } = {}) {
|
|
180
|
+
assertClient(client);
|
|
181
|
+
// Copy the binding: caller mutation after consent must not redirect the token.
|
|
182
|
+
const binding = Object.freeze({ ...client });
|
|
183
|
+
const createdAt = clock();
|
|
184
|
+
if (!Number.isSafeInteger(createdAt) || createdAt < 0) fail('CLICKUP_OAUTH_TIME_INVALID', 'ClickUp OAuth requires a valid clock');
|
|
185
|
+
const state = randomBytes(32).toString('base64url');
|
|
186
|
+
let verifier = randomBytes(32).toString('base64url');
|
|
187
|
+
const url = new URL(`${ISSUER}/oauth/authorize`);
|
|
188
|
+
url.search = new URLSearchParams({ response_type: 'code', client_id: binding.client_id,
|
|
189
|
+
redirect_uri: binding.redirect_uri, scope: SCOPES, resource: CLICKUP_MCP_RESOURCE, state,
|
|
190
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' }).toString();
|
|
191
|
+
let consumed = false;
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
authorization_url: url.href, redirect_uri: binding.redirect_uri,
|
|
194
|
+
expires_at: new Date(createdAt + AUTHORIZATION_LIFETIME_MS).toISOString(),
|
|
195
|
+
async exchange(callbackUrl) {
|
|
196
|
+
const now = clock();
|
|
197
|
+
if (consumed) fail('CLICKUP_OAUTH_ALREADY_USED', 'ClickUp authorization has already been consumed');
|
|
198
|
+
if (!Number.isSafeInteger(now) || now < createdAt || now - createdAt >= AUTHORIZATION_LIFETIME_MS) {
|
|
199
|
+
consumed = true; verifier = undefined;
|
|
200
|
+
fail('CLICKUP_OAUTH_EXPIRED', 'ClickUp authorization expired; start a new explicit connection');
|
|
201
|
+
}
|
|
202
|
+
if (typeof callbackUrl !== 'string' || callbackUrl.length > 16384) fail('CLICKUP_OAUTH_CALLBACK_INVALID', 'ClickUp OAuth callback is invalid');
|
|
203
|
+
let callback;
|
|
204
|
+
try { callback = new URL(callbackUrl); } catch { fail('CLICKUP_OAUTH_CALLBACK_INVALID', 'ClickUp OAuth callback is invalid'); }
|
|
205
|
+
const expected = new URL(binding.redirect_uri);
|
|
206
|
+
const params = callback.searchParams;
|
|
207
|
+
const returnedState = params.get('state');
|
|
208
|
+
if (callback.origin !== expected.origin || callback.pathname !== expected.pathname || callback.hash
|
|
209
|
+
|| callback.username || callback.password || [...params.keys()].some((key) => params.getAll(key).length !== 1)
|
|
210
|
+
|| typeof returnedState !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(returnedState)
|
|
211
|
+
|| !timingSafeEqual(Buffer.from(returnedState), Buffer.from(state))) {
|
|
212
|
+
fail('CLICKUP_OAUTH_CALLBACK_INVALID', 'ClickUp OAuth callback does not match this authorization');
|
|
213
|
+
}
|
|
214
|
+
if (params.has('iss') && params.get('iss') !== ISSUER) fail('CLICKUP_OAUTH_CALLBACK_INVALID', 'ClickUp OAuth callback issuer does not match');
|
|
215
|
+
if (params.has('error')) {
|
|
216
|
+
consumed = true; verifier = undefined;
|
|
217
|
+
fail('CLICKUP_OAUTH_CONSENT_DENIED', 'ClickUp authorization was not granted');
|
|
218
|
+
}
|
|
219
|
+
const code = params.get('code');
|
|
220
|
+
if (!text(code, 8192)) fail('CLICKUP_OAUTH_CALLBACK_INVALID', 'ClickUp OAuth callback has no valid authorization code');
|
|
221
|
+
// Consume before I/O. A timeout may have consumed the code upstream.
|
|
222
|
+
consumed = true;
|
|
223
|
+
const body = new URLSearchParams({ grant_type: 'authorization_code', client_id: binding.client_id,
|
|
224
|
+
code, redirect_uri: binding.redirect_uri, resource: CLICKUP_MCP_RESOURCE, code_verifier: verifier }).toString();
|
|
225
|
+
verifier = undefined;
|
|
226
|
+
const result = await requestJson(`${ISSUER}/oauth/token`, {
|
|
227
|
+
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body,
|
|
228
|
+
}, fetchImpl);
|
|
229
|
+
// Use request start time, not response time, so network delay cannot extend validity.
|
|
230
|
+
return tokenResult(result, now);
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|