@starci/eslint-canon-be 3.0.7 → 3.0.9

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 (171) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +13 -7
  3. package/ambient-id.mjs +90 -0
  4. package/cli.mjs +181 -0
  5. package/comments.mjs +18 -21
  6. package/config-owner.mjs +20 -4
  7. package/connections.mjs +12 -12
  8. package/cookies.mjs +106 -0
  9. package/cqrs.mjs +22 -26
  10. package/data-access.mjs +9 -28
  11. package/default-deny.mjs +2 -1
  12. package/e2e-flow.mjs +4 -4
  13. package/event-bus.mjs +237 -0
  14. package/feature-thin.mjs +237 -0
  15. package/idempotency.mjs +3 -1
  16. package/index.mjs +22 -1
  17. package/infra-owner.mjs +1 -1
  18. package/injection.mjs +67 -22
  19. package/input-bounds.mjs +2 -1
  20. package/jobs.mjs +214 -0
  21. package/lib/ast.mjs +7 -0
  22. package/lib/config.mjs +1 -1
  23. package/lib/db-writes.mjs +1 -1
  24. package/lib/doors.mjs +81 -0
  25. package/lib/edition.mjs +45 -0
  26. package/lib/entity-writes.mjs +35 -0
  27. package/lib/hfs.mjs +3 -3
  28. package/lib/path.mjs +0 -6
  29. package/lib/persistence.mjs +11 -18
  30. package/lib/ports.mjs +5 -2
  31. package/lib/specifier.mjs +27 -0
  32. package/lib/transactions.mjs +37 -0
  33. package/lib/transport-slots.mjs +8 -3
  34. package/lib/unit-spec.mjs +63 -2
  35. package/module-layering.mjs +3 -24
  36. package/module-shape.mjs +2 -2
  37. package/naming.mjs +2 -2
  38. package/package.json +5 -3
  39. package/project-graph.mjs +8 -5
  40. package/projections.mjs +117 -0
  41. package/query-safety.mjs +1 -1
  42. package/realtime.mjs +181 -0
  43. package/runtime/engine/config.mjs +494 -0
  44. package/runtime/engine/invalid-config.mjs +15 -0
  45. package/runtime/engine/orca-config.mjs +27 -0
  46. package/runtime/engine/runtime-root.mjs +5 -0
  47. package/runtime/engine/yaml.mjs +3 -3
  48. package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
  49. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  50. package/runtime/knowledge/hfs/slots.yaml +915 -123
  51. package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
  52. package/runtime/knowledge/sonar-gate.yaml +53 -19
  53. package/runtime/modules/models/registry.yaml +591 -0
  54. package/runtime/modules/models/runtimes.yaml +701 -0
  55. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  56. package/runtime/scripts/api/git/config-get.mjs +10 -0
  57. package/runtime/scripts/api/git/lib.mjs +17 -27
  58. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  59. package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
  60. package/runtime/scripts/api/git/remote.mjs +5 -0
  61. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  62. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  63. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  64. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
  65. package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
  66. package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
  67. package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
  68. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
  69. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
  70. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  71. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  72. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  73. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  74. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  75. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
  76. package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
  77. package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
  78. package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
  79. package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
  80. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
  81. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
  82. package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
  83. package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
  84. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  85. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
  86. package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
  87. package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
  88. package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
  89. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  90. package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
  91. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  92. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
  93. package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
  94. package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
  95. package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
  96. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
  97. package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +17 -114
  98. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  99. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  100. package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +27 -147
  101. package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +59 -18
  102. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  103. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  104. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  107. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +10 -4
  108. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
  109. package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
  110. package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
  111. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
  112. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  113. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  114. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  115. package/runtime/scripts/hfs/manifest-shape.mjs +247 -0
  116. package/runtime/scripts/hfs/param-names.mjs +18 -0
  117. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
  118. package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
  119. package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
  120. package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
  121. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  122. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
  123. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
  124. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
  125. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
  126. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
  127. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  128. package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
  129. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
  130. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  131. package/runtime/scripts/lib/git.mjs +36 -0
  132. package/runtime/scripts/lib/i18n.mjs +86 -0
  133. package/runtime/scripts/lib/json.mjs +13 -3
  134. package/runtime/scripts/lib/language.mjs +26 -7
  135. package/runtime/scripts/lib/line-count.mjs +9 -0
  136. package/runtime/scripts/lib/list.mjs +20 -0
  137. package/runtime/scripts/lib/path-key.mjs +59 -2
  138. package/runtime/scripts/lib/read-text.mjs +34 -0
  139. package/runtime/scripts/lib/regex.mjs +4 -0
  140. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  141. package/runtime/scripts/lib/sops-envelope.mjs +58 -0
  142. package/runtime/scripts/lib/stack-services.mjs +7 -31
  143. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  144. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +5 -15
  145. package/schema-authority.mjs +14 -12
  146. package/service-deps.mjs +7 -13
  147. package/service-isolation.mjs +72 -0
  148. package/size-budget.mjs +3 -2
  149. package/test-world.mjs +5 -4
  150. package/testing.mjs +27 -24
  151. package/transport.mjs +12 -9
  152. package/unit-spec.mjs +1 -1
  153. package/user-copy.mjs +5 -4
  154. package/webhooks.mjs +173 -0
  155. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  156. package/runtime/scripts/lib/recorded-lines.mjs +0 -27
  157. package/runtime/scripts/lib/test-secrets.mjs +0 -116
  158. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  159. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  160. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  161. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  162. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  163. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  164. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  165. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  166. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  167. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  168. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  169. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  170. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  171. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
package/transport.mjs CHANGED
@@ -13,7 +13,7 @@ import ts from "typescript"
13
13
  import { walk } from "./lib/ast.mjs"
14
14
  import { hfsOf } from "./lib/hfs.mjs"
15
15
  import { decoratorArguments, decoratorCallee, importOf, isImportedFrom, moduleReferences } from "./lib/import-source.mjs"
16
- import { isTransportSlot } from "./lib/transport-slots.mjs"
16
+ import { isTransportSlot, KIND_DOOR_SLOTS } from "./lib/transport-slots.mjs"
17
17
  import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
18
18
 
19
19
  /** The HTTP route decorators of `@nestjs/common`. */
@@ -25,15 +25,17 @@ const GRAPHQL_OPERATIONS = ["Query", "Mutation", "Subscription", "ResolveField"]
25
25
  /** The slot of REST doors. */
26
26
  const HTTP_SLOT = "be.transport.http"
27
27
 
28
- /** Slots whose classes are entered through a method the framework calls on a schedule, a queue or a command line. */
29
- const PLAIN_ENTRY_SLOTS = new Set(["be.transport.message", "be.transport.schedule", "be.feature.transport.cli"])
28
+ /** The slot of provider webhook doors (the REST doors of the webhook kind). */
29
+ const WEBHOOK_SLOT = "be.feature.webhooks.http"
30
30
 
31
- /** True for the decorators that make a method a transport handler. */
31
+ /** Slots whose classes are entered through a method the framework calls on a queue. */
32
+ const PLAIN_ENTRY_SLOTS = new Set(["be.transport.message"])
33
+
34
+ /** True for the decorators that make a method a transport handler (a socket handler belongs to the realtime kind, which has its own laws). */
32
35
  const isRouteDecorator = (context, decorator) => {
33
36
  const callee = decoratorCallee(decorator)
34
37
  return isImportedFrom(context, callee, "@nestjs/common", HTTP_ROUTES)
35
38
  || isImportedFrom(context, callee, "@nestjs/graphql", GRAPHQL_OPERATIONS)
36
- || isImportedFrom(context, callee, "@nestjs/websockets", "SubscribeMessage")
37
39
  }
38
40
 
39
41
  /** True when the class declares (transitively through its members' decorators) nothing but is a transport door: it has handler methods. */
@@ -115,7 +117,8 @@ export const transportIsThin = {
115
117
  create(context) {
116
118
  const hfs = hfsOf(context)
117
119
  const slot = hfs.slotOf(context.filename)
118
- if (!isTransportSlot(slot)) return {}
120
+ // a webhook or realtime door answers to its own kind laws (webhooks.mjs, realtime.mjs), not to the one-dispatch shape
121
+ if (!isTransportSlot(slot) || KIND_DOOR_SLOTS.includes(slot)) return {}
119
122
  const sourceCode = context.sourceCode
120
123
  const isMapper = (callee) => {
121
124
  if (callee.type !== "Identifier") return false
@@ -345,19 +348,19 @@ export const noGraphqlJson = {
345
348
  /** True for a `@Controller` decorator of `@nestjs/common`. */
346
349
  const isController = (context, decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Controller")
347
350
 
348
- /** A door is a door whatever its protocol, and every REST door lives in the `transport/http` slot of a feature. */
351
+ /** A door is a door whatever its protocol, and every REST door lives in the `transport/http` slot of a feature or in the door slot of the webhook kind. */
349
352
  export const doorLivesInFeatures = {
350
353
  meta: {
351
354
  type: "problem",
352
355
  docs: { description: "A `@Controller` is declared in the `transport/http` slot of a feature." },
353
356
  schema: [],
354
357
  messages: {
355
- wrongSlot: "A `@Controller` belongs in `src/features/<feature>/transport/http/` (slot `be.transport.http`); this file is in slot `{{slot}}`. A door parked among the capabilities it calls reads as one and gets imported like one.",
358
+ wrongSlot: "A `@Controller` belongs in `src/features/api/<feature>/transport/http/` (slot `be.transport.http`) or, for a provider webhook, in `src/features/webhooks/<provider>/transport/http/` (slot `be.feature.webhooks.http`); this file is in slot `{{slot}}`. A door parked among the capabilities it calls reads as one and gets imported like one.",
356
359
  },
357
360
  },
358
361
  create(context) {
359
362
  const slot = hfsOf(context).slotOf(context.filename)
360
- if (slot === HTTP_SLOT) return {}
363
+ if (slot === HTTP_SLOT || slot === WEBHOOK_SLOT) return {}
361
364
  return {
362
365
  Decorator(node) {
363
366
  if (isController(context, node)) context.report({ node, messageId: "wrongSlot", data: { slot: slot ?? "none" } })
package/unit-spec.mjs CHANGED
@@ -374,7 +374,7 @@ const formOf = (context, node, depth = 0) => {
374
374
  }
375
375
 
376
376
  /** The kit type each double produces, by declared name: what a parameter or a property typed so is a double of. */
377
- const KIT_TYPES = Object.freeze({ MockEntityManager: "mockEntityManager", FakeCache: "fakeCache", FakeLock: "fakeLock", RecordingOutbox: "recordingOutbox", FakeClock: "FakeClock", FakeIds: "fakeIds", FakeTransaction: "fakeTransaction" })
377
+ const KIT_TYPES = Object.freeze({ MockEntityManager: "mockEntityManager", FakeCache: "fakeCache", FakeLock: "fakeLock", RecordingEventBus: "recordingEventBus", RecordingQueueOutbox: "recordingQueueOutbox", FakeClock: "FakeClock", FakeIds: "fakeIds", FakeTransaction: "fakeTransaction" })
378
378
 
379
379
  /** The kit double a value's TYPE says it is (a helper parameter typed `MockEntityManager`, `tx.em` of a `fakeTransaction`), or null. */
380
380
  const kitDoubleOfType = (context, node, kit) => {
package/user-copy.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * (the constructed TYPE decides, not a `*Error` name); the built-in `Error` family itself is left for
12
12
  * internal invariants;
13
13
  * 2. a `subject`/`title`/`body`/`text`/`message` property of an object handed to an outbound port - a call on a
14
- * receiver whose type is declared by an `integrations` owner or by `platform/messaging` (the receiver's type
14
+ * receiver whose type is declared by an `integrations` owner or by `platform/event-bus` or `platform/queue` (the receiver's type
15
15
  * decides, not a method name such as `send`);
16
16
  * 3. a `message`/`description`/`title` property of an object literal built in a transport slot.
17
17
  *
@@ -20,6 +20,7 @@
20
20
  * file's syntax for and cannot split into its static and interpolated halves without guessing. Specs get the same
21
21
  * law: fixture text is a `messages` catalog key or lives in a fixture.
22
22
  */
23
+ import { isTransportSlot } from "./lib/transport-slots.mjs"
23
24
  import { keyName, staticText } from "./lib/ast.mjs"
24
25
  import { hfsOf } from "./lib/hfs.mjs"
25
26
  import { ownerNameOf } from "./lib/ports.mjs"
@@ -52,9 +53,9 @@ const isCustomError = (program, checker, type) => {
52
53
  return extendsError(type)
53
54
  }
54
55
 
55
- /** Whether a receiver's type is declared by an `integrations` owner or by `platform/messaging`: an outbound port. */
56
+ /** Whether a receiver's type is declared by an `integrations` owner or by `platform/event-bus` or `platform/queue`: an outbound port. */
56
57
  const isOutboundPort = (context, hfs, node) =>
57
- typeOrigins(context, node).some((origin) => origin.module === null && (hfs.tierOf(origin.file) === "integrations" || (hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === "messaging")))
58
+ typeOrigins(context, node).some((origin) => origin.module === null && (hfs.tierOf(origin.file) === "integrations" || (hfs.tierOf(origin.file) === "platform" && ["event-bus", "queue"].includes(ownerNameOf(hfs, origin.file) ?? ""))))
58
59
 
59
60
  /** User-facing text is looked up in the catalog by key, never written where it is used. */
60
61
  export const userCopyThroughCatalog = {
@@ -73,7 +74,7 @@ export const userCopyThroughCatalog = {
73
74
  if (isDeclarationFile(filename)) return {}
74
75
  const hfs = hfsOf(context)
75
76
  const slot = hfs.slotOf(filename) ?? ""
76
- const inTransport = slot.startsWith("be.transport.") || slot === "be.feature.transport.cli"
77
+ const inTransport = isTransportSlot(slot)
77
78
  const { program, checker, toTs } = typed(context)
78
79
  const isOutboundCall = (node) =>
79
80
  node?.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && isOutboundPort(context, hfs, node.callee.object)
package/webhooks.mjs ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The rules that hold the webhook kind (R182 `BE_WEBHOOK_SHAPE`, R183 `BE_WEBHOOK_UNVERIFIED`).
3
+ *
4
+ * A provider webhook (`src/features/webhooks/<provider>/transport/http/<provider>.webhook.ts`, slot `be.feature.webhooks.http`) is a signed intake door. It
5
+ * proves the delivery came from the provider, then hands it to exactly ONE domain intake method, which records it and
6
+ * publishes the event inside its own transaction (`eventBus.publish(event, tx)` is called only from `modules/domain`). The door
7
+ * writes nothing and decides nothing:
8
+ *
9
+ * - `webhook-shape` (R182): the door is one `@Controller` with one `@Post` handler, `@Public({ reason: PublicReason.SignedWebhook })`;
10
+ * it injects only the `WebhookSignatureService` of `platform/http-security` and services of `modules/domain`; its handler has no
11
+ * branch, loop or `try`, makes exactly one call on a domain service, calls on the signature service only `verify`, and returns
12
+ * nothing.
13
+ * - `webhook-verify-first` (R183): the first statement of the handler is the unconditional `verify` call, so no path reaches the
14
+ * intake with an unproven delivery.
15
+ *
16
+ * What a receiver is comes from its TYPE (where the class is declared); what a decorator is comes from the import that binds it.
17
+ * No rule here tests a directory spelled in a path pattern or a variable name.
18
+ */
19
+ import { walk } from "./lib/ast.mjs"
20
+ import { callsOf, decoratorsFrom, hasClassDecorator, injectedMembers, methodsDecoratedBy } from "./lib/doors.mjs"
21
+ import { hfsOf } from "./lib/hfs.mjs"
22
+ import { decoratorArguments, decoratorCallee, importOf } from "./lib/import-source.mjs"
23
+ import { baseName, enumMemberOf, isOwnedType } from "./lib/ports.mjs"
24
+ import { typeOrigins } from "./lib/types.mjs"
25
+
26
+ /** The slot of provider webhook doors. */
27
+ const SLOT = "be.feature.webhooks.http"
28
+
29
+ /** The HTTP verbs of `@nestjs/common`; only `Post` is a webhook method. */
30
+ const HTTP_VERBS = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head"]
31
+
32
+ /** Statements and expressions that are a decision or a repetition: none of them belongs in a door. */
33
+ const DECISION_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement"])
34
+
35
+ /** The `WebhookSignatureService` of `platform/http-security`. */
36
+ const isSignatureType = (context, node) => isOwnedType(context, node, { name: "WebhookSignatureService", capability: "http-security", tier: "platform" })
37
+
38
+ /** A service of `modules/domain`: a class declared in a `<name>.service.ts` of the domain tier. */
39
+ const isDomainServiceType = (context, hfs, node) =>
40
+ typeOrigins(context, node).some((origin) => origin.module === null && hfs.tierOf(origin.file) === "domain" && baseName(origin.file).endsWith(".service.ts"))
41
+
42
+ /** The `@Controller` classes of a webhook file, with their handler methods. */
43
+ const doorsOf = (context, program) => {
44
+ const doors = []
45
+ walk(program, (node) => {
46
+ if (node.type !== "ClassDeclaration") return
47
+ if (hasClassDecorator(context, node, "@nestjs/common", "Controller")) doors.push(node)
48
+ })
49
+ return doors
50
+ }
51
+
52
+ /** The route methods of a controller: every method carrying an HTTP verb decorator. */
53
+ const routeMethods = (context, controller) => methodsDecoratedBy(context, controller, "@nestjs/common", HTTP_VERBS)
54
+
55
+ /** The handler of a webhook door: its `@Post` method, or null when it has none. */
56
+ const handlerOf = (context, controller) => methodsDecoratedBy(context, controller, "@nestjs/common", "Post")[0] ?? null
57
+
58
+ /** True when the method carries `@Public({ reason: PublicReason.SignedWebhook })`: the reason is read from the enum member the argument is. */
59
+ const isSignedWebhookDoor = (context, method) =>
60
+ (method.decorators ?? []).some((decorator) => {
61
+ const callee = decoratorCallee(decorator)
62
+ if (callee?.type !== "Identifier" || importOf(context, callee) === null) return false
63
+ const options = decoratorArguments(decorator)[0]
64
+ if (options?.type !== "ObjectExpression") return false
65
+ const reason = options.properties.find((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "reason")
66
+ const member = reason ? enumMemberOf(context, reason.value) : null
67
+ return member !== null && member.enumName === "PublicReason" && member.member === "SignedWebhook"
68
+ })
69
+
70
+ /** A provider webhook door: one signed `@Post`, the signature proof, one domain intake call, nothing else. */
71
+ export const webhookShape = {
72
+ meta: {
73
+ type: "problem",
74
+ docs: { description: "A webhook door is one `@Controller` with one `@Public({ reason: PublicReason.SignedWebhook })` `@Post` handler that injects only the `WebhookSignatureService` and domain services, verifies, makes exactly one domain call and returns nothing." },
75
+ schema: [],
76
+ messages: {
77
+ noDoor: "A webhook file declares one `@Controller` class: the door of the provider. Nothing else lives in `<provider>.webhook.ts`.",
78
+ injects: "`{{what}}` is injected into a webhook door. A webhook injects only the `WebhookSignatureService` of `platform/http-security` and services of `modules/domain`; a bus, a repository, an `EntityManager` or a queue never reaches it. The intake is one domain service method that records the delivery and publishes the event inside its own transaction.",
79
+ route: "A webhook door has exactly one `@Post` handler (this class has {{count}} route methods{{verbs}}). One provider endpoint is one door; another route belongs in its own provider folder or in an api feature.",
80
+ notPublic: "`{{name}}` is not `@Public({ reason: PublicReason.SignedWebhook })`. The signature is the authentication of a webhook: it is public by that declaration and no other, so the open doors stay readable and the strict rate tier applies.",
81
+ branch: "`{{what}}` is a decision or a loop in a webhook door. The door proves the delivery and hands it over: a refusal is the thrown error of `verify`, and every other decision belongs in the domain intake method.",
82
+ call: "`{{what}}` is a call a webhook door does not make. The handler calls `verify` of the `WebhookSignatureService` once and one method of a domain service once; parsing, mapping and publishing belong in the domain service.",
83
+ intakeCalls: "`{{name}}` calls domain services {{count}} times. A webhook hands the verified delivery to exactly one domain intake method (it records the delivery and publishes the event in one transaction); a second call is orchestration that belongs in that method.",
84
+ returns: "`{{name}}` returns a value. A webhook acknowledges with its status and an empty body; it returns nothing of the domain to the provider.",
85
+ },
86
+ },
87
+ create(context) {
88
+ const hfs = hfsOf(context)
89
+ if (hfs.slotOf(context.filename) !== SLOT || !baseName(context.filename).endsWith(".webhook.ts")) return {}
90
+ return {
91
+ "Program:exit"(program) {
92
+ const doors = doorsOf(context, program)
93
+ if (doors.length !== 1) {
94
+ context.report({ node: program, messageId: "noDoor" })
95
+ return
96
+ }
97
+ const door = doors[0]
98
+ for (const { node, annotation } of injectedMembers(context, door)) {
99
+ if (!annotation || !(isSignatureType(context, annotation) || isDomainServiceType(context, hfs, annotation))) context.report({ node, messageId: "injects", data: { what: context.sourceCode.getText(node) } })
100
+ }
101
+ const routes = routeMethods(context, door)
102
+ const handler = handlerOf(context, door)
103
+ if (routes.length !== 1 || handler === null) {
104
+ const verbs = routes.length > 0 ? ` (${routes.map((route) => decoratorsFrom(context, route, "@nestjs/common", HTTP_VERBS).map((decorator) => decoratorCallee(decorator).name ?? "route").join("+")).join(", ")})` : ""
105
+ context.report({ node: door, messageId: "route", data: { count: String(routes.length), verbs } })
106
+ return
107
+ }
108
+ const name = handler.key.type === "Identifier" ? handler.key.name : "the handler"
109
+ if (!isSignedWebhookDoor(context, handler)) context.report({ node: handler.key, messageId: "notPublic", data: { name } })
110
+ walk(handler.value.body, (child) => {
111
+ if (DECISION_NODES.has(child.type)) context.report({ node: child, messageId: "branch", data: { what: child.type === "LogicalExpression" ? child.operator : child.type.replace(/(Statement|Expression)$/, "").toLowerCase() } })
112
+ else if (child.type === "ReturnStatement" && child.argument) context.report({ node: child, messageId: "returns", data: { name } })
113
+ })
114
+ let intake = 0
115
+ for (const { call, receiver, method } of callsOf(handler)) {
116
+ if (receiver !== null && isSignatureType(context, receiver) && method === "verify") continue
117
+ if (receiver !== null && isDomainServiceType(context, hfs, receiver)) {
118
+ intake += 1
119
+ continue
120
+ }
121
+ context.report({ node: call, messageId: "call", data: { what: context.sourceCode.getText(call.callee) } })
122
+ }
123
+ if (intake !== 1) context.report({ node: handler.key, messageId: "intakeCalls", data: { name, count: String(intake) } })
124
+ },
125
+ }
126
+ },
127
+ }
128
+
129
+ /** The first statement of the handler is the unconditional signature proof. */
130
+ export const webhookVerifyFirst = {
131
+ meta: {
132
+ type: "problem",
133
+ docs: { description: "The first statement of a webhook handler is the unconditional `verify` call of the `WebhookSignatureService` (signature and replay window), before anything else runs." },
134
+ schema: [],
135
+ messages: {
136
+ first: "`{{name}}` does not start with `verify` of the `WebhookSignatureService`. The first statement of a webhook handler is `this.<signature>.verify({ rawBody, signature, timestamp })` (awaited when it is async): a delivery that fails the signature or the replay window throws before any other code runs, so no path reaches the intake unproven.",
137
+ },
138
+ },
139
+ create(context) {
140
+ const hfs = hfsOf(context)
141
+ if (hfs.slotOf(context.filename) !== SLOT || !baseName(context.filename).endsWith(".webhook.ts")) return {}
142
+ return {
143
+ "Program:exit"(program) {
144
+ for (const door of doorsOf(context, program)) {
145
+ const handler = handlerOf(context, door)
146
+ if (handler === null) continue
147
+ const first = handler.value.body?.body[0]
148
+ let expression = first?.type === "ExpressionStatement" ? first.expression : null
149
+ if (expression?.type === "AwaitExpression") expression = expression.argument
150
+ const proven = expression?.type === "CallExpression"
151
+ && expression.callee.type === "MemberExpression"
152
+ && !expression.callee.computed
153
+ && expression.callee.property.type === "Identifier"
154
+ && expression.callee.property.name === "verify"
155
+ && isSignatureType(context, expression.callee.object)
156
+ if (!proven) context.report({ node: handler.key, messageId: "first", data: { name: handler.key.type === "Identifier" ? handler.key.name : "the handler" } })
157
+ }
158
+ },
159
+ }
160
+ },
161
+ }
162
+
163
+ /** The rules this law contributes to the plugin. */
164
+ export const rules = {
165
+ "webhook-shape": webhookShape,
166
+ "webhook-verify-first": webhookVerifyFirst,
167
+ }
168
+
169
+ /** Every rule of this law at `error`. */
170
+ export const recommended = {
171
+ "starci-be/webhook-shape": "error",
172
+ "starci-be/webhook-verify-first": "error",
173
+ }
@@ -1,16 +0,0 @@
1
- // decrypt.mjs — one `sops` decryption: the custody reads (scripts/lib/sops-exec-env.mjs readCustody) and the test
2
- // secrets (scripts/lib/test-secrets.mjs testSecret). The plaintext stays in this process's memory (stdout over a pipe).
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
6
-
7
- /**
8
- * Run `<bin> <args>` with SOPS_AGE_KEY_FILE defaulted to ~/.starci/master.identity. {status, stdout, stderr, error}.
9
- */
10
- export function sopsDecrypt(bin, args, { env = process.env, cwd = undefined, maxBuffer = 16 * 1024 * 1024 } = {}) {
11
- const r = spawnSync(bin, args, {
12
- cwd, encoding: 'utf8', windowsHide: true, maxBuffer,
13
- env: { ...env, SOPS_AGE_KEY_FILE: env.SOPS_AGE_KEY_FILE || path.join(os.homedir(), '.starci', 'master.identity') },
14
- });
15
- return { status: r.status, stdout: r.stdout, stderr: r.stderr, error: r.error ?? null };
16
- }
@@ -1,27 +0,0 @@
1
- // recorded-lines.mjs - the one home of the file-size ratchet's arithmetic (R20 HFS_SIZE_GROWTH), shared by the back-end
2
- // and front-end lint canons (each ships a byte copy in its runtime/ bundle, kept by packages/hfs/scripts/sync-runtime.mjs).
3
- import path from 'node:path';
4
- import { runGit } from '../api/git/lib.mjs';
5
-
6
- /** The line count of a text; a trailing newline does not open a line. */
7
- export const lineCount = (text) => {
8
- const lines = String(text).split(/\r?\n/);
9
- return lines.at(-1) === '' ? lines.length - 1 : lines.length;
10
- };
11
-
12
- /** The line count above which a file may not grow: the soft budget when `hardGrowth` holds, else no limit. */
13
- export const hardGrowthLines = ({ soft, hardGrowth }) => (hardGrowth ? soft : Number.POSITIVE_INFINITY);
14
-
15
- /**
16
- * The line count of a file at a git revision, or null when the file did not exist there or git is absent.
17
- *
18
- * @param {string} filename - Absolute path of the file.
19
- * @param {string} ref - The revision, `HEAD` by default (the parent of the commit being made).
20
- * @returns {number | null} The recorded line count.
21
- */
22
- export const recordedLines = (filename, ref = 'HEAD') => {
23
- const r = runGit(['show', `${ref}:./${path.basename(filename)}`], { dir: path.dirname(filename), maxBuffer: 64 * 1024 * 1024 });
24
- // no repository, no such revision, or a new file: all mean there is no recorded size to compare with
25
- if (r.error || r.status !== 0) return null;
26
- return lineCount(r.stdout);
27
- };
@@ -1,116 +0,0 @@
1
- #!/usr/bin/env node
2
- // test-secrets.mjs — test credentials follow the product repository's own `.starcistacks` convention (owner ruling
3
- // push-scan-test-secrets-encrypted, 2026-09-28: they work exactly like the stack custody and a plaintext password is never pushed).
4
- // There is no store of the runtime's own: a test credential lives at `<repo>/.starcistacks/<stack>/secrets/test/<name>`,
5
- // its plaintext git-ignored by the repo's `.starcistacks/**` rules and only the sops-encrypted twin `<name>.enc`
6
- // committed. It is written with the repository's existing command (`node scripts/stack-secret.mjs set
7
- // <stack>/secrets/test/<name>`, npm run secret:set), which encrypts against the recipients `.sops.yaml` names and
8
- // removes the plaintext; this module only READS: the local plaintext when present, else the `.enc` decrypted by sops
9
- // with the shared age identity (SOPS_AGE_KEY_FILE, default ~/.starci/master.identity) into memory.
10
- // A credential that need not be stable across runs (a disposable account registered per run) is generated per run
11
- // and stored nowhere. A value is never logged.
12
- //
13
- // node scripts/lib/test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal] prints nothing unless --reveal
14
- //
15
- // In a script: import { testSecret } from '<runtime>/scripts/lib/test-secrets.mjs';
16
- // const password = testSecret('login-capture-password', { repo });
17
- // The push secret scan (scripts/supervisor/push-mains.mjs) passes a `.enc` only when isSopsEnvelope holds.
18
- import fs from 'node:fs';
19
- import path from 'node:path';
20
- import { sopsDecrypt } from '../api/sops/decrypt.mjs';
21
- import { fileURLToPath } from 'node:url';
22
- import { parseYaml } from '../../engine/yaml.mjs';
23
-
24
- const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
25
- const STACK = /^[a-z0-9][a-z0-9-]{0,31}$/;
26
- /** One sops-encrypted leaf: `ENC[AES256_GCM,data:...,iv:...,tag:...,type:...]` (data may be empty). */
27
- const SOPS_VALUE = /^ENC\[AES256_GCM,data:[A-Za-z0-9+/=]*,iv:[A-Za-z0-9+/=]+,tag:[A-Za-z0-9+/=]+,type:[a-z]+\]$/;
28
-
29
- /** Where test credential `name` of `stack` lives in `repo`: {plain, enc, rel}. `rel` is the path the repo's
30
- * `stack-secret.mjs set` takes. */
31
- export function testSecretPaths(name, { repo, stack = 'dev' } = {}) {
32
- if (!NAME.test(String(name ?? ''))) throw new Error(`test secret name must match ${NAME} (got ${JSON.stringify(name)})`);
33
- if (!STACK.test(String(stack ?? ''))) throw new Error(`stack must match ${STACK} (got ${JSON.stringify(stack)})`);
34
- if (!repo) throw new Error('test secret needs {repo}: the product repository whose .starcistacks holds it');
35
- const rel = `${stack}/secrets/test/${name}`;
36
- const plain = path.join(path.resolve(String(repo)), '.starcistacks', stack, 'secrets', 'test', name);
37
- return { plain, enc: `${plain}.enc`, rel };
38
- }
39
-
40
- /** The command that stores a test credential, in the product repository's own tooling. */
41
- export const setCommand = (name = '<name>', stack = 'dev') => `node scripts/stack-secret.mjs set ${stack}/secrets/test/${name}`;
42
-
43
- /** sops' format for a plaintext path, as the repository's stack-secret.mjs formatFor decides it. */
44
- export const sopsFormatFor = (file) => (file.endsWith('.env') ? 'dotenv' : /\.json$/.test(file) ? 'json' : /\.(ya?ml|kubeconfig)$/.test(file) ? 'yaml' : 'binary');
45
-
46
- /** The sops binary: PATH, then winget's Links and Packages (spawn without a shell ignores PATHEXT). */
47
- export function resolveSops(env = process.env) {
48
- const win = process.platform === 'win32';
49
- const names = win ? ['sops.exe', 'sops'] : ['sops'];
50
- const dirs = String(env.PATH ?? '').split(win ? ';' : ':').filter(Boolean);
51
- if (win && env.LOCALAPPDATA) {
52
- const winget = path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
53
- dirs.push(path.join(winget, 'Links'));
54
- const packages = path.join(winget, 'Packages');
55
- try { for (const e of fs.readdirSync(packages)) if (/sops/i.test(e)) dirs.push(path.join(packages, e)); } catch { /* none */ }
56
- }
57
- for (const dir of dirs) for (const n of names) { const f = path.join(dir, n); try { if (fs.statSync(f).isFile()) return f; } catch { /* next */ } }
58
- return null;
59
- }
60
-
61
- /** Read test credential `name`: the local plaintext when present, else its committed `.enc` through sops. */
62
- export function testSecret(name, { repo, stack = 'dev', env = process.env, sops = null } = {}) {
63
- const { plain, enc, rel } = testSecretPaths(name, { repo, stack });
64
- if (fs.existsSync(plain)) return fs.readFileSync(plain, 'utf8').replace(/\r?\n$/, '');
65
- if (!fs.existsSync(enc)) throw new Error(`test secret ${name} is not in .starcistacks/${rel}.enc; store it with \`${setCommand(name, stack)}\` in ${repo}`);
66
- const bin = sops ?? resolveSops(env);
67
- if (!bin) throw new Error('sops is not installed (Windows: winget install Mozilla.SOPS)');
68
- const format = sopsFormatFor(plain);
69
- const r = sopsDecrypt(bin, ['--decrypt', '--input-type', format, '--output-type', format, enc], { cwd: path.resolve(String(repo)), env, maxBuffer: 1024 * 1024 });
70
- if (r.status !== 0) throw new Error(`test secret ${name}: sops could not decrypt .starcistacks/${rel}.enc (${String(r.stderr ?? r.error?.message ?? '').trim().split(/\r?\n/).pop()})`);
71
- return String(r.stdout).replace(/\r?\n$/, '');
72
- }
73
-
74
- /** Every leaf outside the `sops` block is an ENC[...] value (or null); the block itself carries a mac. */
75
- const allEncrypted = (node) => {
76
- if (node === null) return true;
77
- if (Array.isArray(node)) return node.every(allEncrypted);
78
- if (typeof node === 'object') return Object.values(node).every(allEncrypted);
79
- return typeof node === 'string' && SOPS_VALUE.test(node);
80
- };
81
- const sopsTree = (doc) => !!doc && typeof doc === 'object' && !Array.isArray(doc) && !!doc.sops && typeof doc.sops === 'object'
82
- && typeof doc.sops.mac === 'string' && SOPS_VALUE.test(doc.sops.mac)
83
- && Object.keys(doc).length > 1 && Object.entries(doc).every(([k, v]) => k === 'sops' || allEncrypted(v));
84
-
85
- /**
86
- * True only for a sops-encrypted file with no plaintext value: binary/json form ({"data": ENC[...], "sops": {...}}),
87
- * yaml form (every value ENC[...] plus the sops block) or dotenv form (KEY=ENC[...] lines plus sops_* metadata).
88
- * The sops metadata must carry its mac. A key kept plaintext through `unencrypted_suffix` does not pass.
89
- */
90
- export function isSopsEnvelope(text) {
91
- const src = String(text ?? '').replace(/^/, '');
92
- if (!src.trim()) return false;
93
- try { return sopsTree(JSON.parse(src)); } catch { /* not JSON */ }
94
- const lines = src.split(/\r?\n/).filter((l) => l.trim() && !/^\s*#/.test(l));
95
- if (lines.length && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))) {
96
- const pairs = lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]);
97
- const meta = pairs.filter(([k]) => k.startsWith('sops_'));
98
- const vals = pairs.filter(([k]) => !k.startsWith('sops_'));
99
- return vals.length > 0 && vals.every(([, v]) => SOPS_VALUE.test(v)) && meta.some(([k, v]) => k === 'sops_mac' && SOPS_VALUE.test(v));
100
- }
101
- try { return sopsTree(parseYaml(src)); } catch { return false; }
102
- }
103
-
104
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
105
- const [cmd, name, ...rest] = process.argv.slice(2);
106
- const opt = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
107
- try {
108
- if (cmd === 'get') {
109
- const value = testSecret(name, { repo: opt('--repo'), stack: opt('--stack') ?? 'dev' });
110
- if (rest.includes('--reveal')) process.stdout.write(`${value}\n`);
111
- } else {
112
- console.error(`usage: test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal]\nstore one with the product repo's own command: ${setCommand()}`);
113
- process.exitCode = 2;
114
- }
115
- } catch (error) { console.error(String(error?.message ?? error)); process.exitCode = 1; }
116
- }