@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 +46 -1
- package/README.md +50 -2
- package/dist/server/index.cjs +41 -16
- package/dist/server/index.d.cts +31 -9
- package/dist/server/index.d.ts +31 -9
- package/dist/server/index.mjs +41 -16
- package/package.json +2 -7
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.
|
|
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
|
-
|
|
568
|
-
|
|
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
|
|
package/dist/server/index.cjs
CHANGED
|
@@ -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)
|
|
1517
|
-
*
|
|
1518
|
-
* from its
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
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
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
}
|
package/dist/server/index.d.cts
CHANGED
|
@@ -1869,12 +1869,25 @@ declare class PricingService implements OnModuleInit {
|
|
|
1869
1869
|
* @layer server
|
|
1870
1870
|
*/
|
|
1871
1871
|
|
|
1872
|
-
/**
|
|
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)
|
|
2004
|
-
*
|
|
2005
|
-
* from its
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2009
|
-
*
|
|
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
|
|
2036
|
+
* @returns The verification outcome; see {@link ChainVerification}.
|
|
2015
2037
|
*/
|
|
2016
2038
|
verifyChain(tenantId: string, from?: Date, to?: Date): Promise<ChainVerification>;
|
|
2017
2039
|
/**
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1869,12 +1869,25 @@ declare class PricingService implements OnModuleInit {
|
|
|
1869
1869
|
* @layer server
|
|
1870
1870
|
*/
|
|
1871
1871
|
|
|
1872
|
-
/**
|
|
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)
|
|
2004
|
-
*
|
|
2005
|
-
* from its
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2009
|
-
*
|
|
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
|
|
2036
|
+
* @returns The verification outcome; see {@link ChainVerification}.
|
|
2015
2037
|
*/
|
|
2016
2038
|
verifyChain(tenantId: string, from?: Date, to?: Date): Promise<ChainVerification>;
|
|
2017
2039
|
/**
|
package/dist/server/index.mjs
CHANGED
|
@@ -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)
|
|
1515
|
-
*
|
|
1516
|
-
* from its
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
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
|
|
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
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
+
"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",
|