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.
Linked accounts (CredentialKind.AccountLink)¶
| 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-onlyPUT /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 alongsideSetSecretAsyncfor the same reason (IIntegrationKeysServiceneeds all three).VaultEntryCategory.Integration— new category added toVaultEntry.csfor these entries, alongside the existingSso/Ats/LearningPlatform/Other.- Vault entry naming (
IntegrationKeysService.VaultEntryName(id)): EVERY provider —ApiKeyorAccountLink— 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 fouredu-*learning platforms thatKeePassVaultService.SeedIfEmpty()already seeds as realLearningPlatformcredentials (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(ApiKeyrows only) — the key lives in appsettings/an environment variable; there's no vault entry to delete.PreExisting(AccountLinkrows 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):
- Registry membership first. If
providerIdisn't inApiKeyProviderRegistryat all,Getreturnsnullimmediately — it never checks the vault or config for an unrecognized id. This matters even thoughSetKeyAsync/RemoveKeyAsyncalready refuse unknown ids: without this guard, a leftoverintegration-{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." - Vault first —
IVaultService.HasEntryAsync("integration-{id}"); if present and the vault is unlocked, returnResolveSecretAsyncfor that entry (the key the user pasted into this settings screen). - Config fallback — the registry row's
ConfigKey(e.g.WorkWingman:FmpApiKey), read throughIConfigurationLookup, an indexer-only seam (src/WorkWingman.Infrastructure/Integrations/ApiKeyProvider.cs) that the API host backs with the real ASP.NET CoreIConfigurationviaConfigurationLookupAdapter(src/WorkWingman.Api/Security/ConfigurationLookupAdapter.cs). This keepsWorkWingman.Infrastructurefree of a direct package reference toMicrosoft.Extensions.Configurationwhile 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. - Neither present → returns
null. Callers must treatnullas "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:
- Featured — FMP, called out with a "Recommended" chip.
- Data providers — remaining
ApiKeyrows. - Learning platforms —
AccountLinkrows.
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,ApiKeyrows have aConfigKey/AccountLinkrows don't, FMP is the only featured row, learning-platform ids align with the vault seed, forward-looking rows are markedIsWired = false), vault round-trip of a set key,GetStatusesAsyncnever exposing anything butLast4,AccountLinkrows never getting aLast4, delete removing the vault entry,IApiKeyProviderresolution 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 carriestarget="_blank" rel="noopener"and the rightSignupUrl, 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.jsonmutate globs extended with**/Integrations/*.cs;frontend/stryker.conf.jsonalready mutates all ofsrc/app/**/*.tssointegration-keys.tsis covered without a config change.
Related docs¶
- 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/