backend-skeleton 1.1.0 → 1.2.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.
@@ -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
  }
@@ -172,11 +172,18 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
172
172
  for (const entity of targetModule.entities) {
173
173
  if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
174
174
  const fetchRoute = findFetchRoute(targetModule.controllers, entity.className);
175
- const handlerFile = fetchRoute ? resolveHandlerFile(fetchRoute.file, fetchRoute.method, srcRoot) : null;
175
+ // D-typescript-express-inline-handlers: `method === null` means the scanner found a real
176
+ // route but its handler is an inline function expression, not a named export -- there is
177
+ // genuinely nothing for resolveHandlerFile()'s import/barrel-hop search to correlate to, so
178
+ // this is checked explicitly (a clear, named reason) rather than relying on the incidental
179
+ // fact that a regex built from the literal string "null" also happens not to match anything.
180
+ const handlerFile = fetchRoute && fetchRoute.method ? resolveHandlerFile(fetchRoute.file, fetchRoute.method, srcRoot) : null;
176
181
  const selectFields = handlerFile ? findSelectAllowList(handlerFile) : null;
177
182
 
178
183
  if (!fetchRoute) {
179
184
  notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
185
+ } else if (!fetchRoute.method) {
186
+ notes.push(`${entity.className}: the single-resource GET route's handler is an inline function expression, not a named export -- nothing to correlate to a defining file, resolver NOT generated.`);
180
187
  } else if (!handlerFile) {
181
188
  notes.push(`${entity.className}: could not resolve ${fetchRoute.method}'s own defining file (import, or one barrel hop, from ${path.relative(repoRoot, fetchRoute.file)}) -- resolver NOT generated.`);
182
189
  } else if (!selectFields) {
@@ -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;
@@ -0,0 +1,65 @@
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
6
+ // app, not a hand-fabricated file. A self-contained PORT of lib/attest.mjs's own canonicalize()/
7
+ // signPayload() -- never imported directly. This file runs inside a DEPLOYED target app, a
8
+ // foreign process from this CLI's own even though both happen to be Node -- same "port, don't
9
+ // cross-import into generated code" rule codec.ts.tmpl already established for handles/codec.mjs.
10
+ // node:crypto only, zero new dependency, matching this project's own "no new dependency where the
11
+ // runtime already provides the primitive" discipline (the same reasoning java-spring's own
12
+ // ReceiptSigner.java uses java.security.Signature instead of a Bouncy Castle dependency).
13
+ //
14
+ // Canonicalization must be byte-identical to `bskel observe import`'s own verification side (Node's
15
+ // lib/attest.mjs canonicalize()) -- proven cross-language-compatible by direct execution against a
16
+ // real receipt containing a forward slash, a quote, and non-ASCII text (see DECISIONS.md
17
+ // D-runtime-conformance-receipts). Deep-sorted object keys, array order preserved, compact JSON,
18
+ // non-ASCII left unescaped -- exactly Node's own JSON.stringify default behavior once keys are
19
+ // pre-sorted, so no special-casing is needed here the way Python's ensure_ascii=False is.
20
+
21
+ import { sign as cryptoSign } from 'node:crypto';
22
+
23
+ let privateKeyPem: string | null = null;
24
+
25
+ /**
26
+ * NOT called by any generated code -- a human calls this once, themselves, at application
27
+ * startup, passing a PKCS#8 PEM private key string -- the exact format `bskel attest keygen`
28
+ * already writes. Never hardcode a real key value in source; read it from wherever you already
29
+ * keep secrets (an env var, a mounted file, a secrets manager). Unconfigured (the default,
30
+ * including null/blank) means every receipt stays unsigned -- backward compatible with every
31
+ * already-deployed app using this feature before signing existed.
32
+ */
33
+ export function setSigningKey(pem: string | null | undefined): void {
34
+ privateKeyPem = pem && pem.trim().length > 0 ? pem : null;
35
+ }
36
+
37
+ export function isConfigured(): boolean {
38
+ return privateKeyPem !== null;
39
+ }
40
+
41
+ function sortKeysDeep(value: unknown): unknown {
42
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
43
+ if (value !== null && typeof value === 'object') {
44
+ const sorted: Record<string, unknown> = {};
45
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
46
+ sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
47
+ }
48
+ return sorted;
49
+ }
50
+ return value;
51
+ }
52
+
53
+ function canonicalize(value: unknown): string {
54
+ return JSON.stringify(sortKeysDeep(value));
55
+ }
56
+
57
+ /**
58
+ * Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass an
59
+ * object that does not yet carry a "signature" key -- this module never strips one itself.
60
+ */
61
+ export function sign(receiptWithoutSignature: unknown): string {
62
+ if (!privateKeyPem) throw new Error('receiptSign.sign() called before setSigningKey()');
63
+ const canonical = canonicalize(receiptWithoutSignature);
64
+ return cryptoSign(null, Buffer.from(canonical), privateKeyPem).toString('base64');
65
+ }
package/lib/cli.mjs CHANGED
@@ -148,6 +148,13 @@ export const COMMANDS = {
148
148
  },
149
149
  allowPositionals: true,
150
150
  },
151
+ 'scan repair': {
152
+ usage: 'bskel scan repair --feature <id> [--json]',
153
+ options: {
154
+ feature: { type: 'string', default: null, required: true },
155
+ json: { type: 'boolean', default: false },
156
+ },
157
+ },
151
158
  'scan cross-feature-check': {
152
159
  usage: 'bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]',
153
160
  options: {
@@ -394,12 +401,20 @@ export const COMMANDS = {
394
401
  json: { type: 'boolean', default: false },
395
402
  },
396
403
  },
404
+ // D-runtime-conformance-receipts (cryptographic receipt attestation): --pubkey alone has real
405
+ // standalone meaning (verify signatures where present, tolerate unsigned receipts) -- mirrors
406
+ // `serve`'s own `--sign-key` (opt-in signing, no `--require-sign-key` needed), NOT `gate export
407
+ // --sign`/`--key`'s mutual-requirement (a bare `--key` there is meaningless without `--sign`).
408
+ // --require-signature without --pubkey is refused -- mirrors `serve`'s own "mandatory signing,
409
+ // opt-in-to-more-strictness" `--require-sign-key`-without-`--sign-key` precedent exactly.
397
410
  'observe import': {
398
- usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
411
+ usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--pubkey <path> [--require-signature]] [--json]',
399
412
  options: {
400
413
  feature: { type: 'string', default: null, required: true },
401
414
  receipts: { type: 'string', default: null, required: true },
402
415
  'fail-on-violation': { type: 'boolean', default: false },
416
+ pubkey: { type: 'string', default: null },
417
+ 'require-signature': { type: 'boolean', default: false },
403
418
  json: { type: 'boolean', default: false },
404
419
  },
405
420
  },
@@ -11,6 +11,7 @@
11
11
  // that gets passed and the token later required can never diverge.
12
12
  import path from 'node:path';
13
13
  import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
14
+ import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
14
15
  import { specPath } from './paths.mjs';
15
16
  import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
16
17
  import { listFeatures, loadFeatureFile } from './featurelifecycle.mjs';
@@ -119,7 +120,7 @@ export function dependencyKey(dep) {
119
120
  // slice doesn't address.
120
121
  export function resolveClassFile(root, featureId, resourceType) {
121
122
  const reportPath = specPath(root, featureId, 'brownfield-scan.json');
122
- const report = readJsonIfExists(reportPath);
123
+ const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
123
124
  if (!report) return { file: null, reason: 'no_scan_report' };
124
125
  const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
125
126
  if (!moduleName) return { file: null, reason: 'no_disposition' };
@@ -23,6 +23,7 @@ import { specPath, sbfPath } from './paths.mjs';
23
23
  import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
24
24
  import { loadManifest } from './handles-manifest.mjs';
25
25
  import { dependenciesPath, resolveClassFile } from './field-dependencies.mjs';
26
+ import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
26
27
  import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
27
28
  import { listTransactions, transactionPath } from './patch-transactions.mjs';
28
29
 
@@ -243,7 +244,7 @@ export const GATE_DEFINITIONS = Object.freeze({
243
244
  verifyPolicy: VERIFY_POLICY.REQUIRED,
244
245
  recompute: (root, featureId) => {
245
246
  const reportPath = specPath(root, featureId, 'brownfield-scan.json');
246
- const report = readJsonIfExists(reportPath);
247
+ const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
247
248
  const inputs = {
248
249
  scan_report_hash: sha256File(reportPath),
249
250
  contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
@@ -257,9 +258,12 @@ export const GATE_DEFINITIONS = Object.freeze({
257
258
  // (part 3)" in DECISIONS.md.
258
259
  for (const item of [...(mod?.controllers ?? []), ...(mod?.entities ?? []), ...(mod?.enums ?? []), ...(mod?.dtos ?? [])]) {
259
260
  if (!item.file) continue;
260
- // related_modules[].{controllers,entities,enums}[].file are stored ABSOLUTE
261
- // (unlike Part 1's own repo-relative files_read) -- confirmed live against a real
262
- // scan report before writing this.
261
+ // D-scan-report-portable-paths: `.file` is repo-relative on disk (adapters write
262
+ // it that way now); `hydrateScanReportFilePaths()` above already re-anchored it to
263
+ // THIS root, so a plain path.relative(root, item.file) is correct regardless of
264
+ // where `bskel scan` originally ran -- no longer sensitive to a stale baked-in
265
+ // absolute path from a different worktree/clone. See DECISIONS.md for the real
266
+ // bug this closes (found via a real second-worktree pilot re-verification).
263
267
  const rel = path.relative(root, item.file);
264
268
  inputs[`${MODULE_FILE_PREFIX}${rel}`] = sha256File(item.file);
265
269
  }
@@ -0,0 +1,47 @@
1
+ // D-scan-report-portable-paths: every scanner adapter builds `related_modules[].{controllers,
2
+ // entities,enums,dtos}[].file` ABSOLUTE (this is deliberate, unchanged -- it is the shape every
3
+ // in-memory/direct-API consumer has always expected and still does: `runScan()` called directly
4
+ // and handed straight to `provider.plan()`/`planHandles()` is a real, widely-used pattern across
5
+ // this project's own test suite, not just the CLI). The ONLY place an absolute `.file` is a real
6
+ // problem is once it gets COMMITTED to git as part of `specs/<feature>/brownfield-scan.json`
7
+ // (confirmed NOT gitignored for real feature work) -- a value baked in from wherever `bskel scan`
8
+ // originally ran is meaningless once that same committed branch is checked out somewhere else (a
9
+ // second worktree, a different clone, CI). So the fix lives at exactly the disk-persistence
10
+ // boundary, not in the adapters or in every downstream consumer:
11
+ // - `dehydrateScanReportFilePaths()` converts absolute -> repo-relative, called ONCE, right
12
+ // before `bin/bskel.mjs`'s `cmdScan` writes the report to disk (mirrors the adapters' own
13
+ // pre-existing `filesRead` convention -- one `path.relative(repoRoot, f)` call, just applied
14
+ // at the write boundary instead of duplicated across 4 adapters).
15
+ // - `hydrateScanReportFilePaths()` converts back, called at every point something RE-LOADS the
16
+ // on-disk report (`loadHydratedScanReportOrExit`, the `contract` gate, `resolveClassFile`) --
17
+ // `path.isAbsolute(item.file)` doubles as a legacy-shape guard, so a report committed BEFORE
18
+ // this fix (already absolute on disk) passes through as a correct no-op rather than a bug;
19
+ // `bskel scan repair` is the real remedy for a stale-but-still-absolute value from a moved
20
+ // worktree, not this function.
21
+ //
22
+ // Both functions return a NEW object (via structuredClone), never mutate their input -- `cmdScan`
23
+ // needs the SAME in-memory `report` for both the disk write (dehydrated) and the `--json` stdout
24
+ // print (left absolute, untouched) from ONE scan; mutating in place would make whichever happened
25
+ // first corrupt the other.
26
+ import path from 'node:path';
27
+
28
+ function mapScanReportFiles(report, transform) {
29
+ if (!report) return report;
30
+ const next = structuredClone(report);
31
+ for (const mod of next.related_modules ?? []) {
32
+ for (const key of ['controllers', 'entities', 'enums', 'dtos']) {
33
+ for (const item of mod[key] ?? []) {
34
+ if (item.file) item.file = transform(item.file);
35
+ }
36
+ }
37
+ }
38
+ return next;
39
+ }
40
+
41
+ export function hydrateScanReportFilePaths(report, repoRoot) {
42
+ return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? file : path.join(repoRoot, file)));
43
+ }
44
+
45
+ export function dehydrateScanReportFilePaths(report, repoRoot) {
46
+ return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? path.relative(repoRoot, file) : file));
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
6
6
  "license": "AGPL-3.0-or-later",
@@ -149,7 +149,22 @@ const MAPPING_VERBS = ['Get', 'Post', 'Put', 'Patch', 'Delete'];
149
149
  const MAPPING_ANNOTATION_RE = new RegExp(`@(?:(${MAPPING_VERBS.join('|')})Mapping|RequestMapping)\\b`, 'g');
150
150
  const REQUEST_MAPPING_RE = /@RequestMapping\b/g;
151
151
  const CLASS_OR_RECORD_START_RE = /^(?:public\s+)?(?:class|record)\b/;
152
- const REQUEST_MAPPING_METHOD_RE = /\bmethod\s*=\s*RequestMethod\.(\w+)\b/;
152
+ // D-java-spring-static-import-method: `RequestMethod.` prefix made optional -- confirmed live,
153
+ // dogfooding against a real, popular repo (gothinkster/spring-boot-realworld-example-app, 1,584
154
+ // real GitHub stars): its UsersApi.java (register + login, 2 of the RealWorld spec's most
155
+ // fundamental endpoints) uses `import static ... RequestMethod.POST;` then bare
156
+ // `@RequestMapping(path = "/users", method = POST)` -- a real, common Java style (static-import a
157
+ // single enum constant to cut the qualifier) the original prefix-required regex silently missed
158
+ // (0/2 endpoints on this file; the OTHER 15/17 endpoints in this same corpus, using
159
+ // @PostMapping/@GetMapping shorthand elsewhere, were unaffected and already correct). The verb
160
+ // alternation is restricted to Spring's own real `RequestMethod` enum's 8 actual values (GET,
161
+ // HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE) rather than a bare `\w+` -- narrower than
162
+ // "any identifier", so an unrelated `method = someVariable` still correctly fails to match rather
163
+ // than being misread as a verb. Verified live: the existing multi-verb array form (`method =
164
+ // {RequestMethod.GET, RequestMethod.POST}`, still deliberately unresolved/skipped) does NOT
165
+ // accidentally partial-match here -- the array's own `{` breaks the match before any verb name is
166
+ // reached, same as before this change.
167
+ const REQUEST_MAPPING_METHOD_RE = /\bmethod\s*=\s*(?:RequestMethod\.)?(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE)\b/;
153
168
 
154
169
  // True when `class`/`record` is the next real declaration after `index`, ONE OR MORE further
155
170
  // annotations allowed in between (e.g. a real oracle shape: `@RequestMapping(...)