Two integration families are designed in from the start: loyalty providers (airlines, hotels, credit card reward programs, rail, shopping portals) and credential vaults (1Password, Apple Keychain, Chrome's password manager). Both are modeled as application-layer ports with infrastructure adapters, so adding an integration never touches business logic.
- The catalog of supported programs lives in the domain layer (
packages/core/src/domain/loyalty/provider.ts): United, Delta, American, Marriott, Hilton, Hyatt out of the box. - The
TravelProviderGatewayport (packages/core/src/application/ports.ts) is the integration seam:
interface TravelProviderGateway {
supports(providerId: string): boolean;
fetchBalance(
account: LoyaltyAccount,
credential: ProviderCredential | null,
): Promise<ProviderBalance>;
}- The
CompositeTravelProviderGatewayroutes each sync to the first registered gateway that supports the provider.SimulatedTravelProviderGatewaycurrently handles every cataloged provider with deterministic fake balances for development.
- Add the program to the catalog if it isn't there (one line).
- Write an adapter in
packages/core/src/infrastructure/providers/, e.g.UnitedGateway implements TravelProviderGateway, calling the airline's API (or an aggregator such as an NDC/loyalty API vendor). - Register it in the shared
buildTravelProviderGateway()factory (packages/core/src/infrastructure/providers/build-gateway.ts, used by both web and worker), or compose real adapters directly:
const gateway = new CompositeTravelProviderGateway([
new UnitedGateway(unitedApiOptions),
]);No use case, route, or schema changes are required — this is the open/closed principle at work. Balance history accumulates automatically because syncs append balance_snapshot rows.
HttpAggregatorTravelProviderGateway is a real adapter for a loyalty-data
aggregator's HTTP API — one integration to cover the long tail of programs. It's
composed by the shared buildTravelProviderGateway() factory (used by both the
web app and the worker), which registers the aggregator when it's configured:
const gateway = buildTravelProviderGateway({
aggregator:
env.AGGREGATOR_API_URL && env.AGGREGATOR_API_KEY
? { baseUrl: env.AGGREGATOR_API_URL, apiKey: env.AGGREGATOR_API_KEY }
: undefined,
});Set AGGREGATOR_API_URL + AGGREGATOR_API_KEY (locally, or via CDK context
-c enableAggregator=true -c aggregatorApiUrl=… in AWS — the key becomes a
Secrets Manager placeholder) and sync attempts route through it. Configuration
does not prove vendor authorization or live provider coverage. Unconfigured or
unsupported real providers remain unavailable rather than replacing observed
balances with fake values. The vendor wire contract is:
POST {baseUrl}/v1/balance
Authorization: Bearer {apiKey}
{ "providerId", "membershipNumber", "credential"?: { "username", "secret" } }
→ 200 { "points": number }
Transient credentials the calling surface supplies are forwarded for one-time
use and never persisted. supportedProviderIds scopes the adapter to the
programs a given vendor actually covers.
The factory defaults to no simulated gateway. allowSimulation:true is an
explicit demo-only option; shared web/worker composition enables it only under
the existing AUTH_PROVIDER=dev context. Normal Clerk or omitted authentication
configuration cannot silently simulate a sync. A real adapter failure also does
not fall back to demo success. Unavailable sync leaves history, account metadata,
activity and outbox unchanged.
The dashboard distinguishes configured API sync, demo sync and unavailable sync. Unavailable programs link to manual recording and consented capture rather than offering an ordinary sync button. These capability labels represent configured adapters, not verified vendor connectivity. Official provider partnerships and live capture still need separate evidence.
Previous simulated snapshots used the same sync source as real adapters. Do not delete or automatically reassign historical rows from that label alone. Before production activation, inspect actual deployment history and data, identify any affected balances using reliable evidence and agree on reviewed repair/recovery. This source fix prevents new implicit simulation; it does not repair old rows.
PointUp never stores raw provider passwords. A loyalty account carries at most a credentialRef — an opaque pointer into a vault the user controls. At sync time the credential is resolved, used once in memory, and discarded.
Two resolution paths cover all vault types:
OnePasswordConnectVault implements the CredentialVault port against a self-hosted 1Password Connect server. Configure it with:
OP_CONNECT_HOST="https://op-connect.internal:8080"
OP_CONNECT_TOKEN="..."Credential refs use the format op://<vaultId>/<itemId>. When these env vars are unset, the composition root falls back to NullCredentialVault.
To support another server-side vault (e.g. HashiCorp Vault, AWS Secrets Manager per-user secrets), implement CredentialVault and swap it in the composition root.
The API supports transient credentials supplied by an authorized client for one-time sync. This API capability does not establish a device vault integration. The current Chrome extension captures visible loyalty balances after consent; it does not collect provider passwords or read the browser password manager.
POST /api/v1/loyalty-accounts/{id}/sync
{ "transientCredential": { "username": "...", "secret": "..." } }
- iOS/macOS app: deferred. A future device vault integration needs its own credential lifecycle and client verification.
- Chrome extension: consented balance extraction and reviewed write-back are implemented. Device vault access and transient-credential submission are not implemented.
SyncLoyaltyAccount prefers a transient credential over a stored credentialRef, and never persists it. See packages/core/src/application/loyalty/sync-loyalty-account.ts.
| Situation | Behavior |
|---|---|
| Transient credential provided | Used, regardless of stored ref |
Stored credentialRef, vault resolves it |
Used |
Stored credentialRef, vault cannot resolve |
409 CREDENTIAL_UNAVAILABLE |
| No credential at all | Gateway is called with null (fine for providers/aggregators that only need the membership number) |
PointUp Assistant is a grounded chat over the user's portfolio. The application use case (ChatWithAssistant) assembles balances, trip goals, and transfer-value hints, then calls the LlmAssistant port:
interface LlmAssistant {
complete(input: {
system: string;
messages: AssistantMessage[];
}): Promise<string>;
}Adapters:
| Adapter | When |
|---|---|
OpenAiCompatibleAssistant |
LLM_API_KEY set (OpenAI, Groq, Azure OpenAI, Ollama shim, …) |
HeuristicAssistant |
No key — deterministic advice from transfer hints in the system prompt |
Optional env: LLM_API_KEY, LLM_MODEL (default gpt-4o-mini), LLM_BASE_URL.
The assistant never receives credentials or vault secrets — only portfolio read models.
Deal and award-chart pages are fetched through the PageScraper port (Firecrawl-shaped):
interface PageScraper {
scrape(url: string): Promise<ScrapedPage>; // url, title, markdown
}| Adapter | When |
|---|---|
FirecrawlPageScraper |
FIRECRAWL_API_KEY set (Firecrawl) |
StubPageScraper |
No key — returns demo markdown for Hyatt/United/Hilton URLs |
IngestDealPage parses point/cash pairs from markdown and feeds them into GetValueAdvice ranking. Optional env: FIRECRAWL_API_KEY, FIRECRAWL_BASE_URL.
Editorial transfer edges live in packages/core/src/domain/loyalty/transfer-partners.ts (Chase/Amex/Cap One/Citi/Bilt → airline/hotel partners, plus real, time-boxed transfer bonuses stored in the transfer_bonus table - empty by default, see optimizer.md). GetValueAdvice ranks transfers by effective cents-per-point and scores curated + scraped deals against what the user holds. Dashboard: Value & deals + floating Ask PointUp assistant.
The redemption optimizer attaches real award space to flight plans only through this port (searchAwards(origin, destination, dateFrom, dateTo, cabin)):
| Adapter | When |
|---|---|
HttpAwardAvailabilitySource |
AWARD_SEARCH_API_URL and AWARD_SEARCH_API_KEY set |
StubAwardAvailabilitySource |
Otherwise - returns status: "not_configured" and no options |
HTTP contract (our own; put a gateway in front of your data vendor): GET {AWARD_SEARCH_API_URL}/awards?origin=&destination=&from=&to=&cabin= with Authorization: Bearer <key>, returning { "options": [{ "program", "carrier?", "date", "cabin", "points", "taxes_cents?", "seats?" }] }. Failures never throw; they surface as status: "error". See optimizer.md.
Firecrawl, the legacy OpenAI-compatible assistant and HTTP award search require HTTPS upstreams, with HTTP allowed only on loopback for explicit local fixtures. Configured base URLs cannot contain userinfo, query, fragment, whitespace or backslashes. Requests refuse redirects; provider error bodies and original exceptions are not copied into public guidance. Responses are bounded before parsing (1MiB scraper,256KiB assistant/award); unavailable results do not establish verified award availability. Production CDK also validates scraper/LLM URLs.
Slack/Discord webhook path/query tokens remain supported with HTTPS or explicit loopback HTTP. Redirects are disabled, response bodies are discarded, and failure diagnostics omit token-bearing URLs and notification content.
The Chrome extractor requires balance context or an isolated unit reading, strict integer grouping and a unique value. It refuses conflicting/promotional readings and partial decimal, negative or exponent suffixes. This is conservative synthetic fixture coverage, not live provider-page verification. The bank candidate adds program-specific Chase, US Amex, Capital One Miles and Bilt rules alongside seven airline/hotel rules, with exact bank hosts and negative offer/region fixtures. These are unverified against logged-in pages; controlled live consented validation remains required. Capital One rewards-host compatibility needs separate verification before claiming a working connection.
Public navigation evidence establishes provider-owned starting points, not logged-in balance markup or API access. No provider partnership, credential use or live account connection is claimed. The current core playbooks remain unverified.
| Program | Primary evidence | Remaining limitation |
|---|---|---|
| Chase Ultimate Rewards | Chase rewards portal guide names ultimaterewardspoints.chase.com. |
A shopping/rewards portal link does not verify the signed-in balance label. |
| Amex Membership Rewards (US) | Amex balance FAQ describes the Home dashboard and Points Summary; official rewards navigation is on the shared global host. | Shared hosts also serve international products. Capture needs US identity plus the MR points label. |
| Capital One Miles | Capital One rewards links Sign In to Redeem to verified.capitalone.com/auth/signin/. |
The existing myrewards.capitalone.com seed has no current public primary-source confirmation. A failed tool open does not prove retirement; the new login host does not prove a balance reader. |
| Bilt | Bilt and official account setup establish current and legacy provider-owned navigation. | Authenticated balance layout and region/product compatibility remain unverified. |
Provider/developer API discovery must establish actual loyalty-balance permissions and onboarding; general issuer APIs or public pages do not establish that access. Use consented capture/manual recording until a contracted real adapter is verified.
| Provider | Official API evidence | PointUp implication |
|---|---|---|
| Capital One | Shop with Rewards engineering describes balance/redeem/refund endpoints for approved co-branded retail partners and a rewards balance expressed in dollars. | A partnership inquiry may be useful. It does not establish Venture/Miles portfolio access; dollar balances must not be ingested as miles. |
| Bilt | Partner documentation and partner OpenAPI describe pay-with-points against the partner's own loyalty program: Bilt calls a partner-hosted pointsBalanceUrl. |
This direction is the inverse of PointUp reading a Bilt member balance. Payment SDK onboarding/client credentials do not establish that read access. |
| Amex | Merchant balance-inquiry documentation names Membership Rewards and availableBalance.reward.points, using payment-provider-issued merchant credentials and reward-redemption balance identifiers. |
This older version47 payment-partner contract is an inquiry lead, not established general US-card portfolio access or permission to use it for PointUp. |
| Chase | Official developer registration requires an invitation/relationship for Chase Developer access. | Establish eligibility and the actual loyalty-balance contract before adapter implementation. General account/payment APIs do not prove points access. |
No self-serve loyalty-balance connection or partnership was established by this research. These are concrete onboarding leads and contract boundaries, not configured integrations. Retain manual/consented capture for unsupported programs.
The program graph resolves all122 endpoints, but card eligibility still affects actual transfer yield. Chase→Hyatt's unconditional1:1 edge is incorrect for affected Preferred/Ink products after the2026 transition. See the immediate release backlog for the required card-aware model and primary evidence; do not treat the current fundability result as verified for those products. Other card variants require primary verification.
Amex's5:4 Cathay and Emirates values are supported by its official transfer material. The recorded Emirates2026 reduction note is stale: Amex's October2025 update gives September16,2025; the Cathay update gives March1,2026. Dates were available in official indexed content; direct PDF retrieval failed. Checking-only MR eligibility is also distinct from a universal program-level transfer edge. Do not widen verified card eligibility from these ratio/date facts alone.