@noctcore/lint-meta-rules 0.5.0 → 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,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.
@@ -0,0 +1,77 @@
1
+ # `session-mint-callers`
2
+
3
+ > The method that mints a session is callable only from an allowlist of files, so a new sign-in
4
+ > entry point cannot skip the gate in front of it.
5
+
6
+ Import it from the `session` entry point:
7
+
8
+ ```ts
9
+ import { createSessionMintCallersRule } from '@noctcore/lint-meta-rules/session';
10
+ ```
11
+
12
+ ## Why
13
+
14
+ Most auth stacks have one method that turns an authenticated principal into a session: it writes the
15
+ session store, sets the cookie, rotates CSRF. In front of it sits a gate (a second-factor challenge, an
16
+ epoch capture) that every sign-in is meant to pass through. A new entry point, typically an OAuth
17
+ callback added months later, that calls the mint directly signs the user in with the gate skipped, and
18
+ nothing fails: the flow works, its tests pass, the gate's tests pass. The mint is the one door into a
19
+ session, so this rule fences the door by caller.
20
+
21
+ ## What it flags
22
+
23
+ A file matched by `sourceGlobs` that calls `.<mintCall>(` (whitespace before the paren allowed) and is
24
+ not in `allowedCallers`.
25
+
26
+ ## What it leaves alone
27
+
28
+ - The method's definition (`async establishSession(`): the leading dot is required.
29
+ - `.<mintCall>Something(`: only whitespace may sit between the name and the paren.
30
+ - Files ending in an `excludeSuffixes` entry (tests drive the mint directly), and paths with a
31
+ `skipDirs` segment.
32
+ - Everything outside `sourceGlobs`, including a lint-meta rule module that quotes the call.
33
+
34
+ With no `mintCall` the rule is inert.
35
+
36
+ ## Factory
37
+
38
+ ```ts
39
+ createSessionMintCallersRule(options?: SessionMintCallersOptions): IMetaRule
40
+ ```
41
+
42
+ | Option | Type | Default | Meaning |
43
+ | --- | --- | --- | --- |
44
+ | `id` | `string` | `'session-mint-callers'` | Rule id. |
45
+ | `mintCall` | `string` | none (inert) | The method whose call mints a session, matched as `.<mintCall>(`. |
46
+ | `allowedCallers` | `string[]` | `[]` | Repo-relative files that may call it, matched exactly: the method's home, the gate, and flows with no gate to pass (signup, a re-issue to a caller who already holds a session). |
47
+ | `gateCall` | `string` | none | The method a sign-in entry point must call instead, named in the message. |
48
+ | `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
49
+ | `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
50
+ | `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
51
+ | `hint` | `string` | none | Appended to every message, e.g. a pointer to your auth docs. |
52
+ | `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
53
+
54
+ ## Worked example: Settly
55
+
56
+ ```ts
57
+ const AUTH = 'apps/api/src/modules/auth/services';
58
+
59
+ createSessionMintCallersRule({
60
+ id: 'establish-session-callers',
61
+ mintCall: 'establishSession',
62
+ gateCall: 'beginOrEstablish',
63
+ allowedCallers: [
64
+ `${AUTH}/auth-shared.service.ts`,
65
+ `${AUTH}/two-factor-challenge.service.ts`,
66
+ `${AUTH}/register.service.ts`,
67
+ `${AUTH}/password-change.service.ts`,
68
+ `${AUTH}/session-management.service.ts`,
69
+ ],
70
+ sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
71
+ });
72
+ ```
73
+
74
+ ## When not to use it
75
+
76
+ If sessions are minted by a framework you do not call (a hosted auth provider's middleware), there is no
77
+ method to fence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noctcore/lint-meta-rules",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Portable, parameterized lint-meta rules — whole-repo / cross-file invariants ESLint cannot reach — for the @noctcore/harness lint-meta runner.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,6 +28,16 @@
28
28
  "import": "./dist/resolved-config.js",
29
29
  "require": "./dist/resolved-config.cjs"
30
30
  },
31
+ "./session": {
32
+ "types": "./dist/session.d.ts",
33
+ "import": "./dist/session.js",
34
+ "require": "./dist/session.cjs"
35
+ },
36
+ "./trpc": {
37
+ "types": "./dist/trpc.d.ts",
38
+ "import": "./dist/trpc.js",
39
+ "require": "./dist/trpc.cjs"
40
+ },
31
41
  "./package.json": "./package.json"
32
42
  },
33
43
  "files": [
@@ -56,7 +66,7 @@
56
66
  "homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/lint-meta-rules",
57
67
  "bugs": "https://github.com/noctcore/eslint-plugins/issues",
58
68
  "scripts": {
59
- "build": "tsup src/index.ts src/i18n.ts src/prisma.ts src/resolved-config.ts --format esm,cjs --dts --clean",
69
+ "build": "tsup src/index.ts src/i18n.ts src/prisma.ts src/resolved-config.ts src/session.ts src/trpc.ts --format esm,cjs --dts --clean",
60
70
  "typecheck": "tsc --noEmit",
61
71
  "test": "bun test"
62
72
  },
@@ -79,9 +89,9 @@
79
89
  }
80
90
  },
81
91
  "devDependencies": {
82
- "@types/node": "^22.0.0",
83
- "@typescript-eslint/parser": "^8.61.1",
84
- "eslint": "^10.7.0",
92
+ "@types/node": "^22.20.3",
93
+ "@typescript-eslint/parser": "^8.70.0",
94
+ "eslint": "^10.10.0",
85
95
  "tsup": "^8.5.1",
86
96
  "typescript": "^5.6.0"
87
97
  }