backend-skeleton 1.0.0-beta.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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,177 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import {{JACKSON_PACKAGE}}.JsonNode;
4
+ import {{JACKSON_PACKAGE}}.ObjectMapper;
5
+ import lombok.RequiredArgsConstructor;
6
+ import org.springframework.http.ResponseEntity;
7
+ import org.springframework.security.core.GrantedAuthority;
8
+ import org.springframework.security.core.context.SecurityContextHolder;
9
+ import org.springframework.http.HttpStatus;
10
+ import org.springframework.web.bind.annotation.GetMapping;
11
+ import org.springframework.web.bind.annotation.PatchMapping;
12
+ import org.springframework.web.bind.annotation.PathVariable;
13
+ import org.springframework.web.bind.annotation.RequestBody;
14
+ import org.springframework.web.bind.annotation.RequestMapping;
15
+ import org.springframework.web.bind.annotation.RequestParam;
16
+ import org.springframework.web.bind.annotation.RestController;
17
+ import org.springframework.web.server.ResponseStatusException;
18
+
19
+ import java.time.Instant;
20
+ import java.util.List;
21
+ import java.util.Map;
22
+ import java.util.Objects;
23
+ import java.util.Optional;
24
+ import java.util.UUID;
25
+
26
+ /**
27
+ * D-handles (DECISIONS.md): exposed in production. Per-resolver {@code requiredAuthority()} is
28
+ * the entire defense for this generic-object-accessor security surface -- a resolver that
29
+ * forgets to check something it should is a real vulnerability, not a theoretical one. Review
30
+ * new resolvers accordingly.
31
+ *
32
+ * <p>Generated by backend-skeleton ({@code bskel handles emit}). Registered resolvers come from
33
+ * {@code Map<String, ResourceResolver>} auto-collected by Spring from all {@code @Component}
34
+ * beans implementing {@link ResourceResolver} -- keyed by BEAN NAME, not {@link
35
+ * ResourceResolver#type()}, so {@link #resolverFor} looks up by iterating and matching {@code
36
+ * type()} explicitly rather than assuming bean name == type.
37
+ */
38
+ @RestController
39
+ @RequestMapping("/handles")
40
+ @RequiredArgsConstructor
41
+ public class HandleController {
42
+
43
+ private final Map<String, ResourceResolver> resolversByBeanName;
44
+ private final HandleRegistryRepository handleRegistryRepository;
45
+ private final HandleSnapshotRepository handleSnapshotRepository;
46
+ private final ObjectMapper objectMapper;
47
+
48
+ @GetMapping("/{handle}")
49
+ public ResponseEntity<Object> fetch(@PathVariable String handle) {
50
+ HandleCodec.Decoded decoded = decodeOrThrow(handle);
51
+ ResourceResolver resolver = resolverFor(decoded.type());
52
+ requireAuthority(resolver.requiredAuthority());
53
+
54
+ Object resource = resolver.fetch(decoded.uuid());
55
+ if (decoded.pointer() == null) {
56
+ return ResponseEntity.ok(resource);
57
+ }
58
+ // O4 (D-handle-lifecycle): kind=f -- serialize the whole resource through the SAME
59
+ // ObjectMapper the rest of the app uses, then walk the RFC 6901 pointer via Jackson's own
60
+ // JsonNode#at (no hand-rolled walker needed here, unlike the JS reference implementation's
61
+ // resolveJsonPointer, which had no such built-in to reach for). A pointer that resolves to
62
+ // a genuinely missing node returns 404, not null/absent -- resolveJsonPointer's own JS
63
+ // behavior ("current == null -> return undefined") is preserved as "the field truly isn't
64
+ // there", not silently coerced to some other shape.
65
+ JsonNode root = objectMapper.valueToTree(resource);
66
+ JsonNode target = root.at(decoded.pointer());
67
+ if (target.isMissingNode()) {
68
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "pointer \"" + decoded.pointer() + "\" does not resolve on " + decoded.type());
69
+ }
70
+ return ResponseEntity.ok(target);
71
+ }
72
+
73
+ @PatchMapping("/{handle}")
74
+ public ResponseEntity<Void> patch(@PathVariable String handle, @RequestBody Object value) {
75
+ HandleCodec.Decoded decoded = decodeOrThrow(handle);
76
+ // D-security-10: checks kind explicitly rather than inferring "field handle" from
77
+ // pointer-presence alone -- HandleCodec.decode() doesn't itself enforce that only kind=f
78
+ // carries a pointer (that's enforced on the encode side, see HandleCodec#encode), so a
79
+ // hand-crafted token of a reserved/future kind (e.g. kind=o) with a pointer appended must
80
+ // still be rejected here rather than treated as a field handle. Found by the Codex
81
+ // security review.
82
+ if (!decoded.kind().equals("f") || decoded.pointer() == null) {
83
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH");
84
+ }
85
+ ResourceResolver resolver = resolverFor(decoded.type());
86
+ requireAuthority(resolver.requiredAuthority());
87
+
88
+ resolver.patchField(decoded.uuid(), decoded.pointer(), value);
89
+ return ResponseEntity.noContent().build();
90
+ }
91
+
92
+ @GetMapping("/{handle}/recover")
93
+ public ResponseEntity<?> recover(@PathVariable String handle, @RequestParam(required = false) Instant at) {
94
+ HandleCodec.Decoded decoded = decodeOrThrow(handle);
95
+ ResourceResolver resolver = resolverFor(decoded.type());
96
+ requireAuthority(resolver.requiredAuthority());
97
+
98
+ UUID handleUid = HandleCodec.deriveHandleUid(decoded.kind(), decoded.type(), decoded.uuid(), decoded.pointer());
99
+
100
+ // D-security-9: handleUid alone does not prove WHAT is being recovered -- kind=r's
101
+ // derivation returns the resource UUID verbatim (no type binding baked into the hash), so
102
+ // an attacker who controls `type` in the handle token (as long as it names a real,
103
+ // registered resolver whose requiredAuthority() they can pass) can request the snapshot
104
+ // history of a DIFFERENT, more sensitive resource type that happens to share the same
105
+ // UUID. Found by the Codex security review. Fix: cross-check the decoded type/kind/pointer
106
+ // against the registry row this handleUid was actually registered under, and 404 on ANY
107
+ // disagreement -- without saying which field mismatched, so the error can't be used to
108
+ // probe which part was wrong. A revoked handle is rejected the same way; it must not be
109
+ // recoverable at all.
110
+ HandleRegistry registry = handleRegistryRepository.findById(handleUid)
111
+ .filter(r -> r.getResourceType().equals(decoded.type()))
112
+ .filter(r -> r.getKind().equals(decoded.kind()))
113
+ .filter(r -> Objects.equals(r.getPointer(), decoded.pointer()))
114
+ .filter(r -> !r.isRevoked())
115
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no snapshot recorded for this handle"));
116
+
117
+ List<HandleSnapshot> snapshots = handleSnapshotRepository.findByHandleUidOrderByRecordedAtDesc(handleUid);
118
+ Optional<HandleSnapshot> match = at == null
119
+ ? snapshots.stream().findFirst()
120
+ : snapshots.stream().filter(s -> !s.getRecordedAt().isAfter(at)).findFirst();
121
+
122
+ if (match.isEmpty()) {
123
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no snapshot recorded for this handle" + (at != null ? " at or before " + at : ""));
124
+ }
125
+ HandleSnapshot snapshot = match.get();
126
+ // Reuses the registry row already fetched (and validated) above instead of querying again.
127
+ boolean drift = !registry.getContractRef().equals(snapshot.getContractHash());
128
+
129
+ // O4 (D-handle-lifecycle): snapshot.getPayload() is the raw JSON TEXT HandleService
130
+ // recorded (a Java String) -- embedding it into this response Map as-is would make
131
+ // Jackson serialize it as an ESCAPED JSON STRING VALUE, double-encoding a caller's real
132
+ // payload into something like "payload":"{\"name\":...}" instead of a normal nested
133
+ // object. Confirmed live (not assumed) by inspecting a real recover() response before
134
+ // this fix -- readTree parses it back into a JsonNode so it serializes as genuine nested
135
+ // JSON. A parse failure here means the stored payload isn't valid JSON at all, which only
136
+ // this app's own recordSnapshot() ever writes -- an internal invariant violation, not a
137
+ // caller-facing input error.
138
+ JsonNode payload;
139
+ try {
140
+ payload = objectMapper.readTree(snapshot.getPayload());
141
+ } catch (Exception e) {
142
+ throw new IllegalStateException("stored handle snapshot payload is not valid JSON", e);
143
+ }
144
+
145
+ return ResponseEntity.ok(Map.of(
146
+ "handle", handle,
147
+ "recorded_at", snapshot.getRecordedAt(),
148
+ "operation_id", snapshot.getOperationId(),
149
+ "payload", payload,
150
+ "schema_drift", drift
151
+ ));
152
+ }
153
+
154
+ private HandleCodec.Decoded decodeOrThrow(String handle) {
155
+ try {
156
+ return HandleCodec.decode(handle);
157
+ } catch (IllegalArgumentException e) {
158
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
159
+ }
160
+ }
161
+
162
+ private ResourceResolver resolverFor(String type) {
163
+ return resolversByBeanName.values().stream()
164
+ .filter(r -> r.type().equals(type))
165
+ .findFirst()
166
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no resolver registered for handle type \"" + type + "\""));
167
+ }
168
+
169
+ private void requireAuthority(String requiredRole) {
170
+ boolean granted = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
171
+ .map(GrantedAuthority::getAuthority)
172
+ .anyMatch(authority -> authority.equals("ROLE_" + requiredRole));
173
+ if (!granted) {
174
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires role " + requiredRole);
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,107 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import jakarta.persistence.Column;
4
+ import jakarta.persistence.Entity;
5
+ import jakarta.persistence.Id;
6
+ import jakarta.persistence.Table;
7
+ import lombok.AccessLevel;
8
+ import lombok.Getter;
9
+ import lombok.NoArgsConstructor;
10
+ import org.hibernate.annotations.CreationTimestamp;
11
+
12
+ import java.time.Instant;
13
+ import java.util.UUID;
14
+
15
+ /**
16
+ * Registry row for one addressable handle -- maps a derived {@code handle_uid} (see
17
+ * {@link HandleCodec#deriveHandleUid}) back to the resource/field it addresses, so a handle can
18
+ * be looked up in the reverse direction without re-deriving it from a token.
19
+ *
20
+ * <p>Generated by backend-skeleton ({@code bskel handles emit}). The corresponding migration
21
+ * (see the emitted {@code migration.sql}) is NOT applied automatically -- this repo has no
22
+ * Flyway/Liquibase, so apply it yourself against Supabase, and add a JPA {@code @Table}
23
+ * mapping only once that migration has actually run.
24
+ */
25
+ @Getter
26
+ @Entity
27
+ @Table(name = "sbf_handle")
28
+ @NoArgsConstructor(access = AccessLevel.PROTECTED)
29
+ public class HandleRegistry {
30
+
31
+ @Id
32
+ @Column(name = "handle_uid", updatable = false, nullable = false)
33
+ private UUID handleUid;
34
+
35
+ /** {@code r} (resource), {@code f} (field), or {@code o} (operation instance -- reserved, unused). */
36
+ @Column(name = "kind", nullable = false, length = 1)
37
+ private String kind;
38
+
39
+ @Column(name = "resource_type", nullable = false, length = 100)
40
+ private String resourceType;
41
+
42
+ @Column(name = "resource_uid", nullable = false)
43
+ private UUID resourceUid;
44
+
45
+ /** RFC 6901 JSON Pointer, null for {@code kind = r}. */
46
+ @Column(name = "pointer")
47
+ private String pointer;
48
+
49
+ @Column(name = "feature_uid", nullable = false)
50
+ private UUID featureUid;
51
+
52
+ @Column(name = "operation_id", length = 100)
53
+ private String operationId;
54
+
55
+ @Column(name = "contract_ref", nullable = false)
56
+ private String contractRef;
57
+
58
+ @CreationTimestamp
59
+ @Column(name = "created_at", nullable = false, updatable = false)
60
+ private Instant createdAt;
61
+
62
+ @Column(name = "revoked_at")
63
+ private Instant revokedAt;
64
+
65
+ @Column(name = "revoked_reason")
66
+ private String revokedReason;
67
+
68
+ public static HandleRegistry create(
69
+ UUID handleUid, String kind, String resourceType, UUID resourceUid,
70
+ String pointer, UUID featureUid, String operationId, String contractRef
71
+ ) {
72
+ HandleRegistry registry = new HandleRegistry();
73
+ registry.handleUid = handleUid;
74
+ registry.kind = kind;
75
+ registry.resourceType = resourceType;
76
+ registry.resourceUid = resourceUid;
77
+ registry.pointer = pointer;
78
+ registry.featureUid = featureUid;
79
+ registry.operationId = operationId;
80
+ registry.contractRef = contractRef;
81
+ return registry;
82
+ }
83
+
84
+ public boolean isRevoked() {
85
+ return revokedAt != null;
86
+ }
87
+
88
+ /**
89
+ * O4 (D-handle-lifecycle): {@code handle_uid} is DERIVED from {@code kind}/{@code
90
+ * resourceType}/{@code resourceUid}/{@code pointer} (see {@link HandleCodec#deriveHandleUid})
91
+ * -- those four fields can never legitimately differ for an existing row with this same id,
92
+ * so {@link HandleService#register} only ever needs to refresh the three fields that CAN
93
+ * change across separate registrations of the same resource/pointer: which feature most
94
+ * recently touched it, which operation, and its current contract hash. Deliberately does NOT
95
+ * touch {@code revokedAt} -- re-registering a revoked handle must never silently un-revoke it.
96
+ */
97
+ void refresh(UUID featureUid, String operationId, String contractRef) {
98
+ this.featureUid = featureUid;
99
+ this.operationId = operationId;
100
+ this.contractRef = contractRef;
101
+ }
102
+
103
+ void revoke(String reason) {
104
+ this.revokedAt = Instant.now();
105
+ this.revokedReason = reason;
106
+ }
107
+ }
@@ -0,0 +1,8 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import org.springframework.data.jpa.repository.JpaRepository;
4
+
5
+ import java.util.UUID;
6
+
7
+ public interface HandleRegistryRepository extends JpaRepository<HandleRegistry, UUID> {
8
+ }
@@ -0,0 +1,95 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import {{JACKSON_PACKAGE}}.ObjectMapper;
4
+ import lombok.RequiredArgsConstructor;
5
+ import org.springframework.stereotype.Service;
6
+ import org.springframework.transaction.annotation.Transactional;
7
+
8
+ import java.time.Instant;
9
+ import java.util.UUID;
10
+
11
+ /**
12
+ * O4 (D-handle-lifecycle): the explicit API that makes {@code HandleController.recover()}
13
+ * actually reachable -- before this existed, nothing anywhere ever called {@link
14
+ * HandleRegistry#create}/{@link HandleSnapshot#create}, so a registry lookup could never find a
15
+ * row. Deliberately NEVER auto-invoked by anything generated elsewhere and never wired into any
16
+ * EXISTING business logic class -- call it explicitly from your own service code at the point a
17
+ * resource is actually created/updated, or apply {@code @RecordHandleSnapshot} to an existing
18
+ * service method to have {@link HandleAspect} call it for you (see that annotation's own javadoc
19
+ * for the opt-in, explicit-parameter-index design and why nothing here ever guesses).
20
+ *
21
+ * <p>Generated by backend-skeleton ({@code bskel handles emit}).
22
+ */
23
+ @Service
24
+ @RequiredArgsConstructor
25
+ public class HandleService {
26
+
27
+ private final HandleRegistryRepository handleRegistryRepository;
28
+ private final HandleSnapshotRepository handleSnapshotRepository;
29
+ private final ObjectMapper objectMapper;
30
+
31
+ /**
32
+ * Derives {@code handle_uid} ({@link HandleCodec#deriveHandleUid}) and UPSERTS the registry
33
+ * row -- the schema's own {@code unique (resource_type, resource_uid, pointer)} constraint
34
+ * (see the emitted {@code migration.sql}) means the SAME {@code (kind, type, resourceUid,
35
+ * pointer)} triple always derives the SAME {@code handle_uid}, so re-registering it is
36
+ * expected, not an error: an existing row has its {@code featureUid}/{@code operationId}/
37
+ * {@code contractRef} refreshed (see {@link HandleRegistry#refresh}) rather than failing a
38
+ * unique-constraint check. Returns the encoded handle token ({@link HandleCodec#encode},
39
+ * already implemented -- "minting" a token was never the missing piece, persisting the row
40
+ * backing it was).
41
+ */
42
+ @Transactional
43
+ public String register(String kind, String type, UUID resourceUid, String pointer, UUID featureUid, String operationId, String contractRef) {
44
+ String token = HandleCodec.encode(kind, type, resourceUid, pointer);
45
+ UUID handleUid = HandleCodec.deriveHandleUid(kind, type, resourceUid, pointer);
46
+ HandleRegistry existing = handleRegistryRepository.findById(handleUid).orElse(null);
47
+ if (existing == null) {
48
+ handleRegistryRepository.save(HandleRegistry.create(handleUid, kind, type, resourceUid, pointer, featureUid, operationId, contractRef));
49
+ } else {
50
+ existing.refresh(featureUid, operationId, contractRef);
51
+ }
52
+ return token;
53
+ }
54
+
55
+ /**
56
+ * Records one envelope for an already-registered handle. {@code payload} is serialized
57
+ * through the same {@link ObjectMapper} the rest of the app uses -- callers pass a real
58
+ * object (a DTO, a request body, an error shape), never a pre-serialized string, so this is
59
+ * the one place that decides how it becomes the JSON text {@code sbf_handle_snapshot.payload}
60
+ * stores.
61
+ */
62
+ @Transactional
63
+ public void recordSnapshot(UUID handleUid, String envelopeDir, String operationId, String contractHash, Object payload) {
64
+ // A generic `catch (Exception e)` (not the Jackson-2-specific checked
65
+ // JsonProcessingException, nor assuming Jackson 3's unchecked JacksonException) compiles
66
+ // correctly against EITHER Jackson major version {{JACKSON_PACKAGE}} might have resolved
67
+ // to at `bskel handles emit` time -- this template must compile whichever one a real
68
+ // target repo is actually on, and the two versions disagree on whether this throws a
69
+ // checked or unchecked exception.
70
+ String json;
71
+ try {
72
+ json = objectMapper.writeValueAsString(payload);
73
+ } catch (Exception e) {
74
+ throw new IllegalStateException("could not serialize handle snapshot payload", e);
75
+ }
76
+ handleSnapshotRepository.save(HandleSnapshot.create(handleUid, envelopeDir, operationId, contractHash, json));
77
+ }
78
+
79
+ @Transactional
80
+ public void revoke(UUID handleUid, String reason) {
81
+ handleRegistryRepository.findById(handleUid).ifPresent(registry -> registry.revoke(reason));
82
+ }
83
+
84
+ /**
85
+ * O4 (D-handle-lifecycle): retention is EXPOSED, not auto-scheduled -- nothing generated here
86
+ * wires a {@code @Scheduled} job to call this automatically. Deciding how long snapshots
87
+ * should live, and whether a background job is even appropriate for this application, is left
88
+ * to a human -- the same boundary {@code D-migration-scope} already draws around never
89
+ * applying the emitted {@code migration.sql} on its own.
90
+ */
91
+ @Transactional
92
+ public long pruneSnapshotsOlderThan(Instant cutoff) {
93
+ return handleSnapshotRepository.deleteByRecordedAtBefore(cutoff);
94
+ }
95
+ }
@@ -0,0 +1,75 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import jakarta.persistence.Column;
4
+ import jakarta.persistence.Entity;
5
+ import jakarta.persistence.GeneratedValue;
6
+ import jakarta.persistence.GenerationType;
7
+ import jakarta.persistence.Id;
8
+ import jakarta.persistence.Table;
9
+ import lombok.AccessLevel;
10
+ import lombok.Getter;
11
+ import lombok.NoArgsConstructor;
12
+ import org.hibernate.annotations.CreationTimestamp;
13
+ import org.hibernate.annotations.JdbcTypeCode;
14
+ import org.hibernate.type.SqlTypes;
15
+
16
+ import java.time.Instant;
17
+ import java.util.UUID;
18
+
19
+ /**
20
+ * One recorded envelope for a handle -- what {@code bskel handles emit}'s {@code recover} verb
21
+ * reads from. Rows are written explicitly via {@link HandleService#recordSnapshot}, either called
22
+ * directly or automatically by {@link HandleAspect} for methods annotated
23
+ * {@code @RecordHandleSnapshot} (O4, D-handle-lifecycle) -- nothing here writes on its own.
24
+ *
25
+ * <p>Generated by backend-skeleton ({@code bskel handles emit}). Migration NOT applied
26
+ * automatically -- see the note on {@link HandleRegistry}.
27
+ */
28
+ @Getter
29
+ @Entity
30
+ @Table(name = "sbf_handle_snapshot")
31
+ @NoArgsConstructor(access = AccessLevel.PROTECTED)
32
+ public class HandleSnapshot {
33
+
34
+ @Id
35
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
36
+ @Column(name = "snapshot_id", updatable = false, nullable = false)
37
+ private Long snapshotId;
38
+
39
+ @Column(name = "handle_uid", nullable = false)
40
+ private UUID handleUid;
41
+
42
+ @Column(name = "envelope_dir", nullable = false, length = 20)
43
+ private String envelopeDir;
44
+
45
+ @Column(name = "operation_id", nullable = false, length = 100)
46
+ private String operationId;
47
+
48
+ /**
49
+ * Hash of the feature contract this snapshot was recorded against. {@code recover} compares
50
+ * this to the CURRENT contract's hash -- a mismatch means the contract has since changed
51
+ * shape, and {@code recover} must say so explicitly (a {@code schema_drift} marker) rather
52
+ * than silently returning a payload the current contract no longer describes.
53
+ */
54
+ @Column(name = "contract_hash", nullable = false, length = 64)
55
+ private String contractHash;
56
+
57
+ /** JSON payload, stored as text (same convention as {@code ReportSnapshot.summaryPayload}) -- Hibernate handles JSON<->text at the JDBC layer via {@code SqlTypes.JSON}. */
58
+ @JdbcTypeCode(SqlTypes.JSON)
59
+ @Column(name = "payload", nullable = false)
60
+ private String payload;
61
+
62
+ @CreationTimestamp
63
+ @Column(name = "recorded_at", nullable = false, updatable = false)
64
+ private Instant recordedAt;
65
+
66
+ public static HandleSnapshot create(UUID handleUid, String envelopeDir, String operationId, String contractHash, String payload) {
67
+ HandleSnapshot snapshot = new HandleSnapshot();
68
+ snapshot.handleUid = handleUid;
69
+ snapshot.envelopeDir = envelopeDir;
70
+ snapshot.operationId = operationId;
71
+ snapshot.contractHash = contractHash;
72
+ snapshot.payload = payload;
73
+ return snapshot;
74
+ }
75
+ }
@@ -0,0 +1,20 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import org.springframework.data.jpa.repository.JpaRepository;
4
+
5
+ import java.time.Instant;
6
+ import java.util.List;
7
+ import java.util.UUID;
8
+
9
+ public interface HandleSnapshotRepository extends JpaRepository<HandleSnapshot, Long> {
10
+
11
+ /** Most recent snapshot first -- `recover(handle)` without a timestamp wants index 0 of this. */
12
+ List<HandleSnapshot> findByHandleUidOrderByRecordedAtDesc(UUID handleUid);
13
+
14
+ /**
15
+ * O4 (D-handle-lifecycle): backs {@link HandleService#pruneSnapshotsOlderThan} -- retention is
16
+ * exposed as a callable method, but nothing generated here schedules it automatically (see
17
+ * HandleService's own javadoc for why).
18
+ */
19
+ long deleteByRecordedAtBefore(Instant cutoff);
20
+ }
@@ -0,0 +1,50 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import java.lang.annotation.ElementType;
4
+ import java.lang.annotation.Retention;
5
+ import java.lang.annotation.RetentionPolicy;
6
+ import java.lang.annotation.Target;
7
+
8
+ /**
9
+ * O4 (D-handle-lifecycle): apply this to an EXISTING service method (never generated onto one --
10
+ * a human decides which methods are worth handle-tracking) to have {@link HandleAspect}
11
+ * automatically {@link HandleService#register register} the resource-level handle and {@link
12
+ * HandleService#recordSnapshot record} the request/response/error envelope around every call.
13
+ *
14
+ * <p>{@code resourceUidParam} is an explicit parameter INDEX, not inferred from the method
15
+ * signature -- the same "never guess, always explicit" boundary {@code D-resolver-scope}/
16
+ * {@code D-security-8} already draw for this codebase's generated code: a wrong guess here would
17
+ * silently attribute a snapshot to the wrong resource, which recording nothing at all would never
18
+ * do.
19
+ *
20
+ * <p>{@code redact} is an explicit array of JSON Pointers into the recorded envelope to blank
21
+ * before storage -- deliberately NOT a guessed heuristic (e.g. "any field named like a secret").
22
+ * An empty array (the default) means nothing is redacted; if the annotated method's payload
23
+ * carries anything sensitive, the human applying this annotation must name it.
24
+ *
25
+ * <p>Example:
26
+ * <pre>{@code
27
+ * @RecordHandleSnapshot(resourceType = "Organization", operationId = "updateOrganization", resourceUidParam = 0, redact = {"/internalNote"})
28
+ * public OrganizationResponse updateOrganization(UUID organizationId, UpdateOrganizationRequest request, UUID requesterId) { ... }
29
+ * }</pre>
30
+ *
31
+ * <p>Generated by backend-skeleton ({@code bskel handles emit}). Requires {@code
32
+ * spring-boot-starter-aop} on the target repo's own classpath -- NOT added automatically (see
33
+ * {@code handles emit}'s own post-emit notes), matching this project's established boundary of
34
+ * never auto-editing a target's build file.
35
+ */
36
+ @Retention(RetentionPolicy.RUNTIME)
37
+ @Target(ElementType.METHOD)
38
+ public @interface RecordHandleSnapshot {
39
+
40
+ /** Must match a registered {@link ResourceResolver#type()}. */
41
+ String resourceType();
42
+
43
+ String operationId();
44
+
45
+ /** Zero-based index into the annotated method's own parameter list -- the resource's UUID. */
46
+ int resourceUidParam();
47
+
48
+ /** JSON Pointers (RFC 6901) into the recorded envelope to blank before storage. Empty by default -- nothing redacted unless explicitly named. */
49
+ String[] redact() default {};
50
+ }
@@ -0,0 +1,50 @@
1
+ package {{BASE_PACKAGE}}.global.handle;
2
+
3
+ import java.util.UUID;
4
+
5
+ /**
6
+ * One implementation per resource type, registered as a {@code @Component} so Spring collects
7
+ * them into a {@code Map<String, ResourceResolver>} (bean name -&gt; resolver) that
8
+ * {@link HandleController} dispatches through by decoded handle {@code type} -- no reflection,
9
+ * an unknown type is just a missing map key.
10
+ *
11
+ * <p>{@code fetch}/{@code patchField} return/accept plain {@code Object} (typically the
12
+ * resource's existing response DTO) rather than a fixed type, since resolvers are registered
13
+ * generically -- {@link HandleController#fetch} serializes whatever {@code fetch} returns
14
+ * through Jackson and walks a JSON Pointer with {@code JsonNode#at} for field-level (kind=f)
15
+ * addressing.
16
+ */
17
+ public interface ResourceResolver {
18
+
19
+ /** Must match the {@code type} segment used when this resource's handles were minted (e.g. "Organization"). */
20
+ String type();
21
+
22
+ /** Fetches the whole resource, serialized the same shape its normal API response uses. */
23
+ Object fetch(UUID resourceUid);
24
+
25
+ /**
26
+ * Applies a single field-level patch. Implementations route through the resource's EXISTING
27
+ * update path (its real service method + DTO) rather than raw persistence access, so
28
+ * existing validation/business rules keep applying -- see the generated resolver stub for
29
+ * this feature's target module for the specific pattern to follow (some DTOs use {@code
30
+ * PatchField<T>} for null-has-meaning fields, most just treat null-vs-absent as "unchanged",
31
+ * and some require re-submitting the whole object; check the target DTO before assuming).
32
+ */
33
+ void patchField(UUID resourceUid, String pointer, Object value);
34
+
35
+ /** Spring Security role name (no "ROLE_" prefix) required to fetch/patch this resource type via a handle. */
36
+ String requiredAuthority();
37
+
38
+ /**
39
+ * O4 (D-handle-lifecycle): the content hash of the feature contract this resolver was
40
+ * generated from -- baked in at {@code bskel handles emit} time (regenerated every run, so a
41
+ * contract change is picked up automatically), never read from disk at runtime (a deployed
42
+ * app has no access to {@code specs/} at all). {@link HandleService#register} stores this as
43
+ * a registry row's {@code contract_ref}; {@code recover}'s {@code schema_drift} check compares
44
+ * a snapshot's own recorded hash against the CURRENT value of this method, not a stale one.
45
+ */
46
+ String contractRef();
47
+
48
+ /** O4 (D-handle-lifecycle): the feature_uid this resolver was generated for, baked in the same way as {@link #contractRef()}. */
49
+ UUID featureUid();
50
+ }
@@ -0,0 +1,77 @@
1
+ package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
2
+
3
+ import {{BASE_PACKAGE}}.global.handle.ResourceResolver;
4
+ import {{SERVICE_IMPORT}};
5
+ {{PATCH_IMPORTS}}import lombok.RequiredArgsConstructor;
6
+ import org.springframework.stereotype.Component;
7
+
8
+ import java.util.UUID;
9
+
10
+ /**
11
+ * Generated by backend-skeleton ({@code bskel handles emit}) for feature {{FEATURE_ID}}.
12
+ *
13
+ * <p>{@code fetch} is wired to the real, existing read path ({@link {{SERVICE_TYPE}}#{{FETCH_METHOD}}})
14
+ * -- safe to trust as-is, it's a read-only call into code that already exists and is tested.
15
+ *
16
+ * <p>{@code patchField} is generated PER FIELD, not as one blanket stub (see DECISIONS.md
17
+ * D-patch-strategy, and D-resolver-scope for why a wrong guess here is worse than an honest
18
+ * stub). Each field of the update DTO is classified into one of four partial-update conventions
19
+ * found in this codebase's real DTOs:
20
+ * <ol>
21
+ * <li>{@code patch-wrapper} -- the field uses {@code PatchField<T>} because null itself is
22
+ * meaningful (distinguishes "don't touch" from "set to null").</li>
23
+ * <li>{@code null-means-unchanged} -- a plain nullable field where null/absent both mean
24
+ * "leave unchanged".</li>
25
+ * <li>{@code fetch-merge-submit} -- the field is required ({@code @NotNull} or a primitive)
26
+ * even in this otherwise-partial DTO, so patching it safely means fetching the current
27
+ * resource first and resubmitting the full request with only this field changed.</li>
28
+ * <li>{@code unsupported} -- a collection, nested {@code @Valid} object, or array field, not
29
+ * safely expressible as a single scalar patch.</li>
30
+ * </ol>
31
+ * Only {@code patch-wrapper}/{@code null-means-unchanged} fields are ever auto-generated, and
32
+ * only once explicitly approved via {@code bskel handles patch approve} -- every other case
33
+ * below explains, per field, exactly why it isn't (or isn't yet).
34
+ */
35
+ @Component
36
+ @RequiredArgsConstructor
37
+ public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
38
+
39
+ // O4 (D-handle-lifecycle): baked in at `bskel handles emit` time -- CONTRACT_REF is the
40
+ // content hash of specs/{{FEATURE_ID}}/contracts/{{FEATURE_ID}}.schema.json as of this run,
41
+ // regenerated fresh every time (never read from disk at runtime, a deployed app has no
42
+ // access to specs/ at all).
43
+ private static final String CONTRACT_REF = "{{CONTRACT_REF}}";
44
+ private static final UUID FEATURE_UID = UUID.fromString("{{FEATURE_UID}}");
45
+
46
+ private final {{SERVICE_TYPE}} {{SERVICE_FIELD}};
47
+ {{PATCH_FIELDS}}
48
+ @Override
49
+ public String type() {
50
+ return "{{RESOURCE_TYPE}}";
51
+ }
52
+
53
+ @Override
54
+ public Object fetch(UUID resourceUid) {
55
+ return {{SERVICE_FIELD}}.{{FETCH_METHOD}}(resourceUid);
56
+ }
57
+
58
+ @Override
59
+ public void patchField(UUID resourceUid, String pointer, Object value) {
60
+ {{PATCH_FIELD_BODY}}
61
+ }
62
+
63
+ @Override
64
+ public String requiredAuthority() {
65
+ return "{{REQUIRED_AUTHORITY}}";
66
+ }
67
+
68
+ @Override
69
+ public String contractRef() {
70
+ return CONTRACT_REF;
71
+ }
72
+
73
+ @Override
74
+ public UUID featureUid() {
75
+ return FEATURE_UID;
76
+ }
77
+ }