@starci/eslint-canon-be 3.3.1 → 3.4.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 (91) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/app-composition.mjs +348 -3
  4. package/authorization.mjs +68 -2
  5. package/builders.mjs +2 -2
  6. package/config-owner.mjs +44 -15
  7. package/default-deny.mjs +80 -9
  8. package/edge-hardening.mjs +139 -0
  9. package/feature-thin.mjs +78 -2
  10. package/function-budget.mjs +49 -0
  11. package/health-ready.mjs +539 -0
  12. package/index.mjs +37 -0
  13. package/infra-owner.mjs +3 -0
  14. package/jwt-alg.mjs +45 -0
  15. package/lib/subject-values.mjs +55 -0
  16. package/lib/unit-spec.mjs +0 -3
  17. package/lib/webhook-handoff-inbox.mjs +260 -0
  18. package/lib/webhook-handoff-intake.mjs +140 -0
  19. package/lib/why.mjs +13 -0
  20. package/module-shape.mjs +147 -51
  21. package/operations.mjs +165 -1
  22. package/package.json +1 -1
  23. package/realtime.mjs +59 -22
  24. package/resilience.mjs +45 -14
  25. package/runtime/config.example.yaml +7 -6
  26. package/runtime/engine/config.mjs +10 -18
  27. package/runtime/engine/removed-vocabulary.mjs +0 -2
  28. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  29. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  30. package/runtime/knowledge/hfs/rules.yaml +2855 -163
  31. package/runtime/knowledge/hfs/slots.yaml +333 -62
  32. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  33. package/runtime/knowledge/sonar-gate.yaml +1 -1
  34. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  35. package/runtime/modules/models/runtimes.yaml +5 -7
  36. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  37. package/runtime/scripts/api/git/lib.mjs +4 -1
  38. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  39. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  40. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  41. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  42. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  43. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  44. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  45. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  46. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  47. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  48. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  49. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  50. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  51. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  52. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  53. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  54. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  55. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  56. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  57. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  58. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  59. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  60. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  61. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  62. package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
  63. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  64. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  65. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  66. package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
  67. package/runtime/scripts/hfs/param-names.mjs +1 -1
  68. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  69. package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
  70. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  71. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  72. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  73. package/runtime/scripts/hfs/slots.mjs +3 -2
  74. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  75. package/runtime/scripts/lib/env.mjs +20 -0
  76. package/runtime/scripts/lib/event-contract.mjs +107 -0
  77. package/runtime/scripts/lib/git.mjs +1 -1
  78. package/runtime/scripts/lib/i18n.mjs +0 -2
  79. package/runtime/scripts/lib/json-clone.mjs +61 -0
  80. package/runtime/scripts/lib/language.mjs +1 -1
  81. package/runtime/scripts/lib/list.mjs +4 -0
  82. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  83. package/runtime/scripts/lib/stack-services.mjs +1 -1
  84. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  85. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  86. package/service-isolation.mjs +3 -0
  87. package/size-budget.mjs +9 -5
  88. package/third-party-owner.mjs +149 -0
  89. package/transport.mjs +11 -29
  90. package/unit-spec.mjs +270 -84
  91. package/webhooks.mjs +169 -0
package/webhooks.mjs CHANGED
@@ -17,14 +17,18 @@
17
17
  * What a receiver is comes from its TYPE (where the class is declared); what a decorator is comes from the import that binds it.
18
18
  * No rule here tests a directory spelled in a path pattern or a variable name.
19
19
  */
20
+ import ts from "typescript"
20
21
  import { walk } from "./lib/ast.mjs"
21
22
  import { callsOf, decoratorsFrom, hasClassDecorator, injectedMembers, methodsDecoratedBy } from "./lib/doors.mjs"
22
23
  import { hfsOf } from "./lib/hfs.mjs"
23
24
  import { decoratorArguments, decoratorCallee, importOf, isImportedFrom } from "./lib/import-source.mjs"
24
25
  import { baseName, enumMemberOf } from "./lib/ports.mjs"
26
+ import { typed } from "./lib/types.mjs"
25
27
  import { isProviderAcknowledgement } from "./lib/webhook-ack.mjs"
26
28
  import { webhookForbiddenReferences, webhookIntakeDispatchesCommand } from "./lib/webhook-handoff.mjs"
27
29
  import { firstWebhookChallenge, firstWebhookVerification, isWebhookChallengeReturn, isWebhookDomainServiceType as isDomainServiceType, isWebhookSignatureType as isSignatureType } from "./lib/webhook-verifiers.mjs"
30
+ import { webhookInboxKey } from "./lib/webhook-handoff-inbox.mjs"
31
+ import { webhookIntakeProcessesDelivery } from "./lib/webhook-handoff-intake.mjs"
28
32
 
29
33
  /** The slot of provider webhook doors. */
30
34
  const SLOT = "be.feature.webhooks.http"
@@ -173,6 +177,167 @@ const reportDoor = (context, hfs, program) => {
173
177
  for (const challenge of methodsDecoratedBy(context, door, "@nestjs/common", "Get")) reportRoute(context, hfs, challenge, true)
174
178
  }
175
179
 
180
+ /** Resolve a decorator or method through its imported alias to its declaration. */
181
+ const targetSymbol = (checker, node) => {
182
+ const symbol = checker.getSymbolAtLocation(node)
183
+ return symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
184
+ }
185
+
186
+ /** Public metadata belongs to the identity owner, including aliases and namespace imports. */
187
+ const isPublicDecorator = (context, hfs, decorator) => {
188
+ const { checker, toTs } = typed(context)
189
+ const node = toTs(decoratorCallee(decorator))
190
+ const symbol = node && targetSymbol(checker, ts.isPropertyAccessExpression(node) ? node.name : node)
191
+ return symbol?.getName() === "Public" && symbol.getDeclarations()?.some((declaration) => {
192
+ const file = declaration.getSourceFile().fileName.replace(/\\/g, "/")
193
+ return hfs.tierOf(file) === "domain" && hfs.ownerOf(file)?.split("/").at(-1) === "identity"
194
+ })
195
+ }
196
+
197
+ /** Unwrap const/satisfies annotations without granting a computed declaration a proof. */
198
+ const unwrapTs = (node) => {
199
+ while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) node = node.expression
200
+ return node
201
+ }
202
+
203
+ /** Static object properties keep aliases, spreads and computed literal keys readable. Last writes win. */
204
+ const propertyValue = (checker, expression, name, seen = new Set()) => {
205
+ const node = unwrapTs(expression)
206
+ if (!node || seen.has(node)) return null
207
+ seen.add(node)
208
+ if (ts.isObjectLiteralExpression(node)) {
209
+ for (const property of [...node.properties].reverse()) {
210
+ if (ts.isSpreadAssignment(property)) {
211
+ const value = propertyValue(checker, property.expression, name, seen)
212
+ if (value) return value
213
+ } else if (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) {
214
+ const key = ts.isComputedPropertyName(property.name) ? unwrapTs(property.name.expression) : property.name
215
+ if ((ts.isIdentifier(key) || ts.isStringLiteralLike(key)) && key.text === name) return property.initializer ?? property.name
216
+ }
217
+ }
218
+ return null
219
+ }
220
+ const symbol = targetSymbol(checker, ts.isPropertyAccessExpression(node) ? node.name : node)
221
+ for (const declaration of symbol?.getDeclarations() ?? []) {
222
+ const value = propertyValue(checker, declaration.initializer, name, seen)
223
+ if (value) return value
224
+ }
225
+ return null
226
+ }
227
+
228
+ /** A public reason is the actual `PublicReason` member of the identity owner, even behind a const options alias. */
229
+ const publicReason = (context, decorator, member) => {
230
+ const { checker, toTs } = typed(context)
231
+ const options = decoratorArguments(decorator)[0]
232
+ const reason = options && propertyValue(checker, toTs(options), "reason")
233
+ if (!reason) return false
234
+ const type = checker.getTypeAtLocation(unwrapTs(reason))
235
+ const declaration = type.getSymbol()?.getDeclarations()?.[0]
236
+ const owner = declaration?.parent
237
+ if (!declaration || !ts.isEnumMember(declaration) || !owner || !ts.isEnumDeclaration(owner)
238
+ || owner.name.text !== "PublicReason" || declaration.name.getText().replace(/["']/g, "") !== member) return false
239
+ const hfs = hfsOf(context)
240
+ const file = declaration.getSourceFile().fileName.replace(/\\/g, "/")
241
+ return hfs.tierOf(file) === "domain" && hfs.ownerOf(file)?.split("/").at(-1) === "identity"
242
+ }
243
+
244
+ /** A SignedWebhook reason is the actual identity enum member, even behind a const options alias. */
245
+ const signedReason = (context, decorator) => publicReason(context, decorator, "SignedWebhook")
246
+
247
+ /** An explicit verifier exemption names the exact domain method in a webhook HTTP module's exported const array. */
248
+ const declaredVerifier = (context, hfs, method) => {
249
+ if (method?.type !== "MethodDefinition" || hfs.tierOf(context.filename) !== "domain" || !baseName(context.filename).endsWith(".service.ts")) return false
250
+ const { checker, program, toTs } = typed(context)
251
+ const declaration = toTs(method)
252
+ const symbol = declaration?.name && targetSymbol(checker, declaration.name)
253
+ if (!symbol) return false
254
+ return program.getSourceFiles().some((source) => hfs.slotOf(source.fileName) === SLOT && baseName(source.fileName).endsWith(".module.ts")
255
+ && source.statements.some((statement) => ts.isVariableStatement(statement)
256
+ && statement.declarationList.flags & ts.NodeFlags.Const
257
+ && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
258
+ && statement.declarationList.declarations.some((entry) => {
259
+ const array = unwrapTs(entry.initializer)
260
+ return ts.isIdentifier(entry.name) && entry.name.text === "webhookVerifiers" && array && ts.isArrayLiteralExpression(array)
261
+ && array.elements.some((item) => {
262
+ const reference = unwrapTs(item)
263
+ return reference && ts.isPropertyAccessExpression(reference) && targetSymbol(checker, reference.name) === symbol
264
+ })
265
+ })))
266
+ }
267
+
268
+ /** Read literal paths and const aliases through the checker; Nest accepts path lists and Controller options too. */
269
+ const routePaths = (context, decorator) => {
270
+ const { checker, toTs } = typed(context)
271
+ const seen = new Set()
272
+ const read = (original) => {
273
+ const node = unwrapTs(original)
274
+ if (!node || seen.has(node)) return []
275
+ seen.add(node)
276
+ if (ts.isStringLiteralLike(node)) return [node.text]
277
+ if (ts.isArrayLiteralExpression(node)) return node.elements.flatMap(read)
278
+ if (ts.isObjectLiteralExpression(node)) return read(propertyValue(checker, node, "path"))
279
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
280
+ return read(node.left).flatMap((left) => read(node.right).map((right) => left + right))
281
+ }
282
+ if (ts.isTemplateExpression(node)) {
283
+ return node.templateSpans.reduce((values, span) => values.flatMap((value) => read(span.expression).map((part) => value + part + span.literal.text)), [node.head.text])
284
+ }
285
+ const reference = ts.isPropertyAccessExpression(node) ? node.name : node
286
+ const symbol = targetSymbol(checker, reference)
287
+ return symbol?.getDeclarations()?.flatMap((declaration) => read(declaration.initializer)) ?? []
288
+ }
289
+ return decoratorArguments(decorator).flatMap((argument) => read(toTs(argument)))
290
+ }
291
+
292
+ /**
293
+ * A third-party OAuth redirect into the API (owner ruling 2026-10-17 "Theo y tro": the owner delegated the decision to main):
294
+ * a GET named `callback` that declares the closed reason `PublicReason.AuthHandshake`. It is a browser redirect with no
295
+ * signature to verify, so it is not a webhook door. A POST, a `webhook` or `events` path, or any other reason is still judged.
296
+ */
297
+ const isOAuthRedirect = (context, route, publicMetadata, paths) =>
298
+ routeVerbs(context, route).every((verb) => verb === "Get") && publicMetadata.some((decorator) => publicReason(context, decorator, "AuthHandshake"))
299
+ && !paths.some((path) => /webhook|events/i.test(path))
300
+
301
+ /** R263: public webhook traffic always enters the signed, verified webhook kind. */
302
+ export const webhookScope = {
303
+ meta: {
304
+ type: "problem",
305
+ docs: { description: "SignedWebhook metadata belongs only to webhook doors or explicitly declared domain verifiers; public webhook/callback/events routes belong to the webhook kind, except a GET OAuth redirect that declares PublicReason.AuthHandshake." },
306
+ schema: [],
307
+ messages: {
308
+ scope: "[BE_WEBHOOK_SCOPE] SignedWebhook metadata is allowed only on a `<provider>.webhook.ts` door in the webhook HTTP slot or an exact domain method declared in its HTTP module's exported `webhookVerifiers`. Move public webhook, callback and events routes into that door so signature verification and inbox handoff are enforced. A third-party OAuth redirect is exempt only as a GET with @Public({ reason: PublicReason.AuthHandshake }) on a path that is not a webhook or events route.",
309
+ intake: "[BE_WEBHOOK_SCOPE] The domain intake this webhook door calls processes the delivery inside its receipt transaction: a `process` callback, a call to the command or query bus, or a call to a domain service that is not a receipt port. The receipt transaction only records the inbox delivery (the platform Inbox, or a domain receipt port whose constructor takes only platform ports, one of them the Inbox) and publishes the typed outbox event; a reactor or job processes the delivery after commit.",
310
+ },
311
+ },
312
+ create(context) {
313
+ const hfs = hfsOf(context)
314
+ const slot = hfs.slotOf(context.filename)
315
+ if (slot?.startsWith("be.tests.") || /(?:\.spec|\.d)\.ts$/.test(context.filename)) return {}
316
+ const inDoor = slot === SLOT && baseName(context.filename).endsWith(".webhook.ts")
317
+ return {
318
+ CallExpression(call) {
319
+ if (inDoor && call.callee.type === "MemberExpression" && isDomainServiceType(context, hfs, call.callee.object)
320
+ && webhookIntakeProcessesDelivery(context, hfs, call)) context.report({ node: call, messageId: "intake" })
321
+ },
322
+ Decorator(decorator) {
323
+ if (!inDoor && isPublicDecorator(context, hfs, decorator) && signedReason(context, decorator)
324
+ && !declaredVerifier(context, hfs, decorator.parent)) context.report({ node: decorator, messageId: "scope" })
325
+ },
326
+ ClassDeclaration(controller) {
327
+ if (inDoor || !hasClassDecorator(context, controller, "@nestjs/common", "Controller")) return
328
+ const classPublic = (controller.decorators ?? []).filter((decorator) => isPublicDecorator(context, hfs, decorator))
329
+ const prefix = decoratorsFrom(context, controller, "@nestjs/common", "Controller").flatMap((decorator) => routePaths(context, decorator))
330
+ for (const method of routeMethods(context, controller)) {
331
+ const publicMetadata = [...classPublic, ...(method.decorators ?? []).filter((decorator) => isPublicDecorator(context, hfs, decorator))]
332
+ if (publicMetadata.length === 0 || publicMetadata.some((decorator) => signedReason(context, decorator))) continue
333
+ const paths = [...prefix, ...decoratorsFrom(context, method, "@nestjs/common", HTTP_VERBS).flatMap((decorator) => routePaths(context, decorator))]
334
+ if (paths.some((path) => /webhook|callback|events/i.test(path)) && !isOAuthRedirect(context, method, publicMetadata, paths)) context.report({ node: method.key, messageId: "scope" })
335
+ }
336
+ },
337
+ }
338
+ },
339
+ }
340
+
176
341
  /** A provider webhook door: one signed `@Post`, the signature proof, one domain intake call, nothing else. */
177
342
  export const webhookShape = {
178
343
  meta: {
@@ -236,10 +401,14 @@ export const webhookVerifyFirst = {
236
401
  export const rules = {
237
402
  "webhook-shape": webhookShape,
238
403
  "webhook-verify-first": webhookVerifyFirst,
404
+ "webhook-scope": webhookScope,
405
+ "webhook-inbox-key": webhookInboxKey,
239
406
  }
240
407
 
241
408
  /** Every rule of this law at `error`. */
242
409
  export const recommended = {
243
410
  "starci-be/webhook-shape": "error",
244
411
  "starci-be/webhook-verify-first": "error",
412
+ "starci-be/webhook-scope": "error",
413
+ "starci-be/webhook-inbox-key": "error",
245
414
  }