@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/README.md
CHANGED
|
@@ -106,3 +106,27 @@ needs the optional peer `eslint`. Neither is in `RULE_FACTORIES`: both need the
|
|
|
106
106
|
| --- | --- | --- |
|
|
107
107
|
| [`createTenantModelRegistryParityRule`](./docs/rules/tenant-model-registry-parity.md) | config | Every tenant-bearing schema model is scoped at runtime or exempt with a reason, and the tenant lint rules resolve with exactly that registry. |
|
|
108
108
|
| [`createPrismaMethodSurfaceRule`](./docs/rules/prisma-method-surface.md) | config | The reads and writes the rules police are exactly the generated client's delegate methods, so a Prisma upgrade cannot add an unguarded one. |
|
|
109
|
+
|
|
110
|
+
### `@noctcore/lint-meta-rules/session`
|
|
111
|
+
|
|
112
|
+
Fences around the one seam that turns an authenticated principal into a session. Each rule is inert
|
|
113
|
+
until you name that seam: the method that mints, the gate in front of it, the files allowed to call it,
|
|
114
|
+
the landings a sign-in can end in. None is in `RULE_FACTORIES`. They load nothing beyond the harness
|
|
115
|
+
contract. Ported from a production NestJS app, where each one exists because its bug shipped once.
|
|
116
|
+
|
|
117
|
+
| Factory | Category | What it enforces |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| [`createSessionMintCallersRule`](./docs/rules/session-mint-callers.md) | source-text | The method that mints a session is called only from allowlisted files, so a new sign-in entry point cannot skip the gate in front of it. |
|
|
120
|
+
| [`createSessionKindStampedRule`](./docs/rules/session-kind-stamped.md) | source-text | Every mint stamps the principal kind onto the session, or sits in an allowlisted, provably single-kind flow. |
|
|
121
|
+
| [`createSessionEpochCapturedRule`](./docs/rules/session-epoch-captured.md) | source-text | Every call into the sign-in seam passes the revocation epoch captured before the credential was read. |
|
|
122
|
+
| [`createSessionLandingDeclaredRule`](./docs/rules/session-landing-declared.md) | source-text | Every file that opens a door into a session declares its landing, and a door whose landing demands a return shape has it. |
|
|
123
|
+
|
|
124
|
+
### `@noctcore/lint-meta-rules/trpc`
|
|
125
|
+
|
|
126
|
+
Cross-tree checks between tRPC routers and the clients that call them. The decorator shape defaults to
|
|
127
|
+
`nestjs-trpc`'s; the rule is inert until you name the middleware and the two trees. Not in
|
|
128
|
+
`RULE_FACTORIES`.
|
|
129
|
+
|
|
130
|
+
| Factory | Category | What it enforces |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| [`createIdempotencyKeyParityRule`](./docs/rules/idempotency-key-parity.md) | source-text | A procedure guarded by an idempotency middleware has a client caller that sends the key, or no client caller at all. |
|
|
@@ -47,6 +47,13 @@ function unquote(value) {
|
|
|
47
47
|
function anywhereGlobs(baseNames) {
|
|
48
48
|
return baseNames.flatMap((name) => [`**/${name}`, `**/.*/**/${name}`]);
|
|
49
49
|
}
|
|
50
|
+
function readSourceText(read, rel) {
|
|
51
|
+
try {
|
|
52
|
+
return read(rel);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
50
57
|
|
|
51
58
|
export {
|
|
52
59
|
dirOf,
|
|
@@ -59,5 +66,6 @@ export {
|
|
|
59
66
|
globFiles,
|
|
60
67
|
stripYamlComment,
|
|
61
68
|
unquote,
|
|
62
|
-
anywhereGlobs
|
|
69
|
+
anywhereGlobs,
|
|
70
|
+
readSourceText
|
|
63
71
|
};
|
package/dist/i18n.js
CHANGED
package/dist/index.js
CHANGED
package/dist/prisma.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
globFiles
|
|
3
|
-
} from "./chunk-Z7TXSZR4.js";
|
|
4
1
|
import {
|
|
5
2
|
firstOptionOf,
|
|
6
3
|
resolveRules,
|
|
7
4
|
severityOf
|
|
8
5
|
} from "./chunk-VFCX3QKZ.js";
|
|
6
|
+
import {
|
|
7
|
+
globFiles
|
|
8
|
+
} from "./chunk-OYFQKSJN.js";
|
|
9
9
|
|
|
10
10
|
// src/prisma/prisma-method-surface.ts
|
|
11
11
|
import { PRISMA_READ_METHODS, PRISMA_WRITE_METHODS } from "@noctcore/eslint-plugin-prisma";
|
package/dist/session.cjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/session.ts
|
|
21
|
+
var session_exports = {};
|
|
22
|
+
__export(session_exports, {
|
|
23
|
+
callArguments: () => callArguments,
|
|
24
|
+
createSessionEpochCapturedRule: () => createSessionEpochCapturedRule,
|
|
25
|
+
createSessionKindStampedRule: () => createSessionKindStampedRule,
|
|
26
|
+
createSessionLandingDeclaredRule: () => createSessionLandingDeclaredRule,
|
|
27
|
+
createSessionMintCallersRule: () => createSessionMintCallersRule
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(session_exports);
|
|
30
|
+
|
|
31
|
+
// src/rules/shared.ts
|
|
32
|
+
function escapeRegExp(value) {
|
|
33
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
34
|
+
}
|
|
35
|
+
var DEFAULT_SKIP_DIRS = [
|
|
36
|
+
"node_modules",
|
|
37
|
+
".git",
|
|
38
|
+
"dist",
|
|
39
|
+
".turbo",
|
|
40
|
+
"coverage"
|
|
41
|
+
];
|
|
42
|
+
function globFiles(glob, globs, skipDirs = []) {
|
|
43
|
+
const skip = new Set(skipDirs);
|
|
44
|
+
const found = /* @__PURE__ */ new Set();
|
|
45
|
+
for (const pattern of globs) {
|
|
46
|
+
for (const rel of glob(pattern)) {
|
|
47
|
+
if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...found].sort();
|
|
51
|
+
}
|
|
52
|
+
function readSourceText(read, rel) {
|
|
53
|
+
try {
|
|
54
|
+
return read(rel);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/session/scan.ts
|
|
61
|
+
var DEFAULT_EXCLUDE_SUFFIXES = [
|
|
62
|
+
".spec.ts",
|
|
63
|
+
".spec.tsx",
|
|
64
|
+
".test.ts",
|
|
65
|
+
".test.tsx"
|
|
66
|
+
];
|
|
67
|
+
function scopedSources(ctx, options) {
|
|
68
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
69
|
+
const excludeSuffixes = options.excludeSuffixes ?? DEFAULT_EXCLUDE_SUFFIXES;
|
|
70
|
+
const matched = globFiles(ctx.glob, options.sourceGlobs ?? [], skipDirs).map(toPosix);
|
|
71
|
+
const sources = [];
|
|
72
|
+
for (const file of matched) {
|
|
73
|
+
if (excludeSuffixes.some((suffix) => file.endsWith(suffix))) continue;
|
|
74
|
+
const text = readSourceText(ctx.read, file);
|
|
75
|
+
if (text !== null) sources.push({ file, text });
|
|
76
|
+
}
|
|
77
|
+
return { matched: new Set(matched), sources };
|
|
78
|
+
}
|
|
79
|
+
function toPosix(file) {
|
|
80
|
+
return file.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
81
|
+
}
|
|
82
|
+
function pathSet(files) {
|
|
83
|
+
return new Set((files ?? []).map(toPosix));
|
|
84
|
+
}
|
|
85
|
+
function callsMethod(source, method) {
|
|
86
|
+
return new RegExp(`\\.${escapeRegExp(method)}\\s*\\(`, "u").test(source);
|
|
87
|
+
}
|
|
88
|
+
function callArguments(source, method) {
|
|
89
|
+
const needle = `.${method}`;
|
|
90
|
+
const calls = [];
|
|
91
|
+
let from = 0;
|
|
92
|
+
for (; ; ) {
|
|
93
|
+
const hit = source.indexOf(needle, from);
|
|
94
|
+
if (hit === -1) break;
|
|
95
|
+
from = hit + needle.length;
|
|
96
|
+
const open = source.indexOf("(", from);
|
|
97
|
+
if (open === -1) break;
|
|
98
|
+
if (source.slice(from, open).trim() !== "") continue;
|
|
99
|
+
let depth = 0;
|
|
100
|
+
let end = open;
|
|
101
|
+
for (; end < source.length; end += 1) {
|
|
102
|
+
const char = source[end];
|
|
103
|
+
if (char === "(") depth += 1;
|
|
104
|
+
else if (char === ")") {
|
|
105
|
+
depth -= 1;
|
|
106
|
+
if (depth === 0) break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
calls.push(source.slice(open + 1, end));
|
|
110
|
+
from = end;
|
|
111
|
+
}
|
|
112
|
+
return calls;
|
|
113
|
+
}
|
|
114
|
+
function mentionsIdentifier(text, identifier) {
|
|
115
|
+
return new RegExp(`\\b${escapeRegExp(identifier)}\\b`, "u").test(text);
|
|
116
|
+
}
|
|
117
|
+
function withHint(message, hint) {
|
|
118
|
+
return hint === void 0 || hint === "" ? message : `${message} ${hint}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/session/session-epoch-captured.ts
|
|
122
|
+
var DEFAULT_ID = "session-epoch-captured";
|
|
123
|
+
var DEFAULT_FIELD = "epoch";
|
|
124
|
+
function createSessionEpochCapturedRule(options = {}) {
|
|
125
|
+
const id = options.id ?? DEFAULT_ID;
|
|
126
|
+
const call = options.call ?? "";
|
|
127
|
+
const field = options.field ?? DEFAULT_FIELD;
|
|
128
|
+
const exempt = pathSet(options.exempt);
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
category: "source-text",
|
|
132
|
+
ciCritical: options.ciCritical ?? true,
|
|
133
|
+
description: "Every call into the sign-in seam must pass the session epoch captured before the credential was read, or a revocation landing during the credential check loses the race.",
|
|
134
|
+
run(ctx) {
|
|
135
|
+
if (call === "") return [];
|
|
136
|
+
const violations = [];
|
|
137
|
+
for (const { file, text } of scopedSources(ctx, options).sources) {
|
|
138
|
+
if (exempt.has(file)) continue;
|
|
139
|
+
for (const args of callArguments(text, call)) {
|
|
140
|
+
if (mentionsIdentifier(args, field)) continue;
|
|
141
|
+
violations.push({
|
|
142
|
+
file,
|
|
143
|
+
rule: id,
|
|
144
|
+
message: withHint(
|
|
145
|
+
`${call}() must be passed \`${field}\`, captured${options.captureCall === void 0 ? "" : ` with ${options.captureCall}`} BEFORE this flow reads the credential it authenticates on. Without it the epoch fence starts at the session write, so a revocation landing during the credential check loses the race.`,
|
|
146
|
+
options.hint
|
|
147
|
+
)
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return violations;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/session/session-kind-stamped.ts
|
|
157
|
+
var DEFAULT_ID2 = "session-kind-stamped";
|
|
158
|
+
var DEFAULT_FIELD2 = "kind";
|
|
159
|
+
function createSessionKindStampedRule(options = {}) {
|
|
160
|
+
const id = options.id ?? DEFAULT_ID2;
|
|
161
|
+
const mintCall = options.mintCall ?? "";
|
|
162
|
+
const field = options.field ?? DEFAULT_FIELD2;
|
|
163
|
+
const allowUnstamped = pathSet(options.allowUnstamped);
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
category: "source-text",
|
|
167
|
+
ciCritical: options.ciCritical ?? true,
|
|
168
|
+
description: "Every call that mints a session must stamp the principal kind onto it, or sit in an allowlisted, provably single-kind flow; a session read without the kind falls back to a default and can silently promote one kind of account into another.",
|
|
169
|
+
run(ctx) {
|
|
170
|
+
if (mintCall === "") return [];
|
|
171
|
+
const violations = [];
|
|
172
|
+
for (const { file, text } of scopedSources(ctx, options).sources) {
|
|
173
|
+
if (allowUnstamped.has(file)) continue;
|
|
174
|
+
const calls = callArguments(text, mintCall);
|
|
175
|
+
if (calls.length === 0) continue;
|
|
176
|
+
const fileStamps = mentionsIdentifier(text, field);
|
|
177
|
+
for (const args of calls) {
|
|
178
|
+
const stamped = args.includes("{") ? mentionsIdentifier(args, field) : fileStamps;
|
|
179
|
+
if (stamped) continue;
|
|
180
|
+
violations.push({
|
|
181
|
+
file,
|
|
182
|
+
rule: id,
|
|
183
|
+
message: withHint(
|
|
184
|
+
`${mintCall}() must stamp \`${field}\` onto the session it mints${options.stampExample === void 0 ? "" : `: add \`${options.stampExample}\` to the options`}. A session read without \`${field}\` falls back to the reader's default, so an unstamped mint can silently promote one kind of account into another. If this flow provably can never mint for an account that needs the field, add the file to \`allowUnstamped\` with the proof.`,
|
|
185
|
+
options.hint
|
|
186
|
+
)
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return violations;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/session/session-landing-declared.ts
|
|
196
|
+
var DEFAULT_ID3 = "session-landing-declared";
|
|
197
|
+
function createSessionLandingDeclaredRule(options = {}) {
|
|
198
|
+
const id = options.id ?? DEFAULT_ID3;
|
|
199
|
+
const doorCalls = (options.doorCalls ?? []).filter((name) => name !== "");
|
|
200
|
+
const doors = (options.doors ?? []).map((door) => ({ ...door, file: toPosix(door.file) }));
|
|
201
|
+
const landings = options.landings ?? {};
|
|
202
|
+
const landingNames = Object.keys(landings);
|
|
203
|
+
const callList = doorCalls.join(" or ");
|
|
204
|
+
return {
|
|
205
|
+
id,
|
|
206
|
+
category: "source-text",
|
|
207
|
+
ciCritical: options.ciCritical ?? true,
|
|
208
|
+
description: "Every file that opens a door into a session must declare where it leaves the caller, and a door whose landing demands a return shape (the one that carries the principal kind to the client) must have it.",
|
|
209
|
+
run(ctx) {
|
|
210
|
+
if (doorCalls.length === 0) return [];
|
|
211
|
+
const violations = [];
|
|
212
|
+
const report = (file, message) => {
|
|
213
|
+
violations.push({ file, rule: id, message: withHint(message, options.hint) });
|
|
214
|
+
};
|
|
215
|
+
for (const door of doors) {
|
|
216
|
+
if (!Object.hasOwn(landings, door.landing)) {
|
|
217
|
+
report(
|
|
218
|
+
door.file,
|
|
219
|
+
`\`doors\` declares this file with landing \`${door.landing}\`, which is not one of the configured landings (${landingNames.join(", ") || "none"}).`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (door.because.trim() === "") {
|
|
223
|
+
report(door.file, "`doors` declares this file with an empty `because`. Write down why its landing is the right one.");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const { matched, sources } = scopedSources(ctx, options);
|
|
227
|
+
for (const { file, text } of sources) {
|
|
228
|
+
if (!doorCalls.some((name) => callsMethod(text, name))) continue;
|
|
229
|
+
const declared = doors.find((door) => door.file === file);
|
|
230
|
+
if (declared === void 0) {
|
|
231
|
+
report(
|
|
232
|
+
file,
|
|
233
|
+
`This file opens a door into a session (${callList}) and does not declare where it leaves the caller. Add it to \`doors\` with one of the landings (${landingNames.join(", ") || "none"}) and a \`because\`: can this door end a sign-in for an account the client must route by kind? Then declare the landing that returns the kind. Does the caller already hold a session? Or can such an account provably never reach it? Say so in \`because\`.`
|
|
234
|
+
);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const required = landings[declared.landing];
|
|
238
|
+
if (typeof required === "string" && required !== "" && !text.includes(required)) {
|
|
239
|
+
report(
|
|
240
|
+
file,
|
|
241
|
+
`This door is declared \`${declared.landing}\` but its source never contains \`${required}\`, so the sign-in it completes hands the client nothing to route the account by. Return it, or reclassify the door in \`doors\` with the proof that it cannot end such a sign-in.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const door of doors) {
|
|
246
|
+
if (!matched.has(door.file)) {
|
|
247
|
+
report(
|
|
248
|
+
door.file,
|
|
249
|
+
"`doors` names a file that no longer exists, or that `sourceGlobs` do not reach. Remove the entry, or point it at the file the door moved to."
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return violations;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/session/session-mint-callers.ts
|
|
259
|
+
var DEFAULT_ID4 = "session-mint-callers";
|
|
260
|
+
function createSessionMintCallersRule(options = {}) {
|
|
261
|
+
const id = options.id ?? DEFAULT_ID4;
|
|
262
|
+
const mintCall = options.mintCall ?? "";
|
|
263
|
+
const allowed = pathSet(options.allowedCallers);
|
|
264
|
+
const gate = options.gateCall;
|
|
265
|
+
return {
|
|
266
|
+
id,
|
|
267
|
+
category: "source-text",
|
|
268
|
+
ciCritical: options.ciCritical ?? true,
|
|
269
|
+
description: "The method that mints a session may only be called from allowlisted files; a new sign-in entry point must route through the gate in front of it so the gate cannot be bypassed.",
|
|
270
|
+
run(ctx) {
|
|
271
|
+
if (mintCall === "") return [];
|
|
272
|
+
const violations = [];
|
|
273
|
+
const allowedList = [...allowed].join(", ") || "none";
|
|
274
|
+
for (const { file, text } of scopedSources(ctx, options).sources) {
|
|
275
|
+
if (allowed.has(file)) continue;
|
|
276
|
+
if (!callsMethod(text, mintCall)) continue;
|
|
277
|
+
violations.push({
|
|
278
|
+
file,
|
|
279
|
+
rule: id,
|
|
280
|
+
message: withHint(
|
|
281
|
+
`${mintCall}() may only be called from the allowlisted files (${allowedList}).${gate === void 0 ? "" : ` A sign-in or OAuth entry point must call ${gate}() instead, so the checks it runs before minting are not bypassed.`} If this is a genuine mint that passes no gate (a signup, or a re-issue to a caller who already holds a session), add the file to \`allowedCallers\` with a justification.`,
|
|
282
|
+
options.hint
|
|
283
|
+
)
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return violations;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
291
|
+
0 && (module.exports = {
|
|
292
|
+
callArguments,
|
|
293
|
+
createSessionEpochCapturedRule,
|
|
294
|
+
createSessionKindStampedRule,
|
|
295
|
+
createSessionLandingDeclaredRule,
|
|
296
|
+
createSessionMintCallersRule
|
|
297
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { IMetaRule } from '@noctcore/harness';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The source scope every session rule shares: which files are read, and which
|
|
5
|
+
* of them are left alone because they drive the seam directly (tests).
|
|
6
|
+
*/
|
|
7
|
+
interface SessionSourceScopeOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Globs of the application source that can open a session, relative to the
|
|
10
|
+
* repo root. Empty (the default) makes the rule inert: it reads nothing and
|
|
11
|
+
* reports nothing. Point it at shipped code only; a lint-meta rule module that
|
|
12
|
+
* quotes the call in its own pattern is not a call site.
|
|
13
|
+
*/
|
|
14
|
+
readonly sourceGlobs?: readonly string[];
|
|
15
|
+
/** Paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
16
|
+
readonly skipDirs?: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Files ending in any of these are skipped: tests drive the seam directly,
|
|
19
|
+
* including shapes production never produces. Default
|
|
20
|
+
* `['.spec.ts', '.spec.tsx', '.test.ts', '.test.tsx']`.
|
|
21
|
+
*/
|
|
22
|
+
readonly excludeSuffixes?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The argument text of each `.<method>(...)` call in `source`.
|
|
26
|
+
*
|
|
27
|
+
* Scanned by balancing parentheses rather than matched with a regex: an options
|
|
28
|
+
* object spans lines and holds its own braces and parens, and a non-greedy
|
|
29
|
+
* regex either stops early or runs on into the next call. A scanner that never
|
|
30
|
+
* finds the closing paren returns what it has, which is the fail-closed
|
|
31
|
+
* direction (more text to search, never less). `.<method>Something(` is not a
|
|
32
|
+
* call of `method`: only whitespace may sit between the name and the paren.
|
|
33
|
+
*/
|
|
34
|
+
declare function callArguments(source: string, method: string): string[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Options for {@link createSessionEpochCapturedRule}.
|
|
38
|
+
*
|
|
39
|
+
* Ported from Settly, which hardcoded the seam (`beginOrEstablish`), the field
|
|
40
|
+
* (`epoch`) and the seam's own file. With no `call` the rule is inert.
|
|
41
|
+
*/
|
|
42
|
+
interface SessionEpochCapturedOptions extends SessionSourceScopeOptions {
|
|
43
|
+
/** Rule id, for running more than one instance. Default `session-epoch-captured`. */
|
|
44
|
+
readonly id?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The post-credential seam every sign-in passes through on its way to a
|
|
47
|
+
* session, matched as `.<call>(`. Unset or empty = inert.
|
|
48
|
+
*/
|
|
49
|
+
readonly call?: string;
|
|
50
|
+
/** The argument every call must mention: the epoch captured before the credential was read. Default `epoch`. */
|
|
51
|
+
readonly field?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Repo-relative files skipped outright: the seam's own home, which defines
|
|
54
|
+
* the method and may call itself without a captured value. Default: none.
|
|
55
|
+
*/
|
|
56
|
+
readonly exempt?: readonly string[];
|
|
57
|
+
/** How the epoch is captured (e.g. `sessions.readEpoch(userId)`), quoted in the message. Optional. */
|
|
58
|
+
readonly captureCall?: string;
|
|
59
|
+
/** Appended to every message: a pointer to the project's own docs. */
|
|
60
|
+
readonly hint?: string;
|
|
61
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
62
|
+
readonly ciCritical?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Every call into the sign-in seam passes the session epoch it captured before
|
|
66
|
+
* reading the credential.
|
|
67
|
+
*
|
|
68
|
+
* A revoke-all bumps a per-user epoch and a mint refuses to write a session
|
|
69
|
+
* under a stale one. Captured inside the mint, the fence covers the store write
|
|
70
|
+
* and nothing else: the whole credential check (a password hash verify, an
|
|
71
|
+
* OAuth token exchange) is a window in which a sign-in that already read the
|
|
72
|
+
* revoked state still mints a surviving session. So each entry point captures
|
|
73
|
+
* the epoch first and hands it to the seam, and this rule fails any call whose
|
|
74
|
+
* arguments never mention it.
|
|
75
|
+
*/
|
|
76
|
+
declare function createSessionEpochCapturedRule(options?: SessionEpochCapturedOptions): IMetaRule;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Options for {@link createSessionKindStampedRule}.
|
|
80
|
+
*
|
|
81
|
+
* Ported from Settly, which hardcoded the mint (`establishSession`), the field
|
|
82
|
+
* (`kind`) and the one staff-only exemption. With no `mintCall` the rule is inert.
|
|
83
|
+
*/
|
|
84
|
+
interface SessionKindStampedOptions extends SessionSourceScopeOptions {
|
|
85
|
+
/** Rule id, for running more than one instance. Default `session-kind-stamped`. */
|
|
86
|
+
readonly id?: string;
|
|
87
|
+
/** The method whose call mints a session, matched as `.<mintCall>(`. Unset or empty = inert. */
|
|
88
|
+
readonly mintCall?: string;
|
|
89
|
+
/** The session field every mint must stamp. Default `kind`. */
|
|
90
|
+
readonly field?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Repo-relative files whose mints may stamp nothing because they provably can
|
|
93
|
+
* never mint for a principal that needs the field (a self-signup that only
|
|
94
|
+
* ever creates the default kind). Keep it short and write the proof next to
|
|
95
|
+
* each entry. Default: none.
|
|
96
|
+
*/
|
|
97
|
+
readonly allowUnstamped?: readonly string[];
|
|
98
|
+
/** An example of the stamp, quoted in the message (e.g. a conditional spread). Optional. */
|
|
99
|
+
readonly stampExample?: string;
|
|
100
|
+
/** Appended to every message: a pointer to the project's own docs. */
|
|
101
|
+
readonly hint?: string;
|
|
102
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
103
|
+
readonly ciCritical?: boolean;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every session mint stamps the principal's kind onto the session it writes.
|
|
107
|
+
*
|
|
108
|
+
* When the request pipeline reads the kind from the SESSION rather than the
|
|
109
|
+
* database, a session minted without it reads as whatever the reader defaults
|
|
110
|
+
* to. For a fence between two kinds of account that is a silent promotion of
|
|
111
|
+
* one into the other, and every gate stays green because nothing looks.
|
|
112
|
+
*
|
|
113
|
+
* The check, in order of precision:
|
|
114
|
+
*
|
|
115
|
+
* 1. A call whose arguments contain an object LITERAL must mention the field in
|
|
116
|
+
* that literal. Each literal is checked on its own, so one stamped mint does
|
|
117
|
+
* not excuse an unstamped one in the same file.
|
|
118
|
+
* 2. A call with no literal (options built elsewhere) falls back to the file:
|
|
119
|
+
* the file must mention the field somewhere. Coarser, but no silent hole.
|
|
120
|
+
* 3. Otherwise the file must be on `allowUnstamped`.
|
|
121
|
+
*
|
|
122
|
+
* Residual gap, stated rather than hidden: a file that stamps the field on one
|
|
123
|
+
* delegated mint and forgets it on a second delegated mint passes clause 2.
|
|
124
|
+
*/
|
|
125
|
+
declare function createSessionKindStampedRule(options?: SessionKindStampedOptions): IMetaRule;
|
|
126
|
+
|
|
127
|
+
/** One file that opens a door into a session, and where it leaves the caller. */
|
|
128
|
+
interface SessionDoor {
|
|
129
|
+
/** Repo-relative path of the file. */
|
|
130
|
+
readonly file: string;
|
|
131
|
+
/** One of the keys of `landings`. */
|
|
132
|
+
readonly landing: string;
|
|
133
|
+
/** Why this landing is right for this door. Prose, read by whoever adds the next door. Must not be empty. */
|
|
134
|
+
readonly because: string;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Options for {@link createSessionLandingDeclaredRule}.
|
|
138
|
+
*
|
|
139
|
+
* Ported from Settly, which hardcoded the two door calls, its six doors and the
|
|
140
|
+
* three landings (`login-result`, which must return `Promise<ILoginResult>`,
|
|
141
|
+
* `reissue` and `staff-only`). All of them are options; with no `doorCalls` the
|
|
142
|
+
* rule is inert.
|
|
143
|
+
*/
|
|
144
|
+
interface SessionLandingDeclaredOptions extends SessionSourceScopeOptions {
|
|
145
|
+
/** Rule id, for running more than one instance. Default `session-landing-declared`. */
|
|
146
|
+
readonly id?: string;
|
|
147
|
+
/**
|
|
148
|
+
* The methods whose call opens a door into a session (the mint, and the gate
|
|
149
|
+
* in front of it), each matched as `.<name>(`. Empty (the default) = inert.
|
|
150
|
+
*/
|
|
151
|
+
readonly doorCalls?: readonly string[];
|
|
152
|
+
/** Every door, declared. A file that calls a door method and is not here is reported. */
|
|
153
|
+
readonly doors?: readonly SessionDoor[];
|
|
154
|
+
/**
|
|
155
|
+
* The landings a door may declare, each mapped to the text a door with that
|
|
156
|
+
* landing must contain (the return type that carries the principal kind to the
|
|
157
|
+
* client, say), or `null` when the landing demands nothing of the source. A
|
|
158
|
+
* door whose landing is not a key here is reported. Default: none.
|
|
159
|
+
*/
|
|
160
|
+
readonly landings?: Readonly<Record<string, string | null>>;
|
|
161
|
+
/** Appended to every message: a pointer to the project's own docs. */
|
|
162
|
+
readonly hint?: string;
|
|
163
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
164
|
+
readonly ciCritical?: boolean;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Every file that opens a door into a session declares where it leaves the
|
|
168
|
+
* caller, and a door that must hand the client the principal kind does.
|
|
169
|
+
*
|
|
170
|
+
* With two shells behind one sign-in, the client can only send an account to
|
|
171
|
+
* the right one if the response that ends the sign-in says which kind it is. A
|
|
172
|
+
* second door that finishes a sign-in from a different service can forget to,
|
|
173
|
+
* and nothing fails: the account signs in and lands in the wrong shell.
|
|
174
|
+
*
|
|
175
|
+
* The check is completeness first: a file that calls a door method and is not
|
|
176
|
+
* in `doors` fails the build until someone classifies it, which is what keeps
|
|
177
|
+
* the list an enumeration rather than a docblock nobody updates. Then each
|
|
178
|
+
* door's landing is checked against `landings`, and a declared door that no
|
|
179
|
+
* longer exists (or that `sourceGlobs` do not reach) is reported so the list
|
|
180
|
+
* cannot go stale.
|
|
181
|
+
*/
|
|
182
|
+
declare function createSessionLandingDeclaredRule(options?: SessionLandingDeclaredOptions): IMetaRule;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Options for {@link createSessionMintCallersRule}.
|
|
186
|
+
*
|
|
187
|
+
* Ported from Settly's `establish-session-callers`, which hardcoded the method
|
|
188
|
+
* (`establishSession`), the gate (`beginOrEstablish`) and the five allowlisted
|
|
189
|
+
* files. All three are options here; with no `mintCall` the rule is inert.
|
|
190
|
+
*/
|
|
191
|
+
interface SessionMintCallersOptions extends SessionSourceScopeOptions {
|
|
192
|
+
/** Rule id, for running more than one instance. Default `session-mint-callers`. */
|
|
193
|
+
readonly id?: string;
|
|
194
|
+
/**
|
|
195
|
+
* The method whose call mints a session (sets the cookie, writes the store),
|
|
196
|
+
* matched as `.<mintCall>(`. Unset or empty = inert.
|
|
197
|
+
*/
|
|
198
|
+
readonly mintCall?: string;
|
|
199
|
+
/**
|
|
200
|
+
* Repo-relative files that may call it: the method's own home, the gate in
|
|
201
|
+
* front of it, and flows with no gate to pass (signup, a re-issue to a caller
|
|
202
|
+
* who already holds a session). Matched exactly, so a same-named file in
|
|
203
|
+
* another directory is not allowed. Default: none.
|
|
204
|
+
*/
|
|
205
|
+
readonly allowedCallers?: readonly string[];
|
|
206
|
+
/**
|
|
207
|
+
* The method a new sign-in entry point must route through instead (a second
|
|
208
|
+
* factor challenge, say), named in the message. Optional.
|
|
209
|
+
*/
|
|
210
|
+
readonly gateCall?: string;
|
|
211
|
+
/** Appended to every message: a pointer to the project's own docs. */
|
|
212
|
+
readonly hint?: string;
|
|
213
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
214
|
+
readonly ciCritical?: boolean;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The method that mints a session is callable only from an allowlist of files.
|
|
218
|
+
*
|
|
219
|
+
* A session minted straight from a sign-in entry point skips whatever the gate
|
|
220
|
+
* in front of the mint enforces (a second factor, an epoch capture), and nothing
|
|
221
|
+
* else fails: the new flow signs the user in and every test passes. The mint is
|
|
222
|
+
* the one door into a session, so the rule fences the door by caller.
|
|
223
|
+
*/
|
|
224
|
+
declare function createSessionMintCallersRule(options?: SessionMintCallersOptions): IMetaRule;
|
|
225
|
+
|
|
226
|
+
export { type SessionDoor, type SessionEpochCapturedOptions, type SessionKindStampedOptions, type SessionLandingDeclaredOptions, type SessionMintCallersOptions, type SessionSourceScopeOptions, callArguments, createSessionEpochCapturedRule, createSessionKindStampedRule, createSessionLandingDeclaredRule, createSessionMintCallersRule };
|