feat(apps): add Paperclip Cloud managed OAuth connector (#12600)

## Thinking Path

> - Paperclip lets operators give governed tools to AI agents.
> - Connected Apps already support provider OAuth and personal
connection grants.
> - Some providers require one stable callback and do not support
dynamic client registration.
> - Self-hosted Paperclip instances can run at private or changeable
origins.
> - Paperclip Cloud can provide the stable callback while each instance
keeps its durable provider credentials.
> - This pull request adds the instance side of that managed OAuth
protocol and keeps customer-created clients available.
> - The benefit is a safe path to one-click Workspace connections for
hosted and enrolled self-hosted instances.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting. This change updates the server, Apps UI, shared app
definitions, and connection documentation.

**Problem or motivation**

Some OAuth providers require a pre-registered callback and
provider-owned client. An arbitrary self-hosted Paperclip origin cannot
use that client callback directly. Paperclip ID must also stay limited
to product identity instead of resource authorization.

**Proposed solution**

Use the existing Paperclip Cloud application as the fixed callback
broker. Enroll each instance to an exact origin and separate Ed25519 and
X25519 keys. Bind every request and sealed envelope to the instance,
environment, user, company, provider, profile, and exact scope set.
Store durable provider credentials only in the originating instance
vault.

**Alternatives considered**

Customer-created OAuth clients remain available as the independent
fallback. A generic redirect relay was rejected because it would allow
caller-selected destinations and scopes. Paperclip ID was rejected as
the broker because it is the identity boundary. A new service was
rejected because the existing Cloud application already owns customer
login and the public callback origin.

**Roadmap alignment**

This work extends the shipped MCP Tool Gateway and Apps milestone. It
also supports the Connected Apps and Cloud deployments roadmap items.

Companion Cloud implementation:
https://github.com/paperclipai/paperclip-cloud/pull/312

The duplicate search found no related open Paperclip PR or issue.

## What Changed

- Add a `paperclip_cloud_connector` client with signed requests, exact
profile and scope bindings, and X25519-sealed credential handling.
- Add explicit self-hosted enrollment with owner-only instance key
storage and exact HTTPS origins.
- Route managed Google Workspace setup through Paperclip Cloud and
preserve customer-created OAuth clients.
- Keep broker claims retryable until the local vault transaction
commits.
- Keep managed Google per-profile removal local-only to avoid
client-wide provider revocation.
- Add setup status to the Connections page and retain the Paperclip ID
names as compatibility aliases.
- Document the trust boundaries, enrollment, callback, refresh, removal,
and rollout flows.

## Verification

- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/shared exec vitest run
src/app-definitions.test.ts`
- `pnpm --filter @paperclipai/server exec vitest run
src/services/paperclip-cloud-connector.test.ts
src/services/paperclip-cloud-connector-enrollment.test.ts`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts -t 'brokered Gmail
OAuth|brokered OAuth state'`
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/apps/Connections.test.tsx`
- `pnpm check:token-gates`
- `pnpm build`
- The full stable test runner also reproduced existing macOS workspace,
skill-discovery, and listener fixture failures outside the changed
paths. GitHub Linux CI is the authoritative full-suite result.

## Risks

- The managed flow depends on
https://github.com/paperclipai/paperclip-cloud/pull/312. Real provider
profiles stay disabled until Cloud deploys that protocol and the
provider approves the managed client.
- A Cloud outage blocks new authorization and refresh. Existing access
tokens continue to work until expiry.
- Managed Google profile removal only deletes the local grant. This
avoids invalidating the user's other profiles that share the managed
Google client.
- Legacy `paperclip_id_connector` records require a reconnect after
their current access tokens expire. Old Paperclip ID keys and refresh
tokens are not sent to Paperclip Cloud.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5.6 (Codex). Agentic coding, tool use, code execution, and
subagents were enabled. The context-window size is 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>
This commit is contained in:
Dotta 2026-08-31 14:34:46 -05:00 committed by GitHub
parent ad0ad43cf4
commit a7e6b818e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2030 additions and 687 deletions

View File

@ -150,7 +150,7 @@ fixture. `env` belongs primarily to approved local stdio templates.
| Automatic OAuth | `auth: "oauth"`, `ownershipModes: ["dcr"]` | Browser sign-in | DCR/CIMD client binding plus token secret refs. |
| Automatic OAuth with own-app escape hatch | `ownershipModes: ["customer", "dcr"]` | Recommended browser sign-in; own client under **Advanced** | Same as automatic, or supplied client ID plus encrypted client secret. |
| Customer OAuth only | `ownershipModes: ["customer"]` | Required client ID and optional/required client secret, then browser sign-in | Client ID in redacted config; client secret and provider tokens as secret refs. |
| Paperclip-managed OAuth | `oauthStrategy: "paperclip_id_connector"`, `connectorProfile`, `platform_shared` | Browser sign-in through the managed connector | Provider tokens still land in the instance vault on a user grant. Paperclip ID does not retain them. |
| Paperclip-managed OAuth | `oauthStrategy: "paperclip_cloud_connector"`, `connectorProfile`, `platform_shared` | Browser sign-in through Paperclip Cloud | Provider tokens still land in the instance vault on a user grant. Cloud handles the fixed provider callback but does not persist plaintext credentials; Paperclip ID remains identity-only. |
| API key/PAT | `auth: "api_key"`, `credentialFields`, `keyPlacement` | Write-only credential field | Encrypted secret version plus placement-only refs. |
| Generated URL | `auth: "none"`, no fixed URL/default template | Paste provider-generated MCP URL | Public URL shape in config; full secret-bearing URL in the vault. |
| No auth | `auth: "none"`, fixed `serverUrl` or validated `serverUrlTemplate` | Zero fields or only required tenant/resource fields | No provider credential. |
@ -931,7 +931,7 @@ Suggested PR verification block:
| `transport` | `mcp_remote`, `local_stdio`, or specialized `rest_api`. |
| `auth` | `oauth`, `api_key`, or `none`. |
| `ownershipModes` | Allowed OAuth client ownership modes; also present for non-OAuth customer configuration. |
| `oauthStrategy` | Managed broker strategy. Currently `paperclip_id_connector`. Only valid for OAuth. |
| `oauthStrategy` | Managed broker strategy. New definitions use `paperclip_cloud_connector`; `paperclip_id_connector` is recognized only to require migration when an old grant expires. The protocols and provider clients are not interchangeable. Only valid for OAuth. |
| `connectorProfile` | Managed connector capability/scope profile, required with `oauthStrategy`. |
| `capabilityProfile` | User-facing read/write/mode grouping used for method selection. |
| `grantKinds` | Restricts identity to `organization` and/or `user`; omit for flexible methods. |
@ -1065,7 +1065,7 @@ Choose one method auth mode:
- OAuth: delegated user or workspace authorization. The OAuth client may come
from DCR/CIMD, a customer-created client, a deployment-preconfigured client,
or a reviewed Paperclip ID connector profile. Do not assume Paperclip owns a
or a reviewed Paperclip Cloud connector profile. Do not assume Paperclip owns a
shared client registration.
- API key: operator-supplied token or key. Use only when the provider supports
a suitably restricted key and the value is stored as a `company_secrets`

View File

@ -9,8 +9,9 @@ Google sign-in:
- Gmail authorization lets that user's agents search and read mail and create
drafts. It requests only `gmail.readonly` and `gmail.compose`.
Do not add Gmail scopes to the Google sign-in client. Paperclip ID hosts the
public Gmail OAuth callback, while the originating Paperclip instance remains
Do not add Gmail scopes to the Google sign-in client. The existing Paperclip
Cloud application at `my.paperclip.app` hosts the public Gmail OAuth callback;
Paperclip ID remains identity-only. The originating Paperclip instance remains
the durable owner of the encrypted access and refresh tokens.
> Google Workspace MCP is a Developer Preview. Enroll the required Workspace
@ -23,14 +24,14 @@ Use a separate Google Cloud project and OAuth web client for each environment:
| Environment | Suggested project id | OAuth client name | Authorized redirect URI |
| --- | --- | --- | --- |
| Development | `paperclip-gmail-dev` | `Paperclip Gmail Connection Dev` | `http://localhost:3000/api/connect/oauth/google/callback` |
| Staging | `paperclip-gmail-staging` | `Paperclip Gmail Connection Staging` | `https://id-staging.paperclip.app/api/connect/oauth/google/callback` |
| Production | `paperclip-gmail-prod` | `Paperclip Gmail Connection Production` | `https://id.paperclip.app/api/connect/oauth/google/callback` |
| Development | `paperclip-gmail-dev` | `Paperclip Gmail Connection Dev` | Local Paperclip Cloud origin + `/v1/connector/oauth/google/callback` |
| Staging | `paperclip-gmail-staging` | `Paperclip Gmail Connection Staging` | `https://my-staging.paperclip.app/v1/connector/oauth/google/callback` |
| Production | `paperclip-gmail-prod` | `Paperclip Gmail Connection Production` | `https://my.paperclip.app/v1/connector/oauth/google/callback` |
Replace the development port if the local Paperclip ID service uses another
Replace the development port if the local Paperclip Cloud application uses another
port. Do not register Tailscale, customer, or other self-hosted Paperclip
instance URLs with Google. The browser always returns to Paperclip ID first;
Paperclip ID then sends an opaque, one-time claim identifier to the exact
instance URLs with Google. The browser always returns to Paperclip Cloud first;
Cloud then sends an opaque, one-time claim identifier to the exact
originating instance URL that was enrolled before the flow began.
Keeping projects separate is a Paperclip release policy. It prevents a
@ -87,9 +88,9 @@ Open **Google Auth Platform → Branding**. Set:
The homepage, privacy policy, and terms must be live on the verified domain
before production verification. The privacy policy must explain that the
originating Paperclip instance stores Gmail credentials and that Paperclip ID
performs bounded OAuth exchange, refresh, and revocation without durable
plaintext token storage.
originating Paperclip instance stores Gmail credentials and that Paperclip Cloud
performs bounded OAuth exchange, refresh, and provider-supported revocation
without durable plaintext token storage.
### 4. Configure the audience
@ -137,56 +138,46 @@ Never paste either credential into an issue, document, chat, screenshot,
committed `.env`, build log, or browser-visible configuration. Step 7 lists the
deployment variables that receive them.
### 7. Configure the Paperclip ID broker deployment
### 7. Configure the Paperclip Cloud broker deployment
Set these on the Paperclip ID service that owns the redirect URI above. This is
Set these on the existing Paperclip Cloud application that owns the redirect URI above. This is
the broker half of the configuration; the originating Paperclip instance is
configured separately under [Configure each originating Paperclip
instance](#configure-each-originating-paperclip-instance).
| Variable | Development | Staging | Production |
| --- | --- | --- | --- |
| `GOOGLE_GMAIL_CLIENT_ID` | Dev client id | Staging client id | Production client id |
| `GOOGLE_GMAIL_CLIENT_SECRET` | Dev client secret | Staging client secret | Production client secret |
| `GOOGLE_GMAIL_REDIRECT_URI` | `http://localhost:3000/api/connect/oauth/google/callback` | `https://id-staging.paperclip.app/api/connect/oauth/google/callback` | `https://id.paperclip.app/api/connect/oauth/google/callback` |
| `GOOGLE_GMAIL_CONNECTOR_ENABLED` | `true` once dev testing starts | `true` after dev sign-off | `true` only after Google verification and Security review |
| `CONNECTOR_ENVIRONMENT` | `development` | `staging` | `production` |
| `CLOUD_HARNESS_CONNECTOR_GOOGLE_GMAIL_CLIENT_ID` | Dev client id | Staging client id | Production client id |
| `CLOUD_HARNESS_CONNECTOR_GOOGLE_GMAIL_CLIENT_SECRET_REF` | Dev secret-manager ref | Staging secret-manager ref | Production secret-manager ref |
| Fixed callback | Local Cloud origin + `/v1/connector/oauth/google/callback` | `https://my-staging.paperclip.app/v1/connector/oauth/google/callback` | `https://my.paperclip.app/v1/connector/oauth/google/callback` |
| `CLOUD_HARNESS_CONNECTOR_GOOGLE_ENABLED_PROFILES` | `gmail.read` during the first test | Add reviewed staging profiles | Add only approved production profiles |
| `CLOUD_HARNESS_CONNECTOR_ENVIRONMENT` | `development` | `staging` | `production` |
The three credential variables must be set together. Setting some but not all
of them fails validation at boot, and enabling the connector without all three
fails as well.
The client id and secret reference must both be present before a profile can be
used. The callback is derived from Paperclip Cloud's configured customer origin
and the provider's fixed in-code path; it is not accepted from a request or an
environment override. `CLOUD_HARNESS_CONNECTOR_GOOGLE_ENABLED_PROFILES` is the
profile kill switch. An omitted profile is advertised as disabled and every
authorization, refresh, and revocation request for it fails closed.
`GOOGLE_GMAIL_REDIRECT_URI` is also checked at boot: its path must equal
`/api/connect/oauth/google/callback` exactly, or the service refuses to start.
A redirect URI that points at a path this service does not serve is accepted by
Google and then fails on Google's own error page at the moment a user consents,
where no Paperclip log can see it.
`GOOGLE_GMAIL_CONNECTOR_ENABLED` is the kill switch, and it is off unless it is
set to `true`, `1`, `yes`, or `on` (case-insensitive). While it is off, every
`/api/connect` route answers `503 CONNECTOR_DISABLED` without touching the
database or Google.
Set `CONNECTOR_ENVIRONMENT` explicitly in every environment. Every signed
Set `CLOUD_HARNESS_CONNECTOR_ENVIRONMENT` explicitly in every environment. Every signed
connector request declares its own environment, and the broker accepts the
request only when that value matches both this deployment's environment and the
environment recorded on the enrolled instance. That three-way match is what
makes a leaked staging instance key inert against production, so it must equal
the instance's `PAPERCLIP_ID_CONNECTOR_ENVIRONMENT`.
the instance's `PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT`.
Do not rely on the fallback. When `CONNECTOR_ENVIRONMENT` is unset, the broker
derives the value from the `BASE_URL` host (`id` to production, `id-staging` to
staging, anything else to development). A staging or production deployment on
any other hostname therefore brokers as `development`, and every signed request
from a correctly configured instance fails the environment check. The value is
never derived from `NODE_ENV`, which is `production` on staging too.
Paperclip Cloud derives a safe development, staging, or production fallback
from its own customer origin, but the explicit value makes environment
isolation reviewable and avoids a custom hostname being treated as development.
The value is never derived from `NODE_ENV`.
## Connector request requirements
The Gmail authorization request must use:
- the Gmail connector client, not the Google sign-in client;
- `/api/connect/oauth/google/callback` on Paperclip ID;
- `/v1/connector/oauth/google/callback` on Paperclip Cloud;
- `response_type=code`;
- the two exact Gmail scopes above;
- `access_type=offline`;
@ -200,7 +191,8 @@ personal connection grant inactive and let the user retry deliberately.
No access token, refresh token, Google authorization code, client secret, or
token fragment may appear in a browser URL. The browser return from Paperclip
ID to the originating instance contains only an opaque one-time claim id.
Cloud to the originating instance contains only an opaque one-time claim id and
the instance's local state.
## Token custody and instance enrollment
@ -210,20 +202,21 @@ The expected flow is:
sequenceDiagram
actor U as User browser
participant P as Originating Paperclip instance
participant I as Paperclip ID connector
participant C as Paperclip Cloud connector
participant G as Google OAuth
participant V as Instance encrypted vault
U->>P: Apps → Gmail → Connect
P->>I: Signed, environment-bound authorization session
I-->>U: Google authorization URL with state and PKCE
P->>C: Signed, environment-bound authorization session
C-->>U: Existing Cloud login and destination confirmation
C-->>U: Google authorization URL with state and PKCE
U->>G: Grant Gmail read and draft access
G-->>I: Authorization code
I->>G: Exchange with the Gmail client secret
I-->>U: Opaque one-time claim for the enrolled instance
G-->>C: Authorization code at the fixed Cloud callback
C->>G: Exchange with the Gmail client secret
C-->>U: Opaque one-time claim for the enrolled instance
U->>P: Return to exact enrolled instance URL
P->>I: Signed one-time claim
I-->>P: Instance-encrypted token response
P->>C: Signed one-time claim
C-->>P: Instance-encrypted token response
P->>V: Encrypt tokens and bind them to the user's grant
```
@ -231,20 +224,32 @@ Before an instance can create a session:
1. The instance generates an Ed25519 signing key and a separate X25519 seal
key. Both private keys stay local; Ed25519 authenticates requests and
X25519 lets Paperclip ID encrypt token responses that only the instance can
X25519 lets Paperclip Cloud encrypt token responses that only the instance can
open.
2. An operator signs in to Paperclip ID and enrolls the instance.
3. Paperclip ID binds the account, opaque instance id, both public keys,
2. An instance administrator signs in to Paperclip Cloud through its existing
Paperclip ID OIDC login and enrolls the instance. Enrollment is
instance-global: ordinary company membership cannot start it, and the
initiating administrator must complete the return callback.
3. Paperclip Cloud binds the account, opaque instance id, both public keys,
deployment environment, and exact allowed browser return origins.
4. Tailscale HTTPS origins are allowed only when explicitly enrolled. Loopback
HTTP is development-only. Other plaintext origins are rejected.
5. Create, claim, refresh, and revoke requests are signed, audience-bound,
5. Create, claim, refresh, and supported revoke requests are signed, audience-bound,
timestamped, and protected by a one-time `jti` replay cache.
Paperclip ID may retain instance-encrypted initial-token ciphertext for at most
five minutes. It deletes the ciphertext on claim or expiry and excludes it from
long-term backups. Refresh and revoke handle plaintext only in memory for one
bounded request.
Paperclip Cloud may retain instance-encrypted initial-token ciphertext for at most
five minutes. It binds the first claim to a stable local redemption id and only
returns the same ciphertext to that redemption id during the retry window. It
deletes the ciphertext on expiry and excludes it from long-term backups. Refresh
and supported revoke operations handle plaintext only in memory for one bounded
request.
Removing one managed Google profile revokes only the local Paperclip grant.
Paperclip does not call Google's token revocation endpoint for that action.
Google treats revocation as client-wide for the user, so a provider-side revoke
could also invalidate the user's other managed Gmail, Drive, and Calendar
profiles. A future provider-level disconnect must present that all-profiles
effect explicitly.
### Configure each originating Paperclip instance
@ -252,30 +257,43 @@ Generate the two long-lived instance keys once. PEM-encoded PKCS#8 keys work
directly with Paperclip:
```sh
openssl genpkey -algorithm ED25519 -out paperclip-id-signing.pem
openssl genpkey -algorithm X25519 -out paperclip-id-sealing.pem
openssl pkey -in paperclip-id-signing.pem -pubout -out paperclip-id-signing.pub.pem
openssl pkey -in paperclip-id-sealing.pem -pubout -out paperclip-id-sealing.pub.pem
openssl genpkey -algorithm ED25519 -out paperclip-cloud-signing.pem
openssl genpkey -algorithm X25519 -out paperclip-cloud-sealing.pem
openssl pkey -in paperclip-cloud-signing.pem -pubout -out paperclip-cloud-signing.pub.pem
openssl pkey -in paperclip-cloud-sealing.pem -pubout -out paperclip-cloud-sealing.pub.pem
```
Keep both private files in the instance secret manager. Enroll only the public
files with Paperclip ID, together with the instance id, the matching environment,
files with Paperclip Cloud, together with the instance id, the matching environment,
and every exact browser return origin. Then configure the originating Paperclip
deployment:
| Variable | Development | Staging | Production |
| --- | --- | --- | --- |
| `PAPERCLIP_ID_CONNECTOR_BASE_URL` | Local Paperclip ID URL | `https://id-staging.paperclip.app` | `https://id.paperclip.app` |
| `PAPERCLIP_ID_CONNECTOR_ENVIRONMENT` | `development` | `staging` | `production` |
| `PAPERCLIP_ID_CONNECTOR_INSTANCE_ID` | Enrolled development instance id | Enrolled staging instance id | Enrolled production instance id |
| `PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY` | Development Ed25519 private key | Staging Ed25519 private key | Production Ed25519 private key |
| `PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY` | Development X25519 private key | Staging X25519 private key | Production X25519 private key |
| `PAPERCLIP_CLOUD_CONNECTOR_BASE_URL` | Local Paperclip Cloud URL | `https://my-staging.paperclip.app` | `https://my.paperclip.app` |
| `PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT` | `development` | `staging` | `production` |
| `PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID` | Enrolled development instance id | Enrolled staging instance id | Enrolled production instance id |
| `PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY` | Development Ed25519 private key | Staging Ed25519 private key | Production Ed25519 private key |
| `PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY` | Development X25519 private key | Staging X25519 private key | Production X25519 private key |
Use separate keypairs and instance enrollments across environments. The
connector is unavailable unless all four identity/key variables are present.
HTTP is accepted only for a loopback Paperclip ID URL; staging and production
HTTP is accepted only for a loopback Paperclip Cloud URL; staging and production
must use HTTPS.
Cloud-hosted stacks receive these values automatically through the existing
per-stack secret-reference delivery path. Self-hosted instances normally use
the Apps enrollment action instead of running the OpenSSL commands manually;
it generates the keys and writes them to the ignored instance secret directory
with owner-only permissions.
`PAPERCLIP_ID_CONNECTOR_*` values are not aliases for this protocol. Paperclip
ID used different endpoints, signing metadata, envelope purposes, and Google
client credentials. An instance with only those legacy values fails with
`CONNECTOR_MIGRATION_REQUIRED`. Enroll it with Paperclip Cloud and reconnect
each legacy Google grant. Cloud-hosted fleets must deliver the new enrollment
keys before they deploy a binary that enables the Cloud connector.
## Paperclip access defaults
The first Gmail release is personal-only:
@ -297,8 +315,8 @@ The first Gmail release is personal-only:
### Development
1. Enable the connector only in development.
2. Confirm the broker's `CONNECTOR_ENVIRONMENT` and the instance's
`PAPERCLIP_ID_CONNECTOR_ENVIRONMENT` both read `development`. A mismatch
2. Confirm the broker's `CLOUD_HARNESS_CONNECTOR_ENVIRONMENT` and the instance's
`PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT` both read `development`. A mismatch
fails every signed request with an environment error before Google is ever
contacted, which looks nothing like a Google misconfiguration.
3. Use an isolated Gmail test mailbox.
@ -343,9 +361,9 @@ seven-day testing-token expiry.
| Test user cannot consent | The account is listed under the environment project's Audience test users and is enrolled in Workspace Developer Preview. |
| Refresh fails after seven days | The external app is still in Testing. Reauthorize the test user; do not treat this as token-rotation failure. |
| One required capability is missing | Inspect the returned granted scope set. Keep the grant inactive if either exact required scope is absent. |
| Local or Tailscale return is rejected | Enroll the exact origin on Paperclip ID. Only loopback HTTP is allowed; Tailscale must use HTTPS. |
| Every signed request fails on environment | The broker's `CONNECTOR_ENVIRONMENT`, the enrolled instance record, and the instance's `PAPERCLIP_ID_CONNECTOR_ENVIRONMENT` must all agree. An unset broker value is derived from the `BASE_URL` host and silently becomes `development`. |
| Every `/api/connect` route returns 503 | `GOOGLE_GMAIL_CONNECTOR_ENABLED` is not one of `true`, `1`, `yes`, or `on`. The response is `CONNECTOR_DISABLED`; no database or Google call is attempted. |
| Local or Tailscale return is rejected | Enroll the exact origin on Paperclip Cloud. Only loopback HTTP is allowed; Tailscale must use HTTPS. |
| Every signed request fails on environment | `CLOUD_HARNESS_CONNECTOR_ENVIRONMENT`, the enrollment record, and `PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT` must agree. |
| The managed method is unavailable | Confirm the exact profile is in `CLOUD_HARNESS_CONNECTOR_GOOGLE_ENABLED_PROFILES` and its client id and secret reference are configured. |
| Login starts asking for Gmail | Stop the rollout. The login and Gmail clients or route namespaces have been mixed. |
| Connector is unavailable | Keep the grant in `needs_reauthorization` or an actionable unavailable state. Never use a login token or another environment's client. |

View File

@ -20,7 +20,7 @@ Workspace Search.
Google's hosted Workspace MCP servers are Developer Preview services. The app
cards remain independent even when several services use the same customer-owned
Google OAuth client or the same Paperclip ID broker deployment.
Google OAuth client or the same Paperclip Cloud broker deployment.
## Developer Preview enrollment
@ -63,8 +63,8 @@ Google makes Workspace MCP generally available.
The setup flow asks for the capability first. It then offers the authentication
methods available for that capability:
- **Connect with Paperclip** uses the Paperclip ID broker when that exact
profile is advertised by `GET /api/connect/capabilities`.
- **Connect with Paperclip** uses the Paperclip Cloud broker when that exact
profile is advertised by `GET https://my.paperclip.app/v1/connector/capabilities`.
- **Use your own Google OAuth app** uses customer-supplied OAuth credentials and
the app definition's exact reviewed scopes.
- **Use the Paperclip robot account** remains an additional Google Sheets-only
@ -78,7 +78,10 @@ changing the underlying Google principal.
The Paperclip-managed method signs every broker request with one explicit
profile. The broker binds that profile into sessions, one-time claims, sealed
token envelopes, refresh, and revocation.
token envelopes, and refresh. Per-profile removal is local-only for managed
Google grants. Google's revocation endpoint can invalidate all grants for the
same user and managed client, so Paperclip does not call it while removing one
Workspace profile.
| App | Read profile | Write profile |
| --- | --- | --- |
@ -92,19 +95,19 @@ token envelopes, refresh, and revocation.
| People | `people.read` | — |
| Workspace Search | `workspace-search.read` | — |
An older signed request without a profile remains compatible and resolves only
to `gmail.draft`. New clients always send a profile.
Every new signed request includes a profile. The Cloud broker rejects a request
whose provider, profile, or exact scope set does not match its closed registry.
## Instance configuration
All Paperclip-managed Google methods use the existing enrolled-instance keys:
```dotenv
PAPERCLIP_ID_CONNECTOR_BASE_URL=https://id.paperclip.app
PAPERCLIP_ID_CONNECTOR_ENVIRONMENT=production
PAPERCLIP_ID_CONNECTOR_INSTANCE_ID=inst_example
PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY=...
PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY=...
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL=https://my.paperclip.app
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT=production
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID=inst_example
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY=...
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY=...
```
No per-app client secret is stored on the Paperclip instance for the managed
@ -112,6 +115,13 @@ path. For customer-owned OAuth, the setup flow collects that customer's Google
OAuth client ID and secret and stores them through the normal instance-vault
path.
Cloud-hosted stacks receive these values through the existing per-stack secret
delivery path. A self-hosted instance creates its keys during enrollment and
stores them with owner-only permissions in the instance's ignored secret
directory. The former `PAPERCLIP_ID_CONNECTOR_*` values use an incompatible
Paperclip ID protocol and are not read aliases. Enroll with Paperclip Cloud and
reconnect legacy grants before their old access tokens expire.
The gallery requests the broker capability document with a short cache. A
Paperclip-managed method is omitted unless its exact profile is enabled at the
broker; the independent app card and customer-owned OAuth method remain

View File

@ -104,6 +104,216 @@ This is a local, credential-free handoff check performed through the real BOB ca
The audit found and fixed shared interoperability faults rather than adding provider exceptions: bounded provider-added DCR grants, RFC 7591 zero secret-expiry sentinels for public clients, authorization servers that explicitly omit refresh-token support, guarded HTTP requests that require a stable User-Agent, and numeric-loopback callbacks rejected by DCR servers. Hugging Face now explicitly requests only `read-mcp` instead of allowing the provider's omitted-scope default to request its complete scope set.
## Tailscale HTTPS OAuth compatibility audit — 2026-08-31
This audit used the isolated `apps-https-qa` full-clone instance on port 3102 at commit `5a988df600ebda30e446496862bf83c76d6d53d6`. The default instance remained on port 3100. Tailscale Serve mapped only `https:443` at `https://dottas-macbook-pro.tail29c1aa.ts.net` to `http://127.0.0.1:3102`; Funnel was not enabled. `PAPERCLIP_PUBLIC_URL` used that HTTPS origin, and no generic or provider-specific `PAPERCLIP_TOOL_OAUTH_*CLIENT*` override was present.
The dedicated `Apps HTTPS QA 2026-08-31` company had zero agents. Loopback and HTTPS health, bootstrap readiness, cloned source data, and browser access passed. The OAuth client-metadata document exposed exactly one redirect URI: `https://dottas-macbook-pro.tail29c1aa.ts.net/api/tools/oauth/callback`. Successful grants and unsuccessful drafts were retained; no provider grant was revoked and neither Paperclip server was stopped.
The credential-free preflight reached every one of the 19 automatic-OAuth endpoints, found OAuth metadata for all 19, and found DCR advertised for all 19. CIMD was also advertised by Notion, PostHog, Sentry, Jira, Airtable, Cloudflare, Hugging Face, Resend, and Todoist; the other ten did not advertise CIMD. The Tailscale hostname is tailnet-private and is not a public CIMD client ID. No app in this run was CIMD-only, so none received `public_cimd_retest_needed`.
The matrix has exactly 35 terminal rows: 19 automatic OAuth, 12 customer OAuth, and four non-OAuth. “Exact callback” below means `https://dottas-macbook-pro.tail29c1aa.ts.net/api/tools/oauth/callback`. No provider tool was invoked and no read or write action was run.
| # | App | Method / declared ownership | Credential-free preflight | Callback / registration source | Browser, callback/token, and catalog outcome | Prerequisite / Paperclip error code | Conclusion |
|---:|---|---|---|---|---|---|---|
| 1 | Notion | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; 37 actions discovered | Sole workspace selected; error code none | `works_out_of_box_dcr` |
| 2 | PostHog | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; 684 actions discovered | Default project access; error code none | `works_out_of_box_dcr` |
| 3 | Sentry | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; nine actions discovered | Existing account consent; error code none | `works_out_of_box_dcr` |
| 4 | Jira | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | DCR and provider consent loaded, but tenant policy rejected the callback domain before consent; no token or catalog | Tenant callback-domain policy; error code none | `callback_or_client_allowlist_rejected` |
| 5 | Airtable | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; 43 actions discovered | Sole visibly non-production base selected; error code none | `works_out_of_box_dcr` |
| 6 | Cloudflare | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` draft | Provider accepted the callback and reached account selection; no account was selected, so no token or catalog | Two plausible accounts; error code none | `resource_selection_needed` |
| 7 | Cloudinary | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; 30 actions discovered | Sole cloud selected; error code none | `works_out_of_box_dcr` |
| 8 | Hugging Face | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; five actions discovered | Organization access omitted; error code none | `works_out_of_box_dcr` |
| 9 | Miro | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` | Provider install relay returned through the callback; state and token exchange passed; active/healthy; 57 actions discovered | Sole preselected organization/team; error code none | `works_out_of_box_dcr` |
| 10 | Netlify | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` | Provider relay returned through the callback; state and token exchange passed; active/healthy; nine actions discovered | Existing account consent; error code none | `works_out_of_box_dcr` |
| 11 | Resend | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; 99 actions discovered | Sole team and sending-only access selected; error code none | `works_out_of_box_dcr` |
| 12 | Todoist | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD yes | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; 47 actions discovered | Existing account consent; error code none | `works_out_of_box_dcr` |
| 13 | Webflow | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` draft | Callback trust step passed; a provider sign-in route returned HTTP 502 before authorization; no token or catalog | Provider sign-in path; error code none | `provider_error_or_timeout` |
| 14 | Wix | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` draft | Provider sign-in loaded with the exact callback but did not advance through the available account sign-in; no token or catalog | Provider sign-in/anti-automation gate; error code none | `sign_in_mfa_or_captcha_blocked` |
| 15 | ClickHouse | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / no registration attempted | Paperclip required a ClickHouse Cloud service ID before OAuth; no callback, token, or catalog | Service selection required; error code none | `resource_selection_needed` |
| 16 | Mixpanel | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; 64 actions discovered | Existing account consent; error code none | `works_out_of_box_dcr` |
| 17 | Postman | US minimal hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` | Provider relay returned through the callback; state and token exchange passed; active/healthy; 41 actions discovered | Minimal catalog selected; error code none | `works_out_of_box_dcr` |
| 18 | Stripe | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / `dcr` | Provider returned through the callback; state and token exchange passed; active/healthy; ten actions discovered | Visibly labeled test environment and read-only access selected; error code none | `works_out_of_box_dcr` |
| 19 | Supabase | Recommended hosted OAuth / `dcr` | Reachable; OAuth metadata yes; DCR yes; CIMD no | Exact callback / no registration attempted | Paperclip required a project reference before OAuth; no callback, token, or catalog | Development project selection required; error code none | `resource_selection_needed` |
| 20 | Linear | Customer-created OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and a client ID; client secret is optional for a public client; OAuth not started | Customer-created Linear app; error code none | `preregistration_required_by_design` |
| 21 | Asana | Customer-created OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI states DCR is unsupported and requires preregistering the callback and a client ID; client secret is optional for a public client; OAuth not started | Customer-created Asana MCP app; error code none | `preregistration_required_by_design` |
| 22 | Box | Customer-created OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and a client ID; client secret is optional for a public client; OAuth not started | Box administrator, AI access, and customer-created app; error code none | `preregistration_required_by_design` |
| 23 | Gmail | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 24 | Google Drive | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 25 | Google Docs | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 26 | Google Sheets | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 27 | Google Slides | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 28 | Google Calendar | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 29 | Google Chat | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 30 | Google People | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 31 | Google Workspace Search | Google OAuth app / `customer` | Not applicable; UI contract inspected | Exact callback / customer client | UI requires preregistering the callback and customer client ID; client secret is optional for a public client; OAuth not started | Developer Preview enrollment, Cloud project/API enablement; error code none | `preregistration_required_by_design` |
| 32 | Zapier | Provider-generated MCP URL / non-OAuth | Not applicable | Not applicable | UI requires the complete provider-generated MCP URL, including its embedded token | Provider-generated URL; error code none | `not_applicable_non_oauth` |
| 33 | Shopify | Public Storefront MCP / no auth | Not applicable | Not applicable | UI identifies a public no-auth endpoint and requires a launched public storefront | Public storefront and permanent store domain; error code none | `not_applicable_non_oauth` |
| 34 | Mem0 | Bearer API key / non-OAuth | Not applicable | Not applicable | UI requires a customer-created API key | Customer API key; error code none | `not_applicable_non_oauth` |
| 35 | PagerDuty | API token / non-OAuth | Not applicable | Not applicable | UI requires a customer-created API token and regional endpoint | Customer token and account region; error code none | `not_applicable_non_oauth` |
Summary counts:
- `works_out_of_box_dcr`: 13 — Notion, PostHog, Sentry, Airtable, Cloudinary, Hugging Face, Miro, Netlify, Resend, Todoist, Mixpanel, Postman minimal, and Stripe.
- `preregistration_required_by_design`: 12 — Linear, Asana, Box, and the nine Google Workspace cards.
- `callback_or_client_allowlist_rejected`: 1 — Jira. This is the only newly observed stable-callback/allowlist candidate.
- `resource_selection_needed`: 3 — Cloudflare, ClickHouse, and Supabase.
- `provider_error_or_timeout`: 1 — Webflow.
- `sign_in_mfa_or_captcha_blocked`: 1 — Wix.
- `not_applicable_non_oauth`: 4 — Zapier, Shopify, Mem0, and PagerDuty.
- `manual_client_required`, `oauth_passed_catalog_failed`, `account_or_plan_prerequisite`, and `public_cimd_retest_needed`: 0 each.
The evidence supports arbitrary HTTPS callbacks through DCR for the 13 passing apps. Jira is the only automatic-OAuth app in this run that produced direct callback-domain allowlist evidence. The five inconclusive automatic-OAuth results are resource- or sign-in/provider-path blockers, not evidence that they require a shared stable callback. The 12 customer-client apps already require preregistration by design and are not newly discovered stable-callback candidates.
## Paperclip Cloud managed OAuth broker — 2026-08-31
The managed-callback P2 is part of the existing Paperclip Cloud application at
`my.paperclip.app`. It does not add a service, hostname, repository, login
system, or provider route to Paperclip ID. Paperclip ID authenticates the user
for the existing Cloud customer session. Cloud owns fixed provider callbacks,
provider client credentials, enrollment, explicit destination confirmation,
code exchange, refresh, and revocation. The originating Paperclip instance is
the only durable provider-token vault and continues to execute provider tools
directly.
The production Google callback is fixed at
`https://my.paperclip.app/v1/connector/oauth/google/callback`; the reserved Box
path remains dark until Paperclip owns a distributable, provider-approved Box
application. Provider endpoints, clients, profiles, exact scope sets, resource
servers, eligibility, approval state, and kill switches come from a closed
Cloud registry. A caller cannot supply any of them.
### Self-hosted enrollment
```mermaid
sequenceDiagram
actor U as Self-hosted administrator
participant P as Self-hosted Paperclip
participant C as Paperclip Cloud
participant I as Paperclip ID
U->>P: Enable Paperclip-managed connections
P->>P: Generate Ed25519 signing and X25519 sealing keys
P->>C: Create enrollment draft with public keys and exact origin
C-->>P: Short-lived verification URL
P-->>U: Open my.paperclip.app/connections/enroll
U->>C: Review exact destination
alt No current Cloud session
C->>I: Existing Cloud OIDC login
I-->>C: Existing identity-only callback
end
U->>C: Confirm enrollment
C-->>U: One-time approval code to the confirmed origin
U->>P: Enrollment callback with local state
P->>C: Redeem with signing-key proof
C-->>P: Enrollment id, audiences, origin, and capabilities
P->>P: Store private keys in ignored owner-only instance secrets
```
Cloud-hosted stacks are enrolled automatically during provisioning. Existing
stacks receive a lazy backfill on their next managed deployment or first
managed-connection roll. The existing per-stack secret store mints and retains
the private keypairs, the existing provider delivery path places their values
in the tenant environment, and the Cloud connector registry stores only public
keys and the exact active stack origin.
Every self-hosted enrollment requires HTTPS except exact HTTP loopback. The
Cloud confirmation page prominently displays the normalized destination.
Unknown, replayed, and expired enrollment state terminates on Cloud without an
instance redirect. Tailscale HTTPS works because the browser performs the
return trip; Cloud does not need to reach the private hostname.
### Provider connection and custody
```mermaid
sequenceDiagram
actor U as Connecting user
participant P as Originating Paperclip instance
participant C as my.paperclip.app
participant I as Paperclip ID
participant O as Provider OAuth
participant V as Instance vault
U->>P: Connect a managed app/profile
P->>P: Bind local state to connection, company, user, and profile
P->>C: Signed authorization-session request
C-->>P: Cloud confirmation URL
P-->>U: Open confirmation URL
alt No current Cloud session
C->>I: Existing Cloud OIDC login
I-->>C: Existing Cloud OIDC callback
end
C-->>U: Show provider, scopes, destination, and custody notice
U->>C: Continue
C-->>U: Provider authorization URL with Cloud state and PKCE
U->>O: Choose the resource account and consent
O-->>C: Code and state at the fixed Cloud callback
C->>O: Exchange with the profile's managed client
O-->>C: Access and refresh credentials
C->>C: Validate exact profile scopes and seal to the instance key
C-->>U: 303 with opaque claim id and local state
U->>P: Instance callback
P->>C: Signed one-time claim redemption
C-->>P: X25519-sealed credential envelope
P->>P: Verify every binding and decrypt
P->>V: Store tokens on the personal grant
P->>P: Activate and discover the provider catalog
```
Only the opaque claim id and the instance's local state cross the browser back
to the instance. Provider codes, tokens, error descriptions, client secrets,
emails, and tenant identifiers do not appear in URLs. Cloud consumes provider
state before examining any code or provider error; unknown and replayed state
never redirects. Initial ciphertext expires within five minutes. The first
claim binds it to a stable local redemption id, and only that id can retry the
same sealed envelope before expiry. Plaintext provider tokens exist in Cloud
memory only for the bounded exchange, refresh, or supported revocation request.
```mermaid
sequenceDiagram
participant P as Paperclip instance
participant C as Paperclip Cloud
participant O as Provider OAuth
P->>C: Signed refresh with hash-bound refresh token
C->>O: Refresh using the managed provider client
O-->>C: Rotated credentials
C-->>P: New credentials sealed to the instance key
P->>P: Validate bindings and rotate vault secrets
opt Provider proves isolated per-grant revocation
P->>C: Signed revocation with hash-bound token
C->>O: Revoke provider grant
C-->>P: Detail-free result
end
```
Managed grants therefore depend on Cloud for refresh and supported provider
revocation, but not for provider tool calls. Managed Google profile removal is
local-only because Google client-wide revocation can invalidate the same user's
other Workspace profiles. A temporary Cloud outage leaves an existing access
token usable until it expires and then surfaces as a temporary refresh failure.
Customer-created clients remain available for self-hosters who need full
independence or for providers not approved for a managed multi-tenant client.
### Security and rollout boundary
Every instance operation requires an enrolled Ed25519 signature, exact
audience/environment/provider/profile/scope binding, a short expiry, and a
single-use request id. Credential envelopes bind their purpose plus the same
instance, environment, provider, profile, and sorted scope set as AEAD
additional data. Exact origin matching has no wildcard, prefix, or suffix
mode. Cloud login and explicit destination confirmation are required for every
new authorization. Per-IP, account, instance, and provider limits, instance
suspension, profile kill switches, and self-host eligibility checks provide
additional containment; no generic OAuth endpoint or provider API proxy exists.
All real profiles are dark-launched. Google begins with an internal
`gmail.read` pilot only after Developer Preview enrollment, OAuth verification,
and any restricted-scope assessment. Jira remains the next callback-allowlist
research target. The 13 proven DCR providers stay instance-local, and Box stays
customer-client-only until publication, commercial, enterprise-admin, and
self-host distribution terms are approved.
## Automated acceptance
- [x] Manifest tests assert 46 researched entries, 43 self-serve candidates, three blocked providers, unique slugs, HTTPS documentation/endpoints, authentication mode, prerequisite, risk tier, and verification date.

View File

@ -173,7 +173,7 @@ describe("AppDefinition catalog",()=>{
for(const [slug,serverUrl] of Object.entries(expected)){
const app=APP_DEFINITIONS.find((candidate)=>candidate.slug===slug);
expect(app,slug).toBeTruthy();
expect(app?.methods.some((method)=>method.oauthStrategy==="paperclip_id_connector")).toBe(true);
expect(app?.methods.some((method)=>method.oauthStrategy==="paperclip_cloud_connector")).toBe(true);
for(const method of app?.methods.filter((candidate)=>candidate.auth==="oauth")??[]){
expect(method.grantKinds,`${slug}:${method.key}`).toEqual(["user"]);
expect(method.defaults?.serverUrl,`${slug}:${method.key}`).toBe(serverUrl);
@ -183,7 +183,7 @@ describe("AppDefinition catalog",()=>{
});
it("advertises managed Gmail methods with their profile-scoped grants",()=>{
const gmail=APP_DEFINITIONS.find((app)=>app.slug==="gmail");
const managedMethods=gmail?.methods.filter((method)=>method.oauthStrategy==="paperclip_id_connector")??[];
const managedMethods=gmail?.methods.filter((method)=>method.oauthStrategy==="paperclip_cloud_connector")??[];
expect(managedMethods.map((method)=>method.key)).toEqual(["paperclip-read","paperclip-draft"]);
expect(managedMethods.map((method)=>[method.connectorProfile,method.defaults?.scopesHint])).toEqual([
["gmail.read",["https://www.googleapis.com/auth/gmail.readonly"]],

View File

@ -141,7 +141,7 @@ export function getAvailableConnectionMethod(
export function connectionMethodSupportsAutomaticOAuth(method: ConnectionMethodDef | null | undefined): boolean {
return method?.auth === "oauth" && (
method.oauthStrategy === "paperclip_id_connector"
(method.oauthStrategy === "paperclip_cloud_connector" || method.oauthStrategy === "paperclip_id_connector")
|| method.ownershipModes.includes("dcr")
);
}

View File

@ -33,7 +33,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "gmail.read",
"capabilityProfile": {
"key": "read",
@ -104,7 +104,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "gmail.draft",
"capabilityProfile": {
"key": "draft",

View File

@ -33,7 +33,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "calendar.read",
"capabilityProfile": {
"key": "read",
@ -108,7 +108,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "calendar.write",
"capabilityProfile": {
"key": "write",

View File

@ -34,7 +34,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "chat.read",
"capabilityProfile": {
"key": "read",
@ -112,7 +112,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "chat.write",
"capabilityProfile": {
"key": "write",

View File

@ -34,7 +34,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "docs.read",
"capabilityProfile": {
"key": "read",
@ -107,7 +107,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "docs.write",
"capabilityProfile": {
"key": "write",

View File

@ -34,7 +34,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "drive.read",
"capabilityProfile": {
"key": "read",
@ -105,7 +105,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "drive.write",
"capabilityProfile": {
"key": "write",

View File

@ -33,7 +33,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "people.read",
"capabilityProfile": {
"key": "read",

View File

@ -24,7 +24,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "sheets.read",
"capabilityProfile": {
"key": "read",
@ -97,7 +97,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "sheets.write",
"capabilityProfile": {
"key": "write",

View File

@ -34,7 +34,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "slides.read",
"capabilityProfile": {
"key": "read",
@ -107,7 +107,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "slides.write",
"capabilityProfile": {
"key": "write",

View File

@ -33,7 +33,7 @@
"label": "Connect with Paperclip",
"transport": "mcp_remote",
"auth": "oauth",
"oauthStrategy": "paperclip_id_connector",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "workspace-search.read",
"capabilityProfile": {
"key": "read",

View File

@ -2,7 +2,7 @@ import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionTransp
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
export type OAuthRedirectConstraints = "https-or-loopback-http";
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record<string,unknown>}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export type SelfServeMcpAuthMode =

View File

@ -5,6 +5,6 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
try{return new URL(value).protocol==="https:";}catch{return false;}
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy==="paperclip_id_connector"&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip ID connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&v.oauthStrategy!=="paperclip_id_connector")c.addIssue({code:"custom",message:"connectorProfile requires the Paperclip ID OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})});
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});

View File

@ -63,8 +63,8 @@ import type { ComposioClient } from "../services/composio.js";
import type { VercelConnectClient } from "../services/vercel-connect.js";
import {
GMAIL_CONNECTOR_SCOPES,
type PaperclipIdGmailConnector,
} from "../services/paperclip-id-gmail-connector.js";
type PaperclipCloudConnector,
} from "../services/paperclip-cloud-connector.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -85,7 +85,7 @@ function createTestToolAccessService(
});
}
function fakeGmailConnector(companyId: string, userId: string): PaperclipIdGmailConnector {
function fakeGmailConnector(companyId: string, userId: string): PaperclipCloudConnector {
const credentials = {
v: 1 as const,
accessToken: "gmail-access-token",
@ -95,8 +95,13 @@ function fakeGmailConnector(companyId: string, userId: string): PaperclipIdGmail
scopes: [...GMAIL_CONNECTOR_SCOPES],
subject: userId,
companyId,
instanceId: "test-instance",
environment: "development" as const,
provider: "google" as const,
profile: "gmail.draft",
};
return {
getCapabilities: vi.fn(async () => ["gmail.draft" as const]),
startAuthorization: vi.fn(async ({ returnState }) => ({
authorizationUrl: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(returnState)}`,
expiresAt: new Date(Date.now() + 600_000).toISOString(),
@ -4822,8 +4827,9 @@ describeEmbeddedPostgres("tool access service", () => {
const userId = `gmail-member-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, []);
const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
const connector = fakeGmailConnector(company.id, userId);
const service = createTestToolAccessService(callbackDb, {
paperclipIdGmailConnector: fakeGmailConnector(company.id, userId),
paperclipCloudConnector: connector,
});
const actor = { actorType: "user" as const, actorId: userId };
const gmailDefinition = getConnectableAppDefinition("gmail")!;
@ -4841,13 +4847,13 @@ describeEmbeddedPostgres("tool access service", () => {
name: "Gmail single-pool callback",
}, actor);
const started = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: "https://paperclip.example/api/tools/oauth/paperclip-id/callback",
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback",
actor,
});
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
const completed = await Promise.race([
service.completePaperclipIdGmailCallback({ state, claimId: "gmail-claim", actor }),
service.completePaperclipCloudConnectorCallback({ state, claimId: "gmail-claim", actor }),
new Promise<never>((_resolve, reject) => {
deadline = setTimeout(() => {
void callbackDb.$client.end({ timeout: 0 })
@ -4867,6 +4873,9 @@ describeEmbeddedPostgres("tool access service", () => {
"oauth.access_token",
"oauth.refresh_token",
]);
await expect(service.revokeConnectionGrant(connected.connectionId, grant!.id, actor))
.resolves.toMatchObject({ status: "revoked" });
expect(connector.revoke).not.toHaveBeenCalled();
} finally {
gmailDefinition.ownershipAvailability = previousOwnershipAvailability;
if (deadline) clearTimeout(deadline);
@ -4874,6 +4883,57 @@ describeEmbeddedPostgres("tool access service", () => {
}
}, 15_000);
it("keeps brokered OAuth state retryable until credentials are durably stored", async () => {
const company = await createCompany(db);
const userId = `gmail-retry-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, []);
const connector = fakeGmailConnector(company.id, userId);
vi.mocked(connector.claim)
.mockRejectedValueOnce(new Error("temporary claim failure"));
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
const actor = { actorType: "user" as const, actorId: userId };
const gmailDefinition = getConnectableAppDefinition("gmail")!;
const previousOwnershipAvailability = gmailDefinition.ownershipAvailability;
gmailDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true };
mockToolsList([]);
try {
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "gmail",
connectionMethodKey: "paperclip-draft",
grantKind: "user",
name: "Gmail retryable callback",
}, actor);
const started = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback",
actor,
});
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
await expect(service.completePaperclipCloudConnectorCallback({
state,
claimId: "gmail-retry-claim",
actor,
})).rejects.toThrow("temporary claim failure");
await expect(service.peekOAuthState(state)).resolves.toMatchObject({
companyId: company.id,
connectionId: connected.connectionId,
subjectUserId: userId,
});
await expect(service.completePaperclipCloudConnectorCallback({
state,
claimId: "gmail-retry-claim",
actor,
})).resolves.toMatchObject({ connection: { status: "active", enabled: true } });
await expect(service.peekOAuthState(state)).resolves.toBeNull();
expect(connector.claim).toHaveBeenNthCalledWith(1, expect.objectContaining({ redemptionId: state }));
expect(connector.claim).toHaveBeenNthCalledWith(2, expect.objectContaining({ redemptionId: state }));
} finally {
gmailDefinition.ownershipAvailability = previousOwnershipAvailability;
}
});
it("serializes brokered Gmail OAuth completion behind membership revocation", async () => {
const company = await createCompany(db);
const userId = `gmail-member-${randomUUID()}`;
@ -4881,7 +4941,7 @@ describeEmbeddedPostgres("tool access service", () => {
const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
const service = createTestToolAccessService(callbackDb, {
paperclipIdGmailConnector: fakeGmailConnector(company.id, userId),
paperclipCloudConnector: fakeGmailConnector(company.id, userId),
});
const actor = { actorType: "user" as const, actorId: userId };
const gmailDefinition = getConnectableAppDefinition("gmail")!;
@ -4906,7 +4966,7 @@ describeEmbeddedPostgres("tool access service", () => {
name: "Gmail concurrent revocation callback",
}, actor);
const started = await service.startOAuth(company.id, connected.connectionId, {
redirectUri: "https://paperclip.example/api/tools/oauth/paperclip-id/callback",
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback",
actor,
});
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
@ -4935,7 +4995,7 @@ describeEmbeddedPostgres("tool access service", () => {
});
await membershipIsLocked;
const completion = service.completePaperclipIdGmailCallback({
const completion = service.completePaperclipCloudConnectorCallback({
state,
claimId: "gmail-claim",
actor,

View File

@ -7590,11 +7590,46 @@ registerCurrentRoute({
summary: "Handle a tool app OAuth callback",
});
registerCurrentRoute({
method: "get",
path: "/api/tools/oauth/cloud-connector/callback",
tags: ["tool-access"],
summary: "Handle a brokered Paperclip Cloud OAuth callback",
});
registerCurrentRoute({
method: "get",
path: "/api/tools/oauth/paperclip-id/callback",
tags: ["tool-access"],
summary: "Handle a brokered Paperclip ID OAuth callback",
summary: "Handle a legacy brokered Paperclip ID OAuth callback",
});
registerCurrentRoute({
method: "get",
path: "/api/tools/oauth/cloud-connector/enrollment",
tags: ["tool-access"],
summary: "Get Paperclip Cloud connector enrollment status",
});
registerCurrentRoute({
method: "post",
path: "/api/tools/oauth/cloud-connector/enrollment",
tags: ["tool-access"],
summary: "Start Paperclip Cloud connector enrollment",
body: z.object({ companyId: z.string().min(1), label: z.string().optional() }).strict(),
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registerCurrentRoute({
method: "get",
path: "/api/tools/oauth/cloud-connector/enrollment-callback",
tags: ["tool-access"],
summary: "Complete Paperclip Cloud connector enrollment",
query: z.object({
enrollment_id: z.string().min(1),
approval_code: z.string().min(1),
state: z.string().min(1),
}).strict(),
});
registerCurrentRoute({

View File

@ -1,4 +1,4 @@
import { Router, type Request } from "express";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
import { agents, companies, connectionGrants, issueThreadInteractions, toolConnectionInstalls } from "@paperclipai/db";
import { and, eq, or } from "drizzle-orm";
@ -47,13 +47,22 @@ import {
updateToolProfileWithEntriesSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { getActorInfo, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { getActorInfo, assertBoard, assertCompanyAccess, assertInstanceAdmin, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { accessService, logActivity, toolAccessPolicyService, toolAccessService, vercelConnectIntegrationStatus } from "../services/index.js";
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
import type { ComposioClient } from "../services/composio.js";
import type { VercelConnectClient } from "../services/vercel-connect.js";
import { paperclipIdGoogleConnectorCapabilitiesFromEnv } from "../services/paperclip-id-gmail-connector.js";
import {
isPaperclipCloudConnectorStrategy,
paperclipCloudConnectorCapabilitiesFromEnv,
} from "../services/paperclip-cloud-connector.js";
import {
completePaperclipCloudConnectorEnrollment,
loadPaperclipCloudConnectorIdentity,
startPaperclipCloudConnectorEnrollment,
} from "../services/paperclip-cloud-connector-enrollment.js";
import { reconcilePaperclipCloudConnectorEnrollmentStatus } from "../services/paperclip-cloud-connector-status.js";
import {
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
oauthClientIdMetadataDocument,
@ -336,9 +345,13 @@ export function toolAccessRoutes(
.limit(1);
if (!company) throw new Error("OAuth callback connection belongs to a missing company");
return `/${company.issuePrefix}/apps/${connectionId}/${tab}`;
}
}
/**
function connectorEnrollmentPrincipal(req: Request): string {
return req.actor.userId ? `user:${req.actor.userId}` : `source:${req.actor.source ?? "board"}`;
}
/**
* A failed first authorization is still an incomplete setup, not an app
* configuration task. Send it back to the same exact draft so the operator
* can retry the missing checkpoint. Reauthorization of an already-active
@ -662,7 +675,7 @@ export function toolAccessRoutes(
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const googleConnectorProfiles = new Set(await paperclipIdGoogleConnectorCapabilitiesFromEnv());
const googleConnectorProfiles = new Set(await paperclipCloudConnectorCapabilitiesFromEnv());
const vercelConnect = vercelConnectIntegrationStatus();
res.json({
capabilities: await describeConnectionCreateCapabilities(req, companyId),
@ -681,7 +694,7 @@ export function toolAccessRoutes(
},
apps: APP_STORE_DEFINITIONS.map((app) => {
const methods = app.methods.filter((method) =>
method.oauthStrategy !== "paperclip_id_connector"
!isPaperclipCloudConnectorStrategy(method.oauthStrategy)
|| Boolean(method.connectorProfile && googleConnectorProfiles.has(method.connectorProfile as never))
);
return {
@ -689,7 +702,7 @@ export function toolAccessRoutes(
methods,
ownershipAvailability: {
...DEFAULT_OWNERSHIP_AVAILABILITY,
platform_shared: methods.some((method) => method.oauthStrategy === "paperclip_id_connector"),
platform_shared: methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy)),
},
};
}),
@ -826,7 +839,76 @@ export function toolAccessRoutes(
res.json(result);
});
router.get("/tools/oauth/paperclip-id/callback", async (req, res) => {
router.get("/tools/oauth/cloud-connector/enrollment", async (req, res) => {
assertBoard(req);
res.json(await reconcilePaperclipCloudConnectorEnrollmentStatus());
});
router.post("/tools/oauth/cloud-connector/enrollment", async (req, res) => {
assertInstanceAdmin(req);
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : "";
if (!companyId) throw badRequest("Paperclip Cloud enrollment requires a company");
assertCompanyAccess(req, companyId);
const origin = new URL(oauthRedirectUri(req)).origin;
let status;
try {
status = await startPaperclipCloudConnectorEnrollment({
origin,
companyId,
initiatedBy: connectorEnrollmentPrincipal(req),
label: typeof req.body?.label === "string" ? req.body.label : undefined,
});
} catch {
throw unprocessable("Paperclip Cloud enrollment could not be started", {
code: "paperclip_cloud_connector_enrollment_failed",
});
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "paperclip_cloud_connector.enrollment_started",
entityType: "connector_instance",
entityId: status.instanceId ?? "pending",
details: { environment: status.environment, status: status.status },
});
res.status(201).json(status);
});
router.get("/tools/oauth/cloud-connector/enrollment-callback", async (req, res) => {
assertInstanceAdmin(req);
const enrollmentId = typeof req.query.enrollment_id === "string" ? req.query.enrollment_id : "";
const approvalCode = typeof req.query.approval_code === "string" ? req.query.approval_code : "";
const state = typeof req.query.state === "string" ? req.query.state : "";
if (!enrollmentId || !approvalCode || !state) throw badRequest("Invalid Paperclip Cloud enrollment callback");
const pending = loadPaperclipCloudConnectorIdentity()?.pending;
if (pending?.companyId && !hasCompanyAccess(req, pending.companyId)) {
throw notFound("Paperclip Cloud enrollment not found");
}
if (pending?.initiatedBy && pending.initiatedBy !== connectorEnrollmentPrincipal(req)) {
throw notFound("Paperclip Cloud enrollment not found");
}
let status;
try {
status = await completePaperclipCloudConnectorEnrollment({ enrollmentId, approvalCode, state });
} catch {
throw badRequest("Invalid or expired Paperclip Cloud enrollment callback");
}
if (pending?.companyId) {
await logActivity(db, {
companyId: pending.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "paperclip_cloud_connector.enrollment_completed",
entityType: "connector_instance",
entityId: status.instanceId ?? enrollmentId,
details: { environment: status.environment, status: status.status },
});
}
res.redirect(303, "/apps/connections?cloud_connector=enrolled");
});
const handlePaperclipCloudConnectorCallback = async (req: Request, res: Response) => {
assertBoard(req);
const state = typeof req.query.state === "string" ? req.query.state : "";
const claimId = typeof req.query.claim_id === "string" ? req.query.claim_id : null;
@ -844,7 +926,7 @@ export function toolAccessRoutes(
}
const acceptsHtml = req.get("accept")?.includes("text/html") === true;
try {
const result = await svc.completePaperclipIdGmailCallback({
const result = await svc.completePaperclipCloudConnectorCallback({
state,
claimId,
error,
@ -911,7 +993,9 @@ export function toolAccessRoutes(
typeof details?.code === "string" ? details.code : null,
));
}
});
};
router.get("/tools/oauth/cloud-connector/callback", handlePaperclipCloudConnectorCallback);
router.get("/tools/oauth/paperclip-id/callback", handlePaperclipCloudConnectorCallback);
router.get("/tools/vercel-connect/callback", async (req, res) => {
assertBoard(req);

View File

@ -0,0 +1,191 @@
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
completePaperclipCloudConnectorEnrollment,
loadPaperclipCloudConnectorIdentity,
paperclipCloudConnectorEnrollmentStatus,
paperclipCloudConnectorIdentityPath,
startPaperclipCloudConnectorEnrollment,
} from "./paperclip-cloud-connector-enrollment.js";
import { paperclipCloudConnectorConfigFromEnv } from "./paperclip-cloud-connector.js";
import { reconcilePaperclipCloudConnectorEnrollmentStatus } from "./paperclip-cloud-connector-status.js";
describe("Paperclip Cloud self-host enrollment", () => {
let root = "";
let previousHome: string | undefined;
let previousInstance: string | undefined;
beforeEach(() => {
root = mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-connector-"));
previousHome = process.env.PAPERCLIP_HOME;
previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
process.env.PAPERCLIP_HOME = root;
process.env.PAPERCLIP_INSTANCE_ID = "connector-test";
});
afterEach(() => {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
rmSync(root, { recursive: true, force: true });
});
it("keeps private keys owner-only and activates only the matching one-time callback", async () => {
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
const request = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
requests.push({ url, body });
if (url.endsWith("/v1/connector/enrollments")) {
return Response.json({
enrollmentId: "enroll-test",
verificationUrl: "https://my.example.test/connections/enroll?id=enroll-test",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}, { status: 201 });
}
const token = String(body.request);
const claims = JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString("utf8")) as Record<string, unknown>;
expect(claims).toMatchObject({
iss: loadPaperclipCloudConnectorIdentity()?.instanceId,
aud: "https://my.example.test/v1/connector/enrollment-claims",
env: "development",
op: "enroll",
});
expect(typeof claims.ah).toBe("string");
return Response.json({
id: loadPaperclipCloudConnectorIdentity()?.instanceId,
environment: "development",
origins: ["https://private.example.test"],
});
});
const pending = await startPaperclipCloudConnectorEnrollment({
origin: "https://private.example.test",
env: {
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my.example.test",
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "development",
},
request: request as typeof fetch,
});
expect(pending.status).toBe("pending");
expect(pending.verificationUrl).toBe("https://my.example.test/connections/enroll?id=enroll-test");
expect(statSync(path.dirname(paperclipCloudConnectorIdentityPath())).mode & 0o777).toBe(0o700);
expect(statSync(paperclipCloudConnectorIdentityPath()).mode & 0o777).toBe(0o600);
expect(readFileSync(paperclipCloudConnectorIdentityPath(), "utf8")).not.toContain("approval-code");
await expect(completePaperclipCloudConnectorEnrollment({
enrollmentId: "enroll-test",
approvalCode: "approval-code",
state: "wrong-state",
request: request as typeof fetch,
})).rejects.toThrow(/Invalid or expired/);
const state = loadPaperclipCloudConnectorIdentity()?.pending?.returnState;
const active = await completePaperclipCloudConnectorEnrollment({
enrollmentId: "enroll-test",
approvalCode: "approval-code",
state: state!,
request: request as typeof fetch,
});
expect(active).toMatchObject({ configured: true, status: "active", origins: ["https://private.example.test"] });
const config = paperclipCloudConnectorConfigFromEnv({});
expect(config).toMatchObject({ baseUrl: "https://my.example.test", environment: "development" });
expect(requests).toHaveLength(2);
const statusRequest = vi.fn(async (input: string | URL | Request) => {
expect(String(input)).toBe("https://my.example.test/v1/connector/instance-status");
return Response.json({ active: false, status: "suspended" });
});
await expect(reconcilePaperclipCloudConnectorEnrollmentStatus({}, statusRequest as typeof fetch)).resolves.toMatchObject({
configured: false,
status: "suspended",
instanceId: active.instanceId,
});
});
it("rejects non-loopback plain HTTP destinations before creating keys", async () => {
await expect(startPaperclipCloudConnectorEnrollment({
origin: "http://private.example.test",
request: vi.fn() as typeof fetch,
})).rejects.toThrow(/requires HTTPS/);
expect(paperclipCloudConnectorEnrollmentStatus().status).toBe("not_configured");
});
it("serializes overlapping starts and reuses one unexpired enrollment", async () => {
let releaseBroker!: () => void;
const brokerMayRespond = new Promise<void>((resolve) => {
releaseBroker = resolve;
});
const request = vi.fn(async () => {
await brokerMayRespond;
return Response.json({
enrollmentId: "enroll-shared",
verificationUrl: "https://my.example.test/connections/enroll?id=enroll-shared",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}, { status: 201 });
});
const values = {
origin: "https://private.example.test",
companyId: "company-test",
initiatedBy: "user:admin-test",
env: {
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my.example.test",
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "development",
},
request: request as typeof fetch,
};
const first = startPaperclipCloudConnectorEnrollment(values);
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
const second = startPaperclipCloudConnectorEnrollment(values);
releaseBroker();
const [firstStatus, secondStatus] = await Promise.all([first, second]);
expect(request).toHaveBeenCalledOnce();
expect(firstStatus).toMatchObject({ status: "pending", verificationUrl: expect.stringContaining("enroll-shared") });
expect(secondStatus).toEqual(firstStatus);
expect(loadPaperclipCloudConnectorIdentity()?.pending).toMatchObject({
enrollmentId: "enroll-shared",
companyId: "company-test",
initiatedBy: "user:admin-test",
});
await expect(startPaperclipCloudConnectorEnrollment({
...values,
initiatedBy: "user:another-admin",
})).rejects.toThrow(/another administrator/);
await expect(startPaperclipCloudConnectorEnrollment({
...values,
companyId: "another-company",
})).rejects.toThrow(/another company/);
expect(request).toHaveBeenCalledOnce();
});
it("defaults an enrollment to the environment of the standard Cloud broker", () => {
expect(paperclipCloudConnectorEnrollmentStatus({})).toMatchObject({
brokerBaseUrl: "https://my.paperclip.app",
environment: "production",
});
expect(paperclipCloudConnectorEnrollmentStatus({
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app",
})).toMatchObject({ environment: "staging" });
});
it("does not treat legacy Paperclip ID keys as a Cloud enrollment", () => {
expect(paperclipCloudConnectorEnrollmentStatus({
PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: "legacy-instance",
PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY: "legacy-signing-key",
PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY: "legacy-sealing-key",
PAPERCLIP_ID_CONNECTOR_ENVIRONMENT: "production",
PAPERCLIP_ID_CONNECTOR_BASE_URL: "https://id.paperclip.app",
})).toMatchObject({
configured: false,
status: "not_configured",
brokerBaseUrl: "https://my.paperclip.app",
instanceId: null,
});
});
});

View File

@ -0,0 +1,385 @@
import {
createHash,
createPrivateKey,
generateKeyPairSync,
randomBytes,
randomUUID,
sign,
type KeyObject,
} from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
const IDENTITY_VERSION = 1;
const ENROLLMENT_FILE = "paperclip-cloud-connector.json";
const REQUEST_TYPE = "paperclip-cloud-connector-request+jwt";
export type LocalConnectorEnvironment = "development" | "staging" | "production";
type PendingEnrollment = {
enrollmentId: string;
returnState: string;
origin: string;
expiresAt: string;
companyId?: string;
initiatedBy?: string;
};
export type PaperclipCloudConnectorIdentity = {
version: 1;
instanceId: string;
environment: LocalConnectorEnvironment;
brokerBaseUrl: string;
signPrivateKey: string;
signPublicKey: string;
sealPrivateKey: string;
sealPublicKey: string;
status: "unenrolled" | "pending" | "active";
origins: string[];
pending?: PendingEnrollment;
enrolledAt?: string;
};
export type PaperclipCloudConnectorEnrollmentStatus = {
configured: boolean;
status: PaperclipCloudConnectorIdentity["status"] | "not_configured" | "suspended" | "unverified";
brokerBaseUrl: string;
instanceId: string | null;
environment: LocalConnectorEnvironment;
origins: string[];
verificationUrl?: string;
expiresAt?: string;
};
export function paperclipCloudConnectorIdentityPath(): string {
return path.join(resolvePaperclipInstanceRoot(), "secrets", ENROLLMENT_FILE);
}
export function loadPaperclipCloudConnectorIdentity(): PaperclipCloudConnectorIdentity | null {
const filePath = paperclipCloudConnectorIdentityPath();
if (!existsSync(filePath)) return null;
try {
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
return parseIdentity(parsed);
} catch {
return null;
}
}
export function paperclipCloudConnectorEnrollmentStatus(
env: NodeJS.ProcessEnv = process.env,
): PaperclipCloudConnectorEnrollmentStatus {
const identity = loadPaperclipCloudConnectorIdentity();
const brokerBaseUrl = normalizeBrokerOrigin(
env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL
?? identity?.brokerBaseUrl
?? "https://my.paperclip.app",
);
const environment = connectorEnvironment(env, identity?.environment, brokerBaseUrl);
if (!identity) {
const managedInstanceId = env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID?.trim();
const managedKeysPresent = Boolean(
env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY?.trim()
&& env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY?.trim(),
);
if (managedInstanceId && managedKeysPresent) {
const publicOrigin = env.PAPERCLIP_PUBLIC_URL ? normalizeInstanceOrigin(env.PAPERCLIP_PUBLIC_URL) : undefined;
return {
configured: true,
status: "active",
brokerBaseUrl,
instanceId: managedInstanceId,
environment,
origins: publicOrigin ? [publicOrigin] : [],
};
}
return { configured: false, status: "not_configured", brokerBaseUrl, instanceId: null, environment, origins: [] };
}
return {
configured: identity.status === "active",
status: identity.status,
brokerBaseUrl,
instanceId: identity.instanceId,
environment,
origins: [...identity.origins],
...(identity.pending ? {
verificationUrl: `${brokerBaseUrl}/connections/enroll?id=${encodeURIComponent(identity.pending.enrollmentId)}`,
expiresAt: identity.pending.expiresAt,
} : {}),
};
}
export async function startPaperclipCloudConnectorEnrollment(input: {
origin: string;
label?: string;
companyId?: string;
initiatedBy?: string;
env?: NodeJS.ProcessEnv;
request?: typeof fetch;
}): Promise<PaperclipCloudConnectorEnrollmentStatus> {
return withEnrollmentMutationLock(() => startPaperclipCloudConnectorEnrollmentUnlocked(input));
}
async function startPaperclipCloudConnectorEnrollmentUnlocked(input: {
origin: string;
label?: string;
companyId?: string;
initiatedBy?: string;
env?: NodeJS.ProcessEnv;
request?: typeof fetch;
}): Promise<PaperclipCloudConnectorEnrollmentStatus> {
const env = input.env ?? process.env;
const request = input.request ?? fetch;
const origin = normalizeInstanceOrigin(input.origin);
let identity = loadPaperclipCloudConnectorIdentity() ?? createIdentity(env);
if (identity.status === "pending" && identity.pending && Date.parse(identity.pending.expiresAt) > Date.now()) {
if (identity.pending.origin !== origin) {
throw new Error("Paperclip Cloud enrollment is already pending for another origin");
}
if (identity.pending.companyId !== input.companyId) {
throw new Error("Paperclip Cloud enrollment is already pending for another company");
}
if (input.initiatedBy && identity.pending.initiatedBy && identity.pending.initiatedBy !== input.initiatedBy) {
throw new Error("Paperclip Cloud enrollment is already pending for another administrator");
}
return paperclipCloudConnectorEnrollmentStatus(env);
}
const returnState = randomBytes(32).toString("base64url");
const returnUri = `${origin}/api/tools/oauth/cloud-connector/enrollment-callback`;
const response = await request(`${identity.brokerBaseUrl}/v1/connector/enrollments`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
instanceId: identity.instanceId,
environment: identity.environment,
origin,
returnUri,
returnState,
label: input.label?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || "Self-hosted Paperclip",
signPublicKey: identity.signPublicKey,
sealPublicKey: identity.sealPublicKey,
}),
signal: AbortSignal.timeout(15_000),
}).catch(() => null);
if (!response?.ok) throw new Error("Paperclip Cloud enrollment is unavailable");
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!body || typeof body.enrollmentId !== "string" || typeof body.verificationUrl !== "string" || typeof body.expiresAt !== "string") {
throw new Error("Paperclip Cloud returned an invalid enrollment response");
}
const verificationUrl = new URL(body.verificationUrl);
if (verificationUrl.origin !== identity.brokerBaseUrl || verificationUrl.pathname !== "/connections/enroll") {
throw new Error("Paperclip Cloud returned an invalid enrollment destination");
}
identity = {
...identity,
status: "pending",
pending: {
enrollmentId: body.enrollmentId,
returnState,
origin,
expiresAt: body.expiresAt,
...(input.companyId ? { companyId: input.companyId } : {}),
...(input.initiatedBy ? { initiatedBy: input.initiatedBy } : {}),
},
};
saveIdentity(identity);
return {
...paperclipCloudConnectorEnrollmentStatus(env),
verificationUrl: verificationUrl.toString(),
expiresAt: body.expiresAt,
};
}
export async function completePaperclipCloudConnectorEnrollment(input: {
enrollmentId: string;
approvalCode: string;
state: string;
request?: typeof fetch;
}): Promise<PaperclipCloudConnectorEnrollmentStatus> {
return withEnrollmentMutationLock(() => completePaperclipCloudConnectorEnrollmentUnlocked(input));
}
async function completePaperclipCloudConnectorEnrollmentUnlocked(input: {
enrollmentId: string;
approvalCode: string;
state: string;
request?: typeof fetch;
}): Promise<PaperclipCloudConnectorEnrollmentStatus> {
const identity = loadPaperclipCloudConnectorIdentity();
const pending = identity?.pending;
if (!identity || !pending || identity.status !== "pending"
|| pending.enrollmentId !== input.enrollmentId || pending.returnState !== input.state
|| Date.parse(pending.expiresAt) <= Date.now()) {
throw new Error("Invalid or expired Paperclip Cloud enrollment state");
}
const audience = `${identity.brokerBaseUrl}/v1/connector/enrollment-claims`;
const now = Math.floor(Date.now() / 1_000);
const requestToken = signRequest({
iss: identity.instanceId,
aud: audience,
sub: "self-hosted-admin",
cid: "self-hosted",
env: identity.environment,
op: "enroll",
iat: now,
exp: now + 60,
jti: randomUUID(),
ah: hash(input.approvalCode),
}, privateKey(identity.signPrivateKey));
const response = await (input.request ?? fetch)(audience, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ request: requestToken, enrollmentId: input.enrollmentId, approvalCode: input.approvalCode }),
signal: AbortSignal.timeout(15_000),
}).catch(() => null);
if (!response?.ok) throw new Error("Paperclip Cloud enrollment could not be completed");
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!body || body.id !== identity.instanceId || body.environment !== identity.environment
|| !Array.isArray(body.origins) || !body.origins.includes(pending.origin)) {
throw new Error("Paperclip Cloud returned an invalid enrollment binding");
}
saveIdentity({
...identity,
status: "active",
origins: body.origins.filter((value): value is string => typeof value === "string"),
pending: undefined,
enrolledAt: new Date().toISOString(),
});
return paperclipCloudConnectorEnrollmentStatus();
}
function createIdentity(env: NodeJS.ProcessEnv): PaperclipCloudConnectorIdentity {
const signing = generateKeyPairSync("ed25519");
const sealing = generateKeyPairSync("x25519");
const brokerBaseUrl = normalizeBrokerOrigin(env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL ?? "https://my.paperclip.app");
const identity: PaperclipCloudConnectorIdentity = {
version: IDENTITY_VERSION,
instanceId: `inst_${randomUUID()}`,
environment: connectorEnvironment(env, undefined, brokerBaseUrl),
brokerBaseUrl,
signPrivateKey: rawKey(signing.privateKey, "d"),
signPublicKey: rawKey(signing.publicKey, "x"),
sealPrivateKey: rawKey(sealing.privateKey, "d"),
sealPublicKey: rawKey(sealing.publicKey, "x"),
status: "unenrolled",
origins: [],
};
saveIdentity(identity);
return identity;
}
function saveIdentity(identity: PaperclipCloudConnectorIdentity): void {
const filePath = paperclipCloudConnectorIdentityPath();
const directory = path.dirname(filePath);
mkdirSync(directory, { recursive: true, mode: 0o700 });
chmodSync(directory, 0o700);
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(temporaryPath, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
chmodSync(temporaryPath, 0o600);
renameSync(temporaryPath, filePath);
chmodSync(filePath, 0o600);
}
function parseIdentity(value: unknown): PaperclipCloudConnectorIdentity {
if (!isRecord(value) || value.version !== IDENTITY_VERSION
|| typeof value.instanceId !== "string" || !value.instanceId.startsWith("inst_")
|| !isEnvironment(value.environment) || typeof value.brokerBaseUrl !== "string"
|| typeof value.signPrivateKey !== "string" || typeof value.signPublicKey !== "string"
|| typeof value.sealPrivateKey !== "string" || typeof value.sealPublicKey !== "string"
|| (value.status !== "unenrolled" && value.status !== "pending" && value.status !== "active")
|| !Array.isArray(value.origins) || !value.origins.every((origin) => typeof origin === "string")) {
throw new Error("Invalid Paperclip Cloud connector identity");
}
return value as PaperclipCloudConnectorIdentity;
}
function connectorEnvironment(
env: NodeJS.ProcessEnv,
fallback?: LocalConnectorEnvironment,
brokerBaseUrl = "https://my.paperclip.app",
): LocalConnectorEnvironment {
const host = new URL(brokerBaseUrl).hostname.toLowerCase();
const inferred = host === "my.paperclip.app"
? "production"
: host === "my-staging.paperclip.app"
? "staging"
: "development";
const value = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT ?? fallback ?? inferred;
if (!isEnvironment(value)) throw new Error("Paperclip Cloud connector environment is invalid");
return value;
}
function isEnvironment(value: unknown): value is LocalConnectorEnvironment {
return value === "development" || value === "staging" || value === "production";
}
function normalizeBrokerOrigin(value: string): string {
const url = new URL(value.trim());
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback(url.hostname))) {
throw new Error("Paperclip Cloud connector URL must use HTTPS");
}
if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) {
throw new Error("Paperclip Cloud connector URL must be an origin");
}
return url.origin;
}
function normalizeInstanceOrigin(value: string): string {
const url = new URL(value.trim());
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback(url.hostname))) {
throw new Error("Paperclip Cloud enrollment requires HTTPS except on loopback");
}
if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) {
throw new Error("Paperclip Cloud enrollment requires an exact origin");
}
return url.origin;
}
function privateKey(raw: string): KeyObject {
const prefix = Buffer.from("302e020100300506032b657004220420", "hex");
return createPrivateKey({ key: Buffer.concat([prefix, Buffer.from(raw, "base64url")]), format: "der", type: "pkcs8" });
}
function rawKey(key: KeyObject, field: "d" | "x"): string {
const value = (key.export({ format: "jwk" }) as Record<string, unknown>)[field];
if (typeof value !== "string") throw new Error("Unable to export connector key");
return value;
}
function signRequest(payload: Record<string, unknown>, key: KeyObject): string {
const header = { alg: "EdDSA", typ: REQUEST_TYPE };
const encodedHeader = Buffer.from(JSON.stringify(header), "utf8").toString("base64url");
const encodedPayload = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
const signingInput = `${encodedHeader}.${encodedPayload}`;
return `${signingInput}.${sign(null, Buffer.from(signingInput, "utf8"), key).toString("base64url")}`;
}
function hash(value: string): string {
return createHash("sha256").update(value, "utf8").digest("base64url");
}
function loopback(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
let enrollmentMutationTail: Promise<void> = Promise.resolve();
async function withEnrollmentMutationLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = enrollmentMutationTail;
let release!: () => void;
enrollmentMutationTail = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
}
}

View File

@ -0,0 +1,26 @@
import {
paperclipCloudConnectorEnrollmentStatus,
type PaperclipCloudConnectorEnrollmentStatus,
} from "./paperclip-cloud-connector-enrollment.js";
import {
createPaperclipCloudConnector,
paperclipCloudConnectorConfigFromEnv,
} from "./paperclip-cloud-connector.js";
export async function reconcilePaperclipCloudConnectorEnrollmentStatus(
env: NodeJS.ProcessEnv = process.env,
request: typeof fetch = fetch,
): Promise<PaperclipCloudConnectorEnrollmentStatus> {
const local = paperclipCloudConnectorEnrollmentStatus(env);
if (!local.configured) return local;
const config = paperclipCloudConnectorConfigFromEnv(env);
if (!config) return { ...local, configured: false, status: "not_configured" };
try {
const status = await createPaperclipCloudConnector({ config, request }).getInstanceStatus();
if (status === "active") return { ...local, configured: true, status: "active" };
if (status === "suspended") return { ...local, configured: false, status: "suspended" };
return { ...local, configured: false, status: "not_configured" };
} catch {
return { ...local, configured: false, status: "unverified" };
}
}

View File

@ -9,13 +9,13 @@ import {
import { describe, expect, it, vi } from "vitest";
import {
createPaperclipIdGmailConnector,
createPaperclipCloudConnector,
GMAIL_CONNECTOR_SCOPES,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
paperclipIdGmailConnectorConfigFromEnv,
PaperclipIdConnectorError,
type PaperclipIdGmailConnectorConfig,
} from "./paperclip-id-gmail-connector.js";
paperclipCloudConnectorConfigFromEnv,
PaperclipCloudConnectorError,
type PaperclipCloudConnectorConfig,
} from "./paperclip-cloud-connector.js";
const instanceId = "inst_test";
const companyId = "company_test";
@ -32,47 +32,53 @@ function config() {
const sealing = generateKeyPairSync("x25519");
return {
config: {
baseUrl: "https://id.example.test",
baseUrl: "https://my.example.test",
instanceId,
environment: "staging",
signPrivateKey: rawPrivateKey(signing.privateKey),
sealPrivateKey: rawPrivateKey(sealing.privateKey),
} satisfies PaperclipIdGmailConnectorConfig,
} satisfies PaperclipCloudConnectorConfig,
sealPublicKey: sealing.publicKey,
};
}
describe("Paperclip ID Gmail connector", () => {
describe("Paperclip Cloud connector", () => {
it("starts a signed session with exact endpoint audience and scope contract", async () => {
const keys = config();
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { request: string };
const [, encodedClaims] = body.request.split(".");
const [encodedHeader, encodedClaims] = body.request.split(".");
expect(JSON.parse(Buffer.from(encodedHeader!, "base64url").toString("utf8"))).toEqual({
alg: "EdDSA",
typ: "paperclip-cloud-connector-request+jwt",
});
const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8"));
expect(claims).toMatchObject({
iss: instanceId,
aud: "https://id.example.test/api/connect/sessions",
aud: "https://my.example.test/v1/connector/sessions",
sub: subject,
cid: companyId,
env: "staging",
op: "session",
ruri: "https://paperclip.example.test/api/tools/oauth/paperclip-id/callback",
prv: "google",
prf: "gmail.draft",
scp: [...GMAIL_CONNECTOR_SCOPES],
ruri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
rst: "state-1",
});
return Response.json({
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=broker-state",
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
expiresAt: "2026-08-21T20:00:00.000Z",
scopes: [...GMAIL_CONNECTOR_SCOPES],
}, { status: 201 });
});
const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch });
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
await expect(connector.startAuthorization({
subject,
companyId,
returnUri: "https://paperclip.example.test/api/tools/oauth/paperclip-id/callback",
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-1",
})).resolves.toMatchObject({ authorizationUrl: expect.stringContaining("accounts.google.com") });
})).resolves.toMatchObject({ authorizationUrl: expect.stringContaining("/connections/confirm") });
});
it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => {
@ -86,16 +92,25 @@ describe("Paperclip ID Gmail connector", () => {
scopes: [...GMAIL_CONNECTOR_SCOPES],
subject,
companyId,
instanceId,
environment: "staging" as const,
provider: "google" as const,
profile: "gmail.draft",
};
const sealed = seal(credentials, keys.sealPublicKey, "gmail-initial-tokens", keys.config);
const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "gmail.draft");
const request = vi.fn(async () => Response.json({
claimId: "clm_test",
scopes: [...GMAIL_CONNECTOR_SCOPES],
sealed,
}));
const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch });
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
await expect(connector.claim({ subject, companyId, claimId: "clm_test" })).resolves.toEqual(credentials);
await expect(connector.claim({
subject,
companyId,
claimId: "clm_test",
redemptionId: "local-oauth-state-1",
})).resolves.toEqual(credentials);
});
it("binds non-Gmail credentials and requests to their exact connector profile", async () => {
@ -110,66 +125,130 @@ describe("Paperclip ID Gmail connector", () => {
scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes],
subject,
companyId,
instanceId,
environment: "staging" as const,
provider: "google" as const,
profile,
};
const sealed = seal(credentials, keys.sealPublicKey, "google-workspace-initial-tokens", keys.config, profile);
const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, profile);
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { request: string };
const [, encodedClaims] = body.request.split(".");
const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8"));
expect(claims.prf).toBe(profile);
expect(claims.rid).toBe("local-oauth-state-drive");
return Response.json({ claimId: "clm_drive", scopes: credentials.scopes, sealed });
});
const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch });
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
await expect(connector.claim({ subject, companyId, profile, claimId: "clm_drive" })).resolves.toEqual(credentials);
await expect(connector.claim({
subject,
companyId,
profile,
claimId: "clm_drive",
redemptionId: "local-oauth-state-drive",
})).resolves.toEqual(credentials);
});
it("accepts only the current capability protocol and known profiles", async () => {
const keys = config();
const request = vi.fn(async () => Response.json({
protocolVersion: 2,
profiles: ["gmail.read", "drive.write", "unknown.profile"],
providers: [{ key: "google", profiles: [
{ key: "gmail.read", enabled: true },
{ key: "drive.write", enabled: true },
{ key: "unknown.profile", enabled: true },
{ key: "gmail.draft", enabled: false },
] }],
}));
const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch });
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
await expect(connector.getCapabilities()).resolves.toEqual(["gmail.read", "drive.write"]);
});
it("checks Cloud enrollment status with an instance-only signed request", async () => {
const keys = config();
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { request: string };
const [, encodedClaims] = body.request.split(".");
const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8"));
expect(claims).toMatchObject({
iss: instanceId,
aud: "https://my.example.test/v1/connector/instance-status",
sub: "instance-status",
cid: "instance-status",
env: "staging",
op: "status",
});
expect(claims).not.toHaveProperty("prv");
expect(claims).not.toHaveProperty("prf");
expect(claims).not.toHaveProperty("scp");
return Response.json({ active: true, status: "active" });
});
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
await expect(connector.getInstanceStatus()).resolves.toBe("active");
});
it("treats an unknown Cloud enrollment as removed without exposing Cloud detail", async () => {
const keys = config();
const connector = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => new Response("unknown instance detail", { status: 401 })) as typeof fetch,
});
await expect(connector.getInstanceStatus()).resolves.toBe("removed");
});
it("does not expose a broker response body when a request fails", async () => {
const keys = config();
const request = vi.fn(async () => new Response(JSON.stringify({
error: "provider rejected access-secret refresh-secret",
}), { status: 502 }));
const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch });
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
const error = await connector.refresh({ subject, companyId, refreshToken: "refresh-secret" }).catch((caught) => caught);
expect(error).toBeInstanceOf(PaperclipIdConnectorError);
expect(error).toBeInstanceOf(PaperclipCloudConnectorError);
expect(String(error)).not.toContain("access-secret");
expect(String(error)).not.toContain("refresh-secret");
expect(request).toHaveBeenCalledOnce();
});
it("requires an all-or-nothing environment configuration and loopback for HTTP", () => {
expect(paperclipIdGmailConnectorConfigFromEnv({})).toBeNull();
expect(() => paperclipIdGmailConnectorConfigFromEnv({
PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: instanceId,
expect(paperclipCloudConnectorConfigFromEnv({})).toBeNull();
expect(() => paperclipCloudConnectorConfigFromEnv({
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: instanceId,
})).toThrowError(/incomplete/);
expect(() => paperclipIdGmailConnectorConfigFromEnv({
PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: instanceId,
PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY: "key",
PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY: "key",
PAPERCLIP_ID_CONNECTOR_ENVIRONMENT: "development",
PAPERCLIP_ID_CONNECTOR_BASE_URL: "http://id.example.test",
expect(() => paperclipCloudConnectorConfigFromEnv({
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: instanceId,
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: "key",
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: "key",
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "development",
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "http://my.example.test",
})).toThrowError(/HTTPS/);
const legacyError = (() => {
try {
paperclipCloudConnectorConfigFromEnv({
PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: instanceId,
PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY: "key",
PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY: "key",
PAPERCLIP_ID_CONNECTOR_ENVIRONMENT: "development",
PAPERCLIP_ID_CONNECTOR_BASE_URL: "https://id.paperclip.app",
});
return null;
} catch (error) {
return error;
}
})();
expect(legacyError).toMatchObject({ code: "CONNECTOR_MIGRATION_REQUIRED" });
expect(String(legacyError)).toContain("incompatible legacy protocol");
});
});
function seal(
payload: unknown,
recipientPublicKey: KeyObject,
purpose: "gmail-initial-tokens" | "gmail-access-token" | "google-workspace-initial-tokens" | "google-workspace-access-token",
configValue: PaperclipIdGmailConnectorConfig,
profile?: string,
purpose: "initial" | "access",
configValue: PaperclipCloudConnectorConfig,
profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
) {
const ephemeral = generateKeyPairSync("x25519");
const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string };
@ -182,7 +261,9 @@ function seal(
purpose,
configValue.instanceId,
configValue.environment,
...(profile ? [profile] : []),
"google",
profile,
[...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes].sort().join(" "),
].join("\n"));
const key = Buffer.from(hkdfSync(
"sha256",
@ -199,6 +280,8 @@ function seal(
v: 1,
alg: "X25519-HKDF-SHA256-A256GCM",
purpose,
provider: "google",
profile,
epk: ephemeralJwk.x,
iv: iv.toString("base64url"),
ct: ciphertext.toString("base64url"),

View File

@ -0,0 +1,509 @@
import {
createDecipheriv,
createHash,
createPrivateKey,
createPublicKey,
diffieHellman,
hkdfSync,
randomUUID,
sign,
type KeyObject,
} from "node:crypto";
import {
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
isGoogleWorkspaceConnectorProfileId,
type GoogleWorkspaceConnectorProfileId,
} from "@paperclipai/shared";
import { loadPaperclipCloudConnectorIdentity } 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";
export type PaperclipCloudConnectorConfig = {
baseUrl: string;
instanceId: string;
environment: PaperclipCloudConnectorEnvironment;
signPrivateKey: string;
sealPrivateKey: string;
};
export type SealedGmailCredentials = {
v: 1;
accessToken: string;
refreshToken: string | null;
tokenType: string;
accessTokenExpiresAt: string;
scopes: string[];
subject: string;
companyId: string;
instanceId: string;
environment: PaperclipCloudConnectorEnvironment;
provider: "google";
profile: string;
};
export type SealedGoogleWorkspaceCredentials = SealedGmailCredentials;
type SealedEnvelope = {
v: 1;
alg: "X25519-HKDF-SHA256-A256GCM";
purpose: "initial" | "access";
provider: "google";
profile: GoogleWorkspaceConnectorProfileId;
epk: string;
iv: string;
ct: string;
};
type ConnectorResponse = {
confirmationUrl?: unknown;
expiresAt?: unknown;
scopes?: unknown;
claimId?: unknown;
sealed?: unknown;
profiles?: unknown;
protocolVersion?: unknown;
providers?: unknown;
active?: unknown;
status?: 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",
};
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");
/** A stable, intentionally detail-free error for all remote broker failures. */
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 hasActiveLocalIdentity = localIdentity?.status === "active";
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 cloudConfigured = [
env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID,
env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY,
env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY,
env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT,
env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL,
].some((value) => Boolean(value?.trim()));
if (!cloudConfigured && !hasActiveLocalIdentity && legacyConfigured) {
throw new PaperclipCloudConnectorError(
"Paperclip ID connector settings use an incompatible legacy protocol; enroll this instance with Paperclip Cloud",
"CONNECTOR_MIGRATION_REQUIRED",
);
}
const instanceId = env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID?.trim()
|| (hasActiveLocalIdentity ? localIdentity.instanceId : undefined);
const signPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY?.trim()
|| (hasActiveLocalIdentity ? localIdentity.signPrivateKey : undefined);
const sealPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY?.trim()
|| (hasActiveLocalIdentity ? localIdentity.sealPrivateKey : undefined);
const environment = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim()
|| (hasActiveLocalIdentity ? localIdentity.environment : undefined);
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.every((value) => !value)) return null;
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");
}
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?: GoogleWorkspaceConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string },
secret?: { field: "refreshToken" | "token"; 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) {
payload.prv = "google";
payload.prf = claims.profile;
payload.scp = [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[claims.profile].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) {
throw new PaperclipCloudConnectorError(
"Paperclip Cloud connector rejected the request",
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: GoogleWorkspaceConnectorProfileId,
): SealedGmailCredentials {
const envelope = parseEnvelope(response.sealed, purpose);
const credentials = unseal(
envelope,
sealKey,
config.instanceId,
config.environment,
"google",
profile,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes,
);
if (
credentials.instanceId !== config.instanceId
|| credentials.environment !== config.environment
|| credentials.subject !== subject
|| credentials.companyId !== companyId
|| credentials.provider !== "google"
) {
throw new PaperclipCloudConnectorError("Paperclip Cloud Gmail 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, GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes)) {
throw new PaperclipCloudConnectorError("Paperclip Cloud Gmail 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<GoogleWorkspaceConnectorProfileId[]> {
const endpoint = new URL("/v1/connector/capabilities", `${config.baseUrl}/`).toString();
let response: Response;
try {
response = await request(endpoint, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5_000) });
} catch {
return [];
}
if (!response.ok) return [];
const payload = await response.json().catch(() => null) as ConnectorResponse | null;
if (!Array.isArray(payload?.providers)) return [];
const google = payload.providers.find((value) => isRecord(value) && value.key === "google");
if (!isRecord(google) || !Array.isArray(google.profiles)) return [];
return google.profiles.flatMap((value) => {
if (!isRecord(value) || value.enabled !== true || typeof value.key !== "string") return [];
return isGoogleWorkspaceConnectorProfileId(value.key) ? [value.key] : [];
});
},
async startAuthorization(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; 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");
}
const confirmationUrl = new URL(response.confirmationUrl);
const expectedBroker = new URL(config.baseUrl);
if (confirmationUrl.origin !== expectedBroker.origin || confirmationUrl.pathname !== "/connections/confirm") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
}
return { authorizationUrl: confirmationUrl.toString(), expiresAt: response.expiresAt };
},
async claim(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; 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?: GoogleWorkspaceConnectorProfileId; 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?: GoogleWorkspaceConnectorProfileId; token: string }) {
await call("revoke", { ...values, profile: values.profile ?? "gmail.draft" }, { field: "token", value: values.token });
},
};
}
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";
}
let capabilityCache: { key: string; expiresAt: number; profiles: GoogleWorkspaceConnectorProfileId[] } | null = null;
export async function paperclipCloudConnectorCapabilitiesFromEnv(
env: NodeJS.ProcessEnv = process.env,
): Promise<GoogleWorkspaceConnectorProfileId[]> {
let config: PaperclipCloudConnectorConfig | null;
try {
config = paperclipCloudConnectorConfigFromEnv(env);
} catch (error) {
if (error instanceof PaperclipCloudConnectorError && error.code === "CONNECTOR_MIGRATION_REQUIRED") 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 connector = createPaperclipCloudConnector({ config });
let status: "active" | "suspended" | "removed";
try {
status = await connector.getInstanceStatus();
} catch {
return [];
}
const profiles = status === "active" ? await connector.getCapabilities() : [];
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"]): 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 !== "google" || !candidate.profile || !isGoogleWorkspaceConnectorProfileId(candidate.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: "google",
profile: GoogleWorkspaceConnectorProfileId,
scopes: readonly string[],
): SealedGmailCredentials {
try {
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(),
]);
const parsed = JSON.parse(plaintext.toString("utf8")) as Partial<SealedGmailCredentials>;
if (parsed.v !== 1 || typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0
|| !(parsed.refreshToken === null || typeof parsed.refreshToken === "string")
|| typeof parsed.tokenType !== "string" || typeof parsed.accessTokenExpiresAt !== "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.provider !== provider || parsed.profile !== profile) {
throw badEnvelope();
}
return parsed as SealedGmailCredentials;
} catch (error) {
if (error instanceof PaperclipCloudConnectorError) throw error;
throw badEnvelope();
}
}
function badEnvelope() {
return new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid sealed credential", "CONNECTOR_BAD_RESPONSE");
}
function sealPurpose(
kind: "initial" | "access",
_profile: GoogleWorkspaceConnectorProfileId,
): SealedEnvelope["purpose"] {
return kind;
}
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]";
}

View File

@ -1,407 +1,26 @@
import {
createDecipheriv,
createHash,
createPrivateKey,
createPublicKey,
diffieHellman,
hkdfSync,
randomUUID,
sign,
type KeyObject,
} from "node:crypto";
import {
/**
* @deprecated Paperclip ID is identity-only. Import the Paperclip Cloud
* connector names from `paperclip-cloud-connector.ts` for new code.
*
* These aliases keep source compatibility while deployments and persisted app
* definitions move from the former Paperclip ID broker prototype.
*/
export {
GMAIL_CONNECTOR_SCOPES,
GMAIL_MCP_URL,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
isGoogleWorkspaceConnectorProfileId,
type GoogleWorkspaceConnectorProfileId,
} from "@paperclipai/shared";
PaperclipCloudConnectorError as PaperclipIdConnectorError,
createPaperclipCloudConnector as createPaperclipIdGmailConnector,
paperclipCloudConnectorCapabilitiesFromEnv as paperclipIdGoogleConnectorCapabilitiesFromEnv,
paperclipCloudConnectorConfigFromEnv as paperclipIdGmailConnectorConfigFromEnv,
} from "./paperclip-cloud-connector.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 PaperclipIdConnectorEnvironment = "development" | "staging" | "production";
export type PaperclipIdConnectorOperation = "session" | "claim" | "refresh" | "revoke";
export type PaperclipIdGmailConnectorConfig = {
baseUrl: string;
instanceId: string;
environment: PaperclipIdConnectorEnvironment;
signPrivateKey: string;
sealPrivateKey: string;
};
export type SealedGmailCredentials = {
v: 1;
accessToken: string;
refreshToken: string | null;
tokenType: string;
accessTokenExpiresAt: string;
scopes: string[];
subject: string;
companyId: string;
profile?: string;
};
export type SealedGoogleWorkspaceCredentials = SealedGmailCredentials;
type SealedEnvelope = {
v: 1;
alg: "X25519-HKDF-SHA256-A256GCM";
purpose: "gmail-initial-tokens" | "gmail-access-token" | "google-workspace-initial-tokens" | "google-workspace-access-token";
epk: string;
iv: string;
ct: string;
};
type ConnectorResponse = {
authorizationUrl?: unknown;
expiresAt?: unknown;
scopes?: unknown;
claimId?: unknown;
sealed?: unknown;
profiles?: unknown;
protocolVersion?: unknown;
};
const ENDPOINTS: Record<PaperclipIdConnectorOperation, string> = {
session: "/api/connect/sessions",
claim: "/api/connect/claims",
refresh: "/api/connect/refresh",
revoke: "/api/connect/revoke",
};
const JWS_TYP = "paperclip-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");
/** A stable, intentionally detail-free error for all remote broker failures. */
export class PaperclipIdConnectorError extends Error {
constructor(
message: string,
readonly code: string,
readonly status?: number,
) {
super(message);
this.name = "PaperclipIdConnectorError";
}
}
export function paperclipIdGmailConnectorConfigFromEnv(
env: NodeJS.ProcessEnv = process.env,
): PaperclipIdGmailConnectorConfig | null {
const instanceId = env.PAPERCLIP_ID_CONNECTOR_INSTANCE_ID?.trim();
const signPrivateKey = env.PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY?.trim();
const sealPrivateKey = env.PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY?.trim();
const environment = env.PAPERCLIP_ID_CONNECTOR_ENVIRONMENT?.trim();
const baseUrl = env.PAPERCLIP_ID_CONNECTOR_BASE_URL?.trim() || "https://id.paperclip.app";
const values = [instanceId, signPrivateKey, sealPrivateKey, environment];
if (values.every((value) => !value)) return null;
if (values.some((value) => !value)) {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE");
}
if (environment !== "development" && environment !== "staging" && environment !== "production") {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector environment is invalid", "CONNECTOR_CONFIG_INVALID");
}
const parsedBaseUrl = new URL(baseUrl);
if (parsedBaseUrl.protocol !== "https:" && !(parsedBaseUrl.protocol === "http:" && isLoopback(parsedBaseUrl.hostname))) {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector URL must use HTTPS", "CONNECTOR_CONFIG_INVALID");
}
if (parsedBaseUrl.username || parsedBaseUrl.password || parsedBaseUrl.search || parsedBaseUrl.hash) {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector URL is invalid", "CONNECTOR_CONFIG_INVALID");
}
parsedBaseUrl.pathname = parsedBaseUrl.pathname.replace(/\/$/, "");
return {
baseUrl: parsedBaseUrl.toString().replace(/\/$/, ""),
instanceId: instanceId!,
environment,
signPrivateKey: signPrivateKey!,
sealPrivateKey: sealPrivateKey!,
};
}
export function createPaperclipIdGmailConnector(input: {
config: PaperclipIdGmailConnectorConfig;
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: PaperclipIdConnectorOperation,
claims: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string },
secret?: { field: "refreshToken" | "token"; 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.profile !== undefined) payload.prf = claims.profile;
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 PaperclipIdConnectorError("Paperclip ID Gmail connector is unavailable", "CONNECTOR_UNAVAILABLE");
}
if (operation === "revoke" && response.status === 204) return {};
if (!response.ok) {
throw new PaperclipIdConnectorError(
"Paperclip ID Gmail connector rejected the request",
response.status === 409 ? "REAUTHORIZATION_REQUIRED" : "CONNECTOR_REQUEST_FAILED",
response.status,
);
}
try {
return await response.json() as ConnectorResponse;
} catch {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid response", "CONNECTOR_BAD_RESPONSE");
}
}
function openCredentials(
response: ConnectorResponse,
purpose: SealedEnvelope["purpose"],
subject: string,
companyId: string,
profile: GoogleWorkspaceConnectorProfileId,
): SealedGmailCredentials {
const envelope = parseEnvelope(response.sealed, purpose);
const credentials = unseal(
envelope,
sealKey,
config.instanceId,
config.environment,
purpose.startsWith("google-workspace-") ? profile : undefined,
);
if (credentials.subject !== subject || credentials.companyId !== companyId) {
throw new PaperclipIdConnectorError("Paperclip ID Gmail credential binding did not match", "CONNECTOR_BINDING_MISMATCH");
}
if (credentials.profile && credentials.profile !== profile) {
throw new PaperclipIdConnectorError("Paperclip ID connector profile binding did not match", "CONNECTOR_BINDING_MISMATCH");
}
if (!sameStringSet(credentials.scopes, GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes)) {
throw new PaperclipIdConnectorError("Paperclip ID Gmail scope grant did not match", "REAUTHORIZATION_REQUIRED");
}
return credentials;
}
return {
async getCapabilities(): Promise<GoogleWorkspaceConnectorProfileId[]> {
const endpoint = new URL("/api/connect/capabilities", `${config.baseUrl}/`).toString();
let response: Response;
try {
response = await request(endpoint, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5_000) });
} catch {
return [];
}
if (!response.ok) return [];
const payload = await response.json().catch(() => null) as ConnectorResponse | null;
if (payload?.protocolVersion !== 2) return [];
return Array.isArray(payload?.profiles)
? payload.profiles.filter((value): value is GoogleWorkspaceConnectorProfileId => typeof value === "string" && isGoogleWorkspaceConnectorProfileId(value))
: [];
},
async startAuthorization(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; returnUri: string; returnState: string }) {
const profile = values.profile ?? "gmail.draft";
const response = await call("session", { ...values, profile });
if (typeof response.authorizationUrl !== "string" || typeof response.expiresAt !== "string") {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid session", "CONNECTOR_BAD_RESPONSE");
}
if (!sameStringSet(response.scopes, GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes)) {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid scope set", "CONNECTOR_BAD_RESPONSE");
}
const authorizationUrl = new URL(response.authorizationUrl);
if (authorizationUrl.protocol !== "https:" || authorizationUrl.hostname !== "accounts.google.com") {
throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid authorization URL", "CONNECTOR_BAD_RESPONSE");
}
return { authorizationUrl: authorizationUrl.toString(), expiresAt: response.expiresAt };
},
async claim(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; claimId: 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?: GoogleWorkspaceConnectorProfileId; 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?: GoogleWorkspaceConnectorProfileId; token: string }) {
await call("revoke", { ...values, profile: values.profile ?? "gmail.draft" }, { field: "token", value: values.token });
},
};
}
export type PaperclipIdGmailConnector = ReturnType<typeof createPaperclipIdGmailConnector>;
export type PaperclipIdGoogleWorkspaceConnector = PaperclipIdGmailConnector;
let capabilityCache: { key: string; expiresAt: number; profiles: GoogleWorkspaceConnectorProfileId[] } | null = null;
export async function paperclipIdGoogleConnectorCapabilitiesFromEnv(
env: NodeJS.ProcessEnv = process.env,
): Promise<GoogleWorkspaceConnectorProfileId[]> {
const config = paperclipIdGmailConnectorConfigFromEnv(env);
if (!config) return [];
const key = `${config.baseUrl}|${config.instanceId}|${config.environment}`;
if (capabilityCache?.key === key && capabilityCache.expiresAt > Date.now()) return capabilityCache.profiles;
const profiles = await createPaperclipIdGmailConnector({ config }).getCapabilities();
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 PaperclipIdConnectorError(`Paperclip ID ${curve} private key is invalid`, "CONNECTOR_CONFIG_INVALID");
}
}
function parseEnvelope(value: unknown, purpose: SealedEnvelope["purpose"]): 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
|| 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,
profile?: GoogleWorkspaceConnectorProfileId,
): SealedGmailCredentials {
try {
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");
const aadFields: Array<string | number> = [1, SEAL_ALGORITHM, envelope.purpose, instanceId, environment];
if (profile) aadFields.push(profile);
const aad = Buffer.from(aadFields.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(),
]);
const parsed = JSON.parse(plaintext.toString("utf8")) as Partial<SealedGmailCredentials>;
if (parsed.v !== 1 || typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0
|| !(parsed.refreshToken === null || typeof parsed.refreshToken === "string")
|| typeof parsed.tokenType !== "string" || typeof parsed.accessTokenExpiresAt !== "string"
|| !Array.isArray(parsed.scopes) || !parsed.scopes.every((scope) => typeof scope === "string")
|| typeof parsed.subject !== "string" || typeof parsed.companyId !== "string") {
throw badEnvelope();
}
return parsed as SealedGmailCredentials;
} catch (error) {
if (error instanceof PaperclipIdConnectorError) throw error;
throw badEnvelope();
}
}
function badEnvelope() {
return new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid sealed credential", "CONNECTOR_BAD_RESPONSE");
}
function sealPurpose(
kind: "initial" | "access",
profile: GoogleWorkspaceConnectorProfileId,
): SealedEnvelope["purpose"] {
if (profile === "gmail.draft") return kind === "initial" ? "gmail-initial-tokens" : "gmail-access-token";
return kind === "initial" ? "google-workspace-initial-tokens" : "google-workspace-access-token";
}
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 isLoopback(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
}
export type {
PaperclipCloudConnector as PaperclipIdGmailConnector,
PaperclipCloudConnector as PaperclipIdGoogleWorkspaceConnector,
PaperclipCloudConnectorConfig as PaperclipIdGmailConnectorConfig,
PaperclipCloudConnectorEnvironment as PaperclipIdConnectorEnvironment,
PaperclipCloudConnectorOperation as PaperclipIdConnectorOperation,
SealedGmailCredentials,
SealedGoogleWorkspaceCredentials,
} from "./paperclip-cloud-connector.js";

View File

@ -169,10 +169,11 @@ import { listConnectionLifecycleEvents } from "./tool-connection-activity.js";
import { ComposioApiError, createComposioClient, type ComposioClient } from "./composio.js";
import { composioChildConfig, createComposioSessionManager } from "./composio-session-manager.js";
import {
createPaperclipIdGmailConnector,
paperclipIdGmailConnectorConfigFromEnv,
type PaperclipIdGmailConnector,
} from "./paperclip-id-gmail-connector.js";
createPaperclipCloudConnector,
isPaperclipCloudConnectorStrategy,
paperclipCloudConnectorConfigFromEnv,
type PaperclipCloudConnector,
} from "./paperclip-cloud-connector.js";
import {
createVercelConnectClient,
deriveVercelConnectSubject,
@ -519,7 +520,9 @@ type ToolAccessServiceOptions = {
/** Test seam for Composio without live vendor traffic. */
composioClientFactory?: (apiKey: string) => ComposioClient;
/** Test seam for the centrally registered Gmail OAuth broker. */
paperclipIdGmailConnector?: PaperclipIdGmailConnector | null;
paperclipCloudConnector?: PaperclipCloudConnector | null;
/** @deprecated Use paperclipCloudConnector. */
paperclipIdGmailConnector?: PaperclipCloudConnector | null;
/** Test seam for Vercel Connect without live vendor traffic. */
vercelConnectClient?: VercelConnectClient | null;
};
@ -2078,13 +2081,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
});
const policySvc = toolAccessPolicyService(db);
const now = options.now ?? (() => new Date());
const gmailConnectorConfig = options.paperclipIdGmailConnector === undefined
? paperclipIdGmailConnectorConfigFromEnv()
: null;
const gmailConnector = options.paperclipIdGmailConnector
?? (gmailConnectorConfig
? createPaperclipIdGmailConnector({ config: gmailConnectorConfig, now: () => now().getTime() })
: null);
const configuredCloudConnector = options.paperclipCloudConnector ?? options.paperclipIdGmailConnector;
const connectorWasProvided = options.paperclipCloudConnector !== undefined || options.paperclipIdGmailConnector !== undefined;
let cachedCloudConnector = configuredCloudConnector ?? null;
const currentCloudConnector = (): PaperclipCloudConnector | null => {
if (cachedCloudConnector || connectorWasProvided) return cachedCloudConnector;
const config = paperclipCloudConnectorConfigFromEnv();
cachedCloudConnector = config
? createPaperclipCloudConnector({ config, now: () => now().getTime() })
: null;
return cachedCloudConnector;
};
const vercelConnect = options.vercelConnectClient === undefined
? createVercelConnectClient()
: options.vercelConnectClient;
@ -4896,7 +4903,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
response.status === 401
&& connection.authKind === "oauth"
&& connection.credentialSource === "paperclip_vault"
&& oauthConfig(connection).strategy !== "paperclip_id_connector"
&& !isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy)
) {
headers = {
...projectedConnectionHeaders(connection),
@ -8012,7 +8019,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
if (
connection.authKind !== "oauth"
|| connection.credentialSource !== "paperclip_vault"
|| oauth.strategy === "paperclip_id_connector"
|| isPaperclipCloudConnectorStrategy(oauth.strategy)
|| !oauthTokenUrl
|| !oauthProvider
) {
@ -8511,7 +8518,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}),
}
: { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true };
if (method?.oauthStrategy === "paperclip_id_connector") {
if (method && isPaperclipCloudConnectorStrategy(method.oauthStrategy)) {
const connectorProfile = method.connectorProfile;
if (!connectorProfile || !isGoogleWorkspaceConnectorProfileId(connectorProfile)) {
throw badRequest("This app has an invalid Google connector profile");
@ -8618,7 +8625,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
const credentialPolicy: ToolConnectionCredentialPolicy | undefined = personalIdentityUserId
? "per_user"
: undefined;
const connectionOwnership = method?.oauthStrategy === "paperclip_id_connector" ? "platform_shared" : "customer";
const connectionOwnership = isPaperclipCloudConnectorStrategy(method?.oauthStrategy) ? "platform_shared" : "customer";
let applicationRow: typeof toolApplications.$inferSelect | null = null;
let connectionRow: typeof toolConnections.$inferSelect | null = null;
let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = retainedConnection ?? null;
@ -9650,16 +9657,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
registrationSource: null,
};
}
if (galleryMethod?.oauthStrategy === "paperclip_id_connector") {
if (galleryMethod && isPaperclipCloudConnectorStrategy(galleryMethod.oauthStrategy)) {
const connectorProfile = galleryMethod.connectorProfile;
if (!connectorProfile || !isGoogleWorkspaceConnectorProfileId(connectorProfile)) {
throw badRequest("This app has an invalid Google connector profile");
}
const googleProfile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[connectorProfile];
const providerName = galleryEntry?.name ?? "Google Workspace";
if (!gmailConnector) {
const cloudConnector = currentCloudConnector();
if (!cloudConnector) {
throw unprocessable(`${providerName} connections through Paperclip are not available on this instance yet`, {
code: "paperclip_id_connector_unavailable",
code: "paperclip_cloud_connector_unavailable",
});
}
const binding = starterBinding;
@ -9676,10 +9684,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
await db.delete(toolOauthStates).where(lt(toolOauthStates.expiresAt, now()));
const state = randomOauthToken();
const returnUri = new URL(input.redirectUri);
returnUri.pathname = "/api/tools/oauth/paperclip-id/callback";
returnUri.pathname = "/api/tools/oauth/cloud-connector/callback";
returnUri.search = "";
returnUri.hash = "";
const session = await gmailConnector.startAuthorization({
const session = await cloudConnector.startAuthorization({
subject: subjectUserId,
companyId,
profile: connectorProfile,
@ -9694,9 +9702,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
state,
companyId,
connectionId: connection.id,
// Paperclip ID owns PKCE for this flow. The local state row remains the
// Paperclip Cloud owns PKCE for this flow. The local state row remains the
// single-use browser correlator and never stores broker token material.
codeVerifier: "paperclip-id-connector",
codeVerifier: "paperclip-cloud-connector",
createdByActorType: binding.actorType,
createdByActorId: binding.actorId,
createdBySessionId: binding.sessionId,
@ -9709,7 +9717,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
});
return {
connectionId: connection.id,
provider: "gmail",
provider: "google",
authorizationUrl: session.authorizationUrl,
expiresAt: expiresAt.toISOString(),
issuer: "https://accounts.google.com",
@ -9936,23 +9944,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return row ?? null;
}
/**
* Answer a pending authorization request exactly once (PAP-17109).
*
* Every terminal callback success, denial, cancel comes through here, so
* the row that authorizes a token exchange stops existing the moment the flow
* reaches an outcome. Two properties matter and they pull in opposite
* directions:
*
* - A stranger's callback must not *consume* the request. So the row is loaded
* and bound to the caller before anything is deleted; a failed binding check
* leaves the victim's flow live and completable.
* - A replayed callback must not *complete* the request. So the delete is the
* single statement that decides ownership: `RETURNING` hands the row to
* exactly one of two concurrent callbacks, and the loser is told the state is
* spent instead of exchanging a code against it.
*/
async function consumeOAuthState(state: string, actor: ActorInfo | undefined) {
/** Bind a callback to its initiating actor without consuming retryable state. */
async function validateOAuthState(state: string, actor: ActorInfo | undefined) {
const [stateRow] = await db
.select()
.from(toolOauthStates)
@ -9967,6 +9960,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
} else {
assertSameOAuthActor(stateRow, actor);
}
return stateRow;
}
/**
* Answer a terminal authorization callback exactly once. Actor validation
* happens before the atomic delete, so an unbound callback cannot consume a
* valid flow and concurrent callbacks cannot both complete it.
*/
async function consumeOAuthState(state: string, actor: ActorInfo | undefined) {
const stateRow = await validateOAuthState(state, actor);
const [consumed] = await db
.delete(toolOauthStates)
.where(eq(toolOauthStates.state, state))
@ -10078,42 +10081,48 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return finished;
}
async function completePaperclipIdGmailCallback(input: {
async function completePaperclipCloudConnectorCallback(input: {
state: string;
claimId?: string | null;
error?: string | null;
actor?: ActorInfo;
}): Promise<ConnectToolAppResult> {
const stateRow = await consumeOAuthState(input.state, input.actor);
// Keep the local state live until the sealed claim is in the durable vault.
// The broker binds repeat claim requests to this stable state value, so a
// transient broker, database, or secret-store failure can retry safely.
const stateRow = await validateOAuthState(input.state, input.actor);
let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId);
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
const method = galleryEntry ? connectionMethodForConnection(galleryEntry, connection) : null;
const providerName = galleryEntry?.name ?? "Google Workspace";
if (input.error) {
await rejectPendingOAuthInteraction(stateRow, input.actor);
const consumedState = await consumeOAuthState(input.state, input.actor);
await rejectPendingOAuthInteraction(consumedState, input.actor);
throw new HttpError(400, `Google authorization did not complete. Start a new ${providerName} connection to try again.`, {
code: input.error === "access_denied" ? "oauth_authorization_denied" : "paperclip_id_connector_failed",
code: input.error === "access_denied" ? "oauth_authorization_denied" : "paperclip_cloud_connector_failed",
});
}
if (!input.claimId) throw badRequest(`${providerName} callback is missing a claim identifier`);
if (!gmailConnector) {
const cloudConnector = currentCloudConnector();
if (!cloudConnector) {
throw unprocessable(`${providerName} connections through Paperclip are not available on this instance yet`, {
code: "paperclip_id_connector_unavailable",
code: "paperclip_cloud_connector_unavailable",
});
}
const subjectUserId = stateRow.subjectUserId;
if (method?.oauthStrategy !== "paperclip_id_connector" || !subjectUserId) {
if (!method || !isPaperclipCloudConnectorStrategy(method.oauthStrategy) || !subjectUserId) {
throw badRequest("OAuth state does not belong to a Google connector flow");
}
const connectorProfile = method.connectorProfile;
if (!connectorProfile || !isGoogleWorkspaceConnectorProfileId(connectorProfile)) throw badRequest("Google connector profile is invalid");
const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[connectorProfile];
const credentials = await gmailConnector.claim({
const credentials = await cloudConnector.claim({
subject: subjectUserId,
companyId: stateRow.companyId,
profile: connectorProfile,
claimId: input.claimId,
redemptionId: input.state,
});
const refreshToken = credentials.refreshToken;
if (!refreshToken) {
@ -10136,6 +10145,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
if (!membership) {
throw forbidden(`Your company membership no longer permits connection changes. Restore non-viewer access before you connect ${providerName} again.`);
}
const [consumedState] = await tx
.delete(toolOauthStates)
.where(and(
eq(toolOauthStates.state, input.state),
gte(toolOauthStates.expiresAt, new Date()),
))
.returning({ state: toolOauthStates.state });
if (!consumedState) throw badRequest("OAuth state was not found, expired, or has already been used");
const txSecrets = secretService(tx);
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
const [existingUserGrant] = await tx.select().from(connectionGrants).where(and(
@ -10175,7 +10192,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
name: providerName,
externalId: credentials.subject,
oauth: {
strategy: "paperclip_id_connector",
strategy: "paperclip_cloud_connector",
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
scopes: credentials.scopes,
tokenType: credentials.tokenType,
@ -10205,7 +10222,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
...connection.config,
oauth: {
...oauthConfig(connection),
strategy: "paperclip_id_connector",
strategy: "paperclip_cloud_connector",
provider: galleryEntry?.slug,
connectorProfile,
connectorSubjectUserId: subjectUserId,
@ -11241,7 +11258,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
peekOAuthState,
completePaperclipIdGmailCallback,
completePaperclipCloudConnectorCallback,
completeVercelConnectCallback,
@ -11929,42 +11946,13 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
providerRevocation = "failed";
}
}
} else if (oauthConfig(connection).strategy === "paperclip_id_connector" && gmailConnector) {
const connectorOauth = oauthConfig(connection);
const connectorSubject = currentGrant.subjectUserId ?? readConfigString(connectorOauth, "connectorSubjectUserId");
const connectorProfile = readConfigString(connectorOauth, "connectorProfile");
const tokenRef = currentGrant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token")
?? currentGrant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
if (tokenRef) {
try {
const token = await secrets.resolveSecretValue(
connection.companyId,
tokenRef.secretId,
tokenRef.versionSelector ?? "latest",
{
consumerType: "tool_connection",
consumerId: connection.id,
configPath: tokenRef.configPath,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
},
);
if (!connectorSubject || !connectorProfile || !isGoogleWorkspaceConnectorProfileId(connectorProfile)) {
throw new Error("Google connector binding is incomplete");
}
await gmailConnector.revoke({
subject: connectorSubject,
companyId: connection.companyId,
profile: connectorProfile,
token,
});
providerRevocation = "success";
} catch {
// Local revocation is authoritative for Paperclip and must not be
// rolled back because Google or the broker is temporarily offline.
providerRevocation = "failed";
}
}
} else if (isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy)) {
// Google revocation is client-wide for a user. The managed Workspace
// profiles intentionally share one Paperclip-owned client, so revoking
// one token here could invalidate unrelated Gmail, Drive, and Calendar
// grants. A per-profile removal is therefore local-only. A future
// provider-level disconnect must warn that it removes every profile.
providerRevocation = "local_only_shared_client";
}
const grant = await db.transaction(async (tx) => {
const removedDelegations = await tx.delete(connectionGrantDelegations).where(and(

View File

@ -91,11 +91,12 @@ import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js";
import { composioChildConfig, createComposioSessionManager } from "./composio-session-manager.js";
import type { ComposioClient } from "./composio.js";
import {
createPaperclipIdGmailConnector,
paperclipIdGmailConnectorConfigFromEnv,
PaperclipIdConnectorError,
type PaperclipIdGmailConnector,
} from "./paperclip-id-gmail-connector.js";
createPaperclipCloudConnector,
isPaperclipCloudConnectorStrategy,
paperclipCloudConnectorConfigFromEnv,
PaperclipCloudConnectorError,
type PaperclipCloudConnector,
} from "./paperclip-cloud-connector.js";
import {
createVercelConnectClient,
vercelGrantReference,
@ -832,7 +833,9 @@ export function createToolGatewayService(
/** Test seam for Composio session creation without vendor traffic. */
composioClientFactory?: (apiKey: string) => ComposioClient;
/** Test seam for refreshing personal Gmail grants. */
paperclipIdGmailConnector?: PaperclipIdGmailConnector | null;
paperclipCloudConnector?: PaperclipCloudConnector | null;
/** @deprecated Use paperclipCloudConnector. */
paperclipIdGmailConnector?: PaperclipCloudConnector | null;
/** Refreshes customer-owned/DCR OAuth grants before remote MCP execution. */
oauthGrantRefresher?: (input: {
companyId: string;
@ -871,13 +874,17 @@ export function createToolGatewayService(
const interactions = issueThreadInteractionService(db);
const policyService = toolAccessPolicyService(db);
const secrets = secretService(db);
const gmailConnectorConfig = options.paperclipIdGmailConnector === undefined
? paperclipIdGmailConnectorConfigFromEnv()
: null;
const gmailConnector = options.paperclipIdGmailConnector
?? (gmailConnectorConfig
? createPaperclipIdGmailConnector({ config: gmailConnectorConfig, now: options.now })
: null);
const configuredCloudConnector = options.paperclipCloudConnector ?? options.paperclipIdGmailConnector;
const connectorWasProvided = options.paperclipCloudConnector !== undefined || options.paperclipIdGmailConnector !== undefined;
let cachedCloudConnector = configuredCloudConnector ?? null;
const currentCloudConnector = (): PaperclipCloudConnector | null => {
if (cachedCloudConnector || connectorWasProvided) return cachedCloudConnector;
const config = paperclipCloudConnectorConfigFromEnv();
cachedCloudConnector = config
? createPaperclipCloudConnector({ config, now: options.now })
: null;
return cachedCloudConnector;
};
const gmailRefreshFlights = new Map<string, Promise<typeof connectionGrants.$inferSelect>>();
const vercelConnect = options.vercelConnectClient === undefined
? createVercelConnectClient()
@ -2618,13 +2625,13 @@ export function createToolGatewayService(
return resolved.value;
}
async function maybeRefreshPaperclipIdGoogleGrant(
async function maybeRefreshPaperclipCloudGoogleGrant(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
): Promise<typeof connectionGrants.$inferSelect> {
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (oauth?.strategy !== "paperclip_id_connector") return grant;
if (!oauth || !isPaperclipCloudConnectorStrategy(oauth.strategy)) return grant;
const configuredProfile = oauth.connectorProfile;
const connectorProfile: GoogleWorkspaceConnectorProfileId = configuredProfile === undefined
? "gmail.draft"
@ -2645,10 +2652,24 @@ export function createToolGatewayService(
: Number.NaN;
const currentTime = options.now?.() ?? Date.now();
if (Number.isFinite(expiresAt) && expiresAt > currentTime + 60_000) return grant;
if (oauth.strategy === "paperclip_id_connector") {
// Paperclip ID used different endpoints, signing metadata, envelope
// purposes, and a different Google client. Its refresh token cannot be
// exchanged through Paperclip Cloud. Let an unexpired access token finish
// its useful life, then require an explicit managed-connector enrollment
// and provider reconnect instead of sending it to the wrong client.
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(currentTime) })
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(409, "Legacy Google authorization must be reconnected through Paperclip Cloud", "google_reauthorization_required", {
connectionId: connection.id,
grantId: grant.id,
});
}
const existingFlight = gmailRefreshFlights.get(grant.id);
if (existingFlight) return existingFlight;
const refresh = (async () => {
if (!gmailConnector || !connectorSubject) {
const cloudConnector = currentCloudConnector();
if (!cloudConnector || !connectorSubject) {
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(currentTime) })
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(409, "Google authorization must be reconnected", "google_reauthorization_required", {
@ -2668,7 +2689,7 @@ export function createToolGatewayService(
}
const refreshToken = await resolveGrantSecretValue(session, connection, grant, refreshRef);
try {
const credentials = await gmailConnector.refresh({
const credentials = await cloudConnector.refresh({
subject: connectorSubject,
companyId: connection.companyId,
profile: connectorProfile,
@ -2682,7 +2703,7 @@ export function createToolGatewayService(
...(grant.providerTenant ?? {}),
oauth: {
...(grant.providerTenant?.oauth ?? {}),
strategy: "paperclip_id_connector",
strategy: "paperclip_cloud_connector",
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
scopes: credentials.scopes,
tokenType: credentials.tokenType,
@ -2700,7 +2721,7 @@ export function createToolGatewayService(
return updated;
} catch (error) {
if (error instanceof ToolGatewayHttpError) throw error;
if (error instanceof PaperclipIdConnectorError && error.code === "REAUTHORIZATION_REQUIRED") {
if (error instanceof PaperclipCloudConnectorError && error.code === "REAUTHORIZATION_REQUIRED") {
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(options.now?.() ?? Date.now()) })
.where(eq(connectionGrants.id, grant.id));
throw new ToolGatewayHttpError(409, "Google authorization must be reconnected", "google_reauthorization_required", {
@ -2814,12 +2835,12 @@ export function createToolGatewayService(
});
}
}
grant = await maybeRefreshPaperclipIdGoogleGrant(session, connection, grant);
grant = await maybeRefreshPaperclipCloudGoogleGrant(session, connection, grant);
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (
connection.authKind === "oauth"
&& connection.credentialSource === "paperclip_vault"
&& oauth?.strategy !== "paperclip_id_connector"
&& !isPaperclipCloudConnectorStrategy(oauth?.strategy)
&& options.oauthGrantRefresher
) {
try {
@ -3933,7 +3954,7 @@ export function createToolGatewayService(
response.status === 401
&& connection.authKind === "oauth"
&& connection.credentialSource === "paperclip_vault"
&& oauth?.strategy !== "paperclip_id_connector"
&& !isPaperclipCloudConnectorStrategy(oauth?.strategy)
&& options.oauthGrantRefresher
) {
credentialHeaders = {

View File

@ -100,6 +100,16 @@ export type ToolGalleryResponse = {
};
};
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
export type CloudConnectorEnrollmentStatus = {
configured: boolean;
status: "not_configured" | "unenrolled" | "pending" | "active" | "suspended" | "unverified";
brokerBaseUrl: string;
instanceId: string | null;
environment: "development" | "staging" | "production";
origins: string[];
verificationUrl?: string;
expiresAt?: string;
};
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
expiresAt?: string | Date | null;
};
@ -276,6 +286,10 @@ export type ToolPolicyTestResponse = {
};
export const toolsApi = {
getCloudConnectorEnrollment: () =>
api.get<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment"),
startCloudConnectorEnrollment: (companyId: string, label?: string) =>
api.post<CloudConnectorEnrollmentStatus>("/tools/oauth/cloud-connector/enrollment", { companyId, label }),
// --- Applications ---
listGallery: (companyId: string) =>
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),

View File

@ -13,6 +13,8 @@ const listAppsAttentionMock = vi.hoisted(() => vi.fn());
const listProfilesMock = vi.hoisted(() => vi.fn());
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
const archiveConnectionMock = vi.hoisted(() => vi.fn());
const getCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn());
const startCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
@ -25,6 +27,8 @@ vi.mock("@/api/tools", () => ({
listProfiles: (companyId: string) => listProfilesMock(companyId),
archiveConnection: (connectionId: string, options?: { confirmComposioChildren?: boolean }) =>
archiveConnectionMock(connectionId, options),
getCloudConnectorEnrollment: () => getCloudConnectorEnrollmentMock(),
startCloudConnectorEnrollment: (companyId: string, label?: string) => startCloudConnectorEnrollmentMock(companyId, label),
},
}));
@ -165,6 +169,14 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
listProfilesMock.mockResolvedValue({ profiles: [] });
listUserDirectoryMock.mockResolvedValue({ users: [] });
archiveConnectionMock.mockResolvedValue(connection({ id: "c-deleted", status: "archived" }));
getCloudConnectorEnrollmentMock.mockResolvedValue({
configured: true,
status: "active",
brokerBaseUrl: "https://my.paperclip.app",
instanceId: "instance-test",
environment: "development",
origins: ["http://localhost:3100"],
});
container = document.createElement("div");
document.body.appendChild(container);
});

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AppWindow, Loader2, ShieldAlert, ShieldQuestion, Trash2 } from "lucide-react";
import { AppWindow, Cloud, Loader2, ShieldAlert, ShieldCheck, ShieldQuestion, Trash2 } from "lucide-react";
import type {
ToolApplication,
ToolConnection,
@ -159,6 +159,21 @@ export function Connections() {
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const connectorEnrollmentQuery = useQuery({
queryKey: ["cloud-connector", "enrollment"],
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
});
const startConnectorEnrollment = useMutation({
mutationFn: () => toolsApi.startCloudConnectorEnrollment(selectedCompanyId!, selectedCompany?.name),
onSuccess: (status) => {
if (status.verificationUrl) window.location.assign(status.verificationUrl);
},
onError: (error) => pushToast({
title: "Couldnt reach Paperclip Cloud",
body: error instanceof Error ? error.message : "Try again in a moment.",
tone: "error",
}),
});
const deleteConnection = useMutation({
mutationFn: (target: {
@ -298,7 +313,19 @@ export function Connections() {
const loading = applicationsQuery.isLoading || connectionsQuery.isLoading || galleryQuery.isLoading;
return (
<div className="max-w-5xl">
<div className="max-w-5xl space-y-5">
{!connectorEnrollmentQuery.isLoading ? (
<CloudConnectorEnrollmentBanner
status={connectorEnrollmentQuery.data}
unavailable={connectorEnrollmentQuery.isError}
busy={startConnectorEnrollment.isPending}
onEnable={() => {
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
if (verificationUrl) window.location.assign(verificationUrl);
else startConnectorEnrollment.mutate();
}}
/>
) : null}
{loading ? (
<div className="space-y-3">
<Skeleton className="h-8 w-40" />
@ -552,6 +579,57 @@ export function Connections() {
);
}
function CloudConnectorEnrollmentBanner({
status,
unavailable,
busy,
onEnable,
}: {
status: Awaited<ReturnType<typeof toolsApi.getCloudConnectorEnrollment>> | undefined;
unavailable: boolean;
busy: boolean;
onEnable: () => void;
}) {
if (status?.configured) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<ShieldCheck className="h-5 w-5 text-primary" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">Paperclip-managed sign-in is ready</div>
<div className="truncate text-xs text-muted-foreground">
Provider authorization uses {status.brokerBaseUrl}; credentials stay in this instance.
</div>
</div>
</div>
);
}
if (unavailable) {
return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<Cloud className="h-5 w-5 text-muted-foreground" />
<div className="text-sm text-muted-foreground">Paperclip Cloud enrollment status is unavailable.</div>
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<Cloud className="h-5 w-5 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">
{status?.status === "pending" ? "Finish Paperclip Cloud enrollment" : "Enable Paperclip-managed sign-in"}
</div>
<div className="text-xs text-muted-foreground">
Confirm this servers exact address before Cloud can return encrypted Google credentials to it.
</div>
</div>
<Button variant="outline" size="sm" disabled={busy} onClick={onEnable}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{status?.status === "pending" ? "Continue enrollment" : "Enable"}
</Button>
</div>
);
}
function FilterChip({
active,
tone = "default",