Skip to content

Integration keys — Technical Reference

The "Integrations & API keys" settings screen (/integrations) is the single home for every optional, user-supplied credential the app can use: third-party data-provider API keys (FMP, Alpha Vantage, …) and linked learning-platform accounts (Udemy, LeetCode, …). Every row is optional — the app works fully with none of them configured.

Guardrail — read this before touching this feature. The app never creates an account, never signs up on the user's behalf, never reads the user's email for verification, and never solves a CAPTCHA — for any row, whether it's an API-key provider or a linked learning-platform account. It only ever does two things: (1) opens the provider's own site in the external browser (navigation only), and (2) accepts either a key the user pastes back in, or the user's own say-so that they signed up / logged in. This is enforced by construction, not by convention — there is no code path anywhere in this feature that submits a form on a provider's site, drives a signup flow, or reads an inbox.

Two credential kinds, one registry

CredentialKind (src/WorkWingman.Core/Models/ApiKeyProviderInfo.cs) distinguishes:

Kind Meaning UI affordance
ApiKey User pastes a secret key/token Paste field → masked "configured (••••1234)" state
AccountLink No key exists — user just has an account "Log in / sign up →" external link + "I linked this ✓" toggle

Both kinds are rows in the same ApiKeyProviderInfo registry and the same IIntegrationKeysService/vault storage path — an AccountLink row just stores a fixed placeholder (IntegrationKeysService.LinkedMarker = "linked") instead of a real secret, so "I have a Udemy account" and "here's my FMP key" are handled by the identical plumbing.

The registry

ApiKeyProviderRegistry.All (src/WorkWingman.Infrastructure/Integrations/ApiKeyProviderRegistry.cs) is the single source of truth. Adding a provider is adding one entry here — no other code needs to change for it to show up on the settings screen.

public class ApiKeyProviderInfo
{
    public string Id { get; set; }              // stable id: vault key suffix + route/API param
    public string DisplayName { get; set; }
    public string WhatItUnlocks { get; set; }    // shown as the row's subtitle
    public string SignupUrl { get; set; }        // opened via shell.openExternal / <a target=_blank>
    public CredentialKind Kind { get; set; }     // ApiKey | AccountLink
    public bool IsFree { get; set; }
    public string FreeTierNote { get; set; }
    public string ConfigKey { get; set; }        // IConfiguration key the adapter falls back to (ApiKey rows only)
    public bool IsWired { get; set; }            // false = registry row exists ahead of the feature that reads it
    public bool Featured { get; set; }           // true only for FMP today
}

Data providers (CredentialKind.ApiKey)

Id Display name Free tier ConfigKey Wired?
fmp Financial Modeling Prep (FMP) — featured 250 req/day WorkWingman:FmpApiKey No — registry is forward-looking
alpha-vantage Alpha Vantage 25 req/day WorkWingman:AlphaVantageApiKey No
barchart Barchart Paid only WorkWingman:BarchartApiKey No
github-token GitHub personal access token Free (raises rate limit) WorkWingman:GitHubToken No
hud-fmr HUD Fair Market Rents Free WorkWingman:HudFmrApiKey No
numbeo Numbeo Paid only WorkWingman:NumbeoApiKey No

FMP is featured at the top of the screen as the headline stock/equity data provider, per the onboarding brief — recommended for stock/equity features. None of the config-gated adapters that would read these keys exist yet (IsWired = false on every row above); the registry stays ahead of the feature work so the settings screen never needs a follow-up PR to add a row once an adapter lands. See Integration note for future adapters below.

Id Display name Signup URL Aligns with vault seed
edu-udemy Udemy https://www.udemy.com edu-udemy (seeded in KeePassVaultService)
edu-google-skills Google Cloud Skills Boost https://www.cloudskillsboost.google edu-google-skills
edu-ms-learn Microsoft Learn https://learn.microsoft.com edu-ms-learn
edu-aws-skillbuilder AWS Skill Builder https://skillbuilder.aws edu-aws-skillbuilder
leetcode LeetCode https://leetcode.com — (new)
coursera Coursera https://www.coursera.org — (new)
medium Medium https://medium.com — (new)

The four edu-* ids match the vault entries KeePassVaultService.SeedIfEmpty() already creates, so linking a learning platform here and that pre-existing vault entry are the same concept — this screen is where the user confirms/re-confirms the link, it doesn't create a second, competing record.

Storage: vault-only, masked

Every write from this feature lands in the KeePass vault via IVaultService — there is no plaintext-JSON path for these secrets and no in-app logging of a pasted key.

  • IVaultService.SetSecretAsync(entryName, secret, category, url) — new method; the one place a plaintext secret is accepted anywhere in the app. Only ever called with the request body of a loopback-only PUT /api/integration-keys/{id} — a request that, by construction, can only originate on the user's own machine.
  • IVaultService.HasEntryAsync / RemoveEntryAsync — existence check and delete, added alongside SetSecretAsync for the same reason (IIntegrationKeysService needs all three).
  • VaultEntryCategory.Integration — new category added to VaultEntry.cs for these entries, alongside the existing Sso / Ats / LearningPlatform / Other.
  • Vault entry naming (IntegrationKeysService.VaultEntryName(id)): EVERY provider — ApiKey or AccountLink — maps to its own namespaced marker entry, "integration-{id}" (e.g. integration-fmp, integration-edu-udemy). This service never writes to or deletes a provider's pre-existing vault entry (it only ever reads one, to check whether it exists), even for the four edu-* learning platforms that KeePassVaultService.SeedIfEmpty() already seeds as real LearningPlatform credentials (username + password).

Why not just reuse the bare edu-udemy entry as the link marker? An earlier revision of this service did exactly that, on the reasoning that it would avoid a duplicate entry and let an already-seeded vault show the platform as linked immediately. It was a real data-loss bug: SetKeyAsync overwrote the seeded credential's actual stored password with the literal string "linked", and RemoveKeyAsync deleted the credential entry outright on "unlink." Reusing a credential entry as a state marker is unsafe for any entry this service doesn't fully own. The fix: GetStatusesAsync reports an AccountLink row as configured if either its own marker entry exists or the pre-existing bare-id credential entry exists — the latter checked with HasEntryAsync only, never written to. SetKeyAsync/RemoveKeyAsync only ever touch the namespaced marker entry; if a platform is already linked purely via its pre-existing seeded credential (no marker ever written), "unlink" is correctly a no-op — the user's real account link still exists and this service has no business deleting it.

Status reflects config-fallback keys too — and says so. For ApiKey rows, IntegrationKeysService.GetStatusesAsync resolves through the same IApiKeyProvider (vault-then-config) a real adapter would use, not just IVaultService.HasEntryAsync. A key set only via appsettings/an environment variable — never pasted into this screen — still shows as configured with the right Last4, instead of wrongly prompting the user to paste a key that's already working. AccountLink rows have no config fallback, so they only ever check the vault (own marker entry or pre-existing seeded credential, both read-only for the latter).

That raises a second question the status has to answer honestly: can this key/link be removed from here? Only if it lives in THIS service's own marker entry. IntegrationKeyStatus.Source (IntegrationKeySource: None / Vault / Config / PreExisting) tells the UI which case it's in — the Remove/Unlink button only renders for Source == Vault:

  • Config (ApiKey rows only) — the key lives in appsettings/an environment variable; there's no vault entry to delete.
  • PreExisting (AccountLink rows only) — the row is "linked" purely because the pre-existing seeded credential entry (e.g. edu-udemy) exists; no marker was ever written by this service.

Both show an explanatory note instead of a Remove/Unlink button. IntegrationKeysService.RemoveKeyAsync enforces the same rule server-side: attempting to remove a Config or PreExisting row throws InvalidOperationException (→ 409 from the controller) rather than silently "succeeding" while the row reloads as configured/linked because the underlying value is still there untouched.

Masking. IntegrationKeyStatus (the only shape ever returned to the frontend) carries Configured: bool and Last4: string? — never the key itself:

public class IntegrationKeyStatus
{
    public ApiKeyProviderInfo Provider { get; set; }
    public bool Configured { get; set; }
    public string? Last4 { get; set; }   // last 4 chars only; null for AccountLink rows or when unset
}

IntegrationKeysService.GetStatusesAsync resolves the secret in-process (server-side) purely to compute Last4, then discards it — the full value never crosses the method boundary back to the controller as anything other than that trailing fragment. AccountLink rows never get a Last4 at all, since their stored value is just the fixed LinkedMarker, not a secret worth fragment-revealing.

The IApiKeyProvider resolution seam

IApiKeyProvider (src/WorkWingman.Core/Interfaces/IApiKeyProvider.cs) is the one seam a config-gated adapter should read a provider's key through:

public interface IApiKeyProvider
{
    Task<string?> Get(string providerId, CancellationToken ct = default);
}

Resolution order, implemented by ApiKeyProvider (src/WorkWingman.Infrastructure/Integrations/ApiKeyProvider.cs):

  1. Registry membership first. If providerId isn't in ApiKeyProviderRegistry at all, Get returns null immediately — it never checks the vault or config for an unrecognized id. This matters even though SetKeyAsync/RemoveKeyAsync already refuse unknown ids: without this guard, a leftover integration-{id} vault entry from a renamed/removed provider (or a plain caller typo) would still resolve, silently bypassing the registry as the source of truth for "what's a real provider."
  2. Vault firstIVaultService.HasEntryAsync("integration-{id}"); if present and the vault is unlocked, return ResolveSecretAsync for that entry (the key the user pasted into this settings screen).
  3. Config fallback — the registry row's ConfigKey (e.g. WorkWingman:FmpApiKey), read through IConfigurationLookup, an indexer-only seam (src/WorkWingman.Infrastructure/Integrations/ApiKeyProvider.cs) that the API host backs with the real ASP.NET Core IConfiguration via ConfigurationLookupAdapter (src/WorkWingman.Api/Security/ConfigurationLookupAdapter.cs). This keeps WorkWingman.Infrastructure free of a direct package reference to Microsoft.Extensions.Configuration while still honoring appsettings + environment variables (WorkWingman__FmpApiKey, the standard ASP.NET Core env-var convention) for developers who set a key without going through the vault.
  4. Neither present → returns null. Callers must treat null as "this optional feature is unavailable," never as an error — every provider here is optional by design.

Integration note for future adapters

If you're wiring up FMP, Alpha Vantage, or any other registry row to a real HTTP client: do not read IConfiguration["WorkWingman:FmpApiKey"] directly in the adapter. Inject IApiKeyProvider and call Get("fmp") (using the provider's registry Id) instead. That one-line change is the entire integration — nothing else about the adapter or its DI registration needs to know that keys can also live in the vault. This document exists so an unmerged feature branch adding such an adapter picks up vault-stored keys without a rewrite.

API surface

IntegrationKeysController (src/WorkWingman.Api/Controllers/IntegrationKeysController.cs), mounted at api/integration-keys:

Method Route Auth Behavior
GET /api/integration-keys Open Registry joined with per-provider {configured, last4}. Never the full key.
PUT /api/integration-keys/{id} ApiToken-gated Body { key }. Stores a pasted key (ApiKey rows) or marks an account linked (AccountLink rows, key ignored in favor of the fixed marker). 404 unknown id, 400 empty key on an ApiKey row, 409 if the vault is locked.
DELETE /api/integration-keys/{id} ApiToken-gated Removes the vault entry / unlinks. 404 unknown id, 409 if the vault is locked.

PUT/DELETE are gated on the same per-launch ApiToken (X-WorkWingman-Token header or the trusted localhost:4200 dev origin) that guards the dependency installer — state-changing, vault-writing endpoints must not be callable by an arbitrary web page that happens to reach the loopback API. GET stays open since it never returns a secret.

Registered in Program.cs:

builder.Services.AddSingleton<IIntegrationKeysService, IntegrationKeysService>();
builder.Services.AddSingleton<IConfigurationLookup>(new ConfigurationLookupAdapter(builder.Configuration));
builder.Services.AddSingleton<IApiKeyProvider, ApiKeyProvider>();

Frontend

IntegrationKeys (frontend/src/app/features/integration-keys/), a standalone Angular component using signals, routed at /integrations and linked from the sidebar nav ("Integrations & API keys"). Layout:

  1. Featured — FMP, called out with a "Recommended" chip.
  2. Data providers — remaining ApiKey rows.
  3. Learning platformsAccountLink rows.

Each ApiKey row shows: display name, what it unlocks, free/paid badge, a "Get a key →" external link (SignupUrl), and either a paste field + Save button (unconfigured) or a masked configured (••••1234) chip + Remove button (configured, Source == Vault) — or, for a config-only row (Source == Config), the same masked chip but an explanatory note in place of the Remove button, since there's nothing in the vault to remove. Each AccountLink row shows the same metadata but a "Log in / sign up →" link plus an "I linked this ✓" button (unconfigured) or a linked ✓ chip + Unlink button (configured, Source == Vault) — or, for a row linked only via the pre-existing seeded credential (Source == PreExisting), the same chip but a note ("Already linked in your vault.") instead of Unlink.

Failures are surfaced, not swallowed as success. saveKey/markLinked/remove subscribe with an explicit error handler that shows a "Something went wrong" toast and leaves state untouched — a rejected write (401 wrong origin, 404 unknown provider, 409 locked vault or config-only-remove) does not clear the paste-field draft, flip a row to linked, or trigger a reload that could make a failure look like a save. Only load() (the initial GET) falls back silently to an empty list, since there's nothing destructive to protect there.

Opening the signup page. The link is a plain <a [href]="signupUrl" target="_blank" rel="noopener"> — the same pattern already used for study-resource links (features/study/study.html) and the dependency doctor's info links (features/doctor/doctor.html). In Electron, mainWindow.webContents .setWindowOpenHandler (electron/main.js) intercepts any target="_blank" navigation and routes it to shell.openExternal, denying the in-app popup — so this is navigation-only by construction, not by discipline. In ng serve (no Electron shell), the same anchor just opens a normal browser tab.

ApiService gains getIntegrationKeys() / setIntegrationKey(id, key) / removeIntegrationKey(id). api-token.interceptor.ts was extended to attach X-WorkWingman-Token to /api/integration-keys/{id} PUT/DELETE calls (previously only /api/dependencies/install), matching the backend's ApiToken gate on those routes.

Testing

  • Backend (tests/WorkWingman.Tests/IntegrationKeysTests.cs): registry shape (unique ids, https signup URLs, ApiKey rows have a ConfigKey / AccountLink rows don't, FMP is the only featured row, learning-platform ids align with the vault seed, forward-looking rows are marked IsWired = false), vault round-trip of a set key, GetStatusesAsync never exposing anything but Last4, AccountLink rows never getting a Last4, delete removing the vault entry, IApiKeyProvider resolution order (vault-then-config, vault-locked-falls-back, neither-returns-null).
  • Backend smoke (tests/WorkWingman.Tests/ApiSmokeTests.cs): auth gating on PUT/DELETE without a token/trusted origin, 404 on an unknown provider id from a trusted origin, 409 (not an unhandled 500) when writing against a locked vault.
  • Frontend (frontend/src/app/features/integration-keys/integration-keys.spec.ts): rows render and group correctly, the never-auto-signup banner text is present, masked state renders, every external link carries target="_blank" rel="noopener" and the right SignupUrl, paste → save calls the API with the trimmed value and clears the draft, empty-draft save is a no-op, "I linked this" calls the API and flips to linked, remove/unlink calls the API, offline API falls back to an empty list.
  • Mutation testing: tests/WorkWingman.Tests/stryker-config.json mutate globs extended with **/Integrations/*.cs; frontend/stryker.conf.json already mutates all of src/app/**/*.ts so integration-keys.ts is covered without a config change.
  • Plain-language: Integration keys
  • Connections & SSO — the vault and the broader account-linking picture
  • Security
  • Source: src/WorkWingman.Core/Models/ApiKeyProviderInfo.cs
  • Source: src/WorkWingman.Core/Models/IntegrationKeyStatus.cs
  • Source: src/WorkWingman.Core/Interfaces/IApiKeyProvider.cs
  • Source: src/WorkWingman.Core/Interfaces/IIntegrationKeysService.cs
  • Source: src/WorkWingman.Infrastructure/Integrations/ApiKeyProviderRegistry.cs
  • Source: src/WorkWingman.Infrastructure/Integrations/ApiKeyProvider.cs
  • Source: src/WorkWingman.Infrastructure/Integrations/IntegrationKeysService.cs
  • Source: src/WorkWingman.Api/Controllers/IntegrationKeysController.cs
  • Source: frontend/src/app/features/integration-keys/