fix: withOAuthValidation reads request from getContext() (closes #33)#48
fix: withOAuthValidation reads request from getContext() (closes #33)#48
Conversation
Old code located the request with `args.find((arg) => arg?.session)`, a v4 / legacy pattern. Resource API v2 method signatures are `get(target)` / `receive(target, data)` / etc. — the request is not in the args, it lives on the resource context via `this.getContext()`. The wrapper silently passed through v2 calls without validating. Switch the lookup to `this.getContext()`. No legacy-args fallback — post-v5 migration, v2 is the only shape supported. Tests (`test/lib/withOAuthValidation.test.js`, new) cover: - valid-session passthrough - requireAuth: true + no session → 401 - requireAuth: false + no session → passthrough - custom onValidationError - stale-provider clearing (requireAuth: false) - stale-provider rejection (requireAuth: true) — rejection path that the fix was written to enable - expired token without refresh token (requireAuth: true) — same - non-HTTP methods pass through untouched - no-getContext fallthrough The last two requireAuth:true failure-path tests were added in response to a Claude review finding on the previous combined attempt (#43): the original test file only covered the happy path, leaving the "this is now broken → 401" branch unverified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Security bypass:
|
Addresses the two findings Claude posted on this PR. Pre-existing bug inherited from the old args-based lookup: when the wrapper couldn't locate a request, it passed through to the protected method — regardless of `requireAuth`. Any v2 resource with `requireAuth: true` became silently accessible whenever `getContext()` was missing or returned a context without a session. Now: `requireAuth: true` + no context → 401. `requireAuth: false` + no context → passthrough (unchanged). `onValidationError` is called when provided, matching the existing no-session-data pattern. Test added: the fail-closed branch now has explicit coverage so a regression on this code path surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1.
|
…SDoc Two more findings from the Claude review on this PR. 1. Don't call onValidationError when no request is available. Previous commit called `onValidationError(undefined as any, error)` from the no-context fail-closed path. The callback's published signature is `(request: Request, error: string) => any`; any handler reading `request.session` / `.ip` / `.headers` would TypeError at the exact moment auth is failing, converting a clean 401 into an unhandled exception. Now: the no-context branch always returns a default 401 when `requireAuth` is true. The JSDoc documents that `onValidationError` is not invoked for this specific case, since the callback's request parameter would be undefined. Removing the call also eliminates the previously-untested branch the reviewer flagged; no test is needed. 2. Update the @example block to Resource API v2. The example still showed `async get(target, request)` — the v4 pattern this PR removes. Anyone copying it would hit `request === undefined` on first call. Updated to a v2 class with `static loadAsInstance = false`, `async get(target)`, and `const request = this.getContext()`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Docstring example passes class constructor — Proxy breaks or silently bypasses OAuthFile: 2. No test that
|
…skip Two more findings from the Claude review on this PR. 1. JSDoc example passed the class constructor, not an instance. The wrapper Proxy's `get` trap reads `target[prop]`, which on a class constructor is undefined for instance methods (those live on `.prototype`). A user copying the example verbatim would get a TypeError or — if Harper instantiated the proxied constructor normally — a plain unwrapped instance with OAuth validation silently skipped. Tests all use plain objects, so this was undetected. Updated the example to wrap `new MyResource()` and added an inline note explaining the instance-vs-constructor distinction so the caveat isn't lost on the next editor. 2. The documented "onValidationError is not called when there's no request" behavior had no test. Added one: supply an onValidationError spy, hit the no-context fail-closed path, assert the spy never runs and the result is the default 401. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Missing
|
BREAKING for anyone passing `withOAuthValidation(instance, opts)` — the signature is now `withOAuthValidation(Class, opts)` and returns a subclass of Class, not a Proxy around an instance. Why this changed ---------------- The Proxy-based wrapper silently didn't work with Harper v5's resource registration: `resources.set(path, X)` expects `X` to be a Resource class with `static getResource`, which Harper calls to instantiate the resource per request. A Proxy around an instance has no usable static surface; a Proxy around a class doesn't intercept methods that live on `.prototype`. Either way, `withOAuthValidation(...)` silently bypassed all OAuth validation when registered via the natural Harper API. Claude's review on this PR caught the failure-open behavior and the misleading JSDoc examples that led down this path. New design ---------- `withOAuthValidation(Cls, opts)` returns a subclass of `Cls` that overrides the five HTTP verbs (get/post/put/patch/delete). Each override runs OAuth validation first, then delegates to the parent's method (or returns undefined for methods the parent doesn't define). Harper's `static getResource` is inherited, so per-request instantiation + context injection Just Works. Validation helper is extracted to its own function so the semantics are the same across all five verbs. `onValidationError` is still invoked when a concrete request was resolved but semantic validation failed. It is deliberately NOT called on the no-context path (the callback's signature requires a Request and callers read `request.session` / `.ip` / `.headers`). Tests ----- Full rewrite of `test/lib/withOAuthValidation.test.js` to exercise the class-based API with a `MockResource` base: - Wrapped class is an `instanceof` the base (and grand-base). - Static props (e.g. `loadAsInstance`) inherited. - Valid session → underlying method runs. - No OAuth data + requireAuth:true → 401; requireAuth:false → passthrough. - `onValidationError` override path. - Unknown provider + requireAuth:false → clears stale session, passthrough. - Unknown provider + requireAuth:true → 401. - Expired token + no refresh token + requireAuth:true → 401. - Non-HTTP methods pass through untouched. - No session on context + requireAuth:true → 401 (fail-closed). - No session on context + requireAuth:true + `onValidationError` → callback NOT invoked, default 401 returned (contract preserved). - Base class without a given HTTP method → wrapper returns undefined. 438 / 0 / 2 on `bun test`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1.
|
Two more findings from Claude's review on this PR. 1. onValidationError received a pre-cleared session in two branches. The "unknown provider" and "no provider name" branches both deleted `request.session.oauth` / `.oauthUser` BEFORE invoking the caller's `onValidationError` handler. An integrator logging "which provider caused this failure" in a custom handler would read `undefined` for both fields. The existing onValidationError test only exercised the "no OAuth data" branch — which has nothing to clear — so this mutation-before-notification ordering was never observed. Fix: in both clearing branches, call `onValidationError` (awaited) first, THEN clear the stale session fields. The handler sees the full state at time of failure; the session still ends up cleared for the next request. 2. put, patch, delete had zero test coverage. The verb string passed to `delegate(this, '<verb>', args)` is the only link between the override and the prototype lookup. A typo would silently bypass OAuth validation on that verb. Only `get` and (via one test) `post` were exercised. Added a loop-driven suite covering all five verbs both ways — 401 on requireAuth:true with no session, and delegation to the parent method on a valid session. Also added two callback- ordering tests for the two deferred-delete branches above. Bun tests: 450 pass, 0 fail, 2 skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Two blockers found. 1. withOAuthValidation is never re-exported from the package entry pointFile: export { withOAuthValidation, getOAuthProviders } from './lib/withOAuthValidation.ts';
export type { OAuthValidationOptions } from './lib/withOAuthValidation.ts';2. Test demonstrates spread on session.oauth -- pattern silently fails in productionFile: |
…capture in tests
Two findings from Claude's round-5 review on this PR.
1. `withOAuthValidation` was never re-exported from `src/index.ts`.
The JSDoc example says `import { withOAuthValidation } from
'@harperfast/oauth'`, but the package entry only exported
HookManager / OAuthResource / TenantManager / provider utilities —
the documented symbol was missing. Tests import directly from
`dist/lib/withOAuthValidation.js`, which is why CI stayed green
while the shipped public API was broken. Integrators following
the JSDoc would get a module-resolution error.
Added re-exports for `withOAuthValidation`, `getOAuthProviders`,
and the `OAuthValidationOptions` type.
2. Callback tests captured `request.session.oauth` via spread.
The "callback sees un-mutated request" tests used
`{ ...request.session.oauth }` / `{ ...request.session.oauthUser }`
to snapshot the fields. That's fine for the plain-object mocks
used in tests, but in production these are Harper
`GenericTrackedObject`s where spread copies nothing (per
CLAUDE.md Non-Obvious Gotchas). If an integrator copied the
callback pattern from this test for their own audit logger, they
would silently get empty objects in production.
Replaced both captures with explicit property access. Inline
comment explains why so future edits don't re-introduce spread.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1.
|
Two gaps from Claude's round-6 review.
1. `getOAuthProviders` was newly exported as public API with zero
tests. It has silent error-swallowing (returns null on failure),
so the scope-traversal logic (`parent.resources.get('oauth')` →
`resources.get('oauth')`) was completely unverified. Added four
cases: parent-scope hit, same-scope fallback, miss-returns-null,
and lookup-throws-returns-null.
2. The `requireAuth: false` + expired-token + no-refresh-token path
was newly reachable (v4 couldn't reach validateAndRefreshSession
because `args.find` never found the request). The session is
cleared by validateAndRefreshSession before the wrapper falls
through to the underlying method. Added a test that asserts
(a) the method still runs and (b) `session.oauth` and
`session.oauthUser` are cleared by the time the method observes
the session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1.
|
Critical finding from Claude's round-7 review.
`validateOAuthForRequest` uses `return undefined` as the sentinel
meaning "no problem — delegate should continue." But `onValidationError`
can legitimately return `undefined` (e.g., a plain logging callback:
`async (req, err) => { log(err) }`). When that happens, the wrapper
interprets the return as "continue," falls through to the protected
method, and silently bypasses authentication — even when `requireAuth`
is true.
All four error paths were affected:
- `!hasOAuth`
- `!providerName`
- `!providerData`
- `!validation.valid`
Fix: route every `onValidationError` call through a new
`callCallbackOrDeny` helper that applies `?? defaultDeny` to the
handler's return value. If the handler returns `undefined`, the
wrapper falls back to the default 401 instead of propagating the
undefined as a pass-through. No observable change for handlers that
return a response; handlers that return nothing now fail closed
(matching the documented contract).
Tests added for the previously-uncovered `!validation.valid` path
(expired-token + no refresh token + `requireAuth: true`):
- Handler returns a response → wrapper returns it verbatim.
- Handler returns `undefined` → wrapper returns the default 401 and
the protected method is never invoked. This is the exact bypass
pattern the fix prevents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Expired-token test mocks don't exercise the production
|
…paths Round-8 findings from Claude on this PR, both legitimate. 1. My expired-token tests only exercised `clearOAuthSession`'s in-memory fallback. `makeSession()` has no `delete()` method, so every call took the `delete session.oauth; delete session.oauthUser` branch. In production Harper sessions expose `session.delete(session.id)`, which destroys the entire DB session record — a materially different (and more destructive) outcome. Added `makeProductionLikeSession()` that provides a `delete()` spy and records the calls. Restructured the expired-token `requireAuth: false` suite into two explicit cases — fallback path (no delete method) and production path (delete called, in-memory oauth fields untouched). 2. The wrapper's stale-provider path and expired-token path clear sessions DIFFERENTLY: stale-provider uses the local `clearStaleOAuth()` helper (in-memory only, session survives), while expired-token inherits `validateAndRefreshSession`'s side effect (`clearOAuthSession` → `session.delete(id)` in production, terminal DB destruction). The divergence is defensible — "provider not configured" is a recoverable config issue, "expired token with no refresh" is terminal auth — but it was entirely undocumented, so a v2 integrator using `requireAuth: false` on a hybrid resource would silently and permanently log users out on any token expiry. Added a "Session-cleanup semantics" block to the withOAuthValidation JSDoc spelling out what happens on each path. Test comment on the expired-token case updated to reflect both branches accurately. No behavior change — just documentation + test coverage. A future PR could consider unifying the two paths (e.g. `clearStaleOAuth()` for expired-token too), but that's a semantic decision worth its own PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e visibility)
Two more findings from Claude's review on this PR — this round as
inline review comments rather than top-level issue comments.
1. `onValidationError` is silently never invoked when
`requireAuth: false`.
All four `callCallbackOrDeny` call sites are inside
`if (requireAuth)` branches. The JSDoc on `onValidationError`
said only "Custom error handler for validation failures" and
didn't mention the coupling. An integrator wiring up
`onValidationError` for audit logging on a mixed-auth resource
(`requireAuth: false`) would get silent no-ops on every stale
session with no diagnostic.
Fix: expand the JSDoc to spell out the `requireAuth: true`
requirement and redirect "audit logging on mixed-auth" users to
`logger` (always invoked) instead.
Tests: two new cases — stale-provider + `requireAuth: false` and
expired-token + `requireAuth: false`, both asserting the
callback is NOT fired and the underlying method runs.
2. Session visibility at callback time varies by failure path.
- `!hasOAuth`: no oauth to see.
- stale-provider paths: callback invoked BEFORE
`clearStaleOAuth()` — full oauth/oauthUser readable.
- expired-token path: `validateAndRefreshSession` has ALREADY
called `clearOAuthSession` internally before the wrapper
invokes the callback. On a production Harper session
(`session.delete()` present) the in-memory fields are NOT
mutated — callback sees full data. On a test-shape session
(no `delete()`) the fallback clears in-memory fields first —
callback sees `undefined`.
My existing "handler returns a response" expired-token test
uses the no-delete fallback and only asserts `!!request`, which
masks this divergence. Adding a production-shape variant that
asserts the callback DOES see full oauth data in the real
production path pins the contract.
JSDoc updated with a per-path session-state matrix so integrators
know what's readable when.
No observable behavior change. JSDoc and test coverage only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
No blockers found. |
…ted verbs
A local reviewer running a full pass on this PR caught two real
issues that six rounds of CI review had missed.
1. Static-method dispatch bypass (blocker).
Harper v5's REST dispatcher calls `Class.get(target, request)` at
the STATIC level. When a user follows the v5-recommended pattern
(`static async get(target)` — explicitly documented in
release-notes/v5-lincoln/v5-migration.md as "We recommend using the
static methods on Resources/Tables to implement endpoints"),
static inheritance means `Wrapped.get` resolves to the user's
static. Our instance-method overrides are bypassed and OAuth
validation never runs — the endpoint silently serves
unauthenticated traffic with zero diagnostic signal.
This is the same failure shape as the original Proxy-vs-class
issue the earlier commits addressed, just on a different dispatch
surface. It's the hazard this PR's whole refactor exists to prevent.
Fix: install static-method overrides on Wrapped for each verb the
parent class has (including those inherited from Resource base via
`transactional(...)`). The override runs validation first, then
delegates to the inherited static via `.call(Wrapped, ...)` so
`this` stays Wrapped (preserving `new this(...)` inside
transactional).
2. 405 Method Not Allowed regression.
Previously the wrapper defined all five HTTP verb overrides on
`Wrapped.prototype` unconditionally. When the parent implemented
only `get`, sending POST went:
Harper → instance.post (our override, defined) → delegate
→ parentProto.post (undefined) → returns undefined
→ Harper serializes undefined as 204 No Content
instead of the correct 405 Allow-header response. Harper's 405
path triggers when the instance-level property is falsy at
dispatch time — our always-defined overrides masked it.
Fix: only install instance overrides for verbs the parent actually
implements. Unimplemented verbs remain undefined on Wrapped, so
Harper sees the same "no method" signal it did pre-wrapper and
returns the correct 405.
Dedupe: with overrides installed at BOTH static and instance layers,
naive dispatch would run validation twice per request (static entry,
then Resource base's transactional creates an instance and calls
instance get — which is also our override). A WeakSet keyed by the
request/context marks contexts after the first validation pass, so
subsequent calls in the same chain short-circuit. Non-mutating,
garbage-collects with the request, safe even if the context is
frozen.
Tests:
- `Wrapped.prototype.<verb>` left undefined when parent doesn't
implement (405 preservation)
- `Wrapped.<verb>` static left undefined when parent has no such static
- Static-only user class (v5-recommended pattern) is wrapped:
valid session → user static runs, invalid session → 401 at static
entry before user code
- Validation runs exactly once across the static→instance chain
JSDoc expanded to document the dual-layer install and the WeakSet
dedupe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. WeakSet dedup relies on context-object identity across static→instance dispatch — unverified against real Harper ResourceFile: 2. 405-semantics claim is unverified against real Harper's
|
…patch normalization Round 9 review findings: 1. 405 regression. Harper's Resource base defines `static <verb> = transactional(...)` for every HTTP verb, so every user subclass inherits a truthy static for every verb. The previous `typeof ResourceClass[method] === 'function'` check matched the inherited transactional and installed an auth override for every verb — turning an expected 405 (from REST.ts's missingMethod branch) into a 401. Install only when the user owns the static (hasOwnProperty) OR owns an instance method on the prototype chain; otherwise shadow with `undefined` so REST dispatch's `resource.<verb> ? ... : missingMethod` check takes the 405 branch. 2. WeakSet identity across dispatch normalization. Harper's `transactional` and `getResource` normalize via `request.getContext?.() || request` before constructing the per-request instance, so `this.getContext()` in the instance path may be a different reference than the `request` arg the static received. Stamp both on mark; check both on lookup. 3. Stale-provider fail-closed tests. Added parallel tests for `!providerName` and `!providerData` branches with callbacks that return undefined — mirrors the existing expired-token "handler returns undefined" test. A future refactor that inlines callback invocation on one branch without `?? defaultDeny` would silently bypass auth on that branch; direct coverage catches it. Also adds a 405-preservation test using StaticCapableResource (all 5 statics inherited, closer to real Harper base) where the user only defines instance get — asserts sibling verbs are `undefined` on the Wrapped class. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on-resource-api-v2
Review: fix/withOAuthValidation-resource-api-v2The subclass approach is architecturally correct — covers both the instance-method and static-method dispatch surfaces, deduplication via WeakSet is sound, 405 preservation is correct, and the `callCallbackOrDeny ?? defaultDeny` guard closes the silent-bypass hole from a void-returning `onValidationError`. Overall implementation is solid. One blocker on test coverage. 1. Static POST/PUT/PATCH dispatch — no test for the 3-arg
|
Harper's REST dispatcher uses 2-arg (target, request) for GET/DELETE
and 3-arg (target, data, request) for POST/PUT/PATCH. The wrapper
extracts the request from args[args.length - 1] so both shapes work,
but every existing static-dispatch test hit the 2-arg GET shape only.
A future refactor that switched extraction to args[1] ("always the
second arg") would silently mis-route 3-arg verbs without any test
failing.
New test calls Wrapped.post with the real (target, data, request)
shape, asserts auth enforcement against the request arg and body
survival in the data arg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review — PR #48:
|
Two changes rolled together — both needed to get reliable reviews: 1. Layered review-scopes integration. Clones HarperFast/ai-review-log at the merge of #19 (ef8d994), composes universal + harper/common + harper/v5 + repo-type/plugin layers into a single prompt block, and replaces the previous monolithic inline prompt's "what to ignore", "review scope and style", and "output format" sections with the composed layers. OAuth-specific bullets (CSRF state, provider-of- record, redirect/path validation, session-field preservation across refresh) stack on top. Review guidance for other HarperFast repos (core, pro, manager, apps) now lives centrally; bumping REVIEW_LAYERS is the mechanism to opt each repo in. 2. Timeout 10→15. A recent run on PR #48 cancelled at 10m18s with a partial tool_result logged at 30s and then 9+ minutes of silence. The stall was inside a single Claude API call, not a max-turns loop. 15 gives headroom without removing the backstop; max-turns=24 remains the real cost ceiling. Clone uses AI_REVIEW_LOG_TOKEN (already configured in repo secrets). Compose step fails the job if every layer is missing (hard error), but tolerates individual missing layers with a warning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on-resource-api-v2
Review: fix/withOAuthValidation-resource-api-v2No blockers found. Surfaces verifiedStatic-method dispatch bypass (the core fix)
405 preservation
Deduplication (WeakSet)
Fail-closed, no context
New public symbols
Production-path session (
|
Summary
Second of the split from closed PR #43. The Harper v5 migration landed in #46; this PR is the withOAuthValidation fix for Resource API v2.
Closes #33
Old code:
const request = args.find((arg) => arg?.session !== undefined);— a v4 / legacy pattern. Resource API v2 method signatures areget(target)/receive(target, data)— the request lives on the resource context viathis.getContext(), not in args. The wrapper silently passed through v2 calls without validating.New code: read the request from
this.getContext(). No legacy-args fallback — post-v5 migration, v2 is the only shape supported.Tests
test/lib/withOAuthValidation.test.jsis new (9 tests):requireAuth: true+ no session → 401requireAuth: false+ no session → passthroughonValidationErrorrequireAuth: false)requireAuth: true) — rejection path the fix enablesrequireAuth: true) — samegetContextfallthroughThe two bolded failure-path tests were added after Claude flagged this exact gap on the previous combined attempt: my original test file only covered the happy path, leaving the "this is now broken → 401" branch unverified. That was a legit finding (
ai-review-log#1).Test plan
bun test— 433 pass, 2 skip, 0 failnpm run lintpassesnpm run format:checkpasses (.claude/settings.local.json warn is gitignored)Closes #33