oficina / home Public
Directory roster HTTP and signing integration
Status: design-only; review record in Home discussion 34. Explicit Fable design approval is required before implementation. Scope: Home discussion 34, stacked on directory runtime 171f49a, shared roster 667dc82 and producer 32fb49c. The approved roster contract (discussion 28) and producer storage contract (discussion 33) remain authoritative. This design adds runtime wiring and an HTTP adapter; it adds no operator mutation or provisioning transport and makes no deployment/readiness claim.
Source audit and boundaries
runtime.go currently merges sessions, handoff and directorydata.Schema only for the directory role. The credential role alone owns Runtime.signer and HOMEASSERTIONKEY. Directory configuration holds credential public verification keys and its own sealer; newDirectory rejects a credential private signer. directoryRouter puts sessions middleware around the browser routes. directoryFeatures already supplies the compiled app registry and validated team-origin policy. Reuse that registry and policy when constructing the producer; do not duplicate per-team rows in environment configuration.
The published client builds GET directory+/roster with url.Values.Encode, a Bearer token, Accept application/json and Accept-Encoding identity. It refuses redirects, compression, bodies over 64 KiB and Content-Type values other than exact application/json. It checks signatures before using page fields. Page-zero CursorHash is SHA256(empty), not an empty string. There is no epoch query or 304 shortcut. Wire members use person_ref. The pending Docs cache lease amendment changes no Page/SignPage contract; this adapter remains compatible with 667dc82.
Runtime and schema
Merge directorydata.RosterSchema immediately after directorydata.Schema in the directory-only schema. Credential and legacy schemas must not gain these tables or membership triggers. Applying this migration leaves generation, consumers, tokens and receipts empty. Repeated startup never fills them.
Introduce HOMEROSTERMODE with absent/disabled and serve values. In directory mode, absent/disabled plus an absent HOMEROSTERKEY means a disabled endpoint. The directory browser routes and boot remain available; every request that passes the roster host/method guards receives the same fixed 503 before inspecting any token, query or database. There is no standalone roster fallback.
Serve requires HOMEROSTERKEY. Unknown mode, key supplied while disabled, missing serve key or invalid key refuses startup with one fixed configuration error. Credential and legacy roles reject either roster environment variable when set, including an explicit disabled value. Direct Runtime constructors enforce the same mode/key invariants; LoadRuntime is not the only guard.
Represent roster configuration separately, for example a private rosterRuntime field with mode, signer and derived key ID. Keep Runtime.signer credential-only and preserve newDirectory's existing rejection of it. Directory now deliberately owns one new roster-only private key; update role comments, validation and tests that currently equate all private signing keys with credential authority.
HOMEROSTERKEY is canonical standard-base64 of the existing family.MarshalKeypair format, bounded to 4096 encoded bytes, with no alternate file or second encoding input. Decode strictly and verify the encoding round trip. Validate the closed keypair JSON before calling family.UnmarshalKeypair: exactly signprivd and boxprivd, canonical 64-character lowercase hexadecimal strings, no duplicate or unknown fields. Require P256 signing scalar 0<D<N and valid box scalar; derived public keys come from the existing library. The box half is unused by roster. Derive roster key ID as lowercase hex SHA256(SignPub()), matching the shared 64-byte atom bound. Do not take the key ID from request input.
Reject a roster signing public key equal to any configured credential assertion verification key. The independently audited operator provisioning key must also be distinct; it is not loaded by this producer and its separation is a writer/ provisioning gate. Never derive a roster private key from HOME_DEK, pepper, token, credential key or startup randomness. Startup does not publish a verification key or manufacture a signed operator record. Key rotation requires the separately approved operator record/key rollout before changing the active signing key.
Handler seam and dispatch
Use an unexported rosterHandler with the concrete validated roster runtime, configured directory origin, clock seam and a small read-source interface: Read(context.Context, directorydata.RosterRequest, time.Time) (roster.Page, int, error). Production injects NewRosterSource using the existing store's DB/sealer/app policy. Tests may inject a counting source or real store. Disabled mode does not construct or invoke a source or signer.
Dispatch decoded URL.Path == /roster to this adapter before directory browser session/chooser middleware. The adapter owns all methods for that exact route, so chi's default method response cannot bypass its headers. Cookies neither authorize the request nor cause Set-Cookie, sign-in redirects or session writes. All other routes retain the existing directory router and guards. The roster adapter does not reuse browser CSRF or assertion-consumer middleware.
At adapter entry set Cache-Control: no-store, no-transform, Content-Type: application/json and X-Content-Type-Options: nosniff. No CORS permission, compression, ETag, Last-Modified, Location or browser cookie is emitted. Refusal precedence is:
- r.Host must equal the configured directory host, including any configured port, exactly. No trailing-dot, alternate-host or forwarded-header alias. Wrong host returns fixed 404, before source access.
- Method must be GET; HEAD, POST, OPTIONS and all others return fixed 405 with Allow: GET. Nothing follows a redirect to turn a POST into a roster read.
- Disabled runtime returns fixed 503 for every token/query variation.
- Validate request syntax below; malformed query/path/body returns fixed 400.
- Validate one exact bearer header; malformed/missing/duplicate returns fixed401.
- Call the source once with a current server time. Nonpositive server time returns fixed503 here, before the source's own input check.
Require URL.EscapedPath() exactly /roster, no absolute request-target, no fragment, and no nonempty GET body: ContentLength must be zero and TransferEncoding empty. Do not read an attacker-sized body. The configured directory origin plus the request path/query must fit 4096 bytes; bound RequestURI too. A valid first query contains exactly team and fetch. A continuation additionally contains one nonempty cursor. Query field order is unrestricted. Reject duplicate/unknown or empty fields, extra separators, semicolons, percent encodings, plus encodings and padding. Parse literal bounded name=value pairs; do not use a helper that silently discards malformed parameters. Team is 1..64 ASCII alphanumerics, fetch passes roster.ValidChallenge, and cursor is strict canonical raw-base64url at most roster.CursorLimit (2048 bytes), checked by decoding and round trip. The producer validates the sealed cursor contents again. Neither team nor cursor selects a network destination.
Count Authorization values across case-insensitive header names; require exactly one value equal to Bearer + one ASCII space + a canonical43-character token. Reject comma lists, repeated lines, tabs, extra spaces, alternate schemes and padded/noncanonical values. Use roster.ValidChallenge before calling the source. No browser session, Origin, Referer or query token substitutes for this header. Accept and Accept-Encoding need no special value from the caller: the adapter always returns the one JSON representation without compression. The no-transform directive is required by the CARLOS edge compressor as well as the app adapter: without it, application/json responses can be gzipped for Accept-Encoding: gzip, including small error responses when the upstream length is unknown.
The application adapter never redirects. The existing outer Go ServeMux can canonicalize unrelated malformed paths before the application sees them; the shared client never follows these redirects. Tests through the real outer mux must show no alternate path produces a trusted page, and canonical /roster never redirects for any method/query/error. No change to global router normalization is needed to serve the fixed canonical endpoint.
Response and error boundary
Give source.Read a request-derived context capped at five seconds, and obtain now immediately before it. No network call or operator write occurs under that read transaction. On nil error, accept only status200 with type page or status409 with type stale-cursor. Sign with roster.SignPage using the validated roster key and derived ID, and buffer the complete returned bytes before writing headers. Check len <= roster.BodyLimit as a final boundary. Sign/marshal/status failure returns the fixed unsigned503; never emit an unsigned Page or a partial signed response. Do not duplicate the shared envelope/signature or JSON-pointer logic.
A source failure maps only its fixed401/404/400/503 error/status pairs. Unexpected pairs become fixed503. Keep the approved source's exact mapping, including401 for a missing token-to-registry join; do not infer a more revealing cause. Authenticated epoch/generation change is the signed409 shape, with current scope, fetch, request-cursor hash, generation and epoch and all page-only fields absent. It is never treated as an empty successful roster or a new generation approval.
Unsigned errors contain only a fixed machine code: badrequest, unauthorized, notfound, methodnotallowed or unavailable in an error JSON member. No supplied value or underlying error is serialized. No response includes a team name, email, origin selected from request input or token diagnostic. All errors keep no-store and exact application/json, although the client still refuses unsigned errors. No raw URLs, bodies, cursor, token, label, subject or key enters logs, traces, metrics or status messages. The handler itself needs no logging. Transport and platform access-log behavior must be checked before any live enablement; the application's no-log claim does not attest to an unaudited proxy configuration.
Authority and deployment gates
In serve mode an otherwise authenticated, scoped request with absent generation returns503 through the source; it must not mint a generation, owner, membership, token or receipt. Invalid or unknown tokens remain401 and foreign/unknown teams remain identical404. Merely installing the key does not initialize or approve authority. Runtime health may report process health as before; it is not a claim that roster provisioning is complete or that Docs TeamMode is ready.
The future writer must authenticate independent operator authority before its transaction, verify the exact scope/expected generation/record sequence, assert foreign_keys ON, and atomically write state and a successful receipt. It must follow the producer storage contract's token-slot and epoch-trigger rules. This slice adds no such writer, route, command, migration seed or environment import. No first visitor or roster bearer can supply it. Existing-db bootstrap and restore remain behind the supported fenced maintenance path (platform117); a genuinely fresh first-boot path needs separate audit. Without either approved path there is no live generation/consumer initialization and no general team roster launch. Test SQL and test operator signatures prove behavior only.
Implementation acceptance
Run the complete Home gate, focused producer/handler race tests, actual CI and Fable whole-code review on a frozen candidate before parent integration. Tests must use actual runtime/schema/router paths as well as the injected handler:
- Directory schema contains roster tables/triggers; credential/legacy schemas do not. Empty and repeated startup leave authority tables empty. Disabled runtime returns identical503 across token/query inputs without DB/signing/session work.
- Mode/key matrix covers both environment loader and direct constructors, role separation, malformed/private-key scalar/JSON cases, credential key reuse and no startup generation. Existing Home handoff/chooser browser gates still pass.
- Exact Host, all wrong methods, duplicate/mixed-case Authorization maps, comma/ whitespace forms, absent or noncanonical challenges, hostile team/cursor, duplicate/unknown/escaped query keys, empty cursor, oversized target and GET body all refuse before any source call. Errors leak none of the test corpus.
- Use real directory fixture transactions and the actual shared Cache client against the handler through an injected same-origin TLS test transport. Signed 200 with zero, one and more than100 members verifies; exact empty array stays explicit. Rotation, disable and foreign/unknown team failures remain bounded.
- Between pages mutate membership or generation; verify signed409 and no cache publication/lease extension. Wrong key/context/scope/signature cannot become standing. A missing generation never signs a successful page. No epoch/304 response path exists. Two consumers and two teams stay isolated.
- Buffer/sign failures return unsigned503 with no partial Page. Every adapter result has no-store, no-transform/exact JSON and no Location, encoding, validator or cookie. The outer mux/real server retains these for canonical /roster, and no redirect is followed by the shared client.
- Seed names, tokens and cursor plaintext in fixtures and scan DB/WAL plus captured application logs. Negative mutations removing bearer/mode/Host checks, bypassing SignPage, or dropping signed409 expose failing tests.
No browser UI is introduced by this endpoint. A deployed two-team transport canary is a later root-owned gate once an audited provisioning path exists; this design and local fixtures do not authorize live seeding or mark the suite ready.