id-proto-debugger

Shared Signals (SSF) — the seventeenth protocol, and the first that talks back

Read this before touching client/public/ssf.html, client/src/ssf*.js, api/ssf_proxy.js, api/ssf_receiver.js, POST /ssf/call, the api’s push receiver, or any of the four tests/*ssf*.js.

OpenID Shared Signals Framework 1.0, approved as a Final Specification on 2 September 2025, over RFC 8417 (Security Event Tokens), RFC 9493 (Subject Identifiers), RFC 8935 (push delivery) and RFC 8936 (poll delivery). The transmitter this workflow is built against is the mock STS’s /ssf, whose own notes are in sts/ssf/CLAUDE.md.

This is part one of three, and all three have landed. SSF is the plumbing; CAEP and RISC are two different vocabularies spoken over it. CAEP has been here since 2026-09-03 — see docs/caep.md — and RISC since 2026-09-04, see docs/risc.md. The whole of this workflow was arranged so that adding one is rows in a table, and the two sections below record what that actually cost: four things for the first vocabulary and, for the second, none of those four.


The problem it solves, because it is not obvious from the endpoints

SAML and OpenID Connect authenticate at one instant. After that the relying party holds a session or a token that stays valid for its lifetime — often hours or days — regardless of what happens next. Fire somebody at ten and their session keeps working until the token expires. Shortening token lifetimes to fix that trades security for load and user friction.

Shared Signals inverts it: the identity provider tells the relying party when something changed. That is the whole idea, and every design decision below follows from the fact that this is the one protocol here where the traffic originates at the far end.

The hard part is not receiving the events. It is what an application does with session revoked when its own session store, its caches and its downstream service tokens all have independent lifetimes. A signal that arrives in 200ms and is enforced forty minutes later at token refresh is not continuous access evaluation, it is a faster audit log. Nothing in this debugger can test that for you; what it can do is make every part of the wire visible, which is the half that is otherwise guesswork.


The one thing to hold on to: SSF is the pipe and not the vocabulary

SSF defines how two parties agree a stream, who the events on it are about, what they travel in and how they get there — and exactly two events of its own, both about the pipe:

Event What it is
…/ssf/event-type/verification The receiver asked “is this stream alive?” and this is the answer travelling the ordinary delivery path. It is the only end-to-end test a stream has: a 200 from the management API says the configuration was accepted and says nothing about whether an event can reach the receiver.
…/ssf/event-type/stream-updated The stream’s status changed and the receiver is being told in band. It is the one event a receiver gets without asking, and the one whose absence is hardest to notice: a stream quietly paused at the transmitter looks exactly like a service where nothing has happened lately.

The vocabularies are:

Those are two different sentences and the distinction is the reason there are two specifications.

What that means for this code

client/src/ssf_events.js is the vocabulary and was the only file the other two parts changed. client/src/ssf_client.js is the pipe — the subject grammar, the SET envelope, stream configurations, both deliveries — and twenty-two vocabulary rows later it still names no event type anywhere. The same split holds in the mock (sts/ssf/ssf_events.js against everything else in that directory), on the console page, and in the management API.

If a function in ssf_client.js ever grows a branch that names one of the twenty-four event types, that is the design going wrong.

The page says which vocabularies are here, rather than leaving a short list to be read as a broken page. FAMILIES in ssf_events.js marks all three implemented: true now, and tests/ssf_engine.js asserts that each row is honest about itself — which is the same check in the other direction: a row CLAIMING a vocabulary that is not there would leave a reader unable to tell this tool does not do it from I have not found it yet.

What adding the first vocabulary actually cost

The promise was rows in ssf_events.js’s table and nothing else. Eight rows later, what had to change outside that table was four things, and none of them is a branch naming a vocabulary:

And what adding the SECOND vocabulary cost, which is the shorter list

None of those four. checkMember() grew not one value type; the mock’s transmit() refusal was already written against the row and did not move; and streamCoversSubject()’s complex-subject rule is CAEP’s — RISC’s subjects are plain, so it was not touched. Fourteen event types cost the catalogue’s machinery nothing at all, which is the only kind of evidence a claim like the one above can have.

What RISC added is one thing CAEP also added and one genuinely new:

Three smaller things fell out of the specification rather than the design, and each is documented at length in docs/risc.md: the mock’s observe() answers with a LIST because one directory write can be two events; the register is keyed on the PERSON rather than on the subject, because the two identifier events use a different format and a subject-keyed register would split one person in two at exactly the moment their identifier changed; and there is an opt-out GATE, which CAEP has no equivalent of.

The page itself grew a Profile selector and a CAEP session pane, and narrows every event list to the chosen vocabulary. Nothing is hidden: every pane here is used by all three, because the pipe is the same machinery underneath.


The five modules, and why none of them has a DOM

Module What it holds
client/src/ssf_client.js The pipe. RFC 9493’s eight subject formats with their closed member sets and SSF’s complex subject; discovery and the endpoint lookup; stream configurations; the RFC 8417 envelope and every finding a SET can produce; both deliveries.
client/src/ssf_events.js The vocabulary. SSF’s two event types, the three families, and the validator.
client/src/ssf_history.js The two histories — the token sets and the event messages — and the redaction.
api/ssf_proxy.js What POST /ssf/call will and will not forward. No axios and no network.
api/ssf_receiver.js The push inbox the api hosts on the page’s behalf. No express and no network.

client/src/ssf.js is the DOM and nothing else, which is the rule scim.js and digital_signature.js follow. What it buys is that tests/ssf_engine.js drives the interesting half in node with no browser — and the defects that matter in this protocol are never crashes:

Every one of those produces a workflow that works perfectly against itself.


The subject grammar is written twice on purpose

common/krb5 is vendored into the mock because one wire codec must not exist twice. RFC 9493 is the opposite case and the two look alike, so the reasoning is worth stating.

A subject identifier is JSON, and the defect that matters in it is a READING — an accepted extra member, a missing required one, a format name spelt from memory. If both ends read one implementation, a misunderstanding they SHARE is one neither can see: the round trip passes and the workflow interoperates with nothing.

So client/src/ssf_client.js has this side’s grammar, sts/ssf/ssf_subjects.js has the mock’s, they were written independently, and tests/ssf_protocol.js drives one against the other over the wire — all eight formats, the complex subject, and three refusals. That is the argument common/pq_jose.js makes in the mock about the composite construction, applied to a grammar instead of to a signature.

The eight formats, and the rule that catches people

account (an acct: URI), email, issuer_subject_id, opaque, phone_number, decentralized_identifier, uri, aliases.

Each format’s member set is CLOSED. RFC 9493 section 3 gives every format an exhaustive list of members and a conforming receiver must REJECT an identifier carrying one it does not recognise — it cannot tell whether the extra member NARROWS the subject. That is the check ssf_client.js makes and the mock makes independently, and it is the one nothing else would.

Three value rules are worth knowing because a comparison depends on them:

aliases may not contain another aliases (section 3.2.8). This workflow refuses that rather than flattening it, because flattening builds a document a conforming receiver rejects and the sender never finds out.

The complex subject is what makes CAEP possible

SSF 1.0 section 4 lets a sub_id be an object whose members — user, device, session, tenant, org_unit, group — are each themselves a subject identifier. That is what makes “this session was revoked” expressible at all: the person is not revoked, one session of theirs is.

A complex subject is told from a simple one by the absence of format, which is the specification’s own discriminator. The obvious alternative — “does it have a member called user?” — is wrong for an opaque subject whose id happens to be spelt that way, and tests/ssf_engine.js asserts exactly that case.

critical_subject_members in the transmitter’s metadata names the members a receiver MUST understand. Publishing one is a promise, so this workflow refuses to SEND a complex subject that omits it — the transmitter would refuse it anyway, and the message is more useful on this side.


Discovery: this workflow composes no paths

SSF fixes no endpoint paths. Every one of them is published in the transmitter’s configuration metadata, so ssf.js reads them out of that document and composes none — a transmitter that publishes its stream management API at /v1/streams/manage is driven with nothing typed, and a member the document does not carry produces a sentence naming it rather than a request to a path this tool invented.

The document is looked for at /.well-known/ssf-configuration in both shapes, insertion first: RFC 8414 inserts the well-known segment before the issuer’s path and OpenID Connect Discovery appends it, and a transmitter published under a path can be either. That is the same arrangement metadata_client.js makes for an issuer.

METADATA_MEMBERS in ssf_client.js carries every member with what it is FOR and whether SSF makes it required, and the page draws the whole table beside the document it fetched — because a reader cannot tell a missing OPTIONAL member from a missing REQUIRED one by looking. A member this build does not know is REPORTED and not refused: SSF metadata extends, and one that is not in the table is a transmitter doing something extra rather than something wrong.

Two members decide more than they look like they do:

baseUrl comes from the OAuth2 / OIDC discovery document

The one thing this page does have to be told is where the transmitter is, and since 2026-09-01 it is usually told by the workflow next door rather than by somebody typing.

This page already sends you to the OAuth2 / OIDC workflow for a token — SSF 1.0 section 8 has every management, status, subject, verification and poll endpoint protected, and almost every transmitter names OAuth 2.0 in authorization_schemes. In every deployment this tool is pointed at, the service that issues that token and the service that transmits the events are one service. So the base URL is very nearly always the issuer that was just discovered, and typing it a second time is a second chance to type it differently: an SSF workflow pointed one character away from the host the token was minted for fails with a 401, which reads as a bad token and sends somebody to look at the token.

What is read is the issuer and not an endpoint, and the reason is the section above. issuer is the one member of an OpenID Provider / RFC 8414 document defined to be the base a well-known path is composed against — which is exactly what this page then does with it. Any other member is an endpoint whose path is that server’s business, so deriving from one would be composing a path out of somebody else’s, which is the thing this workflow does not do.

The fallback is the document’s own URL, and only when there is no issuer — a document without one is not conformant, and this is a debugger, so the case is handled rather than refused. The well-known segment is removed in both of the shapes it can appear in, for the same reason it is looked for in both: OpenID Connect Discovery appends /.well-known/openid-configuration to the issuer and RFC 8414 inserts /.well-known/oauth-authorization-server before the issuer’s path, so stripping only a suffix leaves an RFC 8414 issuer’s path stranded on the end.

The precedence is the whole design, and both halves of it fail silently. What is stored wins, because it is what somebody typed — a discovered value that overwrote it would move the transmitter under a reader who had pointed this page somewhere on purpose, and it would do it on a page load, so the field would simply be wrong the next time they looked. An empty field is filled, because otherwise the feature does nothing for the reader who has just come back from the token hand-off, which is the one it exists for. The Source column of the Configuration Parameters table says which of the three answered — you, the OAuth2 / OIDC issuer, or this deployment’s default — and typing in the field takes the credit back. The button beside it (Use the OAuth2 / OIDC issuer) is the deliberate act and therefore does overwrite; a button that did nothing whenever the field was non-empty would do nothing almost always.

op_metadata.js owns the storage key the document is read from, and this page takes it from there rather than writing "discovery_info" out again — a key spelled in two places is a page that silently reads nothing the day the other one changes. It costs this bundle op_metadata.js and metadata_client.js, about 1,200 lines of plain JavaScript and no new third-party dependency.


The stream, and the two members most often confused

events_requested is the ask and events_delivered is the answer — the intersection of the ask with what the transmitter supports.

SSF has no refusal for an event type a transmitter will not agree to. Its absence from events_delivered is the only notice a receiver gets, so a client that reads the first back as the second waits for events nothing will ever send. readStreamConfiguration() compares the two and the page draws the difference as a finding; tests/ssf_engine.js asserts it.

PUT replaces and PATCH merges, and the difference is real: a PUT that behaved like a PATCH would let a receiver believe it had cleared events_requested when it had not, and the symptom is event types still arriving after they were “removed”. tests/ssf_protocol.js asserts both directions against the mock.

aud is required and this workflow refuses to send a configuration without one. So does the mock, and sts/ssf/CLAUDE.md argues why it does not default it to the authenticated caller: a receiver whose audience was invented for it never learns the member is required, and the audience it checks for ITSELF in would be a name the transmitter chose.

The three statuses, and the one that matters

enabled, paused, disabled. A paused stream keeps queueing and delivers nothing; a disabled one drops what is waiting. That is the difference between “I was not listening” and “it did not happen”, and it is the whole reason a receiver taking a maintenance window pauses rather than disables. tests/ssf_protocol.js asserts both halves against a real transmitter: an event asked for during a pause comes back after the resume, and one queued before a disable does not survive it.


Delivery, and the one asymmetry that cannot be designed away

RFC 8936 poll delivery works in a browser. RFC 8935 push delivery cannot.

Poll has the receiver come to the transmitter, which a page can do. Push has the transmitter POST to the receiver — so the receiver has to be REACHABLE, and a browser is not an HTTP server. That is a property of the two specifications rather than of this tool, and no amount of proxying changes it.

So the api hosts an RFC 8935 endpoint on the page’s behalf (api/ssf_receiver.js): the page asks for an inbox, gets a URL, puts it in the stream’s delivery.endpoint_url, and drains what arrives. On the hosted static sites there is no api and therefore no push, and the page says so in its callPath row rather than offering a stream that would be agreed and silently deliver nothing.

The push inbox is an unauthenticated endpoint that accepts data

Which is the most dangerous shape anything in the api has, so it is worth knowing what bounds it. It HAS to be unauthenticated — what pushes to it is somebody else’s transmitter, and the only credential in that exchange is the authorization_header the receiver chose — so the bounds are structural rather than a gate:

  1. an inbox exists only because somebody asked, and its id is 32 hex characters of crypto.randomBytes;
  2. every inbox expires (ssfReceiverTtlMs, an hour) and is swept from every entry point rather than on a timer;
  3. the counts are capped — inboxes, events per inbox, and the OLDEST event goes when the ring fills, because a receiver that has stopped draining most wants what has happened lately;
  4. each event is size-capped;
  5. ssfReceiverEnabled turns the whole thing off, and it is false wherever there is no api at all.

Nothing is executed, rendered or forwarded, and the api verifies no signature: it holds no key of the transmitter’s, and a receiver that refused what it could not verify would be unable to show anybody WHY — which is the question the workflow exists to answer. The PAGE verifies.

ack and setErrs both take an event off the queue

ack names what the receiver STORED. setErrs names what it REFUSED, and that one catches people: a receiver that could not process an event will not process it next time either, so a transmitter that redelivered would poll-loop for ever. The refusal is recorded on the stream instead, where a person can see it.

moreAvailable is the member a client most often ignores, and ignoring it means assuming one poll drains the queue. The page lifts it out and says so.


Push delivery crosses TLS now, and the mock had to be told

The api serves https (common/tls_listener.js), and POST /ssf/receiver builds the deliveryEndpoint it hands back from req.protocol — so the URL that goes into a stream’s delivery.endpoint_url is https://api:4000/ssf/receiver/:id with nothing having been told to change. Two consequences, and both were found by tests rather than anticipated.

The transmitter has to trust that certificate. The mock STS is the only service here that makes an outbound connection TO the api, and a push is it. The stack’s certificate is therefore handed to that container as NODE_EXTRA_CA_CERTS in every compose file — which is the whole reason the pair is generated ONCE, on the host, before compose starts, rather than by each service at its own startup: an environment cannot carry an anchor for a certificate that does not exist yet. Without it the push dies at the handshake and the page waits for an event that was never delivered, which reads as a transmitter that sent nothing.

And the api has to trust its own. POST /ssf/call is a proxy the caller aims, and this workflow legitimately aims it at this same service’s own receiver — which is how tests/api_ssf.js asserts the third outcome (a far-end refusal comes back as a 200 carrying it) with no transmitter in the picture. Before api/sts_truststore.sh learned to write a bundle rather than point NODE_EXTRA_CA_CERTS at one file, that call failed verification and the proxy reported a 502 — “the far end did not deliver” — so a receiver correctly refusing a malformed Security Event Token was indistinguishable from a network failure. NODE_EXTRA_CA_CERTS names one file and node reads it once, so two anchors is a concatenation or it is nothing.

The Security Event Token, and the three things implementations get wrong

A SET is a JWT whose payload carries an events map from event-type URI to that event’s payload. Not an array, and not a single event — the map is what lets one token carry a set of events that happened together, and it is why the media type says “secevent” rather than “event”.

toe is not iat. The Time Of Event is when the thing happened; iat is when the token was minted. A token issued now may report something from an hour ago, and a receiver deciding whether to end a session cares about the first.

inspectSet() and inspectSetHeader() report every check by name rather than one boolean, and the page draws them that way. A single “valid: true” over a token whose aud is somebody else is the most dangerous thing this page could say.


Post-quantum, and why a SET is the document that most wants it

Every signature in this workflow goes through jws.js, which is the module that already does every JWS in this application — so this page gets every registered algorithm for no code at all, including ML-DSA at three sizes, SLH-DSA, and the six composite ML-DSA + traditional algorithms. ssf.signingAlgorithm picks one at the mock.

A SET is the document in this application most worth signing post-quantum. It records that something HAPPENED, RFC 8417 forbids it to expire, and it is therefore read long after it was written — which is precisely the case a harvest-now-decrypt-later argument is about. Every other signed artefact here has a lifetime measured in minutes or hours.

tests/ssf_engine.js signs and verifies a SET with one algorithm from every family jws.js offers, and tests/ssf_protocol.js pushes an ML-DSA-signed one at the mock.

It found a defect in the mock’s centralized crypto

RFC 8417’s typ is asked for through helpers.signJwtAs(). jsonwebtoken merges a caller’s options.header, so the library path had always honoured it — but the mock’s two hand-rolled signers, the ownSigner branch (EdDSA and ES256K) and the post-quantum branch, each hard-coded typ: 'JWT' and ignored options.header entirely. The same call produced a different header depending on which algorithm was chosen, and no caller could have seen that coming. protectedHeaderFor() in sts/common/crypto.js is the fix.


Two call paths, and what a static deployment loses

Every call this page makes can go two ways, exactly as the SCIM page’s can. FRONTEND is a fetch from the browser and is the default; BACKEND goes through POST /ssf/call. The browser path works with no api at all, which is why this page ships to the hosted static sites and its landing card is not greyed.

The api path exists for three things a browser cannot do — and the first bites harder here than on the SCIM page:

On a build with no api the BackEnd option is switched off rather than merely marked — the distinction css/pki.css records about its own disabled pane: a radio that only looks grey is still selectable with a keyboard, and the refusal would then come from a fetch to an api that is not there, which reads as a broken page. tests/ssf_page.js asserts it, and asserts that the note says push delivery is unavailable too.

The three outcomes

A refusal by the api is a 400. A network failure is a 502. An SSF error from the transmitter is a 200, carrying that status and its RFC 8935 {err, description}.

The third matters more here than anywhere else this pattern is used, because of what an SSF refusal SAYS: invalid_audience on a stream whose aud is wrong, a 404 on a stream_id that was deleted, a 403 naming the scope, a 400 naming the member of a subject identifier RFC 9493 does not define. Every one of those is the transmitter explaining exactly what is wrong, in a sentence, and they are the most useful thing this workflow can put on the screen. An endpoint that reported them as failures would throw all of it away.


The two histories, and where each one lives

The workflow keeps both, and they answer different questions.

The token history is every set of tokens used in this session, the way the OAuth2 / OIDC results page keeps one — because an SSF receiver’s whole relationship with a transmitter runs on a bearer token, a stream outlives the token that created it, and “which token was I holding when that stream stopped answering” has no other way of being asked. It is a separate store from that page’s token_history and not a second view of it: these are the tokens this workflow HAS USED, which is a different set from the tokens that page has obtained.

It is in sessionStorage, which is the opposite of what the OAuth2 / OIDC page does with its own and is deliberate. That page’s history is the point of the page; here a token is a CREDENTIAL this workflow is using to drive somebody’s control plane, and the page can do everything it does without one surviving a tab. token_handoff.js makes the same choice for the same reason.

The message history is every Security Event Token sent or received, whole, with what it decoded to and what every check said. It is in localStorage, and the reason is that a SET is evidence rather than a credential — holding one grants nothing and presenting one to anybody achieves nothing — and evidence is what a debugger most needs to survive a navigation. What IS stripped is the Authorization header of the exchange that carried it, which is a credential and is the one part of a push that is.

tests/ssf_page.js asserts both stores, and asserts that the access token, the ID Token and the transmitting private key reach neither.


The hand-off carries the whole token set, and that is new

The SCIM page introduced token_handoff.js: a page marks itself as waiting, the browser goes to the OAuth2 / OIDC workflow, and the access token comes back. That page wants a bearer token and nothing else.

This workflow needs more, and the reason is worth stating: an access token this project’s mock issues is opaque to a client, so a page holding one cannot say who is signed in. The identity is in the ID Token. A hand-off that carried only the bearer credential would leave this page showing an authenticated user it could not name — and the ticket asked for exactly that.

So deliver() takes an optional third argument and take() answers with an optional set member: { idToken, refreshToken, tokenType, scope, expiresIn }. It is additive — a caller that passes nothing is unaffected, and take()’s existing answer goes on meaning what it meant — and oauth2_oidc_2.js fills it at all three of its token-bearing responses.

The page reads the ID Token’s claims without verifying them, and says so. This workflow does not consume an ID Token, so checking its signature would be answering a question nothing here asks; the JWT Tools page is where that is done properly. A page that implied otherwise would be the worst kind of wrong.

And it asks for the scopes these endpoints need

The hand-off’s original claim was that the waiting page “knows nothing whatever about an authorization server”, and for the SCIM page that is exactly true. It is not true of the scope. A workflow knows perfectly well what permission its own endpoints require, and it is the only party in the transaction that does — so start() takes an optional scope, this workflow passes openid ssf:read ssf:write, and both OAuth2 / OIDC pages fill it in.

There is no way to discover those names. SSF 1.0 section 8 protects the stream management, status, subject, verification and poll endpoints and has the transmitter publish authorization_schemes — but each entry carries spec_urn and nothing else. So a receiver can learn “this transmitter takes an OAuth 2.0 bearer token” and cannot learn what that token has to say, and the specification defines no scope names to guess at. ssf:read and ssf:write are this stack’s mock’s (configurable there as ssf.authScopeRead / ssf.authScopeWrite, and advertised in its scopes_supported), and they are two different permissions rather than a pair: read gets a stream, its status and its poll queue; write creates, updates and deletes one, adds and removes subjects, and triggers a verification event. openid is not the transmitter’s business at all — it is how the ID Token above is asked for.

Two fields on two pages, and both are needed. #scope on oauth2_oidc_1.html is the authorization request’s, which the code grant and the three OIDC flows send; #token_scope on oauth2_oidc_2.html is the token request’s, which client credentials and resource owner password send — and those two never pass through page 1 at all, since its onload() sends them straight on. #refresh_scope and #tokenexchange_scope are deliberately left alone: a refresh asks for the original grant’s scope or a subset of it (RFC 6749 section 6) and empty is how “the same as before” is spelled, and a token exchange is a request for a different token entirely.

It is a merge, not a replacement, and it stays editable. mergeScope() in token_handoff.js keeps what the reader already had and appends what is new, without duplicates — so openid profile becomes openid profile ssf:read ssf:write, running the hand-off twice changes nothing, and a transmitter that names its permissions differently is a text field away rather than a defect. The banner on each page says which scopes were added and what the field now reads, because a field that changes under a reader has to say why.


What this workflow deliberately does not do


The four tests, and what each is for

File Needs What only it can see
tests/ssf_engine.js nothing Every rule, against the specifications’ own text. All eight subject formats and every refusal, the SET envelope signed with every algorithm family including the post-quantum ones, stream configurations, both deliveries, the vocabulary table, the two histories, and every bound on the api’s two modules. Never skips.
tests/ssf_protocol.js a transmitter One grammar against another over the wire. The whole stream lifecycle, every subject format the mock’s own reader accepts, what a pause keeps that a disable drops, poll and push end to end — it hosts an RFC 8935 listener of its own — and both deliberate defects.
tests/api_ssf.js the api The wiring: the body parser for application/secevent+jwt (without which every push reads as an empty body), the three outcomes as HTTP statuses, the address policy still covering this endpoint, and the push receiver.
tests/ssf_page.js a browser That the bundle ran at all, the callPath row, both histories, the extended hand-off, the ssf- classes, a clean console — and, since 2026-09-01, the pane collapse, where baseUrl came from, and that the hand-off asks for ssf:read and ssf:write in both pages’ scope fields without losing the reader’s own.

The page’s chrome is asserted rather than eyeballed, and the two sections that do it are worth knowing about because every one of their failures is silent in a browser. thePanesCollapseAndTheProseIsFolded() checks that every pane pairs a .dbg-legend with the fieldset the convention says it drives (a drifted pair is a title that does nothing at all, and wirePanes() logs a warning, which the console check further down does not treat as a failure), that all sixteen <details class="ssf-more"> folds ship CLOSED (a <details open> is prose back on the page, which is the thing the folds exist to end), and that the collapse triangle follows the real state — css/debugger.css turns it with .dbg-pane:has(fieldset[style*="display: none"]), which reads the INLINE style, so a pane hidden by a class instead would show an expanded triangle over a collapsed pane. It clicks the label and never the checkbox: .dbg-toggle input is visually hidden, so clicking it is element not interactable, which reads as a broken control rather than a hidden one. It restores the expanded state before returning, because every section below it drives a control inside one of those fieldsets.

theBaseUrlComesFromTheOAuth2Discovery() runs LAST but for the console check, because it writes discovery_info and ssf_base_url and reloads the page twice; it puts both back. What it pins is the precedence described above — an empty field takes the issuer, a typed one survives a reload, and the button overwrites on request — each of which is a silent failure in the other direction.

They are four rather than one so that a failure NAMES something: a rule, a transmitter, the api’s contract, or a page. ssf_protocol.js and ssf_page.js share the sts-ssf job lock, because the first turns the mock’s two deliberate defects on one at a time and a job polling that transmitter inside that window would report a bad signature as its own failure.


Settings

The api (api/env/*.js)

Setting What
ssfMaxRequestBytes The largest request body POST /ssf/call forwards. Separate from maxContentLength, which bounds the response: an Add Subject carrying a fifty-member aliases identifier is a large request and an EMPTY response.
ssfReceiverEnabled Whether this api hosts a push inbox. Only an explicit false turns it off.
ssfReceiverTtlMs How long an inbox lives.
ssfReceiverMaxInboxes, ssfReceiverMaxEvents, ssfReceiverMaxEventBytes The three caps.

There is deliberately no ssfAllowedPorts, for the reason there is no scimAllowedPorts: SSF is HTTP, and a port allowlist for HTTP would have to carry 80, 443 and every alternate somebody runs a service on.

The client (client/src/env/*.js)

ssfTransmitterUrlDefault — the transmitter’s base URL, resolved by the browser by default (like scimBaseUrlDefault, unlike ldapUrlDefault). Switching callPath to BackEnd moves the resolution to the api, and on the containerized stack that is a different host.

The mock transmitter

Twenty-seven rows in the SSF group of sts/common/config.js, drawn on /admin/ssf. sts/ssf/CLAUDE.md argues the ones with consequences; the two to know first are ssf.signingAlgorithm (which reaches the whole post-quantum table) and ssf.defaultSubjects (which decides what an empty subject list means).