PointUp is a hybrid agent app: humans use the dashboard, and their agents (Claude, ChatGPT, scripts) can manage the portfolio and — with explicit consent — read balances from provider sites in the user's own signed-in browser and write them back.
flowchart LR
subgraph Agents
C[Claude plugin<br/>skills + agent]
G[ChatGPT<br/>GPT Action / connector]
S[Scripts / other MCP clients]
end
subgraph Local["User's machine"]
B[Browser / computer use<br/>already signed in]
end
M[apps/mcp<br/>stateless MCP server]
API[Next.js API v1<br/>bearer pu_… or Clerk session]
UC[Core use cases<br/>scope + consent + guardrails]
DB[(Postgres / Supabase<br/>RLS on, no policies)]
C -- MCP --> M
S -- MCP --> M
G -- Actions OpenAPI --> API
G -- MCP connector --> M
M -- same token --> API
C -. drives .-> B
G -. agent mode .-> B
B -- reads balance --> C
API --> UC --> DB
| Concept | Type | Rule it enforces |
|---|---|---|
AccessToken |
aggregate | SHA-256 hash stored only; plaintext shown once; scoped; expiring; revocable |
ConsentGrant |
aggregate | Per provider, 1–90 days, revocable; no consent → no write-back |
AgentSkill |
catalog (data) | Allowed hosts, start URL, steps, version, verifiedAt, notes; one browser + one computer-use skill per provider |
SubmitObservation |
use case | skill/source checks → protected current credential/consent + owner locks → replay/plausibility → atomic balance + receipt + events |
AgentObservation |
audit log | Durable capture receipt with guarded review transitions; stores host/digest, not full URL; private versioned witnesses |
Ports/adapters follow the existing pattern: repositories are interfaces in the
domain, Drizzle adapters live in infrastructure/repositories/drizzle-agent-repositories.ts,
and tests run against in-memory fakes and a real Postgres
(test/drizzle-agent.integration.test.ts, enabled by TEST_DATABASE_URL).
- No credentials. Agents read a page the user is already signed in to. Skills tell agents to stop on login/MFA/CAPTCHA. PointUp never receives a password.
- Least privilege tokens.
portfolio:read,portfolio:write,observations:write,consents:manage. Browser sessions are trusted for all; tokens only for what they hold. Token minting, consent granting, and confirming/rejecting held readings are session-only: a token can't create tokens or approve its own consent.consents:managelets a token revoke consents only. Browser-only actions reject any Authorization header, including a verified Clerk bearer. Explicit credentials never fall back to cookies or dev identity. Clerk JWTs are verified independently and must agree with Clerk session identity. Cookie mutations require an Origin matching canonicalAPP_URLor the direct host’s expected scheme; forwarded hosts grant no trust. Request bodies must beapplication/json; verified bearers are exempt from CSRF. - Consent is separate from scope. Even with
observations:write, the write is refused (CONSENT_REQUIRED, 403) without an active per-provider consent. Consent is granted only by the signed-in user on Dashboard → Agents (the grant is transactional: one open consent per user and program, enforced by a partial unique index). The MCP toolpointup_request_consentnever grants: it validates the provider id and returns the dashboard link and the catalog display name. There is intentionally no grant tool and no elicitation grant, because elicitation answers are attested by the client, which a hostile or prompt-injected agent controls. - Allow-listed hosts.
sourceUrlrequires HTTPS without userinfo or nondefault ports, on an exact catalog host: apex, www or the seeded start-URL host. Arbitrary subdomains and lookalike suffixes are rejected. Catalog skills remain unverified until checked against the actual provider. - Plausibility guard with a human gate. A reading is held as
needs_review(and not written) when it jumps ≥10× from the last balance, or exceeds the skill's sanity cap (maxPoints, default 5,000,000) — including a first reading. The response carries a server-issued, single-usereviewId. Only the signed-in user can release it (POST /api/v1/agent/observations/{id}/confirm, or Reject, session-only; the Pending review section of Dashboard → Agents). Reviews expire after 24 hours. Confirmation locks the owned account/receipt and checks live expiry and the exact baseline snapshot ID before commit; a different snapshot with equal points is still stale. A private salted witness also binds the stored account membership at submission and is rechecked by the locked balance writer. Historical held receipts without that identity evidence cannot be confirmed; reject them and request a fresh capture. Notes/tags changes alone preserve identity. Expiry after blocking/writing rolls back the full effect; owner rejection remains available after expiry for cleanup. There is no agent-suppliedconfirmedflag. Limitation:sourceUrland the reported value are self-reported by the agent. The host allow-list proves what the agent claims it read, not that it read it, and anything below the guards above is accepted. Treat agent-written balances as provenance-tagged (source: agent) hints, not verified data. - Account creation.
observations:writealone cannot create accounts: passingmembershipNumberto auto-link needsportfolio:write(or a session); otherwise the write fails withLOYALTY_ACCOUNT_NOT_FOUNDand the user links the program first. The locked current PAT scopes are checked again at the write boundary; an earlier HTTP permission flag cannot authorize auto-linking. - Audit. Accepted recorded/unchanged/held/rejected submissions create durable
receipts visible at Dashboard → Agents. Denied or rolled-back attempts do
not fabricate committed receipts. New authenticated receipts preserve trusted
credential and consent witnesses, catalog version, method/hash and exact
baseline/recorded snapshot IDs. Agent labels and source claims are self-reported.
Public DTOs omit private witnesses. Balance, receipt and outbox effects share
one atomic transaction, retaining
source: agentand existing retention rules. - Database. RLS is enabled on every table with no policies (migration
0009), so Supabase's auto-generated REST API exposes nothing.
Optional caller UUID captureId and sourceMethod are additive to the existing
capture contract; optional result observationId identifies the server receipt.
The caller key is distinct from server review/receipt IDs. Under the owner/key
lock, identical claims return the current receipt without another effect; changed
claims return OBSERVATION_REPLAY_CONFLICT (409). Omitted capture times have a
stable omission marker. Same-owner credential rotation still requires current
credential and active provider consent; the receipt keeps its original witnesses.
A resolved held receipt stays resolved on replay, with no actionable review ID.
Calls without a key preserve legacy admission and lack stable replay recovery.
The extension freezes the exact PAT request before sending, retains it through its 25-second timeout/restarts, and binds retry to the configured endpoint/token. It keeps newer candidates separate and stores 16 completion/discard tombstones against late hydration messages. Held, rejected and uncertain results retain recovery context. It opens its own reused dashboard tab and cannot approve. The existing session-token manual-write path remains separate from replay safety. MCP forwards caller keys/times unchanged, keeps per-request PAT authority and adds no consent-grant or review tool. See client protocol and backend/migration record.
| Surface | Where | Auth |
|---|---|---|
| MCP (stdio) | apps/mcp (POINTUP_TOKEN) |
PAT |
| MCP (remote HTTP, stateless) | node apps/mcp/dist/index.mjs --http or Dockerfile.mcp |
PAT per request, forwarded to the API |
| Chrome extension | apps/extension (see extension.md) |
PAT (pu_, consent-gated agent endpoint) or session token |
| Claude Code plugin | plugins/claude + .claude-plugin/marketplace.json |
PAT via POINTUP_TOKEN |
| ChatGPT GPT Action | plugins/chatgpt (spec generated from the zod contracts) |
PAT as API-key bearer |
| ChatGPT / claude.ai connector | remote MCP URL | PAT header |
Read: pointup_get_portfolio_summary, _list_accounts, _get_account, _list_providers,
_get_balance_history, _list_expiring, _get_value_advice, _list_goals, _list_activity,
_plan_redemption (optimizer), _list_sweet_spots, _list_transfer_bonuses.
Write: pointup_link_account, _record_balance (user-stated), _create_goal, _record_transfer_bonus (user-reported, stored unverified).
Agent: pointup_list_skills, _request_consent, _submit_balance, _list_observations.
Prompts: capture-balance, portfolio-review, find-deals.
pointup_plan_redemption is the entry point for "how should I use my points" (optimizer.md). Agents must relay each plan's caveats, never claim award availability unless a plan carries an availability object, and never transfer or book on the user's behalf. The Claude plugin ships the plan-redemption and find-deals skills; the ChatGPT spec exports planRedemption, listSweetSpots, listTransferBonuses and recordTransferBonus.
Resources: pointup://portfolio/summary (JSON) and pointup://skills/{skillId} (markdown playbook per skill).
The main read tools declare an outputSchema and return structuredContent
(list results are wrapped as { "items": [...] }, since MCP structured output
must be an object); the JSON text content is kept for older clients. Inputs are
validated before any API call (trimmed ids, shared-contract nonnegative safe-integer points, https-only
sourceUrl, YYYY-MM-DD goal dates).
POST /mcponly (stateless; GET/DELETE return 405). Body limit 1 MB (413), bad JSON is 400.GET /healthzreturnsokwithout auth.- Missing/non-
pu_token returns 401 withWWW-Authenticate: Bearer realm="pointup", resource_metadata="<public>/.well-known/oauth-protected-resource"; that document lists no authorization servers yet (PAT only, see below). - CORS for browser-based clients:
MCP_ALLOWED_ORIGINS(comma list, default*outside production and no origins in production; tokens are bearer headers, never cookies), preflight on OPTIONS. Public production hosts require canonical HTTPSMCP_PUBLIC_URL; CDK supplies it. Origin and/mcpforms normalize to the same resource. Both root and resource-specific protected-resource metadata paths are available. - Upstream URL/transport configuration is validated before listening. Malformed request targets return400 while the listener remains available. Unknown method/path labels are bounded, and request failures use fixed telemetry categories. Startup logs omit upstream configuration.
- Tool, resource and prompt errors omit raw upstream exception/API text and caller-controlled skill identifiers. Fixed public guidance retains validated request references for support.
- SIGTERM/SIGINT drain the listener, then drop lingering connections after 10 s.
- Covered by
apps/mcp/test/http.test.ts(real HTTP server, fake upstream API, SDKStreamableHTTPClientTransport).
Add the provider to PROVIDER_CATALOG and a seed in domain/agent/skill.ts
(start URL, allowed hosts, hint). Skills are unverified (verifiedAt: null,
unverified: true in the DTO, MCP output and playbooks) until a human checks the
start URL against the live site and sets verifiedAt (ISO datetime) in the seed;
bump version when URL, hosts or steps change. Agents are told start URLs are
best-effort while unverified. Browser and computer-use skills are generated
from the seed; the catalog test asserts the start URL sits on an allowed host.
- OAuth for ChatGPT/claude.ai connectors (per-user authorization instead of a pasted PAT). The PAT path is the supported one today.
- Deterministic scrapers (Playwright scripts) that run without an LLM; skills are playbooks for LLM agents.
withAuthenticatedUser applies a sliding-window limit per principal (token id
for PATs, user id for sessions): 120 req/min by default, 30/min for
token-authenticated writes, 10/min for POST /api/v1/agent/observations.
Exceeding it returns 429 with Retry-After and error code RATE_LIMITED.
The shipped InMemoryRateLimiter is per instance: counters live in one
process, so N instances allow up to N times the limit and a restart resets
them. For a global limit, implement the RateLimiter port
(packages/core/src/application/rate-limit.ts) over Redis (INCR + PEXPIRE)
or Upstash (@upstash/ratelimit) and return it from getRateLimiter() in
apps/web/src/server/rate-limit.ts; nothing else changes.
The API client defaults to HTTPS, with HTTP permitted for loopback development.
For an operator-controlled private Docker network, MCP may configure
POINTUP_TRUSTED_HTTP_ORIGIN=http://web:3000; Compose pins that exact origin.
The client accepts only a matching configured HTTP origin and rejects redirects,
credentials, query strings and fragments. Never derive this option from incoming
headers, tool arguments or browser input. External/hosted MCP should use HTTPS.