keyforge-anvil-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ilyasse-fouaide
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # keyforge-anvil-client
2
+
3
+ Offline-safe license client for keyforge-anvil-protected branch installations.
4
+
5
+ `keyforge-anvil-client` is a Node/ESM module that runs *inside* a branch's local
6
+ backend process to talk to keyforge-anvil (the licensing server, a separate
7
+ repo). It is not a browser client — no UI, no framework dependency. Local
8
+ Ed25519 signature verification is the fast, network-free path;
9
+ server contact (`activate`/`refresh`/`deactivate`) is one-time or
10
+ background. **The branch app must never block startup on a network call** —
11
+ `getEntitlement()` never makes one.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install keyforge-anvil-client
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```js
22
+ import { createKeyforgeClient } from 'keyforge-anvil-client';
23
+
24
+ const client = await createKeyforgeClient({
25
+ publicKeys: { 1: process.env.KEYFORGE_PUBLIC_KEY_V1 },
26
+ baseUrl: 'https://licensing.example.com',
27
+ });
28
+
29
+ // Once, human-initiated (e.g. from a setup wizard). Throws on failure.
30
+ await client.activate(licenseKey);
31
+
32
+ // On every app boot. Network-free, never throws for expected bad states.
33
+ const entitlement = await client.getEntitlement();
34
+ if (entitlement.status !== 'valid') {
35
+ // degraded mode vs. hard stop is your app's call, not this module's
36
+ }
37
+
38
+ // Periodically in the background (e.g. every few hours). Never throws for
39
+ // "offline" — silently no-ops so it's safe to call on a timer.
40
+ setInterval(() => client.refresh(), 6 * 60 * 60 * 1000);
41
+
42
+ // Decommissioning a branch. Throws on failure.
43
+ await client.deactivate();
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ `createKeyforgeClient(config)` takes a single config object:
49
+
50
+ | Field | Required | Default | Notes |
51
+ |---|---|---|---|
52
+ | `publicKeys` | Yes | — | `{ [keyVersion]: pemString }`. keyforge-anvil's Ed25519 public key(s), keyed by `keyVersion` for rotation. Config, not hardcoded, so a server-side key rotation doesn't force a new release of this module. |
53
+ | `baseUrl` | Yes | — | keyforge-anvil server base URL, e.g. `https://licensing.example.com`. |
54
+ | `storage` | No | a JSON-file adapter at `<cwd>/.keyforge-client/state.json` | Any object implementing the `StorageAdapter` interface (`get`/`set`/`delete`, all `Promise`-returning). The default is a plain JSON file — no SQLite, no native-binary install friction. If your backend already manages its own database, implement `StorageAdapter` against it instead; pass an explicit instance (e.g. `import { createJsonFileAdapter } from 'keyforge-anvil-client'; createJsonFileAdapter({ filePath })`) to change the default file's location. |
55
+ | `getNow` | No | real clock (unix seconds) | Injectable clock seam, mainly useful for tests. |
56
+ | `fetchImpl` | No | global `fetch` | Injectable fetch seam, mainly useful for tests. |
57
+
58
+ The returned client exposes exactly four functions: `activate(licenseKey)`,
59
+ `getEntitlement()`, `refresh()`, `deactivate()` — matching the four
60
+ lifecycle operations above.
61
+
62
+ ## `installationFingerprint`
63
+
64
+ A random UUID generated transparently the first time `activate()` is
65
+ called, and persisted under the `installationFingerprint` storage key (it
66
+ is not part of any status object `getEntitlement()`/`refresh()` return).
67
+ It identifies *this device*, not a particular activation:
68
+
69
+ - It is reused unchanged on any later `activate()` call from the same
70
+ installation, which the server uses for idempotent reactivation.
71
+ - It **survives `deactivate()`** deliberately — `deactivate()` clears
72
+ license-activation state (tokens, watermarks, the `revoked` flag) but
73
+ intentionally keeps the fingerprint, since decommissioning a license
74
+ isn't the same event as the device itself changing identity.
75
+
76
+ ## Status and error vocabulary
77
+
78
+ ### `getEntitlement()` statuses
79
+
80
+ Always network-free, never throws for any of these — an unexpected storage
81
+ error (e.g. corrupted state file) propagates instead of becoming a status.
82
+
83
+ | Status | Meaning |
84
+ |---|---|
85
+ | `not_activated` | No stored entitlement token — `activate()` hasn't run yet, or `deactivate()` cleared it. |
86
+ | `valid` | Signature, expiry, clock, and replay checks all pass. Returns `{ status, expiresAt, featureIds, features }` — `featureIds` is the token's array of licensed feature ids (keyforge-anvil's replacement for the old single `productId` claim); `features` is the open-ended capability map (currently `{ maxBranches }`). |
87
+ | `expired` | Token's signature is valid but it's past `expiresAt`. |
88
+ | `revoked` | The server reported a revocation on a past `refresh()` call — see [Revocation propagation](#revocation-propagation) below. |
89
+ | `tampered` | Signature invalid, payload malformed, `installationId` doesn't match this installation, or the token is a replay of an already-superseded one. |
90
+ | `unknown_key_version` | Token's `kid` isn't in the `publicKeys` this client was configured with — usually means local config is behind a server-side key rotation, not a fraudulent token. |
91
+ | `clock_rollback` | Local clock is behind the last recorded validation time. |
92
+
93
+ ### `KeyforgeApiError` codes
94
+
95
+ Thrown by `activate()`/`refresh()`/`deactivate()` for genuinely unexpected
96
+ outcomes (never for `refresh()`'s expected "offline"/"rate limited" cases,
97
+ which resolve to `{ status: 'offline' }` instead of throwing).
98
+
99
+ **Client-side-detected** — these are synthetic codes this library produces
100
+ locally; they are never returned by the keyforge-anvil server, so you won't find
101
+ them in keyforge-anvil's own API docs:
102
+
103
+ | Code | Meaning |
104
+ |---|---|
105
+ | `MALFORMED_RESPONSE` | A 2xx response body was missing required fields or wasn't valid JSON. |
106
+ | `INSTALLATION_ID_MISMATCH` | A response's `installationToken`/entitlement token disagree on `installationId`. |
107
+ | `STALE_TOKEN_REPLAY` | A response's token is not newer than the last one this installation accepted — rejects replayed/captured old responses. |
108
+
109
+ **Server-reported** — `error.code` is passed through verbatim from
110
+ keyforge-anvil's own error vocabulary (this library never invents a parallel
111
+ vocabulary for these). A representative, non-exhaustive sample seen in this
112
+ codebase's tests: `LICENSE_INVALID`, `LICENSE_REVOKED`, `RATE_LIMITED`,
113
+ `INSTALLATION_TOKEN_INVALID`. keyforge-anvil's own docs are the authoritative,
114
+ complete list.
115
+
116
+ One related detail: the same underlying "token failed local verification"
117
+ condition surfaces two different ways depending on which function hits it —
118
+ `activate()` lets `TokenInvalidError`/`TokenExpiredError`/
119
+ `UnknownKeyVersionError` (from local crypto verification) propagate as
120
+ thrown errors, while `getEntitlement()` converts the identical condition
121
+ into a status string (`tampered`/`expired`/`unknown_key_version`) instead
122
+ of throwing. This follows from each function's own contract (`activate()`
123
+ is a one-time action that should throw; `getEntitlement()` reports state
124
+ and never throws for expected bad states), not an inconsistency.
125
+
126
+ ## Revocation propagation
127
+
128
+ `getEntitlement()` runs entirely offline. Run purely offline, it cannot
129
+ know about a revocation that happened after the last successful
130
+ `refresh()` — the server can't tell a client something it hasn't
131
+ contacted. This is inherited by design from the keyforge-anvil server's own
132
+ architecture (revocation propagates only when a client reaches the
133
+ server, bounded by the entitlement token's expiry window); it is not a
134
+ defect in this client. Call `refresh()` periodically in the background to
135
+ bound how stale that window can get.
136
+
137
+ ## Accepted limitations
138
+
139
+ Documented here briefly; see `PROGRESS.md` for the full rationale behind
140
+ each (found and evaluated during this project's per-phase security
141
+ reviews):
142
+
143
+ - **Clock+watermark co-tampering** — an attacker with local filesystem
144
+ write access (the project's existing trust boundary) can roll the clock
145
+ back and edit the local rollback watermark together, defeating both
146
+ checks for an otherwise-genuine token.
147
+ - **Unbounded response body size** — no cap on `entitlementToken`/
148
+ `installationToken` string sizes accepted from a response before
149
+ verification.
150
+ - **Multi-write non-atomicity** — `activate()`/`refresh()` each issue
151
+ several independent storage writes; a process killed mid-sequence can
152
+ leave state requiring a retry (fails closed, not open).
153
+ - **`installationFingerprint`-seeding TOCTOU** — two concurrent, first-ever
154
+ `activate()` calls on the same client could each generate a different
155
+ fingerprint, with the second write silently winning.
156
+
157
+ ## Development
158
+
159
+ - `npm test` / `npm run test:watch` — Vitest
160
+ - `npm run lint` / `npm run lint:fix` — ESLint
161
+ - `npm run format` / `npm run format:check` — Prettier
162
+
163
+ See `CLAUDE.md` for the full command list and `ARCHITECTURE.md`/
164
+ `PROGRESS.md` for design decisions and phase-by-phase history.
165
+
166
+ ## License
167
+
168
+ MIT — see [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "keyforge-anvil-client",
3
+ "version": "0.1.0",
4
+ "description": "Offline-safe license client for keyforge-anvil-protected branch installations",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "ilyasse-fouaide <ilyasse.fouaide@gmail.com>",
8
+ "homepage": "https://github.com/Ilyasse-Fouaide/keyforge-anvil-client#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Ilyasse-Fouaide/keyforge-anvil-client.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Ilyasse-Fouaide/keyforge-anvil-client/issues"
15
+ },
16
+ "keywords": [
17
+ "license",
18
+ "licensing",
19
+ "entitlement",
20
+ "offline",
21
+ "ed25519",
22
+ "keyforge",
23
+ "keyforge-anvil"
24
+ ],
25
+ "main": "./src/index.js",
26
+ "exports": "./src/index.js",
27
+ "files": [
28
+ "src"
29
+ ],
30
+ "engines": {
31
+ "node": ">=24"
32
+ },
33
+ "scripts": {
34
+ "test": "vitest run",
35
+ "test:watch": "vitest",
36
+ "lint": "eslint .",
37
+ "lint:fix": "eslint . --fix",
38
+ "format": "prettier --write .",
39
+ "format:check": "prettier --check .",
40
+ "examples:setup": "node examples/setup-fixtures.js",
41
+ "examples:teardown": "node examples/teardown-fixtures.js",
42
+ "examples:run-all": "node examples/run-all.js"
43
+ },
44
+ "dependencies": {
45
+ "jose": "^6.2.8"
46
+ },
47
+ "devDependencies": {
48
+ "@eslint/js": "^10.0.1",
49
+ "eslint": "^10.8.1",
50
+ "eslint-config-prettier": "^10.1.8",
51
+ "globals": "^17.9.0",
52
+ "prettier": "^3.9.6",
53
+ "vitest": "^4.1.10"
54
+ }
55
+ }
@@ -0,0 +1,123 @@
1
+ // Phase 3 — POST /activate, store tokens via the storage adapter.
2
+ //
3
+ // Closes three of Phase 2's carry-forward items (PROGRESS.md): writes the
4
+ // initial lastValidatedAt watermark (without it every post-activation
5
+ // getEntitlement() call fails closed with clock_rollback, permanently),
6
+ // seeds highestIssuedAtSeen, and stores the installationId entitlement.js
7
+ // now checks against. A human-initiated, one-time action — throws on any
8
+ // failure per ARCHITECTURE.md §4, never returns a status object.
9
+ //
10
+ // The received entitlementToken is verified locally (same crypto/ machinery
11
+ // getEntitlement() uses) before anything is persisted. This is the load-
12
+ // bearing defense against a MITM or malicious server: a compromised network
13
+ // path can return whatever JSON it likes, but it cannot forge a token that
14
+ // verifies against our configured public keys, so nothing this function
15
+ // stores can end up trusted-but-fraudulent.
16
+
17
+ import { randomUUID } from 'node:crypto';
18
+
19
+ import { loadPublicKeys } from './crypto/keys.js';
20
+ import { verifyEntitlementToken } from './crypto/verify.js';
21
+ import { KeyforgeApiError } from './network/errors.js';
22
+ import { apiErrorFromResponse, parseSuccessBody, postJson } from './network/request.js';
23
+
24
+ const defaultGetNow = () => Math.floor(Date.now() / 1000);
25
+
26
+ /**
27
+ * @param {object} options
28
+ * @param {import('./storage/adapter.js').StorageAdapter} options.storage
29
+ * @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
30
+ * @param {string} options.baseUrl - Keyforge base URL, e.g. 'https://licensing.example.com'
31
+ * @param {() => number} [options.getNow]
32
+ * @param {typeof fetch} [options.fetchImpl]
33
+ * @returns {Promise<{ activate: (licenseKey: string) => Promise<{ expiresAt: number }> }>}
34
+ */
35
+ export async function createActivateClient({
36
+ storage,
37
+ publicKeys,
38
+ baseUrl,
39
+ getNow = defaultGetNow,
40
+ fetchImpl = fetch,
41
+ }) {
42
+ const publicKeysByVersion = await loadPublicKeys(publicKeys);
43
+
44
+ async function activate(licenseKey) {
45
+ let installationFingerprint = await storage.get('installationFingerprint');
46
+ if (installationFingerprint === null) {
47
+ installationFingerprint = randomUUID();
48
+ await storage.set('installationFingerprint', installationFingerprint);
49
+ }
50
+
51
+ const response = await postJson(
52
+ baseUrl,
53
+ '/api/v1/licenses/activate',
54
+ { licenseKey, installationFingerprint },
55
+ fetchImpl,
56
+ );
57
+
58
+ if (response.status !== 201) {
59
+ throw await apiErrorFromResponse(response);
60
+ }
61
+
62
+ const body = await parseSuccessBody(response);
63
+ const { entitlementToken, installationToken, installationId } = body?.data ?? {};
64
+ if (
65
+ typeof entitlementToken !== 'string' ||
66
+ typeof installationToken !== 'string' ||
67
+ typeof installationId !== 'string' ||
68
+ installationId.length === 0
69
+ ) {
70
+ throw new KeyforgeApiError(
71
+ response.status,
72
+ 'MALFORMED_RESPONSE',
73
+ 'activate() response is missing required fields',
74
+ );
75
+ }
76
+
77
+ const now = getNow();
78
+ // Verify before trusting anything else in the response — see module
79
+ // comment. Left to propagate as-is (TokenInvalidError/TokenExpiredError/
80
+ // UnknownKeyVersionError): a malformed/malicious/stale-key response here
81
+ // is a genuine failure this one-time action should throw for, not a
82
+ // status this module invents a mapping for.
83
+ const payload = await verifyEntitlementToken(entitlementToken, { publicKeysByVersion, now });
84
+
85
+ if (String(payload.installationId) !== installationId) {
86
+ throw new KeyforgeApiError(
87
+ response.status,
88
+ 'INSTALLATION_ID_MISMATCH',
89
+ 'activate() response installationId does not match the entitlement token payload',
90
+ );
91
+ }
92
+
93
+ // Reject a stale or replayed response BEFORE persisting anything, same
94
+ // defense as refresh() (see its module comment) — guards an
95
+ // already-active installation against a MITM/malicious server replaying
96
+ // an old, still-validly-signed /activate response. Only meaningful when
97
+ // there's a prior watermark to compare against: a genuinely fresh
98
+ // device, or one that just ran deactivate() (which clears this key),
99
+ // has nothing to compare yet — same "absence isn't fail-closed"
100
+ // reasoning entitlement.js already documents for this same field.
101
+ const storedHighestIssuedAt = await storage.get('highestIssuedAtSeen');
102
+ const highestIssuedAtSeen =
103
+ storedHighestIssuedAt === null ? null : Number(storedHighestIssuedAt);
104
+ if (highestIssuedAtSeen !== null && payload.issuedAt <= highestIssuedAtSeen) {
105
+ throw new KeyforgeApiError(
106
+ response.status,
107
+ 'STALE_TOKEN_REPLAY',
108
+ 'activate() response entitlementToken is not newer than the last one seen',
109
+ );
110
+ }
111
+
112
+ await storage.set('entitlementToken', entitlementToken);
113
+ await storage.set('installationToken', installationToken);
114
+ await storage.set('installationId', payload.installationId);
115
+ await storage.set('lastValidatedAt', String(now));
116
+ await storage.set('highestIssuedAtSeen', String(payload.issuedAt));
117
+ await storage.delete('revoked');
118
+
119
+ return { expiresAt: payload.expiresAt };
120
+ }
121
+
122
+ return { activate };
123
+ }
@@ -0,0 +1,40 @@
1
+ // Phase 2 — ported from Keyforge server's tests/helpers/offlineClock.js
2
+ // (assertNoClockRollback), unchanged. That file lives under the server
3
+ // repo's tests/helpers/ because it has no in-repo caller there — it's a
4
+ // tested reference implementation for the client SDK that didn't exist yet.
5
+ // This module is that client, so the check becomes real production code.
6
+
7
+ /**
8
+ * Deliberately extends bare Error, not TokenVerificationError: this check
9
+ * runs before verifyEntitlementToken is ever called, and rejects for a
10
+ * reason crypto/ has no visibility into (local trust state, not the
11
+ * token's own signature/schema/expiry).
12
+ */
13
+ export class ClockRollbackDetectedError extends Error {
14
+ constructor(now, lastValidatedAt) {
15
+ super(`System clock (${now}) is behind the last recorded validation time (${lastValidatedAt})`);
16
+ this.name = this.constructor.name;
17
+ this.now = now;
18
+ this.lastValidatedAt = lastValidatedAt;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Requires both values to already be finite numbers and throws a plain
24
+ * TypeError otherwise, rather than letting `<` silently coerce (e.g.
25
+ * `now < undefined` is false, so a caller whose storage read for the
26
+ * watermark came back empty would otherwise see "no rollback detected"
27
+ * and fall through to trusting the cached token — exactly backwards for a
28
+ * check whose whole point is to fail closed on an ambiguous clock state).
29
+ * A TypeError, not ClockRollbackDetectedError, since this is a contract
30
+ * violation by the caller, not a rollback detection.
31
+ */
32
+ export function assertNoClockRollback({ now, lastValidatedAt }) {
33
+ if (!Number.isFinite(now) || !Number.isFinite(lastValidatedAt)) {
34
+ throw new TypeError('assertNoClockRollback requires now and lastValidatedAt as finite numbers');
35
+ }
36
+
37
+ if (now < lastValidatedAt) {
38
+ throw new ClockRollbackDetectedError(now, lastValidatedAt);
39
+ }
40
+ }
@@ -0,0 +1,42 @@
1
+ // Phase 2 — ported from the keyforge server's src/crypto/errors.js, unchanged
2
+ // (keyforge-anvil's equivalent is identical).
3
+ //
4
+ // Deliberately does NOT extend a shared app-wide error base: this stays
5
+ // framework-agnostic, and translating these into keyforge-anvil-client's own
6
+ // status-object vocabulary is entitlement.js's job, not this file's.
7
+ //
8
+ // "Malformed" and "tampered" both collapse into TokenInvalidError rather
9
+ // than getting their own classes — both mean "reject, don't trust this
10
+ // token," and no caller needs to treat them differently. TokenExpiredError
11
+ // and UnknownKeyVersionError stay distinct because a caller plausibly does
12
+ // want to handle those differently (prompt a refresh vs. flag a stale
13
+ // embedded key).
14
+
15
+ export class TokenVerificationError extends Error {
16
+ constructor(message, options) {
17
+ super(message, options);
18
+ this.name = this.constructor.name;
19
+ }
20
+ }
21
+
22
+ export class TokenInvalidError extends TokenVerificationError {
23
+ code = 'TOKEN_INVALID';
24
+ }
25
+
26
+ export class TokenExpiredError extends TokenVerificationError {
27
+ code = 'TOKEN_EXPIRED';
28
+
29
+ constructor(expiresAt, options) {
30
+ super(`Token expired at ${expiresAt}`, options);
31
+ this.expiresAt = expiresAt;
32
+ }
33
+ }
34
+
35
+ export class UnknownKeyVersionError extends TokenVerificationError {
36
+ code = 'UNKNOWN_KEY_VERSION';
37
+
38
+ constructor(keyVersion, options) {
39
+ super(`Unknown signing key version: ${keyVersion}`, options);
40
+ this.keyVersion = keyVersion;
41
+ }
42
+ }
@@ -0,0 +1,42 @@
1
+ // Phase 2 — new helper, not a port. Keyforge server loads public keys from
2
+ // a file-path manifest (its own src/crypto/keys.js); this module's own §8
3
+ // resolution is inline PEM strings supplied at init instead (see
4
+ // ARCHITECTURE.md §8 and PROGRESS.md's Phase 2 entry for the rationale) —
5
+ // this file only owns the PEM-string -> CryptoKey conversion, no file I/O.
6
+
7
+ import { importSPKI } from 'jose';
8
+
9
+ /**
10
+ * Converts a `{ [keyVersion]: pemString }` config into the
11
+ * `Map<string, CryptoKey>` shape verifyEntitlementToken expects, keyed by
12
+ * version-as-string to match the JWS `kid` header (spec-string-typed).
13
+ * @param {Record<string, string>} publicKeys
14
+ * @returns {Promise<Map<string, CryptoKey>>}
15
+ */
16
+ export async function loadPublicKeys(publicKeys) {
17
+ if (publicKeys === null || typeof publicKeys !== 'object' || Array.isArray(publicKeys)) {
18
+ throw new TypeError('publicKeys must be an object mapping keyVersion to a PEM string');
19
+ }
20
+
21
+ const entries = Object.entries(publicKeys);
22
+ if (entries.length === 0) {
23
+ throw new TypeError('publicKeys must contain at least one key');
24
+ }
25
+
26
+ const resolved = await Promise.all(
27
+ entries.map(async ([version, pem]) => {
28
+ if (typeof pem !== 'string' || pem.length === 0) {
29
+ throw new TypeError(`publicKeys['${version}'] must be a non-empty PEM string`);
30
+ }
31
+ try {
32
+ return [version, await importSPKI(pem, 'EdDSA')];
33
+ } catch (err) {
34
+ throw new TypeError(`publicKeys['${version}'] is not a valid EdDSA public key PEM`, {
35
+ cause: err,
36
+ });
37
+ }
38
+ }),
39
+ );
40
+
41
+ return new Map(resolved);
42
+ }
@@ -0,0 +1,117 @@
1
+ // Phase 2 — ported from Keyforge server's src/crypto/verify.js. Same jose
2
+ // call (compactVerify, not jwtVerify — the payload uses this project's own
3
+ // field names, not registered JWT claims), same error dispatch order, same
4
+ // algorithm pin. The one substantive change: the server validates the
5
+ // parsed payload with a zod schema (entitlementToken.schema.js); this module
6
+ // has no zod dependency (deliberately dependency-light — see ARCHITECTURE.md
7
+ // §3), so assertValidPayloadShape below checks only the one field this
8
+ // module's own logic branches on (expiresAt) instead of porting the full
9
+ // schema. Signature verification already proves authenticity; the server
10
+ // validated shape at signing time.
11
+
12
+ import { compactVerify } from 'jose';
13
+
14
+ import {
15
+ TokenVerificationError,
16
+ TokenInvalidError,
17
+ TokenExpiredError,
18
+ UnknownKeyVersionError,
19
+ } from './errors.js';
20
+
21
+ /**
22
+ * Verifies a signed entitlement token: signature, payload shape, and expiry.
23
+ * Rejects tampered payloads, expired tokens, and unknown key versions with
24
+ * distinct error types — everything else (malformed structure, bad
25
+ * signature, shape violations) collapses into TokenInvalidError, since none
26
+ * of those need different handling downstream.
27
+ *
28
+ * Key resolution happens via jose's own GetKeyFunction callback, keyed on
29
+ * the header's `kid`: an error thrown inside that callback
30
+ * (UnknownKeyVersionError below) propagates through compactVerify()
31
+ * unmodified, with nothing verified yet at the time it runs. That's why the
32
+ * outer catch checks `instanceof TokenVerificationError` first — without
33
+ * that check, a naive wrap would misreport an unknown key version as a
34
+ * generic invalid-token failure.
35
+ */
36
+ export async function verifyEntitlementToken(
37
+ jws,
38
+ { publicKeysByVersion, now = Math.floor(Date.now() / 1000) },
39
+ ) {
40
+ const resolveKey = (protectedHeader) => {
41
+ const { kid } = protectedHeader;
42
+ if (typeof kid !== 'string' || kid.length === 0) {
43
+ throw new TokenInvalidError('Token header is missing kid');
44
+ }
45
+
46
+ const key = publicKeysByVersion.get(kid);
47
+ if (!key) {
48
+ throw new UnknownKeyVersionError(kid);
49
+ }
50
+
51
+ return key;
52
+ };
53
+
54
+ let payloadBytes;
55
+ let protectedHeader;
56
+ try {
57
+ // Pinning algorithms prevents algorithm-confusion attacks (e.g. a
58
+ // crafted alg: 'none' or alg: 'HS256' token). For Ed25519 keys
59
+ // specifically, jose already independently refuses both of those cases
60
+ // via its own type checking, so this pin is currently redundant with
61
+ // that — kept anyway as explicit, load-bearing-by-design defense in
62
+ // depth rather than an incidental side effect of jose's internals, and
63
+ // because it stops being redundant the moment this module ever has to
64
+ // deal with more than one key type.
65
+ ({ payload: payloadBytes, protectedHeader } = await compactVerify(jws, resolveKey, {
66
+ algorithms: ['EdDSA'],
67
+ }));
68
+ } catch (err) {
69
+ if (err instanceof TokenVerificationError) throw err;
70
+ throw new TokenInvalidError('Token signature verification failed', { cause: err });
71
+ }
72
+
73
+ let payload;
74
+ try {
75
+ payload = JSON.parse(new TextDecoder().decode(payloadBytes));
76
+ assertValidPayloadShape(payload);
77
+ } catch (err) {
78
+ throw new TokenInvalidError('Token payload invalid', { cause: err });
79
+ }
80
+
81
+ // Header and payload are both covered by the signature, so these can't
82
+ // actually diverge post-verification given the server is the only writer
83
+ // — cheap to assert anyway as a backstop against a future writer that
84
+ // isn't.
85
+ if (String(payload.keyVersion) !== protectedHeader.kid) {
86
+ throw new TokenInvalidError('Token keyVersion does not match header kid');
87
+ }
88
+
89
+ if (now > payload.expiresAt) {
90
+ throw new TokenExpiredError(payload.expiresAt);
91
+ }
92
+
93
+ return payload;
94
+ }
95
+
96
+ /**
97
+ * Minimal shape guard, scoped to only what this module consumes.
98
+ * `expiresAt` must be checked explicitly: `now > payload.expiresAt` is
99
+ * `false` when `expiresAt` is `undefined` (or any other non-number), which
100
+ * would otherwise make a malformed token silently read as "never expires"
101
+ * instead of being rejected — the same silent-permissive-fallthrough shape
102
+ * as the clock-rollback bug this module's clock/ guards against.
103
+ * `keyVersion` needs no separate check — the cross-check above already
104
+ * rejects a missing/undefined value, since `String(undefined)` can't match
105
+ * a real `kid`. `featureIds` (keyforge-anvil's replacement for the old
106
+ * single `productId` claim) and `features` are both passed through opaquely
107
+ * and unvalidated: neither is compared or branched on here, only relayed to
108
+ * the caller (`entitlement.js` surfaces both on a `valid` status).
109
+ */
110
+ function assertValidPayloadShape(payload) {
111
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
112
+ throw new TypeError('Entitlement token payload must be a JSON object');
113
+ }
114
+ if (!Number.isFinite(payload.expiresAt)) {
115
+ throw new TypeError('Entitlement token payload is missing a valid expiresAt');
116
+ }
117
+ }
@@ -0,0 +1,47 @@
1
+ // Phase 3 — POST /deactivate for branch decommissioning.
2
+ //
3
+ // A deliberate, human-initiated action — throws on any failure (including
4
+ // network unreachability, unlike refresh()) per ARCHITECTURE.md §4. On
5
+ // success, clears all locally stored license-activation state so a
6
+ // subsequent getEntitlement() call reports the existing 'not_activated'
7
+ // status — no new vocabulary needed. installationFingerprint is
8
+ // deliberately kept: it identifies this device, not this activation, and
9
+ // survives across a future re-activation.
10
+
11
+ import { apiErrorFromResponse, postJson } from './network/request.js';
12
+
13
+ /**
14
+ * @param {object} options
15
+ * @param {import('./storage/adapter.js').StorageAdapter} options.storage
16
+ * @param {string} options.baseUrl
17
+ * @param {typeof fetch} [options.fetchImpl]
18
+ * @returns {Promise<{ deactivate: () => Promise<void> }>}
19
+ */
20
+ export async function createDeactivateClient({ storage, baseUrl, fetchImpl = fetch }) {
21
+ async function deactivate() {
22
+ const installationToken = await storage.get('installationToken');
23
+ if (installationToken === null) {
24
+ return; // nothing to deactivate
25
+ }
26
+
27
+ const response = await postJson(
28
+ baseUrl,
29
+ '/api/v1/licenses/deactivate',
30
+ { installationToken },
31
+ fetchImpl,
32
+ );
33
+
34
+ if (response.status !== 204) {
35
+ throw await apiErrorFromResponse(response);
36
+ }
37
+
38
+ await storage.delete('entitlementToken');
39
+ await storage.delete('installationToken');
40
+ await storage.delete('installationId');
41
+ await storage.delete('lastValidatedAt');
42
+ await storage.delete('highestIssuedAtSeen');
43
+ await storage.delete('revoked');
44
+ }
45
+
46
+ return { deactivate };
47
+ }
@@ -0,0 +1,137 @@
1
+ // Phase 2 — getEntitlement(): local verification composition.
2
+ //
3
+ // Composes clock-rollback detection with signature verification, in that
4
+ // order — ported from Keyforge server's tests/offline-flow/clientVerification.test.js
5
+ // verifyStoredToken helper: the clock check is a bare, unguarded statement
6
+ // that runs to completion (or throws) before verifyEntitlementToken is ever
7
+ // called. Translated here from that file's exception-based contract into
8
+ // this module's own status-object vocabulary (ARCHITECTURE.md §4/§9).
9
+
10
+ import { assertNoClockRollback, ClockRollbackDetectedError } from './clock/rollback.js';
11
+ import { TokenExpiredError, TokenInvalidError, UnknownKeyVersionError } from './crypto/errors.js';
12
+ import { loadPublicKeys } from './crypto/keys.js';
13
+ import { verifyEntitlementToken } from './crypto/verify.js';
14
+
15
+ const defaultGetNow = () => Math.floor(Date.now() / 1000);
16
+
17
+ /**
18
+ * @param {object} options
19
+ * @param {import('./storage/adapter.js').StorageAdapter} options.storage
20
+ * @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
21
+ * @param {() => number} [options.getNow] - injectable clock seam for tests; defaults to real time (unix seconds)
22
+ * @returns {Promise<{ getEntitlement: () => Promise<object> }>}
23
+ */
24
+ export async function createEntitlementChecker({ storage, publicKeys, getNow = defaultGetNow }) {
25
+ const publicKeysByVersion = await loadPublicKeys(publicKeys);
26
+
27
+ async function getEntitlement() {
28
+ const tokenStr = await storage.get('entitlementToken');
29
+ if (tokenStr === null) {
30
+ return { status: 'not_activated' };
31
+ }
32
+
33
+ // A server-reported revocation (set by refresh() on a 403 entitlement-
34
+ // failure response — Phase 3) short-circuits everything else: a still-
35
+ // validly-signed, still-unexpired token tells us nothing about
36
+ // revocation that happened after the last successful refresh()
37
+ // (ARCHITECTURE.md §7), so once refresh() has told us, that verdict
38
+ // wins over anything the cached token itself would otherwise report. No
39
+ // clock check, no watermark writes on this path — there's nothing left
40
+ // to protect once the installation itself is revoked.
41
+ const revoked = await storage.get('revoked');
42
+ if (revoked === 'true') {
43
+ return { status: 'revoked' };
44
+ }
45
+
46
+ const now = getNow();
47
+ const storedLastValidatedAt = await storage.get('lastValidatedAt');
48
+ // storage.get() resolves null for a missing key — pass that through as
49
+ // null, NOT Number(null) (which is 0, a finite number). Coercing it
50
+ // would silently defeat assertNoClockRollback's fail-closed guard for
51
+ // "entitlementToken exists but no watermark has ever been written yet"
52
+ // (the state before Phase 3's activate() has run).
53
+ const lastValidatedAt = storedLastValidatedAt === null ? null : Number(storedLastValidatedAt);
54
+
55
+ try {
56
+ assertNoClockRollback({ now, lastValidatedAt });
57
+ } catch (err) {
58
+ if (err instanceof TypeError || err instanceof ClockRollbackDetectedError) {
59
+ return { status: 'clock_rollback' };
60
+ }
61
+ throw err; // unexpected — propagate, never swallow into a fake status
62
+ }
63
+
64
+ // Ratchet the watermark forward now that the clock check has passed,
65
+ // regardless of what verification below decides — the observation "now
66
+ // is at least this far along" is true independent of this particular
67
+ // token's own validity. This closes the gap where a NAIVE clock
68
+ // rollback (rolling the clock back without also editing this stored
69
+ // value) would let an already-observed-expired token look valid again.
70
+ // It is NOT protection against an attacker who edits this file directly
71
+ // alongside the clock — that requires the same local filesystem write
72
+ // access the token file itself already assumes as the trust boundary
73
+ // (ARCHITECTURE.md §5); no purely local, secret-free value can defend
74
+ // against a party who can edit it directly. Accepted, documented
75
+ // limitation — see PROGRESS.md's Phase 2 entry, same shape as §7's
76
+ // revocation-propagation trade-off. Never reached on the rollback path
77
+ // above, so a detected (or ambiguous) rollback never regresses the
78
+ // stored watermark.
79
+ await storage.set('lastValidatedAt', String(now));
80
+
81
+ try {
82
+ const payload = await verifyEntitlementToken(tokenStr, { publicKeysByVersion, now });
83
+
84
+ // Reject a validly-signed token that belongs to a DIFFERENT
85
+ // installation: signature/expiry alone can't distinguish "issued to
86
+ // this device" from "issued to some other device, and copied here."
87
+ // installationId is written by activate() (Phase 3) from the same
88
+ // signed payload this check reads, so a mismatch here means either
89
+ // the stored value or the token was swapped independently of the
90
+ // other — collapsed into 'tampered' rather than a new status, same
91
+ // category as a signature-tampered token (ARCHITECTURE.md §9: don't
92
+ // invent a parallel vocabulary).
93
+ const storedInstallationId = await storage.get('installationId');
94
+ if (
95
+ storedInstallationId !== null &&
96
+ String(payload.installationId) !== storedInstallationId
97
+ ) {
98
+ return { status: 'tampered' };
99
+ }
100
+
101
+ // Reject replay of a superseded (but still validly-signed, still
102
+ // unexpired) token: without this, restoring an old entitlementToken
103
+ // file — e.g. one saved before a downgrade — verifies cleanly and
104
+ // reports valid, since signature/expiry alone can't distinguish "the
105
+ // current token" from "a still-unexpired token this installation
106
+ // already moved past." Mirrors lastValidatedAt's watermark pattern,
107
+ // but keyed on the signature-authenticated payload.issuedAt rather
108
+ // than local clock time. Unlike lastValidatedAt, a missing watermark
109
+ // here is NOT fail-closed: the first token an installation ever
110
+ // verifies has nothing prior to compare against, so absence just
111
+ // means "nothing seen yet," not "something is wrong."
112
+ const storedHighestIssuedAt = await storage.get('highestIssuedAtSeen');
113
+ const highestIssuedAtSeen =
114
+ storedHighestIssuedAt === null ? null : Number(storedHighestIssuedAt);
115
+ if (highestIssuedAtSeen !== null && payload.issuedAt < highestIssuedAtSeen) {
116
+ return { status: 'tampered' };
117
+ }
118
+ if (highestIssuedAtSeen === null || payload.issuedAt > highestIssuedAtSeen) {
119
+ await storage.set('highestIssuedAtSeen', String(payload.issuedAt));
120
+ }
121
+
122
+ return {
123
+ status: 'valid',
124
+ expiresAt: payload.expiresAt,
125
+ featureIds: payload.featureIds,
126
+ features: payload.features,
127
+ };
128
+ } catch (err) {
129
+ if (err instanceof TokenExpiredError) return { status: 'expired' };
130
+ if (err instanceof UnknownKeyVersionError) return { status: 'unknown_key_version' };
131
+ if (err instanceof TokenInvalidError) return { status: 'tampered' };
132
+ throw err; // unexpected — propagate, never swallow into a fake status
133
+ }
134
+ }
135
+
136
+ return { getEntitlement };
137
+ }
package/src/index.js ADDED
@@ -0,0 +1,58 @@
1
+ // Phase 4 — public API surface.
2
+ //
3
+ // Composes the four independent lifecycle factories (Phases 2-3) into one
4
+ // client, constructed once from a single shared config object, exposing
5
+ // exactly ARCHITECTURE.md §4's literal call shapes: activate(licenseKey),
6
+ // getEntitlement(), refresh(), deactivate(). Each sub-factory destructures
7
+ // only the config keys it needs, so the same object is safe to pass to all
8
+ // four (e.g. createDeactivateClient simply ignores publicKeys/getNow).
9
+ //
10
+ // storage defaults to a plain JSON-file adapter when omitted, continuing
11
+ // this project's already-settled "default storage is a plain JSON file"
12
+ // decision (ARCHITECTURE.md §5, CLAUDE.md) up to this composed-client level.
13
+
14
+ import { createActivateClient } from './activate.js';
15
+ import { createDeactivateClient } from './deactivate.js';
16
+ import { createEntitlementChecker } from './entitlement.js';
17
+ import { createRefreshClient } from './refresh.js';
18
+ import { createJsonFileAdapter } from './storage/json-file.js';
19
+
20
+ /**
21
+ * @param {object} options
22
+ * @param {import('./storage/adapter.js').StorageAdapter} [options.storage] - defaults to a JSON-file adapter at the default path
23
+ * @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
24
+ * @param {string} options.baseUrl - Keyforge base URL, e.g. 'https://licensing.example.com'
25
+ * @param {() => number} [options.getNow]
26
+ * @param {typeof fetch} [options.fetchImpl]
27
+ * @returns {Promise<{
28
+ * activate: (licenseKey: string) => Promise<{ expiresAt: number }>,
29
+ * getEntitlement: () => Promise<object>,
30
+ * refresh: () => Promise<object>,
31
+ * deactivate: () => Promise<void>,
32
+ * }>}
33
+ */
34
+ export { createJsonFileAdapter };
35
+
36
+ export async function createKeyforgeClient({
37
+ storage = createJsonFileAdapter(),
38
+ publicKeys,
39
+ baseUrl,
40
+ getNow,
41
+ fetchImpl,
42
+ }) {
43
+ const sharedConfig = { storage, publicKeys, baseUrl, getNow, fetchImpl };
44
+
45
+ const [activateClient, entitlementChecker, refreshClient, deactivateClient] = await Promise.all([
46
+ createActivateClient(sharedConfig),
47
+ createEntitlementChecker(sharedConfig),
48
+ createRefreshClient(sharedConfig),
49
+ createDeactivateClient(sharedConfig),
50
+ ]);
51
+
52
+ return {
53
+ activate: activateClient.activate,
54
+ getEntitlement: entitlementChecker.getEntitlement,
55
+ refresh: refreshClient.refresh,
56
+ deactivate: deactivateClient.deactivate,
57
+ };
58
+ }
@@ -0,0 +1,23 @@
1
+ // Phase 3 — the one new error type for the network surface: a structured,
2
+ // non-2xx (and otherwise unhandled) response from the Keyforge API. Kept
3
+ // separate from src/crypto/errors.js's TokenVerificationError hierarchy —
4
+ // that hierarchy is about a token failing *local* verification; this one is
5
+ // about the *server* rejecting a request outright. Different failure domain,
6
+ // different vocabulary (mirrors ARCHITECTURE.md §9's "don't invent a
7
+ // parallel vocabulary" guidance by reusing the server's own `code` values
8
+ // verbatim rather than re-deriving new ones).
9
+
10
+ export class KeyforgeApiError extends Error {
11
+ /**
12
+ * @param {number} status - HTTP status code, or 0 if there was no response at all
13
+ * @param {string} code - server's `error.code`, or a synthetic one (e.g. `MALFORMED_RESPONSE`)
14
+ * @param {string} [message]
15
+ * @param {ErrorOptions} [options]
16
+ */
17
+ constructor(status, code, message, options) {
18
+ super(message ?? `Keyforge API request failed (${code})`, options);
19
+ this.name = this.constructor.name;
20
+ this.status = status;
21
+ this.code = code;
22
+ }
23
+ }
@@ -0,0 +1,61 @@
1
+ // Phase 3 — small shared plumbing for the three network operations
2
+ // (activate/refresh/deactivate): identical POST-JSON request shape and
3
+ // identical `{ error: { code, message } }` envelope parsing, per
4
+ // docs/client-sdk-integration.md's error format. No HTTP client dependency:
5
+ // `fetchImpl` defaults to the global `fetch` (Node engine is already >=24).
6
+
7
+ import { KeyforgeApiError } from './errors.js';
8
+
9
+ /**
10
+ * @param {string} baseUrl
11
+ * @param {string} path
12
+ * @param {object} payload
13
+ * @param {typeof fetch} fetchImpl
14
+ */
15
+ export function postJson(baseUrl, path, payload, fetchImpl) {
16
+ return fetchImpl(`${baseUrl}${path}`, {
17
+ method: 'POST',
18
+ headers: { 'content-type': 'application/json' },
19
+ body: JSON.stringify(payload),
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Parses a success (2xx) response body, converting a missing/malformed JSON
25
+ * body into a KeyforgeApiError rather than letting a raw SyntaxError escape
26
+ * — a malicious/broken server sending e.g. `201` with an empty or non-JSON
27
+ * body should fail the same recognizable way as any other bad response.
28
+ * @param {Response} response
29
+ */
30
+ export async function parseSuccessBody(response) {
31
+ try {
32
+ return await response.json();
33
+ } catch (err) {
34
+ throw new KeyforgeApiError(
35
+ response.status,
36
+ 'MALFORMED_RESPONSE',
37
+ 'Response body is not valid JSON',
38
+ {
39
+ cause: err,
40
+ },
41
+ );
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Builds a KeyforgeApiError from a non-success Response, tolerating a
47
+ * missing/malformed error body rather than throwing while handling an
48
+ * already-exceptional path.
49
+ * @param {Response} response
50
+ */
51
+ export async function apiErrorFromResponse(response) {
52
+ let body = null;
53
+ try {
54
+ body = await response.json();
55
+ } catch {
56
+ // no/invalid JSON body — fall through to the synthetic code below
57
+ }
58
+ const code = body?.error?.code ?? 'MALFORMED_RESPONSE';
59
+ const message = body?.error?.message;
60
+ return new KeyforgeApiError(response.status, code, message);
61
+ }
package/src/refresh.js ADDED
@@ -0,0 +1,125 @@
1
+ // Phase 3 — POST /refresh, connectivity-gated, silent no-op offline.
2
+ //
3
+ // Closes Phase 2's other carry-forward item: gives refresh() a way to record
4
+ // a server-reported revocation so getEntitlement() can ever report
5
+ // 'revoked' (PROGRESS.md). Unlike activate()/deactivate(), this must never
6
+ // throw for "offline" or "try again later" (unreachable network, 429
7
+ // RATE_LIMITED) — those are expected, routine outcomes for a background
8
+ // operation. It DOES throw for genuinely unexpected server responses (e.g.
9
+ // 401 INSTALLATION_TOKEN_INVALID, 5xx, malformed body).
10
+ //
11
+ // The received entitlementToken is verified locally before being persisted,
12
+ // same MITM defense as activate() — a response arrives over the same
13
+ // network path and deserves the same signature gate before being trusted.
14
+
15
+ import { loadPublicKeys } from './crypto/keys.js';
16
+ import { verifyEntitlementToken } from './crypto/verify.js';
17
+ import { KeyforgeApiError } from './network/errors.js';
18
+ import { apiErrorFromResponse, parseSuccessBody, postJson } from './network/request.js';
19
+
20
+ const defaultGetNow = () => Math.floor(Date.now() / 1000);
21
+
22
+ /**
23
+ * @param {object} options
24
+ * @param {import('./storage/adapter.js').StorageAdapter} options.storage
25
+ * @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
26
+ * @param {string} options.baseUrl
27
+ * @param {() => number} [options.getNow]
28
+ * @param {typeof fetch} [options.fetchImpl]
29
+ * @returns {Promise<{ refresh: () => Promise<object> }>}
30
+ */
31
+ export async function createRefreshClient({
32
+ storage,
33
+ publicKeys,
34
+ baseUrl,
35
+ getNow = defaultGetNow,
36
+ fetchImpl = fetch,
37
+ }) {
38
+ const publicKeysByVersion = await loadPublicKeys(publicKeys);
39
+
40
+ async function refresh() {
41
+ const installationToken = await storage.get('installationToken');
42
+ if (installationToken === null) {
43
+ return { status: 'not_activated' };
44
+ }
45
+
46
+ let response;
47
+ try {
48
+ response = await postJson(
49
+ baseUrl,
50
+ '/api/v1/licenses/refresh',
51
+ { installationToken },
52
+ fetchImpl,
53
+ );
54
+ } catch {
55
+ // Real connectivity check: attempt-and-catch, never a
56
+ // navigator.onLine-style flag. Silent no-op, no state change.
57
+ return { status: 'offline' };
58
+ }
59
+
60
+ if (response.status === 429) {
61
+ // Same "try again later" bucket as unreachable — never throw for it.
62
+ return { status: 'offline' };
63
+ }
64
+
65
+ if (response.status === 403) {
66
+ await storage.set('revoked', 'true');
67
+ return { status: 'revoked' };
68
+ }
69
+
70
+ if (response.status !== 200) {
71
+ // Genuinely unexpected (401 INSTALLATION_TOKEN_INVALID, 400, 5xx, ...).
72
+ throw await apiErrorFromResponse(response);
73
+ }
74
+
75
+ const body = await parseSuccessBody(response);
76
+ const { entitlementToken } = body?.data ?? {};
77
+ if (typeof entitlementToken !== 'string') {
78
+ throw new KeyforgeApiError(
79
+ response.status,
80
+ 'MALFORMED_RESPONSE',
81
+ 'refresh() response is missing entitlementToken',
82
+ );
83
+ }
84
+
85
+ const now = getNow();
86
+ const payload = await verifyEntitlementToken(entitlementToken, { publicKeysByVersion, now });
87
+
88
+ const storedInstallationId = await storage.get('installationId');
89
+ if (storedInstallationId !== null && String(payload.installationId) !== storedInstallationId) {
90
+ throw new KeyforgeApiError(
91
+ response.status,
92
+ 'INSTALLATION_ID_MISMATCH',
93
+ 'refresh() response entitlementToken belongs to a different installation',
94
+ );
95
+ }
96
+
97
+ // Reject a stale or replayed response BEFORE persisting anything: a
98
+ // MITM (or malicious server) that captured a previously-accepted
99
+ // /refresh response must not be able to replay it later to roll
100
+ // entitlementToken back to an already-superseded token or — the
101
+ // critical case — to clear a `revoked` flag set by a real 403 that
102
+ // arrived after the captured response. Strictly-greater-than (not >=)
103
+ // rejects exact replays too: an honest refresh() always returns a token
104
+ // with a newer issuedAt than the last one this installation accepted.
105
+ const storedHighestIssuedAt = await storage.get('highestIssuedAtSeen');
106
+ const highestIssuedAtSeen =
107
+ storedHighestIssuedAt === null ? null : Number(storedHighestIssuedAt);
108
+ if (highestIssuedAtSeen !== null && payload.issuedAt <= highestIssuedAtSeen) {
109
+ throw new KeyforgeApiError(
110
+ response.status,
111
+ 'STALE_TOKEN_REPLAY',
112
+ 'refresh() response entitlementToken is not newer than the last one seen',
113
+ );
114
+ }
115
+
116
+ await storage.set('entitlementToken', entitlementToken);
117
+ await storage.set('lastValidatedAt', String(now));
118
+ await storage.set('highestIssuedAtSeen', String(payload.issuedAt));
119
+ await storage.delete('revoked');
120
+
121
+ return { status: 'updated', expiresAt: payload.expiresAt };
122
+ }
123
+
124
+ return { refresh };
125
+ }
@@ -0,0 +1,33 @@
1
+ // Phase 1 — storage interface: get(key)/set(key,value)/delete(key).
2
+
3
+ /**
4
+ * @typedef {object} StorageAdapter
5
+ * @property {(key: string) => Promise<string | null>} get
6
+ * Resolves to the stored value, or `null` if `key` was never set. Never throws for a missing key.
7
+ * @property {(key: string, value: string) => Promise<void>} set
8
+ * Stores `value` under `key`, overwriting any existing value.
9
+ * @property {(key: string) => Promise<void>} delete
10
+ * Removes `key`. A no-op (does not throw) if `key` was never set.
11
+ */
12
+
13
+ /**
14
+ * Throws a TypeError unless `key` is a non-empty string. Shared by every
15
+ * StorageAdapter implementation so the key contract can't drift between them.
16
+ * @param {unknown} key
17
+ */
18
+ export function assertValidKey(key) {
19
+ if (typeof key !== 'string' || key.length === 0) {
20
+ throw new TypeError(`StorageAdapter key must be a non-empty string, got: ${String(key)}`);
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Throws a TypeError unless `value` is a string. Shared by every
26
+ * StorageAdapter implementation so the value contract can't drift between them.
27
+ * @param {unknown} value
28
+ */
29
+ export function assertValidValue(value) {
30
+ if (typeof value !== 'string') {
31
+ throw new TypeError(`StorageAdapter value must be a string, got: ${typeof value}`);
32
+ }
33
+ }
@@ -0,0 +1,78 @@
1
+ // Phase 1 — default adapter, plain JSON file via node:fs.
2
+
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import { assertValidKey, assertValidValue } from './adapter.js';
7
+
8
+ /**
9
+ * Default StorageAdapter (ARCHITECTURE.md §5): a single flat JSON file on
10
+ * disk, `{ [key]: value }`. Not one-file-per-key — the interface never asks
11
+ * for listing/iterating keys, so one file is simpler and sufficient.
12
+ *
13
+ * @param {object} [options]
14
+ * @param {string} [options.filePath] Defaults to `.keyforge-client/state.json`
15
+ * under the current working directory, mirroring Keyforge's own
16
+ * cwd-relative convention for local file state (see
17
+ * `scripts/generate-signing-keypair.js`'s `keys/` directory).
18
+ * @returns {import('./adapter.js').StorageAdapter}
19
+ */
20
+ export function createJsonFileAdapter({ filePath } = {}) {
21
+ const resolvedPath = filePath ?? path.join(process.cwd(), '.keyforge-client', 'state.json');
22
+ const tmpPath = `${resolvedPath}.tmp`;
23
+
24
+ // Serializes every get/set/delete on this adapter instance so concurrent
25
+ // calls can't interleave a read-modify-write and lose an update.
26
+ let queue = Promise.resolve();
27
+ function enqueue(operation) {
28
+ const result = queue.then(operation);
29
+ // Keep the chain alive even if this operation rejects, so later queued
30
+ // operations still run instead of hanging behind a rejected promise.
31
+ queue = result.catch(() => {});
32
+ return result;
33
+ }
34
+
35
+ async function readStore() {
36
+ try {
37
+ return JSON.parse(await readFile(resolvedPath, 'utf8'));
38
+ } catch (err) {
39
+ if (err.code === 'ENOENT') return {};
40
+ throw err;
41
+ }
42
+ }
43
+
44
+ async function writeStore(store) {
45
+ await mkdir(path.dirname(resolvedPath), { recursive: true, mode: 0o700 });
46
+ await writeFile(tmpPath, JSON.stringify(store, null, 2), { mode: 0o600 });
47
+ await rename(tmpPath, resolvedPath);
48
+ }
49
+
50
+ return {
51
+ async get(key) {
52
+ assertValidKey(key);
53
+ return enqueue(async () => {
54
+ const store = await readStore();
55
+ return store[key] ?? null;
56
+ });
57
+ },
58
+
59
+ async set(key, value) {
60
+ assertValidKey(key);
61
+ assertValidValue(value);
62
+ return enqueue(async () => {
63
+ const store = await readStore();
64
+ store[key] = value;
65
+ await writeStore(store);
66
+ });
67
+ },
68
+
69
+ async delete(key) {
70
+ assertValidKey(key);
71
+ return enqueue(async () => {
72
+ const store = await readStore();
73
+ delete store[key];
74
+ await writeStore(store);
75
+ });
76
+ },
77
+ };
78
+ }