@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.
Files changed (203) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +7 -3
  3. package/emit/db-types.mjs +2 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
  6. package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
  7. package/runtime/knowledge/hfs/rules.yaml +51 -48
  8. package/runtime/knowledge/hfs/slots.yaml +319 -179
  9. package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
  10. package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
  11. package/runtime/knowledge/patterns/be/projections.yaml +10 -0
  12. package/runtime/knowledge/patterns/be/queues.yaml +15 -0
  13. package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
  14. package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
  15. package/runtime/knowledge/patterns/be/saga.yaml +13 -0
  16. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  17. package/runtime/knowledge/sonar-gate.yaml +10 -2
  18. package/runtime/modules/kernel/failure-codes.yaml +40 -60
  19. package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
  20. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  21. package/runtime/scripts/hfs/architecture/config.mjs +3 -2
  22. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  23. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  24. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  25. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  26. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  27. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  28. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  29. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  30. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  31. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  32. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  33. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  34. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  35. package/runtime/scripts/hfs/check.mjs +10 -6
  36. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  37. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  38. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  39. package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
  40. package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
  41. package/runtime/scripts/hfs/rules/database.mjs +4 -3
  42. package/runtime/scripts/hfs/rules/edition.mjs +49 -37
  43. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  44. package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
  45. package/scaffold/add-app.mjs +24 -18
  46. package/scaffold/add-cli-lite.mjs +33 -3
  47. package/scaffold/add-inbox-lite.mjs +24 -8
  48. package/scaffold/add-table.mjs +222 -74
  49. package/scaffold/add.mjs +85 -35
  50. package/scaffold/app.mjs +146 -38
  51. package/scaffold/edition-gate.mjs +21 -2
  52. package/scaffold/lite-exports.mjs +30 -1
  53. package/scaffold/service.mjs +5 -8
  54. package/src/main.mjs +2 -2
  55. package/sync/index.mjs +19 -15
  56. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
  57. package/templates/app/codegen-graphql.mjs.partial +34 -0
  58. package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
  59. package/templates/app/package-scripts/core.json +1 -0
  60. package/templates/app/package-scripts-lite/package.json +4 -0
  61. package/templates/app/skeleton/README.md +1 -1
  62. package/templates/app/skeleton/scripts/codegen.mjs +4 -1
  63. package/templates/app/skeleton-lite/README.md +7 -4
  64. package/templates/app/skeleton-lite/supabase/config.toml +1 -1
  65. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
  66. package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
  67. package/templates/be/inbox/service.spec.ts.tpl +46 -0
  68. package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
  69. package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
  70. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
  71. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
  72. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
  73. package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
  74. package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
  75. package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
  76. package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
  77. package/templates/be/platform/graphql/graphql.module.ts +49 -0
  78. package/templates/be/platform/graphql/graphql.options.ts +2 -0
  79. package/templates/be/platform/graphql/index.ts +1 -0
  80. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
  81. package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
  82. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
  83. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
  84. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
  85. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
  86. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
  87. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
  88. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
  89. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
  90. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
  91. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
  92. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
  93. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  94. package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
  95. package/templates/be/table/api/create.command.ts.tpl +10 -0
  96. package/templates/be/table/api/create.contracts.ts.tpl +8 -0
  97. package/templates/be/table/api/create.handler.ts.tpl +22 -0
  98. package/templates/be/table/api/create.mapper.ts.tpl +5 -0
  99. package/templates/be/table/api/create.resolver.ts.tpl +21 -0
  100. package/templates/be/table/api/create.type.ts.tpl +9 -0
  101. package/templates/be/table/api/feature.index.ts.tpl +1 -0
  102. package/templates/be/table/api/feature.module.ts.tpl +7 -0
  103. package/templates/be/table/api/get.contracts.ts.tpl +11 -0
  104. package/templates/be/table/api/get.handler.ts.tpl +22 -0
  105. package/templates/be/table/api/get.input.ts.tpl +12 -0
  106. package/templates/be/table/api/get.mapper.ts.tpl +9 -0
  107. package/templates/be/table/api/get.query.ts.tpl +10 -0
  108. package/templates/be/table/api/get.resolver.ts.tpl +27 -0
  109. package/templates/be/table/api/get.type.ts.tpl +9 -0
  110. package/templates/be/table/api/graphql.module.ts.tpl +8 -0
  111. package/templates/be/table/error.api.ts.tpl +19 -0
  112. package/templates/be/table/migration.sql.tpl +13 -21
  113. package/templates/be/table/service.api.ts.tpl +30 -0
  114. package/templates/be/table/sql.api.ts.tpl +7 -0
  115. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
  116. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
  117. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
  118. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
  119. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
  120. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
  121. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
  122. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
  123. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
  124. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
  125. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
  126. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
  127. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
  128. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
  129. package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
  130. package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
  131. package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
  132. package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
  133. package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
  134. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
  135. package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
  136. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +27 -0
  137. package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
  138. package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
  139. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
  140. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
  141. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
  142. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
  143. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
  144. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
  145. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
  146. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
  147. package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
  148. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
  149. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
  150. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
  151. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
  152. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
  153. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
  154. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
  155. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
  156. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
  157. package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
  158. package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
  159. package/templates/fe/table/graphql/query.ts.tpl +25 -0
  160. package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
  161. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
  162. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
  163. package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
  164. package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
  165. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
  166. package/templates/fe/transport/graphql/clients/index.ts +2 -0
  167. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
  168. package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
  169. package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
  170. package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
  171. package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
  172. package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
  173. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
  174. package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
  175. package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
  176. package/templates/fe/transport/graphql/clients/response.ts +43 -0
  177. package/upgrade/check.mjs +1 -1
  178. package/upgrade/index.mjs +3 -2
  179. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
  180. package/templates/app/quality-config-lite/sonar-project.properties +0 -5
  181. package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
  182. package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
  183. package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
  184. package/templates/be/patterns/api/http.module.ts.tpl +0 -7
  185. package/templates/be/patterns/api/http.request.ts.tpl +0 -10
  186. package/templates/be/patterns/api/http.response.ts.tpl +0 -5
  187. package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
  188. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
  189. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
  190. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
  191. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
  192. package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
  193. package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
  194. package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
  195. package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
  196. package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
  197. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
  198. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
  199. package/templates/fe/table/read.ts.tpl +0 -16
  200. package/templates/fe/table/write.ts.tpl +0 -16
  201. /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
  202. /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
  203. /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
- const BACKEND_SRC_CHILDREN = new Set(['features', 'modules', 'tests']);
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 (!BACKEND_SRC_CHILDREN.has(child))
279
- finding('HFS_SRC_LAYOUT_INVALID', `src/${child}`, `Backend src/ holds only features/, modules/ and tests/; ${child} must move to its owner.`);
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 { isServerActionModule } from './server-action.mjs';
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
- /** The extra public entries the slot manifest declares for the owner (slot field `entries`, on the slot that holds its entry file), as absolute paths. */
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
- return names.map(name => absolute(config.root, `${found.root}/${name}`));
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
- return { ...owner, root, entry, entries, declared };
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, actionEntries, edge) {
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.entries.has(current.file) || actionEntries.has(current.file) ? null : { owner, chain: current.chain };
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, actionEntries) {
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, actionEntries) {
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, actionEntries, edge);
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, actionEntries),
127
- ...ownerImportViolations(config, context, owners, actionEntries),
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) ?? '')?.tier === 'feature')) {
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
- /** The value of a directive-prologue statement, or null once the prologue has ended. */
2
- function directiveValue(ts, sourceFile, statement) {
3
- const expressionStatement = ts?.isExpressionStatement
4
- ? ts.isExpressionStatement(statement)
5
- : Boolean(statement?.expression);
6
- if (!expressionStatement) return null;
7
- const expression = statement.expression;
8
- if (ts?.isStringLiteral) return ts.isStringLiteral(expression) ? expression.text : null;
9
- const raw = expression?.getText?.(sourceFile);
10
- const quote = raw?.[0];
11
- return typeof expression?.text === 'string' && (quote === "'" || quote === '"') && raw.at(-1) === quote
12
- ? expression.text
13
- : null;
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
- /** True when a TypeScript source file declares `"use server"` in its directive prologue. */
17
- export function isServerActionModule(ts, sourceFile) {
18
- for (const statement of sourceFile?.statements ?? []) {
19
- const value = directiveValue(ts, sourceFile, statement);
20
- if (value === null) return false;
21
- if (value === 'use server') return true;
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 false;
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
- export const signatureFromSupabase = (checker, call) => {
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
- export const isSupabaseValue = (kit, checker, node) => {
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 { isServerActionModule } from './server-action.mjs';
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 on each side:
10
- * `fe.modules.db` / `fe.package.db` in a front end and `be.integrations.supabase` in a back end. The checks below
11
- * recognise provider calls and types by their imported package declarations, not by local variable or type names.
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
- const isClientModule = (ts, sourceFile) => sourceFile.statements.some((statement, index) => index < 4 && ts.isExpressionStatement(statement)
149
- && ts.isStringLiteralLike(statement.expression) && statement.expression.text === 'use client');
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
- 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().');
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 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
-
329
- function checkFrontendAuthAndRoutes(input) {
159
+ function checkFrontendClientOwner(input) {
330
160
  const kit = machineKit(input);
331
- const state = { violations: [], actions: 0, routes: 0 };
161
+ const violations = [];
162
+ const counters = { imports: 0, calls: 0 };
332
163
  for (const file of input.graph.files.values()) {
333
- const checker = kit.checkerOf(file.sourceFile);
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);
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: state.violations, coverage: { status: 'checked', actions: state.actions, routes: state.routes } };
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, 'fe.modules.db')) return { violations: [], coverage: { status: 'not-applicable', reason: 'the Supabase front-end database slot is not enabled' } };
344
- const owner = checkClientOwnership(input, { slots: FE_DB_SLOTS, ruleId: FE_CLIENT_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, ...results.violations, ...secrets.violations, ...auth.violations],
350
- coverage: { status: 'checked', owner: owner.coverage, results: results.coverage, secrets: secrets.coverage, auth: auth.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 || (verdict.reason === 'tierDirection' && blockCallsServerAction(graph, edge)) || !REASON_TEXT[verdict.reason]) return;
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