Skip to content

feat: add secure ChatGPT Thingtime plugin - #412

Merged
lopugit merged 33 commits into
developfrom
codex/thingtime-chatgpt-plugin
Aug 26, 2026
Merged

feat: add secure ChatGPT Thingtime plugin#412
lopugit merged 33 commits into
developfrom
codex/thingtime-chatgpt-plugin

Conversation

@lopugit

@lopugit lopugit commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a packaged Thingtime plugin under integrations/ChatGPT/plugin/thingtime-chatgpt.
  • Implements a narrow OAuth 2.1 + S256 PKCE MCP gateway with encrypted, multi-account PAT-backed connections and explicit API-origin allowlists.
  • Adds MCP OAuth discovery, an origin-scoped capability manifest, runtime/docs registration, user confirmation guidance for writes, and setup/testing documentation.

Security

  • Underlying Thingtime PATs are validated, AES-256-GCM encrypted server-side, and never returned to ChatGPT.
  • The OAuth bridge token is purpose-scoped to the MCP gateway, has no general Thingtime session authority, and supports revocation by disconnecting accounts.
  • The upstream surface is a fixed Things tool allowlist; arbitrary origins, redirects, and generic proxy paths are rejected.

Verification

  • node --import tsx --test app/api/utils/chatgpt/pluginCore.test.ts app/api/utils/chatgpt/plugin.test.ts (5 passed)
  • Targeted ESLint (passed; existing Remix future-change warning only)
  • npm run build in remix/ (passed, including Vercel output verification)
  • Local HTTP discovery/OAuth/MCP-auth challenge smoke test, plus desktop and 390px mobile authorization-page checks.

Follow-up before public installation

  • Configure THINGTIME_CHATGPT_CREDENTIAL_KEY as a deployment secret and deploy this branch to a public HTTPS origin.
  • Add that origin MCP URL in ChatGPT Developer mode; iOS developer-plugin surface availability needs confirmation on the user account.

@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thingtime Ready Ready Preview Aug 26, 2026 8:30am
thingtime (develop) Ready Ready Preview Aug 26, 2026 8:30am

Request Review

@github-actions
github-actions Bot temporarily deployed to develop-pr-412 August 25, 2026 14:01 Destroyed
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🧹 Develop S3 preview removed

The PR-specific alias and every workflow-created develop deployment were removed when this PR closed.

The ordinary generated Vercel Preview remains available on the shared development runtime.

@github-actions

Copy link
Copy Markdown
Contributor

Lopu review — two defects fixed, three for you to decide on

I compared the full head (cbde39e7) against develop (f51cb262). No failing
checks, and the CodeQL snapshot for this head has zero open alerts — nothing
to fix or dispose of there.

The security core holds up under pressure. Two things I tried to break and
couldn't, worth stating explicitly because they're the load-bearing claims:

  • The bridge token really can't act as a Thingtime session. resolveSessionUser
    gates on sessionPurposeCanActAsAccount, a strict allowlist, so all four new
    chatgpt-* purposes are rejected by construction.
  • The patTokens.ts change is load-bearing, not cosmetic. resolveThingsActor
    is a denylist whose fall-through grants full unscoped session authority. Without
    those four added purposes, a chatgpt-mcp bearer token would have been accepted
    as a full Things actor. Good catch on your part.

Fixed in this branch

1. Omitted tool filters were being sent upstream as the literal string "null".
stringValue() returns null for an absent argument, but the query builder only
skipped undefined and '':

list_thingtime_things   -> /api/v1/things?thingtime=null&folder=null
search_thingtime_things -> /api/v1/things/search?q=null&thingtime=null

This breaks the two most natural first calls. Upstream, /api/v1/things does
csv(params.get('thingtime'))['null'], so an unfiltered list filters on a
Thingtime kind named "null" in a folder named "null" and returns nothing;
search looks for the literal word "null". strictNullChecks: false is why the
string | nullstring | number | undefined mismatch compiled clean and the
build stayed green. I moved the loop into pluginCore.ts as applyUpstreamQuery,
matching the pure/side-effectful split you already set up between those two files —
that's what makes it testable without Mongo or jose.

2. escapeHtml in pluginCore.ts escaped only &. The map listed all five
characters but the pattern was /&/g:

escapeHtml('a&b<script>"x"\'y')  ->  'a&amp;b<script>"x"\'y'

plugin.ts had a second, correct copy — the two had diverged, which is exactly how
this survives review. Not currently exploitable (both call sites are a signed JWT and
values already through new URL().origin, and "/</> are forbidden host code
points), but it's a security primitive on the page that collects PATs, doing ~20% of
its job. Fixed the pattern and consolidated the two copies into one so they can't
drift again.

Added regression tests for both. Baseline was 7/7; now 9/9. I verified they're real
regressions: reverting both fixes makes exactly those two tests fail. Also rendered
the live connection page end-to-end (200, request JWT embedded, expected CSP) and
exercised the OAuth error page.

Your call — I deliberately didn't touch these

  1. Refresh reuse doesn't revoke the token family. Rotation is atomic and correct,
    but a replayed refresh token just fails. OAuth 2.1 BCP §4.14.2
    suggests revoking the family on reuse; revokeMcpConnection already does the exact
    right updateMany on meta.connectionSessionJti. I left it because the naive fix
    also punishes a client retrying after a lost response — that's a real UX tradeoff
    and it's yours to make.
  2. No rate limit on POST /oauth/authorize. GET /authorize is public and hands
    out a 10-minute reusable request JWT; the POST then validates up to 20 PATs per
    request upstream with distinct per-failure error messages. Mostly this launders
    per-IP limits on the upstream /api/v1/tokens/self rather than creating a new
    oracle, but it's an unauthenticated amplifier. RATE_LIMIT_DEFAULTS is the natural
    home — heads up that enforceRateLimit silently no-ops for a rule name that isn't
    registered there.
  3. That resolveThingsActor denylist should probably become an allowlist. Its
    default is full authority, so every future purpose is dangerous until someone
    remembers it — this PR needed four additions. Own PR though; it touches the shared
    PAT/browser path.

Non-blocking nits: the connection page ships script-src 'unsafe-inline' for a
12-line bootstrap while verify-vercel-output.mjs enforces no inline scripts for the
app shell; and initialize echoes params.protocolVersion back unvalidated, so a
client asking for a bogus version is told it was agreed.

Approve the direction — neither follow-up blocks merge.

— Lopu, Thingtime's principal developer and repository steward

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Deterministic product contract advisory

These examples are warning-only. They do not fail the build/API contexts or block this PR.

  • ✅ Web CI required-context examples
  • ⚠️ Thin-listener topology examples (exit 1)
Sanitized tail
node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

AssertionError [ERR_ASSERTION]: all-branch.yml must stay retired; Lopu PR manager owns its former public triggers

true !== false

    at file:///home/runner/work/thingtime/thingtime/remix/scripts/workflow-caller-contract.mjs:39:10
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) {
  generatedMessage: false,
  code: 'ERR_ASSERTION',
  actual: true,
  expected: false,
  operator: 'strictEqual',
  diff: 'simple'
}

Node.js v22.23.2

@github-actions github-actions Bot mentioned this pull request Aug 26, 2026
@lopugit

lopugit commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (3f58b43f1c1a3a66d3e668e9d6c233bcc7d6add4).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

⚠️ A review-required historical promotion is running for promote/pr-412-thingtime-chatgpt-plugin--to-main. The exact patch is recoverable, but current develop does not prove that it remains intended (review-required-ambiguous). The resulting PR must be reviewed before merge.

Immutable plan: 378edefa6e85aa97d0b2dc8323e3949963aafa9dd25ebf189495ad537c7e868f · workflow run

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Lopu — first, do not misread the +0/−0 stats; second, no changes requested

This PR is not empty. gh pr view 412 reports +0/−0, files=0, mergeable=UNKNOWN, but GET /repos/…/pulls/412/files returns the real list while GET /repos/…/compare/c9bbd46a…c9dfc0dc fails with 422 — Server Error: Sorry, this diff is taking too long to generate. The cause is graphify-out/graph.json: 40.9 MB, 1,034,838 lines, tracked and regenerated on this branch. GitHub's diff engine gives up, so the stats, the rendered diff and the mergeable computation all come back empty or unknown. Same symptom on #289; it is what leaves #180 and #410 conflicting. I raised it as a repository-health item on #289.

Reviewed from the manifest SHAs instead: 25 real files, +2126/−5, plus 126 graphify files.

The load-bearing line is in patTokens.ts, and it is present

resolveThingsActor rejects a known set of session purposes, then handles pat, and everything else falls through to "full browser/service session — no scope limits". So a new session purpose that is not added to that reject list is silently promoted to full session authority. You added all four (chatgpt-oauth-code, chatgpt-mcp, chatgpt-mcp-refresh, chatgpt-mcp-connection), so an MCP bridge token can never be replayed as a Things credential. Missing that would have been the single worst bug available here.

What else I verified

OAuth 2.1 authorization-code handling is textbook. Every binding — client, redirect URI, resource, PKCE method — lives in the findOneAndUpdate filter, so a mismatch cannot match rather than being compared after the fact, and revokedAt: null in the same filter makes replay lose the race atomically instead of double-spending. PKCE (S256 only, reusing the existing pkceVerifierMatches) is checked after consumption, so a failed verifier still burns the code. The refresh grant consumes and rotates the same way. iss on the callback plus authorization_response_iss_parameter_supported is what makes mix-up attacks detectable.

Redirect URIs cannot be chosen. ChatGPT's two callbacks are an exact-match Set; Codex's loopback callback is derived from the client ID (…/oauth/codex/<id>/client.json → exactly http://127.0.0.1:<port>/callback/<same id>, no userinfo/query/fragment). Tying the two together is the right call — it stops a client presenting a ChatGPT-hosted client ID and then naming an arbitrary local path, while still honouring RFC 8252's ephemeral port.

Crypto envelope is correct. AES-256-GCM, fresh 12-byte randomBytes IV per encryption, 16-byte tag, key with a strict length === 32 check that fails closed to 503 rather than degrading. Decryption validates the envelope shape before touching the cipher and re-parses the plaintext through parseCredentialBundle, so a forged-but-authentic blob still has to satisfy the schema.

No SSRF. Every upstream path is a hardcoded literal — nothing attacker-controlled reaches new URL(path, endpoint). The endpoint is re-validated against allowedThingtimeEndpoints() at call time, not just at connect time, so removing an origin from the allowlist immediately severs existing connections. redirect: 'error' and a 15 s AbortSignal.timeout; request bodies cap at 64 KiB and upstream responses at 512 KiB, both enforced by streaming byte count rather than trusting content-length.

Deployment plumbing is verified, not assumed. The three .well-known paths are non-API and would otherwise hit the SPA fallback and hand ChatGPT index.html. You route them to Nitro and add assertions to verify-vercel-output.mjs that the route exists and precedes both the filesystem and SPA fallbacks — including a careful serverFallbackIndex lookup that excludes the new route so the existing check keeps meaning what it meant. Build-time enforcement of a routing invariant is the right shape.

Index budget respected — the one new index is on sessions, not things, so it costs nothing against the 64-index ceiling #405 documents.

0 CodeQL alerts on this head, which is notable for a PR adding an OAuth server, an encryption envelope and an outbound HTTP gateway.

Notes, no change made

  • This claims the origin's OAuth identity. /.well-known/oauth-authorization-server now advertises the ChatGPT bridge's authorization_endpoint for the whole origin. Thingtime already runs a separate app-authorization system at /api/v1/oauth/authorize (embedded apps, the profile.*/email scope catalog — see Account birthday: private user field + exact profile.birthday scope #180 in this batch). Any future MCP or RFC 8414 client that discovers thingtime.com will be pointed here, not there. That may be exactly what you want — one authorization server per origin is the standard reading — but it is a durable decision worth writing down next to the discovery handler rather than leaving implicit.
  • Discovery is served public, max-age=300; correct for public metadata, just be aware that changing an allowlisted client ID or endpoint takes up to five minutes to propagate.
  • The connection page takes a pasted PAT with no Thingtime session of its own, which is right for a bridge (the PAT is the credential, and CSRF buys an attacker nothing but connecting their own account). Worth stating in SUBMISSION.md so a reviewer does not read the missing session gate as an oversight.

@lopugit

lopugit commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (5b55727011d6d5fd584b6230387d6887a706a5f4).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

@lopugit

lopugit commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (b7fc52ea7998ed66ad22aed39d26cd3b9bdb4072).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

@lopugit

lopugit commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (1c5fdfbafe287e67463758b5c0cf58d7ddf99c3a).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

@lopugit

lopugit commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (1c5fdfbafe287e67463758b5c0cf58d7ddf99c3a).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

⚠️ Source lineage is review-required-ambiguous; the resulting PR will carry source-lineage-unverified and must be reviewed for restoration intent.

@lopugit

lopugit commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (ae012e21a4218f99f8419c670338b267160bd657).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

⚠️ Source lineage is review-required-ambiguous; the resulting PR will carry source-lineage-unverified and must be reviewed for restoration intent.

@lopugit

lopugit commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (5364ece3efd72afd5a41cc535dc42fa4e08939e5).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

⚠️ Source lineage is review-required-ambiguous; the resulting PR will carry source-lineage-unverified and must be reviewed for restoration intent.

@lopugit

lopugit commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

🤖 Promotion conflict resolution was queued automatically for promote/pr-412-thingtime-chatgpt-plugin--to-main at exact base main (37d74d04941b3919d00a8b04ce168db9e0a2e466).

Conflicted source paths: remix/CHANGELOG.md.

The trusted worker will reconstruct, verify, publish, and attest the review branch; no manual branch update is needed.

⚠️ Source lineage is review-required-ambiguous; the resulting PR will carry source-lineage-unverified and must be reviewed for restoration intent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant