trackqa 0.0.0-stage → 0.1.1
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 +175 -2
- package/dist/src/lib/mcpOnboarding.js +143 -0
- package/dist/tools/mcp-pilot/google-pkce.mjs +244 -0
- package/dist/tools/trackqa-connect/src/auth.js +174 -0
- package/dist/tools/trackqa-connect/src/cli.js +249 -0
- package/dist/tools/trackqa-connect/src/clients/adapter.js +3 -0
- package/dist/tools/trackqa-connect/src/clients/claude-session.js +78 -0
- package/dist/tools/trackqa-connect/src/clients/claude.js +113 -0
- package/dist/tools/trackqa-connect/src/clients/codex-verify.js +110 -0
- package/dist/tools/trackqa-connect/src/clients/codex.js +191 -0
- package/dist/tools/trackqa-connect/src/clients/gemini-config.js +141 -0
- package/dist/tools/trackqa-connect/src/clients/gemini-verify.js +14 -0
- package/dist/tools/trackqa-connect/src/clients/gemini-windows.js +134 -0
- package/dist/tools/trackqa-connect/src/clients/gemini.js +249 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-copilot-lifecycle.js +97 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-copilot-session.js +115 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-copilot.js +59 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-process.js +123 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-session.js +145 -0
- package/dist/tools/trackqa-connect/src/clients/vscode.js +123 -0
- package/dist/tools/trackqa-connect/src/core.js +87 -0
- package/dist/tools/trackqa-connect/src/process.js +76 -0
- package/dist/tools/trackqa-connect/src/state.js +80 -0
- package/dist/tools/trackqa-connect/src/testing/copilot-fixture-scope.js +103 -0
- package/dist/tools/trackqa-connect/src/workflow.js +241 -0
- package/package.json +43 -4
package/README.md
CHANGED
|
@@ -1,3 +1,176 @@
|
|
|
1
|
-
#
|
|
1
|
+
# TrackQA Universal Connector — Windows MVP release candidate
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect an existing AI coding client to TrackQA's remote MCP server without copying
|
|
4
|
+
bearer tokens or editing MCP configuration manually. Codex CLI is the MVP-supported
|
|
5
|
+
candidate. Setup is Owner-operated, with explicit project approval and session-based
|
|
6
|
+
credentials. The connector does not install or authenticate the AI client itself.
|
|
7
|
+
|
|
8
|
+
## Package status and installation
|
|
9
|
+
|
|
10
|
+
**trackqa@0.1.0** was published and independently verified on November 4, 2026.
|
|
11
|
+
The locally prepared **v0.1.1 candidate** removes manual public-key setup for the
|
|
12
|
+
official hosted TrackQA service. **v0.1.1 is not published; publication requires
|
|
13
|
+
separate approval.** The original v0.1.0 artifact is retained unchanged.
|
|
14
|
+
|
|
15
|
+
To verify an approved local tarball in a disposable directory:
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
npm install --ignore-scripts --no-audit --no-fund <absolute-path-to-trackqa-0.1.1.tgz>
|
|
19
|
+
npm exec --offline -- trackqa --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
After v0.1.1 is approved and published, the official hosted workflow is:
|
|
23
|
+
|
|
24
|
+
```powershell
|
|
25
|
+
npx trackqa connect
|
|
26
|
+
# Explicit client selection is also supported:
|
|
27
|
+
npx trackqa connect --client codex
|
|
28
|
+
npx trackqa status --client codex
|
|
29
|
+
npx trackqa disconnect --client codex --project <project-UUID>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Until v0.1.1 is published, the bare registry command still resolves to v0.1.0 and
|
|
33
|
+
retains that version's manual public-key requirement. Validate this candidate
|
|
34
|
+
through a local tarball installation and `npm exec --offline -- trackqa ...`.
|
|
35
|
+
Bare `connect` prompts for a client; `--client codex` is optional. No client is
|
|
36
|
+
selected by default.
|
|
37
|
+
|
|
38
|
+
## Requirements and first use
|
|
39
|
+
|
|
40
|
+
- Windows, Node.js **22.19 or newer**, npm and an interactive terminal.
|
|
41
|
+
- An independently installed, compatible Codex CLI and its own account access.
|
|
42
|
+
The connector never installs, upgrades or signs into an AI client automatically.
|
|
43
|
+
- A TrackQA account with a profile and **OWNER** membership in the selected project.
|
|
44
|
+
Google PKCE sign-in is interactive in the system browser. Accounts with verified
|
|
45
|
+
MFA factors are currently refused as unsupported.
|
|
46
|
+
- Free callback port **5173** on localhost. Stop a conflicting development server
|
|
47
|
+
through its own terminal before setup; the connector never terminates it.
|
|
48
|
+
- Official hosted TrackQA's validated **public anon key is included**. No manual
|
|
49
|
+
`TRACKQA_PUBLIC_KEY` setup, application checkout or `.env` file is required.
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
npm exec --offline -- trackqa connect --client codex
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The installed package uses its included shared runtime helpers; it does not need
|
|
56
|
+
the application checkout or its environment files. Source-checkout fallback to the
|
|
57
|
+
app's public-key setting is development-only. No `.env` file is packaged or created.
|
|
58
|
+
|
|
59
|
+
### Advanced: custom public configuration
|
|
60
|
+
|
|
61
|
+
The connector continues to pin its backend URL. A custom deployment uses a custom
|
|
62
|
+
build pinned to its own backend, with a matching public key supplied through
|
|
63
|
+
`TRACKQA_PUBLIC_KEY` or, alternatively, `VITE_SUPABASE_ANON_KEY`. A key override
|
|
64
|
+
alone does not change the destination. No runtime URL override was added.
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
$env:TRACKQA_PUBLIC_KEY = '<matching-deployment-public-anon-or-publishable-key>'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Explicit environment values take precedence over the development-file fallback
|
|
71
|
+
and official default. Invalid explicit values fail closed. Legacy anon JWTs with
|
|
72
|
+
a different project reference are rejected; opaque publishable keys cannot be
|
|
73
|
+
associated with a deployment offline and must be validated by that backend.
|
|
74
|
+
The official default is never used for a different pinned deployment. Never use
|
|
75
|
+
a secret/service-role key, database password, Owner session or MCP bearer token.
|
|
76
|
+
|
|
77
|
+
Select one client and one project, inspect the token-free preview, and approve
|
|
78
|
+
installation, project MCP enablement if needed, and one scoped connection. The
|
|
79
|
+
connector launches the client with the credential only in the child environment.
|
|
80
|
+
TrackQA Owner sign-in and any Codex/GitHub/Google/Claude AI-account sign-in are
|
|
81
|
+
separate. Normal interactive tool approvals remain in effect.
|
|
82
|
+
|
|
83
|
+
## Support and validation limits
|
|
84
|
+
|
|
85
|
+
| Client | Status |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| Codex CLI | **MVP-supported candidate**, Windows and Owner-operated. Hosted initialization, five tools, scoped reads, foreground launch, revocation and cleanup passed. |
|
|
88
|
+
| VS Code Local (`vscode`) | **Experimental**. Windows hosted read-only acceptance passed. Manual MCP activation and same-session model access are required. |
|
|
89
|
+
| VS Code Copilot (`vscode-copilot`) | **Experimental and guarded**. Hosted read-only acceptance passed through the separate fixture-pinned test entry. Ordinary `connect` still stops before Owner authentication, issuance or launch. |
|
|
90
|
+
| Gemini CLI | **Experimental; hosted validation deferred**. Local/native configuration checks passed; native Google authentication remains blocked. |
|
|
91
|
+
| Claude Code | **Experimental; hosted validation deferred**. Native installation/configuration flags checked; test-account eligibility blocked authenticated acceptance. |
|
|
92
|
+
| Antigravity | **Deferred**; no adapter. Safe isolation remains unverified. |
|
|
93
|
+
|
|
94
|
+
VS Code detection currently supports Windows stable 1.143.x. Other platforms,
|
|
95
|
+
portable/forked clients, additional providers and parallel multi-client sessions
|
|
96
|
+
are outside this MVP. Use `trackqa --help` for supported executable-path flags.
|
|
97
|
+
Claims about a client apply only to the validated scope above.
|
|
98
|
+
|
|
99
|
+
On **November 4, 2026**, the isolated Copilot run against MCP Synthetic Pilot reached
|
|
100
|
+
Running, discovered five tools and successfully called `trackqa_connection_status`,
|
|
101
|
+
`trackqa_list_projects`, `trackqa_list_issues` and `trackqa_get_issue` once each. Four
|
|
102
|
+
native Search Tools operations were permitted; no unrelated application tools,
|
|
103
|
+
delegated agents or `trackqa_submit_finding` invocation were recorded. Project
|
|
104
|
+
isolation, revocation, fixture restoration, unchanged inventories and owned MCP
|
|
105
|
+
configuration removal were verified. The credential-pattern scan covered 166 files
|
|
106
|
+
with zero matches or exclusions.
|
|
107
|
+
|
|
108
|
+
Write-tool execution, authentication persistence, normal editor exit and complete
|
|
109
|
+
detached Agent Host/SDK termination remain unvalidated. The status response reported
|
|
110
|
+
`read_only=false` and `issue_writes_allowed=false`: the acceptance procedure used
|
|
111
|
+
only reads; the existing token's finding capability was not removed. Findings still
|
|
112
|
+
follow TrackQA's existing human QA review workflow. Ordinary Copilot activation is
|
|
113
|
+
not enabled by these results. Hosted acceptance scripts are not shipped.
|
|
114
|
+
|
|
115
|
+
## Credentials, disconnect and recovery
|
|
116
|
+
|
|
117
|
+
Owner PKCE/session credentials remain in memory and local sign-out is attempted on
|
|
118
|
+
exit. MCP tokens are never placed in arguments, configuration, logs or recovery
|
|
119
|
+
records. Configuration contains only the literal environment reference. Other
|
|
120
|
+
trusted processes on the same OS account may inspect a child's environment.
|
|
121
|
+
|
|
122
|
+
**The existing 90-day token expiry is unchanged.** This is session-based delivery,
|
|
123
|
+
not a short-lived token guarantee. Normal exit, handled interruption or explicit
|
|
124
|
+
Disconnect attempts revocation and independently checks the server record. A hard
|
|
125
|
+
kill, power loss or network failure can prevent cleanup; local configuration removal
|
|
126
|
+
alone does not revoke access.
|
|
127
|
+
|
|
128
|
+
Recovery metadata is stored in `%LOCALAPPDATA%\TrackQA-Connect`, outside the checkout.
|
|
129
|
+
After an interrupted session:
|
|
130
|
+
|
|
131
|
+
```powershell
|
|
132
|
+
npm exec --offline -- trackqa status --client codex
|
|
133
|
+
npm exec --offline -- trackqa disconnect --client codex --project <project-UUID>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`status` is local-only and does not prove authorization or connectivity. Disconnect
|
|
137
|
+
may require fresh Google Owner sign-in. Failed or uncertain issuance/revocation
|
|
138
|
+
retains actionable recovery metadata; do not bypass it or issue another connection
|
|
139
|
+
automatically. An Owner can also revoke the labeled connection in TrackQA's existing
|
|
140
|
+
AI Integrations → Manage Connections controls.
|
|
141
|
+
|
|
142
|
+
Only unchanged connector-owned configuration is removed. Edited entries and
|
|
143
|
+
unexpected files are preserved. Remove a stale `connector.lock` only after confirming
|
|
144
|
+
its recorded process has exited. Do not delete pending recovery evidence.
|
|
145
|
+
|
|
146
|
+
Normal session cleanup does **not** silently disable project-wide MCP or revoke
|
|
147
|
+
other users' connections. Any project enablement is persistent and explicitly
|
|
148
|
+
approved. Synthetic acceptance restoration was a separate, authorized Owner action.
|
|
149
|
+
|
|
150
|
+
## Package contents and source development
|
|
151
|
+
|
|
152
|
+
The artifact contains this README, package metadata and the exact compiled runtime
|
|
153
|
+
dependency closure. It includes the existing PKCE/shared MCP helper and the internal
|
|
154
|
+
acceptance-capability guard required by authentication/workflow imports. That guard
|
|
155
|
+
is not an acceptance runner or a synthetic server. Its behavior is unchanged.
|
|
156
|
+
|
|
157
|
+
Source, tests, hosted/synthetic runners, fixture servers, development tooling,
|
|
158
|
+
obsolete WMI supervision modules, environment files and runtime profiles are excluded.
|
|
159
|
+
No install scripts run on consumers. The declared runtime dependency is
|
|
160
|
+
`@supabase/supabase-js@2.113.0`; build tooling stays in the source checkout.
|
|
161
|
+
|
|
162
|
+
Source developers use the repository's existing dependencies:
|
|
163
|
+
|
|
164
|
+
```powershell
|
|
165
|
+
npm --prefix tools/trackqa-connect run check
|
|
166
|
+
npm --prefix tools/trackqa-connect run lint
|
|
167
|
+
npm --prefix tools/trackqa-connect run build
|
|
168
|
+
npm --prefix tools/trackqa-connect test
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Build clears only the verified owned `dist` directory, compiles the CLI and shared
|
|
172
|
+
dependencies, and checks the exact runtime allowlist. `prepack` repeats that clean
|
|
173
|
+
build when packing from the source checkout. Preserve generated tarballs/checksums
|
|
174
|
+
under the ignored `artifacts/` directory. Historical implementation and supervised
|
|
175
|
+
acceptance procedures remain in source-only `DEVELOPMENT.md`; no new hosted run or
|
|
176
|
+
publication is authorized by this documentation.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2
|
+
export const connectionFields = 'id,project_id,user_id,approved_by,client_label,token_expires_at,created_at,updated_at,revoked_at';
|
|
3
|
+
export const mcpTools = [
|
|
4
|
+
{ name: 'trackqa_connection_status', description: 'Check the authorized connection and project.' },
|
|
5
|
+
{ name: 'trackqa_list_projects', description: 'Show the single approved project.' },
|
|
6
|
+
{ name: 'trackqa_list_issues', description: 'List issues in that project.' },
|
|
7
|
+
{ name: 'trackqa_get_issue', description: 'Read an issue in that project.' },
|
|
8
|
+
{ name: 'trackqa_submit_finding', description: 'Submit a potential bug for human QA/Owner review.' },
|
|
9
|
+
];
|
|
10
|
+
export const clientDocs = {
|
|
11
|
+
codex: 'https://developers.openai.com/codex/mcp/',
|
|
12
|
+
claude: 'https://code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcpjson',
|
|
13
|
+
};
|
|
14
|
+
// These builders never accept a token. UUID-derived identifiers avoid project
|
|
15
|
+
// name/key injection and keep credentials for different projects independent.
|
|
16
|
+
export function mcpSetup(supabaseUrl, projectId) {
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(supabaseUrl);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error('MCP endpoint configuration is unavailable.');
|
|
23
|
+
}
|
|
24
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/'
|
|
25
|
+
|| url.search || url.hash || url.port || !uuid.test(projectId)) {
|
|
26
|
+
throw new Error('MCP endpoint configuration is unavailable.');
|
|
27
|
+
}
|
|
28
|
+
const id = projectId.replaceAll('-', '').toLowerCase();
|
|
29
|
+
return { endpoint: `${url.origin}/functions/v1/trackqa-mcp`, serverName: `trackqa_${id}`,
|
|
30
|
+
envVar: `TRACKQA_MCP_${id.toUpperCase()}_TOKEN`, projectId: projectId.toLowerCase() };
|
|
31
|
+
}
|
|
32
|
+
function verifySetup(setup) {
|
|
33
|
+
try {
|
|
34
|
+
const expected = mcpSetup(new URL(setup.endpoint).origin, setup.projectId);
|
|
35
|
+
if (setup.endpoint === expected.endpoint && setup.serverName === expected.serverName && setup.envVar === expected.envVar)
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch { /* Return only a fixed configuration error. */ }
|
|
39
|
+
throw new Error('Client setup is unavailable.');
|
|
40
|
+
}
|
|
41
|
+
export function clientConfig(setup, client) {
|
|
42
|
+
verifySetup(setup);
|
|
43
|
+
if (!['codex', 'claude', 'generic'].includes(client))
|
|
44
|
+
throw new Error('Client setup is unavailable.');
|
|
45
|
+
if (client === 'generic')
|
|
46
|
+
return null; // There is no universal client config schema.
|
|
47
|
+
if (client === 'claude')
|
|
48
|
+
return JSON.stringify({ mcpServers: { [setup.serverName]: {
|
|
49
|
+
type: 'http', url: setup.endpoint, headers: { Authorization: 'Bearer ${' + setup.envVar + '}' },
|
|
50
|
+
} } }, null, 2);
|
|
51
|
+
return `[mcp_servers.${setup.serverName}]\nurl = ${JSON.stringify(setup.endpoint)}\nbearer_token_env_var = ${JSON.stringify(setup.envVar)}\nenabled_tools = [${mcpTools.map(tool => JSON.stringify(tool.name)).join(', ')}]`;
|
|
52
|
+
}
|
|
53
|
+
export function clientLaunch(setup, client, terminal) {
|
|
54
|
+
verifySetup(setup);
|
|
55
|
+
// Only fixed executable names and validated variable identifiers enter code.
|
|
56
|
+
if (!/^TRACKQA_MCP_[0-9A-F]{32}_TOKEN$/.test(setup.envVar)
|
|
57
|
+
|| !['codex', 'claude'].includes(client) || !['powershell', 'bash'].includes(terminal)) {
|
|
58
|
+
throw new Error('Client setup is unavailable.');
|
|
59
|
+
}
|
|
60
|
+
const variable = setup.envVar;
|
|
61
|
+
if (terminal === 'bash')
|
|
62
|
+
return `(
|
|
63
|
+
set +x
|
|
64
|
+
trap 'unset ${variable}' EXIT
|
|
65
|
+
if ! read -r -s -p 'TrackQA token (hidden): ' ${variable}; then exit 1; fi
|
|
66
|
+
printf '\\n'
|
|
67
|
+
if [[ ! "$${variable}" =~ ^tqm_[0-9a-f]{64}$ ]]; then printf 'Invalid TrackQA token format.\\n' >&2; exit 1; fi
|
|
68
|
+
export ${variable}
|
|
69
|
+
${client}
|
|
70
|
+
)`;
|
|
71
|
+
return `& {
|
|
72
|
+
$ErrorActionPreference = 'Stop'
|
|
73
|
+
$secureToken = Read-Host 'TrackQA token (hidden)' -AsSecureString
|
|
74
|
+
$tokenPointer = [IntPtr]::Zero
|
|
75
|
+
try {
|
|
76
|
+
$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
|
|
77
|
+
$env:${variable} = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer)
|
|
78
|
+
if ($env:${variable} -notmatch '^tqm_[0-9a-f]{64}$') { throw 'Invalid TrackQA token format.' }
|
|
79
|
+
${client}
|
|
80
|
+
} finally {
|
|
81
|
+
Remove-Item Env:${variable} -ErrorAction SilentlyContinue
|
|
82
|
+
if ($tokenPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) }
|
|
83
|
+
$secureToken.Dispose()
|
|
84
|
+
}
|
|
85
|
+
}`;
|
|
86
|
+
}
|
|
87
|
+
export function connectionStatus(connection, enabled, archived, memberPresent, now) {
|
|
88
|
+
if (connection.revoked_at)
|
|
89
|
+
return 'Revoked';
|
|
90
|
+
const expires = Date.parse(connection.token_expires_at);
|
|
91
|
+
if (!Number.isFinite(expires) || expires <= now)
|
|
92
|
+
return 'Expired';
|
|
93
|
+
if (!connection.user_id || !connection.approved_by || !memberPresent)
|
|
94
|
+
return 'Unavailable';
|
|
95
|
+
if (archived)
|
|
96
|
+
return 'Project archived';
|
|
97
|
+
if (!enabled)
|
|
98
|
+
return 'Project disabled';
|
|
99
|
+
return 'Ready to connect';
|
|
100
|
+
}
|
|
101
|
+
export function watchTokenReveal(clear, documentTarget, windowTarget) {
|
|
102
|
+
const hide = () => { if (documentTarget.hidden)
|
|
103
|
+
clear(); };
|
|
104
|
+
const timer = windowTarget.setTimeout(clear, 120_000);
|
|
105
|
+
documentTarget.addEventListener('visibilitychange', hide);
|
|
106
|
+
windowTarget.addEventListener('pagehide', clear);
|
|
107
|
+
return () => {
|
|
108
|
+
windowTarget.clearTimeout(timer);
|
|
109
|
+
documentTarget.removeEventListener('visibilitychange', hide);
|
|
110
|
+
windowTarget.removeEventListener('pagehide', clear);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const issuanceError = () => new Error('Token issuance could not be completed. Refresh connections and revoke any new entry before trying again.');
|
|
114
|
+
// The existing human-authenticated RPC remains authoritative. No retry, cache,
|
|
115
|
+
// persistence, diagnostics, or token-bearing return value is introduced here.
|
|
116
|
+
export async function issueConnectionToken(call, scope, label, reveal) {
|
|
117
|
+
let rows = null;
|
|
118
|
+
try {
|
|
119
|
+
if (!scope.isOwner || !scope.enabled || scope.archived || !uuid.test(scope.projectId)
|
|
120
|
+
|| !uuid.test(scope.userId) || !scope.memberIds.includes(scope.userId)
|
|
121
|
+
|| !label.trim() || label.trim().length > 120)
|
|
122
|
+
throw issuanceError();
|
|
123
|
+
const result = await call({ p_project_id: scope.projectId, p_user_id: scope.userId, p_client_label: label.trim() });
|
|
124
|
+
rows = result.data;
|
|
125
|
+
if (result.error || !Array.isArray(rows) || rows.length !== 1 || !uuid.test(rows[0]?.connection_id)
|
|
126
|
+
|| !/^tqm_[0-9a-f]{64}$/.test(rows[0]?.access_token))
|
|
127
|
+
throw issuanceError();
|
|
128
|
+
const connectionId = rows[0].connection_id;
|
|
129
|
+
const displayed = reveal({ connectionId, token: rows[0].access_token });
|
|
130
|
+
return { connectionId, displayed };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw issuanceError();
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
// Discard the SDK response's one-time secret after handing it to the local
|
|
137
|
+
// reveal dialog. It must never enter connection metadata or a query cache.
|
|
138
|
+
if (Array.isArray(rows))
|
|
139
|
+
for (const row of rows)
|
|
140
|
+
if (row && typeof row === 'object')
|
|
141
|
+
row.access_token = '';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export const GOOGLE_REDIRECT = 'http://localhost:5173/mcp-pilot/callback';
|
|
6
|
+
export const GOOGLE_CODES = Object.freeze(['callback_port_in_use', 'callback_unavailable',
|
|
7
|
+
'callback_timeout', 'callback_rejected', 'oauth_denied', 'oauth_failed',
|
|
8
|
+
'browser_open_failed', 'interrupted']);
|
|
9
|
+
const HOST = 'localhost:5173';
|
|
10
|
+
const CALLBACK = '/mcp-pilot/callback';
|
|
11
|
+
const COOKIE = 'trackqa_mcp_pilot_flow';
|
|
12
|
+
const START = '/mcp-pilot/start';
|
|
13
|
+
export class GooglePkceError extends Error {
|
|
14
|
+
constructor(code) {
|
|
15
|
+
super('Google authorization did not complete.');
|
|
16
|
+
this.code = GOOGLE_CODES.includes(code) ? code : 'oauth_failed';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const failure = code => new GooglePkceError(code);
|
|
20
|
+
// Only this local, one-time bootstrap URL reaches the default browser launcher.
|
|
21
|
+
// It contains no authorization code, access/refresh token or PKCE verifier.
|
|
22
|
+
export function openSystemBrowser(url) {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
if (process.platform !== 'win32' || u.origin !== 'http://' + HOST || u.pathname !== START
|
|
25
|
+
|| !/^[0-9a-f]{64}$/.test(u.searchParams.get('nonce') ?? ''))
|
|
26
|
+
throw failure('browser_open_failed');
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
execFile(path.join(process.env.SystemRoot ?? 'C:/Windows', 'System32/rundll32.exe'), ['url.dll,FileProtocolHandler', u.href], { windowsHide: true, timeout: 5000, maxBuffer: 1024 }, error => error ? reject(failure('browser_open_failed')) : resolve());
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// No request logger. Two fixed loopback bindings prevent localhost resolving to
|
|
32
|
+
// a different application's IPv4/IPv6 listener. Never use 0.0.0.0 or :: here.
|
|
33
|
+
export async function createGoogleCallback({ timeoutMs = 300_000, signal, serverFactory = createServer } = {}) {
|
|
34
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000)
|
|
35
|
+
throw failure('callback_unavailable');
|
|
36
|
+
const nonce = randomBytes(32).toString('hex');
|
|
37
|
+
const servers = [];
|
|
38
|
+
let authorizationUrl;
|
|
39
|
+
let flowId;
|
|
40
|
+
let started = false;
|
|
41
|
+
let settled = false;
|
|
42
|
+
let closed = false;
|
|
43
|
+
let timer;
|
|
44
|
+
let resolveCode, rejectCode;
|
|
45
|
+
const code = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
|
|
46
|
+
// A failure can occur while opening the browser, before the caller awaits it.
|
|
47
|
+
void code.catch(() => { });
|
|
48
|
+
function finish(error, value) {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
error ? rejectCode(error) : resolveCode(value);
|
|
54
|
+
}
|
|
55
|
+
const abort = () => finish(failure('interrupted'));
|
|
56
|
+
async function close() {
|
|
57
|
+
if (closed)
|
|
58
|
+
return;
|
|
59
|
+
closed = true;
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
signal?.removeEventListener('abort', abort);
|
|
62
|
+
if (!settled)
|
|
63
|
+
finish(failure('interrupted'));
|
|
64
|
+
await Promise.all(servers.map(server => new Promise(resolve => {
|
|
65
|
+
const force = setTimeout(() => server.closeAllConnections?.(), 250);
|
|
66
|
+
try {
|
|
67
|
+
server.close(() => { clearTimeout(force); resolve(); });
|
|
68
|
+
server.closeIdleConnections?.();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
clearTimeout(force);
|
|
72
|
+
resolve();
|
|
73
|
+
}
|
|
74
|
+
})));
|
|
75
|
+
authorizationUrl = undefined;
|
|
76
|
+
flowId = undefined;
|
|
77
|
+
}
|
|
78
|
+
function respond(response, status, message, extra = {}) {
|
|
79
|
+
response.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8',
|
|
80
|
+
'Cache-Control': 'no-store', 'Pragma': 'no-cache', 'Referrer-Policy': 'no-referrer',
|
|
81
|
+
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'", Connection: 'close', ...extra });
|
|
82
|
+
response.end(message);
|
|
83
|
+
}
|
|
84
|
+
const clearCookie = `${COOKIE}=; Path=${CALLBACK}; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
85
|
+
function handler(request, response) {
|
|
86
|
+
try {
|
|
87
|
+
const remote = request.socket.remoteAddress;
|
|
88
|
+
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remote)
|
|
89
|
+
|| request.headers.host !== HOST || (request.headers.origin && request.headers.origin !== 'http://' + HOST)) {
|
|
90
|
+
respond(response, 403, 'Request rejected.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (request.method !== 'GET') {
|
|
94
|
+
respond(response, 405, 'Request rejected.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const raw = request.url ?? '';
|
|
98
|
+
if (!raw.startsWith('/') || raw.startsWith('//') || raw.length > 8192) {
|
|
99
|
+
respond(response, 400, 'Request rejected.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (![START, CALLBACK].includes(raw.split('?')[0])) {
|
|
103
|
+
respond(response, 404, 'Not found.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const url = new URL(raw, 'http://' + HOST);
|
|
107
|
+
if (url.hash) {
|
|
108
|
+
respond(response, 400, 'Request rejected.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (url.pathname === START) {
|
|
112
|
+
if (settled || started || !authorizationUrl || url.searchParams.getAll('nonce').length !== 1
|
|
113
|
+
|| url.searchParams.get('nonce') !== nonce || [...url.searchParams.keys()].some(k => k !== 'nonce')) {
|
|
114
|
+
respond(response, 403, 'Request rejected.');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
started = true;
|
|
118
|
+
// Session cookie: no OAuth credentials and no persistent expiry. It is
|
|
119
|
+
// only a browser-to-attempt correlation value for this loopback origin.
|
|
120
|
+
respond(response, 302, 'Continue authorization in the browser.', {
|
|
121
|
+
Location: authorizationUrl,
|
|
122
|
+
'Set-Cookie': `${COOKIE}=${nonce}; Path=${CALLBACK}; HttpOnly; SameSite=Lax`,
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (url.pathname !== CALLBACK) {
|
|
127
|
+
respond(response, 404, 'Not found.');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (settled) {
|
|
131
|
+
respond(response, 410, 'Authorization response already handled.');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const cookies = String(request.headers.cookie ?? '').split(';').map(v => v.trim())
|
|
135
|
+
.filter(v => v.startsWith(COOKIE + '=')).map(v => v.slice(COOKIE.length + 1));
|
|
136
|
+
const matched = cookies.length === 1 && /^[0-9a-f]{64}$/.test(cookies[0])
|
|
137
|
+
&& timingSafeEqual(Buffer.from(cookies[0]), Buffer.from(nonce));
|
|
138
|
+
const returnedFlow = url.searchParams.getAll('sb_flow_id');
|
|
139
|
+
if (!started || !matched || returnedFlow.length > 1 || (returnedFlow.length === 1 && returnedFlow[0] !== flowId)) {
|
|
140
|
+
respond(response, 400, 'Authorization response rejected.', { 'Set-Cookie': clearCookie });
|
|
141
|
+
finish(failure('callback_rejected'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const allowed = ['code', 'sb_flow_id', 'error', 'error_code', 'error_description'];
|
|
145
|
+
const codes = url.searchParams.getAll('code');
|
|
146
|
+
const hasError = ['error', 'error_code', 'error_description'].some(k => url.searchParams.has(k));
|
|
147
|
+
if ([...url.searchParams.keys()].some(k => !allowed.includes(k) || url.searchParams.getAll(k).length !== 1)
|
|
148
|
+
|| codes.length > 1 || (hasError && codes.length !== 0)) {
|
|
149
|
+
respond(response, 400, 'Authorization response rejected.', { 'Set-Cookie': clearCookie });
|
|
150
|
+
finish(failure('callback_rejected'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (hasError) {
|
|
154
|
+
respond(response, 400, 'Authorization did not complete. Return to the helper.', { 'Set-Cookie': clearCookie });
|
|
155
|
+
finish(failure('oauth_denied'));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const value = codes[0];
|
|
159
|
+
if (!value || !/^[A-Za-z0-9._~-]{1,2048}$/.test(value)) {
|
|
160
|
+
respond(response, 400, 'Authorization response rejected.', { 'Set-Cookie': clearCookie });
|
|
161
|
+
finish(failure('callback_rejected'));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
respond(response, 200, 'Authorization response received. Return to the helper for verification.', { 'Set-Cookie': clearCookie });
|
|
165
|
+
finish(null, value);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
try {
|
|
169
|
+
respond(response, 400, 'Authorization response rejected.');
|
|
170
|
+
}
|
|
171
|
+
catch { /* No request/error details logged. */ }
|
|
172
|
+
finish(failure('callback_rejected'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
if (signal?.aborted)
|
|
177
|
+
throw failure('interrupted');
|
|
178
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
179
|
+
for (const host of ['127.0.0.1', '::1']) {
|
|
180
|
+
const server = serverFactory({ maxHeaderSize: 8192, headersTimeout: 5000, requestTimeout: 5000 }, handler);
|
|
181
|
+
server.maxConnections = 4;
|
|
182
|
+
server.keepAliveTimeout = 1000;
|
|
183
|
+
server.on('clientError', (_error, socket) => socket.destroy());
|
|
184
|
+
servers.push(server);
|
|
185
|
+
await new Promise((resolve, reject) => {
|
|
186
|
+
const onError = error => reject(failure(error.code === 'EADDRINUSE' ? 'callback_port_in_use' : 'callback_unavailable'));
|
|
187
|
+
server.once('error', onError);
|
|
188
|
+
server.listen({ host, port: 5173, ipv6Only: true, exclusive: true }, () => {
|
|
189
|
+
server.removeListener('error', onError);
|
|
190
|
+
server.on('error', () => finish(failure('callback_unavailable')));
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (signal?.aborted)
|
|
196
|
+
throw failure('interrupted');
|
|
197
|
+
timer = setTimeout(() => finish(failure('callback_timeout')), timeoutMs);
|
|
198
|
+
return {
|
|
199
|
+
code, close,
|
|
200
|
+
configure(url, expectedFlow) {
|
|
201
|
+
const u = new URL(url);
|
|
202
|
+
if (authorizationUrl || settled || !/^[A-Za-z0-9_-]{8,64}$/.test(expectedFlow)
|
|
203
|
+
|| u.origin !== 'https://mzeuimqfrswrwoaqjdmp.supabase.co' || u.pathname !== '/auth/v1/authorize'
|
|
204
|
+
|| u.searchParams.get('provider') !== 'google' || u.searchParams.get('redirect_to') !== GOOGLE_REDIRECT
|
|
205
|
+
|| u.searchParams.get('code_challenge_method') !== 's256'
|
|
206
|
+
|| !/^[A-Za-z0-9_-]{43}$/.test(u.searchParams.get('code_challenge') ?? ''))
|
|
207
|
+
throw failure('oauth_failed');
|
|
208
|
+
authorizationUrl = u.href;
|
|
209
|
+
flowId = expectedFlow;
|
|
210
|
+
},
|
|
211
|
+
startUrl: 'http://' + HOST + START + '?nonce=' + nonce,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
await close();
|
|
216
|
+
throw error instanceof GooglePkceError ? error : failure('callback_unavailable');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export async function googlePkceSignIn(client, options = {}) {
|
|
220
|
+
const callback = await createGoogleCallback({ timeoutMs: options.oauthTimeoutMs,
|
|
221
|
+
signal: options.signal, serverFactory: options.serverFactory });
|
|
222
|
+
let value, flowId;
|
|
223
|
+
try {
|
|
224
|
+
const start = await client.auth.signInWithOAuth({ provider: 'google',
|
|
225
|
+
options: { redirectTo: GOOGLE_REDIRECT, skipBrowserRedirect: true, queryParams: { prompt: 'select_account' } } });
|
|
226
|
+
if (start.error)
|
|
227
|
+
throw failure('oauth_failed');
|
|
228
|
+
flowId = start.data.flowId;
|
|
229
|
+
callback.configure(start.data.url, flowId);
|
|
230
|
+
const opening = Promise.resolve().then(() => (options.openBrowser ?? openSystemBrowser)(callback.startUrl));
|
|
231
|
+
await Promise.race([opening, callback.code.then(() => undefined)]);
|
|
232
|
+
value = await callback.code;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
throw error instanceof GooglePkceError ? error : failure('browser_open_failed');
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await callback.close();
|
|
239
|
+
}
|
|
240
|
+
if (options.signal?.aborted)
|
|
241
|
+
throw failure('interrupted');
|
|
242
|
+
// Always select the retained flow's verifier, never a callback-selected slot.
|
|
243
|
+
return client.auth.exchangeCodeForSession(value, { flowId });
|
|
244
|
+
}
|