@starci/eslint-canon-fe 9.4.7 → 9.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/fe-stack.d.ts +3 -3
- package/dist/fe-stack.js +3 -9
- package/dist/index.js +0 -2
- package/dist/lib/scope.d.ts +8 -0
- package/dist/lib/scope.js +10 -0
- package/dist/lib/why.js +2 -2
- package/dist/shape-slot.d.ts +2 -2
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +29 -7
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/slots.yaml +185 -174
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +4 -4
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/dist/callback-shape.d.ts +0 -7
- package/dist/callback-shape.js +0 -396
- package/dist/lib/pure-owner-helper.d.ts +0 -10
- package/dist/lib/pure-owner-helper.js +0 -156
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
package/dist/callback-shape.js
DELETED
|
@@ -1,396 +0,0 @@
|
|
|
1
|
-
import { stringOf, walk } from "./lib/ast.js";
|
|
2
|
-
import { patternPath } from "./lib/bindings.js";
|
|
3
|
-
import { normalizePath } from "./lib/path.js";
|
|
4
|
-
import { ownerExportKind } from "./lib/pure-owner-helper.js";
|
|
5
|
-
import { defineRule } from "./lib/rule.js";
|
|
6
|
-
import { classifyImport, inSlot, resolveImport } from "./lib/scope.js";
|
|
7
|
-
/** A value a well-formed tree always has; its absence stops the run as reading it always has. */
|
|
8
|
-
const must = (value, what) => {
|
|
9
|
-
if (value === null || value === undefined)
|
|
10
|
-
throw new TypeError(`missing ${what}`);
|
|
11
|
-
return value;
|
|
12
|
-
};
|
|
13
|
-
/** The node a runtime wrapper wraps, or undefined when the node wraps nothing. */
|
|
14
|
-
const wrapped = (node) => {
|
|
15
|
-
if (node.type === "AwaitExpression")
|
|
16
|
-
return node.argument;
|
|
17
|
-
if (node.type === "ChainExpression" || node.type === "TSAsExpression" || node.type === "TSTypeAssertion" || node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression")
|
|
18
|
-
return node.expression;
|
|
19
|
-
return undefined;
|
|
20
|
-
};
|
|
21
|
-
const WRAPPERS = new Set(["AwaitExpression", "ChainExpression", "TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSInstantiationExpression"]);
|
|
22
|
-
/** Runtime expression wrappers that preserve the value's source. */
|
|
23
|
-
const unwrap = (input) => {
|
|
24
|
-
let node = input;
|
|
25
|
-
while (node && WRAPPERS.has(node.type))
|
|
26
|
-
node = wrapped(node);
|
|
27
|
-
return node;
|
|
28
|
-
};
|
|
29
|
-
/** A member's static key, including a bracketed string spelling. */
|
|
30
|
-
const memberName = (node) => node.computed ? stringOf(node.property) : node.property.name;
|
|
31
|
-
/** The name an identifier-like node spells, or undefined. */
|
|
32
|
-
const nameOf = (node) => ("name" in node && typeof node.name === "string" ? node.name : undefined);
|
|
33
|
-
/** The lexical binding of an identifier, including shadowing inside the handler. */
|
|
34
|
-
const bindingOf = (sourceCode, node) => {
|
|
35
|
-
const name = nameOf(node);
|
|
36
|
-
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
|
|
37
|
-
const variable = name === undefined ? undefined : scope.set.get(name);
|
|
38
|
-
if (variable)
|
|
39
|
-
return variable;
|
|
40
|
-
}
|
|
41
|
-
return null;
|
|
42
|
-
};
|
|
43
|
-
/** Whether an identifier refers to the actual unshadowed platform global. */
|
|
44
|
-
const isGlobal = (sourceCode, node, name) => node?.type === "Identifier" && node.name === name && !(bindingOf(sourceCode, node)?.defs.length);
|
|
45
|
-
/** Route configuration is inert; a second HTTP door or an exported helper is not. */
|
|
46
|
-
const CONFIG_EXPORTS = new Set(["runtime", "dynamic", "revalidate", "fetchCache", "preferredRegion", "maxDuration"]);
|
|
47
|
-
const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
48
|
-
const FORBIDDEN = new Set(["AssignmentExpression", "UpdateExpression", "ImportExpression", "TaggedTemplateExpression", "ClassDeclaration", "ClassExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "ThrowStatement", "YieldExpression", "SpreadElement", "ArrayPattern", "JSXElement", "JSXFragment"]);
|
|
49
|
-
const TEXT = new Set(["text", "code", "otp-type"]);
|
|
50
|
-
const CALLS = new Set(["factory", "exchange", "verify-otp", "pure", "redirect", "params-get", "params-has", "text-guard"]);
|
|
51
|
-
/** What a pure owner helper may be given: query text, the page URL or request, and plain values; never the credential itself. */
|
|
52
|
-
const PURE_ARGUMENTS = new Set(["text", "otp-type", "scalar", "url", "request"]);
|
|
53
|
-
const URL_TEXT = new Set(["origin", "href", "pathname", "search", "hash", "host", "hostname", "protocol"]);
|
|
54
|
-
const CALL_RESULTS = new Map([["factory", "client"], ["exchange", "exchange-result"], ["verify-otp", "exchange-result"], ["pure", "text"], ["redirect", "redirect-result"], ["params-has", "scalar"], ["text-guard", "scalar"]]);
|
|
55
|
-
const MEMBERS = new Map([
|
|
56
|
-
["request", new Map([["nextUrl", "url"], ["url", "text"]])],
|
|
57
|
-
["url", new Map([["searchParams", "params"], ...[...URL_TEXT].map((key) => [key, "text"])])],
|
|
58
|
-
["params", new Map([["get", "params-get"], ["has", "params-has"]])],
|
|
59
|
-
["client", new Map([["auth", "auth"]])],
|
|
60
|
-
["auth", new Map([["exchangeCodeForSession", "exchange"], ["verifyOtp", "verify-otp"]])],
|
|
61
|
-
["next-response", new Map([["redirect", "redirect"]])],
|
|
62
|
-
["exchange-result", new Map([["error", "exchange-error"], ["success", "scalar"], ["ok", "scalar"]])],
|
|
63
|
-
]);
|
|
64
|
-
const TEXT_GUARDS = ["startsWith", "endsWith", "includes"];
|
|
65
|
-
const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
|
|
66
|
-
const REDIRECT_TARGETS = ["text", "url"];
|
|
67
|
-
const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
|
|
68
|
-
/** The query parameters whose provenance an exchange requires: the PKCE code, and the email link's token hash and type. */
|
|
69
|
-
const PARAM_ORIGINS = new Map([["code", "code"], ["token_hash", "token-hash"], ["type", "otp-type"]]);
|
|
70
|
-
/** True for a function node. */
|
|
71
|
-
const isFunctionNode = (node) => FUNCTIONS.has(node.type);
|
|
72
|
-
/** A locally exported binding's implementation. */
|
|
73
|
-
const exportValue = (sourceCode, entry) => {
|
|
74
|
-
const definition = bindingOf(sourceCode, entry.local)?.defs[0]?.node;
|
|
75
|
-
return unwrap(definition?.type === "VariableDeclarator" ? definition.init : definition);
|
|
76
|
-
};
|
|
77
|
-
/** The spelling of an export or import name: an identifier's name or a string literal's value. */
|
|
78
|
-
const moduleExportName = (node) => node.type === "Identifier" ? node.name : node.value;
|
|
79
|
-
/** The runtime names introduced by one local named export. */
|
|
80
|
-
const namedExports = (statement, sourceCode) => {
|
|
81
|
-
const declaration = statement.declaration;
|
|
82
|
-
if (declaration?.type === "FunctionDeclaration")
|
|
83
|
-
return [{ name: must(declaration.id, "function name").name, value: declaration }];
|
|
84
|
-
if (declaration?.type === "VariableDeclaration") {
|
|
85
|
-
if (declaration.declarations.some((entry) => entry.id.type !== "Identifier"))
|
|
86
|
-
return null;
|
|
87
|
-
return declaration.declarations.map((entry) => ({ name: must(nameOf(entry.id), "variable name"), value: unwrap(entry.init) }));
|
|
88
|
-
}
|
|
89
|
-
if (declaration)
|
|
90
|
-
return [];
|
|
91
|
-
return statement.specifiers.filter((entry) => entry.exportKind !== "type")
|
|
92
|
-
.map((entry) => ({ name: moduleExportName(entry.exported), value: exportValue(sourceCode, entry) }));
|
|
93
|
-
};
|
|
94
|
-
/** Collect runtime exports, refusing reexports and default exports. */
|
|
95
|
-
const exportsOf = (program, sourceCode) => {
|
|
96
|
-
const exported = [];
|
|
97
|
-
for (const statement of program.body) {
|
|
98
|
-
if (statement.type === "ExportDefaultDeclaration" || statement.type === "ExportAllDeclaration")
|
|
99
|
-
return null;
|
|
100
|
-
if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type")
|
|
101
|
-
continue;
|
|
102
|
-
if (statement.source)
|
|
103
|
-
return null;
|
|
104
|
-
const entries = namedExports(statement, sourceCode);
|
|
105
|
-
if (!entries)
|
|
106
|
-
return null;
|
|
107
|
-
exported.push(...entries);
|
|
108
|
-
}
|
|
109
|
-
return exported;
|
|
110
|
-
};
|
|
111
|
-
/** Find the one locally implemented GET export and its single request parameter. */
|
|
112
|
-
const handlerOf = (program, sourceCode) => {
|
|
113
|
-
const exported = exportsOf(program, sourceCode);
|
|
114
|
-
if (!exported || exported.some((entry) => entry.name !== "GET" && !CONFIG_EXPORTS.has(entry.name)))
|
|
115
|
-
return null;
|
|
116
|
-
const handlers = exported.filter((entry) => entry.name === "GET");
|
|
117
|
-
if (handlers.length !== 1)
|
|
118
|
-
return null;
|
|
119
|
-
const handler = must(handlers[0], "GET export").value;
|
|
120
|
-
if (!handler || !isFunctionNode(handler) || !handler.async || handler.generator)
|
|
121
|
-
return null;
|
|
122
|
-
return handler.params.length === 1 && handler.params[0]?.type === "Identifier" ? handler : null;
|
|
123
|
-
};
|
|
124
|
-
/** Framework imports retain their actual owner even under a local alias. */
|
|
125
|
-
const frameworkImportKind = (source, name) => {
|
|
126
|
-
if (source === "next/server" && name === "NextResponse")
|
|
127
|
-
return "next-response";
|
|
128
|
-
if (source === "next/server" && name === "NextRequest")
|
|
129
|
-
return "request-type";
|
|
130
|
-
if (source === "next/navigation" && name === "redirect")
|
|
131
|
-
return "redirect";
|
|
132
|
-
return null;
|
|
133
|
-
};
|
|
134
|
-
/** Only the server auth entry exposes the cookie-owning exchangeAuthCode helper. */
|
|
135
|
-
const isServerAuthImport = (context, source) => {
|
|
136
|
-
const target = normalizePath(resolveImport(context, source));
|
|
137
|
-
return target.endsWith("/modules/auth/server") || target.endsWith("/modules/auth/server.ts");
|
|
138
|
-
};
|
|
139
|
-
/** A directory import resolves to its index, before the generic modules slot's hypothetical routes.ts. */
|
|
140
|
-
const isRoutesImport = (context, source, slot) => slot === "fe.modules.routes" || classifyImport(context, `${source}/index`)?.slot === "fe.modules.routes";
|
|
141
|
-
const CLIENT_NAMES = ["supabase", "supabaseClient", "authClient"];
|
|
142
|
-
/** The exchange, client and factory names of the auth owner. */
|
|
143
|
-
const authImportKind = (context, source, name) => {
|
|
144
|
-
if (name === "exchangeAuthCode" && isServerAuthImport(context, source))
|
|
145
|
-
return "exchange";
|
|
146
|
-
if (name === "verifyOtp" && isServerAuthImport(context, source))
|
|
147
|
-
return "verify-otp";
|
|
148
|
-
if (name === "exchangeCodeForSession")
|
|
149
|
-
return "exchange";
|
|
150
|
-
if (/^(?:create|get)\w*Client$/.test(name ?? ""))
|
|
151
|
-
return "factory";
|
|
152
|
-
if (CLIENT_NAMES.includes(name))
|
|
153
|
-
return "client";
|
|
154
|
-
return null;
|
|
155
|
-
};
|
|
156
|
-
/** Auth helpers and static route values must come from their declared HFS owners; any other export of `modules/routes` or `modules/auth` counts only as a pure helper or a static table. */
|
|
157
|
-
const ownerImportKind = (context, source, name) => {
|
|
158
|
-
const slot = classifyImport(context, source)?.slot;
|
|
159
|
-
if (name === "APP_ROUTES" && isRoutesImport(context, source, slot))
|
|
160
|
-
return "routes";
|
|
161
|
-
const authKind = slot === "fe.modules.auth" ? authImportKind(context, source, name) : null;
|
|
162
|
-
if (authKind || !(slot === "fe.modules.auth" || isRoutesImport(context, source, slot)))
|
|
163
|
-
return authKind;
|
|
164
|
-
// A default or namespace import names no export, so no export of the owner can match it.
|
|
165
|
-
return name === undefined ? null : ownerExportKind(context, source, name);
|
|
166
|
-
};
|
|
167
|
-
/** The imported name of a specifier; undefined for a default or namespace import. */
|
|
168
|
-
const importedName = (specifier) => specifier.type === "ImportSpecifier" ? moduleExportName(specifier.imported) : undefined;
|
|
169
|
-
/** Register the allowed bindings in a single import statement. */
|
|
170
|
-
const addImportBindings = (context, sourceCode, statement, imports) => {
|
|
171
|
-
const source = String(statement.source.value);
|
|
172
|
-
if (source === "server-only" && statement.specifiers.length === 0)
|
|
173
|
-
return true;
|
|
174
|
-
if (statement.specifiers.length === 0)
|
|
175
|
-
return false;
|
|
176
|
-
const variables = sourceCode.getDeclaredVariables(statement);
|
|
177
|
-
for (const specifier of statement.specifiers) {
|
|
178
|
-
if (specifier.type === "ImportSpecifier" && specifier.importKind === "type")
|
|
179
|
-
continue;
|
|
180
|
-
const name = importedName(specifier);
|
|
181
|
-
const kind = frameworkImportKind(source, name) ?? ownerImportKind(context, source, name);
|
|
182
|
-
if (!kind)
|
|
183
|
-
return false;
|
|
184
|
-
imports.set(variables.find((entry) => entry.name === specifier.local.name), kind);
|
|
185
|
-
}
|
|
186
|
-
return true;
|
|
187
|
-
};
|
|
188
|
-
/** Every runtime import is restricted, even if it is unused in the handler. */
|
|
189
|
-
const importsOf = (context, program, sourceCode) => {
|
|
190
|
-
const imports = new Map();
|
|
191
|
-
for (const statement of program.body) {
|
|
192
|
-
if (statement.type !== "ImportDeclaration" || statement.importKind === "type")
|
|
193
|
-
continue;
|
|
194
|
-
if (!addImportBindings(context, sourceCode, statement, imports))
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
return imports;
|
|
198
|
-
};
|
|
199
|
-
/** Select a known property without granting arbitrary methods on the selected value. */
|
|
200
|
-
const memberKind = (kind, key) => {
|
|
201
|
-
if (kind === "routes" && typeof key === "string" && !UNSAFE_KEYS.includes(key))
|
|
202
|
-
return "text";
|
|
203
|
-
if (TEXT.has(kind) && TEXT_GUARDS.includes(key))
|
|
204
|
-
return "text-guard";
|
|
205
|
-
return MEMBERS.get(kind)?.get(key) ?? null;
|
|
206
|
-
};
|
|
207
|
-
/** An identifier keeps the provenance of its one initializer or the GET request parameter. */
|
|
208
|
-
const identifierOrigin = (node, seen, env, origin) => {
|
|
209
|
-
const variable = bindingOf(env.sourceCode, node);
|
|
210
|
-
if (env.imports.has(variable))
|
|
211
|
-
return env.imports.get(variable) ?? null;
|
|
212
|
-
if (!variable || seen.has(variable) || variable.defs.length !== 1)
|
|
213
|
-
return null;
|
|
214
|
-
const definition = must(variable.defs[0], "definition");
|
|
215
|
-
if (definition.type === "Parameter" && definition.node === env.handler)
|
|
216
|
-
return "request";
|
|
217
|
-
if (definition.node.type !== "VariableDeclarator")
|
|
218
|
-
return null;
|
|
219
|
-
const path = patternPath(definition.node.id, node.name);
|
|
220
|
-
const nextSeen = new Set([...seen, variable]);
|
|
221
|
-
return path?.reduce((kind, key) => memberKind(kind, key), origin(definition.node.init, nextSeen)) ?? null;
|
|
222
|
-
};
|
|
223
|
-
/** Combining known primitive values cannot introduce user-defined coercion. */
|
|
224
|
-
const combinedOrigin = (kinds, conditional = false) => {
|
|
225
|
-
if (conditional && kinds[0] === kinds[1])
|
|
226
|
-
return kinds[0];
|
|
227
|
-
if (kinds.every((kind) => TEXT.has(kind)))
|
|
228
|
-
return "text";
|
|
229
|
-
if (!conditional && kinds.every((kind) => TEXT.has(kind) || kind === "scalar"))
|
|
230
|
-
return "scalar";
|
|
231
|
-
return null;
|
|
232
|
-
};
|
|
233
|
-
/** The source of a call's return value, independent of argument validation. */
|
|
234
|
-
const callOrigin = (node, seen, origin) => {
|
|
235
|
-
const kind = origin(node.callee, seen);
|
|
236
|
-
if (kind === "params-get")
|
|
237
|
-
return PARAM_ORIGINS.get(stringOf(node.arguments[0])) ?? "text";
|
|
238
|
-
return CALL_RESULTS.get(kind) ?? null;
|
|
239
|
-
};
|
|
240
|
-
/** Composite expressions retain provenance only for supported primitive operations. */
|
|
241
|
-
const expressionOrigin = (node, seen, env, origin) => {
|
|
242
|
-
if (node.type === "TemplateLiteral")
|
|
243
|
-
return node.expressions.every((expression) => TEXT.has(origin(expression, seen)) || origin(expression, seen) === "scalar") ? "text" : null;
|
|
244
|
-
if (node.type === "MemberExpression")
|
|
245
|
-
return memberKind(origin(node.object, seen), memberName(node));
|
|
246
|
-
if (node.type === "NewExpression" && isGlobal(env.sourceCode, node.callee, "URL"))
|
|
247
|
-
return "url";
|
|
248
|
-
if (node.type === "LogicalExpression" || node.type === "BinaryExpression")
|
|
249
|
-
return combinedOrigin([origin(node.left, seen), origin(node.right, seen)]);
|
|
250
|
-
if (node.type === "ConditionalExpression")
|
|
251
|
-
return combinedOrigin([origin(node.consequent, seen), origin(node.alternate, seen)], true);
|
|
252
|
-
if (node.type === "CallExpression")
|
|
253
|
-
return callOrigin(node, seen, origin);
|
|
254
|
-
return null;
|
|
255
|
-
};
|
|
256
|
-
/** Follow lexical aliases rather than trusting the spelling of an identifier. */
|
|
257
|
-
const originOf = (env) => {
|
|
258
|
-
const origin = (input, seen = new Set()) => {
|
|
259
|
-
const node = unwrap(input);
|
|
260
|
-
if (!node)
|
|
261
|
-
return null;
|
|
262
|
-
if (node.type === "Identifier")
|
|
263
|
-
return identifierOrigin(node, seen, env, origin);
|
|
264
|
-
if (node.type === "Literal")
|
|
265
|
-
return typeof node.value === "string" || node.value === null ? "text" : "scalar";
|
|
266
|
-
return expressionOrigin(node, seen, env, origin);
|
|
267
|
-
};
|
|
268
|
-
return origin;
|
|
269
|
-
};
|
|
270
|
-
/** The closest enclosing function must be the GET handler. */
|
|
271
|
-
const insideHandler = (node, handler) => {
|
|
272
|
-
for (let parent = node.parent; parent; parent = parent.parent)
|
|
273
|
-
if (FUNCTIONS.has(parent.type))
|
|
274
|
-
return parent === handler;
|
|
275
|
-
return false;
|
|
276
|
-
};
|
|
277
|
-
/** Await must wrap the actual exchange call before its enclosing statement ends. */
|
|
278
|
-
const isAwaited = (node, handler) => {
|
|
279
|
-
for (let parent = node.parent; parent && parent !== handler; parent = parent.parent) {
|
|
280
|
-
if (parent.type === "AwaitExpression")
|
|
281
|
-
return true;
|
|
282
|
-
if (parent.type.endsWith("Statement"))
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
return false;
|
|
286
|
-
};
|
|
287
|
-
/** The static key of a plain `init` property, or null for a computed, method or accessor property and for a spread. */
|
|
288
|
-
const plainKey = (property) => {
|
|
289
|
-
if (property.type !== "Property" || property.computed || property.method || property.kind !== "init")
|
|
290
|
-
return null;
|
|
291
|
-
const key = property.key;
|
|
292
|
-
return key.type === "Identifier" ? key.name : key.value;
|
|
293
|
-
};
|
|
294
|
-
/** Only a literal redirect status is allowed in an options object. */
|
|
295
|
-
const isRedirectStatus = (property) => {
|
|
296
|
-
if (property.type !== "Property" || property.computed || property.method || property.kind !== "init")
|
|
297
|
-
return false;
|
|
298
|
-
return plainKey(property) === "status" && property.value.type === "Literal" && REDIRECT_STATUSES.includes(property.value.value);
|
|
299
|
-
};
|
|
300
|
-
/** Arbitrary objects can invoke getters or toString during coercion; only redirect options belong here. */
|
|
301
|
-
const isRedirectOptions = (node, origin) => node.parent.type === "CallExpression" && origin(node.parent.callee) === "redirect" && node.parent.arguments[1] === node && node.properties.every(isRedirectStatus);
|
|
302
|
-
/** The one object `verifyOtp` takes: exactly `token_hash` read from the query `token_hash` and `type` read from the query `type`. */
|
|
303
|
-
const isOtpArguments = (node, origin) => {
|
|
304
|
-
if (node.parent.type !== "CallExpression" || origin(node.parent.callee) !== "verify-otp" || node.parent.arguments[0] !== node)
|
|
305
|
-
return false;
|
|
306
|
-
const expected = new Map([["token_hash", "token-hash"], ["type", "otp-type"]]);
|
|
307
|
-
return node.properties.length === expected.size && node.properties.every((property) => property.type === "Property" && expected.get(plainKey(property)) === origin(property.value))
|
|
308
|
-
&& new Set(node.properties.map(plainKey)).size === expected.size;
|
|
309
|
-
};
|
|
310
|
-
/** Non-call expressions must preserve the callback's narrow set of values and effects. */
|
|
311
|
-
const validExpression = (node, env, origin) => {
|
|
312
|
-
if (node.type === "MemberExpression")
|
|
313
|
-
return Boolean(origin(node));
|
|
314
|
-
if (node.type === "ObjectExpression")
|
|
315
|
-
return isRedirectOptions(node, origin) || isOtpArguments(node, origin);
|
|
316
|
-
if (node.type === "VariableDeclarator" && node.id.type === "ObjectPattern")
|
|
317
|
-
return env.sourceCode.getDeclaredVariables(node).every((variable) => origin(variable.identifiers[0]));
|
|
318
|
-
if (node.type === "NewExpression")
|
|
319
|
-
return isGlobal(env.sourceCode, node.callee, "URL") && node.arguments.length >= 1 && node.arguments.length <= 2 && node.arguments.every((argument) => TEXT.has(origin(argument)));
|
|
320
|
-
if (node.type === "ReturnStatement")
|
|
321
|
-
return insideHandler(node, env.handler) && origin(node.argument) === "redirect-result";
|
|
322
|
-
if (node === env.handler && env.handler.body.type !== "BlockStatement")
|
|
323
|
-
return origin(env.handler.body) === "redirect-result";
|
|
324
|
-
return true;
|
|
325
|
-
};
|
|
326
|
-
/** Validate arguments to each permitted operation. */
|
|
327
|
-
const validCall = (node, kind, env, origin) => {
|
|
328
|
-
if (!insideHandler(node, env.handler) || !CALLS.has(kind) || node.arguments.some((argument) => argument.type === "SpreadElement"))
|
|
329
|
-
return false;
|
|
330
|
-
if (kind === "exchange")
|
|
331
|
-
return isAwaited(node, env.handler) && node.arguments.length === 1 && origin(node.arguments[0]) === "code";
|
|
332
|
-
if (kind === "verify-otp")
|
|
333
|
-
return isAwaited(node, env.handler) && node.arguments.length === 1 && node.arguments[0]?.type === "ObjectExpression";
|
|
334
|
-
if (kind === "pure")
|
|
335
|
-
return node.arguments.length <= 4 && node.arguments.every((argument) => PURE_ARGUMENTS.has(origin(argument)));
|
|
336
|
-
if (kind === "redirect")
|
|
337
|
-
return node.arguments.length >= 1 && node.arguments.length <= 2 && REDIRECT_TARGETS.includes(origin(node.arguments[0]));
|
|
338
|
-
if (kind === "factory")
|
|
339
|
-
return node.arguments.length <= 1 && node.arguments.every((argument) => origin(argument) === "request");
|
|
340
|
-
return node.arguments.length === 1 && TEXT.has(origin(node.arguments[0]));
|
|
341
|
-
};
|
|
342
|
-
/** Syntactic forms that add work beyond the one handler are refused outright. */
|
|
343
|
-
const forbiddenNode = (node, handler) => FORBIDDEN.has(node.type) || (FUNCTIONS.has(node.type) && node !== handler) || (node.type === "UnaryExpression" && node.operator === "delete");
|
|
344
|
-
/** Walk all runtime syntax, counting the single exchange and permitted redirects. */
|
|
345
|
-
const validOperations = (program, env) => {
|
|
346
|
-
const origin = originOf(env);
|
|
347
|
-
const counts = new Map();
|
|
348
|
-
let valid = true;
|
|
349
|
-
walk(env.sourceCode, program, (node) => {
|
|
350
|
-
if (!valid)
|
|
351
|
-
return false;
|
|
352
|
-
valid = !forbiddenNode(node, env.handler) && validExpression(node, env, origin);
|
|
353
|
-
if (!valid || node.type !== "CallExpression")
|
|
354
|
-
return undefined;
|
|
355
|
-
const kind = origin(node.callee);
|
|
356
|
-
valid = validCall(node, kind, env, origin);
|
|
357
|
-
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
358
|
-
return undefined;
|
|
359
|
-
});
|
|
360
|
-
const exchanges = (counts.get("exchange") ?? 0) + (counts.get("verify-otp") ?? 0);
|
|
361
|
-
return valid && exchanges >= 1 && (counts.get("exchange") ?? 0) <= 1 && (counts.get("verify-otp") ?? 0) <= 1 && (counts.get("factory") ?? 0) <= 1 && (counts.get("redirect") ?? 0) > 0;
|
|
362
|
-
};
|
|
363
|
-
/** The callback's request, imports and operations must all have known owners. */
|
|
364
|
-
const callbackVerdict = (context, program) => {
|
|
365
|
-
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
366
|
-
const handler = handlerOf(program, sourceCode);
|
|
367
|
-
if (!handler)
|
|
368
|
-
return false;
|
|
369
|
-
const imports = importsOf(context, program, sourceCode);
|
|
370
|
-
return imports !== null && validOperations(program, { sourceCode, handler, imports });
|
|
371
|
-
};
|
|
372
|
-
/** A callback stays a PKCE exchange and redirect, with every side effect owned by the auth helper. */
|
|
373
|
-
export const callbackShape = defineRule({
|
|
374
|
-
meta: {
|
|
375
|
-
type: "problem",
|
|
376
|
-
docs: { description: "R287 FE_CALLBACK_SHAPE: the Supabase callback reads the PKCE code or the email-link token_hash and type, exchanges it through modules/auth (exchangeAuthCode or verifyOtp), and redirects only." },
|
|
377
|
-
schema: [],
|
|
378
|
-
messages: {
|
|
379
|
-
shape: "FE_CALLBACK_SHAPE: the auth callback must export an async GET that reads the query code (or the email-link token_hash and type), awaits one exchangeAuthCode(code) from modules/auth/server or auth.exchangeCodeForSession(code) and, for an email link, one verifyOtp({ token_hash, type }) built from exactly those two query values, may call pure text helpers of modules/routes or modules/auth (no I/O, no await) to compute the target and validate next, and redirects with NextResponse.redirect or next/navigation redirect. Create the client in modules/auth; keep data access, cookie writes, other calls and additional HTTP handlers outside the callback.",
|
|
380
|
-
},
|
|
381
|
-
},
|
|
382
|
-
create(context) {
|
|
383
|
-
if (!inSlot(context, "fe.route.callback"))
|
|
384
|
-
return {};
|
|
385
|
-
return {
|
|
386
|
-
"Program:exit"(program) {
|
|
387
|
-
if (!callbackVerdict(context, program))
|
|
388
|
-
context.report({ node: program, messageId: "shape" });
|
|
389
|
-
},
|
|
390
|
-
};
|
|
391
|
-
},
|
|
392
|
-
});
|
|
393
|
-
/** The rule R287 contributes to the front-end plugin. */
|
|
394
|
-
export const rules = { "callback-shape": callbackShape };
|
|
395
|
-
/** Every callback shape violation is an error. */
|
|
396
|
-
export const recommended = { "starci-fe/callback-shape": "error" };
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { AnyContext } from "./rule.js";
|
|
2
|
-
/**
|
|
3
|
-
* What an import of the callback's owner modules is: `"pure"` (a pure text-returning helper), `"routes"` (a static route table) or null.
|
|
4
|
-
*
|
|
5
|
-
* @param context - The ESLint rule context.
|
|
6
|
-
* @param source - The import specifier.
|
|
7
|
-
* @param name - The imported name.
|
|
8
|
-
* @returns The kind.
|
|
9
|
-
*/
|
|
10
|
-
export declare function ownerExportKind(context: AnyContext, source: string, name: string): "pure" | "routes" | null;
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The owner exports an auth callback may use besides the exchange (R287 `FE_CALLBACK_SHAPE`, owner ruling `auth-callback-pure-helpers`,
|
|
3
|
-
* 2026-10-17 "Theo y tro"): a PURE helper of the app's `modules/routes` or `modules/auth` that computes the redirect target or validates
|
|
4
|
-
* `next`, and a static route table. Both are found by TypeScript symbol resolution to the declaration, never by the spelling of the name.
|
|
5
|
-
*
|
|
6
|
-
* A pure helper is a synchronous function that returns text (or null), holds no `await` or `yield`, reaches no I/O global (network, storage,
|
|
7
|
-
* DOM, `process`, `require`, `console`) and calls only locals, language built-ins and other pure helpers of `modules/routes` or
|
|
8
|
-
* `modules/auth` (followed through barrels). `modules/config` is the environment owner: a call to its readers is a configuration read, not I/O.
|
|
9
|
-
* Anything else it imports, a library value included, makes it impure.
|
|
10
|
-
*/
|
|
11
|
-
import ts from "typescript";
|
|
12
|
-
import { reaching } from "./own-hook-origin.js";
|
|
13
|
-
import { programFor } from "./program-for.js";
|
|
14
|
-
import { resolveImport } from "./scope.js";
|
|
15
|
-
/** Globals that reach the network, storage, the page, the process or the console. */
|
|
16
|
-
const IO_GLOBALS = new Set(["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "Request", "navigator", "localStorage", "sessionStorage", "indexedDB", "document", "window", "self", "globalThis", "process", "require", "console", "setTimeout", "setInterval", "queueMicrotask", "Worker", "BroadcastChannel"]);
|
|
17
|
-
const OWNER_FILE = /\/modules\/(?:routes|auth)(?:\/|\.tsx?$)/;
|
|
18
|
-
const CONFIG_FILE = /\/modules\/config(?:\/|\.tsx?$)/;
|
|
19
|
-
const EXTENSIONS = ["", ".ts", ".tsx", "/index.ts", "/index.tsx"];
|
|
20
|
-
const normal = (file) => file.fileName.replaceAll("\\", "/");
|
|
21
|
-
/** The module file an import specifier names, or null when none exists on disk. */
|
|
22
|
-
const moduleFile = (context, source) => {
|
|
23
|
-
const target = resolveImport(context, source);
|
|
24
|
-
if (target === null)
|
|
25
|
-
return null;
|
|
26
|
-
return EXTENSIONS.map((extension) => `${target}${extension}`).find((candidate) => ts.sys.fileExists(candidate)) ?? null;
|
|
27
|
-
};
|
|
28
|
-
/** The declaration an export of the module resolves to, after barrels and re-exports. */
|
|
29
|
-
const exportedDeclaration = (context, source, name) => {
|
|
30
|
-
const file = moduleFile(context, source);
|
|
31
|
-
if (file === null)
|
|
32
|
-
return null;
|
|
33
|
-
const program = reaching(programFor(context), context.filename, file);
|
|
34
|
-
const checker = program.getTypeChecker();
|
|
35
|
-
const sourceFile = program.getSourceFile(file);
|
|
36
|
-
const moduleSymbol = sourceFile ? checker.getSymbolAtLocation(sourceFile) : undefined;
|
|
37
|
-
const exported = moduleSymbol ? checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === name) : undefined;
|
|
38
|
-
if (!exported)
|
|
39
|
-
return null;
|
|
40
|
-
const symbol = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported;
|
|
41
|
-
return { checker, declaration: symbol.declarations?.[0] ?? null };
|
|
42
|
-
};
|
|
43
|
-
/** Strip `as`, `satisfies` and parentheses. */
|
|
44
|
-
const bare = (node) => {
|
|
45
|
-
let current = node;
|
|
46
|
-
while (current && (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)))
|
|
47
|
-
current = current.expression;
|
|
48
|
-
return current;
|
|
49
|
-
};
|
|
50
|
-
/** The function-like node of a function declaration or of a variable initialised with an arrow or function expression. */
|
|
51
|
-
const functionOf = (declaration) => {
|
|
52
|
-
if (ts.isFunctionDeclaration(declaration))
|
|
53
|
-
return declaration;
|
|
54
|
-
const init = ts.isVariableDeclaration(declaration) ? bare(declaration.initializer) : undefined;
|
|
55
|
-
return init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) ? init : null;
|
|
56
|
-
};
|
|
57
|
-
const isInside = (node, root) => node.getSourceFile() === root.getSourceFile() && node.pos >= root.pos && node.end <= root.end;
|
|
58
|
-
/** Whether an identifier names a declaration, a property or a member rather than referring to a binding. */
|
|
59
|
-
const isNameOnly = (node) => {
|
|
60
|
-
const parent = node.parent;
|
|
61
|
-
if (ts.isPropertyAccessExpression(parent))
|
|
62
|
-
return parent.name === node;
|
|
63
|
-
if (ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent) || ts.isPropertySignature(parent))
|
|
64
|
-
return parent.name === node;
|
|
65
|
-
return (ts.isParameter(parent) || ts.isVariableDeclaration(parent) || ts.isFunctionDeclaration(parent) || ts.isBindingElement(parent)) && parent.name === node;
|
|
66
|
-
};
|
|
67
|
-
/** A declaration of another file: an owner helper is followed, the environment owner is a leaf, everything else is impure. */
|
|
68
|
-
const foreignPure = (env, declaration, root) => {
|
|
69
|
-
const file = normal(declaration.getSourceFile());
|
|
70
|
-
if (CONFIG_FILE.test(file))
|
|
71
|
-
return true;
|
|
72
|
-
if (!OWNER_FILE.test(file) || declaration.getSourceFile().isDeclarationFile)
|
|
73
|
-
return false;
|
|
74
|
-
return declarationPure(env, declaration, root);
|
|
75
|
-
};
|
|
76
|
-
/** A global of the language or of the lib files is pure unless it reaches I/O. */
|
|
77
|
-
const globalPure = (symbol) => !IO_GLOBALS.has(symbol.getName());
|
|
78
|
-
/** Whether one identifier reference stays inside the pure surface. */
|
|
79
|
-
const referencePure = (env, node, root) => {
|
|
80
|
-
const symbol = env.checker.getSymbolAtLocation(node);
|
|
81
|
-
if (!symbol)
|
|
82
|
-
return node.text === "undefined";
|
|
83
|
-
const target = (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? env.checker.getAliasedSymbol(symbol) : symbol;
|
|
84
|
-
const declarations = target.declarations ?? [];
|
|
85
|
-
if (declarations.length === 0)
|
|
86
|
-
return node.text === "undefined";
|
|
87
|
-
return declarations.every((declaration) => {
|
|
88
|
-
const file = declaration.getSourceFile();
|
|
89
|
-
if (isInside(declaration, root))
|
|
90
|
-
return true;
|
|
91
|
-
if (!ts.isExternalModule(file))
|
|
92
|
-
return globalPure(target);
|
|
93
|
-
return foreignPure(env, declaration, root);
|
|
94
|
-
});
|
|
95
|
-
};
|
|
96
|
-
/** Walk a node: no await or yield, no impure reference; type positions are not behaviour. */
|
|
97
|
-
const nodePure = (env, node, root) => {
|
|
98
|
-
if (ts.isAwaitExpression(node) || ts.isYieldExpression(node))
|
|
99
|
-
return false;
|
|
100
|
-
if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node))
|
|
101
|
-
return true;
|
|
102
|
-
if (ts.isIdentifier(node) && !isNameOnly(node) && !referencePure(env, node, root))
|
|
103
|
-
return false;
|
|
104
|
-
return ts.forEachChild(node, (child) => (nodePure(env, child, root) ? undefined : true)) !== true;
|
|
105
|
-
};
|
|
106
|
-
/** A declaration is pure when its function (or its constant initialiser) is. */
|
|
107
|
-
function declarationPure(env, declaration, _root) {
|
|
108
|
-
if (env.seen.has(declaration))
|
|
109
|
-
return true;
|
|
110
|
-
env.seen.add(declaration);
|
|
111
|
-
const fn = functionOf(declaration);
|
|
112
|
-
if (fn)
|
|
113
|
-
return !("asteriskToken" in fn && fn.asteriskToken) && !fn.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) && (fn.body ? nodePure(env, fn.body, fn) : false);
|
|
114
|
-
if (ts.isVariableDeclaration(declaration) && declaration.initializer)
|
|
115
|
-
return nodePure(env, declaration.initializer, declaration.initializer);
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
/** Whether a function's declared or inferred return type is text, null or undefined. */
|
|
119
|
-
const returnsText = (checker, declaration) => {
|
|
120
|
-
const signature = checker.getSignaturesOfType(checker.getTypeAtLocation(declaration), ts.SignatureKind.Call)[0];
|
|
121
|
-
if (!signature)
|
|
122
|
-
return false;
|
|
123
|
-
const type = checker.getReturnTypeOfSignature(signature);
|
|
124
|
-
const parts = type.isUnion() ? type.types : [type];
|
|
125
|
-
return parts.every((part) => (part.flags & (ts.TypeFlags.StringLike | ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0);
|
|
126
|
-
};
|
|
127
|
-
/** A static table: a constant object whose leaves are string literals or plain templates. */
|
|
128
|
-
const isStaticTable = (node) => {
|
|
129
|
-
const value = bare(node);
|
|
130
|
-
if (!value)
|
|
131
|
-
return false;
|
|
132
|
-
if (ts.isStringLiteralLike(value))
|
|
133
|
-
return true;
|
|
134
|
-
if (ts.isObjectLiteralExpression(value))
|
|
135
|
-
return value.properties.every((property) => ts.isPropertyAssignment(property) && isStaticTable(property.initializer));
|
|
136
|
-
return false;
|
|
137
|
-
};
|
|
138
|
-
/**
|
|
139
|
-
* What an import of the callback's owner modules is: `"pure"` (a pure text-returning helper), `"routes"` (a static route table) or null.
|
|
140
|
-
*
|
|
141
|
-
* @param context - The ESLint rule context.
|
|
142
|
-
* @param source - The import specifier.
|
|
143
|
-
* @param name - The imported name.
|
|
144
|
-
* @returns The kind.
|
|
145
|
-
*/
|
|
146
|
-
export function ownerExportKind(context, source, name) {
|
|
147
|
-
const found = exportedDeclaration(context, source, name);
|
|
148
|
-
if (!found?.declaration)
|
|
149
|
-
return null;
|
|
150
|
-
const { checker, declaration } = found;
|
|
151
|
-
if (ts.isVariableDeclaration(declaration) && !functionOf(declaration))
|
|
152
|
-
return (declaration.parent.flags & ts.NodeFlags.Const) !== 0 && isStaticTable(declaration.initializer) ? "routes" : null;
|
|
153
|
-
const fn = functionOf(declaration);
|
|
154
|
-
const env = { checker, seen: new Set() };
|
|
155
|
-
return fn !== null && returnsText(checker, fn) && declarationPure(env, declaration, fn) ? "pure" : null;
|
|
156
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
const CACHE = new Map();
|
|
5
|
-
|
|
6
|
-
/** A static TypeScript property name, as emitted by `supabase gen types typescript`. */
|
|
7
|
-
const propertyName = (typescript, name) => (typescript.isIdentifier(name) || typescript.isStringLiteralLike(name) ? name.text : null);
|
|
8
|
-
|
|
9
|
-
/** The type-literal members below property `name`, or null when the generated shape is not statically readable. */
|
|
10
|
-
const propertyMembers = (typescript, members, name) => {
|
|
11
|
-
const property = members.find(member => typescript.isPropertySignature(member) && propertyName(typescript, member.name) === name);
|
|
12
|
-
return property?.type && typescript.isTypeLiteralNode(property.type) ? property.type.members : null;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function addTableNames(typescript, members, tables) {
|
|
16
|
-
if (!members) return;
|
|
17
|
-
for (const member of members) {
|
|
18
|
-
if (!typescript.isPropertySignature(member)) continue;
|
|
19
|
-
const name = propertyName(typescript, member.name);
|
|
20
|
-
if (name) tables.add(name.toLowerCase());
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function readTableNames(typescript, file, tables) {
|
|
25
|
-
const source = typescript.createSourceFile(file, fs.readFileSync(file, 'utf8'), typescript.ScriptTarget.Latest, true, typescript.ScriptKind.TS);
|
|
26
|
-
if (source.parseDiagnostics.length !== 0) return;
|
|
27
|
-
const database = source.statements.find(statement => typescript.isTypeAliasDeclaration(statement) && statement.name.text === 'Database');
|
|
28
|
-
const databaseMembers = database && typescript.isTypeLiteralNode(database.type) ? database.type.members : null;
|
|
29
|
-
const publicMembers = databaseMembers ? propertyMembers(typescript, databaseMembers, 'public') : null;
|
|
30
|
-
const tableMembers = publicMembers ? propertyMembers(typescript, publicMembers, 'Tables') : null;
|
|
31
|
-
addTableNames(typescript, tableMembers, tables);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The lower-case keys of `Database['public']['Tables']`, parsed with the architecture context's target compiler.
|
|
36
|
-
* An absent, malformed or unreadable contract proves no table. The result is cached once per app root.
|
|
37
|
-
*/
|
|
38
|
-
export function supabaseTablesOf(appRoot, typescript) {
|
|
39
|
-
const root = path.resolve(appRoot);
|
|
40
|
-
if (CACHE.has(root)) return CACHE.get(root);
|
|
41
|
-
const tables = new Set();
|
|
42
|
-
const file = path.join(root, 'supabase', 'types', 'database.types.ts');
|
|
43
|
-
try {
|
|
44
|
-
readTableNames(typescript, file, tables);
|
|
45
|
-
} catch {
|
|
46
|
-
// Missing and unreadable generated contracts are fail-closed: no table is proven.
|
|
47
|
-
}
|
|
48
|
-
CACHE.set(root, tables);
|
|
49
|
-
return tables;
|
|
50
|
-
}
|