@starci/eslint-canon-be 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +3401 -152
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- 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/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/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 +3 -3
- 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 +67 -7
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -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 +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- 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/health-ready.mjs
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/** R262: API health routes are wired and readiness checks a required dependency under a failing, bounded timeout. */
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import tsParser from "@typescript-eslint/parser"
|
|
5
|
+
import { appRoleOf } from "./lib/app-roles.mjs"
|
|
6
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
7
|
+
import { decoratorName, keyName, paramParts, staticText, walk } from "./lib/ast.mjs"
|
|
8
|
+
|
|
9
|
+
const unwrap = (node) => {
|
|
10
|
+
while (["TSAsExpression", "TSNonNullExpression", "ChainExpression", "TSSatisfiesExpression"].includes(node?.type)) node = node.expression
|
|
11
|
+
return node
|
|
12
|
+
}
|
|
13
|
+
const memberName = (node) => node?.computed ? staticText(node.property) : keyName(node?.property)
|
|
14
|
+
const typeName = (node) => node?.typeAnnotation?.typeAnnotation?.typeName?.name ?? null
|
|
15
|
+
const methodsOf = (node) => node.body.body.filter((entry) => entry.type === "MethodDefinition")
|
|
16
|
+
const classesOf = (program) => {
|
|
17
|
+
const classes = new Map()
|
|
18
|
+
walk(program, (node) => { if (node.type === "ClassDeclaration" && node.id) classes.set(node.id.name, node) })
|
|
19
|
+
return classes
|
|
20
|
+
}
|
|
21
|
+
const bindingsOf = (program) => {
|
|
22
|
+
const bindings = new Map()
|
|
23
|
+
walk(program, (node) => { if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init) bindings.set(node.id.name, node.init) })
|
|
24
|
+
return bindings
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Expand local constants when proving a race: a timeout promise or duration may be held in a const. */
|
|
28
|
+
function expandedNodes(node, bindings, seen = new Set()) {
|
|
29
|
+
const nodes = []
|
|
30
|
+
walk(node, (child) => {
|
|
31
|
+
nodes.push(child)
|
|
32
|
+
if (child.type !== "Identifier" || seen.has(child.name) || !bindings.has(child.name)) return
|
|
33
|
+
const next = new Set(seen).add(child.name)
|
|
34
|
+
nodes.push(...expandedNodes(bindings.get(child.name), bindings, next))
|
|
35
|
+
})
|
|
36
|
+
return nodes
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sourcePath(source, filename, root) {
|
|
40
|
+
let base = null
|
|
41
|
+
if (source.startsWith(".")) base = path.resolve(path.dirname(filename), source)
|
|
42
|
+
if (source.startsWith("@modules/")) base = path.join(root, "src/modules", source.slice(9))
|
|
43
|
+
if (source.startsWith("@features/")) base = path.join(root, "src/features", source.slice(10))
|
|
44
|
+
if (!base || path.relative(root, base).startsWith("..")) return null
|
|
45
|
+
return [base, `${base}.ts`, path.join(base, "index.ts")].find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) ?? null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readProgram(filename) {
|
|
49
|
+
try { return tsParser.parse(fs.readFileSync(filename, "utf8"), { sourceType: "module", ecmaVersion: "latest" }) } catch { return null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const routesOf = (controller) => {
|
|
53
|
+
const decorator = controller.decorators?.find((entry) => decoratorName(entry) === "Controller")
|
|
54
|
+
if (!decorator) return []
|
|
55
|
+
const prefix = staticText(decorator.expression.arguments?.[0]) ?? ""
|
|
56
|
+
return methodsOf(controller).flatMap((method) => (method.decorators ?? []).filter((entry) => decoratorName(entry) === "Get").flatMap((entry) => {
|
|
57
|
+
const arg = entry.expression.arguments?.[0]
|
|
58
|
+
const paths = arg?.type === "ArrayExpression" ? arg.elements.map(staticText) : [staticText(arg) ?? ""]
|
|
59
|
+
return paths.filter((route) => route !== null).map((route) => ({ route: `/${prefix}/${route}`.replace(/\/+/g, "/").replace(/\/$/, ""), method }))
|
|
60
|
+
}))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function injectedOf(controller) {
|
|
64
|
+
const constructor = methodsOf(controller).find((entry) => entry.kind === "constructor")
|
|
65
|
+
return new Map((constructor?.value.params ?? []).map((param) => {
|
|
66
|
+
const { target } = paramParts(param)
|
|
67
|
+
return [target.name, typeName(target)]
|
|
68
|
+
}).filter(([name]) => name))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const injectedCall = (node, injected) => {
|
|
72
|
+
if (node.type !== "CallExpression" || unwrap(node.callee)?.type !== "MemberExpression") return null
|
|
73
|
+
let owner = unwrap(node.callee.object)
|
|
74
|
+
while (owner?.type === "CallExpression" && unwrap(owner.callee)?.type === "MemberExpression") owner = unwrap(owner.callee.object)
|
|
75
|
+
while (owner?.type === "MemberExpression" && owner.object?.type !== "ThisExpression") owner = unwrap(owner.object)
|
|
76
|
+
if (owner?.type !== "MemberExpression" || owner.object?.type !== "ThisExpression") return null
|
|
77
|
+
const name = memberName(owner)
|
|
78
|
+
if (!injected.has(name) || /(?:queryBus|commandBus|logger|liveness|liveProbe)/i.test(`${name} ${injected.get(name)}`)) return null
|
|
79
|
+
return { name, method: memberName(unwrap(node.callee)), type: injected.get(name) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function positiveDuration(node, bindings, seen = new Set()) {
|
|
83
|
+
node = unwrap(node)
|
|
84
|
+
if (node?.type === "Identifier" && bindings.has(node.name) && !seen.has(node.name)) return positiveDuration(bindings.get(node.name), bindings, new Set(seen).add(node.name))
|
|
85
|
+
return node?.type === "Literal" && typeof node.value === "number" && Number.isFinite(node.value) && node.value > 0 && node.value <= 60000
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolvedType(node, source, seen = new Set()) {
|
|
89
|
+
if (node?.type !== "TSTypeReference") return node
|
|
90
|
+
const target = delegatedClass(node.typeName?.name, source.program, source.filename, source.root)
|
|
91
|
+
if (target?.controller.type !== "TSTypeAliasDeclaration") return null
|
|
92
|
+
const identity = `${target.filename}:${target.controller.id.name}`
|
|
93
|
+
if (seen.has(identity)) return null
|
|
94
|
+
return resolvedType(target.controller.typeAnnotation, { ...target, root: source.root }, new Set(seen).add(identity))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function timerMethod(call, injected, source) {
|
|
98
|
+
const dependency = injectedCall(call, injected)
|
|
99
|
+
if (!dependency) return false
|
|
100
|
+
const target = delegatedClass(dependency.type, source.program, source.filename, source.root)
|
|
101
|
+
const members = target?.controller.body?.body ?? []
|
|
102
|
+
const method = members.find((entry) => keyName(entry.key) === dependency.method)
|
|
103
|
+
const params = method?.params ?? method?.value?.params ?? []
|
|
104
|
+
const location = { ...target, root: source.root }
|
|
105
|
+
const callback = resolvedType(params[0]?.typeAnnotation?.typeAnnotation, location)
|
|
106
|
+
const returns = resolvedType(method?.returnType?.typeAnnotation ?? method?.value?.returnType?.typeAnnotation, location)
|
|
107
|
+
const delay = resolvedType(params[1]?.typeAnnotation?.typeAnnotation, location)
|
|
108
|
+
return callback?.type === "TSFunctionType" && callback.params.length === 0
|
|
109
|
+
&& callback.returnType?.typeAnnotation?.type === "TSVoidKeyword"
|
|
110
|
+
&& delay?.type === "TSNumberKeyword"
|
|
111
|
+
&& returns?.type === "TSFunctionType" && returns.params.length === 0
|
|
112
|
+
&& returns.returnType?.typeAnnotation?.type === "TSVoidKeyword"
|
|
113
|
+
&& timerBinding(dependency, target, source)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function timerToken(name, source) {
|
|
117
|
+
const constructor = methodsOf(source.controller).find((entry) => entry.kind === "constructor")
|
|
118
|
+
const parameter = constructor?.value.params.map(paramParts).find((entry) => entry.target.name === name)
|
|
119
|
+
for (const decorator of parameter?.decorators ?? []) {
|
|
120
|
+
const target = delegatedClass(decoratorName(decorator), source.program, source.filename, source.root)
|
|
121
|
+
const body = target?.controller.init?.body ?? target?.controller.body
|
|
122
|
+
const token = body?.type === "CallExpression" && body.arguments[0]?.name
|
|
123
|
+
const resolved = token && delegatedClass(token, target.program, target.filename, source.root)
|
|
124
|
+
if (resolved) return resolved
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function timerImplementation(target, methodName, contract, root) {
|
|
130
|
+
const matches = target.controller.implements?.some((entry) => {
|
|
131
|
+
const implemented = delegatedClass(entry.expression?.name, target.program, target.filename, root)
|
|
132
|
+
return implemented && sameClass(implemented, contract)
|
|
133
|
+
})
|
|
134
|
+
if (!matches) return false
|
|
135
|
+
const method = methodsOf(target.controller).find((entry) => keyName(entry.key) === methodName)
|
|
136
|
+
if (!method?.value.body) return false
|
|
137
|
+
const [callback, delay] = method.value.params.map((entry) => paramParts(entry).target.name)
|
|
138
|
+
const statements = method.value.body.body
|
|
139
|
+
const declarations = statements.filter((entry) => entry.type === "VariableDeclaration").flatMap((entry) => entry.declarations)
|
|
140
|
+
const scheduled = declarations.find((entry) => entry.init?.type === "CallExpression" && entry.init.callee.name === "setTimeout"
|
|
141
|
+
&& entry.init.arguments[0]?.name === callback && entry.init.arguments[1]?.name === delay)
|
|
142
|
+
if (!scheduled) return false
|
|
143
|
+
return statements.some((entry) => entry.type === "ReturnStatement" && entry.argument?.type === "ArrowFunctionExpression"
|
|
144
|
+
&& entry.argument.body?.type === "CallExpression" && entry.argument.body.callee.name === "clearTimeout"
|
|
145
|
+
&& entry.argument.body.arguments[0]?.name === scheduled.id.name)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function providedTimer(module, token, dependency, contract, source) {
|
|
149
|
+
for (const object of moduleObjects(module)) {
|
|
150
|
+
const providers = object.properties.find((entry) => keyName(entry.key) === "providers")?.value
|
|
151
|
+
for (const node of expandedNodes(providers, bindingsOf(source.program)).filter((entry) => entry.type === "ObjectExpression")) {
|
|
152
|
+
const name = node.properties.find((entry) => keyName(entry.key) === "provide")?.value?.name
|
|
153
|
+
const provided = name && delegatedClass(name, source.program, source.filename, source.root)
|
|
154
|
+
if (!provided || !sameClass(provided, token)) continue
|
|
155
|
+
const implementation = node.properties.find((entry) => keyName(entry.key) === "useClass")?.value?.name
|
|
156
|
+
const target = implementation && delegatedClass(implementation, source.program, source.filename, source.root)
|
|
157
|
+
if (target?.controller.type === "ClassDeclaration" && timerImplementation(target, dependency.method, contract, source.root)) return true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function timerBinding(dependency, contract, source) {
|
|
164
|
+
const token = timerToken(dependency.name, source)
|
|
165
|
+
if (!token) return false
|
|
166
|
+
const directory = path.dirname(contract.filename)
|
|
167
|
+
for (const entry of fs.readdirSync(directory).filter((name) => name.endsWith(".module.ts"))) {
|
|
168
|
+
const filename = path.join(directory, entry)
|
|
169
|
+
const program = readProgram(filename)
|
|
170
|
+
if (!program) continue
|
|
171
|
+
const location = { program, filename, root: source.root }
|
|
172
|
+
if ([...classesOf(program).values()].some((module) => providedTimer(module, token, dependency, contract, location))) return true
|
|
173
|
+
}
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function rejectingTimeout(nodes, bindings, injected, source) {
|
|
178
|
+
return nodes.some((node) => {
|
|
179
|
+
if (node.type !== "CallExpression" || !positiveDuration(node.arguments[1], bindings)) return false
|
|
180
|
+
const native = node.callee.type === "Identifier" && node.callee.name === "setTimeout"
|
|
181
|
+
if (!native && !timerMethod(node, injected, source)) return false
|
|
182
|
+
return expandedNodes(node.arguments[0], bindings).some((entry) => entry.type === "CallExpression" && entry.callee.type === "Identifier" && entry.callee.name === "reject")
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parentsOf(body) {
|
|
187
|
+
const parents = new Map()
|
|
188
|
+
walk(body, (node) => {
|
|
189
|
+
for (const [key, value] of Object.entries(node)) {
|
|
190
|
+
if (["parent", "loc", "range", "tokens", "comments"].includes(key)) continue
|
|
191
|
+
for (const child of Array.isArray(value) ? value : [value]) if (typeof child?.type === "string") parents.set(child, node)
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
return parents
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function consumedCall(node, body, bindings) {
|
|
198
|
+
const parents = parentsOf(body)
|
|
199
|
+
const nodes = []
|
|
200
|
+
walk(body, (entry) => nodes.push(entry))
|
|
201
|
+
return nodes.some((entry) => ["AwaitExpression", "ReturnStatement"].includes(entry.type)
|
|
202
|
+
&& expandedNodes(entry.argument, bindings).includes(node)
|
|
203
|
+
&& !expandedNodes(entry.argument, bindings).some((child) => child.type === "UnaryExpression" && child.operator === "void")
|
|
204
|
+
&& !["ArrowFunctionExpression", "FunctionExpression"].includes(parents.get(entry)?.type))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A required dependency failure must still make the readiness promise fail. */
|
|
208
|
+
function propagatesFailures(body) {
|
|
209
|
+
const catches = []
|
|
210
|
+
const promiseCatches = []
|
|
211
|
+
walk(body, (node) => { if (node.type === "CatchClause") catches.push(node) })
|
|
212
|
+
walk(body, (node) => { if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && memberName(node.callee) === "catch") promiseCatches.push(node.arguments[0]) })
|
|
213
|
+
const rejected = (node) => node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object.name === "Promise" && memberName(node.callee) === "reject"
|
|
214
|
+
const fails = (block) => block?.body?.some((statement) => statement.type === "ThrowStatement" || statement.type === "ReturnStatement" && rejected(statement.argument))
|
|
215
|
+
return catches.every((node) => node.body.body.some((statement) => statement.type === "ThrowStatement"
|
|
216
|
+
|| statement.type === "ReturnStatement" && statement.argument?.type === "CallExpression" && statement.argument.callee?.type === "MemberExpression"
|
|
217
|
+
&& statement.argument.callee.object.name === "Promise" && memberName(statement.argument.callee) === "reject"))
|
|
218
|
+
&& promiseCatches.every((callback) => callback && (rejected(callback.body) || fails(callback.body)))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function boundedDependency(method, injected, bindings, source) {
|
|
222
|
+
if (!propagatesFailures(method.value.body)) return false
|
|
223
|
+
const nodes = expandedNodes(method.value.body, bindings)
|
|
224
|
+
return nodes.some((node) => {
|
|
225
|
+
if (node.type !== "CallExpression" || !consumedCall(node, method.value.body, bindings)) return false
|
|
226
|
+
const callee = unwrap(node.callee)
|
|
227
|
+
if (callee?.type !== "MemberExpression") return false
|
|
228
|
+
if (injectedCall(node, injected)) return requiredCall(node, injected, source) && expandedNodes(node, bindings).some((entry) => entry.type === "CallExpression"
|
|
229
|
+
&& entry.callee.type === "MemberExpression" && entry.callee.object.name === "AbortSignal" && memberName(entry.callee) === "timeout" && positiveDuration(entry.arguments[0], bindings))
|
|
230
|
+
if (callee.object.name !== "Promise" || memberName(callee) !== "race") return false
|
|
231
|
+
const racers = expandedNodes(node.arguments[0], bindings)
|
|
232
|
+
return racers.some((entry) => requiredCall(entry, injected, source)) && rejectingTimeout(racers, bindings, injected, source)
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function classInSource(name, program, filename, root, seen = new Set()) {
|
|
237
|
+
const identity = `${filename}:${name}`
|
|
238
|
+
if (seen.has(identity)) return null
|
|
239
|
+
const next = new Set(seen).add(identity)
|
|
240
|
+
let local = classesOf(program).get(name)
|
|
241
|
+
walk(program, (node) => { if (["TSInterfaceDeclaration", "TSTypeAliasDeclaration", "VariableDeclarator", "FunctionDeclaration"].includes(node.type) && node.id?.name === name) local = node })
|
|
242
|
+
if (local) return { controller: local, program, filename }
|
|
243
|
+
for (const entry of program.body.filter((node) => ["ExportNamedDeclaration", "ExportAllDeclaration"].includes(node.type) && node.source)) {
|
|
244
|
+
const specifier = entry.specifiers?.find((node) => keyName(node.exported) === name)
|
|
245
|
+
if (entry.type !== "ExportAllDeclaration" && !specifier) continue
|
|
246
|
+
const target = sourcePath(entry.source.value, filename, root)
|
|
247
|
+
const imported = target && readProgram(target)
|
|
248
|
+
const controller = imported && classInSource(keyName(specifier?.local) ?? name, imported, target, root, next)
|
|
249
|
+
if (controller) return controller
|
|
250
|
+
}
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function delegatedClass(name, program, filename, root) {
|
|
255
|
+
const local = classInSource(name, program, filename, root)
|
|
256
|
+
if (local) return local
|
|
257
|
+
for (const entry of program.body.filter((node) => node.type === "ImportDeclaration")) {
|
|
258
|
+
const specifier = entry.specifiers.find((node) => node.local.name === name)
|
|
259
|
+
if (!specifier) continue
|
|
260
|
+
const target = sourcePath(entry.source.value, filename, root)
|
|
261
|
+
const imported = target && readProgram(target)
|
|
262
|
+
const controller = imported && classInSource(specifier.imported?.name ?? name, imported, target, root)
|
|
263
|
+
if (controller) return controller
|
|
264
|
+
}
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function consumedDependencies(method, injected, source) {
|
|
269
|
+
const bindings = bindingsOf(source.program)
|
|
270
|
+
return expandedNodes(method.value.body, bindings).filter((node) => consumedCall(node, method.value.body, bindings))
|
|
271
|
+
.map((node) => ({ node, call: injectedCall(node, injected) })).filter((entry) => entry.call)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function requiredCall(node, injected, source, visited = new Set()) {
|
|
275
|
+
const call = injectedCall(node, injected)
|
|
276
|
+
if (!call || timerMethod(node, injected, source)) return false
|
|
277
|
+
const target = delegatedClass(call.type, source.program, source.filename, source.root)
|
|
278
|
+
if (!target) return true
|
|
279
|
+
if (target.controller.type !== "ClassDeclaration") return false
|
|
280
|
+
const method = methodsOf(target.controller).find((entry) => keyName(entry.key) === call.method)
|
|
281
|
+
const identity = `${target.filename}:${call.type}:${call.method}`
|
|
282
|
+
if (!method || visited.has(identity) || !propagatesFailures(method.value.body)) return false
|
|
283
|
+
const next = new Set(visited).add(identity)
|
|
284
|
+
const location = { ...target, root: source.root }
|
|
285
|
+
const dependencies = injectedOf(target.controller)
|
|
286
|
+
return consumedDependencies(method, dependencies, location).some((entry) => requiredCall(entry.node, dependencies, location, next))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function importedAs(program, name, imported, module) {
|
|
290
|
+
return program.body.some((entry) => entry.type === "ImportDeclaration" && entry.source.value === module
|
|
291
|
+
&& entry.specifiers.some((specifier) => specifier.local.name === name && keyName(specifier.imported) === imported))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function moduleObjects(controller) {
|
|
295
|
+
const objects = []
|
|
296
|
+
const decorator = controller.decorators?.find((entry) => decoratorName(entry) === "Module")
|
|
297
|
+
const declared = decorator?.expression.arguments?.[0]
|
|
298
|
+
if (declared?.type === "ObjectExpression") objects.push(declared)
|
|
299
|
+
for (const method of methodsOf(controller).filter((entry) => entry.static)) walk(method.value.body, (node) => {
|
|
300
|
+
if (node.type === "ReturnStatement" && node.argument?.type === "ObjectExpression") objects.push(node.argument)
|
|
301
|
+
})
|
|
302
|
+
return objects
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function moduleReferences(controller, field, source) {
|
|
306
|
+
const names = new Set()
|
|
307
|
+
for (const object of moduleObjects(controller)) {
|
|
308
|
+
const value = object.properties.find((entry) => keyName(entry.key) === field)?.value
|
|
309
|
+
for (const node of expandedNodes(value, bindingsOf(source.program))) if (node.type === "Identifier") names.add(node.name)
|
|
310
|
+
}
|
|
311
|
+
return [...names].map((name) => delegatedClass(name, source.program, source.filename, source.root))
|
|
312
|
+
.filter((entry) => entry?.controller.type === "ClassDeclaration")
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sameClass = (left, right) => left.filename === right.filename && left.controller.id.name === right.controller.id.name
|
|
316
|
+
|
|
317
|
+
function routeModules(controller, source) {
|
|
318
|
+
const roots = []
|
|
319
|
+
let directory = path.dirname(source.filename)
|
|
320
|
+
while (!path.relative(source.root, directory).startsWith("..")) {
|
|
321
|
+
for (const entry of fs.readdirSync(directory).filter((name) => name.endsWith(".module.ts"))) {
|
|
322
|
+
const filename = path.join(directory, entry)
|
|
323
|
+
const program = readProgram(filename)
|
|
324
|
+
if (!program) continue
|
|
325
|
+
const location = { program, filename, root: source.root }
|
|
326
|
+
for (const module of classesOf(program).values()) {
|
|
327
|
+
const target = { controller, filename: source.filename }
|
|
328
|
+
if (moduleReferences(module, "controllers", location).some((candidate) => sameClass(candidate, target))) roots.push({ ...location, controller: module })
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (directory === source.root) break
|
|
332
|
+
directory = path.dirname(directory)
|
|
333
|
+
}
|
|
334
|
+
return roots
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function registeredProviders(controller, source) {
|
|
338
|
+
const pending = routeModules(controller, source)
|
|
339
|
+
const visited = new Set()
|
|
340
|
+
const providers = []
|
|
341
|
+
while (pending.length) {
|
|
342
|
+
const module = pending.pop()
|
|
343
|
+
const identity = `${module.filename}:${module.controller.id.name}`
|
|
344
|
+
if (visited.has(identity)) continue
|
|
345
|
+
visited.add(identity)
|
|
346
|
+
providers.push(...moduleReferences(module.controller, "providers", { ...module, root: source.root }))
|
|
347
|
+
pending.push(...moduleReferences(module.controller, "imports", { ...module, root: source.root }))
|
|
348
|
+
}
|
|
349
|
+
return providers
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function thisCall(node, name) {
|
|
353
|
+
return node.type === "CallExpression" && node.callee.type === "MemberExpression"
|
|
354
|
+
&& node.callee.object.type === "ThisExpression" && memberName(node.callee) === name
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function handlerEntry(target, root) {
|
|
358
|
+
const own = methodsOf(target.controller)
|
|
359
|
+
const execute = own.find((entry) => keyName(entry.key) === "execute")
|
|
360
|
+
if (execute) return execute
|
|
361
|
+
const base = target.controller.superClass
|
|
362
|
+
const parent = base?.type === "Identifier" && delegatedClass(base.name, target.program, target.filename, root)
|
|
363
|
+
const inherited = parent?.controller.type === "ClassDeclaration" && methodsOf(parent.controller).find((entry) => keyName(entry.key) === "execute")
|
|
364
|
+
if (!inherited || !propagatesFailures(inherited.value.body)) return null
|
|
365
|
+
const bindings = bindingsOf(parent.program)
|
|
366
|
+
const consumesProcess = expandedNodes(inherited.value.body, bindings).some((node) => thisCall(node, "process") && consumedCall(node, inherited.value.body, bindings))
|
|
367
|
+
return consumesProcess && own.find((entry) => keyName(entry.key) === "process")
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function handlerOf(target, query, root) {
|
|
371
|
+
return target.controller.decorators?.some((entry) => {
|
|
372
|
+
const expression = entry.expression
|
|
373
|
+
if (!importedAs(target.program, expression.callee?.name, "QueryHandler", "@nestjs/cqrs")) return false
|
|
374
|
+
const name = expression.arguments?.[0]?.name
|
|
375
|
+
const handled = name && delegatedClass(name, target.program, target.filename, root)
|
|
376
|
+
return handled && sameClass(handled, query)
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function dispatchedQueries(controller, method, source) {
|
|
381
|
+
const injected = injectedOf(controller)
|
|
382
|
+
const bindings = bindingsOf(source.program)
|
|
383
|
+
return expandedNodes(method.value.body, bindings).flatMap((node) => {
|
|
384
|
+
const callee = unwrap(node.callee)
|
|
385
|
+
if (node.type !== "CallExpression" || callee?.type !== "MemberExpression" || memberName(callee) !== "execute") return []
|
|
386
|
+
const owner = callee.object
|
|
387
|
+
if (owner.type !== "MemberExpression" || owner.object.type !== "ThisExpression") return []
|
|
388
|
+
if (!importedAs(source.program, injected.get(memberName(owner)), "QueryBus", "@nestjs/cqrs")) return []
|
|
389
|
+
if (!consumedCall(node, method.value.body, bindings)) return []
|
|
390
|
+
const message = unwrap(node.arguments[0])
|
|
391
|
+
const target = message?.type === "NewExpression" && delegatedClass(message.callee.name, source.program, source.filename, source.root)
|
|
392
|
+
return target ? [target] : []
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function busReadiness(controller, method, source, visited) {
|
|
397
|
+
const queries = dispatchedQueries(controller, method, source)
|
|
398
|
+
if (!queries.length) return false
|
|
399
|
+
return registeredProviders(controller, source).some((target) => {
|
|
400
|
+
if (!queries.some((query) => handlerOf(target, query, source.root))) return false
|
|
401
|
+
const entry = handlerEntry(target, source.root)
|
|
402
|
+
return entry && realReadiness(target.controller, entry, target.program, target.filename, source.root, visited)
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function realReadiness(controller, method, program, filename, root, visited = new Set()) {
|
|
407
|
+
const identity = `${filename}:${controller.id.name}:${keyName(method.key)}`
|
|
408
|
+
if (visited.has(identity)) return false
|
|
409
|
+
const next = new Set(visited).add(identity)
|
|
410
|
+
const injected = injectedOf(controller)
|
|
411
|
+
const source = { program, filename, root, controller }
|
|
412
|
+
if (boundedDependency(method, injected, bindingsOf(program), source)) return true
|
|
413
|
+
if (!propagatesFailures(method.value.body)) return false
|
|
414
|
+
if (busReadiness(controller, method, source, next)) return true
|
|
415
|
+
return consumedDependencies(method, injected, source).some(({ call }) => {
|
|
416
|
+
const target = call.type && delegatedClass(call.type, program, filename, root)
|
|
417
|
+
const delegated = target?.controller.type === "ClassDeclaration" && methodsOf(target.controller).find((entry) => keyName(entry.key) === call.method)
|
|
418
|
+
return delegated && realReadiness(target.controller, delegated, target.program, target.filename, root, next)
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Readiness delegation is proved through the injected service; the HTTP controller remains a thin door. */
|
|
423
|
+
export const readyReal = {
|
|
424
|
+
meta: {
|
|
425
|
+
type: "problem",
|
|
426
|
+
docs: { description: "BE_READY_REAL: readiness probes an injected required dependency and fails under a bounded timeout." },
|
|
427
|
+
schema: [],
|
|
428
|
+
messages: { real: "BE_READY_REAL: this readiness route must probe an injected required dependency with a failing, bounded timeout; a liveness query or an unconditional success is not readiness." },
|
|
429
|
+
},
|
|
430
|
+
create(context) {
|
|
431
|
+
const hfs = hfsOf(context)
|
|
432
|
+
const filename = context.filename || context.getFilename()
|
|
433
|
+
if (hfs.slotOf(filename) !== "be.transport.http") return {}
|
|
434
|
+
return {
|
|
435
|
+
"Program:exit"(program) {
|
|
436
|
+
for (const controller of classesOf(program).values()) for (const { route, method } of routesOf(controller)) {
|
|
437
|
+
if (!/(?:\/health\/ready|\/readyz)$/.test(route)) continue
|
|
438
|
+
if (!realReadiness(controller, method, program, filename, hfs.repoRoot)) context.report({ node: method, messageId: "real" })
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Follow the app's local import closure; unused imports do not install a health transport. */
|
|
446
|
+
function wiringNames(program, wanted) {
|
|
447
|
+
const used = new Set()
|
|
448
|
+
const bindings = bindingsOf(program)
|
|
449
|
+
const collect = (node) => { for (const entry of expandedNodes(node, bindings)) if (entry.type === "Identifier") used.add(entry.name) }
|
|
450
|
+
const moduleObject = (node) => {
|
|
451
|
+
if (node?.type !== "ObjectExpression") return
|
|
452
|
+
for (const property of node.properties) if (["imports", "controllers"].includes(keyName(property.key))) collect(property.value)
|
|
453
|
+
}
|
|
454
|
+
for (const controller of classesOf(program).values()) {
|
|
455
|
+
if (wanted?.includes(controller.id.name) === false) continue
|
|
456
|
+
const module = controller.decorators?.find((entry) => decoratorName(entry) === "Module")
|
|
457
|
+
moduleObject(module?.expression.arguments?.[0])
|
|
458
|
+
for (const method of methodsOf(controller).filter((entry) => entry.static)) walk(method.value.body, (node) => {
|
|
459
|
+
if (node.type === "ReturnStatement" && node.argument?.type === "ObjectExpression" && node.argument.properties.some((entry) => keyName(entry.key) === "module")) moduleObject(node.argument)
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
walk(program, (node) => {
|
|
463
|
+
if (node.type === "VariableDeclarator" && wanted?.includes(node.id.name)) collect(node.init)
|
|
464
|
+
if (node.type === "FunctionDeclaration" && wanted?.includes(node.id?.name)) collect(node.body)
|
|
465
|
+
if (node.type === "ExportNamedDeclaration" && !node.source) for (const specifier of node.specifiers) if (!wanted || wanted.includes(keyName(specifier.exported))) used.add(keyName(specifier.local))
|
|
466
|
+
})
|
|
467
|
+
return used
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function importedTargets(program, filename, root, used) {
|
|
471
|
+
const targets = []
|
|
472
|
+
for (const entry of program.body.filter((node) => node.type === "ImportDeclaration")) {
|
|
473
|
+
if (entry.importKind === "type" || !entry.specifiers.some((specifier) => used.has(specifier.local.name))) continue
|
|
474
|
+
const target = sourcePath(entry.source.value, filename, root)
|
|
475
|
+
const imported = entry.specifiers.filter((specifier) => used.has(specifier.local.name)).map((specifier) => keyName(specifier.imported) ?? specifier.local.name)
|
|
476
|
+
if (target) targets.push({ filename: target, wanted: imported })
|
|
477
|
+
}
|
|
478
|
+
return targets
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function exportedTargets(program, filename, root, wanted) {
|
|
482
|
+
const targets = []
|
|
483
|
+
for (const entry of program.body.filter((node) => ["ExportNamedDeclaration", "ExportAllDeclaration"].includes(node.type) && node.source && node.exportKind !== "type")) {
|
|
484
|
+
const target = sourcePath(entry.source.value, filename, root)
|
|
485
|
+
const exported = entry.type === "ExportAllDeclaration" ? wanted : entry.specifiers.filter((specifier) => !wanted || wanted.includes(keyName(specifier.exported))).map((specifier) => keyName(specifier.local))
|
|
486
|
+
if (target && (!exported || exported.length)) targets.push({ filename: target, wanted: exported })
|
|
487
|
+
}
|
|
488
|
+
return targets
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function usedRoutes(program, wanted, used) {
|
|
492
|
+
return [...classesOf(program).values()].filter((controller) => !wanted || wanted.includes(controller.id.name) || used.has(controller.id.name))
|
|
493
|
+
.flatMap((controller) => routesOf(controller).map((entry) => entry.route))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function appRoutes(filename, root) {
|
|
497
|
+
const routes = new Set()
|
|
498
|
+
const pending = [{ filename, wanted: ["AppModule"] }]
|
|
499
|
+
const visited = new Set()
|
|
500
|
+
while (pending.length) {
|
|
501
|
+
const { filename: current, wanted } = pending.pop()
|
|
502
|
+
const identity = `${current}:${wanted?.join(",") ?? "*"}`
|
|
503
|
+
if (visited.has(identity)) continue
|
|
504
|
+
visited.add(identity)
|
|
505
|
+
const program = readProgram(current)
|
|
506
|
+
if (!program) continue
|
|
507
|
+
const used = wiringNames(program, wanted)
|
|
508
|
+
for (const route of usedRoutes(program, wanted, used)) routes.add(route)
|
|
509
|
+
pending.push(...importedTargets(program, current, root, used), ...exportedTargets(program, current, root, wanted))
|
|
510
|
+
}
|
|
511
|
+
return routes
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Each declared API composition exposes both process-local liveness and required-dependency readiness. */
|
|
515
|
+
export const healthSignals = {
|
|
516
|
+
meta: {
|
|
517
|
+
type: "problem",
|
|
518
|
+
docs: { description: "BE_HEALTH_SIGNALS: every API app exposes /health/live and /health/ready." },
|
|
519
|
+
schema: [],
|
|
520
|
+
messages: { signals: "BE_HEALTH_SIGNALS: API {{app}} must register both /health/live and /health/ready in its module composition (missing {{missing}})." },
|
|
521
|
+
},
|
|
522
|
+
create(context) {
|
|
523
|
+
const hfs = hfsOf(context)
|
|
524
|
+
const filename = context.filename || context.getFilename()
|
|
525
|
+
if (appRoleOf(hfs, filename)?.entry !== "main.ts") return {}
|
|
526
|
+
const app = hfs.apps.find((entry) => entry.name === hfs.classify(filename).bindings?.app)
|
|
527
|
+
if (app?.kind !== "api") return {}
|
|
528
|
+
return {
|
|
529
|
+
"Program:exit"(node) {
|
|
530
|
+
const routes = appRoutes(path.join(path.dirname(filename), "app.module.ts"), hfs.repoRoot)
|
|
531
|
+
const missing = ["/health/live", "/health/ready"].filter((route) => !routes.has(route))
|
|
532
|
+
if (missing.length) context.report({ node, messageId: "signals", data: { app: app.name, missing: missing.join(", ") } })
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export const rules = { "ready-real": readyReal, "health-signals": healthSignals }
|
|
539
|
+
export const recommended = { "starci-be/ready-real": "error", "starci-be/health-signals": "error" }
|
package/index.mjs
CHANGED
|
@@ -183,3 +183,40 @@ export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin: pluginFo
|
|
|
183
183
|
|
|
184
184
|
export { linterOptions } from "./lib/config.mjs"
|
|
185
185
|
export { loadHfs } from "./lib/hfs.mjs"
|
|
186
|
+
|
|
187
|
+
// A12 registration is appended so the shared registry can be union-merged.
|
|
188
|
+
import { recommended as thirdPartyOwnerRecommended, rules as thirdPartyOwnerRules } from "./third-party-owner.mjs"
|
|
189
|
+
Object.assign(rules, thirdPartyOwnerRules)
|
|
190
|
+
Object.assign(recommended, thirdPartyOwnerRecommended)
|
|
191
|
+
lawOwners.push("third-party-owner")
|
|
192
|
+
for (const name of Object.keys(thirdPartyOwnerRules)) {
|
|
193
|
+
ruleOwners[name] = "third-party-owner"
|
|
194
|
+
// infra-owner also exposes these rules so the catalog's static contribution reader sees them.
|
|
195
|
+
const declaration = ruleDeclarations.find((entry) => entry.name === name)
|
|
196
|
+
if (declaration) declaration.law = "third-party-owner"
|
|
197
|
+
else ruleDeclarations.push({ law: "third-party-owner", name })
|
|
198
|
+
}
|
|
199
|
+
// A21 append-only registration: keep the already exported plugin and recommendation live.
|
|
200
|
+
import { recommended as functionBudgetRecommended, rules as functionBudgetRules } from "./function-budget.mjs"
|
|
201
|
+
CONTRIBUTIONS.push({ law: "function-budget", rules: functionBudgetRules, recommended: functionBudgetRecommended })
|
|
202
|
+
lawOwners.push("function-budget")
|
|
203
|
+
ruleDeclarations.push(...Object.keys(functionBudgetRules).map((name) => ({ law: "function-budget", name })))
|
|
204
|
+
Object.assign(ruleOwners, Object.fromEntries(Object.keys(functionBudgetRules).map((name) => [name, "function-budget"])))
|
|
205
|
+
Object.assign(rules, functionBudgetRules)
|
|
206
|
+
Object.assign(recommended, functionBudgetRecommended)
|
|
207
|
+
// A06 registrations are appended: the coordinator union-merges this shared registry.
|
|
208
|
+
import { recommended as edgeHardeningRecommended, rules as edgeHardeningRules } from "./edge-hardening.mjs"
|
|
209
|
+
import { recommended as healthReadyRecommended, rules as healthReadyRules } from "./health-ready.mjs"
|
|
210
|
+
import { recommended as jwtAlgRecommended, rules as jwtAlgRules } from "./jwt-alg.mjs"
|
|
211
|
+
|
|
212
|
+
for (const entry of [
|
|
213
|
+
{ law: "edge-hardening", rules: edgeHardeningRules, recommended: edgeHardeningRecommended },
|
|
214
|
+
{ law: "health-ready", rules: healthReadyRules, recommended: healthReadyRecommended },
|
|
215
|
+
{ law: "jwt-alg", rules: jwtAlgRules, recommended: jwtAlgRecommended },
|
|
216
|
+
]) {
|
|
217
|
+
lawOwners.push(entry.law)
|
|
218
|
+
ruleDeclarations.push(...Object.keys(entry.rules).map((name) => ({ law: entry.law, name })))
|
|
219
|
+
Object.assign(ruleOwners, Object.fromEntries(Object.keys(entry.rules).map((name) => [name, entry.law])))
|
|
220
|
+
Object.assign(rules, entry.rules)
|
|
221
|
+
Object.assign(recommended, entry.recommended)
|
|
222
|
+
}
|
package/infra-owner.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
27
27
|
import { moduleReferences } from "./lib/import-source.mjs"
|
|
28
28
|
import { isOwnedType } from "./lib/ports.mjs"
|
|
29
|
+
import { recommended as thirdPartyRecommended, rules as thirdPartyRules } from "./third-party-owner.mjs"
|
|
29
30
|
|
|
30
31
|
/** The owner id of a file: the last two segments of its owner root (`platform/http`), or null when no owner holds it. */
|
|
31
32
|
const ownerIdOf = (hfs, filename) => {
|
|
@@ -126,10 +127,12 @@ export const httpClientOnlyInIntegrations = {
|
|
|
126
127
|
export const rules = {
|
|
127
128
|
"infra-import-owner": infraImportOwner,
|
|
128
129
|
"http-client-only-in-integrations": httpClientOnlyInIntegrations,
|
|
130
|
+
...thirdPartyRules,
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/** Every rule of this law at `error`. */
|
|
132
134
|
export const recommended = {
|
|
133
135
|
"starci-be/infra-import-owner": "error",
|
|
134
136
|
"starci-be/http-client-only-in-integrations": "error",
|
|
137
|
+
...thirdPartyRecommended,
|
|
135
138
|
}
|
package/jwt-alg.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** R260 BE_JWT_ALG_PINNED: remote JWT verification pins algorithms, configuration and principal role. */
|
|
2
|
+
import ts from "typescript"
|
|
3
|
+
import { typed } from "./lib/types.mjs"
|
|
4
|
+
import { isDeclarationFile } from "./lib/path.mjs"
|
|
5
|
+
import { jwtPolicyProblems } from "./runtime/scripts/hfs/architecture/supabase-ast.mjs"
|
|
6
|
+
|
|
7
|
+
/** Recognize jose imports and aliases through their declarations, including namespace imports. */
|
|
8
|
+
const joseVerifier = (checker, expression, seen = new Set()) => {
|
|
9
|
+
if (!expression || seen.has(expression)) return false
|
|
10
|
+
seen.add(expression)
|
|
11
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
12
|
+
if (expression.name.text !== "jwtVerify") return false
|
|
13
|
+
return checker.getSymbolAtLocation(expression.expression)?.declarations?.some(declaration =>
|
|
14
|
+
ts.isNamespaceImport(declaration) && declaration.parent.parent.moduleSpecifier.text === "jose") ?? false
|
|
15
|
+
}
|
|
16
|
+
return checker.getSymbolAtLocation(expression)?.declarations?.some(declaration => {
|
|
17
|
+
if (ts.isImportSpecifier(declaration)) return (declaration.propertyName ?? declaration.name).text === "jwtVerify"
|
|
18
|
+
&& declaration.parent.parent.parent.moduleSpecifier.text === "jose"
|
|
19
|
+
return ts.isVariableDeclaration(declaration) && declaration.initializer && joseVerifier(checker, declaration.initializer, seen)
|
|
20
|
+
}) ?? false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const jwtAlgPinned = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: "problem",
|
|
26
|
+
docs: { description: "R260 BE_JWT_ALG_PINNED: literal secure algorithms, config issuer and authenticated principal role." },
|
|
27
|
+
schema: [],
|
|
28
|
+
messages: { pinned: "{{detail}}" },
|
|
29
|
+
},
|
|
30
|
+
create(context) {
|
|
31
|
+
if (isDeclarationFile(context.filename || context.getFilename())) return {}
|
|
32
|
+
const { checker, toTs } = typed(context)
|
|
33
|
+
return {
|
|
34
|
+
CallExpression(node) {
|
|
35
|
+
const call = toTs(node)
|
|
36
|
+
if (!call || !joseVerifier(checker, call.expression)) return
|
|
37
|
+
const problems = jwtPolicyProblems(ts, checker, call)
|
|
38
|
+
if (problems.length) context.report({ node, messageId: "pinned", data: { detail: problems.join(" ") } })
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const rules = { "jwt-alg-pinned": jwtAlgPinned }
|
|
45
|
+
export const recommended = { "starci-be/jwt-alg-pinned": "error" }
|