feat(core): declarative auth descriptors — ADR 0020 - #487
Open
rejifald wants to merge 3 commits into
Open
Conversation
…r or a factory (ADR 0020)
`StitchConfig.auth` now accepts `AuthStrategy | AuthDescriptor` (exported as
`AuthConfig`). A descriptor is a flat discriminated union on `strategy`
(`{ strategy: 'apiKey', in: 'cookie', name: 'sid', value: env('KEY') }`) that
resolves to the exact strategy its factory builds — same wire behaviour, no factory
import. Both forms are first-class and permanent.
- Detection (Q6): `apply` fn ⇒ live strategy (wins even alongside `strategy`);
else `strategy` key ⇒ descriptor; else throw at construction.
- `auth` becomes an atomic last-writer-wins `extends` slot (Q7, like store/kind);
it is normalized once, after `extends` resolves and before `__config` is built,
so redaction and the engine always see a live `AuthStrategy` and a descriptor's
secret leaves never reach the strict-JSON `__config`.
- Folds in the symmetric `apiKey({ in: 'header'|'query'|'cookie', name, value })`
shape ADR 0020 builds on (was unmerged); the legacy `header` alias still works.
Bundle: the resolution machinery (the five factories `fromDescriptor` dispatches to,
incl. heavy oauth2/cookieSession) is kept OFF the core path via a resolver seam
(`auth-registry.ts`). The auth module arms the seam from the secret resolvers
(`env`/`secretsFile`/`secretFrom`) — which a real descriptor's credential runs
eagerly — NOT as a module-load side effect (esbuild would keep that and re-bloat the
lean path). So a bare `import { stitch }` with no auth tree-shakes the factories away
entirely; only the small on-core-path detection seam remains. A literal-secret
descriptor with no auth import throws a clear construction error. Budget bumped
24.80→25.30 / 20.00→20.25 KB (measured 25.07 / 20.04; ~0.2 KB headroom restored) —
the +2.45 KB the factories weigh does NOT hit `import { stitch }`.
`AuthConfig` joins the P3 `*Config` carve-out (it is the config-slot union, not an
options bag). CONTRACT.md records `strategy` as the settled discriminant.
Tests: descriptor↔factory wire parity for all five strategies, `{strategy,apply}`
precedence, extends last-writer-wins, `__config` redaction, apiKey symmetric shape,
and the unarmed edge case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a descriptor variant beside the factory snippet in every auth guide
(bearer/api-key/basic/oauth2/cookie-session) and a "Declarative descriptors"
section in the auth-strategies reference (the five shapes + the `AuthConfig`
type + the detection rule). The api-key guide also documents the symmetric
`{ in: 'header'|'query'|'cookie', name, value }` shape and the `in: 'cookie'`
location. All `ts twoslash` blocks type-check against the real exports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 0020)
`stitch from-curl` now emits `auth: { strategy: '…', … }` (pure data) instead of a
factory call, so the ejected client imports only `stitch` + `env`, no strategy
factory — the "no import in generated code" win. Recognised Bearer/Basic/API-key
credentials map to their descriptor form; the captured secret is still an `env()`
placeholder. Updates the CLI help text, the from-curl golden tests, and the
from-curl blog + agents doc that showed the old emitted output.
(`export --openapi` already emits descriptor-shaped JSON; no OpenAPI→source `gen`
command exists yet, so from-curl is the only source emitter to switch.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rejifald
added a commit
that referenced
this pull request
Jul 28, 2026
…port (P15/P9) (#526) Broken out of #470. **P15 — the required fields go positionally.** `basic` has exactly two required fields and no optional ones, so the options bag was pure ceremony: basic('alice', 's3cret') ≡ basic({ user: 'alice', pass: 's3cret' }) Both spellings stay; the envelope form is unchanged. Discrimination is sound without a runtime sniff of object shape: a `Secret` is a string or a thunk and never a plain object, so an object argument IS the options form. **P9** — the bag is now the named, exported `BasicOptions` (was inline at the parameter), and it is re-exported from the core barrel like every other option type. This is an ORPHAN from #470 that no other in-flight PR carries: I checked both auth branches (#485 `worktree-apikey-name-in-triad` and #487 `claude/adr-20-03641e`) and each still has only the object-form `basic(opts)`. Landing it separately keeps it from falling through the cracks between them. It touches only the `basic` block of auth.ts, so it should rebase cleanly under either. Verified: core typecheck + DTS build clean, 130 files / 1230 tests green (including the new positional spec), eslint clean, contract ratchet 0, prettier clean, and the bundle-size gate passes with room to spare (24.68/19.86 against 24.80/20.00 — the overload adds no runtime weight). Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements ADR 0020 —
authaccepts a declarativeAuthDescriptoralongside the strategy factories — across all three proposed rollout steps, as three commits.What changed
1 · Core (
feat(core): declarative auth descriptors)StitchConfig.authnow acceptsAuthStrategy | AuthDescriptor(exported asAuthConfig). A descriptor is a flat discriminated union onstrategy:{ strategy: 'apiKey', in: 'cookie', name: 'sid', value: env('KEY') }resolves to exactly whatapiKey({ … })builds — same wire behaviour, no factory import. Both forms are first-class and permanent.applyfn ⇒ live strategy (wins even alongsidestrategy); elsestrategykey ⇒ descriptor; else throw at construction.extendsslot (Q7):authstops flowing throughdeepMerge(which would blend two strategies field-by-field — a latent bug) and becomes last-writer-wins likestore/kind; the winner is normalized once, afterextendsresolves and before__configis built, so a descriptor's secret leaves never reach the strict-JSON__config.apiKey: the ADR builds onapiKey({ in: 'header'|'query'|'cookie', name, value }), but refactor(auth)!: apiKey — symmetric name+in triad (header/query/cookie) #485 wasn't merged — so this extends the factory to that shape (incl.in: 'cookie'), keeping the legacyheaderalias working. If refactor(auth)!: apiKey — symmetric name+in triad (header/query/cookie) #485 lands separately, the two need reconciling.2 · Docs (
docs(auth): …) — a descriptor variant beside the factory snippet in every auth guide, a "Declarative descriptors" section in the reference, and the symmetric/cookieapiKeyshape documented. Allts twoslashblocks type-check against the real exports.3 · from-curl (
feat(core): from-curl scaffolds auth as a declarative descriptor) —stitch from-curlemitsauth: { strategy: '…', … }(pure data; imports onlystitch+env, no strategy factory).export --openapialready emits descriptor-shaped JSON, and no OpenAPI→sourcegencommand exists yet, so from-curl is the only source emitter to switch.Bundle (the load-bearing design decision)
Naively importing the resolver into
stitch.tsdragged all five factories (incl. heavyoauth2/cookieSession) into the leanimport { stitch }path — for every consumer, even no-auth ones. Fix: a resolver seam (packages/core/src/auth-registry.ts) holding the detection + a resolver slot;auth.tskeepsfromDescriptor(which references the factories) and arms the seam from the secret resolvers (env/secretsFile/secretFrom) — which a real descriptor's credential runs eagerly — not as a module-load side effect (esbuild would keep that and re-bloat the path). A bareimport { stitch }with no auth therefore tree-shakes the factories away; only the small on-core-path detection seam remains. A literal-secret descriptor with no auth import throws a clear construction error (pinned byauth-descriptor-unarmed.spec.ts).Budget bumped in
bundle-size.mjs— the factories' weight does not hitimport { stitch }:import { stitch }(A naive in-core resolver would have been +2.45 KB / 22.27 KB on
import { stitch }— 2.27 KB over. The seam avoids that.) Advertised sizes stay ~25/~20 KB, so the yakir advertised-size tether is unaffected.AuthConfigjoins the P3*Configcarve-out (it's the config-slot union, not an options bag).Deliberate deviation from the ADR
ADR Q1's belt-and-suspenders "add
value/token/clientSecretto the trace secret stems" — not done forvalue.token/secret(→clientSecret) are already stems; but the shared stem set also drivesredactSecretsDeepover request bodies on everystartframe, so avaluestem would redact legitimate body fields for no gain — a descriptor isconfig.auth, never body data, andredactConfigstripsauthwholesale regardless. Reasoned in a code comment nearAuthConfig.Validation
Local: full core suite (1236 passing),
check:types+check:types-d(tsd), eslint, prettier,check:contract,check:exports,check:size(within budget), andfumadocs-mdxtwoslash over every edited doc block.build:typed-depsbuilds all 7 integration packages against the newAuthConfig(non-breaking, monorepo-wide). The full pre-push/CI gate (whole-treepnpm test+build-docs) runs here.🤖 Generated with Claude Code