@starci/hfs 4.0.9 → 4.1.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 +1 -1
- package/LICENSE +21 -0
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +82 -8
- package/package.json +3 -2
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +247 -273
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +102 -69
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -397
- package/runtime/modules/models/runtimes.yaml +92 -262
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +36 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
- package/runtime/scripts/api/process/run-program.mjs +8 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +309 -11
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +90 -45
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/fs-kind.mjs +14 -4
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +42 -9
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +103 -2
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { machineKit } from './machine-ast.mjs';
|
|
3
3
|
import { isServerActionModule } from './server-action.mjs';
|
|
4
|
-
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall,
|
|
4
|
+
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, signatureFromSupabase, supabaseClientType, SUPABASE_MODULES, unwrap } from './supabase-ast.mjs';
|
|
5
5
|
import { checkBackendJwt } from './supabase-be.mjs';
|
|
6
|
+
import { checkFrontendQueryResults, FE_DB_SLOTS, sameSymbol, symbolOf } from './supabase-results.mjs';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* L10-L16, the slot-driven Supabase machine. Supabase is one data transport with one owner on each side:
|
|
@@ -22,193 +23,74 @@ export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNE
|
|
|
22
23
|
|
|
23
24
|
const FE_CLIENT_OWNER = 'FE_SUPABASE_CLIENT_OWNER';
|
|
24
25
|
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
26
|
const FE_SERVICE_ROLE = 'FE_SERVICE_ROLE_FORBIDDEN';
|
|
28
27
|
const FE_SESSION_TRUST = 'FE_AUTH_SESSION_TRUST';
|
|
29
28
|
const FE_WRITE_SHAPE = 'FE_DB_WRITE_SHAPE';
|
|
30
29
|
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
30
|
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
33
31
|
const FE_CONFIG_SLOT = 'fe.modules.config';
|
|
34
32
|
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
33
|
const supabaseSlotEnabled = (input, slotId) => {
|
|
39
34
|
const slot = input.graph.resolver.slot(slotId);
|
|
40
35
|
return Boolean(slot && input.graph.resolver.slotEnabled(slot));
|
|
41
36
|
};
|
|
42
37
|
|
|
43
|
-
|
|
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);
|
|
38
|
+
const CLIENT_FACTORY = /^create(?:Client|ServerClient|BrowserClient)$/u;
|
|
84
39
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
};
|
|
40
|
+
function inspectSupabaseImport(scope, node) {
|
|
41
|
+
const { kit, file, slots, ruleId, violations, counters } = scope;
|
|
42
|
+
const moduleName = moduleNameOf(kit.ts, node);
|
|
43
|
+
if (!moduleName || !SUPABASE_MODULES.has(moduleName)) return;
|
|
44
|
+
counters.imports += 1;
|
|
45
|
+
if (!slots.has(file.slot)) 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 });
|
|
46
|
+
}
|
|
110
47
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
48
|
+
function inspectClientTypeReference(scope, node) {
|
|
49
|
+
const { kit, checker, file, ruleId, violations, counters } = scope;
|
|
50
|
+
if (!kit.ts.isTypeReferenceNode(node) || !supabaseClientType(kit, checker, node)) return;
|
|
51
|
+
counters.clients += 1;
|
|
52
|
+
if (!isDatabaseType(kit.ts, node.typeArguments?.[0])) reportAt(violations, kit, ruleId, file, node, 'SupabaseClient must carry the committed Database type as SupabaseClient<Database>.');
|
|
53
|
+
}
|
|
116
54
|
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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;
|
|
55
|
+
function inspectClientFactoryCall(scope, node) {
|
|
56
|
+
const { kit, checker, file, ruleId, violations, counters } = scope;
|
|
57
|
+
const binding = kit.importBinding(checker, node.expression);
|
|
58
|
+
if (!binding || !SUPABASE_MODULES.has(binding.module) || !CLIENT_FACTORY.test(binding.name)) return;
|
|
59
|
+
counters.clients += 1;
|
|
60
|
+
if (!isDatabaseType(kit.ts, node.typeArguments?.[0]) && !contextTypesClient(kit, checker, node)) {
|
|
61
|
+
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>).`);
|
|
134
62
|
}
|
|
135
|
-
|
|
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
|
-
};
|
|
63
|
+
}
|
|
149
64
|
|
|
150
|
-
|
|
65
|
+
function inspectProviderCall(scope, node) {
|
|
66
|
+
const { kit, checker, file, slots, ruleId, violations, counters } = scope;
|
|
67
|
+
const { ts } = kit;
|
|
68
|
+
if (!isSupabaseCall(kit, checker, node)) return;
|
|
69
|
+
const properties = chainParts(ts, node);
|
|
70
|
+
const method = callName(ts, node);
|
|
71
|
+
if (!SUPABASE_CALLS.has(method) && !properties.includes('auth') && !properties.includes('storage')) return;
|
|
72
|
+
counters.calls += 1;
|
|
73
|
+
if (!slots.has(file.slot)) 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 });
|
|
74
|
+
}
|
|
151
75
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (kit.ts.isVariableDeclaration(variable) && variable.initializer && fromAwaitedSupabase(kit, checker, variable.initializer)) return symbol;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
};
|
|
76
|
+
function inspectOwnershipNode(scope, node) {
|
|
77
|
+
inspectSupabaseImport(scope, node);
|
|
78
|
+
inspectClientTypeReference(scope, node);
|
|
79
|
+
if (!scope.kit.ts.isCallExpression(node)) return;
|
|
80
|
+
inspectClientFactoryCall(scope, node);
|
|
81
|
+
inspectProviderCall(scope, node);
|
|
82
|
+
}
|
|
163
83
|
|
|
164
|
-
function
|
|
84
|
+
function checkClientOwnership(input, { slots, ruleId }) {
|
|
165
85
|
const kit = machineKit(input);
|
|
166
|
-
const { ts } = kit;
|
|
167
86
|
const violations = [];
|
|
168
|
-
|
|
169
|
-
let listReads = 0;
|
|
87
|
+
const counters = { imports: 0, clients: 0, calls: 0 };
|
|
170
88
|
for (const file of input.graph.files.values()) {
|
|
171
89
|
const checker = kit.checkerOf(file.sourceFile);
|
|
172
|
-
const
|
|
173
|
-
|
|
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
|
-
});
|
|
90
|
+
const scope = { kit, checker, file, slots, ruleId, violations, counters };
|
|
91
|
+
kit.walk(file.sourceFile, node => inspectOwnershipNode(scope, node));
|
|
210
92
|
}
|
|
211
|
-
return { violations, coverage: { status: 'checked',
|
|
93
|
+
return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [...slots] } };
|
|
212
94
|
}
|
|
213
95
|
|
|
214
96
|
const words = text => String(text).replace(/([a-z0-9])([A-Z])/gu, '$1_$2').toUpperCase();
|
|
@@ -226,33 +108,41 @@ const envRead = (ts, node) => {
|
|
|
226
108
|
return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
|
|
227
109
|
};
|
|
228
110
|
|
|
111
|
+
/** Reports a SERVICE_ROLE identifier or string literal: the browser and Next process never hold the service role. */
|
|
112
|
+
function inspectServiceRoleText(ts, node, report) {
|
|
113
|
+
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);
|
|
114
|
+
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);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Counts an environment read and reports a credential-shaped or product-specific public variable. */
|
|
118
|
+
function inspectEnvironmentRead(state, file, node, name) {
|
|
119
|
+
state.reads += 1;
|
|
120
|
+
if (credentialEnv(name)) state.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);
|
|
121
|
+
else if (name.startsWith('NEXT_PUBLIC_') && !CORE_PUBLIC_ENV.has(name) && file.slot !== FE_CONFIG_SLOT) {
|
|
122
|
+
state.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);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
229
126
|
function checkFrontendSecretNames(input) {
|
|
230
127
|
const kit = machineKit(input);
|
|
231
128
|
const { ts } = kit;
|
|
232
129
|
const violations = [];
|
|
233
|
-
|
|
130
|
+
const state = { reads: 0, report: null };
|
|
234
131
|
for (const file of input.graph.files.values()) {
|
|
235
132
|
const seen = new Set();
|
|
236
|
-
|
|
133
|
+
state.report = (node, message, name) => {
|
|
237
134
|
const key = `${node.pos}:${name ?? ''}`;
|
|
238
135
|
if (seen.has(key)) return;
|
|
239
136
|
seen.add(key);
|
|
240
137
|
reportAt(violations, kit, FE_SERVICE_ROLE, file, node, message, name ? { name } : {});
|
|
241
138
|
};
|
|
242
139
|
kit.walk(file.sourceFile, node => {
|
|
243
|
-
|
|
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);
|
|
140
|
+
inspectServiceRoleText(ts, node, state.report);
|
|
245
141
|
const name = envRead(ts, node);
|
|
246
|
-
if (
|
|
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;
|
|
142
|
+
if (name) inspectEnvironmentRead(state, file, node, name);
|
|
253
143
|
});
|
|
254
144
|
}
|
|
255
|
-
return { violations, coverage: { status: 'checked', envReads: reads } };
|
|
145
|
+
return { violations, coverage: { status: 'checked', envReads: state.reads } };
|
|
256
146
|
}
|
|
257
147
|
|
|
258
148
|
const isClientModule = (ts, sourceFile) => sourceFile.statements.some((statement, index) => index < 4 && ts.isExpressionStatement(statement)
|
|
@@ -260,24 +150,25 @@ const isClientModule = (ts, sourceFile) => sourceFile.statements.some((statement
|
|
|
260
150
|
|
|
261
151
|
const isDirectiveStatement = (ts, statement) => ts.isExpressionStatement(statement) && ts.isStringLiteralLike(statement.expression);
|
|
262
152
|
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
153
|
+
const hasExportModifier = (ts, node) => node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
154
|
+
|
|
155
|
+
const exportedValuesOf = (ts, statement) => {
|
|
156
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
157
|
+
return hasExportModifier(ts, statement) && statement.name && statement.body
|
|
158
|
+
? [{ name: statement.name.text, implementation: statement.body, functionNode: statement }] : [];
|
|
159
|
+
}
|
|
160
|
+
if (!ts.isVariableStatement(statement) || !hasExportModifier(ts, statement)) return [];
|
|
161
|
+
return statement.declarationList.declarations
|
|
162
|
+
.filter(declaration => ts.isIdentifier(declaration.name) && declaration.initializer)
|
|
163
|
+
.map(declaration => {
|
|
273
164
|
const functionNode = (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))
|
|
274
165
|
&& ts.isBlock(declaration.initializer.body) ? declaration.initializer : null;
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return out;
|
|
166
|
+
return { name: declaration.name.text, implementation: declaration.initializer, functionNode };
|
|
167
|
+
});
|
|
279
168
|
};
|
|
280
169
|
|
|
170
|
+
const exportedValues = (ts, sourceFile) => sourceFile.statements.flatMap(statement => exportedValuesOf(ts, statement));
|
|
171
|
+
|
|
281
172
|
const exportedActions = (ts, sourceFile) => exportedValues(ts, sourceFile).map(value => value.functionNode).filter(Boolean);
|
|
282
173
|
const exportedImplementations = (ts, sourceFile, name) => exportedValues(ts, sourceFile)
|
|
283
174
|
.filter(value => value.name === name).map(value => value.implementation);
|
|
@@ -293,8 +184,8 @@ const contains = (kit, root, predicate) => {
|
|
|
293
184
|
|
|
294
185
|
const principalCall = (kit, checker, graph, node) => kit.ts.isCallExpression(node) && (() => {
|
|
295
186
|
const expression = node.expression;
|
|
296
|
-
const name = kit.ts.
|
|
297
|
-
if (!name || name.text !== 'getPrincipal') return false;
|
|
187
|
+
const name = kit.ts.isPropertyAccessExpression(expression) ? expression.name : expression;
|
|
188
|
+
if (!kit.ts.isIdentifier(name) || name.text !== 'getPrincipal') return false;
|
|
298
189
|
return kit.declarationsOf(checker, name).some(declaration => {
|
|
299
190
|
const rel = kit.graphPath(declaration);
|
|
300
191
|
return rel !== null && FE_DB_SLOTS.has(graph.files.get(rel)?.slot);
|
|
@@ -333,82 +224,119 @@ const parameterBindings = (ts, name) => {
|
|
|
333
224
|
return [];
|
|
334
225
|
};
|
|
335
226
|
|
|
227
|
+
function inspectServerSessionCalls(kit, file, checker, violations) {
|
|
228
|
+
const { ts } = kit;
|
|
229
|
+
if (isClientModule(ts, file.sourceFile)) return;
|
|
230
|
+
kit.walk(file.sourceFile, node => {
|
|
231
|
+
if (ts.isCallExpression(node) && callName(ts, node) === 'getSession' && signatureFromSupabase(checker, node)) {
|
|
232
|
+
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.');
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function inspectPrincipalProvider(kit, file, checker, violations) {
|
|
239
|
+
const { ts } = kit;
|
|
240
|
+
if (!FE_DB_SLOTS.has(file.slot)) return;
|
|
241
|
+
for (const implementation of exportedImplementations(ts, file.sourceFile, 'getPrincipal')) {
|
|
242
|
+
const readsClaims = contains(kit, implementation, node => ts.isCallExpression(node) && callName(ts, node) === 'getClaims'
|
|
243
|
+
&& signatureFromSupabase(checker, node));
|
|
244
|
+
if (!readsClaims) reportAt(violations, kit, FE_SESSION_TRUST, file, implementation,
|
|
245
|
+
'getPrincipal() must establish the server principal through Supabase auth.getClaims().');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function inspectActionDirective(kit, file, action, violations) {
|
|
250
|
+
const { ts } = kit;
|
|
251
|
+
const first = action.body.statements[0];
|
|
252
|
+
const functionLevel = first?.expression?.text === 'use server' && isDirectiveStatement(ts, first);
|
|
253
|
+
if (!isServerActionModule(ts, file.sourceFile) && !functionLevel) {
|
|
254
|
+
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.');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function inspectActionPrincipal(input, kit, file, checker, action, authenticatesAnonymous, violations) {
|
|
259
|
+
const { ts } = kit;
|
|
260
|
+
// Function directives are the prologue, not work: the principal is the first non-directive statement.
|
|
261
|
+
const work = action.body.statements.slice(action.body.statements.findIndex(statement => !isDirectiveStatement(ts, statement)) >>> 0);
|
|
262
|
+
const first = work[0];
|
|
263
|
+
const principal = first ? firstPrincipal(kit, checker, input.graph, first) : { call: null, symbol: null };
|
|
264
|
+
if (!principal.call || !principal.symbol) {
|
|
265
|
+
reportAt(violations, kit, FE_SESSION_TRUST, file, first ?? action, 'A write Server Action must begin with `const principal = await getPrincipal()` from the db owner.');
|
|
266
|
+
} else if (!authenticatesAnonymous) {
|
|
267
|
+
const refuses = work.slice(1).some(statement => ts.isIfStatement(statement)
|
|
268
|
+
&& contains(kit, statement.expression, node => ts.isIdentifier(node) && sameSymbol(kit, checker, node, principal.symbol))
|
|
269
|
+
&& typedRefusal(kit, statement.thenStatement));
|
|
270
|
+
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.');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function inspectActionInputs(kit, file, action, checker, violations) {
|
|
275
|
+
const { ts } = kit;
|
|
276
|
+
for (const parameter of action.parameters.flatMap(item => parameterBindings(ts, item.name))) {
|
|
277
|
+
const symbol = symbolOf(kit, checker, parameter);
|
|
278
|
+
const references = [];
|
|
279
|
+
kit.walk(action.body, node => {
|
|
280
|
+
if (ts.isIdentifier(node) && sameSymbol(kit, checker, node, symbol)) references.push(node);
|
|
281
|
+
return true;
|
|
282
|
+
});
|
|
283
|
+
references.sort((a, b) => a.getStart() - b.getStart());
|
|
284
|
+
const firstUse = references[0];
|
|
285
|
+
if (firstUse && !parseUse(ts, firstUse)) reportAt(violations, kit, FE_WRITE_SHAPE, file, firstUse,
|
|
286
|
+
`Server Action input ${parameter.text} is used before schema.parse(...) or schema.safeParse(...).`, { parameter: parameter.text });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function inspectSensitiveAction(kit, file, checker, action, violations) {
|
|
291
|
+
const { ts } = kit;
|
|
292
|
+
const sensitive = /(?:^|\/)(?:billing|payment|payout|refund|charge|subscription|password|credential|role|permission|membership)[/.-]/iu.test(file.rel);
|
|
293
|
+
if (sensitive && !contains(kit, action.body, node => ts.isCallExpression(node) && callName(ts, node) === 'getUser' && signatureFromSupabase(checker, node))) {
|
|
294
|
+
reportAt(violations, kit, FE_SESSION_TRUST, file, action, 'A money, account-security or role-changing Server Action must call auth.getUser() before the mutation.');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function inspectWriteAction(input, kit, file, checker, action, authenticatesAnonymous, violations) {
|
|
299
|
+
inspectActionDirective(kit, file, action, violations);
|
|
300
|
+
inspectActionPrincipal(input, kit, file, checker, action, authenticatesAnonymous, violations);
|
|
301
|
+
inspectActionInputs(kit, file, action, checker, violations);
|
|
302
|
+
inspectSensitiveAction(kit, file, checker, action, violations);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function inspectWriteModule(input, kit, file, checker, state) {
|
|
306
|
+
const { ts } = kit;
|
|
307
|
+
if (!FE_DB_SLOTS.has(file.slot) || !path.posix.basename(file.rel).startsWith('write-')) return;
|
|
308
|
+
const classified = input.graph.resolver.classifyPath(file.rel);
|
|
309
|
+
const belowSlot = classified.root && file.rel.startsWith(`${classified.root}/`) ? file.rel.slice(classified.root.length + 1) : '';
|
|
310
|
+
const authenticatesAnonymous = (input.graph.resolver.slot(file.slot)?.anonymousActions ?? []).includes(belowSlot);
|
|
311
|
+
const declared = exportedActions(ts, file.sourceFile);
|
|
312
|
+
if (!declared.length) reportAt(state.violations, kit, FE_WRITE_SHAPE, file, file.sourceFile, `${file.rel} is a write module but exports no Server Action function.`);
|
|
313
|
+
for (const action of declared) {
|
|
314
|
+
state.actions += 1;
|
|
315
|
+
inspectWriteAction(input, kit, file, checker, action, authenticatesAnonymous, state.violations);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function inspectRouteHandler(input, kit, file, state) {
|
|
320
|
+
if (!file.rel.endsWith('/route.ts')) return;
|
|
321
|
+
state.routes += 1;
|
|
322
|
+
const classified = input.graph.resolver.classifyPath(file.rel);
|
|
323
|
+
const app = classified.bindings?.app;
|
|
324
|
+
const live = app !== undefined && file.rel === `apps/${app}/src/app/health/live/route.ts`;
|
|
325
|
+
if (!live && file.slot !== 'fe.route.callback') reportAt(state.violations, kit, FE_ROUTE_HANDLER, file, file.sourceFile,
|
|
326
|
+
`${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.`);
|
|
327
|
+
}
|
|
328
|
+
|
|
336
329
|
function checkFrontendAuthAndRoutes(input) {
|
|
337
330
|
const kit = machineKit(input);
|
|
338
|
-
const {
|
|
339
|
-
const violations = [];
|
|
340
|
-
let actions = 0;
|
|
341
|
-
let routes = 0;
|
|
331
|
+
const state = { violations: [], actions: 0, routes: 0 };
|
|
342
332
|
for (const file of input.graph.files.values()) {
|
|
343
333
|
const checker = kit.checkerOf(file.sourceFile);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
}
|
|
334
|
+
inspectServerSessionCalls(kit, file, checker, state.violations);
|
|
335
|
+
inspectPrincipalProvider(kit, file, checker, state.violations);
|
|
336
|
+
inspectWriteModule(input, kit, file, checker, state);
|
|
337
|
+
inspectRouteHandler(input, kit, file, state);
|
|
410
338
|
}
|
|
411
|
-
return { violations, coverage: { status: 'checked', actions, routes } };
|
|
339
|
+
return { violations: state.violations, coverage: { status: 'checked', actions: state.actions, routes: state.routes } };
|
|
412
340
|
}
|
|
413
341
|
|
|
414
342
|
export function checkFrontendSupabase(input) {
|