@bymax-one/nest-ai-tokens 1.1.3 → 1.1.4

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/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.4] - 2026-11-01
12
+
13
+ **Apply:** `verifyChain` has a new result, `{ valid: false, status: 'never-enabled' }`, which has no
14
+ `brokenAtRecordId`; handle it wherever you read that field. Enable `ledger.hashChain` before the
15
+ first write: records written earlier are reported as unchained and are not protected.
16
+
17
+ ### Fixed
18
+
19
+ - **Enabling `ledger.hashChain` on a ledger that already has records no longer makes `verifyChain`
20
+ report the oldest record as tampered.** `verifyChain` required every settled record to carry a
21
+ hash, so the first record written before the chain was enabled failed verification and ended the
22
+ walk, and the chained records after it were never checked. It now follows the chain by its links:
23
+ records without a hash are counted in the new `unchainedCount` and reported as not protected, the
24
+ hashed records must start at one genesis with no gap in the links, and a record whose hash was
25
+ removed while its `prevHash` stayed is reported as broken. A ledger with settled records and no
26
+ hashed one returns `{ valid: false, status: 'never-enabled' }`. The result type gains
27
+ `unchainedCount` and an optional `chainStartsAtRecordId`; the new `never-enabled` variant has no
28
+ `brokenAtRecordId`. A `from`/`to` call still checks each record's own hash only. A record that no other
29
+ record names as its predecessor (the newest one, and a hold that settled while a later-created record
30
+ was already hashed) can be deleted without detection, and the chain is unkeyed; both limits are in the README.
31
+
32
+ **Apply:** code that reads `brokenAtRecordId` after `valid === false` must also handle the
33
+ `never-enabled` variant, which has none. Enable the chain before the first write; a rise in
34
+ `unchainedCount` after enabling is worth investigating.
35
+
36
+ ### Changed
37
+
38
+ - The GitHub Release body is now the tag's own `CHANGELOG.md` section instead of the generated
39
+ pull-request list, and the release stops before publishing when the version has no section.
40
+ No change to the package.
41
+ - The release-notes step starts the section only at a line that begins `## [<version>]`, so a
42
+ version token elsewhere in a heading, such as `## [Unreleased] [1.1.1]`, no longer selects the
43
+ wrong body.
44
+ - The unused optional peer `@opentelemetry/api` is removed from `peerDependencies` and
45
+ `peerDependenciesMeta`. The package imports nothing from it; telemetry goes through the
46
+ `ITelemetrySink` you implement. Consumers lose only an install hint.
47
+
48
+ ### Documentation
49
+
50
+ - The README says that `MODEL_PRICES_SEED` has no rows for `azure-openai`, `vertex`, `bedrock` or
51
+ `openrouter`, so with `pricing.strict` on a call through the first three presets throws
52
+ `AI_TOKENS_PRICE_NOT_FOUND` until the host adds a price (the `openrouter` preset rates from the
53
+ provider-reported cost), and shows a minimal `upsertPrice` call.
54
+
11
55
  ## [1.1.3] - 2026-10-31
12
56
 
13
57
  **Billing:** if you set prices with `upsertPrice` for a model the snapshot also prices while
@@ -397,7 +441,8 @@ have regressed from. They are kept because the reasoning is worth having.
397
441
  [1.1.0]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...v1.1.0
398
442
  [1.1.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.1...v1.1.2
399
443
  [1.1.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.2...v1.1.3
444
+ [1.1.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.3...v1.1.4
400
445
  [1.1.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.0...v1.1.1
401
446
  [1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
402
447
  [1.0.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...v1.0.3
403
- [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.3...HEAD
448
+ [Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.4...HEAD
package/README.md CHANGED
@@ -337,6 +337,13 @@ when the application boots.
337
337
 
338
338
  All normalizers are pure functions over plain objects — no provider SDK peer dependency. Pass the raw response object and get back a `NormalizedUsage`.
339
339
 
340
+ `MODEL_PRICES_SEED` has no rows for `azure-openai`, `vertex`, `bedrock` or `openrouter`; it covers
341
+ `openai`, `anthropic`, `gemini`, `mistral`, `deepseek`, `xai` and `groq`. With `pricing.strict` on
342
+ (the default), a call through the `azure-openai`, `vertex` or `bedrock` preset throws
343
+ `AI_TOKENS_PRICE_NOT_FOUND` until you add its price. The `openrouter` preset rates from the cost the
344
+ provider reports, so it needs no price unless a call sets `ratingMode: 'rate-table'`. See
345
+ [Pricing & Markup](#-pricing--markup).
346
+
340
347
  ---
341
348
 
342
349
  ## 💰 Pricing & Markup
@@ -359,6 +366,20 @@ BymaxAiTokensModule.forRootAsync({
359
366
  // Margin: $0.015 per call
360
367
  ```
361
368
 
369
+ A provider or model the snapshot does not price needs a rate before it can be rated. Add one with
370
+ `PricingService.upsertPrice`; the new row closes the previous one at its `effectiveFrom`, which
371
+ defaults to now. Rates are nano-USD per million tokens:
372
+
373
+ ```typescript
374
+ await pricing.upsertPrice({
375
+ provider: 'bedrock',
376
+ model: 'my-model', // the model id your calls report
377
+ operation: 'chat',
378
+ inputNanoUsdPerMillion: 3_000_000_000n, // $3 per million input tokens
379
+ outputNanoUsdPerMillion: 15_000_000_000n, // $15 per million output tokens
380
+ })
381
+ ```
382
+
362
383
  For per-tenant or per-feature pricing, implement `IMarkupPolicy`:
363
384
 
364
385
  ```typescript
@@ -564,8 +585,35 @@ The sections above document each of these with a runnable example. This is the i
564
585
  | `UsageReportService` | `summarize` · `export` |
565
586
  | `StreamUsageCollector` | Accumulates streamed chunks; prefers the provider's final usage over its own count |
566
587
 
567
- `LedgerService.verifyChain(tenantId, from?, to?)` resolves to a `ChainVerification`: `{ valid: true }`
568
- or `{ valid: false, brokenAtRecordId }`. It checks the chain that `ledger.hashChain: true` writes.
588
+ #### Verifying the hash chain
589
+
590
+ `LedgerService.verifyChain(tenantId, from?, to?)` resolves to a `ChainVerification`:
591
+
592
+ - `{ valid: true, unchainedCount, chainStartsAtRecordId? }` — every hashed record recomputes and, on a call
593
+ without `from`/`to`, the hashed records form one chain. `chainStartsAtRecordId` is the genesis record
594
+ when the verified records include it.
595
+ - `{ valid: false, brokenAtRecordId, unchainedCount }` — the record that fails: a record whose hash was
596
+ removed while its link stayed, a changed record, or, without `from`/`to`, a gap (a second chain start)
597
+ or a chain that does not start at a genesis record (its first head). Those checks run in that order,
598
+ and within each the earliest record in listing order is reported. Two records may share a predecessor:
599
+ a hold that settles after a later record links to the record created last, as the next record does.
600
+ - `{ valid: false, status: 'never-enabled', unchainedCount }` — settled records exist and none carries a hash or a link. This
601
+ is not evidence of tampering; `ledger.hashChain` was simply never on for this tenant.
602
+
603
+ Enable `ledger.hashChain` before the first write. Records written before it was enabled are **unchained**:
604
+ they are counted in `unchainedCount`, not verified and not protected.
605
+
606
+ What it does not catch, plainly:
607
+
608
+ 1. A record that no other record names as its predecessor can be deleted without detection. That is
609
+ the newest record, and a hold that settled while a later-created record was already hashed (a record
610
+ links to the hashed record created last, so that hold is never linked to). A hold that settles as
611
+ the newest-created record is linked by the next record and is not affected.
612
+ 2. The chain is unkeyed. Someone with write access who recomputes the whole tail is not detected, so
613
+ anchor the latest hash outside the database.
614
+ 3. An `unchainedCount` that rises after the chain was enabled is a signal to investigate. A rolling deploy
615
+ with mixed `hashChain` settings can also cause it.
616
+ 4. A call with `from` or `to` checks each record's own hash only, not the links between records.
569
617
 
570
618
  ### Decorators, helpers and catalogs
571
619
 
@@ -1371,6 +1371,25 @@ function chainHash(prevHash, record) {
1371
1371
  content: computePayloadHash(record)
1372
1372
  });
1373
1373
  }
1374
+ function verifyChainRecords(records, structural) {
1375
+ const hashed = records.filter((record) => record.hash !== void 0);
1376
+ const unchainedCount = records.length - hashed.length;
1377
+ const stripped = records.find((record) => record.hash === void 0 && record.prevHash !== void 0);
1378
+ if (stripped !== void 0) return { valid: false, brokenAtRecordId: stripped.id, unchainedCount };
1379
+ const forged = hashed.find((record) => record.hash !== chainHash(record.prevHash ?? null, record));
1380
+ if (forged !== void 0) return { valid: false, brokenAtRecordId: forged.id, unchainedCount };
1381
+ if (hashed.length === 0) {
1382
+ return records.length === 0 ? { valid: true, unchainedCount } : { valid: false, status: "never-enabled", unchainedCount };
1383
+ }
1384
+ const hashes = new Set(hashed.map((record) => record.hash));
1385
+ const heads = hashed.filter((record) => !hashes.has(record.prevHash));
1386
+ const genesis = heads.find((record) => record.prevHash === void 0);
1387
+ if (structural) {
1388
+ const offending = heads.find((record) => record !== genesis);
1389
+ if (offending !== void 0) return { valid: false, brokenAtRecordId: offending.id, unchainedCount };
1390
+ }
1391
+ return genesis === void 0 ? { valid: true, unchainedCount } : { valid: true, unchainedCount, chainStartsAtRecordId: genesis.id };
1392
+ }
1374
1393
 
1375
1394
  // src/server/services/ledger-idempotency-conflict.ts
1376
1395
  function isLedgerIdempotencyConflict(error) {
@@ -1513,33 +1532,39 @@ exports.LedgerService = class LedgerService {
1513
1532
  return this.store.transition(id, from2, to, patch, this.hashChainEnabled);
1514
1533
  }
1515
1534
  /**
1516
- * Verify a tenant's tamper-evident hash chain (§8.6). Walks its settled records
1517
- * (`posted` + `reversed`) in append order and recomputes each record's chain hash
1518
- * from its stored `prevHash` link, comparing it to the stored `hash`; reports the
1519
- * first record whose hash does not match (a post-hoc content modification).
1520
- * Recomputing from each record's own stored link makes verification independent
1521
- * of walk order and of any `from`/`to` window (the first in-range record keeps
1522
- * its real predecessor link). Always emits an `ai_tokens.audit` hook.
1535
+ * Verify a tenant's tamper-evident hash chain (§8.6) over its settled records (`posted` +
1536
+ * `reversed`). The chain is followed by its links (`prevHash` to `hash`), not by row order.
1537
+ * Every hashed record must recompute from its own `prevHash`; a record that has lost its hash
1538
+ * but kept its `prevHash` is reported as broken. Records with no hash and no `prevHash` were
1539
+ * written before the chain was enabled: they are counted in `unchainedCount`, are not verified
1540
+ * and are not protected. With no `from`/`to` bound the hashed records must also form one chain:
1541
+ * one genesis (no `prevHash`) and no gap. If settled records exist and none carries a hash or a `prevHash`
1542
+ * the result is `{ valid: false, status: 'never-enabled' }`, which is not evidence of tampering.
1543
+ *
1544
+ * Limits: a record that no other record names as its predecessor (the newest one, and a hold that
1545
+ * settled while a later-created record was already hashed) can be deleted without detection;
1546
+ * the chain is unkeyed, so someone with write access who recomputes the whole tail is not
1547
+ * detected (anchor the latest hash outside the database); a bounded call (`from`/`to`) checks
1548
+ * each record's own hash only; and an `unchainedCount` that rises after the chain was enabled is
1549
+ * a signal to investigate (a rolling deploy with mixed `hashChain` settings can also cause it).
1550
+ * Always emits an `ai_tokens.chain.verified` audit hook.
1523
1551
  *
1524
1552
  * @param tenantId The tenant whose chain to verify.
1525
1553
  * @param from Optional inclusive lower bound on `occurredAt`.
1526
1554
  * @param to Optional inclusive upper bound on `occurredAt`.
1527
- * @returns `{ valid: true }`, or `{ valid: false, brokenAtRecordId }`.
1555
+ * @returns The verification outcome; see {@link ChainVerification}.
1528
1556
  */
1529
1557
  async verifyChain(tenantId, from2, to) {
1530
1558
  const filter = { tenantId, status: ["posted", "reversed"] };
1531
1559
  if (from2 !== void 0) filter.from = from2;
1532
1560
  if (to !== void 0) filter.to = to;
1533
1561
  const settled = await this.store.query(filter);
1534
- let result = { valid: true };
1535
- for (const record of settled) {
1536
- if (record.hash !== chainHash(record.prevHash ?? null, record)) {
1537
- result = { valid: false, brokenAtRecordId: record.id };
1538
- break;
1539
- }
1562
+ const result = verifyChainRecords(settled, from2 === void 0 && to === void 0);
1563
+ const details = { tenantId, valid: result.valid, unchainedCount: result.unchainedCount };
1564
+ if (!result.valid) {
1565
+ if ("status" in result) details.status = result.status;
1566
+ else details.brokenAtRecordId = result.brokenAtRecordId;
1540
1567
  }
1541
- const details = { tenantId, valid: result.valid };
1542
- if (!result.valid) details.brokenAtRecordId = result.brokenAtRecordId;
1543
1568
  this.audit("ai_tokens.chain.verified", details);
1544
1569
  return result;
1545
1570
  }
@@ -1869,12 +1869,25 @@ declare class PricingService implements OnModuleInit {
1869
1869
  * @layer server
1870
1870
  */
1871
1871
 
1872
- /** The outcome of {@link LedgerService.verifyChain}: intact, or the first broken record. */
1872
+ /**
1873
+ * The outcome of {@link LedgerService.verifyChain}. `unchainedCount` is the number of settled
1874
+ * records that carry no chain hash (written before the chain was enabled): they are counted, not
1875
+ * verified, and the chain does not protect them. `chainStartsAtRecordId` is the genesis record when
1876
+ * the verified records include it. The third variant means settled records exist and none carries a hash or a `prevHash`,
1877
+ * i.e. the chain was never enabled for this tenant, which is not evidence of tampering.
1878
+ */
1873
1879
  type ChainVerification = {
1874
1880
  valid: true;
1881
+ unchainedCount: number;
1882
+ chainStartsAtRecordId?: string;
1875
1883
  } | {
1876
1884
  valid: false;
1877
1885
  brokenAtRecordId: string;
1886
+ unchainedCount: number;
1887
+ } | {
1888
+ valid: false;
1889
+ status: 'never-enabled';
1890
+ unchainedCount: number;
1878
1891
  };
1879
1892
 
1880
1893
  /**
@@ -2000,18 +2013,27 @@ declare class LedgerService {
2000
2013
  */
2001
2014
  transition(id: string, from: UsageStatus, to: UsageStatus, patch?: Partial<UsageRecord>): Promise<UsageRecord | null>;
2002
2015
  /**
2003
- * Verify a tenant's tamper-evident hash chain (§8.6). Walks its settled records
2004
- * (`posted` + `reversed`) in append order and recomputes each record's chain hash
2005
- * from its stored `prevHash` link, comparing it to the stored `hash`; reports the
2006
- * first record whose hash does not match (a post-hoc content modification).
2007
- * Recomputing from each record's own stored link makes verification independent
2008
- * of walk order and of any `from`/`to` window (the first in-range record keeps
2009
- * its real predecessor link). Always emits an `ai_tokens.audit` hook.
2016
+ * Verify a tenant's tamper-evident hash chain (§8.6) over its settled records (`posted` +
2017
+ * `reversed`). The chain is followed by its links (`prevHash` to `hash`), not by row order.
2018
+ * Every hashed record must recompute from its own `prevHash`; a record that has lost its hash
2019
+ * but kept its `prevHash` is reported as broken. Records with no hash and no `prevHash` were
2020
+ * written before the chain was enabled: they are counted in `unchainedCount`, are not verified
2021
+ * and are not protected. With no `from`/`to` bound the hashed records must also form one chain:
2022
+ * one genesis (no `prevHash`) and no gap. If settled records exist and none carries a hash or a `prevHash`
2023
+ * the result is `{ valid: false, status: 'never-enabled' }`, which is not evidence of tampering.
2024
+ *
2025
+ * Limits: a record that no other record names as its predecessor (the newest one, and a hold that
2026
+ * settled while a later-created record was already hashed) can be deleted without detection;
2027
+ * the chain is unkeyed, so someone with write access who recomputes the whole tail is not
2028
+ * detected (anchor the latest hash outside the database); a bounded call (`from`/`to`) checks
2029
+ * each record's own hash only; and an `unchainedCount` that rises after the chain was enabled is
2030
+ * a signal to investigate (a rolling deploy with mixed `hashChain` settings can also cause it).
2031
+ * Always emits an `ai_tokens.chain.verified` audit hook.
2010
2032
  *
2011
2033
  * @param tenantId The tenant whose chain to verify.
2012
2034
  * @param from Optional inclusive lower bound on `occurredAt`.
2013
2035
  * @param to Optional inclusive upper bound on `occurredAt`.
2014
- * @returns `{ valid: true }`, or `{ valid: false, brokenAtRecordId }`.
2036
+ * @returns The verification outcome; see {@link ChainVerification}.
2015
2037
  */
2016
2038
  verifyChain(tenantId: string, from?: Date, to?: Date): Promise<ChainVerification>;
2017
2039
  /**
@@ -1869,12 +1869,25 @@ declare class PricingService implements OnModuleInit {
1869
1869
  * @layer server
1870
1870
  */
1871
1871
 
1872
- /** The outcome of {@link LedgerService.verifyChain}: intact, or the first broken record. */
1872
+ /**
1873
+ * The outcome of {@link LedgerService.verifyChain}. `unchainedCount` is the number of settled
1874
+ * records that carry no chain hash (written before the chain was enabled): they are counted, not
1875
+ * verified, and the chain does not protect them. `chainStartsAtRecordId` is the genesis record when
1876
+ * the verified records include it. The third variant means settled records exist and none carries a hash or a `prevHash`,
1877
+ * i.e. the chain was never enabled for this tenant, which is not evidence of tampering.
1878
+ */
1873
1879
  type ChainVerification = {
1874
1880
  valid: true;
1881
+ unchainedCount: number;
1882
+ chainStartsAtRecordId?: string;
1875
1883
  } | {
1876
1884
  valid: false;
1877
1885
  brokenAtRecordId: string;
1886
+ unchainedCount: number;
1887
+ } | {
1888
+ valid: false;
1889
+ status: 'never-enabled';
1890
+ unchainedCount: number;
1878
1891
  };
1879
1892
 
1880
1893
  /**
@@ -2000,18 +2013,27 @@ declare class LedgerService {
2000
2013
  */
2001
2014
  transition(id: string, from: UsageStatus, to: UsageStatus, patch?: Partial<UsageRecord>): Promise<UsageRecord | null>;
2002
2015
  /**
2003
- * Verify a tenant's tamper-evident hash chain (§8.6). Walks its settled records
2004
- * (`posted` + `reversed`) in append order and recomputes each record's chain hash
2005
- * from its stored `prevHash` link, comparing it to the stored `hash`; reports the
2006
- * first record whose hash does not match (a post-hoc content modification).
2007
- * Recomputing from each record's own stored link makes verification independent
2008
- * of walk order and of any `from`/`to` window (the first in-range record keeps
2009
- * its real predecessor link). Always emits an `ai_tokens.audit` hook.
2016
+ * Verify a tenant's tamper-evident hash chain (§8.6) over its settled records (`posted` +
2017
+ * `reversed`). The chain is followed by its links (`prevHash` to `hash`), not by row order.
2018
+ * Every hashed record must recompute from its own `prevHash`; a record that has lost its hash
2019
+ * but kept its `prevHash` is reported as broken. Records with no hash and no `prevHash` were
2020
+ * written before the chain was enabled: they are counted in `unchainedCount`, are not verified
2021
+ * and are not protected. With no `from`/`to` bound the hashed records must also form one chain:
2022
+ * one genesis (no `prevHash`) and no gap. If settled records exist and none carries a hash or a `prevHash`
2023
+ * the result is `{ valid: false, status: 'never-enabled' }`, which is not evidence of tampering.
2024
+ *
2025
+ * Limits: a record that no other record names as its predecessor (the newest one, and a hold that
2026
+ * settled while a later-created record was already hashed) can be deleted without detection;
2027
+ * the chain is unkeyed, so someone with write access who recomputes the whole tail is not
2028
+ * detected (anchor the latest hash outside the database); a bounded call (`from`/`to`) checks
2029
+ * each record's own hash only; and an `unchainedCount` that rises after the chain was enabled is
2030
+ * a signal to investigate (a rolling deploy with mixed `hashChain` settings can also cause it).
2031
+ * Always emits an `ai_tokens.chain.verified` audit hook.
2010
2032
  *
2011
2033
  * @param tenantId The tenant whose chain to verify.
2012
2034
  * @param from Optional inclusive lower bound on `occurredAt`.
2013
2035
  * @param to Optional inclusive upper bound on `occurredAt`.
2014
- * @returns `{ valid: true }`, or `{ valid: false, brokenAtRecordId }`.
2036
+ * @returns The verification outcome; see {@link ChainVerification}.
2015
2037
  */
2016
2038
  verifyChain(tenantId: string, from?: Date, to?: Date): Promise<ChainVerification>;
2017
2039
  /**
@@ -1369,6 +1369,25 @@ function chainHash(prevHash, record) {
1369
1369
  content: computePayloadHash(record)
1370
1370
  });
1371
1371
  }
1372
+ function verifyChainRecords(records, structural) {
1373
+ const hashed = records.filter((record) => record.hash !== void 0);
1374
+ const unchainedCount = records.length - hashed.length;
1375
+ const stripped = records.find((record) => record.hash === void 0 && record.prevHash !== void 0);
1376
+ if (stripped !== void 0) return { valid: false, brokenAtRecordId: stripped.id, unchainedCount };
1377
+ const forged = hashed.find((record) => record.hash !== chainHash(record.prevHash ?? null, record));
1378
+ if (forged !== void 0) return { valid: false, brokenAtRecordId: forged.id, unchainedCount };
1379
+ if (hashed.length === 0) {
1380
+ return records.length === 0 ? { valid: true, unchainedCount } : { valid: false, status: "never-enabled", unchainedCount };
1381
+ }
1382
+ const hashes = new Set(hashed.map((record) => record.hash));
1383
+ const heads = hashed.filter((record) => !hashes.has(record.prevHash));
1384
+ const genesis = heads.find((record) => record.prevHash === void 0);
1385
+ if (structural) {
1386
+ const offending = heads.find((record) => record !== genesis);
1387
+ if (offending !== void 0) return { valid: false, brokenAtRecordId: offending.id, unchainedCount };
1388
+ }
1389
+ return genesis === void 0 ? { valid: true, unchainedCount } : { valid: true, unchainedCount, chainStartsAtRecordId: genesis.id };
1390
+ }
1372
1391
 
1373
1392
  // src/server/services/ledger-idempotency-conflict.ts
1374
1393
  function isLedgerIdempotencyConflict(error) {
@@ -1511,33 +1530,39 @@ var LedgerService = class {
1511
1530
  return this.store.transition(id, from2, to, patch, this.hashChainEnabled);
1512
1531
  }
1513
1532
  /**
1514
- * Verify a tenant's tamper-evident hash chain (§8.6). Walks its settled records
1515
- * (`posted` + `reversed`) in append order and recomputes each record's chain hash
1516
- * from its stored `prevHash` link, comparing it to the stored `hash`; reports the
1517
- * first record whose hash does not match (a post-hoc content modification).
1518
- * Recomputing from each record's own stored link makes verification independent
1519
- * of walk order and of any `from`/`to` window (the first in-range record keeps
1520
- * its real predecessor link). Always emits an `ai_tokens.audit` hook.
1533
+ * Verify a tenant's tamper-evident hash chain (§8.6) over its settled records (`posted` +
1534
+ * `reversed`). The chain is followed by its links (`prevHash` to `hash`), not by row order.
1535
+ * Every hashed record must recompute from its own `prevHash`; a record that has lost its hash
1536
+ * but kept its `prevHash` is reported as broken. Records with no hash and no `prevHash` were
1537
+ * written before the chain was enabled: they are counted in `unchainedCount`, are not verified
1538
+ * and are not protected. With no `from`/`to` bound the hashed records must also form one chain:
1539
+ * one genesis (no `prevHash`) and no gap. If settled records exist and none carries a hash or a `prevHash`
1540
+ * the result is `{ valid: false, status: 'never-enabled' }`, which is not evidence of tampering.
1541
+ *
1542
+ * Limits: a record that no other record names as its predecessor (the newest one, and a hold that
1543
+ * settled while a later-created record was already hashed) can be deleted without detection;
1544
+ * the chain is unkeyed, so someone with write access who recomputes the whole tail is not
1545
+ * detected (anchor the latest hash outside the database); a bounded call (`from`/`to`) checks
1546
+ * each record's own hash only; and an `unchainedCount` that rises after the chain was enabled is
1547
+ * a signal to investigate (a rolling deploy with mixed `hashChain` settings can also cause it).
1548
+ * Always emits an `ai_tokens.chain.verified` audit hook.
1521
1549
  *
1522
1550
  * @param tenantId The tenant whose chain to verify.
1523
1551
  * @param from Optional inclusive lower bound on `occurredAt`.
1524
1552
  * @param to Optional inclusive upper bound on `occurredAt`.
1525
- * @returns `{ valid: true }`, or `{ valid: false, brokenAtRecordId }`.
1553
+ * @returns The verification outcome; see {@link ChainVerification}.
1526
1554
  */
1527
1555
  async verifyChain(tenantId, from2, to) {
1528
1556
  const filter = { tenantId, status: ["posted", "reversed"] };
1529
1557
  if (from2 !== void 0) filter.from = from2;
1530
1558
  if (to !== void 0) filter.to = to;
1531
1559
  const settled = await this.store.query(filter);
1532
- let result = { valid: true };
1533
- for (const record of settled) {
1534
- if (record.hash !== chainHash(record.prevHash ?? null, record)) {
1535
- result = { valid: false, brokenAtRecordId: record.id };
1536
- break;
1537
- }
1560
+ const result = verifyChainRecords(settled, from2 === void 0 && to === void 0);
1561
+ const details = { tenantId, valid: result.valid, unchainedCount: result.unchainedCount };
1562
+ if (!result.valid) {
1563
+ if ("status" in result) details.status = result.status;
1564
+ else details.brokenAtRecordId = result.brokenAtRecordId;
1538
1565
  }
1539
- const details = { tenantId, valid: result.valid };
1540
- if (!result.valid) details.brokenAtRecordId = result.brokenAtRecordId;
1541
1566
  this.audit("ai_tokens.chain.verified", details);
1542
1567
  return result;
1543
1568
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-ai-tokens",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "AI token metering & usage-based billing for NestJS 11: provider usage normalizer (×9), versioned effective-dated pricing, immutable append-only ledger, prepaid wallets, multi-dimension budgets, and markup/margin. Zero runtime dependencies.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -107,8 +107,7 @@
107
107
  "rxjs": "^7.8.0",
108
108
  "@prisma/client": "^6.0.0 || ^7.0.0",
109
109
  "ioredis": "^6.0.0",
110
- "@nestjs/event-emitter": ">=2.0.0",
111
- "@opentelemetry/api": "^1.9.0"
110
+ "@nestjs/event-emitter": ">=2.0.0"
112
111
  },
113
112
  "peerDependenciesMeta": {
114
113
  "@nestjs/common": {
@@ -131,9 +130,6 @@
131
130
  },
132
131
  "@nestjs/event-emitter": {
133
132
  "optional": true
134
- },
135
- "@opentelemetry/api": {
136
- "optional": true
137
133
  }
138
134
  },
139
135
  "devDependencies": {
@@ -144,7 +140,6 @@
144
140
  "@nestjs/event-emitter": "^3.0.1",
145
141
  "@nestjs/platform-express": "^11.1.28",
146
142
  "@nestjs/testing": "^11.1.28",
147
- "@opentelemetry/api": "^1.9.0",
148
143
  "@prisma/adapter-pg": "^7.9.1",
149
144
  "@prisma/client": "^7.9.1",
150
145
  "@stryker-mutator/core": "^9",