Files
PaperClipAI/server/src/services/remote-url-credentials.ts
T
Dotta d387cc0ff0 feat(connections): add managed external MCP connectors (#12346)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connection intents need secure provider implementations to complete
setup.
> - Some providers use managed OAuth or external credential brokers.
> - Those tokens must stay out of durable Paperclip state and fail
closed when refresh fails.
> - This pull request adds managed connector backends and the required
storage contract.
> - The benefit is safer provider setup with governed credential
lifecycles.

## Linked Issues or Issue Description

Refs #11965

This is stack 8 of 11. It depends on stack 7 and replaces another
reviewable part of #11965.

## What Changed

- Add managed Google Workspace and external connector backends.
- Add Vercel Connect support without storing provider bearer tokens.
- Add replay-safe migration 0232 and its generated snapshot.
- Fail closed and clear stale token bindings when organization OAuth
refresh needs reauthorization.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 194 tests passed.
- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm build`
- `pnpm exec vitest run --project @paperclipai/server
server/src/services/remote-url-credentials.test.ts` (5 passed, including
URL userinfo vault extraction)

## Risks

- Broker metadata errors can block provider setup.
- OAuth refresh failure disables the shared organization connection
until reauthorization.
- Migration 0232 is generated, ordered after 0231, and safe to replay.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have linked the public source pull request with `Refs #`
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-29 12:08:34 -05:00

71 lines
2.4 KiB
TypeScript

const REDACTED_URL_VALUE = "REDACTED";
const SENSITIVE_URL_FIELD_PATTERN =
/^(?:code|state|nonce|key|[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:[-_]?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*)$/i;
export const REMOTE_URL_SECRET_CONFIG_PATH = "remote.url";
export interface SplitRemoteUrlCredential {
publicUrl: string;
secretUrl: string | null;
secretParameterNames: string[];
}
/**
* Separate credential-bearing query values from the URL that is safe to store
* on a tool connection. The complete URL is written to the encrypted vault;
* only its public origin, path, and non-sensitive query configuration remain
* in connection config and API responses.
*/
export function splitRemoteUrlCredential(value: string): SplitRemoteUrlCredential {
const input = value.trim();
const url = new URL(input);
const hasUserInfo = url.username.length > 0 || url.password.length > 0;
if (hasUserInfo) {
url.username = "";
url.password = "";
}
const secretParameterNames: string[] = [];
for (const key of [...url.searchParams.keys()]) {
if (!SENSITIVE_URL_FIELD_PATTERN.test(key)) continue;
secretParameterNames.push(key);
url.searchParams.delete(key);
}
return {
publicUrl: url.toString(),
secretUrl: hasUserInfo || secretParameterNames.length > 0 ? input : null,
secretParameterNames,
};
}
/** Preserve useful endpoint context in logs without retaining credentials. */
export function redactRemoteUrlCredential(value: string): string {
const input = value.trim();
try {
const url = new URL(input);
if (url.username || url.password) {
url.username = REDACTED_URL_VALUE;
url.password = "";
}
for (const key of [...url.searchParams.keys()]) {
if (SENSITIVE_URL_FIELD_PATTERN.test(key)) url.searchParams.set(key, REDACTED_URL_VALUE);
}
return url.toString();
} catch {
return "configured remote MCP endpoint";
}
}
/**
* A vault value may be replaced independently of connection config. Keep that
* replacement constrained to the public endpoint the operator originally
* reviewed; only credential-bearing query values may differ.
*/
export function remoteUrlCredentialMatchesPublicUrl(publicValue: string, secretValue: string): boolean {
try {
return splitRemoteUrlCredential(secretValue).publicUrl === new URL(publicValue).toString();
} catch {
return false;
}
}