@starci/hfs 4.2.12 → 4.2.14
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 +26 -1
- package/README.md +7 -3
- package/emit/db-types.mjs +2 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
- package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
- package/runtime/knowledge/hfs/rules.yaml +51 -48
- package/runtime/knowledge/hfs/slots.yaml +319 -179
- package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
- package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
- package/runtime/knowledge/patterns/be/projections.yaml +10 -0
- package/runtime/knowledge/patterns/be/queues.yaml +15 -0
- package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
- package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
- package/runtime/knowledge/patterns/be/saga.yaml +13 -0
- package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
- package/runtime/knowledge/sonar-gate.yaml +10 -2
- package/runtime/modules/kernel/failure-codes.yaml +40 -60
- package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
- package/runtime/scripts/hfs/architecture/config.mjs +3 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
- package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
- package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
- package/runtime/scripts/hfs/architecture/index.mjs +2 -0
- package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
- package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
- package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
- package/runtime/scripts/hfs/check.mjs +10 -6
- package/runtime/scripts/hfs/edition-slots.mjs +13 -4
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
- package/runtime/scripts/hfs/rules/database.mjs +4 -3
- package/runtime/scripts/hfs/rules/edition.mjs +49 -37
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
- package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
- package/scaffold/add-app.mjs +24 -18
- package/scaffold/add-cli-lite.mjs +33 -3
- package/scaffold/add-inbox-lite.mjs +24 -8
- package/scaffold/add-table.mjs +222 -74
- package/scaffold/add.mjs +85 -35
- package/scaffold/app.mjs +146 -38
- package/scaffold/edition-gate.mjs +21 -2
- package/scaffold/lite-exports.mjs +30 -1
- package/scaffold/service.mjs +5 -8
- package/src/main.mjs +2 -2
- package/sync/index.mjs +19 -15
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
- package/templates/app/codegen-graphql.mjs.partial +34 -0
- package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
- package/templates/app/package-scripts/core.json +1 -0
- package/templates/app/package-scripts-lite/package.json +4 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton/scripts/codegen.mjs +4 -1
- package/templates/app/skeleton-lite/README.md +7 -4
- package/templates/app/skeleton-lite/supabase/config.toml +1 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
- package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
- package/templates/be/inbox/service.spec.ts.tpl +46 -0
- package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
- package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
- package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
- package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
- package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
- package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
- package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
- package/templates/be/platform/graphql/graphql.module.ts +49 -0
- package/templates/be/platform/graphql/graphql.options.ts +2 -0
- package/templates/be/platform/graphql/index.ts +1 -0
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
- package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
- package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/table/api/create.command.ts.tpl +10 -0
- package/templates/be/table/api/create.contracts.ts.tpl +8 -0
- package/templates/be/table/api/create.handler.ts.tpl +22 -0
- package/templates/be/table/api/create.mapper.ts.tpl +5 -0
- package/templates/be/table/api/create.resolver.ts.tpl +21 -0
- package/templates/be/table/api/create.type.ts.tpl +9 -0
- package/templates/be/table/api/feature.index.ts.tpl +1 -0
- package/templates/be/table/api/feature.module.ts.tpl +7 -0
- package/templates/be/table/api/get.contracts.ts.tpl +11 -0
- package/templates/be/table/api/get.handler.ts.tpl +22 -0
- package/templates/be/table/api/get.input.ts.tpl +12 -0
- package/templates/be/table/api/get.mapper.ts.tpl +9 -0
- package/templates/be/table/api/get.query.ts.tpl +10 -0
- package/templates/be/table/api/get.resolver.ts.tpl +27 -0
- package/templates/be/table/api/get.type.ts.tpl +9 -0
- package/templates/be/table/api/graphql.module.ts.tpl +8 -0
- package/templates/be/table/error.api.ts.tpl +19 -0
- package/templates/be/table/migration.sql.tpl +13 -21
- package/templates/be/table/service.api.ts.tpl +30 -0
- package/templates/be/table/sql.api.ts.tpl +7 -0
- package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
- package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
- package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
- package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
- package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
- package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
- package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
- package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
- package/templates/fe/table/graphql/query.ts.tpl +25 -0
- package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
- package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
- package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
- package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
- package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
- package/templates/fe/transport/graphql/clients/index.ts +2 -0
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
- package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
- package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
- package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
- package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
- package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
- package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
- package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
- package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
- package/templates/fe/transport/graphql/clients/response.ts +43 -0
- package/upgrade/check.mjs +1 -1
- package/upgrade/index.mjs +3 -2
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
- package/templates/app/quality-config-lite/sonar-project.properties +0 -5
- package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
- package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
- package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
- package/templates/be/patterns/api/http.module.ts.tpl +0 -7
- package/templates/be/patterns/api/http.request.ts.tpl +0 -10
- package/templates/be/patterns/api/http.response.ts.tpl +0 -5
- package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
- package/templates/fe/table/read.ts.tpl +0 -16
- package/templates/fe/table/write.ts.tpl +0 -16
- /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
- /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
- /package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/component.tsx +0 -0
|
@@ -43,7 +43,8 @@ export const HFS_RULE_IDS = [
|
|
|
43
43
|
|
|
44
44
|
const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
|
|
45
45
|
const HUSKY_HOOKS_PATH = /^\.husky(?:\/_)?\/?$/u;
|
|
46
|
-
|
|
46
|
+
/** The folders of a back end's src/ the slot manifest knows (features, modules, tests, assets): read from the be slot paths, never listed here. */
|
|
47
|
+
const backendSrcChildrenOf = resolver => new Set(resolver.slots().filter(slot => slot.profiles.includes('be')).flatMap(slot => braceVariants(slot.path)).map(variant => /^src\/([a-z][a-z-]*)\//.exec(variant)?.[1]).filter(Boolean));
|
|
47
48
|
const moduleTiersOf = resolver => new Set(resolver.slots().filter(slot => slot.profiles.includes('be')).flatMap(slot => braceVariants(slot.path)).map(variant => /^src\/modules\/([a-z][a-z-]*)\//.exec(variant)?.[1]).filter(Boolean)); // the folders of src/modules/ the slot manifest knows (domain, platform, integrations, events, queues, projections)
|
|
48
49
|
// Owner test layout 2026-09-30: unit `<name>.spec.ts`, integration, e2e and contract by folder and suffix; int-spec and harness-spec stay banned.
|
|
49
50
|
const UNSUPPORTED_TEST_SUFFIX = /\.(?:int|harness)-spec\.[cm]?[jt]sx?$/u;
|
|
@@ -274,9 +275,11 @@ function checkApplications(state) {
|
|
|
274
275
|
function checkBackendSourceLayout(state) {
|
|
275
276
|
if (!state.backend) return;
|
|
276
277
|
const { tree, resolver, finding } = state;
|
|
278
|
+
const srcChildren = backendSrcChildrenOf(resolver);
|
|
279
|
+
const srcList = [...srcChildren].sort(byCodeUnit).map(name => name + '/').join(', ');
|
|
277
280
|
for (const child of tree.children('src').sort(byCodeUnit)) {
|
|
278
|
-
if (!
|
|
279
|
-
finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only
|
|
281
|
+
if (!srcChildren.has(child))
|
|
282
|
+
finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only ${srcList}; ${child} must move to its owner.`);
|
|
280
283
|
}
|
|
281
284
|
for (const tier of tree.children('src/modules').sort(byCodeUnit)) {
|
|
282
285
|
if (!moduleTiersOf(resolver).has(tier))
|
|
@@ -48,6 +48,7 @@ import { checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS } from './fe-slot-allows.mjs
|
|
|
48
48
|
import { checkI18nKeys, I18N_KEYS_RULE_IDS } from './i18n-keys.mjs';
|
|
49
49
|
import { checkDocLanguage, DOC_LANGUAGE_RULE_IDS } from './doc-language.mjs';
|
|
50
50
|
import { checkBackendSupabase, checkFrontendSupabase, BACKEND_SUPABASE_RULE_IDS, FRONTEND_SUPABASE_RULE_IDS } from './supabase.mjs';
|
|
51
|
+
import { checkServerActions, SERVER_ACTION_RULE_IDS } from './server-action.mjs';
|
|
51
52
|
import { LINT_CODES, onLintSurface } from './surface.mjs';
|
|
52
53
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
53
54
|
|
|
@@ -98,6 +99,7 @@ const FRONTEND_MACHINE = {
|
|
|
98
99
|
feSlotAllows: [checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS],
|
|
99
100
|
i18nKeys: [checkI18nKeys, I18N_KEYS_RULE_IDS],
|
|
100
101
|
supabaseFrontend: [checkFrontendSupabase, FRONTEND_SUPABASE_RULE_IDS],
|
|
102
|
+
serverActions: [checkServerActions, SERVER_ACTION_RULE_IDS],
|
|
101
103
|
};
|
|
102
104
|
|
|
103
105
|
/** Errors the machine reports when a program cannot be built or an edge cannot be proven: it cannot judge, so it refuses. */
|
|
@@ -2,17 +2,23 @@ import path from 'node:path';
|
|
|
2
2
|
import { canonical, isInside } from './config.mjs';
|
|
3
3
|
import { relativePath, workspaceExportSources } from './typescript.mjs';
|
|
4
4
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { globExpression } from '../../lib/glob.mjs';
|
|
6
|
+
import { posixPath } from '../../lib/path-key.mjs';
|
|
6
7
|
|
|
7
8
|
function absolute(root, relative) {
|
|
8
9
|
return canonical(path.resolve(root, ...relative.split('/')));
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The extra public entries the slot manifest declares for the owner (slot field `entries`, on the slot that holds its entry file):
|
|
14
|
+
* exact files as absolute paths, and the entries holding a `*` (one file per operation, `graphql/queries/query-*.ts`) as patterns.
|
|
15
|
+
*/
|
|
12
16
|
function slotEntries(config, owner) {
|
|
13
17
|
const found = config.hfs?.classifyPath?.(owner.entry);
|
|
14
18
|
const names = found?.slot ? config.hfs.slot(found.slot)?.entries ?? [] : [];
|
|
15
|
-
|
|
19
|
+
const exact = names.filter(name => !name.includes('*')).map(name => absolute(config.root, `${found.root}/${name}`));
|
|
20
|
+
const patterns = names.filter(name => name.includes('*')).map(name => globExpression(`${posixPath(absolute(config.root, found.root))}/${name}`));
|
|
21
|
+
return { exact, patterns };
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/** The owner's public entries: its declared entry, the `entries` its slot declares, plus every source file its package.json `exports` maps when the owner is a workspace package. */
|
|
@@ -21,9 +27,10 @@ function ownerDeclarations(config, context) {
|
|
|
21
27
|
const root = absolute(config.root, owner.root);
|
|
22
28
|
const entry = absolute(config.root, owner.entry);
|
|
23
29
|
const workspace = context.workspaces?.find(item => item.root === root);
|
|
24
|
-
const declared = slotEntries(config, owner);
|
|
30
|
+
const { exact: declared, patterns } = slotEntries(config, owner);
|
|
25
31
|
const entries = new Set([entry, ...declared, ...(workspace ? workspaceExportSources(context.ts, workspace) : [])]);
|
|
26
|
-
|
|
32
|
+
const isEntry = file => entries.has(file) || patterns.some(pattern => pattern.test(posixPath(file)));
|
|
33
|
+
return { ...owner, root, entry, entries, isEntry, declared };
|
|
27
34
|
}).sort((a, b) => b.root.length - a.root.length);
|
|
28
35
|
}
|
|
29
36
|
|
|
@@ -35,7 +42,7 @@ function reexportSteps(context, current) {
|
|
|
35
42
|
return (context.edges.get(current.file) ?? []).filter(candidate => candidate.reexport).map(candidate => ({ file: candidate.to, chain: [...current.chain, candidate.to] }));
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
function privateOwnerChain(context, owners,
|
|
45
|
+
function privateOwnerChain(context, owners, edge) {
|
|
39
46
|
const sourceOwner = ownerOf(owners, edge.from);
|
|
40
47
|
const queue = [{ file: edge.to, chain: [edge.from, edge.to] }];
|
|
41
48
|
const visited = new Set();
|
|
@@ -44,7 +51,7 @@ function privateOwnerChain(context, owners, actionEntries, edge) {
|
|
|
44
51
|
if (visited.has(current.file)) continue;
|
|
45
52
|
visited.add(current.file);
|
|
46
53
|
const owner = ownerOf(owners, current.file);
|
|
47
|
-
if (owner && sourceOwner?.id !== owner.id) return owner.
|
|
54
|
+
if (owner && sourceOwner?.id !== owner.id) return owner.isEntry(current.file) ? null : { owner, chain: current.chain };
|
|
48
55
|
queue.push(...reexportSteps(context, current));
|
|
49
56
|
}
|
|
50
57
|
return null;
|
|
@@ -61,7 +68,7 @@ function arrangesSchema(config, from, to) {
|
|
|
61
68
|
&& resolver.classifyPath(relativePath(config.root, to)).slot === 'be.persistence';
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
function publicEntryViolations(config, context, owners, sourceFiles
|
|
71
|
+
function publicEntryViolations(config, context, owners, sourceFiles) {
|
|
65
72
|
const violations = [];
|
|
66
73
|
for (const owner of owners) {
|
|
67
74
|
const sourceFile = sourceFiles.get(owner.entry);
|
|
@@ -73,7 +80,6 @@ function publicEntryViolations(config, context, owners, sourceFiles, actionEntri
|
|
|
73
80
|
const publicFiles = new Set([
|
|
74
81
|
sourceFile,
|
|
75
82
|
...owner.declared.map(file => sourceFiles.get(file)).filter(Boolean),
|
|
76
|
-
...[...actionEntries].filter(file => isInside(owner.root, file)).map(file => sourceFiles.get(file)).filter(Boolean),
|
|
77
83
|
]);
|
|
78
84
|
for (const entryFile of publicFiles) {
|
|
79
85
|
for (const statement of entryFile.statements) if (context.ts.isExportDeclaration(statement) && !statement.exportClause) {
|
|
@@ -90,12 +96,12 @@ function publicEntryViolations(config, context, owners, sourceFiles, actionEntri
|
|
|
90
96
|
return violations;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
function ownerImportViolations(config, context, owners
|
|
99
|
+
function ownerImportViolations(config, context, owners) {
|
|
94
100
|
const violations = [];
|
|
95
101
|
for (const sourceFile of context.files) {
|
|
96
102
|
const from = canonical(sourceFile.fileName);
|
|
97
103
|
for (const edge of context.edges.get(from) ?? []) {
|
|
98
|
-
const bypass = privateOwnerChain(context, owners,
|
|
104
|
+
const bypass = privateOwnerChain(context, owners, edge);
|
|
99
105
|
if (!bypass) continue;
|
|
100
106
|
if (arrangesSchema(config, from, bypass.chain.at(-1))) continue;
|
|
101
107
|
violations.push({
|
|
@@ -119,11 +125,8 @@ export function checkOwners(config, context) {
|
|
|
119
125
|
if (!config.owners?.length) return [];
|
|
120
126
|
const owners = ownerDeclarations(config, context);
|
|
121
127
|
const sourceFiles = new Map(context.files.map(file => [canonical(file.fileName), file]));
|
|
122
|
-
const actionEntries = new Set(config.kinds.includes('frontend')
|
|
123
|
-
? [...sourceFiles].filter(([, file]) => isServerActionModule(context.ts, file)).map(([name]) => name)
|
|
124
|
-
: []);
|
|
125
128
|
return [
|
|
126
|
-
...publicEntryViolations(config, context, owners, sourceFiles
|
|
127
|
-
...ownerImportViolations(config, context, owners
|
|
129
|
+
...publicEntryViolations(config, context, owners, sourceFiles),
|
|
130
|
+
...ownerImportViolations(config, context, owners),
|
|
128
131
|
];
|
|
129
132
|
}
|
|
@@ -24,6 +24,9 @@ const HOOK_ROUTES = new Set([...MOUNTING_ROUTES, 'page']);
|
|
|
24
24
|
const HOOK_NAME = /^use[A-Z0-9]/u;
|
|
25
25
|
const HOOKS_SLOT = 'fe.hooks';
|
|
26
26
|
|
|
27
|
+
/** The client providers (fe.components.providers) wrap the mounted owner like a package shell; they are not a second owner. */
|
|
28
|
+
const PROVIDERS_SLOT = 'fe.components.providers';
|
|
29
|
+
const isFeatureOwner = target => target?.tier === 'feature' && target.slot !== PROVIDERS_SLOT;
|
|
27
30
|
const isFunction = (ts, node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node);
|
|
28
31
|
const jsxOf = (ts, node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
29
32
|
|
|
@@ -68,7 +71,7 @@ function reportRoute(violations, kit, file, stem, node, message, extra = {}) {
|
|
|
68
71
|
function inspectMountingTag(tag, { graph, kit, ts, file, stem, checker, violations, state }) {
|
|
69
72
|
if (ts.isIdentifier(tag) && /^[a-z]/u.test(tag.text)) {
|
|
70
73
|
reportRoute(violations, kit, file, stem, tag, `<${tag.text}> is drawing in a ${stem} route file; a route file mounts one feature and draws nothing. Move the markup into the feature.`);
|
|
71
|
-
} else if (kit.declarationsOf(checker, ts.isPropertyAccessExpression(tag) ? tag.name : tag).some(declaration => graph.files.get(kit.graphPath(declaration) ?? '')
|
|
74
|
+
} else if (kit.declarationsOf(checker, ts.isPropertyAccessExpression(tag) ? tag.name : tag).some(declaration => isFeatureOwner(graph.files.get(kit.graphPath(declaration) ?? '')))) {
|
|
72
75
|
state.owners += 1;
|
|
73
76
|
}
|
|
74
77
|
}
|
|
@@ -1,24 +1,79 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The front end serves no API (owner ruling 2026-10-13, both editions, every app): it calls the back end through Apollo GraphQL and
|
|
5
|
+
* axios in modules/api and never runs server code of its own.
|
|
6
|
+
*
|
|
7
|
+
* - FE_SERVER_ACTION_FORBIDDEN: a `"use server"` directive anywhere in a front-end source file, in the file's directive prologue
|
|
8
|
+
* (a Server Action module) or in the prologue of any function body (an inline Server Action), apps and packages alike;
|
|
9
|
+
* - FE_ROUTE_HANDLER_FORBIDDEN: a route handler (`route.ts`, `route.js` and their module variants) under `apps/<app>/src/app/` other than the
|
|
10
|
+
* liveness probe `health/live/route.ts` and the file the slot `fe.route.callback` owns (the Supabase auth code exchange).
|
|
11
|
+
*
|
|
12
|
+
* The check is provider-independent: it runs for every front-end repository.
|
|
13
|
+
*/
|
|
14
|
+
export const SERVER_ACTION_RULE_IDS = Object.freeze(['FE_SERVER_ACTION_FORBIDDEN', 'FE_ROUTE_HANDLER_FORBIDDEN']);
|
|
15
|
+
|
|
16
|
+
const SERVER_ACTION = 'FE_SERVER_ACTION_FORBIDDEN';
|
|
17
|
+
const ROUTE_HANDLER = 'FE_ROUTE_HANDLER_FORBIDDEN';
|
|
18
|
+
const CALLBACK_SLOT = 'fe.route.callback';
|
|
19
|
+
const ROUTE_FILE = /^apps\/([^/]+)\/src\/app\/(?:.+\/)?route\.[cm]?[jt]s$/u;
|
|
20
|
+
|
|
21
|
+
/** The statement nodes of a directive prologue: the leading string-literal expression statements. */
|
|
22
|
+
function prologue(ts, statements) {
|
|
23
|
+
const directives = [];
|
|
24
|
+
for (const statement of statements ?? []) {
|
|
25
|
+
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) break;
|
|
26
|
+
directives.push(statement);
|
|
27
|
+
}
|
|
28
|
+
return directives;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const useServerIn = (ts, statements) => prologue(ts, statements).find(statement => statement.expression.text === 'use server') ?? null;
|
|
32
|
+
|
|
33
|
+
/** Every `"use server"` directive of one source file: the file-level one and each function-level one. */
|
|
34
|
+
function serverDirectives(ts, sourceFile) {
|
|
35
|
+
const found = [];
|
|
36
|
+
const fileLevel = useServerIn(ts, sourceFile.statements);
|
|
37
|
+
if (fileLevel) found.push({ node: fileLevel, level: 'file' });
|
|
38
|
+
const visit = node => {
|
|
39
|
+
// a function with a block body: declaration, expression, arrow, method, constructor or accessor
|
|
40
|
+
if (ts.isFunctionLike(node) && node.body && ts.isBlock(node.body)) {
|
|
41
|
+
const inline = useServerIn(ts, node.body.statements);
|
|
42
|
+
if (inline) found.push({ node: inline, level: 'function' });
|
|
43
|
+
}
|
|
44
|
+
ts.forEachChild(node, visit);
|
|
45
|
+
};
|
|
46
|
+
ts.forEachChild(sourceFile, visit);
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function serverActionViolations(ts, file) {
|
|
51
|
+
return serverDirectives(ts, file.sourceFile).map(({ node, level }) => ({
|
|
52
|
+
ruleId: SERVER_ACTION, path: file.rel, ...sourceLocation(file.sourceFile, node), level,
|
|
53
|
+
message: `${file.rel} declares a ${level}-level "use server" directive: the front end runs no Server Action. Call the back end through a modules/api GraphQL operation (or a rest call) from an SWR hook, and move the server work into a be feature.`,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function routeHandlerViolation(file) {
|
|
58
|
+
const match = ROUTE_FILE.exec(file.rel);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
if (file.rel === `apps/${match[1]}/src/app/health/live/route.ts` || file.slot === CALLBACK_SLOT) return null;
|
|
61
|
+
return {
|
|
62
|
+
ruleId: ROUTE_HANDLER, path: file.rel, line: 1, column: 1,
|
|
63
|
+
message: `${file.rel} is a front-end route handler outside health/live/route.ts and the ${CALLBACK_SLOT} slot: the front end serves no API. Webhooks, cron, public APIs and third-party callbacks belong to be features; the browser reaches the back end through modules/api.`,
|
|
64
|
+
};
|
|
14
65
|
}
|
|
15
66
|
|
|
16
|
-
/**
|
|
17
|
-
export function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
67
|
+
/** The FE machine check of both bans over every source file of the front-end program. */
|
|
68
|
+
export function checkServerActions({ graph, context }) {
|
|
69
|
+
const ts = context.ts;
|
|
70
|
+
const violations = [];
|
|
71
|
+
let routes = 0;
|
|
72
|
+
for (const file of graph.files.values()) {
|
|
73
|
+
violations.push(...serverActionViolations(ts, file));
|
|
74
|
+
const route = routeHandlerViolation(file);
|
|
75
|
+
if (ROUTE_FILE.test(file.rel)) routes += 1;
|
|
76
|
+
if (route) violations.push(route);
|
|
22
77
|
}
|
|
23
|
-
return
|
|
78
|
+
return { violations, coverage: { status: 'checked', files: graph.files.size, routes } };
|
|
24
79
|
}
|
|
@@ -34,7 +34,7 @@ const typeFromSupabase = (checker, node) => {
|
|
|
34
34
|
return false;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const signatureFromSupabase = (checker, call) => {
|
|
38
38
|
try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
|
|
39
39
|
};
|
|
40
40
|
|
|
@@ -45,7 +45,7 @@ export const unwrap = (ts, node) => {
|
|
|
45
45
|
return current;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
const isSupabaseValue = (kit, checker, node) => {
|
|
49
49
|
const current = unwrap(kit.ts, node);
|
|
50
50
|
if (!current) return false;
|
|
51
51
|
if (importedFrom(kit, checker, current, SUPABASE_MODULES)) return true;
|
|
@@ -1,35 +1,27 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { machineKit } from './machine-ast.mjs';
|
|
3
|
-
import {
|
|
4
|
-
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, signatureFromSupabase, supabaseClientType, SUPABASE_MODULES, unwrap } from './supabase-ast.mjs';
|
|
2
|
+
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, supabaseClientType, SUPABASE_MODULES } from './supabase-ast.mjs';
|
|
5
3
|
import { checkBackendJwt } from './supabase-be.mjs';
|
|
6
|
-
import { checkFrontendQueryResults, FE_DB_SLOTS, sameSymbol, symbolOf } from './supabase-results.mjs';
|
|
7
4
|
|
|
8
5
|
/**
|
|
9
|
-
* L10-L16, the slot-driven Supabase machine. Supabase is one data transport with one owner
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
6
|
+
* L10-L16, the slot-driven Supabase machine. On the back end Supabase is one data transport with one owner,
|
|
7
|
+
* `be.integrations.supabase`. On the front end Supabase is auth only (owner ruling 2026-10-13): `fe.modules.auth` is the one
|
|
8
|
+
* importer of `@supabase/*` and calls only the auth API; data, storage, rpc and realtime go through the back end (modules/api).
|
|
9
|
+
* The checks below recognise provider calls and types by their imported package declarations, not by local variable or type names.
|
|
12
10
|
*/
|
|
13
|
-
export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze([
|
|
14
|
-
'FE_SUPABASE_CLIENT_OWNER',
|
|
15
|
-
'FE_DB_RESULT_TYPED',
|
|
16
|
-
'FE_DB_ERROR_HANDLED',
|
|
17
|
-
'FE_SERVICE_ROLE_FORBIDDEN',
|
|
18
|
-
'FE_AUTH_SESSION_TRUST',
|
|
19
|
-
'FE_DB_WRITE_SHAPE',
|
|
20
|
-
'FE_ROUTE_HANDLER_FORBIDDEN',
|
|
21
|
-
]);
|
|
11
|
+
export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze(['FE_SUPABASE_CLIENT_OWNER', 'FE_SERVICE_ROLE_FORBIDDEN']);
|
|
22
12
|
export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNER', 'BE_SUPABASE_JWT_VERIFIED']);
|
|
23
13
|
|
|
24
14
|
const FE_CLIENT_OWNER = 'FE_SUPABASE_CLIENT_OWNER';
|
|
25
15
|
const BE_CLIENT_OWNER = 'BE_SUPABASE_CLIENT_OWNER';
|
|
26
16
|
const FE_SERVICE_ROLE = 'FE_SERVICE_ROLE_FORBIDDEN';
|
|
27
|
-
const FE_SESSION_TRUST = 'FE_AUTH_SESSION_TRUST';
|
|
28
|
-
const FE_WRITE_SHAPE = 'FE_DB_WRITE_SHAPE';
|
|
29
|
-
const FE_ROUTE_HANDLER = 'FE_ROUTE_HANDLER_FORBIDDEN';
|
|
30
17
|
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
18
|
+
const FE_AUTH_SLOT = 'fe.modules.auth';
|
|
19
|
+
/** The front-end provider slot: enabled exactly when a connection declares provider supabase. */
|
|
20
|
+
const FE_SUPABASE_PROVIDER_SLOT = 'fe.route.callback';
|
|
31
21
|
const FE_CONFIG_SLOT = 'fe.modules.config';
|
|
32
22
|
const SUPABASE_CALLS = new Set(['from', 'rpc']);
|
|
23
|
+
/** The members of a Supabase client that read or write data, files or channels: never called from the front end. */
|
|
24
|
+
const FE_DATA_MEMBERS = new Set(['from', 'rpc', 'schema', 'storage', 'functions', 'realtime', 'channel', 'getChannels', 'removeChannel', 'removeAllChannels']);
|
|
33
25
|
const supabaseSlotEnabled = (input, slotId) => {
|
|
34
26
|
const slot = input.graph.resolver.slot(slotId);
|
|
35
27
|
return Boolean(slot && input.graph.resolver.slotEnabled(slot));
|
|
@@ -145,209 +137,43 @@ function checkFrontendSecretNames(input) {
|
|
|
145
137
|
return { violations, coverage: { status: 'checked', envReads: state.reads } };
|
|
146
138
|
}
|
|
147
139
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const isDirectiveStatement = (ts, statement) => ts.isExpressionStatement(statement) && ts.isStringLiteralLike(statement.expression);
|
|
152
|
-
|
|
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 => {
|
|
164
|
-
const functionNode = (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))
|
|
165
|
-
&& ts.isBlock(declaration.initializer.body) ? declaration.initializer : null;
|
|
166
|
-
return { name: declaration.name.text, implementation: declaration.initializer, functionNode };
|
|
167
|
-
});
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const exportedValues = (ts, sourceFile) => sourceFile.statements.flatMap(statement => exportedValuesOf(ts, statement));
|
|
171
|
-
|
|
172
|
-
const exportedActions = (ts, sourceFile) => exportedValues(ts, sourceFile).map(value => value.functionNode).filter(Boolean);
|
|
173
|
-
const exportedImplementations = (ts, sourceFile, name) => exportedValues(ts, sourceFile)
|
|
174
|
-
.filter(value => value.name === name).map(value => value.implementation);
|
|
175
|
-
|
|
176
|
-
const contains = (kit, root, predicate) => {
|
|
177
|
-
let found = false;
|
|
178
|
-
kit.walk(root, node => {
|
|
179
|
-
if (predicate(node)) found = true;
|
|
180
|
-
return !found;
|
|
181
|
-
});
|
|
182
|
-
return found;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const principalCall = (kit, checker, graph, node) => kit.ts.isCallExpression(node) && (() => {
|
|
186
|
-
const expression = node.expression;
|
|
187
|
-
const name = kit.ts.isPropertyAccessExpression(expression) ? expression.name : expression;
|
|
188
|
-
if (!kit.ts.isIdentifier(name) || name.text !== 'getPrincipal') return false;
|
|
189
|
-
return kit.declarationsOf(checker, name).some(declaration => {
|
|
190
|
-
const rel = kit.graphPath(declaration);
|
|
191
|
-
return rel !== null && FE_DB_SLOTS.has(graph.files.get(rel)?.slot);
|
|
192
|
-
});
|
|
193
|
-
})();
|
|
194
|
-
|
|
195
|
-
const firstPrincipal = (kit, checker, graph, statement) => {
|
|
196
|
-
let call = null;
|
|
197
|
-
kit.walk(statement, node => {
|
|
198
|
-
if (kit.ts.isAwaitExpression(node) && principalCall(kit, checker, graph, unwrap(kit.ts, node.expression))) call = unwrap(kit.ts, node.expression);
|
|
199
|
-
return call === null;
|
|
200
|
-
});
|
|
201
|
-
if (!call || !kit.ts.isVariableStatement(statement)) return { call, symbol: null };
|
|
202
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
203
|
-
if (kit.ts.isIdentifier(declaration.name) && declaration.initializer && contains(kit, declaration.initializer, node => node === call)) {
|
|
204
|
-
return { call, symbol: symbolOf(kit, checker, declaration.name) };
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return { call, symbol: null };
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const typedRefusal = (kit, node) => contains(kit, node, child => (kit.ts.isStringLiteralLike(child) && child.text === 'refused')
|
|
211
|
-
|| (kit.ts.isIdentifier(child) && /^refused(?:Outcome)?$/iu.test(child.text)));
|
|
212
|
-
|
|
213
|
-
const parseUse = (ts, identifier) => {
|
|
214
|
-
let current = identifier;
|
|
215
|
-
while (current.parent && (ts.isParenthesizedExpression(current.parent) || ts.isAsExpression(current.parent))) current = current.parent;
|
|
216
|
-
const call = current.parent;
|
|
217
|
-
return ts.isCallExpression(call) && call.arguments.includes(current) && ts.isPropertyAccessExpression(call.expression)
|
|
218
|
-
&& ['parse', 'safeParse'].includes(call.expression.name.text);
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const parameterBindings = (ts, name) => {
|
|
222
|
-
if (ts.isIdentifier(name)) return [name];
|
|
223
|
-
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) return name.elements.flatMap(element => ts.isBindingElement(element) ? parameterBindings(ts, element.name) : []);
|
|
224
|
-
return [];
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
function inspectServerSessionCalls(kit, file, checker, violations) {
|
|
140
|
+
function inspectFrontendNode(scope, node) {
|
|
141
|
+
const { kit, checker, file, violations, counters } = scope;
|
|
228
142
|
const { ts } = kit;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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().');
|
|
143
|
+
const moduleName = moduleNameOf(ts, node);
|
|
144
|
+
if (moduleName?.startsWith('@supabase/')) {
|
|
145
|
+
counters.imports += 1;
|
|
146
|
+
if (file.slot !== FE_AUTH_SLOT) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `${moduleName} is imported by ${file.rel}, whose slot is ${file.slot ?? 'unowned'}; on the front end Supabase is auth only and ${FE_AUTH_SLOT} (modules/auth) is its one importer.`, { module: moduleName, slot: file.slot ?? null });
|
|
147
|
+
return;
|
|
246
148
|
}
|
|
149
|
+
if (!ts.isCallExpression(node) || !isSupabaseCall(kit, checker, node)) return;
|
|
150
|
+
counters.calls += 1;
|
|
151
|
+
const member = callName(ts, node);
|
|
152
|
+
const parts = chainParts(ts, node);
|
|
153
|
+
let data = null;
|
|
154
|
+
if (FE_DATA_MEMBERS.has(member)) data = parts.find(name => FE_DATA_MEMBERS.has(name)) ?? member;
|
|
155
|
+
if (data) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase .${data} call is made from the front end; Supabase on the front end is auth only - read and write data, files and realtime through the back end (a modules/api GraphQL operation, an SWR hook, hooks/socketio).`, { member: data, slot: file.slot ?? null });
|
|
156
|
+
else if (file.slot !== FE_AUTH_SLOT && parts.includes('auth')) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase auth call is made from ${file.slot ?? 'an unowned path'}; the auth client is called only inside ${FE_AUTH_SLOT}, which exports the session and the access token.`, { slot: file.slot ?? null });
|
|
247
157
|
}
|
|
248
158
|
|
|
249
|
-
function
|
|
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
|
-
|
|
329
|
-
function checkFrontendAuthAndRoutes(input) {
|
|
159
|
+
function checkFrontendClientOwner(input) {
|
|
330
160
|
const kit = machineKit(input);
|
|
331
|
-
const
|
|
161
|
+
const violations = [];
|
|
162
|
+
const counters = { imports: 0, calls: 0 };
|
|
332
163
|
for (const file of input.graph.files.values()) {
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
inspectPrincipalProvider(kit, file, checker, state.violations);
|
|
336
|
-
inspectWriteModule(input, kit, file, checker, state);
|
|
337
|
-
inspectRouteHandler(input, kit, file, state);
|
|
164
|
+
const scope = { kit, checker: kit.checkerOf(file.sourceFile), file, violations, counters };
|
|
165
|
+
kit.walk(file.sourceFile, node => inspectFrontendNode(scope, node));
|
|
338
166
|
}
|
|
339
|
-
return { violations
|
|
167
|
+
return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [FE_AUTH_SLOT] } };
|
|
340
168
|
}
|
|
341
169
|
|
|
342
170
|
export function checkFrontendSupabase(input) {
|
|
343
|
-
if (!supabaseSlotEnabled(input,
|
|
344
|
-
const owner =
|
|
345
|
-
const results = checkFrontendQueryResults(input);
|
|
171
|
+
if (!supabaseSlotEnabled(input, FE_SUPABASE_PROVIDER_SLOT)) return { violations: [], coverage: { status: 'not-applicable', reason: 'no connection declares provider supabase' } };
|
|
172
|
+
const owner = checkFrontendClientOwner(input);
|
|
346
173
|
const secrets = checkFrontendSecretNames(input);
|
|
347
|
-
const auth = checkFrontendAuthAndRoutes(input);
|
|
348
174
|
return {
|
|
349
|
-
violations: [...owner.violations, ...
|
|
350
|
-
coverage: { status: 'checked', owner: owner.coverage,
|
|
175
|
+
violations: [...owner.violations, ...secrets.violations],
|
|
176
|
+
coverage: { status: 'checked', owner: owner.coverage, secrets: secrets.coverage },
|
|
351
177
|
};
|
|
352
178
|
}
|
|
353
179
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isServerActionModule } from './server-action.mjs';
|
|
2
1
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -102,37 +101,8 @@ const triggerOf = (resolver, file) => {
|
|
|
102
101
|
return owner ? (resolver.slot(owner.slot)?.trigger ?? null) : null;
|
|
103
102
|
};
|
|
104
103
|
|
|
105
|
-
/** The db Server Action an edge targets, or null. */
|
|
106
|
-
const serverActionTarget = (graph, edge) => {
|
|
107
|
-
if (graph.profile !== 'fe') return null;
|
|
108
|
-
const to = graph.resolver.classifyPath(edge.to);
|
|
109
|
-
return to.slot === 'fe.modules.db' && /^write-[^/]+\.ts$/u.test(edge.to.split('/').at(-1))
|
|
110
|
-
&& isServerActionModule(null, graph.files.get(edge.to).sourceFile) ? to : null;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/** A connected block may call a db writer directly when that writer is a file-level Next Server Action. */
|
|
114
|
-
const blockCallsServerAction = (graph, edge) => {
|
|
115
|
-
if (!serverActionTarget(graph, edge)) return false;
|
|
116
|
-
const from = graph.resolver.classifyPath(edge.from);
|
|
117
|
-
return from.slot === 'fe.components' && from.kind === 'blocks' && from.role === 'entry';
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
function reportDirectServerAction(graph, edge, verdict, actionTarget, tierRule, counts, violations) {
|
|
121
|
-
if (!(verdict.allowed || verdict.reason === 'notPublicEntry') || !actionTarget
|
|
122
|
-
|| graph.unit(edge.from) === graph.unit(edge.to) || blockCallsServerAction(graph, edge)) return false;
|
|
123
|
-
counts.tierDirection += 1;
|
|
124
|
-
violations.push({
|
|
125
|
-
ruleId: tierRule,
|
|
126
|
-
path: edge.from, line: edge.line, column: edge.column,
|
|
127
|
-
specifier: edge.specifier, resolvedPath: edge.to, typeOnly: !edge.runtime,
|
|
128
|
-
fromTier: graph.resolver.tierOf(edge.from), toTier: graph.resolver.tierOf(edge.to),
|
|
129
|
-
message: `${edge.from} -> ${edge.to}: a db Server Action is called directly only by a connected block entry; hooks and other tiers use their declared data path${edge.runtime ? '' : ' (type-only imports count)'}.`,
|
|
130
|
-
});
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
104
|
function reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations) {
|
|
135
|
-
if (verdict.allowed ||
|
|
105
|
+
if (verdict.allowed || !REASON_TEXT[verdict.reason]) return;
|
|
136
106
|
counts[verdict.reason] += 1;
|
|
137
107
|
const featureToFeature = graph.profile === 'be' && verdict.reason === 'tierDirection' && verdict.fromTier === 'feature' && verdict.toTier === 'feature';
|
|
138
108
|
const fromKind = featureToFeature ? triggerOf(graph.resolver, edge.from) : null;
|
|
@@ -162,8 +132,6 @@ function inspectTierEdge(graph, edge, tierRule, counts, violations, coverage) {
|
|
|
162
132
|
return;
|
|
163
133
|
}
|
|
164
134
|
coverage.edgesChecked += 1;
|
|
165
|
-
const actionTarget = serverActionTarget(graph, edge);
|
|
166
|
-
if (reportDirectServerAction(graph, edge, verdict, actionTarget, tierRule, counts, violations)) return;
|
|
167
135
|
reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
|
|
168
136
|
}
|
|
169
137
|
|