mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connections let agents use provider tools through the permission
gateway.
> - Asana provides an official remote MCP server, but its v2 server
requires a registered MCP OAuth app.
> - Setup can discover retired v1 endpoints and send a callback that
differs from the displayed URL.
> - This pull request repairs custom app setup and adds sign-in through
Paperclip's shared app.
> - Users can choose their own app without enrolling with Paperclip
Cloud.
> - Agents can use Asana tools after the user connects their account and
sets action permissions.
## Linked Issues or Issue Description
Related: #14739 supplies the personal credential repair used by resumed
Asana setup. No duplicate Asana authentication PR was found.
**What happened?**
Asana setup failed even with a user-created app. Root discovery metadata
still points at v1. MCP v2 uses the Asana OAuth issuer and requires an
MCP app with a client secret. Local setup also displayed a localhost
callback while an Origin header could make authorization use a numeric
loopback callback.
**Expected behavior**
Sign in with Paperclip's app when its broker profile is available. Keep
custom MCP app setup available without Cloud enrollment. Use the correct
issuer, callback, client credentials, and resource throughout setup.
**Steps to reproduce**
1. Open Asana in the connection catalog.
2. Supply an Asana MCP app's client ID and secret.
3. Start OAuth on a local instance opened with a numeric loopback
address, or resume a draft that cached v1 metadata.
4. Observe the wrong discovery endpoint or callback mismatch.
**Paperclip version or commit**
Reproduced from b54b2dc35c. Rebased onto
master at `0829d94af` after the single-screen setup change in #14811.
**Deployment mode**
Local development from source. The managed path also supports enrolled
self-hosted instances.
## What Changed
- Add the `asana.mcp` managed profile and the default Sign in with Asana
method.
- Use Asana's reviewed v2 protected-resource metadata before cached
endpoints.
- Require a custom MCP app's client secret and retain saved credentials
during setup or reconnect. Repair only the known Asana v1
issuer/resource binding, retaining company and callback checks.
- Expose a boolean for the acting user's saved client secret. The form
offers secret reuse only when that user has an active grant with the
required reference.
- Let users select their own app from the enrollment and
shared-app-unavailable screens, or from Advanced on the single-screen
setup page.
- Canonicalize HTTP loopback callbacks even when the request includes an
Origin header.
- Extend signed broker claims and provider URL validation for Asana.
Require refresh credentials on managed authorization.
- Document setup, distribution, and shared-app rollout requirements.
- Resolve permission-profile name collisions when finishing another
account. The live staging test found this after renaming the first Asana
connection; OAuth succeeded but profile finalization failed.
## Verification
- Final live staging proof used app commit
`c6053157c4e42ac017727117b754ae77fa5c45fa` and the real Cloud broker at
`767b63835170f664542afd0df99a76615e204b62`. In the embedded browser,
default shared sign-in required no client credentials, returned through
the central Cloud callback to the tenant, and discovered 39 actions. Get
me succeeded through the gateway as the selected QA agent (2.1 seconds).
The custom-app connection also returned a real result on this final
build (0.9 seconds).
- Retried the shared draft that failed during the first staging test. It
completed after the profile-name fix, retained the selected agent, and
kept the existing custom connection intact. Two database regressions
reproduced the collision before the fix and passed afterward. The
updated transaction rollback test also passes.
- Shared reconnect returned to the same staging connection with 39
actions. Earlier staging checks verified the custom-app fallback when
the shared profile was unavailable, saved-secret reuse on reconnect, and
Off blocking the action test. Allowed was restored after that check.
- Local live-provider checks also repaired a saved Asana v1
issuer/resource binding without reentering the secret and verified that
numeric-loopback setup uses the displayed localhost callback. Expiring
the local managed access-token timestamp triggered a real Asana refresh
and a successful Get me call. These early local broker tests used
enrollment/authentication and storage fixtures; the final staging proof
used deployed Cloud identity and persistent storage.
- The new production app is registered and configured, but production
sign-in has not been deployed or verified. Live provider revocation was
not run because the existing staging test app is shared with other
connections.
- After rebasing onto the single-screen setup flow, full `pnpm -r
typecheck`, `pnpm build`, and `pnpm check:token-gates` pass. Focused
verification passes 365 service and 36 broker-client tests. Broader
checks pass all 833 shared-package tests and all 530 connector-page
tests. The shared suite uses `TMPDIR=/private/tmp` to avoid macOS
temporary-directory symlinks in its canonical-path tests. The UI tests
verify the shared-app default and switching to a custom app with its
required client secret.
- Embedded-browser smoke on the current rebased build verified the
shared sign-in default, Advanced → custom app (client ID and secret
required), and switching back to Paperclip. Both existing Asana
connections remained connected after restart. No new provider
authorization was performed during this smoke.
- The full local `pnpm test:run` was interrupted when the execution
session restarted. Before interruption, it reported one runtime-slot
restart test failure. That test passed on an isolated retry after
clearing two unused PostgreSQL shared-memory segments. The full local
suite did not complete; CI must pass on the current head before merge.
- All 52 CI and security checks pass on
`9318fd4e9b616cdc3de12f40cdb9bd32d865af4c` (CI run `36882064080`),
including all eight browser shards, nine serialized-server shards,
build, typecheck, and canary dry run. Two optional Storybook checks were
skipped. Greptile review 4 reports 5/5 on this exact commit, with all
review threads resolved. Its updated summary identifies the current SHA;
this comment-triggered review did not publish a separate GitHub check
run.
- Provider revocation is unit-tested in the companion broker. Live
provider revocation was not run because the existing test app is shared
with other connections.
## Risks
- The shared Paperclip Asana MCP app has been registered with its
production callback and Any workspace distribution. Its secret is
provisioned in the production secret store, and the runtime client ID
and secret reference are configured. The production profile is enabled
in the saved deployment configuration. The companion broker has merged
and passed staging deployment; production sign-in still requires a
production deployment and live verification. Custom setup remains
available.
- Asana MCP uses the provider's fixed `default` grant. Paperclip action
policies limit agent tool use; they do not narrow provider consent.
- The callback correction affects HTTP loopback OAuth flows. Public
HTTPS callbacks retain their existing behavior.
- Reviewed discovery URLs now override stale cached endpoints. Tests
cover the Asana v1-to-v2 repair.
- No schema migration. Connection removal retains the existing
local-only revocation behavior.
## Model Used
OpenAI GPT-6 through Codex, with code execution, browser testing, and
GitHub tooling. The exact model variant and context window are not
exposed in this session.
## 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
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
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
806 lines
33 KiB
TypeScript
806 lines
33 KiB
TypeScript
import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
|
import {
|
|
createDecipheriv,
|
|
createHash,
|
|
createPrivateKey,
|
|
createPublicKey,
|
|
diffieHellman,
|
|
hkdfSync,
|
|
randomUUID,
|
|
sign,
|
|
type KeyObject,
|
|
} from "node:crypto";
|
|
import {
|
|
DEFAULT_OWNERSHIP_AVAILABILITY,
|
|
type AppDefinition,
|
|
GITHUB_CONNECTOR_PROFILES,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
|
isGitHubConnectorProfileId,
|
|
isGoogleWorkspaceConnectorProfileId,
|
|
type GitHubConnectorProfileId,
|
|
type GoogleWorkspaceConnectorProfileId,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
loadPaperclipCloudConnectorIdentity,
|
|
paperclipCloudConnectorEnrollmentStatus,
|
|
} from "./paperclip-cloud-connector-enrollment.js";
|
|
|
|
export const GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1";
|
|
export const GMAIL_CONNECTOR_SCOPES = [
|
|
"https://www.googleapis.com/auth/gmail.readonly",
|
|
"https://www.googleapis.com/auth/gmail.compose",
|
|
] as const;
|
|
export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES };
|
|
|
|
export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production";
|
|
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack";
|
|
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId;
|
|
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana";
|
|
|
|
export type PaperclipCloudConnectorConfig = {
|
|
baseUrl: string;
|
|
instanceId: string;
|
|
environment: PaperclipCloudConnectorEnvironment;
|
|
signPrivateKey: string;
|
|
sealPrivateKey: string;
|
|
};
|
|
|
|
export type SealedConnectorCredentials = {
|
|
v: 1;
|
|
accessToken: string;
|
|
refreshToken: string | null;
|
|
tokenType: string;
|
|
accessTokenExpiresAt: string | null;
|
|
refreshTokenExpiresAt: string | null;
|
|
scopes: string[];
|
|
subject: string;
|
|
companyId: string;
|
|
instanceId: string;
|
|
environment: PaperclipCloudConnectorEnvironment;
|
|
provider: PaperclipCloudConnectorProvider;
|
|
profile: string;
|
|
appSlug?: string;
|
|
};
|
|
|
|
export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" };
|
|
export type SealedGoogleWorkspaceCredentials = SealedGmailCredentials;
|
|
|
|
export type SealedConnectorEvents = {
|
|
v: 1;
|
|
instanceId: string;
|
|
environment: PaperclipCloudConnectorEnvironment;
|
|
leaseId: string;
|
|
events: Array<{
|
|
id: string;
|
|
provider: "github";
|
|
event: string;
|
|
action: string | null;
|
|
installationId: string | null;
|
|
repositoryId: string | null;
|
|
payload: Record<string, unknown>;
|
|
createdAt: string;
|
|
bindingIds: string[];
|
|
}>;
|
|
};
|
|
|
|
type SealedEnvelope = {
|
|
v: 1;
|
|
alg: "X25519-HKDF-SHA256-A256GCM";
|
|
purpose: "initial" | "access" | "events";
|
|
provider: PaperclipCloudConnectorProvider;
|
|
profile: PaperclipCloudConnectorProfileId;
|
|
epk: string;
|
|
iv: string;
|
|
ct: string;
|
|
};
|
|
|
|
type ConnectorResponse = {
|
|
confirmationUrl?: unknown;
|
|
authorizationUrl?: unknown;
|
|
handoff?: unknown;
|
|
expiresAt?: unknown;
|
|
scopes?: unknown;
|
|
claimId?: unknown;
|
|
sealed?: unknown;
|
|
profiles?: unknown;
|
|
protocolVersion?: unknown;
|
|
providers?: unknown;
|
|
active?: unknown;
|
|
status?: unknown;
|
|
leaseId?: unknown;
|
|
events?: unknown;
|
|
acknowledged?: unknown;
|
|
};
|
|
|
|
const ENDPOINTS: Record<PaperclipCloudConnectorOperation, string> = {
|
|
status: "/v1/connector/instance-status",
|
|
session: "/v1/connector/sessions",
|
|
claim: "/v1/connector/claims",
|
|
refresh: "/v1/connector/refresh",
|
|
revoke: "/v1/connector/revoke",
|
|
"webhook-bind": "/v1/connector/webhook-bindings",
|
|
"event-lease": "/v1/connector/events/lease",
|
|
"event-ack": "/v1/connector/events/ack",
|
|
};
|
|
const JWS_TYP = "paperclip-cloud-connector-request+jwt";
|
|
const SEAL_ALGORITHM = "X25519-HKDF-SHA256-A256GCM";
|
|
const AES_TAG_BYTES = 16;
|
|
const RAW_PRIVATE_KEY_BYTES = 32;
|
|
const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
const X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
|
|
const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
|
|
|
|
// Only fixed protocol codes may enter errors/logs. Never retain the broker's
|
|
// message, request data, return URI, or an arbitrary error/code string.
|
|
const BROKER_REJECTION_REASONS = new Set([
|
|
"RETURN_ORIGIN_NOT_ENROLLED", "INVALID_RETURN_URI", "INVALID_REQUEST",
|
|
"UNKNOWN_INSTANCE", "INSTANCE_APPROVAL_REQUIRED", "INSTANCE_SUSPENDED",
|
|
"ENVIRONMENT_MISMATCH", "PROFILE_NOT_AVAILABLE", "PROFILE_NOT_ELIGIBLE",
|
|
"REQUEST_EXPIRED", "REQUEST_REPLAYED", "SIGNATURE_REJECTED",
|
|
"AUDIENCE_MISMATCH", "OPERATION_MISMATCH", "PAYLOAD_MISMATCH",
|
|
"MALFORMED_REQUEST", "UNSUPPORTED_ALGORITHM", "RATE_LIMITED",
|
|
"PROVIDER_OPERATION_FAILED",
|
|
]);
|
|
const UNKNOWN_BROKER_REASON = "UNKNOWN_BROKER_ERROR";
|
|
|
|
async function readBrokerRejectionReason(response: Response): Promise<string> {
|
|
if (response.bodyUsed || response.body?.locked) return UNKNOWN_BROKER_REASON;
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return UNKNOWN_BROKER_REASON;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
(async () => {
|
|
const chunks: Uint8Array[] = [];
|
|
let size = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
size += value.byteLength;
|
|
if (size > 4_096) return UNKNOWN_BROKER_REASON;
|
|
chunks.push(value);
|
|
}
|
|
const body: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
const reason = body && typeof body === "object" && "error" in body ? body.error : null;
|
|
return typeof reason === "string" && BROKER_REJECTION_REASONS.has(reason)
|
|
? reason : UNKNOWN_BROKER_REASON;
|
|
})(),
|
|
new Promise<string>((resolve) => {
|
|
timer = setTimeout(() => resolve(UNKNOWN_BROKER_REASON), 500);
|
|
}),
|
|
]);
|
|
} catch {
|
|
return UNKNOWN_BROKER_REASON;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
// Diagnostic reads and cancellation must never delay the original failure.
|
|
void reader.cancel().catch(() => {});
|
|
}
|
|
}
|
|
|
|
/** Stable public code/status with only allowlisted broker diagnostics. */
|
|
export class PaperclipCloudConnectorError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly code: string,
|
|
readonly status?: number,
|
|
) {
|
|
super(message);
|
|
this.name = "PaperclipCloudConnectorError";
|
|
}
|
|
}
|
|
|
|
export function paperclipCloudConnectorConfigFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): PaperclipCloudConnectorConfig | null {
|
|
const localIdentity = loadPaperclipCloudConnectorIdentity();
|
|
const legacyConfigured = [
|
|
env.PAPERCLIP_ID_CONNECTOR_INSTANCE_ID,
|
|
env.PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY,
|
|
env.PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY,
|
|
env.PAPERCLIP_ID_CONNECTOR_ENVIRONMENT,
|
|
env.PAPERCLIP_ID_CONNECTOR_BASE_URL,
|
|
].some((value) => Boolean(value?.trim()));
|
|
const managedInstanceId = env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID?.trim();
|
|
const managedSignPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY?.trim();
|
|
const managedSealPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY?.trim();
|
|
const managedEnvironment = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim();
|
|
const hasManagedIdentityOverride = [managedInstanceId, managedSignPrivateKey, managedSealPrivateKey]
|
|
.some(Boolean);
|
|
const localStatus = hasManagedIdentityOverride ? null : paperclipCloudConnectorEnrollmentStatus(env);
|
|
const hasActiveLocalIdentity = localIdentity?.status === "active" && localStatus?.configured === true;
|
|
if (!hasManagedIdentityOverride && !hasActiveLocalIdentity && legacyConfigured) {
|
|
throw new PaperclipCloudConnectorError(
|
|
"Paperclip ID connector settings use an incompatible legacy protocol; enroll this instance with Paperclip Cloud",
|
|
"CONNECTOR_MIGRATION_REQUIRED",
|
|
);
|
|
}
|
|
if (!hasManagedIdentityOverride && !hasActiveLocalIdentity) return null;
|
|
|
|
const instanceId = hasManagedIdentityOverride ? managedInstanceId : localIdentity!.instanceId;
|
|
const signPrivateKey = hasManagedIdentityOverride ? managedSignPrivateKey : localIdentity!.signPrivateKey;
|
|
const sealPrivateKey = hasManagedIdentityOverride ? managedSealPrivateKey : localIdentity!.sealPrivateKey;
|
|
const environment = hasManagedIdentityOverride ? managedEnvironment : localIdentity!.environment;
|
|
const baseUrl = env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL?.trim()
|
|
|| (hasActiveLocalIdentity ? localIdentity!.brokerBaseUrl : undefined)
|
|
|| "https://my.paperclip.app";
|
|
const values = [instanceId, signPrivateKey, sealPrivateKey, environment];
|
|
if (values.some((value) => !value)) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE");
|
|
}
|
|
if (environment !== "development" && environment !== "staging" && environment !== "production") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector environment is invalid", "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
const parsedBaseUrl = new URL(baseUrl);
|
|
if (parsedBaseUrl.protocol !== "https:" && !(parsedBaseUrl.protocol === "http:" && isLoopback(parsedBaseUrl.hostname))) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector URL must use HTTPS", "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
if (parsedBaseUrl.username || parsedBaseUrl.password || parsedBaseUrl.search || parsedBaseUrl.hash) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector URL is invalid", "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
const brokerHost = parsedBaseUrl.hostname.toLowerCase();
|
|
if ((brokerHost === "my.paperclip.app" && environment !== "production")
|
|
|| (brokerHost === "my-staging.paperclip.app" && environment !== "staging")) {
|
|
throw new PaperclipCloudConnectorError(
|
|
"Paperclip Cloud connector broker and environment do not match",
|
|
"CONNECTOR_CONFIG_INVALID",
|
|
);
|
|
}
|
|
parsedBaseUrl.pathname = parsedBaseUrl.pathname.replace(/\/$/, "");
|
|
return {
|
|
baseUrl: parsedBaseUrl.toString().replace(/\/$/, ""),
|
|
instanceId: instanceId!,
|
|
environment,
|
|
signPrivateKey: signPrivateKey!,
|
|
sealPrivateKey: sealPrivateKey!,
|
|
};
|
|
}
|
|
|
|
export function createPaperclipCloudConnector(input: {
|
|
config: PaperclipCloudConnectorConfig;
|
|
request?: typeof fetch;
|
|
now?: () => number;
|
|
}) {
|
|
const config = input.config;
|
|
const request = input.request ?? fetch;
|
|
const now = input.now ?? Date.now;
|
|
const signingKey = privateKey(config.signPrivateKey, "ed25519");
|
|
const sealKey = privateKey(config.sealPrivateKey, "x25519");
|
|
|
|
async function call(
|
|
operation: PaperclipCloudConnectorOperation,
|
|
claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string },
|
|
secret?: { field: "refreshToken" | "token" | "binding" | "acknowledgement"; value: string },
|
|
): Promise<ConnectorResponse> {
|
|
const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString();
|
|
const issuedAt = Math.floor(now() / 1000);
|
|
const payload: Record<string, unknown> = {
|
|
iss: config.instanceId,
|
|
aud: endpoint,
|
|
sub: claims.subject,
|
|
cid: claims.companyId,
|
|
env: config.environment,
|
|
op: operation,
|
|
iat: issuedAt,
|
|
exp: issuedAt + 60,
|
|
jti: randomUUID(),
|
|
};
|
|
if (claims.returnUri !== undefined) payload.ruri = claims.returnUri;
|
|
if (claims.returnState !== undefined) payload.rst = claims.returnState;
|
|
if (claims.claimId !== undefined) payload.cl = claims.claimId;
|
|
if (claims.redemptionId !== undefined) payload.rid = claims.redemptionId;
|
|
if (claims.profile !== undefined) {
|
|
const definition = connectorProfileDefinition(claims.profile);
|
|
payload.prv = definition.provider;
|
|
payload.prf = claims.profile;
|
|
payload.scp = [...definition.scopes];
|
|
}
|
|
if (secret) payload.sh = await sha256Base64Url(secret.value);
|
|
const body = {
|
|
request: signRequest(payload, signingKey),
|
|
...(secret ? { [secret.field]: secret.value } : {}),
|
|
};
|
|
let response: Response;
|
|
try {
|
|
response = await request(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector is unavailable", "CONNECTOR_UNAVAILABLE");
|
|
}
|
|
if (operation === "revoke" && response.status === 204) return {};
|
|
if (!response.ok) {
|
|
const reason = await readBrokerRejectionReason(response);
|
|
throw new PaperclipCloudConnectorError(
|
|
`Paperclip Cloud connector rejected the request (operation=${operation}, status=${response.status}, reason=${reason})`,
|
|
response.status === 409 ? "REAUTHORIZATION_REQUIRED" : "CONNECTOR_REQUEST_FAILED",
|
|
response.status,
|
|
);
|
|
}
|
|
try {
|
|
return await response.json() as ConnectorResponse;
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid response", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
}
|
|
|
|
function openCredentials(
|
|
response: ConnectorResponse,
|
|
purpose: SealedEnvelope["purpose"],
|
|
subject: string,
|
|
companyId: string,
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
): SealedConnectorCredentials {
|
|
const definition = connectorProfileDefinition(profile);
|
|
const envelope = parseEnvelope(response.sealed, purpose, definition.provider, profile);
|
|
const credentials = unseal(
|
|
envelope,
|
|
sealKey,
|
|
config.instanceId,
|
|
config.environment,
|
|
definition.provider,
|
|
profile,
|
|
definition.scopes,
|
|
);
|
|
if (
|
|
credentials.instanceId !== config.instanceId
|
|
|| credentials.environment !== config.environment
|
|
|| credentials.subject !== subject
|
|
|| credentials.companyId !== companyId
|
|
|| credentials.provider !== definition.provider
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud credential binding did not match", "CONNECTOR_BINDING_MISMATCH");
|
|
}
|
|
if (credentials.profile !== profile) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector profile binding did not match", "CONNECTOR_BINDING_MISMATCH");
|
|
}
|
|
if (!sameStringSet(credentials.scopes, definition.scopes)) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud scope grant did not match", "REAUTHORIZATION_REQUIRED");
|
|
}
|
|
return credentials;
|
|
}
|
|
|
|
return {
|
|
async getInstanceStatus(): Promise<"active" | "suspended" | "removed"> {
|
|
let response: ConnectorResponse;
|
|
try {
|
|
response = await call("status", {
|
|
subject: "instance-status",
|
|
companyId: "instance-status",
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof PaperclipCloudConnectorError && error.status === 401) return "removed";
|
|
throw error;
|
|
}
|
|
if (response.status === "active" && response.active === true) return "active";
|
|
if (response.status === "suspended" && response.active === false) return "suspended";
|
|
if (response.status === "removed" && response.active === false) return "removed";
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid instance status", "CONNECTOR_BAD_RESPONSE");
|
|
},
|
|
async getCapabilities(): Promise<PaperclipCloudConnectorProfileId[]> {
|
|
let response: ConnectorResponse;
|
|
try {
|
|
response = await call("status", {
|
|
subject: "instance-capabilities",
|
|
companyId: "instance-capabilities",
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles)) return [];
|
|
return [...new Set(response.profiles.flatMap((value) =>
|
|
typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : []
|
|
))];
|
|
},
|
|
async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) {
|
|
const profile = values.profile ?? "gmail.draft";
|
|
const response = await call("session", { ...values, profile });
|
|
if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid session", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
let confirmationUrl: URL;
|
|
try {
|
|
confirmationUrl = new URL(response.confirmationUrl);
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
const expectedBroker = new URL(config.baseUrl);
|
|
if (
|
|
confirmationUrl.origin !== expectedBroker.origin
|
|
|| confirmationUrl.pathname !== "/connections/confirm"
|
|
|| confirmationUrl.username
|
|
|| confirmationUrl.password
|
|
|| confirmationUrl.hash
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
let authorizationUrl: URL | undefined;
|
|
if (response.authorizationUrl !== undefined) {
|
|
if (typeof response.authorizationUrl !== "string") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
try {
|
|
authorizationUrl = new URL(response.authorizationUrl);
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
if (
|
|
authorizationUrl.protocol !== "https:"
|
|
|| authorizationUrl.username
|
|
|| authorizationUrl.password
|
|
|| authorizationUrl.hash
|
|
|| !isExpectedProviderAuthorizationUrl(profile, authorizationUrl)
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
}
|
|
const handoff = parseCloudHandoff(response.handoff);
|
|
return {
|
|
authorizationUrl: authorizationUrl?.toString() ?? confirmationUrl.toString(),
|
|
expiresAt: response.expiresAt,
|
|
...(handoff ? { handoff } : {}),
|
|
};
|
|
},
|
|
async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; claimId: string; redemptionId: string }) {
|
|
const profile = values.profile ?? "gmail.draft";
|
|
return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile);
|
|
},
|
|
async refresh(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; refreshToken: string }) {
|
|
const profile = values.profile ?? "gmail.draft";
|
|
return openCredentials(
|
|
await call("refresh", { ...values, profile }, { field: "refreshToken", value: values.refreshToken }),
|
|
sealPurpose("access", profile),
|
|
values.subject,
|
|
values.companyId,
|
|
profile,
|
|
);
|
|
},
|
|
async revoke(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; token: string }) {
|
|
await call("revoke", { ...values, profile: values.profile ?? "gmail.draft" }, { field: "token", value: values.token });
|
|
},
|
|
async setWebhookBinding(values: {
|
|
subject: string;
|
|
companyId: string;
|
|
id: string;
|
|
installationId: string;
|
|
connectionId: string;
|
|
grantId: string;
|
|
active: boolean;
|
|
accessToken?: string;
|
|
}) {
|
|
if (values.active && !values.accessToken) {
|
|
throw new PaperclipCloudConnectorError(
|
|
"GitHub access token is required to authorize a webhook binding",
|
|
"CONNECTOR_CONFIG_INVALID",
|
|
);
|
|
}
|
|
const binding = JSON.stringify({
|
|
id: values.id,
|
|
installationId: values.installationId,
|
|
connectionId: values.connectionId,
|
|
grantId: values.grantId,
|
|
active: values.active,
|
|
...(values.active ? { accessToken: values.accessToken } : {}),
|
|
});
|
|
await call("webhook-bind", { ...values, profile: "github.code" }, { field: "binding", value: binding });
|
|
},
|
|
async leaseEvents(values: { subject: string; companyId: string }): Promise<{ leaseId: string; events: SealedConnectorEvents["events"] } | null> {
|
|
const response = await call("event-lease", values);
|
|
if (Array.isArray(response.events) && response.events.length === 0) return null;
|
|
if (typeof response.leaseId !== "string") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid event lease", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
const envelope = parseEnvelope(response.sealed, "events", "github", "github.code");
|
|
const opened = unsealEvents(envelope, sealKey, config.instanceId, config.environment);
|
|
if (opened.leaseId !== response.leaseId) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector event lease did not match", "CONNECTOR_BINDING_MISMATCH");
|
|
}
|
|
return { leaseId: opened.leaseId, events: opened.events };
|
|
},
|
|
async acknowledgeEvents(values: { subject: string; companyId: string; leaseId: string; deliveryIds: string[] }): Promise<number> {
|
|
const acknowledgement = JSON.stringify({ leaseId: values.leaseId, deliveryIds: values.deliveryIds });
|
|
const response = await call("event-ack", values, { field: "acknowledgement", value: acknowledgement });
|
|
const acknowledged = response.acknowledged;
|
|
if (typeof acknowledged !== "number" || !Number.isSafeInteger(acknowledged) || acknowledged < 0) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid acknowledgement", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
return acknowledged;
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseCloudHandoff(value: unknown): { kind: "paperclip_cloud"; session: string } | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const session = record.session;
|
|
if (
|
|
record.kind !== "tenant_background"
|
|
|| typeof session !== "string"
|
|
|| session.length < 16
|
|
|| session.length > 512
|
|
|| !/^[A-Za-z0-9_-]+$/.test(session)
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
return { kind: "paperclip_cloud", session };
|
|
}
|
|
|
|
export type PaperclipCloudConnector = ReturnType<typeof createPaperclipCloudConnector>;
|
|
export type PaperclipCloudGoogleWorkspaceConnector = PaperclipCloudConnector;
|
|
|
|
/** Accept persisted Paperclip ID-era records while all new records use the Cloud strategy. */
|
|
export function isPaperclipCloudConnectorStrategy(value: unknown): boolean {
|
|
return value === "paperclip_cloud_connector" || value === "paperclip_id_connector";
|
|
}
|
|
|
|
/** Use the same signed instance profiles for catalog display and setup validation. */
|
|
export function appWithPaperclipCloudConnectorAvailability(
|
|
app: AppDefinition,
|
|
profiles: readonly string[],
|
|
): AppDefinition {
|
|
const enabledProfiles = new Set(profiles);
|
|
const methods = app.methods.filter((method) =>
|
|
!isPaperclipCloudConnectorStrategy(method.oauthStrategy)
|
|
|| Boolean(method.connectorProfile && enabledProfiles.has(method.connectorProfile))
|
|
);
|
|
return {
|
|
...app,
|
|
methods,
|
|
ownershipAvailability: {
|
|
...DEFAULT_OWNERSHIP_AVAILABILITY,
|
|
...app.ownershipAvailability,
|
|
platform_shared: methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy)),
|
|
},
|
|
};
|
|
}
|
|
|
|
let capabilityCache: { key: string; expiresAt: number; profiles: PaperclipCloudConnectorProfileId[] } | null = null;
|
|
let capabilityCacheGeneration = 0;
|
|
|
|
export function invalidatePaperclipCloudConnectorCapabilities(): void {
|
|
capabilityCacheGeneration += 1;
|
|
capabilityCache = null;
|
|
}
|
|
|
|
export async function paperclipCloudConnectorCapabilitiesFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<PaperclipCloudConnectorProfileId[]> {
|
|
let config: PaperclipCloudConnectorConfig | null;
|
|
try {
|
|
config = paperclipCloudConnectorConfigFromEnv(env);
|
|
} catch (error) {
|
|
// Gallery discovery is useful even while connector enrollment is pending or
|
|
// local connector settings are incomplete. Treat every connector-config
|
|
// error as "no managed profiles" here; enrollment/status surfaces still
|
|
// report the actionable configuration problem.
|
|
if (error instanceof PaperclipCloudConnectorError) return [];
|
|
throw error;
|
|
}
|
|
if (!config) return [];
|
|
const key = `${config.baseUrl}|${config.instanceId}|${config.environment}`;
|
|
if (capabilityCache?.key === key && capabilityCache.expiresAt > Date.now()) return capabilityCache.profiles;
|
|
const generation = capabilityCacheGeneration;
|
|
const connector = createPaperclipCloudConnector({ config });
|
|
const profiles = await connector.getCapabilities();
|
|
if (generation === capabilityCacheGeneration) {
|
|
capabilityCache = { key, expiresAt: Date.now() + 60_000, profiles };
|
|
}
|
|
return profiles;
|
|
}
|
|
|
|
function signRequest(payload: Record<string, unknown>, key: KeyObject): string {
|
|
const header = { alg: "EdDSA", typ: JWS_TYP };
|
|
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`;
|
|
return `${signingInput}.${sign(null, Buffer.from(signingInput, "utf8"), key).toString("base64url")}`;
|
|
}
|
|
|
|
function privateKey(value: string, curve: "ed25519" | "x25519"): KeyObject {
|
|
try {
|
|
let parsed: KeyObject;
|
|
if (value.includes("BEGIN PRIVATE KEY")) {
|
|
parsed = createPrivateKey(value);
|
|
} else {
|
|
const raw = Buffer.from(value, "base64url");
|
|
parsed = raw.length === RAW_PRIVATE_KEY_BYTES
|
|
? createPrivateKey({
|
|
key: Buffer.concat([curve === "ed25519" ? ED25519_PKCS8_PREFIX : X25519_PKCS8_PREFIX, raw]),
|
|
format: "der",
|
|
type: "pkcs8",
|
|
})
|
|
: createPrivateKey({ key: raw, format: "der", type: "pkcs8" });
|
|
}
|
|
if (parsed.asymmetricKeyType !== curve) throw new Error("wrong key type");
|
|
return parsed;
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError(`Paperclip Cloud ${curve} private key is invalid`, "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
}
|
|
|
|
function parseEnvelope(
|
|
value: unknown,
|
|
purpose: SealedEnvelope["purpose"],
|
|
provider: PaperclipCloudConnectorProvider,
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
): SealedEnvelope {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw badEnvelope();
|
|
const candidate = value as Partial<SealedEnvelope>;
|
|
if (candidate.v !== 1 || candidate.alg !== SEAL_ALGORITHM || candidate.purpose !== purpose
|
|
|| candidate.provider !== provider || candidate.profile !== profile
|
|
|| typeof candidate.epk !== "string" || typeof candidate.iv !== "string" || typeof candidate.ct !== "string") {
|
|
throw badEnvelope();
|
|
}
|
|
return candidate as SealedEnvelope;
|
|
}
|
|
|
|
function unseal(
|
|
envelope: SealedEnvelope,
|
|
recipientPrivateKey: KeyObject,
|
|
instanceId: string,
|
|
environment: string,
|
|
provider: PaperclipCloudConnectorProvider,
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
scopes: readonly string[],
|
|
): SealedConnectorCredentials {
|
|
try {
|
|
const parsed = decryptEnvelope(envelope, recipientPrivateKey, instanceId, environment, provider, profile, scopes) as Partial<SealedConnectorCredentials>;
|
|
if (parsed.v !== 1 || typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0
|
|
|| !(parsed.refreshToken === null || typeof parsed.refreshToken === "string")
|
|
|| typeof parsed.tokenType !== "string"
|
|
|| !(parsed.accessTokenExpiresAt === null || typeof parsed.accessTokenExpiresAt === "string")
|
|
|| !(parsed.refreshTokenExpiresAt === null || typeof parsed.refreshTokenExpiresAt === "string")
|
|
|| !Array.isArray(parsed.scopes) || !parsed.scopes.every((scope) => typeof scope === "string")
|
|
|| typeof parsed.subject !== "string" || typeof parsed.companyId !== "string"
|
|
|| typeof parsed.instanceId !== "string" || typeof parsed.environment !== "string"
|
|
|| !(parsed.appSlug === undefined || (typeof parsed.appSlug === "string" && /^[a-z0-9-]{1,100}$/.test(parsed.appSlug)))
|
|
|| parsed.provider !== provider || parsed.profile !== profile) {
|
|
throw badEnvelope();
|
|
}
|
|
return parsed as SealedConnectorCredentials;
|
|
} catch (error) {
|
|
if (error instanceof PaperclipCloudConnectorError) throw error;
|
|
throw badEnvelope();
|
|
}
|
|
}
|
|
|
|
function unsealEvents(
|
|
envelope: SealedEnvelope,
|
|
recipientPrivateKey: KeyObject,
|
|
instanceId: string,
|
|
environment: PaperclipCloudConnectorEnvironment,
|
|
): SealedConnectorEvents {
|
|
try {
|
|
const parsed = decryptEnvelope(envelope, recipientPrivateKey, instanceId, environment, "github", "github.code", []) as Partial<SealedConnectorEvents>;
|
|
if (parsed.v !== 1 || parsed.instanceId !== instanceId || parsed.environment !== environment
|
|
|| typeof parsed.leaseId !== "string" || !Array.isArray(parsed.events)) throw badEnvelope();
|
|
for (const event of parsed.events) {
|
|
if (!isRecord(event) || typeof event.id !== "string" || event.provider !== "github"
|
|
|| typeof event.event !== "string" || !isRecord(event.payload) || !Array.isArray(event.bindingIds)
|
|
|| !event.bindingIds.every((id) => typeof id === "string")) throw badEnvelope();
|
|
}
|
|
return parsed as SealedConnectorEvents;
|
|
} catch (error) {
|
|
if (error instanceof PaperclipCloudConnectorError) throw error;
|
|
throw badEnvelope();
|
|
}
|
|
}
|
|
|
|
function decryptEnvelope(
|
|
envelope: SealedEnvelope,
|
|
recipientPrivateKey: KeyObject,
|
|
instanceId: string,
|
|
environment: string,
|
|
provider: PaperclipCloudConnectorProvider,
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
scopes: readonly string[],
|
|
): unknown {
|
|
const ephemeralRaw = Buffer.from(envelope.epk, "base64url");
|
|
if (ephemeralRaw.length !== 32) throw badEnvelope();
|
|
const ephemeralKey = createPublicKey({
|
|
key: Buffer.concat([X25519_SPKI_PREFIX, ephemeralRaw]),
|
|
format: "der",
|
|
type: "spki",
|
|
});
|
|
const recipientJwk = createPublicKey(recipientPrivateKey).export({ format: "jwk" }) as { x?: string };
|
|
if (!recipientJwk.x) throw badEnvelope();
|
|
const recipientRaw = Buffer.from(recipientJwk.x, "base64url");
|
|
if (envelope.provider !== provider || envelope.profile !== profile) throw badEnvelope();
|
|
const aad = Buffer.from([
|
|
1,
|
|
SEAL_ALGORITHM,
|
|
envelope.purpose,
|
|
instanceId,
|
|
environment,
|
|
provider,
|
|
profile,
|
|
[...scopes].sort().join(" "),
|
|
].join("\n"), "utf8");
|
|
const key = Buffer.from(hkdfSync(
|
|
"sha256",
|
|
diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }),
|
|
Buffer.concat([ephemeralRaw, recipientRaw]),
|
|
aad,
|
|
32,
|
|
));
|
|
const iv = Buffer.from(envelope.iv, "base64url");
|
|
const combined = Buffer.from(envelope.ct, "base64url");
|
|
if (iv.length !== 12 || combined.length <= AES_TAG_BYTES) throw badEnvelope();
|
|
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: AES_TAG_BYTES });
|
|
decipher.setAAD(aad);
|
|
decipher.setAuthTag(combined.subarray(-AES_TAG_BYTES));
|
|
const plaintext = Buffer.concat([
|
|
decipher.update(combined.subarray(0, -AES_TAG_BYTES)),
|
|
decipher.final(),
|
|
]);
|
|
return JSON.parse(plaintext.toString("utf8")) as unknown;
|
|
}
|
|
|
|
function badEnvelope() {
|
|
return new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid sealed credential", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
|
|
function sealPurpose(
|
|
kind: "initial" | "access",
|
|
_profile: PaperclipCloudConnectorProfileId,
|
|
): SealedEnvelope["purpose"] {
|
|
return kind;
|
|
}
|
|
|
|
function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): {
|
|
provider: PaperclipCloudConnectorProvider;
|
|
scopes: readonly string[];
|
|
} {
|
|
if (isAsanaConnectorProfileId(profile)) {
|
|
return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES };
|
|
}
|
|
if (isGitHubConnectorProfileId(profile)) {
|
|
return { provider: "github", scopes: GITHUB_CONNECTOR_PROFILES[profile].scopes };
|
|
}
|
|
return { provider: "google", scopes: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes };
|
|
}
|
|
|
|
function isExpectedProviderAuthorizationUrl(
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
url: URL,
|
|
): boolean {
|
|
if (isAsanaConnectorProfileId(profile)) {
|
|
return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize";
|
|
}
|
|
if (isGitHubConnectorProfileId(profile)) {
|
|
return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize";
|
|
}
|
|
return url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
|
}
|
|
|
|
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
|
|
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value);
|
|
}
|
|
|
|
async function sha256Base64Url(value: string): Promise<string> {
|
|
return createHash("sha256").update(value, "utf8").digest("base64url");
|
|
}
|
|
|
|
function base64Url(value: string): string {
|
|
return Buffer.from(value, "utf8").toString("base64url");
|
|
}
|
|
|
|
function sameStringSet(value: unknown, expected: readonly string[]): boolean {
|
|
return Array.isArray(value)
|
|
&& value.every((item) => typeof item === "string")
|
|
&& value.length === expected.length
|
|
&& expected.every((item) => value.includes(item));
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isLoopback(hostname: string): boolean {
|
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
}
|