@starci/eslint-canon-be 3.3.1 → 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 +12 -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/module-shape.mjs
CHANGED
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
* capability's representative module is reached through its `Inject*()` decorators, never by import. One owner may
|
|
13
13
|
* import its own modules (a transport module its feature's application module, a representative its sub-modules);
|
|
14
14
|
* the app composes every owner.
|
|
15
|
-
* - `capability-module-shape` holds the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
15
|
+
* - `capability-module-shape` holds the definition of every module (owner ruling module-definition-everywhere): the representative
|
|
16
|
+
* `<c>.module-definition.ts` of a capability is a typed `ConfigurableModuleBuilder` with `.setExtras({ isGlobal: false }, ...)`
|
|
17
|
+
* and `.build()`; every other module's definition (a sub-module, a feature, a transport, a cli group) is
|
|
18
|
+
* `.setClassMethodName("register").build()` with no extras; only a representative may override
|
|
19
|
+
* `static register(options: typeof OPTIONS_TYPE): DynamicModule`.
|
|
20
|
+
* - `module-definition-required` holds that every module has one: a `<x>.module.ts` of a feature, capability or projection has a
|
|
21
|
+
* sibling `<x>.module-definition.ts` and `<x>.options.ts`, its class extends the `ConfigurableModuleClass` of that definition,
|
|
22
|
+
* and a module of the product appears in an `imports` array only as `X.register(...)`, never as a bare class
|
|
23
|
+
* (BE_MODULE_DEFINITION_MISSING).
|
|
20
24
|
* - `typed-module-definition` requires `ConfigurableModuleBuilder<Options>` to state its options type.
|
|
21
25
|
* - `static-module-register` allows one factory name, `register`, and requires it `static`: no `forRoot`, `forFeature`
|
|
22
26
|
* or `registerAsync`, because options are parsed once in `main.ts`.
|
|
@@ -25,6 +29,8 @@
|
|
|
25
29
|
* - `no-module-let` refuses mutable module-level state (`let` and `var` at the top of a file).
|
|
26
30
|
* - `one-module-per-file` allows one `@Module` class in a file, so one transport or application module has one home.
|
|
27
31
|
*/
|
|
32
|
+
import { existsSync } from "node:fs"
|
|
33
|
+
import { dirname, join } from "node:path"
|
|
28
34
|
import ts from "typescript"
|
|
29
35
|
import { decoratorName, keyName, walk } from "./lib/ast.mjs"
|
|
30
36
|
import { importsFrom } from "./lib/declared.mjs"
|
|
@@ -294,7 +300,9 @@ export const oneModulePerFile = {
|
|
|
294
300
|
},
|
|
295
301
|
}
|
|
296
302
|
|
|
297
|
-
const CAPABILITY_TIERS = new Set(["domain", "platform", "integrations"])
|
|
303
|
+
const CAPABILITY_TIERS = new Set(["domain", "platform", "integrations", "projections"])
|
|
304
|
+
/** The tiers whose `<x>.module.ts` files are modules of the product: every one of them has a module definition. */
|
|
305
|
+
const MODULE_TIERS = new Set(["feature", "domain", "platform", "integrations", "projections", "queues", "events"])
|
|
298
306
|
|
|
299
307
|
/** `{ inside, name }`: the owner-relative path of a file and the owner's directory name, or null outside an owner. */
|
|
300
308
|
const insideOwner = (hfs, filename) => {
|
|
@@ -330,84 +338,170 @@ const builderChain = (newExpression) => {
|
|
|
330
338
|
return calls
|
|
331
339
|
}
|
|
332
340
|
|
|
341
|
+
/** True when the argument is the string literal `register`. */
|
|
342
|
+
const isRegisterName = (node) => node?.type === "Literal" && node.value === "register"
|
|
343
|
+
|
|
344
|
+
/** The reasons a builder chain is not the module definition of its kind (an empty list when it is). */
|
|
345
|
+
const chainFaults = (calls, representative) => {
|
|
346
|
+
const missing = []
|
|
347
|
+
const extras = calls.find((entry) => entry.name === "setExtras")
|
|
348
|
+
const named = calls.find((entry) => entry.name === "setClassMethodName")
|
|
349
|
+
if (representative) {
|
|
350
|
+
const defaults = extras?.call.arguments[0]
|
|
351
|
+
const localByDefault =
|
|
352
|
+
defaults?.type === "ObjectExpression" &&
|
|
353
|
+
defaults.properties.some((item) => item.type === "Property" && keyName(item.key) === "isGlobal" && item.value.type === "Literal" && item.value.value === false)
|
|
354
|
+
if (!localByDefault) missing.push("`.setExtras({ isGlobal: false }, ...)` is missing or does not default `isGlobal` to a literal `false`")
|
|
355
|
+
if (named && !isRegisterName(named.call.arguments[0])) missing.push('`.setClassMethodName(...)` names a factory other than "register"')
|
|
356
|
+
} else {
|
|
357
|
+
if (extras) missing.push("a sub-module, feature or transport module is never global, so it has no `.setExtras(...)`")
|
|
358
|
+
if (!named || !isRegisterName(named.call.arguments[0])) missing.push('`.setClassMethodName("register")` is missing')
|
|
359
|
+
}
|
|
360
|
+
if (calls.at(-1)?.name !== "build") missing.push("the chain does not end in `.build()`")
|
|
361
|
+
return missing
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The module scope of a file: its kind, tier and name when it is a `<x>.module.ts` or `<x>.module-definition.ts` of a product tier, else null. */
|
|
365
|
+
const moduleFileOf = (hfs, filename) => {
|
|
366
|
+
const base = filename.split("/").at(-1)
|
|
367
|
+
const suffix = base.endsWith(".module-definition.ts") ? ".module-definition.ts" : base.endsWith(".module.ts") ? ".module.ts" : null
|
|
368
|
+
if (!suffix) return null
|
|
369
|
+
const tier = hfs.tierOf(filename)
|
|
370
|
+
if (!MODULE_TIERS.has(tier)) return null
|
|
371
|
+
const name = base.slice(0, base.length - suffix.length)
|
|
372
|
+
const where = insideOwner(hfs, filename)
|
|
373
|
+
const representative = CAPABILITY_TIERS.has(tier) && where !== null && where.inside === `${where.name}${suffix}`
|
|
374
|
+
return { kind: suffix === ".module.ts" ? "module" : "definition", tier, name, representative }
|
|
375
|
+
}
|
|
376
|
+
|
|
333
377
|
/**
|
|
334
|
-
* The shape of a
|
|
335
|
-
*
|
|
336
|
-
* `
|
|
337
|
-
*
|
|
378
|
+
* The shape of a module's definition (BE-CONVENTION 1.2, owner ruling module-definition-everywhere): the representative
|
|
379
|
+
* module of a capability builds `new ConfigurableModuleBuilder<XOptions>().setExtras({ isGlobal: false }, ...).build()` and
|
|
380
|
+
* may override only `static register(options: typeof OPTIONS_TYPE): DynamicModule`; every other module (a sub-module, a feature,
|
|
381
|
+
* a transport, a cli group) builds `new ConfigurableModuleBuilder<XOptions>().setClassMethodName("register").build()` and
|
|
382
|
+
* declares nothing but its `@Module` metadata.
|
|
338
383
|
*/
|
|
339
384
|
export const capabilityModuleShape = {
|
|
340
385
|
meta: {
|
|
341
386
|
type: "problem",
|
|
342
|
-
docs: { description: "A
|
|
387
|
+
docs: { description: "A module definition is a typed `ConfigurableModuleBuilder` chain of its kind; only a capability's representative module overrides `register`." },
|
|
343
388
|
schema: [],
|
|
344
389
|
messages: {
|
|
345
|
-
extendsBase: "The representative module of `{{name}}` must be `@Module({...}) export class X extends ConfigurableModuleClass {}` with `ConfigurableModuleClass` imported from `./{{name}}.module-definition`.",
|
|
346
390
|
registerSignature: "The only `register` a capability module may declare is the override `static register(options: typeof OPTIONS_TYPE): DynamicModule`, with `OPTIONS_TYPE` from `./{{name}}.module-definition`.",
|
|
347
|
-
plain: "`{{class}}` is a sub-module
|
|
348
|
-
chain: "The module definition must be
|
|
349
|
-
featureDefinition: "A feature has no module definition: feature modules are static. Delete this file and register the feature's providers in its `@Module`.",
|
|
391
|
+
plain: "`{{class}}` is a sub-module, feature module, transport module or cli group: it declares its providers in `@Module({...})` and has no `register` of its own. Only the representative module of a capability may override `register`.",
|
|
392
|
+
chain: "The module definition of this kind must be {{shape}}: {{missing}}.",
|
|
350
393
|
},
|
|
351
394
|
},
|
|
352
395
|
create(context) {
|
|
353
396
|
const filename = normalizePath(context.filename || context.getFilename())
|
|
354
|
-
if (isDeclarationFile(filename)) return {}
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (where.tier === "feature" && isDefinition) return { Program: (program) => context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "featureDefinition" }) }
|
|
362
|
-
if (CAPABILITY_TIERS.has(where.tier) && isDefinition) {
|
|
397
|
+
if (isDeclarationFile(filename) || isSpecFile(filename)) return {}
|
|
398
|
+
const scope = moduleFileOf(hfsOf(context), filename)
|
|
399
|
+
if (!scope) return {}
|
|
400
|
+
if (scope.kind === "definition") {
|
|
401
|
+
const shape = scope.representative
|
|
402
|
+
? "`new ConfigurableModuleBuilder<XOptions>().setExtras({ isGlobal: false }, (d, e) => ({ ...d, global: e.isGlobal })).build()`"
|
|
403
|
+
: '`new ConfigurableModuleBuilder<XOptions>().setClassMethodName("register").build()`'
|
|
363
404
|
return {
|
|
364
405
|
Program(program) {
|
|
365
406
|
let found = false
|
|
366
407
|
walk(program, (node) => {
|
|
367
408
|
if (node.type !== "NewExpression" || node.callee.type !== "Identifier" || node.callee.name !== "ConfigurableModuleBuilder") return
|
|
368
409
|
found = true
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
const defaults = extras?.call.arguments[0]
|
|
372
|
-
const localByDefault =
|
|
373
|
-
defaults?.type === "ObjectExpression" &&
|
|
374
|
-
defaults.properties.some((item) => item.type === "Property" && keyName(item.key) === "isGlobal" && item.value.type === "Literal" && item.value.value === false)
|
|
375
|
-
const missing = []
|
|
376
|
-
if (!localByDefault) missing.push("`.setExtras({ isGlobal: false }, ...)` is missing or does not default `isGlobal` to a literal `false`")
|
|
377
|
-
if (calls.at(-1)?.name !== "build") missing.push("the chain does not end in `.build()`")
|
|
378
|
-
if (missing.length > 0) context.report({ node, messageId: "chain", data: { missing: missing.join("; ") } })
|
|
410
|
+
const missing = chainFaults(builderChain(node), scope.representative)
|
|
411
|
+
if (missing.length > 0) context.report({ node, messageId: "chain", data: { shape, missing: missing.join("; ") } })
|
|
379
412
|
})
|
|
380
|
-
if (!found) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "chain", data: { missing: "no `new ConfigurableModuleBuilder<XOptions>()` is built here" } })
|
|
413
|
+
if (!found) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "chain", data: { shape, missing: "no `new ConfigurableModuleBuilder<XOptions>()` is built here" } })
|
|
381
414
|
},
|
|
382
415
|
}
|
|
383
416
|
}
|
|
384
|
-
if (!isModuleFile || (where.tier !== "feature" && !CAPABILITY_TIERS.has(where.tier))) return {}
|
|
385
|
-
const importedFrom = new Map()
|
|
386
|
-
const representative = CAPABILITY_TIERS.has(where.tier) && where.inside === `${where.name}.module.ts`
|
|
387
417
|
return {
|
|
388
418
|
ClassDeclaration(node) {
|
|
389
419
|
if (!isModuleClass(node)) return
|
|
390
|
-
if (!representative) {
|
|
391
|
-
const hasRegister = node.body.body.some((member) => member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "register")
|
|
392
|
-
if (node.superClass || hasRegister) context.report({ node: node.id ?? node, messageId: "plain", data: { class: node.id?.name ?? "This class", name: where.name } })
|
|
393
|
-
return
|
|
394
|
-
}
|
|
395
|
-
const base = node.superClass
|
|
396
|
-
const imported = base?.type === "Identifier" ? importedFrom.get(base.name) : null
|
|
397
|
-
if (!imported || imported.name !== "ConfigurableModuleClass" || imported.source !== `./${where.name}.module-definition`) {
|
|
398
|
-
context.report({ node: node.id ?? node, messageId: "extendsBase", data: { name: where.name } })
|
|
399
|
-
}
|
|
400
420
|
for (const member of node.body.body) {
|
|
401
|
-
if (member.type
|
|
402
|
-
|
|
403
|
-
}
|
|
421
|
+
if (member.type !== "MethodDefinition" || member.key.type !== "Identifier" || member.key.name !== "register") continue
|
|
422
|
+
if (!scope.representative) context.report({ node: member, messageId: "plain", data: { class: node.id?.name ?? "This class" } })
|
|
423
|
+
else if (!isRegisterOverride(member)) context.report({ node: member, messageId: "registerSignature", data: { name: scope.name } })
|
|
404
424
|
}
|
|
405
425
|
},
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** True when a class declaration carries the Nest `Module` decorator. */
|
|
431
|
+
const isModuleDeclaration = (declaration) => {
|
|
432
|
+
if (!ts.isClassDeclaration(declaration)) return false
|
|
433
|
+
return (ts.getDecorators(declaration) ?? []).some((decorator) => {
|
|
434
|
+
const expression = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
|
|
435
|
+
return ts.isIdentifier(expression) && expression.text === "Module"
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The array of an `imports` property (the `@Module({ imports })` metadata or a dynamic module literal), else null. */
|
|
440
|
+
const importsArrayOf = (property) => (!property.computed && keyName(property.key) === "imports" && property.value.type === "ArrayExpression" ? property.value : null)
|
|
441
|
+
|
|
442
|
+
/** The PascalCase options type name a module file name implies: `sepay-feed-http` gives `SepayFeedHttpOptions`. */
|
|
443
|
+
const optionsTypeOf = (name) => `${name.replace(/(?:^|-)(\w)/g, (_, letter) => letter.toUpperCase())}Options`
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Every Nest module has a module definition and is registered through it (owner ruling module-definition-everywhere): its
|
|
447
|
+
* `<x>.module.ts` has a sibling `<x>.module-definition.ts` and `<x>.options.ts`, its class extends the `ConfigurableModuleClass`
|
|
448
|
+
* of that definition, and a module of the product is listed in an `imports` array only as `X.register(...)`, never as a bare
|
|
449
|
+
* class. The `AppModule` of an app root and the test world are composition roots and are not judged here.
|
|
450
|
+
*/
|
|
451
|
+
export const moduleDefinitionRequired = {
|
|
452
|
+
meta: {
|
|
453
|
+
type: "problem",
|
|
454
|
+
docs: { description: "A module has a sibling module definition and options file, extends its `ConfigurableModuleClass`, and is imported only as `X.register(...)`." },
|
|
455
|
+
schema: [],
|
|
456
|
+
messages: {
|
|
457
|
+
missingDefinition: '`{{name}}.module.ts` has no sibling `{{name}}.module-definition.ts`. Every module builds its base class: `export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new ConfigurableModuleBuilder<{{type}}>().setClassMethodName("register").build()`.',
|
|
458
|
+
missingOptions: "`{{name}}.module.ts` has no sibling `{{name}}.options.ts`. Declare `export type {{type}} = Record<string, never>` there (with a one-line doc saying the module takes no options yet), or its real options.",
|
|
459
|
+
extendsBase: "`{{class}}` must be `@Module({...}) export class {{class}} extends ConfigurableModuleClass {}` with `ConfigurableModuleClass` imported from `./{{name}}.module-definition`.",
|
|
460
|
+
unregistered: "`{{name}}` is imported as a bare class. Every module is registered through its definition: write `{{name}}.register({...})` (`{}` when it takes no options).",
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
create(context) {
|
|
464
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
465
|
+
if (isDeclarationFile(filename) || isSpecFile(filename)) return {}
|
|
466
|
+
const hfs = hfsOf(context)
|
|
467
|
+
if (inTestWorld(hfs, filename)) return {}
|
|
468
|
+
const scope = moduleFileOf(hfs, filename)
|
|
469
|
+
const importedFrom = new Map()
|
|
470
|
+
const { checker, toTs } = typed(context)
|
|
471
|
+
const isModuleClassIdentifier = (identifier) => {
|
|
472
|
+
const tsNode = toTs(identifier)
|
|
473
|
+
let symbol = tsNode ? checker.getSymbolAtLocation(tsNode) : undefined
|
|
474
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
|
|
475
|
+
return (symbol?.declarations ?? []).some((declaration) => !/[\\/]node_modules[\\/]/.test(declaration.getSourceFile().fileName) && isModuleDeclaration(declaration))
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
406
478
|
ImportDeclaration(node) {
|
|
407
479
|
for (const specifier of node.specifiers) {
|
|
408
480
|
if (specifier.type === "ImportSpecifier") importedFrom.set(specifier.local.name, { name: specifier.imported.name ?? specifier.imported.value, source: String(node.source.value) })
|
|
409
481
|
}
|
|
410
482
|
},
|
|
483
|
+
Program(program) {
|
|
484
|
+
if (scope?.kind !== "module") return
|
|
485
|
+
const siblings = dirname(filename)
|
|
486
|
+
const type = optionsTypeOf(scope.name)
|
|
487
|
+
if (!existsSync(join(siblings, `${scope.name}.module-definition.ts`))) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "missingDefinition", data: { name: scope.name, type } })
|
|
488
|
+
if (!existsSync(join(siblings, `${scope.name}.options.ts`))) context.report({ node: program, loc: { line: 1, column: 0 }, messageId: "missingOptions", data: { name: scope.name, type } })
|
|
489
|
+
},
|
|
490
|
+
ClassDeclaration(node) {
|
|
491
|
+
if (scope?.kind !== "module" || !isModuleClass(node)) return
|
|
492
|
+
const base = node.superClass
|
|
493
|
+
const imported = base?.type === "Identifier" ? importedFrom.get(base.name) : null
|
|
494
|
+
if (!imported || imported.name !== "ConfigurableModuleClass" || imported.source !== `./${scope.name}.module-definition`) {
|
|
495
|
+
context.report({ node: node.id ?? node, messageId: "extendsBase", data: { class: node.id?.name ?? "This class", name: scope.name } })
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
Property(node) {
|
|
499
|
+
const array = importsArrayOf(node)
|
|
500
|
+
if (!array) return
|
|
501
|
+
for (const entry of array.elements) {
|
|
502
|
+
if (entry?.type === "Identifier" && isModuleClassIdentifier(entry)) context.report({ node: entry, messageId: "unregistered", data: { name: entry.name } })
|
|
503
|
+
}
|
|
504
|
+
},
|
|
411
505
|
}
|
|
412
506
|
},
|
|
413
507
|
}
|
|
@@ -418,6 +512,7 @@ export const rules = {
|
|
|
418
512
|
"is-global-only-in-app": isGlobalOnlyInApp,
|
|
419
513
|
"no-cross-owner-module-import": noCrossOwnerModuleImport,
|
|
420
514
|
"capability-module-shape": capabilityModuleShape,
|
|
515
|
+
"module-definition-required": moduleDefinitionRequired,
|
|
421
516
|
"typed-module-definition": typedModuleDefinition,
|
|
422
517
|
"static-module-register": staticModuleRegister,
|
|
423
518
|
"no-new-injectable": noNewInjectable,
|
|
@@ -431,6 +526,7 @@ export const recommended = {
|
|
|
431
526
|
"starci-be/is-global-only-in-app": "error",
|
|
432
527
|
"starci-be/no-cross-owner-module-import": "error",
|
|
433
528
|
"starci-be/capability-module-shape": "error",
|
|
529
|
+
"starci-be/module-definition-required": "error",
|
|
434
530
|
"starci-be/typed-module-definition": "error",
|
|
435
531
|
"starci-be/static-module-register": "error",
|
|
436
532
|
"starci-be/no-new-injectable": "error",
|
package/operations.mjs
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* a path pattern.
|
|
12
12
|
*/
|
|
13
13
|
import ts from "typescript"
|
|
14
|
+
import { keyName, walk } from "./lib/ast.mjs"
|
|
14
15
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
15
|
-
import { decoratorCallee, isImportedFrom } from "./lib/import-source.mjs"
|
|
16
|
+
import { decoratorArguments, decoratorCallee, isImportedFrom } from "./lib/import-source.mjs"
|
|
16
17
|
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
17
18
|
import { typed } from "./lib/types.mjs"
|
|
18
19
|
|
|
@@ -155,6 +156,167 @@ const unwrapPromise = (annotation) => {
|
|
|
155
156
|
return node ?? null
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/** The canon request and reply annotations of one HTTP method. */
|
|
160
|
+
const operationReferences = (context, method) => {
|
|
161
|
+
const hfs = hfsOf(context)
|
|
162
|
+
const { checker, toTs } = typed(context)
|
|
163
|
+
const params = method.value.params.filter((param) => (param.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Body")))
|
|
164
|
+
return {
|
|
165
|
+
request: params.map((param) => canonReference(hfs, checker, toTs, param.typeAnnotation ?? param.parameter?.typeAnnotation)).find((reference) => reference?.name === "OperationRequest") ?? null,
|
|
166
|
+
reply: canonReference(hfs, checker, toTs, unwrapPromise(method.value.returnType)),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A route's table must be the nonempty result of the canon defineOperations, including an imported app table. */
|
|
171
|
+
export const operationRouteEvidence = (context) => {
|
|
172
|
+
const hfs = hfsOf(context)
|
|
173
|
+
const { program, checker } = typed(context)
|
|
174
|
+
let tables
|
|
175
|
+
const declaredTables = () => {
|
|
176
|
+
if (tables) return tables
|
|
177
|
+
tables = new Set()
|
|
178
|
+
const visit = (node) => {
|
|
179
|
+
if (ts.isCallExpression(node) && isCanonType(hfs, checker, checker.getSymbolAtLocation(node.expression), "defineOperations")) {
|
|
180
|
+
const table = checker.getTypeAtLocation(node)
|
|
181
|
+
if (checker.getPropertiesOfType(table).length > 0) tables.add(table)
|
|
182
|
+
}
|
|
183
|
+
ts.forEachChild(node, visit)
|
|
184
|
+
}
|
|
185
|
+
for (const source of program.getSourceFiles()) {
|
|
186
|
+
if (!source.isDeclarationFile && hfs.slotOf(source.fileName)) visit(source)
|
|
187
|
+
}
|
|
188
|
+
return tables
|
|
189
|
+
}
|
|
190
|
+
return (method) => {
|
|
191
|
+
const { request, reply } = operationReferences(context, method)
|
|
192
|
+
return request?.table != null && reply?.name === "OperationReply" && request.table === reply.table && declaredTables().has(request.table)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A symbol declared by the app's domain identity capability, rather than a same-named local decorator or enum. */
|
|
197
|
+
const identityExport = (context, node, name) => {
|
|
198
|
+
if (!node) return false
|
|
199
|
+
const hfs = hfsOf(context)
|
|
200
|
+
const { checker, toTs } = typed(context)
|
|
201
|
+
const target = toTs(node)
|
|
202
|
+
if (!target) return false
|
|
203
|
+
return declarationsOfSymbol(checker, checker.getSymbolAtLocation(target)).some((declaration) => {
|
|
204
|
+
const file = declaration.getSourceFile().fileName
|
|
205
|
+
return declaration.name?.text === name && hfs.tierOf(file) === "domain" && hfs.ownerOf(file)?.split("/").pop() === "identity"
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const REST_REASONS = new Set(["Health", "AuthHandshake", "SignedWebhook"])
|
|
210
|
+
const FILE_INTERCEPTORS = ["FileInterceptor", "FilesInterceptor", "AnyFilesInterceptor", "FileFieldsInterceptor"]
|
|
211
|
+
|
|
212
|
+
/** A stream actually returned by this method; importing or mentioning a stream constructor proves nothing. */
|
|
213
|
+
const returnsBytes = (context, method) => {
|
|
214
|
+
const seen = new Set()
|
|
215
|
+
const isBytes = (expression) => {
|
|
216
|
+
if (!expression || seen.has(expression)) return false
|
|
217
|
+
seen.add(expression)
|
|
218
|
+
if (["AwaitExpression", "TSAsExpression", "TSTypeAssertion", "TSNonNullExpression"].includes(expression.type)) return isBytes(expression.argument ?? expression.expression)
|
|
219
|
+
if (expression.type === "NewExpression" && isImportedFrom(context, expression.callee, "@nestjs/common", "StreamableFile")) return true
|
|
220
|
+
if (expression.type === "CallExpression" && isImportedFrom(context, expression.callee, ["node:fs", "fs"], "createReadStream")) return true
|
|
221
|
+
if (expression.type !== "Identifier") return false
|
|
222
|
+
for (let scope = context.sourceCode.getScope(expression); scope; scope = scope.upper) {
|
|
223
|
+
const variable = scope.set.get(expression.name)
|
|
224
|
+
if (variable) return variable.defs.some((definition) => definition.type === "Variable" && isBytes(definition.node.init))
|
|
225
|
+
}
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
let found = false
|
|
229
|
+
walk(method.value.body, (node) => {
|
|
230
|
+
if (node.type === "ReturnStatement" && isBytes(node.argument)) found = true
|
|
231
|
+
}, { intoFunctions: false })
|
|
232
|
+
return found
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** True when a class declaration implements the `CanActivate` of `@nestjs/common` (resolved by symbol, not by spelling). */
|
|
236
|
+
const implementsCanActivate = (checker, declaration) =>
|
|
237
|
+
(declaration.heritageClauses ?? []).some((clause) => clause.token === ts.SyntaxKind.ImplementsKeyword && clause.types.some((type) => {
|
|
238
|
+
const symbol = checker.getSymbolAtLocation(type.expression)
|
|
239
|
+
return declarationsOfSymbol(checker, symbol).some((found) => found.name?.text === "CanActivate" && /[\/]@nestjs[\/]common[\/]/.test(String(found.getSourceFile().fileName)))
|
|
240
|
+
}))
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* True when the identity capability that declares `Credential` also declares the canon `CredentialGuard`, a class implementing
|
|
244
|
+
* `CanActivate`. The decorator only selects the credential kind; this guard is what authenticates it.
|
|
245
|
+
*/
|
|
246
|
+
const credentialGuardDeclared = (context, decoratorCallee_) => {
|
|
247
|
+
const hfs = hfsOf(context)
|
|
248
|
+
const { program, checker, toTs } = typed(context)
|
|
249
|
+
const target = toTs(decoratorCallee_)
|
|
250
|
+
const home = declarationsOfSymbol(checker, target ? checker.getSymbolAtLocation(target) : undefined).find((declaration) => declaration.name?.text === "Credential")
|
|
251
|
+
const owner = home && hfs.ownerOf(home.getSourceFile().fileName)
|
|
252
|
+
if (!owner) return false
|
|
253
|
+
for (const source of program.getSourceFiles()) {
|
|
254
|
+
if (source.isDeclarationFile || hfs.ownerOf(source.fileName) !== owner) continue
|
|
255
|
+
const found = source.statements.some((statement) => ts.isClassDeclaration(statement) && statement.name?.text === "CredentialGuard" && implementsCanActivate(checker, statement))
|
|
256
|
+
if (found) return true
|
|
257
|
+
}
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Credential-guarded route (BE_REST_DOOR_EVIDENCE, owner delegation 2026-10-16): the route, or its controller class, carries
|
|
263
|
+
* `@Credential({ kind: "<literal>" })` imported from the domain identity capability, and that capability declares a
|
|
264
|
+
* `CredentialGuard implements CanActivate`. A same-named local decorator, a `@UseGuards` of any other guard and a non-literal kind prove nothing.
|
|
265
|
+
*/
|
|
266
|
+
const credentialGuardEvidence = (context, method) => {
|
|
267
|
+
const carriers = [...(method.decorators ?? []), ...(method.parent?.parent?.decorators ?? [])]
|
|
268
|
+
for (const decorator of carriers) {
|
|
269
|
+
const callee = decoratorCallee(decorator)
|
|
270
|
+
if (!identityExport(context, callee, "Credential")) continue
|
|
271
|
+
const options = decoratorArguments(decorator)[0]
|
|
272
|
+
const kind = options?.type === "ObjectExpression" ? options.properties.find((property) => property.type === "Property" && keyName(property.key) === "kind")?.value : null
|
|
273
|
+
if (kind?.type === "Literal" && typeof kind.value === "string" && kind.value !== "" && credentialGuardDeclared(context, callee)) return true
|
|
274
|
+
}
|
|
275
|
+
return false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Route-level evidence for probes, auth handshakes, signed webhooks, redirects, byte transfers and credential-guarded routes. */
|
|
279
|
+
export const restExceptionEvidence = (context, method) => {
|
|
280
|
+
if (credentialGuardEvidence(context, method)) return true
|
|
281
|
+
for (const decorator of method.decorators ?? []) {
|
|
282
|
+
const callee = decoratorCallee(decorator)
|
|
283
|
+
if (isImportedFrom(context, callee, "@nestjs/common", "Redirect")) return true
|
|
284
|
+
if (identityExport(context, callee, "Public")) {
|
|
285
|
+
const options = decoratorArguments(decorator)[0]
|
|
286
|
+
const reason = options?.type === "ObjectExpression" ? options.properties.find((property) => property.type === "Property" && keyName(property.key) === "reason")?.value : null
|
|
287
|
+
if (reason?.type === "MemberExpression" && !reason.computed && REST_REASONS.has(keyName(reason.property)) && identityExport(context, reason.object, "PublicReason")) return true
|
|
288
|
+
}
|
|
289
|
+
if (isImportedFrom(context, callee, "@nestjs/common", "UseInterceptors") && decoratorArguments(decorator).some((argument) => argument.type === "CallExpression" && isImportedFrom(context, argument.callee, "@nestjs/platform-express", FILE_INTERCEPTORS))) return true
|
|
290
|
+
}
|
|
291
|
+
return returnsBytes(context, method)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Public REST JSON is exposed through the app's declared operation table. */
|
|
295
|
+
export const restRouteDeclared = {
|
|
296
|
+
meta: {
|
|
297
|
+
type: "problem",
|
|
298
|
+
docs: { description: "Every REST route outside probes, auth handshakes, signed webhooks and byte transfers uses a declared operation table." },
|
|
299
|
+
schema: [],
|
|
300
|
+
messages: {
|
|
301
|
+
undeclared: "[BE_REST_ROUTE_UNDECLARED] This REST route is absent from the app's defineOperations table. Serve public v1 JSON through @Body() request: OperationRequest<Table> and Promise<OperationReply<Table>> of the same declared table; otherwise expose it through GraphQL.",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
create(context) {
|
|
305
|
+
const hfs = hfsOf(context)
|
|
306
|
+
if (!isTransportSlot(hfs.slotOf(context.filename))) return {}
|
|
307
|
+
const declared = operationRouteEvidence(context)
|
|
308
|
+
return {
|
|
309
|
+
MethodDefinition(node) {
|
|
310
|
+
if (node.kind !== "method" || node.static) return
|
|
311
|
+
if (!(node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", HTTP_ROUTES))) return
|
|
312
|
+
const controller = node.parent?.parent
|
|
313
|
+
if (!(controller?.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), "@nestjs/common", "Controller"))) return
|
|
314
|
+
if (!restExceptionEvidence(context, node) && !declared(node)) context.report({ node: node.key, messageId: "undeclared" })
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
|
|
158
320
|
/** An operation route takes the table's `OperationRequest` and answers its `OperationReply`, of one table. */
|
|
159
321
|
export const operationRouteDrivenByTable = {
|
|
160
322
|
meta: {
|
|
@@ -192,10 +354,12 @@ export const operationRouteDrivenByTable = {
|
|
|
192
354
|
export const rules = {
|
|
193
355
|
"operation-contract-decidable": operationContractDecidable,
|
|
194
356
|
"operation-route-driven-by-table": operationRouteDrivenByTable,
|
|
357
|
+
"rest-route-declared": restRouteDeclared,
|
|
195
358
|
}
|
|
196
359
|
|
|
197
360
|
/** Every rule of this law at `error`. */
|
|
198
361
|
export const recommended = {
|
|
199
362
|
"starci-be/operation-contract-decidable": "error",
|
|
200
363
|
"starci-be/operation-route-driven-by-table": "error",
|
|
364
|
+
"starci-be/rest-route-declared": "error",
|
|
201
365
|
}
|
package/package.json
CHANGED
package/realtime.mjs
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
* subscription, slot `be.feature.realtime`) READS and PUSHES. It never writes: the push is fed by a reactor that calls the `RealtimeHub`
|
|
6
6
|
* of `platform/realtime`, and the door only subscribes a client to a topic of that hub.
|
|
7
7
|
*
|
|
8
|
-
* - `realtime-shape` (R184): a `.gateway.ts` holds one `@WebSocketGateway` class whose handlers are `@SubscribeMessage
|
|
8
|
+
* - `realtime-shape` (R184): a `.gateway.ts` holds one `@WebSocketGateway` class whose handlers are `@SubscribeMessage`, or a
|
|
9
|
+
* push-only gateway whose `@WebSocketServer` streams take the server's typed principal; a
|
|
9
10
|
* `.subscription.ts` holds one `@Resolver` class whose operations are `@Subscription`. Each is the door its file role names, and
|
|
10
11
|
* neither carries a query, a mutation or a REST route.
|
|
11
12
|
* - `realtime-read-only` (R185): the only value injected into a realtime door is the `RealtimeHub` of `platform/realtime`. No
|
|
12
13
|
* `EntityManager`, bus, queue, event bus or domain service reaches it.
|
|
13
14
|
* - `realtime-topic-scope` (R186): the topic passed to `hub.subscribe` is built from the principal parameter of the handler
|
|
14
|
-
* (`@CurrentPrincipal()` of `domain/identity`
|
|
15
|
+
* (`@CurrentPrincipal()` of `domain/identity`, or `Principal` of `platform/cqrs` for a push-only stream), so a client
|
|
16
|
+
* can only listen to what the principal owns.
|
|
15
17
|
*
|
|
16
18
|
* What a receiver is comes from its TYPE, what a decorator is from the import that binds it, and where a parameter comes
|
|
17
19
|
* from from the declaration its name resolves to. No rule here tests a path pattern or a variable name.
|
|
@@ -46,15 +48,37 @@ const doorClasses = (context, program, role) => {
|
|
|
46
48
|
return found
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/** The framework supplies this server property; no inbound message supplies a stream's principal. */
|
|
52
|
+
const hasSocketServer = (context, node) => node.body.body.some((member) =>
|
|
53
|
+
member.type === "PropertyDefinition" && hasClassDecorator(context, member, "@nestjs/websockets", "WebSocketServer"))
|
|
54
|
+
|
|
55
|
+
/** The authenticated principal port the socket middleware passes to a push-only stream. */
|
|
56
|
+
const isStreamPrincipal = (context, param) => isOwnedType(context, param, { name: "Principal", capability: "cqrs", tier: "platform" })
|
|
57
|
+
|
|
58
|
+
/** Subscriptions on the platform's hub, including those inside closures. */
|
|
59
|
+
const hubSubscriptions = (context, method) => callsOf(method).filter(({ receiver, method: called }) =>
|
|
60
|
+
called === "subscribe" && receiver !== null && isHubType(context, receiver))
|
|
61
|
+
|
|
62
|
+
/** The nonstatic methods a push-only gateway uses to feed its socket server. */
|
|
63
|
+
const streamMethods = (context, node) => {
|
|
64
|
+
if (!hasSocketServer(context, node)) return []
|
|
65
|
+
return node.body.body.filter((member) => member.type === "MethodDefinition" && member.kind === "method" && !member.static
|
|
66
|
+
&& hubSubscriptions(context, member).length > 0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An inbound handler or a push-only stream taking the server's authenticated principal. */
|
|
70
|
+
const hasOperation = (context, node, role) => methodsDecoratedBy(context, node, ...ROLES[role].operation).length > 0
|
|
71
|
+
|| (role === "gateway" && streamMethods(context, node).some((method) => parametersOf(method).some((param) => isStreamPrincipal(context, param))))
|
|
72
|
+
|
|
49
73
|
/** A realtime file is the one door its role names, and nothing else. */
|
|
50
74
|
export const realtimeShape = {
|
|
51
75
|
meta: {
|
|
52
76
|
type: "problem",
|
|
53
|
-
docs: { description: "A realtime file is one door: a `.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers
|
|
77
|
+
docs: { description: "A realtime file is one door: a `.gateway.ts` holds one `@WebSocketGateway` class with `@SubscribeMessage` handlers or principal-typed push streams through `@WebSocketServer`, a `.subscription.ts` holds one `@Resolver` class with `@Subscription` operations, and neither carries a route, query or mutation." },
|
|
54
78
|
schema: [],
|
|
55
79
|
messages: {
|
|
56
80
|
door: "A `<channel>.{{role}}.ts` file declares exactly one `{{decorator}}` class (this one declares {{count}}). One file is one door.",
|
|
57
|
-
operations: "This {{role}} door has no `@{{operation}}` handler
|
|
81
|
+
operations: "This {{role}} door has no `@{{operation}}` handler or principal-typed push stream through `@WebSocketServer`. A realtime door subscribes the client to a topic of the `RealtimeHub`.",
|
|
58
82
|
foreign: "`@{{name}}` has no place in a realtime {{role}} door. A realtime door only subscribes and pushes; a query, a mutation, a field resolver or a REST route belongs in an api feature, and the other realtime protocol in its own file.",
|
|
59
83
|
},
|
|
60
84
|
},
|
|
@@ -70,7 +94,7 @@ export const realtimeShape = {
|
|
|
70
94
|
context.report({ node: program, messageId: "door", data: { role, decorator: `@${spec.classDecorator[1]}`, count: String(doors.length) } })
|
|
71
95
|
return
|
|
72
96
|
}
|
|
73
|
-
if (
|
|
97
|
+
if (!hasOperation(context, doors[0], role)) context.report({ node: doors[0], messageId: "operations", data: { role, operation: spec.operation[1] } })
|
|
74
98
|
walk(program, (node) => {
|
|
75
99
|
if (node.type !== "Decorator") return
|
|
76
100
|
for (const [source, names] of spec.foreign) {
|
|
@@ -128,14 +152,37 @@ const resolvesTo = (context, identifier, parameters) => {
|
|
|
128
152
|
return false
|
|
129
153
|
}
|
|
130
154
|
|
|
155
|
+
/** A method's principal parameters, declared by the identity decorator or the push stream's platform type. */
|
|
156
|
+
const principalParameters = (context, hfs, method, push) => {
|
|
157
|
+
const principals = new Set()
|
|
158
|
+
for (const param of parametersOf(method)) {
|
|
159
|
+
if (!isPrincipalParameter(context, hfs, param) && !(push && isStreamPrincipal(context, param))) continue
|
|
160
|
+
const target = param.type === "AssignmentPattern" ? param.left : param
|
|
161
|
+
if (target.type === "Identifier") principals.add(target)
|
|
162
|
+
}
|
|
163
|
+
return principals
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The hub topic must use the method's principal declaration rather than a shadowing identifier. */
|
|
167
|
+
const checkMethodTopics = (context, hfs, method, push) => {
|
|
168
|
+
const principals = principalParameters(context, hfs, method, push)
|
|
169
|
+
for (const { call } of hubSubscriptions(context, method)) {
|
|
170
|
+
let scoped = false
|
|
171
|
+
walk(call.arguments[0], (child) => {
|
|
172
|
+
if (child.type === "Identifier" && resolvesTo(context, child, principals)) scoped = true
|
|
173
|
+
})
|
|
174
|
+
if (!scoped) context.report({ node: call, messageId: "scope" })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
131
178
|
/** The topic a client subscribes to is built from the principal the handler receives. */
|
|
132
179
|
export const realtimeTopicScope = {
|
|
133
180
|
meta: {
|
|
134
181
|
type: "problem",
|
|
135
|
-
docs: { description: "The topic passed to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter." },
|
|
182
|
+
docs: { description: "The topic passed to `RealtimeHub.subscribe` is built from the handler's `@CurrentPrincipal()` parameter or the push-only stream's platform `Principal` parameter." },
|
|
136
183
|
schema: [],
|
|
137
184
|
messages: {
|
|
138
|
-
scope: "The topic of this `subscribe` is not built from the principal. A realtime handler takes `@CurrentPrincipal() principal` and subscribes to a topic made from `principal.id` (plus any verified id), so one client cannot listen to another's channel. A topic taken only from client input is a leak.",
|
|
185
|
+
scope: "The topic of this `subscribe` is not built from the principal. A realtime handler takes `@CurrentPrincipal() principal`, or a push-only stream takes the platform `Principal`, and subscribes to a topic made from `principal.id` (plus any verified id), so one client cannot listen to another's channel. A topic taken only from client input is a leak.",
|
|
139
186
|
},
|
|
140
187
|
},
|
|
141
188
|
create(context) {
|
|
@@ -145,21 +192,11 @@ export const realtimeTopicScope = {
|
|
|
145
192
|
const [source, name] = ROLES[role].operation
|
|
146
193
|
return {
|
|
147
194
|
ClassDeclaration(node) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (target.type === "Identifier") principals.add(target)
|
|
154
|
-
}
|
|
155
|
-
for (const { call, receiver, method: called } of callsOf(method)) {
|
|
156
|
-
if (called !== "subscribe" || receiver === null || !isHubType(context, receiver)) continue
|
|
157
|
-
let scoped = false
|
|
158
|
-
walk(call.arguments[0], (child) => {
|
|
159
|
-
if (child.type === "Identifier" && resolvesTo(context, child, principals)) scoped = true
|
|
160
|
-
})
|
|
161
|
-
if (!scoped) context.report({ node: call, messageId: "scope" })
|
|
162
|
-
}
|
|
195
|
+
const handlers = methodsDecoratedBy(context, node, source, name)
|
|
196
|
+
for (const method of handlers) checkMethodTopics(context, hfs, method, false)
|
|
197
|
+
if (role !== "gateway") return
|
|
198
|
+
for (const method of streamMethods(context, node)) {
|
|
199
|
+
if (!handlers.includes(method)) checkMethodTopics(context, hfs, method, true)
|
|
163
200
|
}
|
|
164
201
|
},
|
|
165
202
|
}
|