How EndpointTrust is put together
The shape
┌──────────────────────────┐
Windows endpoints ───▶│ apps/device-api :8080 │
(credential provider) │ 5 contract ops + 2 │
│ pairing ops │
└────────────┬─────────────┘
│
┌──────────────┴──────────────┐
│ packages/core │
│ the domain: decisions, │
│ seats, verification, │
│ licensing, signing, SMS, │
│ billing, analytics │
└──────────────┬──────────────┘
│
┌──────────────┴──────────────┐
│ packages/db PostgreSQL 18 │
│ schema + committed │
│ migrations │
└──────────────┬──────────────┘
│
Administrators ─────▶┌─────────────┴────────────┐
Stripe webhooks ────▶│ apps/dashboard :3000 │
└──────────────────────────┘
packages/contract holds the wire schemas, shared by both apps, mirroring the endpoint
repository's api/openapi-mvp.json. Where they disagree, **the endpoint repository is
normative and this repository has the defect.**
Why invariants live in the database
The interesting constraints here are not "this field is a string". They are "at most one of
these can exist", under concurrency, while several machines are signing people in at the
same moment. Those are expressed as partial unique indexes, because an index is a guarantee
and a code path is an intention:
| Index | What it makes true |
|---|---|
phone_numbers_one_active_per_user WHERE retired_at is null | Exactly one live destination per person. This is what makes "EndpointTrust selects the registered destination" a fact rather than a policy: there is only one to select. |
end_users_tenant_account_key on (tenant, lower(domain), lower(sam)) | One record per account. Windows compares case-insensitively; without lower() this table would hold CORP\alice twice. |
end_users_tenant_sid_key WHERE user_sid is not null | A SID identifies exactly one person in a tenant. |
devices_tenant_device_key | One registration per installation GUID. A second activation is a controlled re-enrollment, not a second row — which is what stops one machine quietly consuming two seats. |
associations_user_device_key | A seat is one (user, device) pair. |
challenges_one_pending_per_attempt WHERE state = 'pending' | One live code per attempt. |
attempts_device_nonce_key | A nonce cannot be reused on a device. |
licenses_one_active_per_tenant WHERE status = 'active' | One licence in force. |
signing_keys_one_active_per_tenant WHERE retired_at is null | One signing key in force. |
subscriptions_one_live_per_tenant WHERE status is non-terminal | One subscription. A cancelled one may coexist with its replacement. |
pairings_one_live_per_device | One pairing in flight per machine. |
sms_providers_one_default_per_tenant WHERE is_default | Codes go out through exactly one provider. |
audit_events.id = the endpoint's own event_id | An at-least-once uploader becomes exactly-once storage, with no deduplication logic anywhere. |
Seat allocation
packages/core/src/seats.ts. The invariant is a COUNT over rows — "at most N allocated
associations for this tenant" — so it uses a SERIALIZABLE transaction with a retry on
40001/40P01, not an advisory lock and not a counter column.
A counter column would need its own locking to stay consistent with the rows. A lock held
across the whole allocation would serialise every tenant's sign-ins behind each other.
Serializable isolation lets PostgreSQL detect the conflict and retry only the loser.
tests/seats.test.ts runs real concurrent allocations against a real server to prove it.
Single-use codes
packages/core/src/verification.ts. The primitive is a conditional UPDATE:
update challenges set state = 'verified' where id = $1 and state = 'pending'
Exactly one concurrent caller gets a row back. That caller verified the code; everyone else
did not. The attempt counter is incremented before the comparison, under a precondition,
so a crash between the two cannot hand out a free guess.
Signing
ES256, one key per tenant. Per tenant rather than one global root so that a compromise
affects one customer and a rotation does not have to be coordinated across every deployment
at once. A tenant's bundle carries only that tenant's public key, which is also what makes
a licence issued for one organisation structurally unusable at another.
Signatures are JOSE form: R‖S, 64 bytes, not ASN.1 DER. In Node that is
dsaEncoding: "ieee-p1363", and packages/core/src/crypto.ts asserts the length because
getting this wrong produces signatures that verify in Node and fail on Windows.
Idempotency
Two patterns, because the operations differ:
- Safe handlers (
validate) usebeginIdempotent: act, then record. A replayed request
returns the recorded result.
- Unsafe handlers (
activate,sms,verify, pairing) use
reserveIdempotent → act → finaliseIdempotent, with releaseIdempotent on failure. The
reservation happens before acting, because an SMS cannot be un-sent and a credential
cannot be un-minted. A concurrent duplicate is refused before it acts, not after.
Keys are bound to caller, route and body. Reusing a key with a different body is a 409
idempotency_conflict, not a silent replay of the wrong answer.
A retry of verify with the same key returns the original result, including the same
grant, even after the code is consumed. That is one verification, not two — an endpoint
whose response was lost to a dropped connection must not be punished for retrying.
Egress
A tenant may point the http SMS provider at a URL they control, which makes this service
able to be asked to make a request to an address of somebody's choosing.
packages/core/src/sms/guards.ts resolves the host first, checks the resolved address
against loopback, link-local, 169.254.169.254 and private ranges, and then connects to
that address — so a DNS record that changes between the check and the connection does not
help. The check runs when the URL is saved as well as when a message is sent.
What the endpoint cannot observe, and what this service therefore cannot report
The contract fixes two endpoint capabilities to false, and this service checks rather
than trusts them:
supports_pre_nla_block: false— target-side MFA cannot block the RDP network-level
authentication exchange.
supports_complete_session_audit: false— a credential provider is not a session
monitor.
The second one shapes the whole analytics surface. There is no logoff event, no lock event,
and no reliable "signed in right now" signal. A machine that loses power between submitting
a credential and hearing back from Windows reports windows_outcome: "unknown" and stays
that way forever.
So the analytics report attempts and their outcomes, verifications this service performed
itself, machines last seen, and bypasses used — and they do not report who is logged in,
session duration, or logoffs. Nothing in this repository turns an unknown into a success.
See the long comment at the top of packages/core/src/analytics.ts.
What the analytics can and cannot say
/app/analytics reports everything a credential provider observes: sign-ins and unlocks,
split by channel, Windows product, agent version and hour; who authenticated, from which
address, on which machine, and what the machine said afterwards; how many people are
registered, licensed, newly added, active and dormant.
The page ends with a card listing what it does not report and why, because a dashboard
that silently omits a metric invites the reader to assume it is zero:
| Not measured | Why |
|---|---|
| Sign-outs | A credential provider is invoked when a credential is presented, not when a session ends. supports_complete_session_audit is fixed false for this reason. |
| Locks | Locking presents no credential. The unlock afterwards does, and is counted. |
| Who is signed in now | Follows from the first two: without session ends the list would only ever grow. |
| BitLocker state, hardware type | Device posture was deliberately left out of the agent's scope. Reporting it would mean putting posture back in. |
Source addresses are recorded on the ATTEMPT, not on the audit event — an audit batch is
uploaded later and possibly from a different network, so its source address is not where
the sign-in happened. X-Forwarded-For is honoured only when TRUST_PROXY is set, because
a header is whatever the client wrote in it; otherwise the socket's peer address is used.
Either way it is the address this service saw, which behind NAT is a site rather than a
workstation, and the page says so.
Time windows carry a five-minute forward margin. occurred_at comes from the
endpoint's clock, not this server's, so a machine running a minute fast would otherwise
vanish from "the last 30 days".
Paging and date ranges
Every grid in the console is paged server-side, with the page number in the query string,
so a page of a table is a URL that can be bookmarked, pasted into a ticket, or reloaded
after a form post. Offset paging rather than a cursor: these are administrative tables of a
few thousand rows at most, bounded per tenant, and a cursor would cost a stable sort key on
every query for a benefit nobody here would notice.
Two details that are easy to get wrong and are tested:
- Paging keeps every other parameter. A Next link that dropped the search term or the
date range would show the reader a different set than they think they are looking at.
- Several tables on one screen page independently, each with its own parameter
(page, src_page, ev_page…), so turning one does not reset the others.
The pager is rendered even for a single page, because "1–8 of 8" tells a reader the table
is complete; a table with no footer does not.
Date ranges are presets plus a from/to form, both plain HTML — the presets are anchors
and the custom range is a GET form, so a chosen range is a URL and the CSV export link
carries it. Every range is validated server-side: a backwards range is swapped rather than
returning nothing, a span over two years is clamped, a malformed date falls back to the
default, and the page says when it corrected the request. A query string is user input, and
an unbounded one is an unindexed full-table scan.
The console's UI
React, rendered on the server with renderToStaticMarkup, and no client bundle at all.
Components and composition where they help; zero hydration, zero JavaScript shipped.
That combination is what lets the content security policy be default-src 'none' with no
script-src and style-src 'self' with no 'unsafe-inline' — the strictest policy a page
can have. It also means every page works with script disabled, and the whole UI is testable
by asking the app for a URL and reading the response.
The consequences are deliberate and worth knowing before changing anything:
- The theme is a cookie, not a script. Three states: light, dark, and system. System
stores nothing and lets prefers-color-scheme decide; the other two stamp data-theme
on <html>, which the server renders. The right theme is therefore in the first byte of
HTML and there is no flash of the wrong colours. The toggle is a form POST.
- The collapsed sidebar is the same mechanism.
- Nothing dynamic may use a
styleattribute, because the CSP blocks it. Charts are
SVG; everything else is a utility class.
- The stylesheet is served from
/assets/app-<hash>.css, the hash being of its own
contents, which is what makes immutable caching correct. A stale hash redirects to the
current one rather than 404ing a page cached across a deploy.
Tests
205 tests across ten suites, all against a real PostgreSQL 18 server
(embedded-postgres, no Docker and no root), with fileParallelism: false so each suite
owns its database.
| Suite | What it covers |
|---|---|
tests/schema.test.ts | The indexes above, as behaviour |
tests/seats.test.ts | Concurrent allocation under serializable isolation |
tests/verification.test.ts | Single-use codes, counters, grants |
tests/pairing.test.ts | One test per bound in pairing-security.md |
tests/device-api.test.ts | The device API over HTTP, end to end, including a complete sign-in |
tests/dashboard.test.ts | The console over HTTP: sessions, CSRF, roles, escaping, uploads, the bundle |
tests/billing.test.ts | Entitlement via synthetic Stripe events |
tests/releases.test.ts | What a release offers, refuses, and tells a machine |
tests/analytics.test.ts | That the dashboard's counts are the real counts, and that the breakdowns split the way they claim |
packages/core/src/crypto.test.ts | ES256 against OpenSSL |
The one thing stubbed anywhere is the socket to the SMS provider — and in
tests/device-api.test.ts that stub is what makes a full sign-in testable, because the
code is not recoverable from the database by design, so a test has to read it off the wire
exactly like a handset would.