@git-stunts/git-warp 10.8.0 → 11.2.1
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/README.md +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +6 -0
- package/bin/presenters/text.js +136 -0
- package/bin/warp-graph.js +5 -2346
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +8 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/hooks/post-merge.sh +0 -60
|
@@ -11,8 +11,8 @@ import WarpError from './WarpError.js';
|
|
|
11
11
|
*
|
|
12
12
|
* | Code | Description |
|
|
13
13
|
* |------|-------------|
|
|
14
|
-
* | `E_NO_STATE` | No
|
|
15
|
-
* | `E_STALE_STATE` |
|
|
14
|
+
* | `E_NO_STATE` | No materialized state available; call `materialize()` or use `autoMaterialize: true` |
|
|
15
|
+
* | `E_STALE_STATE` | State is stale; call `materialize()` to refresh |
|
|
16
16
|
* | `E_QUERY_MATCH_TYPE` | Invalid type passed to `match()` (expected string) |
|
|
17
17
|
* | `E_QUERY_WHERE_TYPE` | Invalid type passed to `where()` (expected function or object) |
|
|
18
18
|
* | `E_QUERY_WHERE_VALUE` | Non-primitive value in where() object shorthand |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import WarpError from './WarpError.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error class for trust operations.
|
|
5
|
+
*
|
|
6
|
+
* ## Error Codes
|
|
7
|
+
*
|
|
8
|
+
* | Code | Description |
|
|
9
|
+
* |------|-------------|
|
|
10
|
+
* | `E_TRUST_UNSUPPORTED_ALGORITHM` | Algorithm is not `ed25519` |
|
|
11
|
+
* | `E_TRUST_INVALID_KEY` | Public key is malformed (wrong length or bad base64) |
|
|
12
|
+
* | `TRUST_ERROR` | Generic/default trust error |
|
|
13
|
+
*
|
|
14
|
+
* @class TrustError
|
|
15
|
+
* @extends WarpError
|
|
16
|
+
*
|
|
17
|
+
* @property {string} name - Always 'TrustError' for instanceof checks
|
|
18
|
+
* @property {string} code - Machine-readable error code for programmatic handling
|
|
19
|
+
* @property {Object} context - Serializable context object with error details
|
|
20
|
+
*/
|
|
21
|
+
export default class TrustError extends WarpError {
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} message
|
|
24
|
+
* @param {{ code?: string, context?: Object }} [options={}]
|
|
25
|
+
*/
|
|
26
|
+
constructor(message, options = {}) {
|
|
27
|
+
super(message, 'TRUST_ERROR', options);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -17,5 +17,6 @@ export { default as ShardValidationError } from './ShardValidationError.js';
|
|
|
17
17
|
export { default as StorageError } from './StorageError.js';
|
|
18
18
|
export { default as SchemaUnsupportedError } from './SchemaUnsupportedError.js';
|
|
19
19
|
export { default as TraversalError } from './TraversalError.js';
|
|
20
|
+
export { default as TrustError } from './TrustError.js';
|
|
20
21
|
export { default as WriterError } from './WriterError.js';
|
|
21
22
|
export { default as WormholeError } from './WormholeError.js';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit message encoding and decoding for WARP audit commit messages.
|
|
3
|
+
*
|
|
4
|
+
* Handles the 'audit' message type which records the outcome of materializing
|
|
5
|
+
* a data commit. See {@link module:domain/services/WarpMessageCodec} for the
|
|
6
|
+
* facade that re-exports all codec functions.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/services/AuditMessageCodec
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { validateGraphName, validateWriterId } from '../utils/RefLayout.js';
|
|
12
|
+
import {
|
|
13
|
+
getCodec,
|
|
14
|
+
MESSAGE_TITLES,
|
|
15
|
+
TRAILER_KEYS,
|
|
16
|
+
validateOid,
|
|
17
|
+
validateSha256,
|
|
18
|
+
} from './MessageCodecInternal.js';
|
|
19
|
+
|
|
20
|
+
// -----------------------------------------------------------------------------
|
|
21
|
+
// Encoder
|
|
22
|
+
// -----------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Encodes an audit commit message with trailers.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} options
|
|
28
|
+
* @param {string} options.graph - The graph name
|
|
29
|
+
* @param {string} options.writer - The writer ID
|
|
30
|
+
* @param {string} options.dataCommit - The OID of the data commit being audited
|
|
31
|
+
* @param {string} options.opsDigest - SHA-256 hex digest of the canonical ops JSON
|
|
32
|
+
* @returns {string} The encoded commit message
|
|
33
|
+
* @throws {Error} If any validation fails
|
|
34
|
+
*/
|
|
35
|
+
export function encodeAuditMessage({ graph, writer, dataCommit, opsDigest }) {
|
|
36
|
+
validateGraphName(graph);
|
|
37
|
+
validateWriterId(writer);
|
|
38
|
+
validateOid(dataCommit, 'dataCommit');
|
|
39
|
+
validateSha256(opsDigest, 'opsDigest');
|
|
40
|
+
|
|
41
|
+
const codec = getCodec();
|
|
42
|
+
return codec.encode({
|
|
43
|
+
title: MESSAGE_TITLES.audit,
|
|
44
|
+
trailers: {
|
|
45
|
+
[TRAILER_KEYS.dataCommit]: dataCommit,
|
|
46
|
+
[TRAILER_KEYS.graph]: graph,
|
|
47
|
+
[TRAILER_KEYS.kind]: 'audit',
|
|
48
|
+
[TRAILER_KEYS.opsDigest]: opsDigest,
|
|
49
|
+
[TRAILER_KEYS.schema]: '1',
|
|
50
|
+
[TRAILER_KEYS.writer]: writer,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// -----------------------------------------------------------------------------
|
|
56
|
+
// Decoder
|
|
57
|
+
// -----------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decodes an audit commit message.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} message - The raw commit message
|
|
63
|
+
* @returns {{ kind: 'audit', graph: string, writer: string, dataCommit: string, opsDigest: string, schema: number }}
|
|
64
|
+
* @throws {Error} If the message is not a valid audit message
|
|
65
|
+
*/
|
|
66
|
+
export function decodeAuditMessage(message) {
|
|
67
|
+
const codec = getCodec();
|
|
68
|
+
const decoded = codec.decode(message);
|
|
69
|
+
const { trailers } = decoded;
|
|
70
|
+
|
|
71
|
+
// Check for duplicate trailers (strict decode)
|
|
72
|
+
const keys = Object.keys(trailers);
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
if (seen.has(key)) {
|
|
76
|
+
throw new Error(`Duplicate trailer rejected: ${key}`);
|
|
77
|
+
}
|
|
78
|
+
seen.add(key);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate kind discriminator
|
|
82
|
+
const kind = trailers[TRAILER_KEYS.kind];
|
|
83
|
+
if (kind !== 'audit') {
|
|
84
|
+
throw new Error(`Invalid audit message: eg-kind must be 'audit', got '${kind}'`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extract and validate required fields
|
|
88
|
+
const graph = trailers[TRAILER_KEYS.graph];
|
|
89
|
+
if (!graph) {
|
|
90
|
+
throw new Error('Invalid audit message: missing required trailer eg-graph');
|
|
91
|
+
}
|
|
92
|
+
validateGraphName(graph);
|
|
93
|
+
|
|
94
|
+
const writer = trailers[TRAILER_KEYS.writer];
|
|
95
|
+
if (!writer) {
|
|
96
|
+
throw new Error('Invalid audit message: missing required trailer eg-writer');
|
|
97
|
+
}
|
|
98
|
+
validateWriterId(writer);
|
|
99
|
+
|
|
100
|
+
const dataCommit = trailers[TRAILER_KEYS.dataCommit];
|
|
101
|
+
if (!dataCommit) {
|
|
102
|
+
throw new Error('Invalid audit message: missing required trailer eg-data-commit');
|
|
103
|
+
}
|
|
104
|
+
validateOid(dataCommit, 'dataCommit');
|
|
105
|
+
|
|
106
|
+
const opsDigest = trailers[TRAILER_KEYS.opsDigest];
|
|
107
|
+
if (!opsDigest) {
|
|
108
|
+
throw new Error('Invalid audit message: missing required trailer eg-ops-digest');
|
|
109
|
+
}
|
|
110
|
+
validateSha256(opsDigest, 'opsDigest');
|
|
111
|
+
|
|
112
|
+
const schemaStr = trailers[TRAILER_KEYS.schema];
|
|
113
|
+
if (!schemaStr) {
|
|
114
|
+
throw new Error('Invalid audit message: missing required trailer eg-schema');
|
|
115
|
+
}
|
|
116
|
+
if (!/^\d+$/.test(schemaStr)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Invalid audit message: eg-schema must be a positive integer, got '${schemaStr}'`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const schema = Number(schemaStr);
|
|
122
|
+
if (!Number.isInteger(schema) || schema < 1) {
|
|
123
|
+
throw new Error(`Invalid audit message: eg-schema must be a positive integer, got '${schemaStr}'`);
|
|
124
|
+
}
|
|
125
|
+
if (schema > 1) {
|
|
126
|
+
throw new Error(`Unsupported audit schema version: ${schema}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
kind: 'audit',
|
|
131
|
+
graph,
|
|
132
|
+
writer,
|
|
133
|
+
dataCommit,
|
|
134
|
+
opsDigest,
|
|
135
|
+
schema,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuditReceiptService — persistent, chained, tamper-evident audit receipts.
|
|
3
|
+
*
|
|
4
|
+
* When audit mode is enabled, each data commit produces a corresponding
|
|
5
|
+
* audit commit recording per-operation outcomes. Audit commits form an
|
|
6
|
+
* independent chain per (graphName, writerId) pair, linked via
|
|
7
|
+
* `prevAuditCommit` and Git commit parents.
|
|
8
|
+
*
|
|
9
|
+
* @module domain/services/AuditReceiptService
|
|
10
|
+
* @see docs/specs/AUDIT_RECEIPT.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildAuditRef } from '../utils/RefLayout.js';
|
|
14
|
+
import { encodeAuditMessage } from './AuditMessageCodec.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Constants
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Domain-separated prefix for opsDigest computation.
|
|
22
|
+
* The trailing \0 is a literal null byte (U+0000) acting as an
|
|
23
|
+
* unambiguous delimiter between the prefix and the JSON payload.
|
|
24
|
+
* @type {string}
|
|
25
|
+
*/
|
|
26
|
+
export const OPS_DIGEST_PREFIX = 'git-warp:opsDigest:v1\0';
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Normative Canonicalization Helpers (DO NOT ALTER — tied to spec Sections 5.2-5.3)
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* JSON.stringify replacer that sorts object keys lexicographically
|
|
34
|
+
* at every nesting level. Produces canonical JSON per spec Section 5.2.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} _key
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @returns {unknown}
|
|
39
|
+
*/
|
|
40
|
+
export function sortedReplacer(_key, value) {
|
|
41
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
42
|
+
const sorted = /** @type {Record<string, unknown>} */ ({});
|
|
43
|
+
const obj = /** @type {Record<string, unknown>} */ (value);
|
|
44
|
+
for (const k of Object.keys(obj).sort()) {
|
|
45
|
+
sorted[k] = obj[k];
|
|
46
|
+
}
|
|
47
|
+
return sorted;
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Produces canonical JSON string of an ops array per spec Section 5.2.
|
|
54
|
+
* Exported for testing.
|
|
55
|
+
*
|
|
56
|
+
* @param {ReadonlyArray<Readonly<import('../types/TickReceipt.js').OpOutcome>>} ops
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function canonicalOpsJson(ops) {
|
|
60
|
+
return JSON.stringify(ops, sortedReplacer);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @type {TextEncoder} */
|
|
64
|
+
const textEncoder = new TextEncoder();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Computes the domain-separated SHA-256 opsDigest per spec Section 5.3.
|
|
68
|
+
*
|
|
69
|
+
* @param {ReadonlyArray<Readonly<import('../types/TickReceipt.js').OpOutcome>>} ops
|
|
70
|
+
* @param {import('../../ports/CryptoPort.js').default} crypto - Crypto adapter
|
|
71
|
+
* @returns {Promise<string>} Lowercase hex SHA-256 digest
|
|
72
|
+
*/
|
|
73
|
+
export async function computeOpsDigest(ops, crypto) {
|
|
74
|
+
const json = canonicalOpsJson(ops);
|
|
75
|
+
const prefix = textEncoder.encode(OPS_DIGEST_PREFIX);
|
|
76
|
+
const payload = textEncoder.encode(json);
|
|
77
|
+
const combined = new Uint8Array(prefix.length + payload.length);
|
|
78
|
+
combined.set(prefix);
|
|
79
|
+
combined.set(payload, prefix.length);
|
|
80
|
+
return await crypto.hash('sha256', combined);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Receipt Construction
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/** @type {RegExp} */
|
|
88
|
+
const OID_HEX_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validates and builds a frozen receipt record with keys in sorted order.
|
|
92
|
+
*
|
|
93
|
+
* @param {Object} fields
|
|
94
|
+
* @param {number} fields.version
|
|
95
|
+
* @param {string} fields.graphName
|
|
96
|
+
* @param {string} fields.writerId
|
|
97
|
+
* @param {string} fields.dataCommit
|
|
98
|
+
* @param {number} fields.tickStart
|
|
99
|
+
* @param {number} fields.tickEnd
|
|
100
|
+
* @param {string} fields.opsDigest
|
|
101
|
+
* @param {string} fields.prevAuditCommit
|
|
102
|
+
* @param {number} fields.timestamp
|
|
103
|
+
* @returns {Readonly<Record<string, unknown>>}
|
|
104
|
+
* @throws {Error} If any field is invalid
|
|
105
|
+
*/
|
|
106
|
+
export function buildReceiptRecord(fields) {
|
|
107
|
+
const {
|
|
108
|
+
version, graphName, writerId, dataCommit,
|
|
109
|
+
tickStart, tickEnd, opsDigest, prevAuditCommit, timestamp,
|
|
110
|
+
} = fields;
|
|
111
|
+
|
|
112
|
+
// version
|
|
113
|
+
if (version !== 1) {
|
|
114
|
+
throw new Error(`Invalid version: must be 1, got ${version}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// graphName — validated by RefLayout
|
|
118
|
+
if (typeof graphName !== 'string' || graphName.length === 0) {
|
|
119
|
+
throw new Error('Invalid graphName: must be a non-empty string');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// writerId — validated by RefLayout
|
|
123
|
+
if (typeof writerId !== 'string' || writerId.length === 0) {
|
|
124
|
+
throw new Error('Invalid writerId: must be a non-empty string');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// dataCommit
|
|
128
|
+
const dc = dataCommit.toLowerCase();
|
|
129
|
+
if (!OID_HEX_PATTERN.test(dc)) {
|
|
130
|
+
throw new Error(`Invalid dataCommit OID: ${dataCommit}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// opsDigest
|
|
134
|
+
const od = opsDigest.toLowerCase();
|
|
135
|
+
if (!/^[0-9a-f]{64}$/.test(od)) {
|
|
136
|
+
throw new Error(`Invalid opsDigest: must be 64-char lowercase hex, got ${opsDigest}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// prevAuditCommit
|
|
140
|
+
const pac = prevAuditCommit.toLowerCase();
|
|
141
|
+
if (!OID_HEX_PATTERN.test(pac)) {
|
|
142
|
+
throw new Error(`Invalid prevAuditCommit OID: ${prevAuditCommit}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// OID length consistency
|
|
146
|
+
const oidLen = dc.length;
|
|
147
|
+
if (pac.length !== oidLen) {
|
|
148
|
+
throw new Error(`OID length mismatch: dataCommit=${dc.length}, prevAuditCommit=${pac.length}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// tick constraints
|
|
152
|
+
if (!Number.isInteger(tickStart) || tickStart < 1) {
|
|
153
|
+
throw new Error(`Invalid tickStart: must be integer >= 1, got ${tickStart}`);
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isInteger(tickEnd) || tickEnd < tickStart) {
|
|
156
|
+
throw new Error(`Invalid tickEnd: must be integer >= tickStart, got ${tickEnd}`);
|
|
157
|
+
}
|
|
158
|
+
if (version === 1 && tickStart !== tickEnd) {
|
|
159
|
+
throw new Error(`v1 requires tickStart === tickEnd, got ${tickStart} !== ${tickEnd}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Zero-hash sentinel only for genesis (tickStart === 1)
|
|
163
|
+
const zeroHash = '0'.repeat(oidLen);
|
|
164
|
+
if (pac === zeroHash && tickStart > 1) {
|
|
165
|
+
throw new Error('Non-genesis receipt cannot use zero-hash sentinel');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// timestamp
|
|
169
|
+
if (!Number.isInteger(timestamp) || timestamp < 0) {
|
|
170
|
+
throw new Error(`Invalid timestamp: must be non-negative safe integer, got ${timestamp}`);
|
|
171
|
+
}
|
|
172
|
+
if (!Number.isSafeInteger(timestamp)) {
|
|
173
|
+
throw new Error(`Invalid timestamp: exceeds Number.MAX_SAFE_INTEGER: ${timestamp}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Build with keys in sorted order (canonical for CBOR)
|
|
177
|
+
return Object.freeze({
|
|
178
|
+
dataCommit: dc,
|
|
179
|
+
graphName,
|
|
180
|
+
opsDigest: od,
|
|
181
|
+
prevAuditCommit: pac,
|
|
182
|
+
tickEnd,
|
|
183
|
+
tickStart,
|
|
184
|
+
timestamp,
|
|
185
|
+
version,
|
|
186
|
+
writerId,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ============================================================================
|
|
191
|
+
// Service
|
|
192
|
+
// ============================================================================
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* AuditReceiptService manages the audit receipt chain for a single writer.
|
|
196
|
+
*
|
|
197
|
+
* ## Lifecycle
|
|
198
|
+
* 1. Construct with dependencies
|
|
199
|
+
* 2. Call `init()` to read the current audit ref tip
|
|
200
|
+
* 3. Call `commit(tickReceipt)` after each data commit succeeds
|
|
201
|
+
*
|
|
202
|
+
* ## Error handling
|
|
203
|
+
* All errors are caught, logged with structured codes, and never propagated.
|
|
204
|
+
* The data commit has already succeeded — audit failures create gaps that
|
|
205
|
+
* are detectable by M4 verification.
|
|
206
|
+
*/
|
|
207
|
+
export class AuditReceiptService {
|
|
208
|
+
/**
|
|
209
|
+
* @param {Object} options
|
|
210
|
+
* @param {import('../../ports/RefPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default & import('../../ports/CommitPort.js').default} options.persistence
|
|
211
|
+
* @param {string} options.graphName
|
|
212
|
+
* @param {string} options.writerId
|
|
213
|
+
* @param {import('../../ports/CodecPort.js').default} options.codec
|
|
214
|
+
* @param {import('../../ports/CryptoPort.js').default} options.crypto
|
|
215
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger]
|
|
216
|
+
*/
|
|
217
|
+
constructor({ persistence, graphName, writerId, codec, crypto, logger }) {
|
|
218
|
+
this._persistence = persistence;
|
|
219
|
+
this._graphName = graphName;
|
|
220
|
+
this._writerId = writerId;
|
|
221
|
+
this._codec = codec;
|
|
222
|
+
this._crypto = crypto;
|
|
223
|
+
this._logger = logger || null;
|
|
224
|
+
this._auditRef = buildAuditRef(graphName, writerId);
|
|
225
|
+
|
|
226
|
+
/** @type {string|null} Previous audit commit SHA (null = genesis) */
|
|
227
|
+
this._prevAuditCommit = null;
|
|
228
|
+
|
|
229
|
+
/** @type {string|null} Expected old ref value for CAS (null = ref doesn't exist) */
|
|
230
|
+
this._expectedOldRef = null;
|
|
231
|
+
|
|
232
|
+
/** @type {boolean} If true, service is degraded — skip all commits */
|
|
233
|
+
this._degraded = false;
|
|
234
|
+
|
|
235
|
+
/** @type {boolean} If true, currently retrying — prevents recursive retry */
|
|
236
|
+
this._retrying = false;
|
|
237
|
+
|
|
238
|
+
// Stats
|
|
239
|
+
this._committed = 0;
|
|
240
|
+
this._skipped = 0;
|
|
241
|
+
this._failed = 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Initializes the service by reading the current audit ref tip.
|
|
246
|
+
* Must be called before `commit()`.
|
|
247
|
+
* @returns {Promise<void>}
|
|
248
|
+
*/
|
|
249
|
+
async init() {
|
|
250
|
+
try {
|
|
251
|
+
const tip = await this._persistence.readRef(this._auditRef);
|
|
252
|
+
if (tip) {
|
|
253
|
+
this._prevAuditCommit = tip;
|
|
254
|
+
this._expectedOldRef = tip;
|
|
255
|
+
// We don't know the tick counter from a cold start without walking the chain.
|
|
256
|
+
// Use 0 and let the first commit set it from the lamport clock.
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// Log so operators see unexpected cold starts, then start fresh
|
|
260
|
+
this._logger?.warn('[warp:audit]', {
|
|
261
|
+
code: 'AUDIT_INIT_READ_FAILED',
|
|
262
|
+
writerId: this._writerId,
|
|
263
|
+
ref: this._auditRef,
|
|
264
|
+
});
|
|
265
|
+
this._prevAuditCommit = null;
|
|
266
|
+
this._expectedOldRef = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Creates an audit commit for the given tick receipt.
|
|
272
|
+
*
|
|
273
|
+
* DESIGN NOTE: Data commit has already succeeded at this point.
|
|
274
|
+
* If audit commit fails, the data is persisted but the audit chain
|
|
275
|
+
* has a gap. This is acceptable by design in M3 — gaps are detected
|
|
276
|
+
* by M4 verification coverage rules (receipt count vs data commit count).
|
|
277
|
+
*
|
|
278
|
+
* @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
|
|
279
|
+
* @returns {Promise<string|null>} The audit commit SHA, or null on failure
|
|
280
|
+
*/
|
|
281
|
+
async commit(tickReceipt) {
|
|
282
|
+
if (this._degraded) {
|
|
283
|
+
this._skipped++;
|
|
284
|
+
this._logger?.warn('[warp:audit]', {
|
|
285
|
+
code: 'AUDIT_DEGRADED_ACTIVE',
|
|
286
|
+
writerId: this._writerId,
|
|
287
|
+
});
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
return await this._commitInner(tickReceipt);
|
|
293
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
294
|
+
this._failed++;
|
|
295
|
+
this._logger?.warn('[warp:audit]', {
|
|
296
|
+
code: 'AUDIT_COMMIT_FAILED',
|
|
297
|
+
writerId: this._writerId,
|
|
298
|
+
error: err?.message,
|
|
299
|
+
});
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Returns audit stats for coverage probing.
|
|
306
|
+
* @returns {{ committed: number, skipped: number, failed: number, degraded: boolean }}
|
|
307
|
+
*/
|
|
308
|
+
getStats() {
|
|
309
|
+
return {
|
|
310
|
+
committed: this._committed,
|
|
311
|
+
skipped: this._skipped,
|
|
312
|
+
failed: this._failed,
|
|
313
|
+
degraded: this._degraded,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Inner commit logic. Throws on failure (caught by `commit()`).
|
|
319
|
+
* @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
|
|
320
|
+
* @returns {Promise<string>}
|
|
321
|
+
* @private
|
|
322
|
+
*/
|
|
323
|
+
async _commitInner(tickReceipt) {
|
|
324
|
+
const { patchSha, writer, lamport, ops } = tickReceipt;
|
|
325
|
+
|
|
326
|
+
// Guard: reject cross-writer attribution
|
|
327
|
+
if (writer !== this._writerId) {
|
|
328
|
+
this._logger?.warn('[warp:audit]', {
|
|
329
|
+
code: 'AUDIT_WRITER_MISMATCH',
|
|
330
|
+
expected: this._writerId,
|
|
331
|
+
actual: writer,
|
|
332
|
+
patchSha,
|
|
333
|
+
});
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Audit writer mismatch: expected '${this._writerId}', got '${writer}'`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Compute opsDigest
|
|
340
|
+
const opsDigest = await computeOpsDigest(ops, this._crypto);
|
|
341
|
+
|
|
342
|
+
// Timestamp
|
|
343
|
+
const timestamp = Date.now();
|
|
344
|
+
|
|
345
|
+
// Determine prevAuditCommit
|
|
346
|
+
const oidLen = patchSha.length;
|
|
347
|
+
const prevAuditCommit = this._prevAuditCommit || '0'.repeat(oidLen);
|
|
348
|
+
|
|
349
|
+
// Build receipt record
|
|
350
|
+
const receipt = buildReceiptRecord({
|
|
351
|
+
version: 1,
|
|
352
|
+
graphName: this._graphName,
|
|
353
|
+
writerId: writer,
|
|
354
|
+
dataCommit: patchSha,
|
|
355
|
+
tickStart: lamport,
|
|
356
|
+
tickEnd: lamport,
|
|
357
|
+
opsDigest,
|
|
358
|
+
prevAuditCommit,
|
|
359
|
+
timestamp,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Encode to CBOR
|
|
363
|
+
const cborBytes = this._codec.encode(receipt);
|
|
364
|
+
|
|
365
|
+
// Write blob
|
|
366
|
+
let blobOid;
|
|
367
|
+
try {
|
|
368
|
+
blobOid = await this._persistence.writeBlob(Buffer.from(cborBytes));
|
|
369
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
370
|
+
this._logger?.warn('[warp:audit]', {
|
|
371
|
+
code: 'AUDIT_WRITE_BLOB_FAILED',
|
|
372
|
+
writerId: this._writerId,
|
|
373
|
+
error: err?.message,
|
|
374
|
+
});
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Write tree
|
|
379
|
+
let treeOid;
|
|
380
|
+
try {
|
|
381
|
+
treeOid = await this._persistence.writeTree([
|
|
382
|
+
`100644 blob ${blobOid}\treceipt.cbor`,
|
|
383
|
+
]);
|
|
384
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
|
|
385
|
+
this._logger?.warn('[warp:audit]', {
|
|
386
|
+
code: 'AUDIT_WRITE_TREE_FAILED',
|
|
387
|
+
writerId: this._writerId,
|
|
388
|
+
error: err?.message,
|
|
389
|
+
});
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Encode commit message with trailers
|
|
394
|
+
const message = encodeAuditMessage({
|
|
395
|
+
graph: this._graphName,
|
|
396
|
+
writer,
|
|
397
|
+
dataCommit: patchSha.toLowerCase(),
|
|
398
|
+
opsDigest,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Determine parents
|
|
402
|
+
const parents = this._prevAuditCommit ? [this._prevAuditCommit] : [];
|
|
403
|
+
|
|
404
|
+
// Create commit
|
|
405
|
+
const commitSha = await this._persistence.commitNodeWithTree({
|
|
406
|
+
treeOid,
|
|
407
|
+
parents,
|
|
408
|
+
message,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// CAS ref update
|
|
412
|
+
try {
|
|
413
|
+
await this._persistence.compareAndSwapRef(
|
|
414
|
+
this._auditRef,
|
|
415
|
+
commitSha,
|
|
416
|
+
this._expectedOldRef,
|
|
417
|
+
);
|
|
418
|
+
} catch {
|
|
419
|
+
if (this._retrying) {
|
|
420
|
+
// Second CAS failure during retry → degrade
|
|
421
|
+
throw new Error('CAS failed during retry');
|
|
422
|
+
}
|
|
423
|
+
// CAS mismatch — retry once with refreshed tip
|
|
424
|
+
return await this._retryAfterCasConflict(commitSha, tickReceipt);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Success — update cached state
|
|
428
|
+
this._prevAuditCommit = commitSha;
|
|
429
|
+
this._expectedOldRef = commitSha;
|
|
430
|
+
this._committed++;
|
|
431
|
+
return commitSha;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Retry-once after CAS conflict. Reads fresh tip, rebuilds receipt, retries.
|
|
436
|
+
* @param {string} _failedCommitSha - The commit that failed CAS (unused, for logging)
|
|
437
|
+
* @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
|
|
438
|
+
* @returns {Promise<string>}
|
|
439
|
+
* @private
|
|
440
|
+
*/
|
|
441
|
+
async _retryAfterCasConflict(_failedCommitSha, tickReceipt) {
|
|
442
|
+
this._logger?.warn('[warp:audit]', {
|
|
443
|
+
code: 'AUDIT_REF_CAS_CONFLICT',
|
|
444
|
+
writerId: this._writerId,
|
|
445
|
+
ref: this._auditRef,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Read fresh tip
|
|
449
|
+
const freshTip = await this._persistence.readRef(this._auditRef);
|
|
450
|
+
this._prevAuditCommit = freshTip;
|
|
451
|
+
this._expectedOldRef = freshTip;
|
|
452
|
+
|
|
453
|
+
// Rebuild and retry (with guard against recursive retry)
|
|
454
|
+
this._retrying = true;
|
|
455
|
+
try {
|
|
456
|
+
const result = await this._commitInner(tickReceipt);
|
|
457
|
+
return result;
|
|
458
|
+
} catch {
|
|
459
|
+
// Second failure → degraded mode
|
|
460
|
+
this._degraded = true;
|
|
461
|
+
this._logger?.warn('[warp:audit]', {
|
|
462
|
+
code: 'AUDIT_DEGRADED_ACTIVE',
|
|
463
|
+
writerId: this._writerId,
|
|
464
|
+
reason: 'second CAS failure',
|
|
465
|
+
});
|
|
466
|
+
throw new Error('Audit service degraded after second CAS failure');
|
|
467
|
+
} finally {
|
|
468
|
+
this._retrying = false;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|