PayCargo · AI-Sandbox · Step 6.n

6.nOAuth abstraction in the Console: providers as data, connections per user

The Console owns OAuth as a foundation concern, not a per-cartridge concern. Providers are JSON manifests in SSM. Connections are per-user tokens stored in Secrets Manager with KMS at rest. A scheduler keeps tokens fresh. Cartridges read the current valid token for the requesting user with one call. Adding a provider is configuration; the OAuth flow code never changes.
Block: 6.n (Console doctrine; companion to 6.g Console, 6.i cartridge metadata, 6.k schedules) · State: Specced; v1 ships with Salesforce Sandbox + Production · Modules: substrate/modules/platform/oauth/, shared/modules/oauth/, console/lambda/oauth.js
Doctrine
OAuth lives in the Console, not in cartridges. Cartridges that need Salesforce ask for a current valid token for the current user; the foundation answers. The first cartridge to integrate Salesforce sees no OAuth code. The tenth cartridge to integrate GitBook sees no OAuth code either. Adding a provider is a JSON manifest in SSM and a Connected App on the provider side. The OAuth flow Lambda never changes.

Sections

  1. Why OAuth is a foundation concern
  2. Three concepts: provider, connection, consumer
  3. Connections are per-user (and why per-cartridge is a trap)
  4. Provider registry: data in SSM, not code
  5. The Setup tab
  6. Capability surface: the adapter layer
  7. Token refresh: why scheduler beats lazy
  8. Salesforce as the first provider
  9. Zero-npm OAuth implementation
  10. Where everything lives
  11. Cartridge consumption pattern
  12. Security
  13. Scope: v1 in / deferred
  14. Ship order

1. Why OAuth is a foundation concern

Every PayCargo cartridge that integrates a SaaS system of record (Salesforce, GitHub, GitBook, Monday) will need OAuth. If each cartridge implements its own OAuth flow, four things go wrong:

The Forge model already says cartridges declare; the platform enforces. OAuth is the same pattern. The Console owns the OAuth flow. Cartridges declare which providers they consume, and the foundation hands them a current valid token.

2. Three concepts: provider, connection, consumer

Distinct on purpose. Mixing them is the root cause of every OAuth abstraction that ages badly.

ConceptWhat it isIdentityStorage
Provider A registered OAuth target. Static configuration: authorize URL, token URL, scopes, PKCE flag, callback path, where the client credentials live. provider_id (e.g., salesforce-sandbox) SSM Parameter Store under /forge/oauth/providers/<id>
Connection One authenticated linkage between Forge and a provider, owned by a specific PayCargo user. Has tokens. connection_id = <provider_id>:<username> DDB row for metadata + Secrets Manager entry for tokens (KMS-encrypted)
Consumer A cartridge that needs a current valid access token for a connection to make API calls. The cartridge identity (its IAM role) + the user from the JWT N/A (reads via IAM-scoped Secrets Manager access)

Adding a provider is one SSM parameter. Adding a connection is one OAuth flow run by an end user. Adding a consumer is zero work for the OAuth layer; the cartridge calls getOAuthConnection('salesforce-sandbox') and it just works.

3. Connections are per-user (and why per-cartridge is a trap)

Doctrine A connection is owned by the user who authenticated it, and that user's identity rides through every cartridge that uses it. One Salesforce session per PayCargo user, reusable across every cartridge that user touches. No per-cartridge connections. No platform-shared connections. Per-user, all-cartridge.

The platform-shared option, briefly

Tempting first instinct: one "PayCargo Salesforce connection" the whole platform shares. Wrong, because every Salesforce API call would look like it came from a single service account, defeating Salesforce's audit log on our side. The Salesforce admins would have one row for everything. Compliance fails.

The per-cartridge option is worse

If every cartridge has its own Salesforce connection, every cartridge has its own login flow, its own refresh token, its own Connected App. The auth surface multiplies with N cartridges. Operators connect Salesforce five times, once per cartridge, with five sets of consent screens. Every cartridge's IAM role needs Salesforce client secret read access. The refresh scheduler iterates N times more.

And the operational insight that closes the case: we cannot escape a role boundary anyway. IAM permission boundaries already constrain what each cartridge's role can do. Layering an application-level "this connection is only for cartridge X" check on top of an IAM boundary that already enforces "this role can only do Y" is redundant complexity with no marginal safety. The role boundary is the trap door; application-level scoping is theater.

The per-user model that works

One connection per user per provider. mtang connects Salesforce Sandbox once in Setup. Every cartridge invocation made by mtang resolves getOAuthConnection('salesforce-sandbox') to mtang's token. jdoe needs to connect separately to make Salesforce calls under her own identity. Salesforce's audit log shows the right user every time.

PropertyWhy it matters
One refresh path per user per providerThe scheduler sweeps M users × P providers connections, deterministic.
Setup UI shows only your own connectionsEach user manages their own. No "which connection am I editing" confusion.
Audit trail aligns user identity end-to-endThe Cognito JWT username matches the Salesforce username on the API call. Audit reviewer sees one user, one trail.
Revocation is boundedIf jdoe leaves PayCargo, we revoke her one Salesforce connection. mtang's connection is untouched.

4. Provider registry: data in SSM, not code

A provider is a JSON manifest under /forge/oauth/providers/<provider_id> in SSM Parameter Store. Adding a provider means adding a Terraform aws_ssm_parameter resource. The OAuth flow Lambda reads the registry at runtime, never hard-codes provider URLs.

Example: Salesforce Sandbox manifest

{
  "id":                  "salesforce-sandbox",
  "display_name":        "Salesforce Sandbox",
  "icon":                "fa-solid fa-cloud-arrow-up",
  "authorize_url":       "https://test.salesforce.com/services/oauth2/authorize",
  "token_url":           "https://test.salesforce.com/services/oauth2/token",
  "revoke_url":          "https://test.salesforce.com/services/oauth2/revoke",
  "userinfo_url":        "https://test.salesforce.com/services/oauth2/userinfo",
  "scopes_requested":    ["api", "refresh_token", "offline_access"],
  "use_pkce":            true,
  "callback_url":        "https://console.paycargo.org/api/oauth/callback/salesforce-sandbox",
  "client_id_secret_arn": "arn:aws:secretsmanager:us-east-1:959228203854:secret:/forge/oauth/clients/salesforce-sandbox",
  "metadata_fields":     ["instance_url", "id"],
  "refresh_window_min":  60,
  "adapter":             "salesforce",
  "capabilities": [
    { "name": "soql",           "scope_required": "api", "summary": "Run a SOQL query, returns records" },
    { "name": "sobject.get",    "scope_required": "api", "summary": "Read one sobject by id" },
    { "name": "sobject.create", "scope_required": "api", "summary": "Create one sobject" },
    { "name": "sobject.update", "scope_required": "api", "summary": "Update one sobject by id" },
    { "name": "sobject.delete", "scope_required": "api", "summary": "Delete one sobject by id" },
    { "name": "describe",       "scope_required": "api", "summary": "Get sobject metadata" },
    { "name": "bulk.query",     "scope_required": "api", "summary": "Bulk API SOQL for large result sets" }
  ]
}

metadata_fields is the abstraction's escape hatch for provider-specific response fields. Salesforce returns instance_url in the token response; this manifest tells the OAuth flow to persist it as connection metadata. GitHub's token response has no instance URL; GitHub's manifest omits the field. No code change.

refresh_window_min: 60 tells the scheduler to refresh anything expiring in the next 60 minutes. Per-provider tunable.

adapter names which file in shared/modules/oauth/adapters/ implements this provider's capability surface. capabilities is the data-side declaration of what the adapter exposes. See section 6 below for what these do.

Provider catalog (v1 and roadmap)

Providerv1PKCENotes
salesforce-sandboxYesRequiredtest.salesforce.com
salesforce-productionYesRequiredlogin.salesforce.com
githubRoadmapSupportedPer-user PAT-style flow
gitbookRoadmapSupportedSpaces API
mondayRoadmapSupportedGraphQL API

The egress allowlist (Step 3) already lets these five domains out of the VPC. OAuth is the layer above that enforcement.

5. The Setup tab

New top-level tab in the Console app. Available to every authenticated user (not admin-gated): each user manages their own connections.

Layout

┌────────────────────────────────────────────────────────────────────┐ │ Setup │ │ Connect your OAuth providers so cartridges can act on your behalf │ ├────────────────────────────────────────────────────────────────────┤ │ │ │ Available Providers │ │ ┌───────────────────────┐ ┌───────────────────────┐ │ │ │ Salesforce Sandbox │ │ Salesforce Production │ │ │ │ test.salesforce.com │ │ login.salesforce.com │ │ │ │ [Connect] │ │ [Connect] │ │ │ └───────────────────────┘ └───────────────────────┘ │ │ │ │ Your Connections │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ Provider │ Connected as │ Status │ Expires │ ... │ │ │ │ SF Sandbox │ mtang@pc.demo.full │ OK │ 1h 47m │ ... │ │ │ │ SF Production │ mtang@pc.com │ OK │ 1h 12m │ ... │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────────┘

Status semantics

StatusColorMeaning
OKGreenLast refresh succeeded; token valid; cartridges can use it.
RefreshingYellowScheduler is currently rotating this token. Brief state, normally invisible.
Needs re-authAmberRefresh failed 3 times in a row. User must click Connect again. SES email sent on first transition into this state.
RevokedRedDisconnected, either by user click or by provider-side revocation. Cartridges using it fail until reconnected.

Actions per connection

6. Capability surface: the adapter layer

The token is necessary but not sufficient. A cartridge that receives a Salesforce access token still needs to know what Salesforce can do, how to call it, and which response fields matter. If every cartridge author re-derives that from Salesforce's docs, the abstraction has failed.

Doctrine A provider has two parts: token plumbing (Layer 1, the OAuth flow) and a capability surface (Layer 2, the adapter). Cartridges program against the adapter, never against raw provider URLs. Capabilities are declared in the provider manifest as data and implemented in a per-provider adapter file as code. Capabilities only attach to the runtime adapter if the connection's granted scopes cover them. Operators see capability availability in the Setup tab.

Why this layer exists

Without an adapter, the cartridge becomes a Salesforce expert. It hardcodes /services/data/v59.0/query. It builds query strings. It parses Salesforce's slightly-non-standard JSON. The next cartridge integrating Salesforce repeats every line of that. When Salesforce bumps its API version, the cartridges break in N places, not one.

With an adapter, the cartridge calls sf.soql('SELECT Id FROM Account'). The adapter owns the version, the URL shape, the error decoding, the pagination, the rate-limit handling. Cartridges treat Salesforce as a verb library, not as an HTTP server.

Two halves to a capability

Declared (data, in the SSM provider manifest)

"capabilities": [
  { "name": "soql",           "scope_required": "api", "summary": "Run a SOQL query, returns records" },
  { "name": "sobject.get",    "scope_required": "api", "summary": "Read one sobject by id" },
  ...
]

This serves two audiences. Operators see capability availability in the Setup tab (which capabilities a given connection actually enables, given what scopes Salesforce granted). Cartridge authors see a discoverable list of verbs without reading Salesforce docs.

Implemented (code, in the adapter file)

shared/modules/oauth/adapters/
  salesforce.js          # implements every capability for both salesforce-sandbox and salesforce-production
  github.js              # implements GitHub's capability set
  gitbook.js             # implements GitBook's capability set
  monday.js              # implements Monday's capability set
  _index.js              # adapter registry: maps adapter name from manifest to the module

Each adapter is one file, zero npm, fetch + JSON. The adapter is constructed from a connection and a manifest, returns an object whose methods are the named capabilities. Adapters never deal with tokens directly; they receive a thin connection handle that fetches the current valid token from Secrets Manager on demand.

The scope-to-capability gate

This is the subtle part. The connection record stores scopes_granted (the actual scopes Salesforce returned in the token response, which may be a subset of scopes_requested). The adapter factory only attaches methods whose scope_required is in the granted set. Methods that need an ungranted scope still exist on the adapter, but they throw a clear error rather than calling the API and getting a 403.

// Inside salesforce.js (paraphrased)
export function buildAdapter(connection, manifest) {
  const granted = new Set(connection.scopes_granted);
  const adapter = {};
  for (const cap of manifest.capabilities) {
    if (granted.has(cap.scope_required)) {
      adapter[cap.name] = wireImplementation(cap.name, connection);
    } else {
      adapter[cap.name] = () => {
        throw new Error(
          `Capability ${cap.name} requires scope ${cap.scope_required}, ` +
          `which was not granted on this connection. Reconnect from Setup with broader scopes.`
        );
      };
    }
  }
  return adapter;
}

Calling sf.bulk.query(...) on a connection that did not get the api scope fails fast in the Lambda with a debuggable message, instead of round-tripping to Salesforce for a 403.

What an operator sees in Setup

The connection row in the Setup table gains a Capabilities column showing what is actually available on each connection.

ProviderConnected AsScopes GrantedCapabilities AvailableStatus
Salesforce Sandbox mtang@pc.demo.full api refresh_token offline_access soql, sobject.{get,create,update,delete}, describe, bulk.query OK
Salesforce Sandbox jdoe@pc.demo.full id (none) — reconnect with broader scopes Limited

What a cartridge author sees

Opening shared/modules/oauth/adapters/salesforce.js in the editor shows the entire Salesforce surface as one file with JSdoc on every method. The cartridge author never looks up Salesforce API version numbers, endpoint paths, or quirks of the JSON shape.

/**
 * Run a SOQL query against this Salesforce connection.
 *
 * @param {string} soql          A SOQL query string
 * @param {object} [opts]
 * @param {number} [opts.limit]  Max records (default: no client-side limit)
 * @returns {Promise<{ totalSize: number, done: boolean, records: object[] }>}
 *
 * Example:
 *   const r = await sf.soql('SELECT Id, Name FROM Account LIMIT 10');
 *   for (const row of r.records) console.log(row.Name);
 */
async function soql(soql, opts = {}) { ... }

Capability matrix for the v1 + roadmap providers

ProviderCapabilities the adapter shipsApprox adapter size
Salesforce (sandbox + production)soql, sobject.get/create/update/delete, describe, bulk.query, userinfo~200 lines
GitHubissues.{get,list,create,update}, labels.{add,remove}, repos.getContent, pulls.{get,list,create}, actions.dispatch~250 lines
GitBookspaces.list, spaces.getContent, pages.{get,create,update}~120 lines
Mondayboards.{list,get}, items.{list,get,create,update}, columns.update~150 lines (GraphQL, single endpoint)

Audit log of capability use

Every capability call emits a structured oauth-capability-complete log line. Shape: { msg, user, provider_id, capability, durationMs, success, error? }. The obs Cartridges tab joins these by user and by provider so an audit reviewer can answer "what did mtang do on Salesforce yesterday" in one query.

Why this is not an SDK in the npm sense

An adapter is a thin verb wrapper. It does not model Salesforce's full object tree. It does not generate types. It does not parse SOQL or validate it. It passes the SOQL string through to Salesforce. If Salesforce supports it, it works. If not, the cartridge gets Salesforce's error back. We are not in the business of being a Salesforce SDK; we are in the business of giving cartridges a clean verb surface so that token plumbing and capability discovery are not their problem.

This is the right altitude for v1 and probably forever. If a cartridge does something exotic with Salesforce, it can use sf.raw(method, path, body) as the escape hatch, which every adapter provides.

7. Token refresh: why scheduler beats lazy

Tokens expire. Some pattern keeps them current. Four candidates, ranked.

PatternHow it worksVerdict
Scheduler sweep (chosen) EventBridge Scheduler fires every 10 min. Lambda iterates all connections, refreshes any expiring within refresh_window_min. Failures bumped onto retry; 3 consecutive failures flips status to needs-reauth. Chosen. Token always fresh in Secrets Manager. Refresh failures isolated from cartridge request path. Reuses Step 6.k EventBridge + DLQ + watchdog plumbing.
Lazy refresh on read Cartridge reads token from Secrets Manager. If expired, calls provider's refresh endpoint inline, writes new token back, retries. Rejected. Refresh failures hit user-facing latency. Concurrent cartridge invocations near expiry can race on the refresh and the loser sees a brief 401.
Reactive on 401 Cartridge uses cached token. On Salesforce 401, refreshes, retries. Rejected. Same race condition. Adds 200-500ms to the unlucky first call after expiry.
Provider webhook Provider notifies us when token is about to expire. Rejected. Almost no providers actually support this. Doesn't generalize.

The sweeper

┌──────────────────┐ │ EventBridge │ every 10 minutes │ Scheduler │ └────────┬─────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ oauth-refresh-sweeper Lambda │ │ 1. DDB query: pk=oauth-connection, every row │ │ 2. For each row where expires_at < now() + refresh_window_min: │ │ a. Read current refresh_token from Secrets Manager │ │ b. POST to provider.token_url with grant_type=refresh_token │ │ c. Write new access_token + refresh_token + expires_at │ │ back to Secrets Manager │ │ d. Update DDB metadata (last_refresh_at, expires_at) │ │ 3. On 3 consecutive refresh failures for one connection: │ │ a. Set status = "needs-reauth" │ │ b. SES-email the connection owner │ │ 4. Emit oauth-refresh-complete log line per attempt │ └─────────────────────────────────────────────────────────────────────┘

Same primitives as the obs narrative sweeper: wrapScheduledHandler writes start + end run records to DDB; the Step 6.k watchdog fires SES alerts if the sweeper itself misses its SLA.

Cost: at 10-minute cadence, the sweeper invokes 4,320 times per month. Lambda billing for a 5-second function call at 128 MB is roughly $0.10 / month. Negligible.

8. Salesforce as the first provider

Salesforce drives the abstraction shape because it carries every OAuth wrinkle. Once it works, every other provider is a subset.

Connected App setup (one-time, manual)

Pre-requisite, done by a Salesforce admin in Salesforce Setup → App Manager:

  1. Create New Connected App.
  2. Enable OAuth Settings. Callback URL: https://console.paycargo.org/api/oauth/callback/salesforce-sandbox (plus the production callback for the prod Connected App).
  3. OAuth Scopes: api, refresh_token, offline_access.
  4. Enable PKCE (Require Proof Key for Code Exchange).
  5. Refresh Token Policy: "Refresh token is valid until revoked" (lets the sweeper renew indefinitely).
  6. Copy consumer key (client_id) and consumer secret (client_secret).
  7. Hand off to the Forge operator; the operator runs a Terraform PR to write these into Secrets Manager at /forge/oauth/clients/salesforce-sandbox.

Two Connected Apps: one in the Salesforce sandbox instance, one in production. Two provider manifests in SSM. Two distinct client_id pairs. No environment toggling in code.

Salesforce-specific response handling

Salesforce's token response carries instance_url, which differs per Salesforce org. Cartridges making API calls need it ({instance_url}/services/data/...). The provider manifest's metadata_fields: ["instance_url", "id"] tells the OAuth flow to persist it as connection metadata. The cartridge consumption helper returns it alongside the access token:

const sf = await getOAuthConnection('salesforce-sandbox');
// {
//   accessToken: 'sf-eyJ...',
//   metadata: {
//     instance_url: 'https://paycargodev.my.salesforce.com',
//     id: 'https://test.salesforce.com/id/00DXX.../005XX...'
//   },
//   expiresAt: '2026-06-10T22:17:00Z'
// }

OAuth flow sequence

User Setup UI Console Lambda Salesforce │ │ │ │ │ click [Connect] │ │ │ │ ────────────────────>│ │ │ │ │ POST /api/oauth/ │ │ │ │ connect/sf-sandbox │ │ │ │ ────────────────────────>│ │ │ │ │ generate state + │ │ │ │ PKCE verifier; │ │ │ │ store state in DDB │ │ │ │ (10-min TTL) │ │ │ authorize_url + params │ │ │ │ <────────────────────────│ │ │ 302 to Salesforce │ │ │ │ <────────────────────│ │ │ │ │ │ authenticate, grant consent │ │ ─────────────────────────────────────────────────────────────────────────>│ │ │ │ 302 to /api/oauth/ │ │ callback/sf-sandbox │ │ with code + state │ │ <─────────────────────────────────────────────────────────────────────────│ │ │ │ │ │ GET /api/oauth/ │ │ │ │ callback/sf- │ │ │ │ sandbox?code=&state= │ │ │ ────────────────────────────────────────────────>│ │ │ │ │ │ │ verify state from DDB │ │ │ POST token_url │ │ │ with code + verifier │ │ │ ───────────────────────>│ │ │ │ │ │ access + refresh tokens │ │ │ + instance_url + id │ │ │ <───────────────────────│ │ │ │ │ │ write tokens to │ │ │ Secrets Manager (KMS) │ │ │ write metadata to DDB │ │ │ emit oauth-connect- │ │ │ complete log line │ │ │ │ │ 302 back to Setup tab with status=connected │ │ │ <────────────────────────────────────────────────│ │

9. Zero-npm OAuth implementation

The Console Lambda has the same no-npm rule as every cartridge Lambda. Node 20's built-ins cover everything.

NeedBuilt-in
HTTPS to authorize / token / revokefetch()
PKCE code_verifiercrypto.randomBytes(32).toString('base64url')
PKCE code_challengecrypto.createHash('sha256').update(verifier).digest('base64url')
State parameter (CSRF)crypto.randomBytes(32).toString('hex')
Form-encoded token request bodyURLSearchParams
Token storage@aws-sdk/client-secrets-manager (Lambda runtime)
Metadata storage@aws-sdk/client-dynamodb + lib-dynamodb (Lambda runtime)
SSM provider manifests@aws-sdk/client-ssm (Lambda runtime)

Helpers live under shared/modules/oauth/, staged into lambda/_shared/oauth/ at apply time via the same local_file + archive_file pattern we established in shared/modules/.

shared/modules/oauth/
  README.md                # zero-npm doctrine + provider manifest shape + capability convention
  flow.js                  # createOAuthFlow({provider, userJwt}): { authorize_url, complete(code, state) }
  pkce.js                  # generatePkce() and verify helpers
  store.js                 # readConnection / writeConnection / deleteConnection (DDB + Secrets Manager)
  refresh.js               # refreshConnection({connection_id}): rotates tokens
  client.js                # getOAuthClient(provider_id, jwt): returns a runtime adapter
  adapters/
    _index.js              # adapter registry (manifest.adapter -> module)
    salesforce.js          # implements Salesforce capability set; used by both sandbox and production manifests
    github.js              # roadmap
    gitbook.js             # roadmap
    monday.js              # roadmap

10. Where everything lives

LayerWhatPath
Foundation (Terraform) OAuth module: SSM parameter prefix, Secrets Manager namespace, KMS grants, DDB key shape, refresh sweeper Lambda + schedule substrate/modules/platform/oauth/
Foundation (data) Provider manifests as SSM parameters /forge/oauth/providers/salesforce-sandbox
/forge/oauth/providers/salesforce-production
Foundation (secrets) OAuth client credentials per provider Secrets Manager: /forge/oauth/clients/<provider_id>
Shared modules Zero-npm OAuth helpers (flow, PKCE, store, refresh, client) shared/modules/oauth/
Console Lambda OAuth API routes; consumes shared/modules/oauth/ console/lambda/handler.js dispatches to console/lambda/oauth.js
Console frontend Setup tab (provider cards, connections table) console/frontend/setup.js + setup section in index.html
Cartridges Token consumption (via shared helper) Each cartridge: lambda/handler.js imports from ./_shared/oauth/client.js

Console API routes

MethodPathAuthPurpose
GET/api/oauth/providersJWTList registered providers from SSM
GET/api/oauth/connectionsJWTList the caller's connections (DDB query pk=oauth-connection, sk=<username>)
POST/api/oauth/connect/:providerJWTStart flow: generate PKCE + state, return authorize_url
GET/api/oauth/callback/:providerNone (state-validated)Exchange code for tokens, persist, redirect back to Setup
POST/api/oauth/disconnect/:connectionJWT (must match owner)Revoke + delete
POST/internal/oauth/sweepEventBridge principalRefresh sweeper (called by EventBridge Scheduler)

11. Cartridge consumption pattern

// In any cartridge's lambda/handler.js
import { getOAuthClient } from './_shared/oauth/client.js';

// jwtPayload is from the existing JWT verify path
const sf = await getOAuthClient('salesforce-sandbox', jwtPayload.username);
if (!sf) {
  return jsonResponse(409, {
    error: 'No Salesforce connection',
    detail: 'Open the Console Setup tab and click Connect on Salesforce Sandbox.',
    setupUrl: 'https://console.paycargo.org/setup',
  });
}

// Adapter exposes Salesforce capabilities as typed verbs.
// No fetch, no URLs, no Authorization header, no refresh handling.
const accounts = await sf.soql('SELECT Id, Name FROM Account LIMIT 10');
for (const row of accounts.records) console.log(row.Name);

const opp = await sf.sobject.create('Opportunity', {
  Name:      'Q3 PayCargo Pilot',
  CloseDate: '2026-09-30',
  StageName: 'Prospecting',
});

getOAuthClient reads the connection row, fetches the provider manifest, loads the named adapter, and constructs a runtime object whose methods are the capabilities granted by the connection's scopes. The cartridge calls verbs, never raw HTTP.

What the cartridge does not do

This is the abstraction's whole point. Cartridge code is small, declarative, and never changes when we swap providers or add new ones. The adapter and the manifest move; the cartridge does not.

Escape hatch

Every adapter ships a raw(method, path, body?) capability for the cases where the cartridge needs something the adapter does not yet expose. sf.raw('GET', '/services/data/v59.0/limits'). Same auth plumbing, same audit log. When a cartridge uses the escape hatch more than twice, the next adapter PR adds that capability natively.

12. Security

ConcernHandling
Tokens at restKMS-encrypted via the agent CMK in Secrets Manager. Read events flow into CloudTrail data events.
Tokens in transitHTTPS everywhere. CloudFront → API Gateway HTTP API → Lambda already enforced.
OAuth state CSRFRandom state per flow, stored short-lived in DDB under pk=oauth-state, sk=<state> with 10-minute TTL. Callback rejects unknown or expired state.
OAuth code interceptionPKCE on every provider that supports it (Salesforce: required).
Setup tab accessJWT-gated for any authenticated user; each user only sees their own connections. No admin gate (per-user model).
DisconnectCalls provider's revoke endpoint first, then deletes secret + DDB row. Failed revoke still completes local deletion (provider eventually expires the token).
Capability gateThe adapter only attaches methods whose scope_required is in connection.scopes_granted. Ungranted methods throw with a clear message instead of round-tripping to the provider for a 403.
AuditEvery connect / refresh / disconnect / token-fetch / capability-call emits a structured oauth-<event>-complete log line (using shared/modules/log.js) for the obs Cartridges tab to surface. Capability calls log provider_id + capability + durationMs + success.
Refresh failuresAfter 3 consecutive failed refreshes, connection flips to needs-reauth and SES-emails the owner. The user reconnects from the Setup tab.
Egress allowlist alignmentThe five providers (Salesforce sandbox + prod, GitHub, GitBook, Monday) align with the network egress allowlist from Step 3. OAuth does not add new external destinations; it authenticates against ones already permitted.

13. Scope: v1 in / deferred

In v1Deferred
Provider abstraction (data-driven, SSM-backed)GitHub provider (next)
Salesforce Sandbox + Production providersGitBook provider
Setup tab UI with Connect, Disconnect, Re-authenticateMonday provider
Background refresh sweeper (10-min cadence)Per-connection rate-limit configuration
Token storage in Secrets Manager (KMS-encrypted)Multi-account-per-user Salesforce (today: one Salesforce org per user per environment)
Cartridge consumption helperOAuth scope renegotiation UI (today: declared in provider manifest only)
Audit log surfacing in obs Cartridges tabSAML-based providers (different protocol, separate doctrine)
Refresh-failure SES emailOAuth 1.0 providers (none on roadmap)

14. Ship order

Five small PRs, each independently apply-able, each with a clear rollback.

  1. PR 1: shared/modules/oauth/ — helpers + first adapter. No TF, no Lambda wiring. Includes flow.js, pkce.js, store.js, refresh.js, client.js, and adapters/salesforce.js with the seven Salesforce capabilities and the raw escape hatch. Smoke-tested locally against a unit harness; integration smoke deferred to PR 5. Same shape as the JWT verify migration that landed earlier this week.
  2. PR 2: substrate/modules/platform/oauth/ — foundation module. KMS grants, Secrets Manager namespace, DDB key shape, SSM parameter prefix, refresh-sweeper Lambda + EventBridge Schedule (off until provider manifests land), audit log group.
  3. PR 3: Salesforce provider manifests + Connected App secrets — two SSM parameters, two Secrets Manager entries. Requires the Salesforce admin to have created the Connected Apps first (manual prereq).
  4. PR 4: Console Lambda + Setup tab UI — six new routes, Setup tab markup + JS. JWT-gated. Calls the helpers from PR 1, reads providers from PR 3.
  5. PR 5: First cartridge consumption — whichever cartridge needs Salesforce first (likely a future sales/quoting cartridge) imports getOAuthConnection and makes its first Salesforce call. End-to-end smoke test of the whole stack.

Custom domain prerequisite: PR 4's Setup tab and PR 5's cartridge token consumption work fine on the CloudFront URLs, but the Connected App callback URLs (configured in Salesforce admin) reference console.paycargo.org. That domain needs to be live before PR 3 lands. See the DNS / cert wire-up PR currently in flight.

Custom domain dependency

The Salesforce Connected Apps' callback URLs are registered against https://console.paycargo.org/api/oauth/callback/<provider_id>. We finalize the custom domain in the cert/Cognito wire-up PR (depends on DNS admin records being in place). Until then, the OAuth flow returns to the CloudFront URL and Salesforce rejects the callback. Sequence: DNS records propagate → custom domain wire-up applies → Salesforce Connected App created with the final callback → PR 3 ships.

Decisions locked

DecisionLocked answer
Provider registry mechanismSSM Parameter Store
Connection storage splitDDB metadata + Secrets Manager tokens
Token freshnessBackground scheduler sweep (10-min cadence)
Setup tab nameSetup (available to all authenticated users for their own connections)
Connection scopePer-user, all-cartridge. Per-cartridge rejected as a trap (IAM role boundaries already enforce scope; application-level repeat is theater).
Salesforce Connected AppManual one-time admin task in Salesforce. Confirmed for staging (sandbox + production) at the start.
Provider roadmapv1: Salesforce Sandbox + Production. Roadmap: GitHub, GitBook, Monday. (HubSpot removed.)
Capability surfaceTwo-layer abstraction. Layer 1 (token plumbing) covered in sections 1-5 and 7-9. Layer 2 (capability adapters) covered in section 6. Cartridges program against adapter verbs, never raw HTTP. Capabilities declared in manifest, implemented in shared/modules/oauth/adapters/<name>.js. Scope-to-capability gate enforced at adapter construction time.

References