@starci/eslint-canon-be 3.0.8 → 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 +12 -6
- 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/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 +3 -1
- package/project-graph.mjs +5 -2
- 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/knowledge/hfs/canon-pins.yaml +160 -29
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +912 -120
- package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
- package/runtime/knowledge/sonar-gate.yaml +52 -18
- 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 +16 -26
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
- package/runtime/scripts/hfs/architecture/config.mjs +9 -6
- package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
- 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/hfs/architecture/contract-fixture-guard.mjs +4 -10
- package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
- package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +17 -4
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
- package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
- package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
- package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
- package/runtime/scripts/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/hfs/architecture/source-names.mjs +27 -147
- package/runtime/scripts/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/hfs/architecture/surface.mjs +9 -3
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
- package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
- 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 +109 -32
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/hfs/path-findings.mjs +1 -1
- package/runtime/scripts/hfs/project-graph.mjs +3 -3
- package/runtime/scripts/hfs/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
- package/runtime/scripts/hfs/rules/read.mjs +1 -1
- package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +78 -108
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
- package/runtime/scripts/hfs/view.mjs +3 -2
- 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 +24 -5
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/path-key.mjs +57 -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/lib/walk.mjs +4 -3
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +1 -1
- 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/test-secrets.mjs +0 -116
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { HOLE, tokenizeSql } from './sql-tokens.mjs';
|
|
3
|
+
import { machineKit } from './machine-ast.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* R181 `sql-returning` (BE_SQL_RETURNING_SHAPE). TypeORM's `EntityManager.query` returns an UPDATE or DELETE with RETURNING as
|
|
7
|
+
* `[rows, affectedCount]`, not as rows (an INSERT ... RETURNING returns rows), so a caller that reads the result as rows silently reads
|
|
8
|
+
* the wrong thing and no unit spec that stubs the SQL can see it. Every `sql` tagged template (the tag declared in platform/database) of a
|
|
9
|
+
* `<name>.sql.ts` file in a capability's `persistence/` is read with the tokenizer of sql-tokens.mjs, split into statements at top-level
|
|
10
|
+
* semicolons, and a statement that STARTS with UPDATE or DELETE and carries RETURNING at the top level is refused: write it as
|
|
11
|
+
* `WITH changed AS (UPDATE ... RETURNING ...) SELECT ... FROM changed`, which returns rows. A RETURNING inside a parenthesised CTE is the
|
|
12
|
+
* wrapped form and passes; so does INSERT ... RETURNING and any statement without RETURNING.
|
|
13
|
+
*/
|
|
14
|
+
export const SQL_RETURNING_RULE_IDS = ['BE_SQL_RETURNING_SHAPE'];
|
|
15
|
+
|
|
16
|
+
const RULE = 'BE_SQL_RETURNING_SHAPE';
|
|
17
|
+
|
|
18
|
+
/** The leading verbs of the top-level statements of `text` that RETURN from an UPDATE or DELETE without a wrapping SELECT. */
|
|
19
|
+
function unwrappedReturning(text) {
|
|
20
|
+
const found = [];
|
|
21
|
+
let depth = 0;
|
|
22
|
+
let verb = null;
|
|
23
|
+
let returning = false;
|
|
24
|
+
const close = () => { if (verb && returning) found.push(verb); verb = null; returning = false; };
|
|
25
|
+
for (const token of tokenizeSql(text)) {
|
|
26
|
+
if (token.t === 'punct') {
|
|
27
|
+
if (token.v === '(') depth += 1;
|
|
28
|
+
else if (token.v === ')') depth = Math.max(0, depth - 1);
|
|
29
|
+
else if (token.v === ';' && depth === 0) close();
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (depth !== 0 || token.t !== 'word') continue;
|
|
33
|
+
if (verb === null) verb = token.up;
|
|
34
|
+
else if (token.up === 'RETURNING') returning = true;
|
|
35
|
+
}
|
|
36
|
+
close();
|
|
37
|
+
return found.filter(verb => verb === 'UPDATE' || verb === 'DELETE');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function checkSqlReturning(input) {
|
|
41
|
+
const { graph } = input;
|
|
42
|
+
const kit = machineKit(input);
|
|
43
|
+
const { ts } = kit;
|
|
44
|
+
const violations = [];
|
|
45
|
+
let templates = 0;
|
|
46
|
+
for (const file of graph.files.values()) {
|
|
47
|
+
if (file.slot !== 'be.persistence' || !path.posix.basename(file.rel).endsWith('.sql.ts') || !file.owner) continue;
|
|
48
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
49
|
+
kit.walk(file.sourceFile, node => {
|
|
50
|
+
if (!ts.isTaggedTemplateExpression(node)) return true;
|
|
51
|
+
const home = kit.declarationsOf(checker, node.tag).map(kit.ownerOfDeclaration).find(Boolean);
|
|
52
|
+
if (!home || home.tier !== 'platform' || home.name !== 'database') return true;
|
|
53
|
+
templates += 1;
|
|
54
|
+
const template = node.template;
|
|
55
|
+
const text = ts.isNoSubstitutionTemplateLiteral(template) ? template.text
|
|
56
|
+
: template.head.text + template.templateSpans.map(span => HOLE + span.literal.text).join('');
|
|
57
|
+
for (const verb of unwrappedReturning(text)) {
|
|
58
|
+
violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message: `${verb} ... RETURNING in ${file.rel} is returned by EntityManager.query as [rows, affectedCount], not as rows, so a caller reading rows reads the wrong value. Wrap it: WITH changed AS (${verb} ... RETURNING ...) SELECT ... FROM changed.`, verb });
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return { violations, coverage: { status: 'checked', templates } };
|
|
64
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// supabase-ast.mjs - shared TypeScript-symbol and expression helpers for the front-end and back-end Supabase
|
|
2
|
+
// architecture checks. Recognition follows imported declarations and inferred types, never local spelling alone.
|
|
3
|
+
export const SUPABASE_MODULES = new Set(['@supabase/ssr', '@supabase/supabase-js']);
|
|
4
|
+
|
|
5
|
+
export const moduleNameOf = (ts, node) => {
|
|
6
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) return node.moduleSpecifier.text;
|
|
7
|
+
if (!ts.isCallExpression(node)) return null;
|
|
8
|
+
const dynamic = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
9
|
+
const required = ts.isIdentifier(node.expression) && node.expression.text === 'require';
|
|
10
|
+
return (dynamic || required) && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0]) ? node.arguments[0].text : null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const importedFrom = (kit, checker, node, modules, names = null) => {
|
|
14
|
+
const binding = kit.importBinding(checker, node);
|
|
15
|
+
return Boolean(binding && modules.has(binding.module) && (names === null || names.has(binding.name)));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const declarationFromSupabase = (declaration) => /(?:^|\/)node_modules\/@supabase\/(?:ssr|supabase-js|auth-js|postgrest-js|storage-js)(?:\/|$)/u
|
|
19
|
+
.test(String(declaration?.getSourceFile?.().fileName ?? '').replace(/\\/gu, '/'));
|
|
20
|
+
|
|
21
|
+
const typeFromSupabase = (checker, node) => {
|
|
22
|
+
let type;
|
|
23
|
+
try { type = checker.getTypeAtLocation(node); } catch { return false; }
|
|
24
|
+
const queue = [type];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
while (queue.length) {
|
|
27
|
+
const item = queue.shift();
|
|
28
|
+
if (!item || seen.has(item)) continue;
|
|
29
|
+
seen.add(item);
|
|
30
|
+
if ([item.symbol, item.aliasSymbol].some((symbol) => symbol?.declarations?.some(declarationFromSupabase))) return true;
|
|
31
|
+
if (item.types) queue.push(...item.types);
|
|
32
|
+
if (item.target && item.target !== item) queue.push(item.target);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const signatureFromSupabase = (checker, call) => {
|
|
38
|
+
try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const unwrap = (ts, node) => {
|
|
42
|
+
let current = node;
|
|
43
|
+
while (current && (ts.isParenthesizedExpression(current) || ts.isAwaitExpression(current) || ts.isAsExpression(current)
|
|
44
|
+
|| ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current))) current = current.expression;
|
|
45
|
+
return current;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const isSupabaseValue = (kit, checker, node) => {
|
|
49
|
+
const current = unwrap(kit.ts, node);
|
|
50
|
+
if (!current) return false;
|
|
51
|
+
if (importedFrom(kit, checker, current, SUPABASE_MODULES)) return true;
|
|
52
|
+
if (kit.ts.isCallExpression(current) && signatureFromSupabase(checker, current)) return true;
|
|
53
|
+
return typeFromSupabase(checker, current);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const callName = (ts, call) => ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression) ? call.expression.name.text : null;
|
|
57
|
+
|
|
58
|
+
export const chainParts = (ts, expression) => {
|
|
59
|
+
const names = [];
|
|
60
|
+
let current = unwrap(ts, expression);
|
|
61
|
+
while (current) {
|
|
62
|
+
if (ts.isCallExpression(current)) {
|
|
63
|
+
if (ts.isPropertyAccessExpression(current.expression)) {
|
|
64
|
+
names.unshift(current.expression.name.text);
|
|
65
|
+
current = unwrap(ts, current.expression.expression);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
current = unwrap(ts, current.expression);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (ts.isPropertyAccessExpression(current)) {
|
|
72
|
+
names.unshift(current.name.text);
|
|
73
|
+
current = unwrap(ts, current.expression);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
return names;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const isSupabaseCall = (kit, checker, node) => kit.ts.isCallExpression(node)
|
|
82
|
+
&& (signatureFromSupabase(checker, node) || isSupabaseValue(kit, checker, kit.ts.isPropertyAccessExpression(node.expression) ? node.expression.expression : node.expression));
|
|
83
|
+
|
|
84
|
+
export const isDatabaseType = (ts, node) => {
|
|
85
|
+
if (!node) return false;
|
|
86
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
87
|
+
const name = ts.isIdentifier(node.typeName) ? node.typeName.text : node.typeName.right.text;
|
|
88
|
+
return name === 'Database';
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const supabaseClientType = (kit, checker, node) => {
|
|
94
|
+
if (!node || !kit.ts.isTypeReferenceNode(node)) return false;
|
|
95
|
+
const name = kit.ts.isIdentifier(node.typeName) ? node.typeName : null;
|
|
96
|
+
return Boolean(name && importedFrom(kit, checker, name, SUPABASE_MODULES, new Set(['SupabaseClient'])));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const contextTypesClient = (kit, checker, call) => {
|
|
100
|
+
let parent = call.parent;
|
|
101
|
+
while (parent && (kit.ts.isParenthesizedExpression(parent) || kit.ts.isAsExpression(parent))) {
|
|
102
|
+
if (kit.ts.isAsExpression(parent) && supabaseClientType(kit, checker, parent.type) && isDatabaseType(kit.ts, parent.type.typeArguments?.[0])) return true;
|
|
103
|
+
parent = parent.parent;
|
|
104
|
+
}
|
|
105
|
+
return kit.ts.isVariableDeclaration(parent) && supabaseClientType(kit, checker, parent.type)
|
|
106
|
+
&& isDatabaseType(kit.ts, parent.type.typeArguments?.[0]);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const reportAt = (violations, kit, ruleId, file, node, message, extra = {}) => {
|
|
110
|
+
violations.push({ ruleId, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
111
|
+
};
|
|
@@ -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
|
+
}
|