Skip to content

feat(core): declarative auth descriptors — ADR 0020 - #487

Open
rejifald wants to merge 3 commits into
mainfrom
claude/adr-20-03641e
Open

feat(core): declarative auth descriptors — ADR 0020#487
rejifald wants to merge 3 commits into
mainfrom
claude/adr-20-03641e

Conversation

@rejifald

Copy link
Copy Markdown
Owner

Implements ADR 0020auth accepts a declarative AuthDescriptor alongside the strategy factories — across all three proposed rollout steps, as three commits.

What changed

1 · Core (feat(core): declarative auth descriptors)

  • 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') } resolves to exactly what apiKey({ … }) 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.
  • Atomic extends slot (Q7): auth stops flowing through deepMerge (which would blend two strategies field-by-field — a latent bug) and becomes last-writer-wins like store/kind; the winner is normalized once, after extends resolves and before __config is built, so a descriptor's secret leaves never reach the strict-JSON __config.
  • Folded in refactor(auth)!: apiKey — symmetric name+in triad (header/query/cookie) #485's symmetric apiKey: the ADR builds on apiKey({ 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 legacy header alias 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/cookie apiKey shape documented. All ts twoslash blocks type-check against the real exports.

3 · from-curl (feat(core): from-curl scaffolds auth as a declarative descriptor)stitch from-curl emits auth: { strategy: '…', … } (pure data; imports only stitch + env, no strategy factory). export --openapi already emits descriptor-shaped JSON, and no OpenAPI→source gen command exists yet, so from-curl is the only source emitter to switch.

Bundle (the load-bearing design decision)

Naively importing the resolver into stitch.ts dragged all five factories (incl. heavy oauth2/cookieSession) into the lean import { 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.ts keeps fromDescriptor (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 bare import { 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 by auth-descriptor-unarmed.spec.ts).

Budget bumped in bundle-size.mjs — the factories' weight does not hit import { stitch }:

scenario before (main) after Δ old budget → new
import { stitch } 19.82 KB 20.04 KB +0.22 KB 20.00 → 20.25
whole entry 24.62 KB 25.07 KB +0.45 KB 24.80 → 25.30

(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. AuthConfig joins the P3 *Config carve-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/clientSecret to the trace secret stems" — not done for value. token/secret (→clientSecret) are already stems; but the shared stem set also drives redactSecretsDeep over request bodies on every start frame, so a value stem would redact legitimate body fields for no gain — a descriptor is config.auth, never body data, and redactConfig strips auth wholesale regardless. Reasoned in a code comment near AuthConfig.

Validation

Local: full core suite (1236 passing), check:types + check:types-d (tsd), eslint, prettier, check:contract, check:exports, check:size (within budget), and fumadocs-mdx twoslash over every edited doc block. build:typed-deps builds all 7 integration packages against the new AuthConfig (non-breaking, monorepo-wide). The full pre-push/CI gate (whole-tree pnpm test + build-docs) runs here.

Note: this branch's worktree had two pre-existing, unrelated environmental gaps — a stale packages/core/lib/ (fixed by a rebuild) and a missing twoslash devDep (fixed by pnpm install); neither is caused by these changes.

🤖 Generated with Claude Code

rejifald and others added 3 commits July 23, 2026 22:24
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant