feat!: replace the shared token with a session pool - #26
Conversation
BREAKING CHANGE: calls are no longer serialized on one shared session, so per-session server state (found sets, script globals) does not persist across separate calls anymore; use withSession() to pin related calls to one session. The client holds server sessions, so call destroy() on shutdown.
lavere
left a comment
There was a problem hiding this comment.
Automated review. Generated automatically when I was requested as a reviewer. It has not been read by a human.
The pool refactor is well built: the token/session lifecycle moves cleanly into Session/SessionPool, the tarn wiring (validate on invalid + TTL, non-rejecting destroyer, unref'd reaper, propagateCreateError override) is deliberate and commented, and the 84 tests, lint, typecheck and rollup build all pass on the head commit. I found no correctness bug in the new pooling logic. Two smaller items: the deprecated clearToken() can resurrect a destroyed client, and the container-URL host check (pre-existing, moved in this diff) is a prefix match that can leak the bearer token to a lookalike host.
src/Client.ts:85(medium) The container host check is a raw prefix match, sohttps://fms.example.com.attacker.net/...passes whenuriishttps://fms.example.com, andSession.requestContainerthen sendsAuthorization: Bearer <session token>to that host. This is pre-existing behaviour moved into the new structure, but the diff touches the line (new comment above it) and the token is now a pooled session that stays valid for other calls, so leaking one is worth more than before. Comparing parsednew URL(containerUrl).originagainstnew URL(this.uri).origincloses it without changing any legitimate call.src/Client.ts:110(low)clearToken()unconditionally assigns a brand newSessionPooltothis.pool, which silently undoesdestroy(). Afterawait client.destroy(); await client.clearToken();the client is usable again and will sign in fresh server sessions that the shutdown path already believes it released —SessionPool.acquire()'sdestroyedguard is bypassed because it is a different pool object. Same hole if aclearToken()lands after a concurrentdestroy()resolves. A client-level destroyed flag (or an early return inclearTokenwhenthis.poolis already destroyed) keeps the documented "cannot be used afterwards" contract intact.
sdbennett
left a comment
There was a problem hiding this comment.
cna-coder review: Comment · round 1 · head 7bd391c
Contract NOT cross-checked against a sibling repo (this is a standalone published library, not an api/frontend pair).
TLDR
Replaces the single shared Data API token with a tarn-backed session pool, so concurrent calls stop interleaving on one FileMaker session. The pooling logic itself is sound — I found no correctness bug in acquire/validate/destroy, and the tarn wiring is deliberate and explained at each non-obvious point. What's left is a cluster of docblocks that describe behaviour the code does not have, plus two gaps where new logic ships without an assertion.
- Must fix before approval (0 blocking): none, nothing blocks approval
- OK to leave for now (9 non-blocking):
clearToken()can resurrect a destroyed client (src/Client.ts:110);markReleaseddocblock states the unsafe order (src/Session.ts:115); container stream outlives its session (src/Client.ts:89); theunrefreach and the new TTL check are both unasserted (src/SessionPool.ts:96,:134); four doc/style nits - Pre-existing, not introduced by this PR (2): the container host prefix match (
src/Client.ts:85) and two assertion-free TTL tests (test/Client.test.ts:65) — file them or leave them
Findings (introduced by this PR)
| Severity | Location | Issue | Suggested fix |
|---|---|---|---|
| 🟡 Medium | src/Client.ts:110 |
clearToken() unconditionally installs a fresh live pool, so await destroy(); await clearToken(); makes a destroyed client usable again and signs in new FileMaker sessions after shutdown. SessionPool.acquire()'s destroyed guard is bypassed because it is a different pool object, breaking the documented "cannot be used afterwards" contract. |
Track a destroyed flag on Client and have clearToken() no-op or throw once destroy() has run. |
| 🟡 Medium | src/Session.ts:115 |
The markReleased docblock says it is called "once the session has gone back to the pool", but Client.withSession calls it before pool.release. The docblock states the unsafe order, so a later refactor that makes the code match it would introduce a real use-after-release. (#103) |
Restate as called while returning the session, before the pooled resource is released. |
| 🟡 Medium | src/Client.ts:89 |
requestContainer resolves with an unread ReadableStream after withSession has already released the session, so destroy() signs that session out mid-download rather than waiting for the call to finish as README:149 promises. |
Hold the session until the stream ends, or document that the buffer must be consumed before destroy(). |
| 🟡 Medium | src/SessionPool.ts:96 |
The unref of tarn's undeclared interval field is unasserted and guarded by optional chaining, so a tarn rename silently reintroduces a timer that keeps the process alive after the last call — exactly the failure the comment says it is defending against. |
Add a test asserting the reap interval is unref'd after the first release, so a tarn upgrade fails CI instead of regressing quietly. |
| 🟡 Medium | src/SessionPool.ts:134 |
The new isUsable TTL check, including the Math.abs clock-step handling, ships with no assertion covering it. The only two tests named for the 14-minute rule are pre-existing and contain no expect at all. |
Assert the sign-in count in both TTL tests: one POST to /sessions for the reuse case, two for the expiry case. |
| 🟢 Low | src/Session.ts:103 |
requestContainer skips the lastUsedAt refresh when the download fails, while request deliberately refreshes it on error. This contradicts the PooledSession docblock's "refreshed on every completed response", and can retire a live session early. |
Move this.pooled.lastUsedAt = Date.now() above the !response.ok throw. |
| 🟢 Low | src/SessionPool.ts:21 |
The min docblock says a non-zero value "keeps the reaping timer alive for the lifetime of the process", which this same constructor's unref at line 94 contradicts. The advice to leave min at 0 is still right for the other reason given. |
Say the timer keeps running, and leave the process-lifetime claim to the one place it is accurate. |
| 🟢 Low | README.md:64 |
Three British spellings introduced into a repo that currently has none: serialises here, favour at README.md:41, recognises at src/Session.ts:55. |
Use serializes, favor, recognizes. |
| 🟢 Low | src/Layout.ts:9 |
LayoutClient is declared as an interface, the only one in src/, where the house convention reserves interface for declaration merging and module augmentation. |
Declare it as a type alias. |
Pre-existing (not introduced by this PR, does not gate merge)
| Severity | Location | Issue | Note |
|---|---|---|---|
| 🟡 Medium | src/Client.ts:85 |
The container host check is a raw startsWith prefix match, so https://fms.example.com.attacker.net/... passes when uri is https://fms.example.com, and the bearer token is then sent to that host. |
Byte-identical to main; this PR only adds a comment above the line. Worth its own ticket — comparing new URL(containerUrl).origin against new URL(this.uri).origin closes it without affecting any legitimate call. |
| 🟢 Low | test/Client.test.ts:65 |
The two 14-minute TTL tests contain no expect at all and pass whether or not the rule is honored. |
Byte-identical to main and untouched here. Fixing them is what would give the isUsable finding above its assertion. |
Note on the existing automated review
The other automated review on this head raises the same Client.ts:85 host check and argues it matters more now because "the token is now a pooled session that stays valid for other calls". That reasoning is inverted: on main, getToken() caches one process-wide token and reuses it for 14 minutes across every call, so a leak there exposes the shared token for the entire client. Here it exposes one of up to five pooled sessions. The vulnerability is real and worth a ticket, but this PR narrows its blast radius rather than widening it, and is not the right place to gate it.
What's good
injectHeaders now copies rather than mutates the caller's RequestInit, fixing a latent bug on main where the caller's object was modified in place. The README's constructor example is corrected to the real argument order. The tarn wiring is deliberate and commented at each point where it deviates from the defaults — non-rejecting destroyer, propagateCreateError override, unref'd reaper, validate-on-invalid-plus-TTL — which is what made the pooling logic reviewable. Around 30 new tests cover the behaviour that matters: concurrency up to max, single sign-in for concurrent cold starts, retirement on 952, release-on-throw, and rejection after destroy().
Reviewed by /cna-coder:review, round 1. 🔴 High items in the first table gate merge; 🟡 Medium and 🟢 Low are FYI; the pre-existing table is context, not a request. Re-runs update this review when the PR gets new commits.
|
FYI, semantic release is not able to parse |
BREAKING CHANGE: calls are no longer serialized on one shared session, so per-session server state (found sets, script globals) does not persist across separate calls anymore; use withSession() to pin related calls to one session. The client holds server sessions, so call destroy() on shutdown.
This is my alternative to #25