@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/README.md +24 -0
- package/dist/{chunk-Z7TXSZR4.js → chunk-OYFQKSJN.js} +9 -1
- package/dist/i18n.js +1 -1
- package/dist/index.js +1 -1
- package/dist/prisma.js +3 -3
- package/dist/session.cjs +297 -0
- package/dist/session.d.cts +226 -0
- package/dist/session.d.ts +226 -0
- package/dist/session.js +244 -0
- package/dist/trpc.cjs +122 -0
- package/dist/trpc.d.cts +59 -0
- package/dist/trpc.d.ts +59 -0
- package/dist/trpc.js +73 -0
- package/docs/rules/idempotency-key-parity.md +83 -0
- package/docs/rules/session-epoch-captured.md +83 -0
- package/docs/rules/session-kind-stamped.md +81 -0
- package/docs/rules/session-landing-declared.md +87 -0
- package/docs/rules/session-mint-callers.md +77 -0
- package/package.json +15 -5
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.
|
|
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.
|
|
83
|
-
"@typescript-eslint/parser": "^8.
|
|
84
|
-
"eslint": "^10.
|
|
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
|
}
|