@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.
- package/CHANGELOG.md +15 -0
- package/README.md +2 -2
- package/app-composition.mjs +348 -3
- package/authorization.mjs +68 -2
- package/builders.mjs +2 -2
- package/config-owner.mjs +44 -15
- package/default-deny.mjs +80 -9
- package/edge-hardening.mjs +139 -0
- package/feature-thin.mjs +78 -2
- package/function-budget.mjs +49 -0
- package/health-ready.mjs +539 -0
- package/index.mjs +37 -0
- package/infra-owner.mjs +3 -0
- package/jwt-alg.mjs +45 -0
- package/lib/subject-values.mjs +55 -0
- package/lib/unit-spec.mjs +0 -3
- package/lib/webhook-handoff-inbox.mjs +260 -0
- package/lib/webhook-handoff-intake.mjs +140 -0
- package/lib/why.mjs +13 -0
- package/module-shape.mjs +147 -51
- package/operations.mjs +165 -1
- package/package.json +1 -1
- package/realtime.mjs +59 -22
- package/resilience.mjs +45 -14
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +2855 -163
- package/runtime/knowledge/hfs/slots.yaml +333 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +5 -7
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +4 -1
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/service-isolation.mjs +3 -0
- package/size-budget.mjs +9 -5
- package/third-party-owner.mjs +149 -0
- package/transport.mjs +11 -29
- package/unit-spec.mjs +270 -84
- 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
|
}
|