backend-skeleton 1.1.1 → 1.3.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/README.md +127 -1
- package/bin/bskel.mjs +526 -20
- package/contracts/csv.mjs +100 -0
- package/handles/providers/java-spring/observe.mjs +15 -1
- package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +38 -0
- package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/ReceiptSigner.java.tmpl +174 -0
- package/handles/providers/python-fastapi/observe.mjs +5 -0
- package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +19 -0
- package/handles/providers/python-fastapi/templates/receipt_sign.py.tmpl +68 -0
- package/handles/providers/typescript-express/observe.mjs +5 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +19 -0
- package/handles/providers/typescript-express/templates/receiptSign.ts.tmpl +65 -0
- package/lib/cli.mjs +78 -2
- package/lib/doctor.mjs +11 -10
- package/lib/field-dependencies.mjs +2 -1
- package/lib/gate-definitions.mjs +8 -4
- package/lib/scan-report-paths.mjs +47 -0
- package/lib/workflow.mjs +6 -0
- package/new/index.mjs +20 -0
- package/package.json +5 -2
- package/patterns/schema.sql +18 -0
- package/patterns/store.mjs +122 -0
- package/scanners/adapters/_express-shared.mjs +7 -9
- package/scanners/adapters/java-spring.mjs +7 -9
- package/scanners/adapters/python-fastapi.mjs +68 -11
- package/scanners/db/erd.mjs +0 -0
- package/scanners/index.mjs +71 -18
- package/scanners/render.mjs +12 -3
- package/scanners/text-util.mjs +22 -0
- package/schemas/conformance-report.schema.json +12 -1
- package/schemas/observe-receipt.schema.json +10 -1
- package/schemas/pattern-record.schema.json +19 -0
- package/schemas/scan-report.schema.json +7 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// D-contract-csv: a spreadsheet-shaped projection of a feature contract -- one row per operation,
|
|
2
|
+
// opened by someone who will never read a JSON Schema. Pure module, no I/O, no gate awareness, no
|
|
3
|
+
// process -- mirrors contracts/export.mjs's own contract (see that file's header comment) exactly,
|
|
4
|
+
// so this file can be unit-tested with hand-built `contract` objects and nothing else.
|
|
5
|
+
//
|
|
6
|
+
// C1: a fixed 14-column header, always -- see C2 in DECISIONS.md for why a column is never
|
|
7
|
+
// dropped just because every operation leaves it blank (a scan-only contract's `summary`/`tags`/
|
|
8
|
+
// `security` columns ARE the finding "nobody stated these", not something to hide by omitting the
|
|
9
|
+
// column). C3: `description` is deliberately NOT a column (measured average 2,442.7 bytes/op,
|
|
10
|
+
// larger than every other copied field combined -- see D-openapi-description).
|
|
11
|
+
export const CSV_COLUMNS = Object.freeze([
|
|
12
|
+
{ name: 'operation_id', extract: (op, operationId) => operationId },
|
|
13
|
+
{ name: 'verb', extract: (op) => op.verb },
|
|
14
|
+
{ name: 'path', extract: (op) => op.path },
|
|
15
|
+
{ name: 'path_params', extract: (op) => sortedKeys(op.pathParams?.properties).join(', ') },
|
|
16
|
+
{ name: 'path_params_unverified', extract: (op) => (op.pathParamsHeuristic ?? []).join(', ') },
|
|
17
|
+
{ name: 'body', extract: (op) => String(op.body) },
|
|
18
|
+
{ name: 'request_body_required', extract: (op) => (op.requestBodyRequired == null ? '' : String(op.requestBodyRequired)) },
|
|
19
|
+
{ name: 'request_body_fields', extract: (op) => sortedKeys(op.requestBodySchema?.properties).join(', ') },
|
|
20
|
+
{ name: 'response_fields', extract: (op) => sortedKeys(op.responseSchema?.properties).join(', ') },
|
|
21
|
+
{ name: 'error_fields', extract: (op) => sortedKeys(op.errorSchema?.properties).join(', ') },
|
|
22
|
+
{ name: 'provenance', extract: (op) => op.provenance },
|
|
23
|
+
{ name: 'summary', extract: (op) => op.sourceSummary ?? '' },
|
|
24
|
+
{ name: 'tags', extract: (op) => (op.sourceTags ?? []).join(', ') },
|
|
25
|
+
{ name: 'security', extract: (op) => extractSecurity(op.sourceSecurity) },
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function sortedKeys(properties) {
|
|
29
|
+
return properties ? Object.keys(properties).sort() : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `security: []` is a genuine positive claim ("this operation declared no auth requirement") --
|
|
33
|
+
// see schemas/feature-contract.schema.json's own sourceSecurity description. An ABSENT
|
|
34
|
+
// sourceSecurity means the source document said nothing about security for this operation at all.
|
|
35
|
+
// The two must render distinguishably, or a reviewer cannot tell "confirmed open" from "unknown".
|
|
36
|
+
function extractSecurity(sourceSecurity) {
|
|
37
|
+
if (sourceSecurity == null) return '';
|
|
38
|
+
if (sourceSecurity.length === 0) return '(source-declared: none)';
|
|
39
|
+
const schemeNames = new Set();
|
|
40
|
+
for (const requirement of sourceSecurity) {
|
|
41
|
+
for (const scheme of Object.keys(requirement)) schemeNames.add(scheme);
|
|
42
|
+
}
|
|
43
|
+
return [...schemeNames].sort().join(', ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// C4: RFC 4180, hand-rolled -- quote a field iff it contains a double quote, comma, CR, or LF;
|
|
47
|
+
// an embedded double quote is escaped by doubling it. Deliberately does NOT quote a field that
|
|
48
|
+
// needs none of this (the negative case is what catches an over-eager escaper in tests). No new
|
|
49
|
+
// dependency: this is the entire rule CSV has needed since RFC 4180 (2005).
|
|
50
|
+
export function escapeCsvField(value) {
|
|
51
|
+
const str = String(value);
|
|
52
|
+
if (/["\n\r,]/.test(str)) {
|
|
53
|
+
return `"${str.replaceAll('"', '""')}"`;
|
|
54
|
+
}
|
|
55
|
+
return str;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// C4: LF line terminator (not RFC 4180's CRLF) -- every other artifact this project writes is
|
|
59
|
+
// LF, and these files get committed/diffed. No comment/provenance preamble: line 1 is always the
|
|
60
|
+
// header row, because a leading `#`/comment line breaks `pandas.read_csv`/`csv.DictReader`/every
|
|
61
|
+
// spreadsheet importer's default settings -- provenance goes to stderr/`--json`, never into the
|
|
62
|
+
// file itself.
|
|
63
|
+
export function toCsv(header, rows) {
|
|
64
|
+
const lines = [header, ...rows].map((row) => row.map(escapeCsvField).join(','));
|
|
65
|
+
return `${lines.join('\n')}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// C5: deliberately does NOT check gate state, does NOT read a scan report, does NOT look at
|
|
69
|
+
// path-prefix signals -- this is a pure projection of an already-loaded `contract` object. The
|
|
70
|
+
// caller (bin/bskel.mjs's cmdContractExportCsv) owns every refusal/warning decision; this
|
|
71
|
+
// function only ever succeeds or reports "there's nothing to export" for a genuinely empty
|
|
72
|
+
// contract.
|
|
73
|
+
export function buildContractCsv({ contract }) {
|
|
74
|
+
const operationIds = Object.keys(contract.operations).sort((a, b) => a.localeCompare(b));
|
|
75
|
+
if (operationIds.length === 0) {
|
|
76
|
+
return { ok: false, error: 'contract has zero operations' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const header = CSV_COLUMNS.map((c) => c.name);
|
|
80
|
+
const rows = operationIds.map((operationId) => {
|
|
81
|
+
const op = contract.operations[operationId];
|
|
82
|
+
return CSV_COLUMNS.map((c) => c.extract(op, operationId));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// C2: coverage is reported alongside the file, never inferred by a reader staring at blank
|
|
86
|
+
// cells wondering whether the tool is broken.
|
|
87
|
+
const columns = CSV_COLUMNS.map((c, i) => ({
|
|
88
|
+
name: c.name,
|
|
89
|
+
populatedRows: rows.filter((row) => row[i] !== '').length,
|
|
90
|
+
}));
|
|
91
|
+
const emptyColumns = columns.filter((c) => c.populatedRows === 0).map((c) => c.name);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
csv: toCsv(header, rows),
|
|
96
|
+
rowCount: rows.length,
|
|
97
|
+
columns,
|
|
98
|
+
emptyColumns,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -24,6 +24,10 @@ const INFRA_FILES = [
|
|
|
24
24
|
{ template: 'ContractCheck.java.tmpl', target: 'global/observe/ContractCheck.java' },
|
|
25
25
|
{ template: 'ObserveSchemaLoader.java.tmpl', target: 'global/observe/ObserveSchemaLoader.java' },
|
|
26
26
|
{ template: 'ContractObservationAspect.java.tmpl', target: 'global/observe/ContractObservationAspect.java' },
|
|
27
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): JDK-stdlib-only Ed25519
|
|
28
|
+
// signer, used by ContractObservationAspect -- see that template's own javadoc for why this is
|
|
29
|
+
// a hand-rolled canonicalizer, not a Jackson mapper feature.
|
|
30
|
+
{ template: 'ReceiptSigner.java.tmpl', target: 'global/observe/ReceiptSigner.java' },
|
|
27
31
|
];
|
|
28
32
|
|
|
29
33
|
function render(templatePath, vars) {
|
|
@@ -51,12 +55,21 @@ function writeUnit(target, content) {
|
|
|
51
55
|
export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePackage, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
52
56
|
const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
|
|
53
57
|
const jacksonPackage = detectJacksonPackage(repoRoot);
|
|
58
|
+
// D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 3's
|
|
59
|
+
// JsonNode has no #fields()/#fieldNames() at all (confirmed live via javap against real
|
|
60
|
+
// jackson-databind 3.1.5 -- only #properties(), a Set); Jackson 2's #fields() exists across
|
|
61
|
+
// every real version checked (2.14 through 2.21), but #properties() does NOT exist on the
|
|
62
|
+
// older ones (2.14 lacks it, 2.17+ has it) -- so the safe, version-spanning choice per major is
|
|
63
|
+
// #fields() for Jackson 2, #properties() for Jackson 3, never the other way around.
|
|
64
|
+
const jacksonFieldsOfImpl = jacksonPackage === 'tools.jackson.databind'
|
|
65
|
+
? 'return node.properties();'
|
|
66
|
+
: 'return () -> node.fields();';
|
|
54
67
|
|
|
55
68
|
const infraUnits = INFRA_FILES.map((f) => ({
|
|
56
69
|
id: f.template,
|
|
57
70
|
templatePath: path.join(TEMPLATES_DIR, f.template),
|
|
58
71
|
targetAbs: path.join(javaSrcRoot, f.target),
|
|
59
|
-
rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
|
|
72
|
+
rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage, JACKSON_FIELDS_OF_IMPL: jacksonFieldsOfImpl }),
|
|
60
73
|
}));
|
|
61
74
|
|
|
62
75
|
const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
@@ -89,6 +102,7 @@ export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePacka
|
|
|
89
102
|
'NOT done automatically: route the "bskel.observe.receipts" SLF4J logger to wherever you want receipt lines collected (a dedicated logback/log4j2 appender to a file, your existing log pipeline, etc.) -- bskel never edits your logging config. Point `bskel observe import --receipts <path>` at whatever that logger\'s output ends up as.',
|
|
90
103
|
`Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
|
|
91
104
|
'NOT done automatically: apply @ObserveContract(operationId = "...") to whichever existing controller/service methods you want observed -- nothing is annotated for you (D-resolver-scope: never guess which method implements which operation).',
|
|
105
|
+
'NOT done automatically: to sign receipts, set the `bskel.observe.signing-key-pem` Spring property (e.g. an env var via Spring\'s own relaxed binding: BSKEL_OBSERVE_SIGNING_KEY_PEM=...) to a PKCS#8 Ed25519 private key PEM -- `bskel attest keygen --out <dir>` already generates one in this exact format. Unset means every receipt stays unsigned (backward compatible). Verify with `bskel observe import --pubkey <path/to/attest-public.pem>`.',
|
|
92
106
|
],
|
|
93
107
|
};
|
|
94
108
|
}
|
|
@@ -5,11 +5,13 @@ import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
|
5
5
|
import {{JACKSON_PACKAGE}}.node.ArrayNode;
|
|
6
6
|
import {{JACKSON_PACKAGE}}.node.ObjectNode;
|
|
7
7
|
import {{BASE_PACKAGE}}.global.observe.ObserveSchemaLoader.ObservedOperation;
|
|
8
|
+
import jakarta.annotation.PostConstruct;
|
|
8
9
|
import lombok.RequiredArgsConstructor;
|
|
9
10
|
import lombok.extern.slf4j.Slf4j;
|
|
10
11
|
import org.aspectj.lang.ProceedingJoinPoint;
|
|
11
12
|
import org.aspectj.lang.annotation.Around;
|
|
12
13
|
import org.aspectj.lang.annotation.Aspect;
|
|
14
|
+
import org.springframework.beans.factory.annotation.Value;
|
|
13
15
|
import org.springframework.http.ResponseEntity;
|
|
14
16
|
import org.springframework.stereotype.Component;
|
|
15
17
|
import org.springframework.web.bind.annotation.RequestBody;
|
|
@@ -49,6 +51,10 @@ import java.util.Map;
|
|
|
49
51
|
* deliberately deferred, same unexamined assumption {@code HandleAspect} already carries for
|
|
50
52
|
* {@code @Around} advice generally. See DECISIONS.md D-runtime-conformance-receipts.
|
|
51
53
|
*
|
|
54
|
+
* <p>Optionally signs each receipt (Ed25519, via {@link ReceiptSigner}) when {@code
|
|
55
|
+
* bskel.observe.signing-key-pem} is set -- see that class's own javadoc. Unset means every
|
|
56
|
+
* receipt stays unsigned, exactly like before this capability existed.
|
|
57
|
+
*
|
|
52
58
|
* <p>Generated by backend-skeleton ({@code bskel observe emit}). Requires {@code
|
|
53
59
|
* spring-boot-starter-aop} on the classpath -- see {@link ObserveContract}'s own javadoc.
|
|
54
60
|
*/
|
|
@@ -63,6 +69,21 @@ public class ContractObservationAspect {
|
|
|
63
69
|
private final ObserveSchemaLoader schemaLoader;
|
|
64
70
|
private final ObjectMapper objectMapper;
|
|
65
71
|
|
|
72
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): a plain, non-final,
|
|
73
|
+
// separately field-injected member -- NOT routed through the @RequiredArgsConstructor-generated
|
|
74
|
+
// constructor above. @Value on a `final` constructor-injected field alongside
|
|
75
|
+
// @RequiredArgsConstructor is a documented Lombok/Spring interop gap (the generated constructor
|
|
76
|
+
// does not reliably propagate the annotation); this is the standard, real-world-safe pattern for
|
|
77
|
+
// @Value-injected simple config even in classes that otherwise use constructor injection.
|
|
78
|
+
// Empty (the default) means every receipt stays unsigned -- see ReceiptSigner's own javadoc.
|
|
79
|
+
@Value("${bskel.observe.signing-key-pem:}")
|
|
80
|
+
private String signingKeyPem;
|
|
81
|
+
|
|
82
|
+
@PostConstruct
|
|
83
|
+
private void configureReceiptSigning() {
|
|
84
|
+
ReceiptSigner.configure(signingKeyPem);
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
@Around("@annotation(observeContract)")
|
|
67
88
|
public Object observe(ProceedingJoinPoint joinPoint, ObserveContract observeContract) throws Throwable {
|
|
68
89
|
String operationId = observeContract.operationId();
|
|
@@ -159,9 +180,26 @@ public class ContractObservationAspect {
|
|
|
159
180
|
vn.put("keyword", v.keyword());
|
|
160
181
|
vn.put("message", v.message());
|
|
161
182
|
}
|
|
183
|
+
safelySign(receipt);
|
|
162
184
|
safelyLog(receipt);
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): its OWN inner try/catch,
|
|
188
|
+
// separate from safelyLog()'s -- a signing failure (bad/missing key config, malformed PEM) must
|
|
189
|
+
// fall back to logging the receipt UNSIGNED, not silently drop the whole receipt the way sharing
|
|
190
|
+
// safelyLog()'s catch block would.
|
|
191
|
+
private void safelySign(ObjectNode receipt) {
|
|
192
|
+
if (!ReceiptSigner.isConfigured()) return;
|
|
193
|
+
try {
|
|
194
|
+
String signatureValue = ReceiptSigner.sign(receipt);
|
|
195
|
+
ObjectNode signature = receipt.putObject("signature");
|
|
196
|
+
signature.put("algorithm", "ed25519");
|
|
197
|
+
signature.put("value", signatureValue);
|
|
198
|
+
} catch (Exception e) {
|
|
199
|
+
log.warn("ContractObservationAspect: could not sign a receipt -- logging it unsigned instead", e);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
165
203
|
private void safelyLog(Object receipt) {
|
|
166
204
|
try {
|
|
167
205
|
RECEIPTS.info(objectMapper.writeValueAsString(receipt));
|
|
@@ -11,7 +11,6 @@ import java.io.IOException;
|
|
|
11
11
|
import java.io.InputStream;
|
|
12
12
|
import java.util.ArrayList;
|
|
13
13
|
import java.util.HashMap;
|
|
14
|
-
import java.util.Iterator;
|
|
15
14
|
import java.util.List;
|
|
16
15
|
import java.util.Map;
|
|
17
16
|
import java.util.regex.Pattern;
|
|
@@ -69,9 +68,7 @@ public class ObserveSchemaLoader {
|
|
|
69
68
|
String featureId = root.path("feature_id").asText(null);
|
|
70
69
|
String featureUid = root.path("feature_uid").asText(null);
|
|
71
70
|
String contractRef = root.path("contract_ref").asText(null);
|
|
72
|
-
|
|
73
|
-
while (ops.hasNext()) {
|
|
74
|
-
Map.Entry<String, JsonNode> entry = ops.next();
|
|
71
|
+
for (Map.Entry<String, JsonNode> entry : fieldsOf(root.path("operations"))) {
|
|
75
72
|
String operationId = entry.getKey();
|
|
76
73
|
JsonNode op = entry.getValue();
|
|
77
74
|
String body = op.path("body").asText("unknown");
|
|
@@ -99,9 +96,7 @@ public class ObserveSchemaLoader {
|
|
|
99
96
|
List<String> required = parseStringList(node.path("required"));
|
|
100
97
|
List<String> unsupported = new ArrayList<>(parseStringList(node.path("unsupported")));
|
|
101
98
|
Map<String, ObservedProperty> properties = new HashMap<>();
|
|
102
|
-
|
|
103
|
-
while (props.hasNext()) {
|
|
104
|
-
Map.Entry<String, JsonNode> entry = props.next();
|
|
99
|
+
for (Map.Entry<String, JsonNode> entry : fieldsOf(node.path("properties"))) {
|
|
105
100
|
JsonNode propSchema = entry.getValue();
|
|
106
101
|
String type = propSchema.path("type").asText(null);
|
|
107
102
|
String patternText = propSchema.has("pattern") ? propSchema.get("pattern").asText() : null;
|
|
@@ -119,6 +114,17 @@ public class ObserveSchemaLoader {
|
|
|
119
114
|
return new ObservedObject(required, properties, unsupported);
|
|
120
115
|
}
|
|
121
116
|
|
|
117
|
+
// D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 2's
|
|
118
|
+
// JsonNode#fields() (an Iterator) does not exist on Jackson 3's JsonNode at all -- Jackson 3
|
|
119
|
+
// replaced it with #properties() (a Set, no Iterator wrapper needed). Neither API exists on
|
|
120
|
+
// EVERY version of the other major (older Jackson 2.x, e.g. 2.14, has no #properties() either),
|
|
121
|
+
// so this can't be unified into one call safely -- {{JACKSON_PACKAGE}} already tells us which
|
|
122
|
+
// major is on this target's real classpath (detectJacksonPackage() in emit.mjs), so that same
|
|
123
|
+
// signal picks the one real, correct implementation body at emit time.
|
|
124
|
+
private static Iterable<Map.Entry<String, JsonNode>> fieldsOf(JsonNode node) {
|
|
125
|
+
{{JACKSON_FIELDS_OF_IMPL}}
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
private static List<String> parseStringList(JsonNode node) {
|
|
123
129
|
List<String> values = new ArrayList<>();
|
|
124
130
|
if (node != null && node.isArray()) {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.observe;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.JsonNode;
|
|
4
|
+
import {{JACKSON_PACKAGE}}.node.ObjectNode;
|
|
5
|
+
|
|
6
|
+
import java.nio.charset.StandardCharsets;
|
|
7
|
+
import java.security.KeyFactory;
|
|
8
|
+
import java.security.PrivateKey;
|
|
9
|
+
import java.security.Signature;
|
|
10
|
+
import java.security.spec.PKCS8EncodedKeySpec;
|
|
11
|
+
import java.util.ArrayList;
|
|
12
|
+
import java.util.Base64;
|
|
13
|
+
import java.util.Collections;
|
|
14
|
+
import java.util.List;
|
|
15
|
+
import java.util.Map;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* D-runtime-conformance-receipts (cryptographic receipt attestation): signs a receipt at emission
|
|
19
|
+
* time so {@code bskel observe import --pubkey <path>} can prove it genuinely came from this
|
|
20
|
+
* running app, not a hand-fabricated file. JDK-stdlib-only ({@link Signature}, Ed25519 -- standard
|
|
21
|
+
* since JDK 15) -- zero new dependency, matching this project's own JDK 17 floor.
|
|
22
|
+
*
|
|
23
|
+
* <p>Canonicalization must be byte-identical to {@code bskel observe import}'s own verification
|
|
24
|
+
* side (Node's {@code lib/attest.mjs} {@code canonicalize()}: deep-sorted keys, compact JSON,
|
|
25
|
+
* non-ASCII left unescaped) -- proven cross-language-compatible by direct execution against a real
|
|
26
|
+
* receipt containing a forward slash, a quote, and non-ASCII text (see DECISIONS.md
|
|
27
|
+
* D-runtime-conformance-receipts). Jackson's own {@code MapperFeature.SORT_PROPERTIES_ALPHABETICALLY}
|
|
28
|
+
* does NOT sort an {@link ObjectNode}'s own tree-model field order -- confirmed live, it only
|
|
29
|
+
* affects bean/POJO introspection -- so this class hand-walks the {@link JsonNode} tree instead of
|
|
30
|
+
* relying on a mapper feature that would silently no-op.
|
|
31
|
+
*
|
|
32
|
+
* <p>The private key is never baked into generated source (that would commit a secret to the
|
|
33
|
+
* repo) -- see {@link #configure(String)}'s own javadoc for how a human wires this in.
|
|
34
|
+
*/
|
|
35
|
+
public final class ReceiptSigner {
|
|
36
|
+
|
|
37
|
+
private static volatile PrivateKey privateKey;
|
|
38
|
+
|
|
39
|
+
private ReceiptSigner() {
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* NOT called by any generated code -- a human calls this once, themselves, at application
|
|
44
|
+
* startup (see {@code ContractObservationAspect}'s own {@code @PostConstruct} wiring), passing a
|
|
45
|
+
* PKCS#8 PEM private key string -- the exact format {@code bskel attest keygen} already writes.
|
|
46
|
+
* Never hardcode a real key value in source; read it from wherever you already keep secrets (an
|
|
47
|
+
* env var, a mounted file, a secrets manager). Unconfigured (the default) means every receipt
|
|
48
|
+
* stays unsigned -- backward compatible with every already-deployed app using this feature
|
|
49
|
+
* before signing existed.
|
|
50
|
+
*/
|
|
51
|
+
public static void configure(String privateKeyPem) {
|
|
52
|
+
privateKey = (privateKeyPem == null || privateKeyPem.isBlank()) ? null : parsePkcs8(privateKeyPem);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static boolean isConfigured() {
|
|
56
|
+
return privateKey != null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass a node
|
|
61
|
+
* that does not yet carry a {@code "signature"} field (this class never strips one itself --
|
|
62
|
+
* {@code ContractObservationAspect} only ever calls this before adding one).
|
|
63
|
+
*/
|
|
64
|
+
static String sign(ObjectNode receiptWithoutSignature) {
|
|
65
|
+
try {
|
|
66
|
+
Signature signer = Signature.getInstance("Ed25519");
|
|
67
|
+
signer.initSign(privateKey);
|
|
68
|
+
signer.update(canonicalize(receiptWithoutSignature).getBytes(StandardCharsets.UTF_8));
|
|
69
|
+
return Base64.getEncoder().encodeToString(signer.sign());
|
|
70
|
+
} catch (Exception e) {
|
|
71
|
+
throw new IllegalStateException("could not sign receipt", e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private static PrivateKey parsePkcs8(String pem) {
|
|
76
|
+
try {
|
|
77
|
+
String base64 = pem
|
|
78
|
+
.replace("-----BEGIN PRIVATE KEY-----", "")
|
|
79
|
+
.replace("-----END PRIVATE KEY-----", "")
|
|
80
|
+
.replaceAll("\\s", "");
|
|
81
|
+
byte[] der = Base64.getDecoder().decode(base64);
|
|
82
|
+
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
|
|
83
|
+
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der));
|
|
84
|
+
} catch (Exception e) {
|
|
85
|
+
throw new IllegalArgumentException("not a usable PKCS#8 Ed25519 private key PEM", e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Recursively sorts every object's field names, preserves array order (matching lib/gates.mjs's
|
|
90
|
+
// own sortKeysDeep() semantics exactly on the Node verification side), emits compact JSON by
|
|
91
|
+
// hand. The schema this signs has exactly one numeric field (a plain integer HTTP status) and
|
|
92
|
+
// nothing else numeric anywhere -- no float-formatting cross-language risk exists here.
|
|
93
|
+
static String canonicalize(JsonNode node) {
|
|
94
|
+
StringBuilder sb = new StringBuilder();
|
|
95
|
+
canonicalizeInto(node, sb);
|
|
96
|
+
return sb.toString();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private static void canonicalizeInto(JsonNode node, StringBuilder sb) {
|
|
100
|
+
if (node == null || node.isNull()) {
|
|
101
|
+
sb.append("null");
|
|
102
|
+
} else if (node.isObject()) {
|
|
103
|
+
List<String> names = new ArrayList<>();
|
|
104
|
+
for (Map.Entry<String, JsonNode> entry : fieldsOf(node)) {
|
|
105
|
+
names.add(entry.getKey());
|
|
106
|
+
}
|
|
107
|
+
Collections.sort(names);
|
|
108
|
+
sb.append('{');
|
|
109
|
+
for (int i = 0; i < names.size(); i++) {
|
|
110
|
+
if (i > 0) sb.append(',');
|
|
111
|
+
sb.append(jsonString(names.get(i))).append(':');
|
|
112
|
+
canonicalizeInto(node.get(names.get(i)), sb);
|
|
113
|
+
}
|
|
114
|
+
sb.append('}');
|
|
115
|
+
} else if (node.isArray()) {
|
|
116
|
+
sb.append('[');
|
|
117
|
+
for (int i = 0; i < node.size(); i++) {
|
|
118
|
+
if (i > 0) sb.append(',');
|
|
119
|
+
canonicalizeInto(node.get(i), sb);
|
|
120
|
+
}
|
|
121
|
+
sb.append(']');
|
|
122
|
+
} else if (node.isTextual()) {
|
|
123
|
+
sb.append(jsonString(node.asText()));
|
|
124
|
+
} else if (node.isBoolean()) {
|
|
125
|
+
sb.append(node.asBoolean());
|
|
126
|
+
} else if (node.isNumber()) {
|
|
127
|
+
sb.append(node.numberValue());
|
|
128
|
+
} else {
|
|
129
|
+
sb.append(jsonString(node.asText()));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 2's
|
|
134
|
+
// JsonNode#fields() (an Iterator) does not exist on Jackson 3's JsonNode at all -- Jackson 3
|
|
135
|
+
// replaced it with #properties() (a Set, no Iterator wrapper needed). Neither API exists on
|
|
136
|
+
// EVERY version of the other major (older Jackson 2.x, e.g. 2.14, has no #properties() either),
|
|
137
|
+
// so this can't be unified into one call safely -- {{JACKSON_PACKAGE}} already tells us which
|
|
138
|
+
// major is on this target's real classpath (detectJacksonPackage() in emit.mjs), so that same
|
|
139
|
+
// signal picks the one real, correct implementation body at emit time. Mirrors
|
|
140
|
+
// ObserveSchemaLoader's own identically-named, identically-reasoned helper exactly.
|
|
141
|
+
private static Iterable<Map.Entry<String, JsonNode>> fieldsOf(JsonNode node) {
|
|
142
|
+
{{JACKSON_FIELDS_OF_IMPL}}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Standard JSON string escaping (RFC 8259) -- the same set Node's JSON.stringify and Python's
|
|
146
|
+
// json.dumps(ensure_ascii=False) both already apply, proven byte-identical live. Non-ASCII is
|
|
147
|
+
// deliberately left UNESCAPED to match (ensure_ascii=False on the Python side is the analogous,
|
|
148
|
+
// load-bearing requirement there -- Python's own json.dumps default would otherwise diverge).
|
|
149
|
+
private static String jsonString(String s) {
|
|
150
|
+
StringBuilder sb = new StringBuilder(s.length() + 2);
|
|
151
|
+
sb.append('"');
|
|
152
|
+
for (int i = 0; i < s.length(); i++) {
|
|
153
|
+
char c = s.charAt(i);
|
|
154
|
+
switch (c) {
|
|
155
|
+
case '"' -> sb.append("\\\"");
|
|
156
|
+
case '\\' -> sb.append("\\\\");
|
|
157
|
+
case '\b' -> sb.append("\\b");
|
|
158
|
+
case '\f' -> sb.append("\\f");
|
|
159
|
+
case '\n' -> sb.append("\\n");
|
|
160
|
+
case '\r' -> sb.append("\\r");
|
|
161
|
+
case '\t' -> sb.append("\\t");
|
|
162
|
+
default -> {
|
|
163
|
+
if (c < 0x20) {
|
|
164
|
+
sb.append(String.format("\\u%04x", (int) c));
|
|
165
|
+
} else {
|
|
166
|
+
sb.append(c);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
sb.append('"');
|
|
172
|
+
return sb.toString();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -26,6 +26,10 @@ const INFRA_FILES = [
|
|
|
26
26
|
{ template: 'observed_schema.py.tmpl', target: 'observed_schema.py' },
|
|
27
27
|
{ template: 'contract_check.py.tmpl', target: 'contract_check.py' },
|
|
28
28
|
{ template: 'observe_contract.py.tmpl', target: 'observe_contract.py' },
|
|
29
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): Ed25519 signer used by
|
|
30
|
+
// observe_contract.py -- imports the `cryptography` package lazily, only if actually configured
|
|
31
|
+
// with a real key (see receipt_sign.py's own docstring for why).
|
|
32
|
+
{ template: 'receipt_sign.py.tmpl', target: 'receipt_sign.py' },
|
|
29
33
|
];
|
|
30
34
|
|
|
31
35
|
function render(templatePath, vars) {
|
|
@@ -92,6 +96,7 @@ export function emitObservePythonFastApi({ repoRoot, featureId, contract, plan,
|
|
|
92
96
|
'NOT done automatically: route the "bskel.observe.receipts" logger (Python\'s standard logging module) to wherever you want receipt lines collected (a dedicated handler to a file, your existing log pipeline, etc.) -- bskel never edits your logging config. Point `bskel observe import --receipts <path>` at whatever that logger\'s output ends up as.',
|
|
93
97
|
`Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
|
|
94
98
|
'NOT done automatically: apply @observe_contract(operation_id="...") to whichever existing route handlers you want observed -- nothing is decorated for you (D-resolver-scope: never guess which function implements which operation). For a request body to be checked, also pass body_param="<the argument name>" explicitly -- Python has no @RequestBody-equivalent marker to infer it from.',
|
|
99
|
+
'NOT done automatically: to sign receipts, call receipt_sign.configure(os.environ.get("BSKEL_OBSERVE_SIGNING_KEY_PEM")) yourself at application startup, with a PKCS#8 Ed25519 private key PEM -- `bskel attest keygen --out <dir>` already generates one in this exact format. This also requires `pip install cryptography` (Python\'s stdlib has no Ed25519 signing -- receipt_sign.py imports it lazily, only when configure() is actually called with a real key). Unconfigured means every receipt stays unsigned (backward compatible). Verify with `bskel observe import --pubkey <path/to/attest-public.pem>`.',
|
|
95
100
|
],
|
|
96
101
|
};
|
|
97
102
|
}
|
|
@@ -32,6 +32,10 @@ return value transparently, unmodified, even without inspecting it) -- FastAPI r
|
|
|
32
32
|
commonly `async def`, so getting this wrong breaks production traffic, not just the conformance
|
|
33
33
|
check.
|
|
34
34
|
|
|
35
|
+
Optionally signs each receipt (Ed25519, via receipt_sign.py) once `receipt_sign.configure(...)`
|
|
36
|
+
has been called with a real key -- see that module's own docstring. Unconfigured means every
|
|
37
|
+
receipt stays unsigned, exactly like before this capability existed.
|
|
38
|
+
|
|
35
39
|
Example:
|
|
36
40
|
@observe_contract(operation_id="items-read_item")
|
|
37
41
|
async def read_item(session: SessionDep, id: str):
|
|
@@ -48,6 +52,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
48
52
|
|
|
49
53
|
from . import contract_check
|
|
50
54
|
from . import observed_schema
|
|
55
|
+
from . import receipt_sign
|
|
51
56
|
|
|
52
57
|
logger = logging.getLogger(__name__)
|
|
53
58
|
_RECEIPTS = logging.getLogger("bskel.observe.receipts")
|
|
@@ -102,11 +107,25 @@ def _emit_receipt(op: dict, operation_id: str, request_violations: list, respons
|
|
|
102
107
|
receipt["status"] = status
|
|
103
108
|
if error_class is not None:
|
|
104
109
|
receipt["error_class"] = error_class
|
|
110
|
+
_sign_receipt(receipt)
|
|
105
111
|
_RECEIPTS.info(json.dumps(receipt))
|
|
106
112
|
except Exception:
|
|
107
113
|
logger.warning("observe_contract: could not emit a receipt -- the wrapped call already returned unaffected", exc_info=True)
|
|
108
114
|
|
|
109
115
|
|
|
116
|
+
def _sign_receipt(receipt: dict) -> None:
|
|
117
|
+
# D-runtime-conformance-receipts (cryptographic receipt attestation): its OWN inner try/except,
|
|
118
|
+
# separate from _emit_receipt()'s outer one -- a signing failure (bad/missing key config,
|
|
119
|
+
# malformed PEM) must fall back to logging the receipt UNSIGNED, not silently drop the whole
|
|
120
|
+
# receipt the way sharing the outer except block would.
|
|
121
|
+
if not receipt_sign.is_configured():
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
receipt["signature"] = {"algorithm": "ed25519", "value": receipt_sign.sign(receipt)}
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.warning("observe_contract: could not sign a receipt -- logging it unsigned instead", exc_info=True)
|
|
127
|
+
|
|
128
|
+
|
|
110
129
|
def observe_contract(*, operation_id: str, body_param: str | None = None):
|
|
111
130
|
def decorator(fn):
|
|
112
131
|
signature = inspect.signature(fn)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
|
|
2
|
+
template and regenerate.
|
|
3
|
+
|
|
4
|
+
D-runtime-conformance-receipts (cryptographic receipt attestation): signs a receipt at emission
|
|
5
|
+
time so `bskel observe import --pubkey <path>` can prove it genuinely came from this running app,
|
|
6
|
+
not a hand-fabricated file. Uses the `cryptography` package's Ed25519 primitives -- Python's
|
|
7
|
+
stdlib has no Ed25519 signing at all, so this is a genuinely new, first-of-its-kind third-party
|
|
8
|
+
dependency for this provider's generated runtime code (same honest framing this project already
|
|
9
|
+
gave `pg`, its own first-ever database dependency -- see DECISIONS.md
|
|
10
|
+
D-runtime-conformance-receipts). The import is deliberately LAZY (inside configure(), not at
|
|
11
|
+
module level) so this module -- and observe_contract.py, which imports it unconditionally --
|
|
12
|
+
stays importable even when `cryptography` is not installed, as long as signing is never
|
|
13
|
+
configured. Only pip-install `cryptography` if you actually call configure() with a real key.
|
|
14
|
+
|
|
15
|
+
Canonicalization must be byte-identical to `bskel observe import`'s own verification side (Node's
|
|
16
|
+
`lib/attest.mjs` canonicalize(): deep-sorted keys, compact JSON, non-ASCII left unescaped) --
|
|
17
|
+
`json.dumps(receipt, sort_keys=True, separators=(",", ":"), ensure_ascii=False)` is exactly that,
|
|
18
|
+
proven cross-language-compatible by direct execution against a real receipt containing a forward
|
|
19
|
+
slash, a quote, and non-ASCII text (see DECISIONS.md D-runtime-conformance-receipts).
|
|
20
|
+
`ensure_ascii=False` is load-bearing -- Python's own json.dumps default (True) escapes non-ASCII
|
|
21
|
+
as `\\uXXXX`, which neither Node's JSON.stringify nor Jackson's default writer do; without this
|
|
22
|
+
flag, signatures would never cross-verify on any receipt whose message contains non-ASCII text.
|
|
23
|
+
|
|
24
|
+
The private key is never baked into generated source (that would commit a secret to the repo) --
|
|
25
|
+
see configure()'s own docstring for how a human wires this in.
|
|
26
|
+
"""
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
|
|
30
|
+
_private_key = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configure(private_key_pem):
|
|
34
|
+
"""NOT called by any generated code -- a human calls this once, themselves, at application
|
|
35
|
+
startup (e.g. `receipt_sign.configure(os.environ.get("BSKEL_OBSERVE_SIGNING_KEY_PEM"))`),
|
|
36
|
+
passing a PKCS#8 PEM private key string -- the exact format `bskel attest keygen` already
|
|
37
|
+
writes. Never hardcode a real key value in source; read it from wherever you already keep
|
|
38
|
+
secrets (an env var, a mounted file, a secrets manager). Unconfigured (the default, including
|
|
39
|
+
a None/blank string) means every receipt stays unsigned -- backward compatible with every
|
|
40
|
+
already-deployed app using this feature before signing existed.
|
|
41
|
+
"""
|
|
42
|
+
global _private_key
|
|
43
|
+
if not private_key_pem or not private_key_pem.strip():
|
|
44
|
+
_private_key = None
|
|
45
|
+
return
|
|
46
|
+
from cryptography.hazmat.primitives import serialization
|
|
47
|
+
|
|
48
|
+
_private_key = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_configured():
|
|
52
|
+
return _private_key is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def canonicalize(receipt_without_signature):
|
|
56
|
+
# The schema this signs has exactly one numeric field (a plain integer HTTP status) and
|
|
57
|
+
# nothing else numeric anywhere -- no float-formatting cross-language risk exists here.
|
|
58
|
+
return json.dumps(
|
|
59
|
+
receipt_without_signature, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
60
|
+
).encode("utf-8")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sign(receipt_without_signature):
|
|
64
|
+
"""Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass a
|
|
65
|
+
dict that does not yet carry a "signature" key -- this module never strips one itself.
|
|
66
|
+
"""
|
|
67
|
+
signature_bytes = _private_key.sign(canonicalize(receipt_without_signature))
|
|
68
|
+
return base64.b64encode(signature_bytes).decode("ascii")
|
|
@@ -28,6 +28,10 @@ const INFRA_FILES = [
|
|
|
28
28
|
{ template: 'contractCheck.ts.tmpl', target: 'contractCheck.ts' },
|
|
29
29
|
{ template: 'observedSchema.ts.tmpl', target: 'observedSchema.ts' },
|
|
30
30
|
{ template: 'observeContract.ts.tmpl', target: 'observeContract.ts' },
|
|
31
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): node:crypto-only Ed25519
|
|
32
|
+
// signer used by observeContract.ts -- a self-contained port of lib/attest.mjs, never imported
|
|
33
|
+
// directly (this runs inside a deployed target app, a foreign process from this CLI's own).
|
|
34
|
+
{ template: 'receiptSign.ts.tmpl', target: 'receiptSign.ts' },
|
|
31
35
|
];
|
|
32
36
|
|
|
33
37
|
function render(templatePath, vars) {
|
|
@@ -97,6 +101,7 @@ export function emitObserveTypeScriptExpress({ repoRoot, featureId, contract, pl
|
|
|
97
101
|
'error_class is never populated in this provider\'s receipts (always omitted) -- Express middleware runs BEFORE the route handler and is structurally unable to observe a thrown error the way java\'s @Around/python\'s except block can (by the time a handler throws or calls next(err), this middleware\'s own call frame has already returned). See DECISIONS.md D-runtime-conformance-receipts.',
|
|
98
102
|
'Response-body checking only covers a handler that calls res.json(...) or res.send(<object>) (Express\'s own res.send delegates to res.json for a plain-object body) -- a handler that calls res.send(<string>)/res.end(...) directly, or whose response is produced by Express\'s own default/generic error handler, has its response check silently skipped, never guessed.',
|
|
99
103
|
'OpenAPI reconciliation for this adapter matches scanned Express route strings EXACTLY against the OpenAPI document\'s own path keys (contracts/openapi.mjs has no ":id" <-> "{id}" translation) -- a real, standards-compliant OpenAPI document (which must use "{id}") will not match a scanned ":id"/":id([0-9]+)" route unless the document\'s own path key happens to already read that way. Unlike python-fastapi, this is not "for free."',
|
|
104
|
+
'NOT done automatically: to sign receipts, call setSigningKey(pem) yourself at application startup (import { setSigningKey } from \'./observe/receiptSign\';), with a PKCS#8 Ed25519 private key PEM -- `bskel attest keygen --out <dir>` already generates one in this exact format. Unconfigured means every receipt stays unsigned (backward compatible). Verify with `bskel observe import --pubkey <path/to/attest-public.pem>`.',
|
|
100
105
|
],
|
|
101
106
|
};
|
|
102
107
|
}
|
|
@@ -34,12 +34,17 @@
|
|
|
34
34
|
// (req.body is Express's own unambiguous body once body-parsing middleware has run -- no
|
|
35
35
|
// python-style explicit body_param argument needed here).
|
|
36
36
|
//
|
|
37
|
+
// Optionally signs each receipt (Ed25519, via receiptSign.ts) once setSigningKey() has been
|
|
38
|
+
// called with a real key -- see that module's own doc comment. Unconfigured means every
|
|
39
|
+
// receipt stays unsigned, exactly like before this capability existed.
|
|
40
|
+
//
|
|
37
41
|
// Example:
|
|
38
42
|
// router.get('/users/:id', [checkJwt, observeContract('users-show')], showUser);
|
|
39
43
|
|
|
40
44
|
import type { RequestHandler, Request, Response } from 'express';
|
|
41
45
|
import * as contractCheck from './contractCheck';
|
|
42
46
|
import * as observedSchema from './observedSchema';
|
|
47
|
+
import * as receiptSign from './receiptSign';
|
|
43
48
|
import type { ObservedOperation } from './observedSchema';
|
|
44
49
|
import type { Violation } from './contractCheck';
|
|
45
50
|
|
|
@@ -90,12 +95,26 @@ function emitReceipt(op: ObservedOperation, operationId: string, requestViolatio
|
|
|
90
95
|
recorded_at: new Date().toISOString(),
|
|
91
96
|
violations: allViolations,
|
|
92
97
|
};
|
|
98
|
+
signReceipt(receipt);
|
|
93
99
|
receiptSink(JSON.stringify(receipt));
|
|
94
100
|
} catch (err) {
|
|
95
101
|
console.warn(`observeContract: could not emit a receipt for "${operationId}"`, err);
|
|
96
102
|
}
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): its OWN inner try/catch,
|
|
106
|
+
// separate from emitReceipt()'s outer one -- a signing failure (bad/missing key config, malformed
|
|
107
|
+
// PEM) must fall back to sending the receipt UNSIGNED, not silently drop the whole receipt the way
|
|
108
|
+
// sharing the outer catch would.
|
|
109
|
+
function signReceipt(receipt: Record<string, unknown>): void {
|
|
110
|
+
if (!receiptSign.isConfigured()) return;
|
|
111
|
+
try {
|
|
112
|
+
receipt.signature = { algorithm: 'ed25519', value: receiptSign.sign(receipt) };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn('observeContract: could not sign a receipt -- sending it unsigned instead', err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
export function observeContract(operationId: string): RequestHandler {
|
|
100
119
|
return (req: Request, res: Response, next) => {
|
|
101
120
|
let op: ObservedOperation | undefined;
|