Skip to content

feat(contrib): data retention guidance and enforcement for cached session state - #369

Merged
davedumto merged 1 commit into
Vellar-Wallet:devfrom
Adeolu01:contrib/session-retention-guidance
Aug 31, 2026
Merged

feat(contrib): data retention guidance and enforcement for cached session state#369
davedumto merged 1 commit into
Vellar-Wallet:devfrom
Adeolu01:contrib/session-retention-guidance

Conversation

@Adeolu01

@Adeolu01 Adeolu01 commented Aug 31, 2026

Copy link
Copy Markdown

closes #292
closes #297
closes #287
closes #299

Summary

Documents how long cached session state should persist in consumer local storage, and enforces that window on read.

This PR is scoped entirely to contrib/ per CONTRIBUTING.md rule 3, so it is not caught by the contrib-only bot. Four files, all under contrib/:

File What it is
contrib/session-retention.md The retention guidance: recommended window, reasoning, per-deployment table, enforcement semantics, edge cases, and the core-integration recipe
contrib/session-retention.ts Reference implementation — the configurable max age, enforced on read
contrib/session-retention.test.ts 22 tests
contrib/README.md Section 5, following the existing numbered pattern

Requirements

Document a recommended retention window. 30 days of inactivity, as DEFAULT_SESSION_MAX_AGE_MS.

The reasoning matters more than the number, so the guidance leads with what this data actually is. Cached session state holds no key material and cannot authorize anything on its own — every signature still requires a live WebAuthn ceremony against the passkey. It is the public smart-account address, the public passkey credential id, and two timestamps. What it does carry is a durable link between a browser profile and an on-chain account.

So the risk is privacy and lingering account linkage, not key compromise. That is precisely why the recommendation is a bounded window rather than an aggressive minutes-long expiry: a shorter default would push users through avoidable passkey prompts and train them to click through auth ceremonies, which is itself a security cost. 30 days keeps "open the app, still signed in" true for ordinary use while bounding how long an abandoned profile keeps pointing at an account. The doc includes a table of suggested values per deployment (personal device, shared device, kiosk, custodial dashboard, ephemeral storage).

A configurable max age enforced by the SDK on read. withSessionRetention(adapter, { maxAgeMs }) wraps any SessionStorageAdapter. On load() it measures the entry's age from lastActiveAt and, past the window, returns null and clears the entry from the underlying storage.

A test verifying expired cached state past the max age is discarded. Plus eviction, boundary, default, and end-to-end restore() cases — see below.

Document the retention behavior in the README. contrib/README.md gets the summary section. Since the root README.md is outside contributor scope, the guidance doc carries the exact proposed root-README section, ready to paste, along with the Helpers table row — so a maintainer can land it verbatim without rewriting anything.

Design notes

Why wrap the adapter rather than the store. The window then applies to every read path — restore() today, plus anything that later loads through the same adapter — and composes with any adapter (web storage, extension storage, custom) without the core store needing to know retention exists. It is also what makes a faithful implementation possible from contrib/ without touching src/.

Expired state is evicted, not merely ignored. Returning null while leaving the record in localStorage would defeat the purpose — the very entry the window exists to bound would sit there forever. The wrapper clears it.

The window is an idle timeout, not a hard cap on session lifetime. Age comes from lastActiveAt, which the store's touch() already refreshes on user activity, so an actively used session keeps renewing while an untouched one ages out. A hard cap would force re-authentication on active users for no benefit, given the cached data is not a credential.

Edge cases resolved deliberately, each with a test:

Case Behavior Rationale
Unparseable lastActiveAt Expired, evicted An age that cannot be bounded is what the window exists to prevent
Future lastActiveAt (clock skew) Never expired, age clamped at zero Skew or a doctored entry shouldn't extend expiry or cause negative-age nonsense
Underlying clear() throws Read still resolves null Read-only or full storage must not resurrect an expired session
Underlying load() throws Propagates restore() already maps this to disconnected; the wrapper must not swallow it
maxAgeMs: Infinity Expiry disabled For an adapter that is itself ephemeral
maxAgeMs zero, negative, NaN RangeError at wiring A misconfigured window must fail loudly, not silently disable expiry
Stored value is null Passes through, no clear() Nothing to evict

Verification

npx vitest run contrib/session-retention.test.ts    # 22 passed

Full-suite comparison against dev, to be explicit that this PR does not regress anything:

Test files Tests
dev (baseline) 8 failed, 159 passed 18 failed, 1515 passed
this branch 8 failed, 160 passed 18 failed, 1537 passed

The same 18 pre-existing failures, all in contrib/examples/ and contrib/rpc-server.test.ts, identical before and after and untouched here — plus 22 new passing tests.

One thing a maintainer should know

src/session.test.ts does not parse on dev. The dispose() clears the timer so the store is garbage-collectable case is missing its closing braces and a new describe( opens immediately after it:

    expect(vi.getTimerCount()).toBe(0);
describe("createSessionStore — refresh & expiry edge cases", () => {

tsc reports session.test.ts(428,1): error TS1005: '}' expected. and vitest fails the file with Transform failed. That is why this PR's tests live in their own contrib/ file rather than extending the existing suite, and it will need fixing before these tests can be ported into src/. Flagged in the integration recipe.

Related: that file's shared session fixture pins lastActiveAt to a hardcoded 2026-07-16. Once any retention window exists, a fixed past timestamp silently ages out as the calendar moves and breaks the restore() tests — worth making relative to now during integration. Also noted in the recipe.

…sion state

Cached session state persisted through a SessionStorageAdapter had no
documented bound on how long it may live in consumer local storage, and
no mechanism to enforce one.

Add guidance plus a reference implementation, both inside contrib/:

- contrib/session-retention.md documents the recommended window and the
  reasoning behind it. Cached session state is not a credential (no key
  material; every signature still needs a live WebAuthn ceremony), but it
  is a durable link between a browser profile and an on-chain account, so
  the risk is privacy and lingering account linkage rather than key
  compromise. That is why the recommendation is a bounded window rather
  than an aggressive minutes-long expiry. Includes a per-deployment table
  for choosing a stricter value, the enforcement semantics, an edge-case
  table, a step-by-step recipe for landing it in src/, and the proposed
  root README section.

- contrib/session-retention.ts implements the window. 30 days of
  inactivity is the documented default (DEFAULT_SESSION_MAX_AGE_MS).
  withSessionRetention() wraps any SessionStorageAdapter and enforces the
  max age on read: state past the window yields null and is cleared from
  the underlying storage, so it is evicted rather than merely ignored.
  Wrapping the adapter instead of the store applies the window to every
  read path and composes with any adapter without the core store needing
  to know retention exists.

  Age is measured from lastActiveAt, which touch() refreshes, so the
  window is an idle timeout rather than a hard cap on session lifetime.
  isSessionExpired() exposes the same rule as a pure helper. Unparseable
  lastActiveAt counts as expired (an age that cannot be bounded is what
  the window exists to prevent); a future lastActiveAt (clock skew) is
  clamped at zero; a failed eviction still reports expiry so a read-only
  storage backend cannot resurrect a session; a non-positive or NaN
  maxAgeMs throws a RangeError at wiring time.

- contrib/session-retention.test.ts covers all of the above, including
  expiry past the max age, eviction from the underlying storage, the
  exact boundary, the 30-day default, and end-to-end restore() behavior
  through both the memory and web storage adapters.
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@Adeolu01 is attempting to deploy a commit to the david's projects Team on Vercel.

A member of the Team first needs to authorize it.

@davedumto
davedumto merged commit 7f21f52 into Vellar-Wallet:dev Aug 31, 2026
2 of 3 checks passed
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.

2 participants