Skip to content

Releases: C9up/transit

v0.1.6

Choose a tag to compare

@github-actions github-actions released this 23 Sep 17:12

Build against the published ream, require 0.2.26 at run time

The devDependency pinned ^0.2.26, which is not on npm, so pnpm install
failed before a single test ran and CI could not say whether the code was
good. Nothing here imports a symbol that 0.2.26 added: the only reference
is the @c9up/ream/types module augmentation, which 0.2.25 carries.

The peer stays ^0.2.26 — that is where configure()'s makeUsingStub
requirement belongs — and the caret picks 0.2.26 up on its own once it is
published.

Generate the config from a stub

The file this package writes lived as a template literal inside its own
TypeScript — every backtick and every ${ escaped, and no way for an
application to change it without forking the package.

It is a stub now, read through codemods.makeUsingStub, the same route
ream-cli has always used for the make: generators. An application that
publishes stubs/<path> gets its copy instead, and the generated file is
byte-identical to what the literal produced.

The ream peer moves to ^0.2.26: that is the release the codemod appears in.


Changes since v0.1.5.

v0.1.5

Choose a tag to compare

@github-actions github-actions released this 22 Sep 17:17

release: transit 0.1.5

Require Node 24, and build the crates for production

Node 24, not because it is the current LTS — that is AdonisJS v7's own
stated reason and it is not one for us, since 22 still receives security
fixes until 2027, npm 11 is irrelevant under pnpm, and node:sqlite is
not what atlas uses. The reason is measurable and it is the framework's:
AsyncLocalStorage is on the request hot path, and Node 24 backs it with
AsyncContextFrame by default — 0.61 us per request instead of 1.55 us on
that exact pattern. Before 24 the same mechanism sat behind an
experimental flag, and a framework cannot base its performance on a flag
the application has to remember to pass. The reason travels with the
constraint, in a "//engines" key beside it.

Where there are crates: the default release profile leaves lto = false
and codegen-units = 16, so nothing inlines across crate boundaries —
and here the hot loop and the N-API binding that calls it are always two
different crates. Measured on atom, a scalar call through the binding
went from 18.85 ms to 13.59 ms for 50 000 operations. No panic = "abort": napi-rs catches panics and turns them into JavaScript
exceptions.

CI moves to Node 24 with them, since that is what the packages now ask
for.


Changes since v0.1.4.

v0.1.4

Choose a tag to compare

@github-actions github-actions released this 19 Sep 09:22

Accept quasar 0.2 in the peer range

The connection contract these packages use — a default export with
connection(name?) on @c9up/quasar/services/main — is unchanged in 0.2.0,
but the range still said ^0.1.0, so every install reported an unmet peer
against the version actually on disk.

Release 0.1.4.

Keep the dev-dependency alignment, drop the workspace: protocol

The internal ranges had been rewritten to workspace:^. That resolves inside
this monorepo and nowhere else: every package CI checks out its own repository
alone and runs pnpm install, where the protocol has no workspace to point at
and fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND before a single test runs. The
concrete ranges are back; the dev-dependency bumps that came with the same edit
are kept, and now match what the lockfile already resolved.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_014TPESFkjvtFdG6f3cSy6Zi


Changes since v0.1.3.

v0.1.3

Choose a tag to compare

@github-actions github-actions released this 06 Sep 15:34

Keep the vendored copies out of this package's coverage floor

src/vendor/** is generated and identical in every package that carries it, so
measuring it here counts the same lines N times and holds this package to a
floor for code it cannot change — which is what pushed several suites under
their thresholds the moment the copies landed.

The behaviour is not left unmeasured: it is pinned where it broke, in bay's
quasar-bridge suite, which now covers both manager shapes the loader has to
accept.

Fold back into 0.1.3, which was never published

0.1.3 exists as a tag and never reached npm, so incrementing past it left a
version number nobody can install and a tag marking nothing. The rule is to
fold into an unpublished version and move its tag.

Sort the imports the vendored switch left out of order

Take nodeEnv from the vendored copy

The file was identical to the copies in the other packages, and they had
drifted apart only in their comments. It is generated from
scripts/vendor/nodeEnv.ts now and never edited here.

Take the quasar loader from the vendored copy

Seven packages carried the same optional-peer loader: the runtime specifier,
the manager guard, the command check and the messages around them. Only three
things differed — the commands each issues, what it does with them, and how it
builds an error — so those are passed in and the rest is generated from
scripts/vendor/quasarConnection.ts.

Two things the packages' own tests caught while it was being unified, and both
are now properties of the shared copy rather than of one package:

The module namespace is probed with in before it is read. Reading an export a
namespace does not have is not always harmless — under a test double it raises
instead of answering undefined, so the probe failed on the mock rather than
falling through to the default export.

The error is built by the caller. nova raises NovaError with E_NOVA_* codes
that a caller catches on, and a shared helper throwing a bare Error would have
dropped them silently. Packages that offer a client object instead of a
connection name keep saying so, too: unifying the wording had removed the
alternative from the one message where it was actionable.

Release the SSO manager when the application stops

The provider seated the manager and never released it, so a stopped
application left a live TransitManager reachable through services/main — and
that one holds the SSO providers, so a stale manager answers sign-ins for an
application that no longer exists. It clears its own now, and only its own.

Bound what an unverified message may cost

A SAML response is parsed before its signature is checked — it has to be, the
signature is inside it — so the parser ran on bytes an unauthenticated caller
chose, with no limit on any of them. It duplicated the whole string to
normalise line endings before looking at its size, and it recursed once per
nesting level, so a deep enough document overflowed the stack that had not
verified anything yet.

parseXml now takes limits and defaults them: a megabyte, a hundred levels,
fifty thousand nodes, and two hundred and fifty-six attributes on an element.
The length is checked before the copy, and the depth on the way into the
recursion.

An LDAP length header declares how much is coming before any of it arrives, and
four length bytes can ask for four gigabytes — so the declaration is refused
above eight megabytes rather than the bytes being accumulated. A peer that
sends without ever completing a message is cut off and the socket destroyed.

Lint this package the way its own repository will

biome's configuration lived only at the workspace root. This package is
built from its own repository, where that file does not exist and biome
falls back to its defaults — so lint in CI has been checking a different
set of rules from lint here, and the bans this project actually cares
about were never enforced where it counts.

The config is now the package's own, and says the same thing the root one
did.

Lint the tests, and stop hiding a throw behind an optional chain

Same shape as elsewhere: an optional chain in front of a cast reads like a
guard and is not one. The form bodies now go through a formBody helper
that names the call it read.

Plus the rest of the gates the package already declared.


Changes since v0.1.2.

v0.1.2

Choose a tag to compare

@github-actions github-actions released this 04 Sep 15:25

Reformat what the strictness pass reflowed

Two files-worth of blank lines and one long call the formatter wraps
differently now that a helper sits above them. CI resolves biome from a
caret range and installs a newer one than the lockfile pins.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01CGEy3LzUWzMGAruWS7JEDP

Turn on noUncheckedIndexedAccess

It was not missing here — it was explicitly false, in sixteen of the
seventeen tsconfigs. eon alone had it on, which is why nobody had seen
what it finds.

It stays a named deviation from upstream: @adonisjs/tsconfig sets
strictNullChecks and noImplicitAny but not this one. We keep it because
turning it on is what caught an as asserting a possibly-absent regex
group was a known value — the exact shape the flag exists to find. Doing
better than upstream is kept and written down, not reverted to parity.

Every site is restated rather than silenced: no !, no cast, no ?? 0
standing in for a branch that cannot happen. A reversed copy read by
value where an index walked a callback list backwards, the winner of a
scan kept as the value it found rather than its position, destructuring
where a length check was doing the proving, and an explicit break where a
loop condition already bounds the read.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01CGEy3LzUWzMGAruWS7JEDP

Say what container.make() returns for the tokens this package binds

ream declares ContainerBindings open on purpose: it registers its own
entries and expects each package to contribute the ones it owns — its
comment on the interface names auth (warden), logger (spectrum) and db
(atlas) as exactly this. None of them did, and every other package that
binds a string token was in the same state, so container.make('cache'),
make('mail'), make('hash') and the rest all answered unknown and
every call site had to assert a type it could not prove.

Loaded from the barrel AND from the provider, the second of which is where
AdonisJS puts its own (providers/redis_provider.ts carries the
declare module for redis, database_provider.ts for lucid.db).

Verified live rather than assumed: a declare module naming a specifier
that does not resolve is silently inert, so renaming the member has to
break the compile. It does.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01CGEy3LzUWzMGAruWS7JEDP

Check the claims instead of asserting them

assertIdTokenClaims ended on a cast that promised iss, sub, aud
and exp were the types its checks had established. Three of the four
were; iss was only ever compared, never type-checked, and aud was
accepted whenever the expected audience appeared anywhere in it — so a
list holding the right string beside arbitrary junk passed, and the claim
went on described as string[] while carrying something else. Both are
checked now, and the return value is built from the narrowed locals
rather than asserted over the bag they came from.

The three remaining as unknown as / as never casts turned out to
assert nothing the compiler did not already accept, and are gone.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01CGEy3LzUWzMGAruWS7JEDP

Release 0.1.2

Try every certificate the provider's metadata lists

A SAML signature was verified against the first listed certificate and no
other, so an assertion signed with the second was rejected with "the signature
does not verify".

That is precisely a key rotation. A provider preparing one publishes the
outgoing and the incoming certificate together and may sign with either, which
is why certificates is a list and why its own doc-comment says several are
accepted. Every sign-in would have failed for the length of the rotation
window, blaming the signature rather than the key lookup.

It only bites when the document carries no of its own —
KeyInfo is optional in XML-DSIG and plenty of providers omit it. With one
embedded, the certificate still has to match a listed one exactly and that one
alone is used, unchanged. Every existing test signed with KeyInfo present, so
the whole suite exercised the matching path and none of it reached this one.

JwksCache already handles the OIDC side of the same problem by refetching, so
the two halves of this package now agree.

Also compares the OAuth state with timingSafeEqual. The state is what stands
between an attacker's authorization code and the victim's session, and !==
stops at the first differing byte. Every other secret comparison in the
framework is already constant-time; this one was the outlier.


Changes since v0.1.1.

v0.1.1

Choose a tag to compare

@github-actions github-actions released this 01 Sep 15:40

Turn on noUnusedLocals/noUnusedParameters

Release 0.1.1

Put a deadline on every outbound call

Discovery, JWKS, the token exchange and the callback all went out through a
bare fetch with no timeout and no signal. A provider that accepts the
connection and then never answers held the request open for as long as the
socket lived, and an identity provider is the one dependency an application
cannot route around.

One helper, used by all nine call sites, with the ten seconds the LDAP client
already waited so the package has one answer rather than two. A caller's own
signal still cancels: the deadline is added to it, not substituted for it.


Changes since v0.1.0.

v0.1.0

Choose a tag to compare

@github-actions github-actions released this 31 Aug 08:57

Refuse to choose the in-process replay store in production

It bounds replay within ONE process, so behind a second replica the same
assertion is accepted again on each. Nothing here can see how many are
running, so the deployment now says which store it wants, at construction
rather than at the first sign-in. NODE_ENV is normalised so NODE_ENV=prod
does not read as 'not production'.

Declare the environment variables the generated config reads

Checked against @adonisjs/mail 10.4.0, whose configure() publishes the config
AND calls defineEnvVariables beside it. Mine wrote a config full of
env.get('QUEUE_STORE') and declared nothing, which is the half-installation
the hook exists to prevent: the application boots, the config asks the
environment for something nothing ever put there, and the fallback answers.

addEnvVars was already on ream's codemods and simply went unused.

Say that ream add sets this up, because it does now

Ship the configure hook ream add expects

ream add <pkg> installs, then imports <pkg>/configure and runs it. Nine
packages provided that hook and this one did not, so ream add left an
application with a provider registered and no config file for it to read —
falling back to a default that is rarely the one anybody wanted, silently.

The hook registers the provider and writes the config stub beside it, because
the two are one step: a provider without its file is not installed, it is half
installed.

Close what a review found: PKCE, the optional peer, and two seams

An external review of the package. Three of its five findings were real.

The verifier was sent on every token exchange, including to a provider that
had said it does not take S256 and therefore never received a challenge. At
best that is noise; a strict provider refuses the exchange over it. Whether
PKCE was used now travels in secret, and the verifier goes only when a
challenge went.

@c9up/quasar is reached at runtime and never imported, so it is an optional
peer — which bay, echo, ream and warden all declare and this did not. Runtime
was fine; npm and pnpm simply told the user nothing.

test:coverage failed: branches at 78.55% against a threshold of 80, and the
coverage provider was borrowed from the workspace root rather than declared —
which a standalone clone of this repository does not have. Both fixed, and the
gap was where the review said it was: the service accessor and the quasar
bridge, the two seams that break at integration while the protocol code is
covered. The accessor's answer to then is now pinned, since a proxy that
throws there takes down the import rather than the first call.

Two findings are not defects. The vitest and coverage versions resolve to
4.1.6 together here. And files carrying src is deliberate and repo-wide:
the build emits declaration maps and source maps that point into it, so
go-to-definition and stack traces work in a consumer.

Let the test double stand in for a directory too

FakeTransit covered the redirect providers and nothing else, so an
application signing in against LDAP had no way to test it — the directory
contract arrived after the double did.

authenticate now answers the same way, and refuses an empty password for the
same reason the double refuses a missing state: a bind with no password is an
anonymous bind the directory accepts, and a fake that let it through would
teach an application to submit one. willAccept declares the credentials a
test wants checked, for the tests that are about a wrong password rather than
about what happens after a right one.

Sign in against LDAP and Active Directory

A directory is a different shape from everything else here: nothing is
redirected. The application already holds the credentials, and the directory is
the authority that says whether they are right. So it gets its own contract —
DirectoryDriver, reached with authenticate — and asking for one through
begin(), or a redirect provider through authenticate(), says which of the
two it is rather than failing on a missing method.

Two things here are the difference between an LDAP sign-in that works and one
that lets anybody through.

An empty password is refused before a socket is opened. A simple bind with no
password is an ANONYMOUS bind, and the directory answers success — a login form
that passes a blank one straight through therefore signs in as whoever was
named. It is the oldest bug in LDAP authentication and it is one if.

And there is no filter string in this API. A filter is a structure, and BER
writes its values as length-prefixed octets, so a login of *)(uid=admin is a
login containing those characters. Injection is not escaped away; it is
unrepresentable.

Two connections per sign-in: one to find the person's DN, and one to bind AS
them — which is what verifies the password — closed immediately, because a
connection carries the identity of whatever last bound on it. ldaps:// unless
told otherwise, since a simple bind sends the password as it was typed.

The tests run over real sockets against a directory that decodes what this
encodes, so a mistake in the wire format shows up here rather than against a
live server.

Ship SAML 2.0

The fifth layer, and the one that makes the other four usable: the walk between
them, and the two bindings a browser sign-in uses — the request leaves deflated
in a query string, the response comes back base64 in a form post.

The mapping onto the driver contract needed no new shape, the same way OAuth1
needed none. The SAMLResponse is the code, the RelayState is the state, and the
request id begin() minted is the secret. One controller serves OAuth2, PKCE,
OAuth1 and now SAML.

The order is the security. The signature is verified first, and everything read
afterwards comes out of the element it covers. The one exception is the status,
read once from the unsigned response and deliberately: a refusal arrives
unsigned when the assertion is what carries the signature, and it exists to be
reported rather than trusted — flipping it the other way cannot produce an
assertion that verifies.

Certificates come from the provider's metadata and nowhere else, several of
them so a rotation is prepared rather than survived. A driver with none refuses
to exist, at boot rather than at the first sign-in.

Attributes keep every value: SAML attributes are multi-valued, and collapsing
them loses the group memberships an application usually authorises on.

That completes the chain — XML reader, exclusive canonicalization, signature
verification, assertion validation, binding — with no dependency at any layer.

Validate a SAML assertion, and refuse a second use of it

The fourth layer. A valid signature says the provider wrote this. It says
nothing about whether the statement was meant for THIS application, at THIS
moment, in answer to THIS request — and a response that is genuine but none of
those is what a replay or a misdirected sign-in looks like.

So each condition answers one of those questions: the issuer is the provider
the metadata names, the audience is this application, the Recipient is the URL
this application answers at, the InResponseTo is the request this sign-in made,
and both windows — the assertion's and the bearer confirmation's — are open,
with a minute of tolerance for clock drift against the provider.

Absences are refused as firmly as mismatches. An assertion that restricts no
audience does not say who it is for; one with no expiry never stops being
usable; one answering a request this application never made is one obtained
somewhere else and posted here.

Everything works on the element the signature covers, and nothing searches the
document again — that is the guarantee the layer below hands over.

An assertion is also a bearer token: captured once and posted twice it is valid
both times, and no condition on it changes that. AssertionReplayStore
remembers the id until it expires. Both backends ship, because a memory-only
one accepts the same assertion once per replica — the Redis store decides with
SET … NX, in one atomic round trip, so two concurrent posts cannot both win.

The binding and the driver are what remain; SAML is still not usable.

Verify XML signatures, and hand back what they cover

The third layer. The danger in XML-DSig is not the cryptography: a signature
can be perfectly valid over one part of a document while the application reads
another. That is XML Signature Wrapping, and it has broken SAML
implementations repeatedly.

The mitigation is structural rather than a check. verifyXmlSignature returns
the element the signature actually covers, and the caller reads that object —
a reader that never searches the document again cannot be looking at a part the
signature does not cover.

Everything else exists so that guarantee holds. Exactly one signature and one
reference, because several multiply what "the signed element" could mean. The
reference must resolve to exactly ONE element: a second element carrying the
same id is the classic wrapping payload, and the signature stays valid while a
reader that looks the id up finds the attacker's copy. The signature must sit
inside what it references. Only the enveloped-signature and exclusive-c14n
transforms are accepted — XPath can make the digest cover something other than
the element, and XSLT is executable.

SHA-1 is refused, for signatures and for digests. Collisions against it are
practical, and a signature over attacker-influenced XML is where that matters.

The key comes from the caller, out of the provider's metadata. A certificate
embedded in the document is checked against that list and never trusted on its
own: verifying a document against a key it carries is verifying it against
itself. Several certificates are accepted so a rotation can be absorbed
without an outage.

The tests sign real documents with a throwaway certificate and then attack
them — a duplicated id, a ...

Read more