@starci/eslint-canon-be 3.0.7 → 3.0.9
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 +13 -1
- package/README.md +13 -7
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +18 -21
- package/config-owner.mjs +20 -4
- package/connections.mjs +12 -12
- package/cookies.mjs +106 -0
- package/cqrs.mjs +22 -26
- package/data-access.mjs +9 -28
- package/default-deny.mjs +2 -1
- package/e2e-flow.mjs +4 -4
- package/event-bus.mjs +237 -0
- package/feature-thin.mjs +237 -0
- package/idempotency.mjs +3 -1
- package/index.mjs +22 -1
- package/infra-owner.mjs +1 -1
- package/injection.mjs +67 -22
- package/input-bounds.mjs +2 -1
- package/jobs.mjs +214 -0
- package/lib/ast.mjs +7 -0
- package/lib/config.mjs +1 -1
- package/lib/db-writes.mjs +1 -1
- package/lib/doors.mjs +81 -0
- package/lib/edition.mjs +45 -0
- package/lib/entity-writes.mjs +35 -0
- package/lib/hfs.mjs +3 -3
- package/lib/path.mjs +0 -6
- package/lib/persistence.mjs +11 -18
- package/lib/ports.mjs +5 -2
- package/lib/specifier.mjs +27 -0
- package/lib/transactions.mjs +37 -0
- package/lib/transport-slots.mjs +8 -3
- package/lib/unit-spec.mjs +63 -2
- package/module-layering.mjs +3 -24
- package/module-shape.mjs +2 -2
- package/naming.mjs +2 -2
- package/package.json +5 -3
- package/project-graph.mjs +8 -5
- package/projections.mjs +117 -0
- package/query-safety.mjs +1 -1
- package/realtime.mjs +181 -0
- package/runtime/engine/config.mjs +494 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/engine/runtime-root.mjs +5 -0
- package/runtime/engine/yaml.mjs +3 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +915 -123
- package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
- package/runtime/knowledge/sonar-gate.yaml +53 -19
- package/runtime/modules/models/registry.yaml +591 -0
- package/runtime/modules/models/runtimes.yaml +701 -0
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
- package/runtime/scripts/api/git/config-get.mjs +10 -0
- package/runtime/scripts/api/git/lib.mjs +17 -27
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
- package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
- package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
- package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
- package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
- package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
- package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
- package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
- package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
- package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
- package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
- package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
- package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
- package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
- package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
- package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
- package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +17 -114
- package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
- package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +27 -147
- package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +59 -18
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
- package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
- package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +10 -4
- package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
- package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
- package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
- package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
- package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
- package/runtime/scripts/hfs/edition-slots.mjs +66 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +247 -0
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
- package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
- package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
- package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
- package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
- package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
- package/runtime/scripts/lib/dockerfile.mjs +124 -0
- package/runtime/scripts/lib/git.mjs +36 -0
- package/runtime/scripts/lib/i18n.mjs +86 -0
- package/runtime/scripts/lib/json.mjs +13 -3
- package/runtime/scripts/lib/language.mjs +26 -7
- package/runtime/scripts/lib/line-count.mjs +9 -0
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/path-key.mjs +59 -2
- package/runtime/scripts/lib/read-text.mjs +34 -0
- package/runtime/scripts/lib/regex.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +4 -3
- package/runtime/scripts/lib/sops-envelope.mjs +58 -0
- package/runtime/scripts/lib/stack-services.mjs +7 -31
- package/runtime/scripts/lib/ts-ast.mjs +35 -0
- package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +5 -15
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +3 -2
- package/test-world.mjs +5 -4
- package/testing.mjs +27 -24
- package/transport.mjs +12 -9
- package/unit-spec.mjs +1 -1
- package/user-copy.mjs +5 -4
- package/webhooks.mjs +173 -0
- package/runtime/scripts/api/sops/decrypt.mjs +0 -16
- package/runtime/scripts/lib/recorded-lines.mjs +0 -27
- package/runtime/scripts/lib/test-secrets.mjs +0 -116
- /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// supabase-be.mjs - the back-end half of the slot-driven Supabase architecture machine: a single integration owns
|
|
2
|
+
// JWT verification, pins the remote JWKS/issuer/audience/algorithms, and never trusts authorization claims from a body.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { machineKit } from './machine-ast.mjs';
|
|
5
|
+
import { importedFrom, reportAt, unwrap } from './supabase-ast.mjs';
|
|
6
|
+
|
|
7
|
+
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
8
|
+
const BE_JWT_VERIFIED = 'BE_SUPABASE_JWT_VERIFIED';
|
|
9
|
+
const JWT_MODULES = new Set(['jose', 'jsonwebtoken', 'jwt-decode']);
|
|
10
|
+
const CLAIM_NAMES = new Set(['role', 'roles', 'membership', 'memberships']);
|
|
11
|
+
|
|
12
|
+
const objectLiteral = (kit, checker, node) => {
|
|
13
|
+
const current = unwrap(kit.ts, node);
|
|
14
|
+
if (kit.ts.isObjectLiteralExpression(current)) return current;
|
|
15
|
+
if (!kit.ts.isIdentifier(current)) return null;
|
|
16
|
+
for (const declaration of kit.declarationsOf(checker, current)) {
|
|
17
|
+
if (kit.ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
18
|
+
const value = unwrap(kit.ts, declaration.initializer);
|
|
19
|
+
if (kit.ts.isObjectLiteralExpression(value)) return value;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const staticStrings = (kit, checker, node) => {
|
|
26
|
+
const current = unwrap(kit.ts, node);
|
|
27
|
+
if (kit.ts.isStringLiteralLike(current)) return [current.text];
|
|
28
|
+
if (kit.ts.isArrayLiteralExpression(current) && current.elements.every((element) => kit.ts.isStringLiteralLike(element))) return current.elements.map((element) => element.text);
|
|
29
|
+
const value = kit.stringValue(checker, current);
|
|
30
|
+
return value === null ? null : [value];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const jwksArgument = (kit, checker, node) => {
|
|
34
|
+
const current = unwrap(kit.ts, node);
|
|
35
|
+
if (kit.ts.isCallExpression(current)) return importedFrom(kit, checker, current.expression, new Set(['jose']), new Set(['createRemoteJWKSet']));
|
|
36
|
+
if (!kit.ts.isIdentifier(current)) return false;
|
|
37
|
+
return kit.declarationsOf(checker, current).some((declaration) => kit.ts.isVariableDeclaration(declaration) && declaration.initializer
|
|
38
|
+
&& jwksArgument(kit, checker, declaration.initializer));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const importedCall = (kit, checker, call) => {
|
|
42
|
+
const direct = kit.importBinding(checker, call.expression);
|
|
43
|
+
if (direct?.module === 'jwt-decode' && direct.name === 'default') return { module: direct.module, name: 'jwtDecode' };
|
|
44
|
+
if (direct) return direct;
|
|
45
|
+
if (!kit.ts.isPropertyAccessExpression(call.expression) || !kit.ts.isIdentifier(call.expression.expression)) return null;
|
|
46
|
+
const owner = kit.importBinding(checker, call.expression.expression);
|
|
47
|
+
return owner?.name === 'default' ? { module: owner.module, name: call.expression.name.text } : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const accessName = (ts, node) => {
|
|
51
|
+
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
52
|
+
if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const requestLike = (ts, node) => {
|
|
57
|
+
const current = unwrap(ts, node);
|
|
58
|
+
if (ts.isIdentifier(current)) return /^(?:req|request)$/iu.test(current.text);
|
|
59
|
+
return (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) && accessName(ts, current) === 'request';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const fromRequestBody = (kit, checker, node, seen = new Set()) => {
|
|
63
|
+
const current = unwrap(kit.ts, node);
|
|
64
|
+
if (!current || seen.has(current)) return false;
|
|
65
|
+
seen.add(current);
|
|
66
|
+
if (kit.ts.isPropertyAccessExpression(current) || kit.ts.isElementAccessExpression(current)) {
|
|
67
|
+
if (accessName(kit.ts, current) === 'body' && requestLike(kit.ts, current.expression)) return true;
|
|
68
|
+
return fromRequestBody(kit, checker, current.expression, seen);
|
|
69
|
+
}
|
|
70
|
+
if (!kit.ts.isIdentifier(current)) return false;
|
|
71
|
+
return kit.declarationsOf(checker, current).some(declaration => kit.ts.isVariableDeclaration(declaration) && declaration.initializer
|
|
72
|
+
&& fromRequestBody(kit, checker, declaration.initializer, seen));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function checkBackendJwt(input) {
|
|
76
|
+
const kit = machineKit(input);
|
|
77
|
+
const { ts } = kit;
|
|
78
|
+
const violations = [];
|
|
79
|
+
const integrations = [...input.graph.files.values()].filter((file) => file.slot === BE_SUPABASE_SLOT);
|
|
80
|
+
if (!integrations.length) return { violations, coverage: { status: 'not-applicable', integrationFiles: 0, verifiedCalls: 0 } };
|
|
81
|
+
let verifyCalls = 0;
|
|
82
|
+
let verifiedCalls = 0;
|
|
83
|
+
for (const file of input.graph.files.values()) {
|
|
84
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
85
|
+
kit.walk(file.sourceFile, (node) => {
|
|
86
|
+
if (ts.isCallExpression(node)) {
|
|
87
|
+
const binding = importedCall(kit, checker, node);
|
|
88
|
+
if (binding && JWT_MODULES.has(binding.module) && ['decode', 'decodeJwt', 'jwtDecode'].includes(binding.name)) {
|
|
89
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${binding.module}.${binding.name} decodes a token without proving its signature; use the Supabase JWKS verifier.`, { module: binding.module, method: binding.name });
|
|
90
|
+
}
|
|
91
|
+
if (binding?.module === 'jose' && binding.name === 'jwtVerify') {
|
|
92
|
+
verifyCalls += 1;
|
|
93
|
+
if (file.slot !== BE_SUPABASE_SLOT) {
|
|
94
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `jwtVerify is called outside ${BE_SUPABASE_SLOT}; the integration is the one token verifier.`);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
const options = objectLiteral(kit, checker, node.arguments[2]);
|
|
98
|
+
const property = (name) => options ? kit.propertyOf(options, name) : null;
|
|
99
|
+
const values = (name) => {
|
|
100
|
+
const entry = property(name);
|
|
101
|
+
return entry ? staticStrings(kit, checker, kit.valueOfProperty(entry)) : null;
|
|
102
|
+
};
|
|
103
|
+
const issuer = property('issuer');
|
|
104
|
+
const audience = values('audience');
|
|
105
|
+
const algorithms = values('algorithms');
|
|
106
|
+
const good = jwksArgument(kit, checker, node.arguments[1]) && issuer != null && audience?.includes('authenticated')
|
|
107
|
+
&& algorithms !== null && algorithms.length > 0;
|
|
108
|
+
if (!good) {
|
|
109
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, file, node, 'Supabase jwtVerify must use createRemoteJWKSet(...) and pin issuer, audience `authenticated`, and a non-empty algorithms list; jwtVerify then enforces signature and exp.');
|
|
110
|
+
} else verifiedCalls += 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && CLAIM_NAMES.has(accessName(ts, node))) {
|
|
114
|
+
const claim = accessName(ts, node);
|
|
115
|
+
if (fromRequestBody(kit, checker, node.expression)) reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${claim} is read from the request body; authorization claims come only from the verified token or the database.`, { claim });
|
|
116
|
+
}
|
|
117
|
+
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer && fromRequestBody(kit, checker, node.initializer)) {
|
|
118
|
+
for (const element of node.name.elements) {
|
|
119
|
+
const property = element.propertyName ?? element.name;
|
|
120
|
+
if (ts.isIdentifier(property) && CLAIM_NAMES.has(property.text)) reportAt(violations, kit, BE_JWT_VERIFIED, file, element, `${property.text} is destructured from the request body; authorization claims come only from the verified token or the database.`, { claim: property.text });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (verifyCalls === 0) {
|
|
127
|
+
const anchor = integrations.find((file) => path.posix.basename(file.rel).endsWith('.jwks.ts')) ?? integrations[0];
|
|
128
|
+
reportAt(violations, kit, BE_JWT_VERIFIED, anchor, anchor.sourceFile, `${BE_SUPABASE_SLOT} has no jose.jwtVerify call backed by createRemoteJWKSet.`);
|
|
129
|
+
}
|
|
130
|
+
return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls, verifiedCalls } };
|
|
131
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* The lower-case keys of `Database['public']['Tables']`, parsed with the architecture context's target compiler.
|
|
17
|
+
* An absent, malformed or unreadable contract proves no table. The result is cached once per app root.
|
|
18
|
+
*/
|
|
19
|
+
export function supabaseTablesOf(appRoot, typescript) {
|
|
20
|
+
const root = path.resolve(appRoot);
|
|
21
|
+
if (CACHE.has(root)) return CACHE.get(root);
|
|
22
|
+
const tables = new Set();
|
|
23
|
+
const file = path.join(root, 'supabase', 'types', 'database.types.ts');
|
|
24
|
+
try {
|
|
25
|
+
const source = typescript.createSourceFile(file, fs.readFileSync(file, 'utf8'), typescript.ScriptTarget.Latest, true, typescript.ScriptKind.TS);
|
|
26
|
+
if (source.parseDiagnostics.length === 0) {
|
|
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
|
+
if (tableMembers) for (const member of tableMembers) {
|
|
32
|
+
if (!typescript.isPropertySignature(member)) continue;
|
|
33
|
+
const name = propertyName(typescript, member.name);
|
|
34
|
+
if (name) tables.add(name.toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Missing and unreadable generated contracts are fail-closed: no table is proven.
|
|
39
|
+
}
|
|
40
|
+
CACHE.set(root, tables);
|
|
41
|
+
return tables;
|
|
42
|
+
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { machineKit } from './machine-ast.mjs';
|
|
3
|
+
import { isServerActionModule } from './server-action.mjs';
|
|
4
|
+
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, isSupabaseValue, moduleNameOf, reportAt, signatureFromSupabase, supabaseClientType, SUPABASE_MODULES, unwrap } from './supabase-ast.mjs';
|
|
5
|
+
import { checkBackendJwt } from './supabase-be.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* L10-L16, the slot-driven Supabase machine. Supabase is one data transport with one owner on each side:
|
|
9
|
+
* `fe.modules.db` / `fe.package.db` in a front end and `be.integrations.supabase` in a back end. The checks below
|
|
10
|
+
* recognise provider calls and types by their imported package declarations, not by local variable or type names.
|
|
11
|
+
*/
|
|
12
|
+
export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze([
|
|
13
|
+
'FE_SUPABASE_CLIENT_OWNER',
|
|
14
|
+
'FE_DB_RESULT_TYPED',
|
|
15
|
+
'FE_DB_ERROR_HANDLED',
|
|
16
|
+
'FE_SERVICE_ROLE_FORBIDDEN',
|
|
17
|
+
'FE_AUTH_SESSION_TRUST',
|
|
18
|
+
'FE_DB_WRITE_SHAPE',
|
|
19
|
+
'FE_ROUTE_HANDLER_FORBIDDEN',
|
|
20
|
+
]);
|
|
21
|
+
export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNER', 'BE_SUPABASE_JWT_VERIFIED']);
|
|
22
|
+
|
|
23
|
+
const FE_CLIENT_OWNER = 'FE_SUPABASE_CLIENT_OWNER';
|
|
24
|
+
const BE_CLIENT_OWNER = 'BE_SUPABASE_CLIENT_OWNER';
|
|
25
|
+
const FE_RESULT_TYPED = 'FE_DB_RESULT_TYPED';
|
|
26
|
+
const FE_ERROR_HANDLED = 'FE_DB_ERROR_HANDLED';
|
|
27
|
+
const FE_SERVICE_ROLE = 'FE_SERVICE_ROLE_FORBIDDEN';
|
|
28
|
+
const FE_SESSION_TRUST = 'FE_AUTH_SESSION_TRUST';
|
|
29
|
+
const FE_WRITE_SHAPE = 'FE_DB_WRITE_SHAPE';
|
|
30
|
+
const FE_ROUTE_HANDLER = 'FE_ROUTE_HANDLER_FORBIDDEN';
|
|
31
|
+
const FE_DB_SLOTS = new Set(['fe.modules.db', 'fe.package.db', 'fe.modules.db.outcome']);
|
|
32
|
+
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
33
|
+
const FE_CONFIG_SLOT = 'fe.modules.config';
|
|
34
|
+
const SUPABASE_CALLS = new Set(['from', 'rpc']);
|
|
35
|
+
const QUERY_TERMINALS = new Set(['single', 'maybeSingle', 'returns']);
|
|
36
|
+
const QUERY_WRITES = new Set(['insert', 'update', 'upsert', 'delete']);
|
|
37
|
+
const LIST_BOUNDS = new Set(['gt', 'gte', 'lt', 'lte', 'range']);
|
|
38
|
+
const supabaseSlotEnabled = (input, slotId) => {
|
|
39
|
+
const slot = input.graph.resolver.slot(slotId);
|
|
40
|
+
return Boolean(slot && input.graph.resolver.slotEnabled(slot));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function checkClientOwnership(input, { slots, ruleId }) {
|
|
44
|
+
const kit = machineKit(input);
|
|
45
|
+
const { ts } = kit;
|
|
46
|
+
const violations = [];
|
|
47
|
+
let imports = 0;
|
|
48
|
+
let clients = 0;
|
|
49
|
+
let calls = 0;
|
|
50
|
+
for (const file of input.graph.files.values()) {
|
|
51
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
52
|
+
const owned = slots.has(file.slot);
|
|
53
|
+
kit.walk(file.sourceFile, node => {
|
|
54
|
+
const moduleName = moduleNameOf(ts, node);
|
|
55
|
+
if (moduleName && SUPABASE_MODULES.has(moduleName)) {
|
|
56
|
+
imports += 1;
|
|
57
|
+
if (!owned) reportAt(violations, kit, ruleId, file, node, `${moduleName} is imported by ${file.rel}, whose slot is ${file.slot ?? 'unowned'}; Supabase imports belong only to ${[...slots].join(' or ')}.`, { module: moduleName, slot: file.slot ?? null });
|
|
58
|
+
}
|
|
59
|
+
if (ts.isTypeReferenceNode(node) && supabaseClientType(kit, checker, node)) {
|
|
60
|
+
clients += 1;
|
|
61
|
+
if (!isDatabaseType(ts, node.typeArguments?.[0])) reportAt(violations, kit, ruleId, file, node, 'SupabaseClient must carry the committed Database type as SupabaseClient<Database>.');
|
|
62
|
+
}
|
|
63
|
+
if (!ts.isCallExpression(node)) return true;
|
|
64
|
+
const binding = kit.importBinding(checker, node.expression);
|
|
65
|
+
if (binding && SUPABASE_MODULES.has(binding.module) && /^create(?:Client|ServerClient|BrowserClient)$/u.test(binding.name)) {
|
|
66
|
+
clients += 1;
|
|
67
|
+
if (!isDatabaseType(ts, node.typeArguments?.[0]) && !contextTypesClient(kit, checker, node)) {
|
|
68
|
+
reportAt(violations, kit, ruleId, file, node, `${binding.name} creates an untyped Supabase client; create it as ${binding.name}<Database>(...) (or give it the contextual type SupabaseClient<Database>).`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!isSupabaseCall(kit, checker, node)) return true;
|
|
72
|
+
const properties = chainParts(ts, node);
|
|
73
|
+
const method = callName(ts, node);
|
|
74
|
+
if (!SUPABASE_CALLS.has(method) && !properties.includes('auth') && !properties.includes('storage')) return true;
|
|
75
|
+
calls += 1;
|
|
76
|
+
if (!owned) reportAt(violations, kit, ruleId, file, node, `A Supabase ${method ?? 'client'} call is made from ${file.slot ?? 'an unowned path'}; database, auth and storage calls belong only to ${[...slots].join(' or ')}.`, { method, slot: file.slot ?? null });
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return { violations, coverage: { status: 'checked', imports, clients, calls, ownerSlots: [...slots] } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const symbolOf = (kit, checker, node) => kit.aliased(checker, kit.symbolAt(checker, node)) ?? kit.symbolAt(checker, node);
|
|
84
|
+
|
|
85
|
+
const sameSymbol = (kit, checker, node, symbol) => Boolean(symbol && symbolOf(kit, checker, node) === symbol);
|
|
86
|
+
|
|
87
|
+
const fromAwaitedSupabase = (kit, checker, node, seen = new Set()) => {
|
|
88
|
+
const current = unwrap(kit.ts, node);
|
|
89
|
+
if (!current || seen.has(current)) return false;
|
|
90
|
+
seen.add(current);
|
|
91
|
+
if (kit.ts.isCallExpression(current)) return isSupabaseCall(kit, checker, current);
|
|
92
|
+
if (!kit.ts.isIdentifier(current)) return false;
|
|
93
|
+
for (const declaration of kit.declarationsOf(checker, current)) {
|
|
94
|
+
if (kit.ts.isVariableDeclaration(declaration) && declaration.initializer && fromAwaitedSupabase(kit, checker, declaration.initializer, seen)) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const callIsToOutcome = (kit, checker, graph, call) => {
|
|
100
|
+
if (!kit.ts.isCallExpression(call)) return false;
|
|
101
|
+
const expression = call.expression;
|
|
102
|
+
const name = kit.ts.isIdentifier(expression) ? expression.text : (kit.ts.isPropertyAccessExpression(expression) ? expression.name.text : null);
|
|
103
|
+
if (name !== 'toOutcome') return false;
|
|
104
|
+
return kit.declarationsOf(checker, kit.ts.isPropertyAccessExpression(expression) ? expression.name : expression)
|
|
105
|
+
.some(declaration => {
|
|
106
|
+
const rel = kit.graphPath(declaration);
|
|
107
|
+
return rel !== null && FE_DB_SLOTS.has(graph.files.get(rel)?.slot);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const parentCall = (ts, node) => {
|
|
112
|
+
let current = node;
|
|
113
|
+
while (current.parent && ts.isParenthesizedExpression(current.parent)) current = current.parent;
|
|
114
|
+
return ts.isCallExpression(current.parent) ? current.parent : null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handledAwait = (kit, checker, graph, awaitNode) => {
|
|
118
|
+
const direct = parentCall(kit.ts, awaitNode);
|
|
119
|
+
if (direct && callIsToOutcome(kit, checker, graph, direct) && direct.arguments.some(argument => argument === awaitNode || argument === awaitNode.parent)) return true;
|
|
120
|
+
let cursor = awaitNode;
|
|
121
|
+
while (cursor.parent && kit.ts.isParenthesizedExpression(cursor.parent)) cursor = cursor.parent;
|
|
122
|
+
const declaration = kit.ts.isVariableDeclaration(cursor.parent) ? cursor.parent : null;
|
|
123
|
+
if (!declaration) return false;
|
|
124
|
+
if (kit.ts.isIdentifier(declaration.name)) {
|
|
125
|
+
const result = symbolOf(kit, checker, declaration.name);
|
|
126
|
+
let handled = false;
|
|
127
|
+
kit.walk(fileOf(awaitNode), node => {
|
|
128
|
+
if (kit.ts.isPropertyAccessExpression(node) && node.name.text === 'error' && sameSymbol(kit, checker, node.expression, result)) handled = true;
|
|
129
|
+
if (kit.ts.isElementAccessExpression(node) && kit.ts.isStringLiteralLike(node.argumentExpression) && node.argumentExpression.text === 'error' && sameSymbol(kit, checker, node.expression, result)) handled = true;
|
|
130
|
+
if (kit.ts.isCallExpression(node) && callIsToOutcome(kit, checker, graph, node) && node.arguments.some(argument => sameSymbol(kit, checker, unwrap(kit.ts, argument), result))) handled = true;
|
|
131
|
+
return !handled;
|
|
132
|
+
});
|
|
133
|
+
return handled;
|
|
134
|
+
}
|
|
135
|
+
if (!kit.ts.isObjectBindingPattern(declaration.name)) return false;
|
|
136
|
+
const errorBinding = declaration.name.elements.find(element => {
|
|
137
|
+
const property = element.propertyName ?? element.name;
|
|
138
|
+
return kit.ts.isIdentifier(property) && property.text === 'error';
|
|
139
|
+
});
|
|
140
|
+
if (!errorBinding || !kit.ts.isIdentifier(errorBinding.name)) return false;
|
|
141
|
+
const error = symbolOf(kit, checker, errorBinding.name);
|
|
142
|
+
let references = 0;
|
|
143
|
+
kit.walk(fileOf(awaitNode), node => {
|
|
144
|
+
if (kit.ts.isIdentifier(node) && sameSymbol(kit, checker, node, error)) references += 1;
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
return references > 1;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const fileOf = node => node.getSourceFile();
|
|
151
|
+
|
|
152
|
+
const dataSymbolFromAwait = (kit, checker, identifier) => {
|
|
153
|
+
const symbol = symbolOf(kit, checker, identifier);
|
|
154
|
+
for (const declaration of symbol?.declarations ?? []) {
|
|
155
|
+
if (!kit.ts.isBindingElement(declaration) || !kit.ts.isObjectBindingPattern(declaration.parent)) continue;
|
|
156
|
+
const property = declaration.propertyName ?? declaration.name;
|
|
157
|
+
if (!kit.ts.isIdentifier(property) || property.text !== 'data') continue;
|
|
158
|
+
const variable = declaration.parent.parent;
|
|
159
|
+
if (kit.ts.isVariableDeclaration(variable) && variable.initializer && fromAwaitedSupabase(kit, checker, variable.initializer)) return symbol;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function checkFrontendQueryResults(input) {
|
|
165
|
+
const kit = machineKit(input);
|
|
166
|
+
const { ts } = kit;
|
|
167
|
+
const violations = [];
|
|
168
|
+
let awaited = 0;
|
|
169
|
+
let listReads = 0;
|
|
170
|
+
for (const file of input.graph.files.values()) {
|
|
171
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
const report = (ruleId, node, message) => {
|
|
174
|
+
const key = `${ruleId}:${node.pos}:${message}`;
|
|
175
|
+
if (seen.has(key)) return;
|
|
176
|
+
seen.add(key);
|
|
177
|
+
reportAt(violations, kit, ruleId, file, node, message);
|
|
178
|
+
};
|
|
179
|
+
kit.walk(file.sourceFile, node => {
|
|
180
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && QUERY_TERMINALS.has(node.expression.name.text)
|
|
181
|
+
&& node.typeArguments?.length && isSupabaseValue(kit, checker, node.expression.expression)) {
|
|
182
|
+
report(FE_RESULT_TYPED, node, `${node.expression.name.text}<T>() supplies a result generic; derive the row type from Database and let the Supabase chain infer it.`);
|
|
183
|
+
}
|
|
184
|
+
if ((ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node)) && fromAwaitedSupabase(kit, checker, node.expression)) {
|
|
185
|
+
report(FE_RESULT_TYPED, node, 'A Supabase query result is cast or non-null asserted; narrow its typed data/error outcome instead.');
|
|
186
|
+
}
|
|
187
|
+
if (ts.isAwaitExpression(node) && isSupabaseValue(kit, checker, node.expression)) {
|
|
188
|
+
const expression = unwrap(ts, node.expression);
|
|
189
|
+
if (!ts.isCallExpression(expression)) return true;
|
|
190
|
+
awaited += 1;
|
|
191
|
+
const methods = chainParts(ts, expression);
|
|
192
|
+
if (methods.includes('select') && !methods.some(method => QUERY_WRITES.has(method)) && !methods.includes('single') && !methods.includes('maybeSingle')) {
|
|
193
|
+
listReads += 1;
|
|
194
|
+
if (!methods.includes('limit') && !methods.some(method => LIST_BOUNDS.has(method))) {
|
|
195
|
+
report(FE_RESULT_TYPED, node, 'A Supabase list read has no .limit(), .range(), or keyset bound (.gt/.gte/.lt/.lte); bound every multi-row read.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!handledAwait(kit, checker, input.graph, node)) {
|
|
199
|
+
report(FE_ERROR_HANDLED, node, 'The awaited Supabase result does not read its error and is not passed whole to toOutcome(result).');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && ts.isArrayLiteralExpression(node.right) && node.right.elements.length === 0) {
|
|
203
|
+
const left = unwrap(ts, node.left);
|
|
204
|
+
const swallowed = ts.isIdentifier(left) ? Boolean(dataSymbolFromAwait(kit, checker, left))
|
|
205
|
+
: ts.isPropertyAccessExpression(left) && left.name.text === 'data' && fromAwaitedSupabase(kit, checker, left.expression);
|
|
206
|
+
if (swallowed) report(FE_ERROR_HANDLED, node, 'Supabase data is collapsed with ?? []; refused or unavailable is not empty data. Pass the whole result to toOutcome.');
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return { violations, coverage: { status: 'checked', awaited, listReads } };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const words = text => String(text).replace(/([a-z0-9])([A-Z])/gu, '$1_$2').toUpperCase();
|
|
215
|
+
const serviceRoleText = text => /(?:^|[^A-Z0-9])SERVICE[_ -]?ROLE(?:[^A-Z0-9]|$)/iu.test(words(text));
|
|
216
|
+
const credentialEnv = name => /(?:^|_)(?:SECRET|PRIVATE|TOKEN|PASSWORD)$/u.test(name) || /(?:^|_)SERVICE_ROLE(?:_|$)/u.test(name);
|
|
217
|
+
const CORE_PUBLIC_ENV = new Set(['NEXT_PUBLIC_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_ANON_KEY', 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'NEXT_PUBLIC_SITE_URL']);
|
|
218
|
+
|
|
219
|
+
const envRead = (ts, node) => {
|
|
220
|
+
if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) return null;
|
|
221
|
+
const object = node.expression;
|
|
222
|
+
const processEnv = ts.isPropertyAccessExpression(object) && ts.isIdentifier(object.expression) && object.expression.text === 'process' && object.name.text === 'env';
|
|
223
|
+
const importMetaEnv = ts.isPropertyAccessExpression(object) && object.name.text === 'env' && object.expression.kind === ts.SyntaxKind.MetaProperty;
|
|
224
|
+
if (!processEnv && !importMetaEnv) return null;
|
|
225
|
+
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
226
|
+
return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
function checkFrontendSecretNames(input) {
|
|
230
|
+
const kit = machineKit(input);
|
|
231
|
+
const { ts } = kit;
|
|
232
|
+
const violations = [];
|
|
233
|
+
let reads = 0;
|
|
234
|
+
for (const file of input.graph.files.values()) {
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
const report = (node, message, name) => {
|
|
237
|
+
const key = `${node.pos}:${name ?? ''}`;
|
|
238
|
+
if (seen.has(key)) return;
|
|
239
|
+
seen.add(key);
|
|
240
|
+
reportAt(violations, kit, FE_SERVICE_ROLE, file, node, message, name ? { name } : {});
|
|
241
|
+
};
|
|
242
|
+
kit.walk(file.sourceFile, node => {
|
|
243
|
+
if (ts.isIdentifier(node) && serviceRoleText(node.text)) report(node, 'A SERVICE_ROLE identifier appears in the front end; the browser and Next process never hold the Supabase service role.', node.text);
|
|
244
|
+
if (ts.isStringLiteralLike(node) && serviceRoleText(node.text)) report(node, 'A service_role string appears in the front end; the service role belongs behind the back-end guard chain.', node.text);
|
|
245
|
+
const name = envRead(ts, node);
|
|
246
|
+
if (!name) return true;
|
|
247
|
+
reads += 1;
|
|
248
|
+
if (credentialEnv(name)) report(node, `${name} is a credential-shaped environment read in the front end; no *_SECRET, *_PRIVATE, *_TOKEN, *_PASSWORD or service-role value enters fe/.`, name);
|
|
249
|
+
else if (name.startsWith('NEXT_PUBLIC_') && !CORE_PUBLIC_ENV.has(name) && file.slot !== FE_CONFIG_SLOT) {
|
|
250
|
+
report(node, `${name} is not a core Supabase/site public variable and is read outside ${FE_CONFIG_SLOT}; product public configuration is declared by the one config owner.`, name);
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return { violations, coverage: { status: 'checked', envReads: reads } };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const isClientModule = (ts, sourceFile) => sourceFile.statements.some((statement, index) => index < 4 && ts.isExpressionStatement(statement)
|
|
259
|
+
&& ts.isStringLiteralLike(statement.expression) && statement.expression.text === 'use client');
|
|
260
|
+
|
|
261
|
+
const isDirectiveStatement = (ts, statement) => ts.isExpressionStatement(statement) && ts.isStringLiteralLike(statement.expression);
|
|
262
|
+
|
|
263
|
+
const exportedValues = (ts, sourceFile) => {
|
|
264
|
+
const hasExport = node => node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const statement of sourceFile.statements) {
|
|
267
|
+
if (ts.isFunctionDeclaration(statement) && hasExport(statement) && statement.name && statement.body) {
|
|
268
|
+
out.push({ name: statement.name.text, implementation: statement.body, functionNode: statement });
|
|
269
|
+
}
|
|
270
|
+
if (!ts.isVariableStatement(statement) || !hasExport(statement)) continue;
|
|
271
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
272
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
|
273
|
+
const functionNode = (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))
|
|
274
|
+
&& ts.isBlock(declaration.initializer.body) ? declaration.initializer : null;
|
|
275
|
+
out.push({ name: declaration.name.text, implementation: declaration.initializer, functionNode });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const exportedActions = (ts, sourceFile) => exportedValues(ts, sourceFile).map(value => value.functionNode).filter(Boolean);
|
|
282
|
+
const exportedImplementations = (ts, sourceFile, name) => exportedValues(ts, sourceFile)
|
|
283
|
+
.filter(value => value.name === name).map(value => value.implementation);
|
|
284
|
+
|
|
285
|
+
const contains = (kit, root, predicate) => {
|
|
286
|
+
let found = false;
|
|
287
|
+
kit.walk(root, node => {
|
|
288
|
+
if (predicate(node)) found = true;
|
|
289
|
+
return !found;
|
|
290
|
+
});
|
|
291
|
+
return found;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const principalCall = (kit, checker, graph, node) => kit.ts.isCallExpression(node) && (() => {
|
|
295
|
+
const expression = node.expression;
|
|
296
|
+
const name = kit.ts.isIdentifier(expression) ? expression : (kit.ts.isPropertyAccessExpression(expression) ? expression.name : null);
|
|
297
|
+
if (!name || name.text !== 'getPrincipal') return false;
|
|
298
|
+
return kit.declarationsOf(checker, name).some(declaration => {
|
|
299
|
+
const rel = kit.graphPath(declaration);
|
|
300
|
+
return rel !== null && FE_DB_SLOTS.has(graph.files.get(rel)?.slot);
|
|
301
|
+
});
|
|
302
|
+
})();
|
|
303
|
+
|
|
304
|
+
const firstPrincipal = (kit, checker, graph, statement) => {
|
|
305
|
+
let call = null;
|
|
306
|
+
kit.walk(statement, node => {
|
|
307
|
+
if (kit.ts.isAwaitExpression(node) && principalCall(kit, checker, graph, unwrap(kit.ts, node.expression))) call = unwrap(kit.ts, node.expression);
|
|
308
|
+
return call === null;
|
|
309
|
+
});
|
|
310
|
+
if (!call || !kit.ts.isVariableStatement(statement)) return { call, symbol: null };
|
|
311
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
312
|
+
if (kit.ts.isIdentifier(declaration.name) && declaration.initializer && contains(kit, declaration.initializer, node => node === call)) {
|
|
313
|
+
return { call, symbol: symbolOf(kit, checker, declaration.name) };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { call, symbol: null };
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const typedRefusal = (kit, node) => contains(kit, node, child => (kit.ts.isStringLiteralLike(child) && child.text === 'refused')
|
|
320
|
+
|| (kit.ts.isIdentifier(child) && /^refused(?:Outcome)?$/iu.test(child.text)));
|
|
321
|
+
|
|
322
|
+
const parseUse = (ts, identifier) => {
|
|
323
|
+
let current = identifier;
|
|
324
|
+
while (current.parent && (ts.isParenthesizedExpression(current.parent) || ts.isAsExpression(current.parent))) current = current.parent;
|
|
325
|
+
const call = current.parent;
|
|
326
|
+
return ts.isCallExpression(call) && call.arguments.includes(current) && ts.isPropertyAccessExpression(call.expression)
|
|
327
|
+
&& ['parse', 'safeParse'].includes(call.expression.name.text);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const parameterBindings = (ts, name) => {
|
|
331
|
+
if (ts.isIdentifier(name)) return [name];
|
|
332
|
+
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) return name.elements.flatMap(element => ts.isBindingElement(element) ? parameterBindings(ts, element.name) : []);
|
|
333
|
+
return [];
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
function checkFrontendAuthAndRoutes(input) {
|
|
337
|
+
const kit = machineKit(input);
|
|
338
|
+
const { ts } = kit;
|
|
339
|
+
const violations = [];
|
|
340
|
+
let actions = 0;
|
|
341
|
+
let routes = 0;
|
|
342
|
+
for (const file of input.graph.files.values()) {
|
|
343
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
344
|
+
if (!isClientModule(ts, file.sourceFile)) {
|
|
345
|
+
kit.walk(file.sourceFile, node => {
|
|
346
|
+
if (ts.isCallExpression(node) && callName(ts, node) === 'getSession' && signatureFromSupabase(checker, node)) {
|
|
347
|
+
reportAt(violations, kit, FE_SESSION_TRUST, file, node, 'auth.getSession() trusts cookie-backed session data in server code; call getPrincipal() (getClaims), and getUser() for sensitive mutations.');
|
|
348
|
+
}
|
|
349
|
+
return true;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (FE_DB_SLOTS.has(file.slot)) {
|
|
353
|
+
for (const implementation of exportedImplementations(ts, file.sourceFile, 'getPrincipal')) {
|
|
354
|
+
const readsClaims = contains(kit, implementation, node => ts.isCallExpression(node) && callName(ts, node) === 'getClaims'
|
|
355
|
+
&& signatureFromSupabase(checker, node));
|
|
356
|
+
if (!readsClaims) {
|
|
357
|
+
reportAt(violations, kit, FE_SESSION_TRUST, file, implementation, 'getPrincipal() must establish the server principal through Supabase auth.getClaims().');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (FE_DB_SLOTS.has(file.slot) && path.posix.basename(file.rel).startsWith('write-')) {
|
|
362
|
+
const classified = input.graph.resolver.classifyPath(file.rel);
|
|
363
|
+
const belowSlot = classified.root && file.rel.startsWith(`${classified.root}/`) ? file.rel.slice(classified.root.length + 1) : '';
|
|
364
|
+
const authenticatesAnonymous = (input.graph.resolver.slot(file.slot)?.anonymousActions ?? []).includes(belowSlot);
|
|
365
|
+
const declared = exportedActions(ts, file.sourceFile);
|
|
366
|
+
if (!declared.length) reportAt(violations, kit, FE_WRITE_SHAPE, file, file.sourceFile, `${file.rel} is a write module but exports no Server Action function.`);
|
|
367
|
+
for (const action of declared) {
|
|
368
|
+
actions += 1;
|
|
369
|
+
const functionLevel = action.body.statements[0]?.expression?.text === 'use server' && isDirectiveStatement(ts, action.body.statements[0]);
|
|
370
|
+
if (!isServerActionModule(ts, file.sourceFile) && !functionLevel) {
|
|
371
|
+
reportAt(violations, kit, FE_WRITE_SHAPE, file, action, 'An exported write function must be a Server Action through a file-level or function-level `use server` directive.');
|
|
372
|
+
}
|
|
373
|
+
// Function directives are the prologue, not work: the principal is the first non-directive statement.
|
|
374
|
+
const work = action.body.statements.slice(action.body.statements.findIndex(statement => !isDirectiveStatement(ts, statement)) >>> 0);
|
|
375
|
+
const first = work[0];
|
|
376
|
+
const principal = first ? firstPrincipal(kit, checker, input.graph, first) : { call: null, symbol: null };
|
|
377
|
+
if (!principal.call || !principal.symbol) {
|
|
378
|
+
reportAt(violations, kit, FE_SESSION_TRUST, file, first ?? action, 'A write Server Action must begin with `const principal = await getPrincipal()` from the db owner.');
|
|
379
|
+
} else if (!authenticatesAnonymous) {
|
|
380
|
+
const refuses = work.slice(1).some(statement => ts.isIfStatement(statement)
|
|
381
|
+
&& contains(kit, statement.expression, node => ts.isIdentifier(node) && sameSymbol(kit, checker, node, principal.symbol))
|
|
382
|
+
&& typedRefusal(kit, statement.thenStatement));
|
|
383
|
+
if (!refuses) reportAt(violations, kit, FE_SESSION_TRUST, file, action, 'A write Server Action must refuse an anonymous principal with a typed `refused` Outcome before doing work.');
|
|
384
|
+
}
|
|
385
|
+
for (const parameter of action.parameters.flatMap(item => parameterBindings(ts, item.name))) {
|
|
386
|
+
const symbol = symbolOf(kit, checker, parameter);
|
|
387
|
+
const references = [];
|
|
388
|
+
kit.walk(action.body, node => {
|
|
389
|
+
if (ts.isIdentifier(node) && sameSymbol(kit, checker, node, symbol)) references.push(node);
|
|
390
|
+
return true;
|
|
391
|
+
});
|
|
392
|
+
const firstUse = references.sort((a, b) => a.getStart() - b.getStart())[0];
|
|
393
|
+
if (firstUse && !parseUse(ts, firstUse)) reportAt(violations, kit, FE_WRITE_SHAPE, file, firstUse, `Server Action input ${parameter.text} is used before schema.parse(...) or schema.safeParse(...).`, { parameter: parameter.text });
|
|
394
|
+
}
|
|
395
|
+
const sensitive = /(?:^|\/)(?:billing|payment|payout|refund|charge|subscription|password|credential|role|permission|membership)(?:\/|-|\.)/iu.test(file.rel);
|
|
396
|
+
if (sensitive && !contains(kit, action.body, node => ts.isCallExpression(node) && callName(ts, node) === 'getUser' && signatureFromSupabase(checker, node))) {
|
|
397
|
+
reportAt(violations, kit, FE_SESSION_TRUST, file, action, 'A money, account-security or role-changing Server Action must call auth.getUser() before the mutation.');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (file.rel.endsWith('/route.ts')) {
|
|
402
|
+
routes += 1;
|
|
403
|
+
const classified = input.graph.resolver.classifyPath(file.rel);
|
|
404
|
+
const app = classified.bindings?.app;
|
|
405
|
+
const live = app !== undefined && file.rel === `apps/${app}/src/app/health/live/route.ts`;
|
|
406
|
+
if (!live && file.slot !== 'fe.route.callback') {
|
|
407
|
+
reportAt(violations, kit, FE_ROUTE_HANDLER, file, file.sourceFile, `${file.rel} is a front-end route handler outside health/live and the fe.route.callback slot; webhooks, cron, public APIs and third-party callbacks belong to be features.`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { violations, coverage: { status: 'checked', actions, routes } };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function checkFrontendSupabase(input) {
|
|
415
|
+
if (!supabaseSlotEnabled(input, 'fe.modules.db')) return { violations: [], coverage: { status: 'not-applicable', reason: 'the Supabase front-end database slot is not enabled' } };
|
|
416
|
+
const owner = checkClientOwnership(input, { slots: FE_DB_SLOTS, ruleId: FE_CLIENT_OWNER });
|
|
417
|
+
const results = checkFrontendQueryResults(input);
|
|
418
|
+
const secrets = checkFrontendSecretNames(input);
|
|
419
|
+
const auth = checkFrontendAuthAndRoutes(input);
|
|
420
|
+
return {
|
|
421
|
+
violations: [...owner.violations, ...results.violations, ...secrets.violations, ...auth.violations],
|
|
422
|
+
coverage: { status: 'checked', owner: owner.coverage, results: results.coverage, secrets: secrets.coverage, auth: auth.coverage },
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function checkBackendSupabase(input) {
|
|
427
|
+
if (!supabaseSlotEnabled(input, BE_SUPABASE_SLOT)) return { violations: [], coverage: { status: 'not-applicable', reason: 'the Supabase back-end integration slot is not enabled' } };
|
|
428
|
+
const owner = checkClientOwnership(input, { slots: new Set([BE_SUPABASE_SLOT]), ruleId: BE_CLIENT_OWNER });
|
|
429
|
+
const jwt = checkBackendJwt(input);
|
|
430
|
+
return {
|
|
431
|
+
violations: [...owner.violations, ...jwt.violations],
|
|
432
|
+
coverage: { status: 'checked', owner: owner.coverage, jwt: jwt.coverage },
|
|
433
|
+
};
|
|
434
|
+
}
|