Skip to main content

Keystone Product Requirements Document

Version: 1.2. Date: September 2026. Author: Pete Carroll, Founder — Traxs Group LLC (with Claude). Status: Draft.

Sections marked Proven in spike describe what's actually built and tested (keystone-spike-results.md); sections marked Proven in production build describe real, later work beyond the spike, tested end-to-end. Sections marked Proposed are genuine design proposals for the ready-to-use and production tiers — real recommendations, not settled fact. See keystone-brd.md §8 for the specific open items these proposals still need confirmed against.


1. Overview​

This document defines the detailed requirements for each Keystone endpoint — what it accepts, what it returns, how it behaves, and how it fits into the platform. See keystone-brd.md for why each capability exists and keystone-sad.md for how the service is actually built.


2. Authentication Model​

Keystone has three real audiences, each with different trust levels:

2.1 End-user-facing endpoints (browser, interactive)​

Login, registration, external-federation, and account-recovery pages. Unauthenticated by definition (that's what they're for), but not public in Command Center's sense — these aren't meant to be called by arbitrary API clients, they're browser flows with cookies and redirects.

2.2 Token endpoints (OAuth2/OIDC standard)​

/connect/authorize, /connect/token, /.well-known/openid-configuration, and the JWKS endpoint — standard OpenIddict/OIDC surface. Every product validates tokens against Keystone's published signing keys; no product needs to call back into Keystone to check if a token is valid, only to obtain one and to know when it's been revoked (Section 3.10).

2.3 Service-to-service endpoints (API key or client-credentials)​

Used by RoundTrip, Waypoint, Relay, and the admin app for anything beyond "validate a token I already have" — admin account lookups, forced resets, audit log queries. Requires the same kind of service-to-service credential Command Center already uses (X-CommandCenter-ApiKey's Keystone-side equivalent) — exact mechanism proposed in keystone-sad.md §4.

2.4 Outbound integration — none​

Corrected: Keystone was originally thought to call Command Center's GET /v1/entitlements directly. It doesn't, and structurally can't — see Section 3.11 for the real finding. Keystone has no outbound integration to another Traxs service at all today.


3. Endpoint Specifications​


3.1 Health Check​

GET /health Public. No auth required.

Proposed — matches Command Center's own real health check (command-center-sad.md §7): ASP.NET Core Health Checks, AddDbContextCheck against Keystone's credential store. Given Section 2.3 of the BRD (identity outage takes the whole platform down), this deserves the platform's most aggressive uptime monitoring pointed at it — not a nice-to-have.


3.2 Registration — Email/Password​

POST /account/register Anonymous.

Proven in spike — email/password account creation, real PBKDF2 password hashing, a real row created and tested against a real database. Open self-signup, as tested and confirmed (keystone-brd.md §8 item 4) — both registration paths (this one, and external-login Case 3, §3.5) are consistently open, with no invite requirement anywhere in the proven code. This isn't an unresolved ambiguity in what was tested — it proved open signup, deliberately departing from keystone-external-login-proposal.md's original invite-gated design, which was scoped to RoundTrip's own tenant-provisioning model rather than Keystone's role as the platform's central credential store.

Request (as actually built and tested):

{
"email": "pete@example.com",
"password": "..."
}

Response 200 (as actually built and tested):

{ "userId": "..." }

Response 409: email already registered. Response 400: validation error (empty email/password), via DomainException.

3.2.1 Service-to-Service Variant — POST /v1/accounts/provision​

API-key authenticated (X-Keystone-ApiKey) — not anonymous, unlike /account/register above. Built for Command Center's signup orchestration to call directly rather than routing through the browser-facing endpoint (different trust tier; immune to future anti-bot changes on the public path; rate-limiting logic aimed at per-person abuse doesn't fit a single server-side caller acting on behalf of many people). Numbered as a sub-section of Registration, not its own top-level section, so it doesn't shift the numbering every other platform document already cross-references (§3.11, keystone-brd.md §8, etc.).

Proven in production build — idempotent by design: a repeated call with the same email returns the same userId instead of a 409, closing the retry gap /account/register still has (keystone-signup-orchestration.md §4's accepted v1 limitation doesn't apply here).

Request:

{
"email": "pete@example.com",
"password": "..."
}

Response 200 (always — this endpoint never returns 409):

{
"userId": "...",
"wasAlreadyRegistered": true
}

3.3 Login — Interactive Flow​

GET /connect/authorize, POST /connect/token Standard OpenIddict/OIDC authorization-code flow.

Proven in spike — "the full interactive authorization-code flow was built too, beyond the item's original scope" (keystone-spike-results.md §1, item 2). Real encrypted tokens, correct claims, spec-correct rejection on bad credentials.


Sections 3.4–3.7 (External Login) were built, proven in the spike, then permanently removed from the codebase on 2026-10-01. This is Pete's final decision not to offer Microsoft Entra federation at all — not the operational pause described in earlier revisions of this doc. See keystone-production-secrets.md for the incident that started the conversation, and platform-action-items.md (Keystone section) for the decision itself. The endpoints, the ExternalLogin domain entity, and its EF migration are gone from the real build as of this version. The four sections below are struck through as historical record of what was built and tested — not a current or proposed feature. keystone-api-reference.md (the live contract, regenerated from source) has no trace of them, by design.

### 3.4 External Login — Challenge

GET /account/login/external/{provider} Anonymous.

~~Proven in spike. provider is "MicrosoftEntra" today; the endpoint shape supports adding others later without restructuring. Redirects to the provider's own login, never auto-triggered — this is what actually resolves TRA-389 (Home Realm Discovery only fires on a chosen action, never silently during registration).

Note on route naming: keystone-api-design.md §2 renamed this away from an earlier /account/tenant-login/external/{provider} naming (scoped to RoundTrip's own TenantUser concept) before any code was written. keystone-external-login-proposal.md §3.3 intentionally preserves that earlier pre-rename naming as original reference material — it is not the authoritative source for the endpoint's current shape. The real, tested implementation (ExternalLoginChallengeEndpoint) uses the path shown above.

csharp~~ ~~Get("/account/login/external/{provider}");~~ ~~AllowAnonymous();~~ ~~// ...HttpContext.ChallengeAsync(provider, ...)~~ ~~


### 3.5 External Login — Callback

GET /account/login/external-callback Anonymous.

Proven in spike, all three linking cases tested:

1. Already linked — the ExternalLogin row exists for this (Provider, ProviderKey) — sign in directly. 2. Not linked, verified email matches an existing account — never auto-merges. Redirects to the confirm-link step (Section 3.6), requiring the user's own password before the link is created. 3. Not linked, brand-new person, verified email registers directly, no invite required (see Section 3.2). A deliberate departure from keystone-external-login-proposal.md §3.3's original design — Keystone's role as the platform's central credential store, not RoundTrip's tenant-provisioning model, governs here.

The real, tested implementation lives in ExternalLoginCallbackEndpoint and ProcessExternalLoginCommandHandler. keystone-external-login-proposal.md §3.3 is the original design reference these were built from, not a current, authoritative description of the as-built behavior.


### 3.6 Confirm-Link

GET/POST /account/confirm-link Anonymous (pre-authentication — the user isn't signed in yet at this point in the flow).

Proven in spike, Case 2 specifically — "full test coverage for external login + Case 2 confirm-link flow" (keystone-spike-results.md §2, entry 10). Requires the account's real password before ExternalLogins.Add(...) — this is the guardrail against silent account takeover via a matching-but-unverified email.

Explicitly out of scope for the spike, still open: production polish (styled forms, clearer error messaging) — keystone-spike-results.md §4 names this directly as bare-but-functional, not production-ready as-is.


### 3.7 Unlink External Login

DELETE /account/external-logins/{provider} Self-service — authenticated via the same interactive-login cookie every other signed-in account action uses.

Proven in production build — the caller's own UserId is resolved from their authenticated cookie's sub claim, never a caller-supplied identifier, so there's no way to unlink someone else's account by guessing an id. Wraps the already-existing, already-unit-tested User.RemoveExternalLogin domain method — the guardrail against removing your only remaining credential (leaving the account with no way to authenticate) is enforced there and surfaces here as a real 400.

Response 200: unlinked successfully (empty body). Response 400: would leave the account with no way to authenticate. Response 401: not signed in.

An admin-app equivalent (Section 3.14) remains proposed, production tier, for cases where the account holder themselves can't complete this (e.g., they've also lost their password).


3.8 Password Reset — Request​

POST /account/password-reset/request Anonymous.

Proven in production build — built and fully tested (unit, integration, and functional test coverage) beyond the original spike's scope, closing the single named gap keystone-spike-results.md §4 identified. Accepts an email, always returns 202 regardless of whether the account exists (anti-enumeration, matching the spike's own discipline), and generates a time-limited (1-hour) reset token, hashed at rest (SHA-256, deliberately not PBKDF2 — the token is a high-entropy generated value, not a low-entropy human password, so the expensive-hashing rationale doesn't apply). Email delivery goes through a stubbed ICommandCenterEmailClient today, pending Command Center's real email-sending endpoint.

Request:

{ "email": "pete@example.com" }

Response 202 (always, regardless of whether the account exists):

{ "message": "If an account exists for this email, a reset link has been sent." }

3.9 Password Reset — Confirm​

POST /account/password-reset/confirm Anonymous, requires a valid reset token.

Proven in production build — including the full revocation guarantee described below, confirmed directly against the database (a live token's status observed changing from valid to revoked immediately after a successful reset).

{ "resetToken": "...", "newPassword": "..." }

Response 200:

{ "message": "Your password has been reset." }

Password changed; every existing OpenIddict token for this person is revoked via IOpenIddictTokenManager.RevokeBySubjectAsync — a stolen password being reset by its real owner doesn't leave an attacker's already-issued token still valid.

Response 400 (confirmed against the real, tested endpoint):

{ "error": "This password reset link is invalid or has expired." }

Covers an unknown token, an expired token, and a reused (already-used) token identically — anti-enumeration applied to the reset flow itself, not just login. Note the field name is error, not message — the two response shapes on this endpoint are deliberately not parallel.


3.10 Token Revocation / Introspection​

POST /connect/revocation (standard OIDC revocation endpoint).

The underlying mechanism is now proven, though only as a side effect of password reset (Section 3.9) rather than as its own standalone capability. IOpenIddictTokenManager.RevokeBySubjectAsync — confirmed as the correct, single-call API for revoking every token belonging to a person — is real, tested, working code today.

Still proposed, production tier: a dedicated, standalone mechanism for forcing revocation independent of a password reset — the real requirement (BRD §5.3) is "a compromised session or token can be killed" even when the person isn't also resetting their password. Resolved per keystone-build-log.md Entry 2: this should be exposed as both a self-service "log out everywhere" account action and an admin-app action, not either/or — settled as a real decision, though neither surface is built yet.


3.11 Entitlement Checks — Not Keystone's Responsibility​

Superseded, 2026-09-09 — see keystone-spike-build-log.md Entry 13. An earlier version of this section described entitlement data as baked into the token Keystone issues at login. That was investigated directly while scoping Keystone's own Command Center integration, and the premise didn't hold up — reversed here, not merely amended.

The corrected design: Keystone issues a token carrying only a stable person ID — nothing about tenants, products, or entitlement. This isn't a scope-trimming choice; it's structural. Keystone owns credentials and a person's identity, and deliberately nothing else (keystone-framing.md's core split). Entitlement — whether a person's tenant is currently paying for a given product, at what tier, at what effective price — is Command Center's own data, scoped by tenantId. A token minted before any tenant is known has no tenant to describe entitlement for.

Who actually calls the entitlement check, and how:

RoundTrip and Waypoint each already resolve tenantId locally as part of their own existing request handling — that's what makes the call to Command Center meaningful. Keystone is never in this path at all, by design, not by omission.

The real, built, and tested contract (confirmed against Command Center's actual implementation, not the original design sketch):

GET /v1/entitlements?tenantId={tenantId}
X-CommandCenter-ApiKey: <key>
{
"tenantId": "...",
"entitlements": [
{
"productSlug": "roundtrip",
"isEntitled": true,
"tier": "Standard",
"effectivePrice": 79.00
}
]
}

effectivePrice already accounts for any active rate lock (the Founders Program pattern) — the caller never needs to know that logic exists, only to read the number it gets back.

What this means for Keystone concretely: nothing to build. This section exists to correct the record, not to hand Keystone new work — the entitlement check is entirely a RoundTrip/Waypoint-to-Command-Center concern, and Keystone's own scope stays exactly as narrow as keystone-framing.md already says it should be.


3.12 Multi-Factor Authentication​

POST /account/mfa/enroll, POST /account/mfa/verify, POST /connect/token (extended to require an MFA step when enabled)

Proposed, production tier — not proven anywhere, genuinely new design. TOTP confirmed as the method (RFC 6238, authenticator-app based — keystone-build-log.md Entry 2), since it requires no SMS provider and no per-message cost, matching the solo-developer/low-overhead constraint already governing every other Keystone decision.

Enrollment trigger remains genuinely open, explicitly flagged as needing real discussion rather than a quick call — current leaning is tenant-admin-controlled opt-in, not platform-wide mandatory or fully optional. A real architectural dependency this surfaces: Keystone has no tenant concept of its own, so a tenant-scoped "require MFA" setting would need to live in Command Center and reach Keystone the same way entitlement claims were originally (incorrectly) proposed to — this needs its own real design, not an assumption that Keystone can simply "see" a tenant's policy.

// POST /account/mfa/enroll → 200
{ "secret": "BASE32SECRET", "qrCodeUri": "otpauth://totp/Keystone:pete@example.com?secret=..." }

// POST /account/mfa/verify
{ "code": "123456" }

3.13 Rate Limiting​

Not an endpoint — a cross-cutting requirement on Sections 3.2, 3.3, 3.8, and 3.9 specifically (registration, login, password-reset request/confirm — the endpoints a credential-stuffing or brute-force attack would actually target).

Proposed, production tier — real design direction decided (keystone-build-log.md Entry 2), specific numbers still open. Command Center's existing public-endpoint pattern (10 requests/minute/IP) was judged too lenient for an identity platform, but pure per-IP limiting has a real weakness worth designing around directly: office networks put many legitimate users behind one shared IP, and a strict IP-only limit risks locking out several real employees at once. Decided direction: a two-layer approach — a moderate per-IP limit (looser than a naive "high-security" number, to tolerate shared-office IPs) combined with a stricter per-account lockout/backoff after repeated failures on that specific account, regardless of source IP. The exact thresholds for both layers still need to be set.


3.14 Admin Identity Endpoints​

GET /admin/accounts?search={query}, POST /admin/accounts/{id}/force-reset, DELETE /admin/accounts/{id}/external-logins/{provider}, GET /admin/accounts/{id}/audit-log

Proposed, production tier — the Keystone-facing half of the admin app named in keystone-spike-scope.md §1. Consumed only by the admin app, authenticated through Keystone itself as "a real, early test of the identity service on the one account that matters most while building it" (same source). Requires its own elevated auth scope, distinct from a normal user's token — not yet designed in detail; keystone-sad.md should carry the actual authorization model.


4. Error Handling​

Consistent with Command Center's own pattern (command-center-prd.md §4, GlobalExceptionMiddleware): structured error responses, no leaking of internal exception details, no distinguishing "account doesn't exist" from "wrong password" in login error messages (standard anti-enumeration practice, and consistent with the anti-enumeration guarantee already proven elsewhere in the spike and in password reset).


5. Versioning​

Proposed: unlike Command Center's /v1/ prefix convention, OpenIddict's own endpoints (/connect/*) follow the OIDC spec's own unversioned convention — not a deviation from platform standard, a constraint of building on a spec-compliant identity provider. Keystone-specific endpoints (/account/*, /admin/*) should still carry /v1/ for consistency with the rest of the platform. Worth confirming this split makes sense before it's built either way.


6. Document History​

VersionDateAuthorChanges
1.0September 2026Pete Carroll (with Claude)Initial PRD. Sections 3.1-3.7 document what the spike actually proved; Sections 3.8-3.14 are new design proposals for the ready-to-use and production tiers, several with explicitly flagged open questions carried from keystone-brd.md §8.
1.1September 2026Pete Carroll (with Claude)§3.2.1 added — real, proven POST /v1/accounts/provision service-to-service endpoint. §3.9 (Password Reset Confirm) upgraded from Proposed to Proven in production build, with the real response shape and revocation mechanism. §3.11 rewritten to correct the earlier premise that Keystone calls Command Center's entitlement endpoint directly — it doesn't, and structurally can't. §2.4 corrected to match. Reconciled from three diverged copies (two truncated, one structurally outdated) into one document.
1.2September 2026Pete Carroll (with Claude)Corrected a real regression introduced by the previous merge: §§3.10, 3.12, and 3.13 had reverted to a pre-1.1 version, silently dropping already-settled decisions (the proven revocation mechanism, TOTP confirmed as MFA's method, the two-layer rate-limiting direction). Restored from the actual 1.1 content. §3.9's Response 400 corrected to the real, tested shape (error field, not message; exact wording confirmed via direct testing) — this had never been verified against the real build before. §3.7 (Unlink External Login) upgraded from Proposed to Proven in production build, with its real, tested response codes.