@starci/hfs 4.2.13 → 4.2.15

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 +18 -0
  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 +200 -8
  6. package/runtime/knowledge/hfs/fe-stack.yaml +221 -0
  7. package/runtime/knowledge/hfs/rules.yaml +50 -47
  8. package/runtime/knowledge/hfs/slots.yaml +307 -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 +43 -0
  20. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  21. package/runtime/scripts/hfs/architecture/config.mjs +2 -1
  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 +140 -36
  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 +32 -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
@@ -1,6 +1,7 @@
1
1
  "build:be": "cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
2
2
  "build:fe": "{{buildFe}}",
3
3
  "codegen": "node scripts/codegen.mjs",
4
+ "codegen:graphql": "node scripts/codegen.mjs --graphql",
4
5
  "typecheck": "{{typecheck}}",
5
6
  "lint": "{{lintPrefix}}{{appBinary}} lint",
6
7
  "lint:fix": "{{lintPrefix}}{{appBinary}} lint --fix",
@@ -3,6 +3,10 @@
3
3
  "prepare": "husky",
4
4
  {{appScripts}}
5
5
  {{> app/package-scripts/core.json}}
6
+ "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
7
+ "test": "cd be && jest --selectProjects unit --coverage",
8
+ "test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
9
+ "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
6
10
  "db:start": "supabase start",
7
11
  "db:stop": "supabase stop",
8
12
  "db:reset": "supabase db reset",
@@ -31,7 +31,7 @@ The core api app reads `PORT`, `HTTP_SECURITY_ALLOWED_ORIGINS` (comma-separated
31
31
  requests) and `PRIMARY_DB_URL` at boot; every door is closed until it is marked public or sign-in is designed. The cli app reads
32
32
  `PRIMARY_DB_URL`: `npm run migrate` applies the migrations of the primary database before the api starts, and
33
33
  `npm run cli -- seed run` runs the dev seeds of `.starcistacks/dev/seeds` (run both from the app root). The product app reads
34
- `NEXT_PUBLIC_SITE_URL`, the landing `NEXT_PUBLIC_APP_URL` (the product app it hands the reader over to). A missing key stops the process with an error that names it.
34
+ `NEXT_PUBLIC_SITE_URL` and `NEXT_PUBLIC_GRAPHQL_URL` (the back end it calls through `src/modules/api`, read once the first GraphQL operation exists), the landing `NEXT_PUBLIC_APP_URL` (the product app it hands the reader over to). A missing key stops the process with an error that names it.
35
35
 
36
36
  ## Work
37
37
 
@@ -1,4 +1,7 @@
1
1
  // codegen.mjs - the app's generated code, the one step behind `npm run codegen` (lint, typecheck, dev and build run it first).
2
2
  // The front end reads the back end's committed contracts in place (be/contracts/, hfs.json sides.fe.reads) and generates its
3
3
  // client from them into a gitignored __generated__/ folder. A new app has no contract yet, so there is nothing to generate.
4
- process.stdout.write("codegen: no contract under be/contracts yet\n")
4
+ import fs from "node:fs"
5
+ import path from "node:path"
6
+
7
+ // {{> app/codegen-graphql.mjs.partial}}
@@ -4,7 +4,7 @@ One StarCi lite app: a Nest API in `be/`, one Next web app in `fe/`, and Supabas
4
4
 
5
5
  ## Overview
6
6
 
7
- The starter keeps one deployable API and one web app. The browser uses only the public Supabase client under row-level security; privileged Auth and Storage operations stay behind the API.
7
+ The starter keeps one deployable API and one web app, with the same code structure as a full app: the web app calls the API over GraphQL (`src/modules/api`, SWR hooks under `src/hooks/swr`) and uses Supabase only for Auth in the browser (`src/modules/auth`); every data read and write goes through the API, which verifies the access token.
8
8
 
9
9
  ## Stack
10
10
 
@@ -14,7 +14,7 @@ TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, Supabas
14
14
 
15
15
  - `supabase/`: local configuration, forward-only SQL migrations, seed data, and generated database types.
16
16
  - `be/`: the API, shared platform capabilities, verified identity admission, and privileged Supabase integrations.
17
- - `fe/`: the web app; all Supabase access is owned by `src/modules/db`.
17
+ - `fe/`: the web app; the only Supabase client is the Auth client of `src/modules/auth`, data goes through `src/modules/api`.
18
18
  - `.starcistacks/`: value-free environment metadata and encrypted-secret custody.
19
19
  - `.starciwork/`: product workspace, brand, and shell records.
20
20
 
@@ -25,15 +25,18 @@ npm install
25
25
  npm run contract:emit
26
26
  npm run typecheck
27
27
  npm run lint
28
+ npm test
28
29
  npm run build:be
29
30
  npm run build:fe
30
31
  ```
31
32
 
32
- The API reads `PORT`, `HTTP_SECURITY_ALLOWED_ORIGINS`, `PRIMARY_DB_URL`, `PRIMARY_DB_SCHEMA`, `SUPABASE_URL`, and `SUPABASE_SERVICE_ROLE_KEY`. The web app reads `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, and `NEXT_PUBLIC_SUPABASE_ANON_KEY`. Missing configuration stops the relevant process with a named error; no template supplies credential defaults.
33
+ The API reads `PORT`, `HTTP_SECURITY_ALLOWED_ORIGINS`, `PRIMARY_DB_URL`, `PRIMARY_DB_SCHEMA`, `SUPABASE_URL`, and `SUPABASE_SERVICE_ROLE_KEY`. The web app reads `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, and, once the first GraphQL operation exists, `NEXT_PUBLIC_GRAPHQL_URL`. Missing configuration stops the relevant process with a named error; no template supplies credential defaults.
34
+
35
+ `npm test` runs the back end's unit specs with per-file coverage. Optional integration specs run with `npm run test:integration` against the local Supabase stack (`npm run db:start`); a lite app has no e2e world.
33
36
 
34
37
  ## Database changes
35
38
 
36
- Create a forward-only migration under `supabase/migrations`, keep each table's RLS policies and grants beside the table, and run `npm run contract:emit` to refresh `supabase/types/database.types.ts`.
39
+ Create a forward-only migration under `supabase/migrations`, keep each table's RLS policies and grants beside the table, and run `npm run contract:emit` to refresh `supabase/types/database.types.ts`. `starci app add table <name> --fe` adds the table with its GraphQL feature on the API and its operations and SWR hooks in the web app; `npm run codegen:graphql` regenerates the web app's wire types from `be/contracts/<app>/schema.graphql`.
37
40
 
38
41
  ## Work
39
42
 
@@ -28,7 +28,7 @@ port = {{supabasePortAnalytics}}
28
28
  [auth]
29
29
  enabled = true
30
30
  site_url = "http://127.0.0.1:3000"
31
- additional_redirect_urls = ["http://127.0.0.1:3000/auth/callback"]
31
+ additional_redirect_urls = ["http://127.0.0.1:3000/"]
32
32
  enable_signup = false
33
33
  jwt_expiry = 3600
34
34
 
@@ -30,27 +30,26 @@ before update on public.profiles
30
30
  for each row
31
31
  execute function private.set_updated_at();
32
32
 
33
- create policy profiles_authenticated_select
33
+ create policy profiles_app_be_select
34
34
  on public.profiles
35
35
  for select
36
- to authenticated
37
- using ((select auth.uid()) = id);
36
+ to app_be
37
+ using (id is not null);
38
38
 
39
- create policy profiles_authenticated_insert
39
+ create policy profiles_app_be_insert
40
40
  on public.profiles
41
41
  for insert
42
- to authenticated
43
- with check ((select auth.uid()) = id);
42
+ to app_be
43
+ with check (id is not null);
44
44
 
45
- create policy profiles_authenticated_update
45
+ create policy profiles_app_be_update
46
46
  on public.profiles
47
47
  for update
48
- to authenticated
49
- using ((select auth.uid()) = id)
50
- with check ((select auth.uid()) = id);
48
+ to app_be
49
+ using (id is not null)
50
+ with check (id is not null);
51
51
 
52
- grant usage on schema public to authenticated;
53
52
  grant usage on schema public to app_be;
54
- revoke all on table public.profiles from public, anon;
55
- grant select, insert on table public.profiles to authenticated;
56
- grant update (display_name) on table public.profiles to authenticated;
53
+ revoke all on table public.profiles from public, anon, authenticated;
54
+ grant select, insert on table public.profiles to app_be;
55
+ grant update (display_name) on table public.profiles to app_be;
@@ -0,0 +1,47 @@
1
+ import { fakeTransaction, mockEntityManager } from "@starci/jest-preset"
2
+ import type { EntityManager } from "typeorm"
3
+ import { sql } from "./database.sql"
4
+ import { DatabaseErrorCode } from "./errors/database.error"
5
+ import { acceptWebhookDelivery } from "./webhook-inbox.client"
6
+
7
+ // The client runs whatever claim and completion statements its caller owns; opaque function calls stand in for them here.
8
+ const INSERT = sql`SELECT id FROM claim_spec_inbox($1, $2, $3::jsonb)`
9
+ const COMPLETE = sql`SELECT id FROM complete_spec_inbox($1)`
10
+ const PAYLOAD = { id: "delivery-1" }
11
+
12
+ const accept = (entityManager: EntityManager, deliveryId: string | undefined, process: (manager: EntityManager) => Promise<void>) =>
13
+ acceptWebhookDelivery({ entityManager, insert: INSERT, complete: COMPLETE, provider: "spec", deliveryId, payload: PAYLOAD, process })
14
+
15
+ describe("acceptWebhookDelivery", () => {
16
+ it("claims a new delivery, processes it and marks it complete in one committed transaction", async () => {
17
+ const transaction = fakeTransaction(mockEntityManager({ query: [[INSERT, [{ id: "inbox-1" }]], [COMPLETE, [{ id: "inbox-1" }]]] }))
18
+ const process = jest.fn<Promise<void>, [EntityManager]>().mockResolvedValue(undefined)
19
+
20
+ await accept(transaction.em, " delivery-1 ", process)
21
+
22
+ expect(process).toHaveBeenCalledTimes(1)
23
+ expect(transaction.em.query).toHaveBeenCalledWith(INSERT, ["spec", "delivery-1", JSON.stringify(PAYLOAD)])
24
+ expect(transaction.em.query).toHaveBeenCalledWith(COMPLETE, ["inbox-1"])
25
+ expect(transaction.commits).toBe(1)
26
+ })
27
+
28
+ it("acknowledges a duplicate without processing it again", async () => {
29
+ const transaction = fakeTransaction(mockEntityManager({ query: [INSERT, []] }))
30
+ const process = jest.fn<Promise<void>, [EntityManager]>()
31
+
32
+ await accept(transaction.em, "delivery-1", process)
33
+
34
+ expect(process).not.toHaveBeenCalled()
35
+ expect(transaction.em.query).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it.each([["no", undefined], ["a blank", " "]])("refuses %s delivery id before opening a transaction", async (_label, deliveryId) => {
39
+ const transaction = fakeTransaction(mockEntityManager())
40
+ const process = jest.fn<Promise<void>, [EntityManager]>()
41
+
42
+ await expect(accept(transaction.em, deliveryId, process)).rejects.toMatchObject({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
43
+
44
+ expect(transaction.outcomes).toEqual([])
45
+ expect(process).not.toHaveBeenCalled()
46
+ })
47
+ })
@@ -0,0 +1,46 @@
1
+ import { fakeTransaction, mock, mockEntityManager } from "@starci/jest-preset"
2
+ import { Test } from "@nestjs/testing"
3
+ import { @@service@@ } from "@@serviceModule@@"
4
+ import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
5
+ import { COMPLETE_@@providerUpper@@_INBOX, INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
6
+ import { @@Provider@@InboxService } from "./@@provider@@-inbox.service"
7
+
8
+ const DELIVERY = { id: "delivery-1" }
9
+
10
+ const build = async (claimed: ReadonlyArray<{ readonly id: string }>) => {
11
+ const entityManager = mockEntityManager({
12
+ query: [[INSERT_@@providerUpper@@_INBOX, claimed], [COMPLETE_@@providerUpper@@_INBOX, claimed]],
13
+ })
14
+ const transaction = fakeTransaction(entityManager)
15
+ const deliveries = mock<@@service@@>()
16
+ const moduleRef = await Test.createTestingModule({
17
+ providers: [
18
+ @@Provider@@InboxService,
19
+ { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
20
+ { provide: @@service@@, useValue: deliveries },
21
+ ],
22
+ }).compile()
23
+ return { inbox: moduleRef.get(@@Provider@@InboxService), deliveries, transaction }
24
+ }
25
+
26
+ describe("@@Provider@@InboxService", () => {
27
+ describe("accept@@Provider@@Delivery", () => {
28
+ it("claims a new @@provider@@ delivery and runs the domain intake inside the claiming transaction", async () => {
29
+ const { inbox, deliveries, transaction } = await build([{ id: "inbox-1" }])
30
+
31
+ await inbox.accept@@Provider@@Delivery("delivery-1", DELIVERY)
32
+
33
+ expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledTimes(1)
34
+ expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledWith(DELIVERY, expect.anything())
35
+ expect(transaction.commits).toBe(1)
36
+ })
37
+
38
+ it("acknowledges a duplicate @@provider@@ delivery without running the domain intake again", async () => {
39
+ const { inbox, deliveries } = await build([])
40
+
41
+ await inbox.accept@@Provider@@Delivery("delivery-1", DELIVERY)
42
+
43
+ expect(deliveries.accept@@Provider@@Delivery).not.toHaveBeenCalled()
44
+ })
45
+ })
46
+ })
@@ -0,0 +1,76 @@
1
+ import { createHmac } from "node:crypto"
2
+ import { FakeClock, mock } from "@starci/jest-preset"
3
+ import type { RawBodyRequest } from "@nestjs/common"
4
+ import { Test } from "@nestjs/testing"
5
+ import type { Request } from "express"
6
+ import { @@Provider@@InboxService } from "@modules/domain/@@provider@@-inbox"
7
+ import { CLOCK, ClockModule } from "@modules/platform/clock"
8
+ import { Secret } from "@modules/platform/config"
9
+ import { HttpSecurityModule } from "@modules/platform/http-security"
10
+ import { @@Event@@Request } from "./dto/@@event@@.request"
11
+ import { @@Provider@@Webhook } from "./@@provider@@.webhook"
12
+
13
+ const AT = "2026-01-02T03:04:05.000Z"
14
+ const SECRET = "spec-webhook-secret"
15
+ const SIGNED_AT = String(new Date(AT).getTime())
16
+ const DELIVERY_ID = "delivery-1"
17
+
18
+ /** The delivery the notifier sends: the validated request class, as the pipe builds it. */
19
+ const deliveryOf = (): @@Event@@Request => {
20
+ const delivery = new @@Event@@Request()
21
+ delivery.id = DELIVERY_ID
22
+ return delivery
23
+ }
24
+
25
+ const DELIVERY = deliveryOf()
26
+ const RAW_BODY = Buffer.from(JSON.stringify(DELIVERY))
27
+ const REQUEST = mock<RawBodyRequest<Request>>({ rawBody: RAW_BODY })
28
+
29
+ const sign = (rawBody: Buffer, timestamp: string): string =>
30
+ `sha256=${createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex")}`
31
+
32
+ const build = async (deliveries: @@Provider@@InboxService) => {
33
+ const moduleRef = await Test.createTestingModule({
34
+ imports: [
35
+ ClockModule.register({ isGlobal: true }),
36
+ HttpSecurityModule.register({
37
+ allowedOrigins: [],
38
+ rateLimit: { windowMs: 60_000, defaultLimit: 100, strictLimit: 100 },
39
+ webhooks: { "@@provider@@": { secret: new Secret(SECRET), toleranceMs: 300_000 } },
40
+ }),
41
+ ],
42
+ controllers: [@@Provider@@Webhook],
43
+ providers: [
44
+ { provide: @@Provider@@InboxService, useValue: deliveries },
45
+ ],
46
+ })
47
+ .overrideProvider(CLOCK)
48
+ .useValue(new FakeClock(AT))
49
+ .compile()
50
+ return moduleRef.get(@@Provider@@Webhook)
51
+ }
52
+
53
+ describe("@@Provider@@Webhook", () => {
54
+ describe("receive", () => {
55
+ it("proves the delivery on its raw body and headers, then hands it and its delivery id to the inbox once", async () => {
56
+ const deliveries = mock<@@Provider@@InboxService>()
57
+ const door = await build(deliveries)
58
+
59
+ await door.receive(REQUEST, sign(RAW_BODY, SIGNED_AT), SIGNED_AT, DELIVERY_ID, DELIVERY)
60
+
61
+ expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledTimes(1)
62
+ expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledWith(DELIVERY_ID, DELIVERY)
63
+ })
64
+
65
+ it("never reaches the inbox when the signature is not the provider's", async () => {
66
+ const deliveries = mock<@@Provider@@InboxService>()
67
+ const door = await build(deliveries)
68
+
69
+ await expect(
70
+ door.receive(REQUEST, sign(Buffer.from("other body"), SIGNED_AT), SIGNED_AT, DELIVERY_ID, DELIVERY),
71
+ ).rejects.toMatchObject({ code: "HTTP_SECURITY_WEBHOOK_SIGNATURE_INVALID" })
72
+
73
+ expect(deliveries.accept@@Provider@@Delivery).not.toHaveBeenCalled()
74
+ })
75
+ })
76
+ })
@@ -1,7 +1,7 @@
1
1
  import { Module } from "@nestjs/common"
2
- import { @@Feature@@Module } from "../../@@feature@@.module"
2
+ import { @@Feature@@ApiModule } from "../../@@feature@@.module"
3
3
  import { @@Action@@Resolver } from "./@@action@@.resolver"
4
4
 
5
- @Module({ imports: [@@Feature@@Module], providers: [@@Action@@Resolver] })
5
+ @Module({ imports: [@@Feature@@ApiModule], providers: [@@Action@@Resolver] })
6
6
  /** The GraphQL transport of the @@feature@@ feature. */
7
7
  export class @@Feature@@GraphqlModule {}
@@ -4,7 +4,7 @@ import type { RawBodyRequest } from "@nestjs/common"
4
4
  import { Test } from "@nestjs/testing"
5
5
  import type { Request } from "express"
6
6
  import { @@service@@ } from "@@serviceModule@@"
7
- import { CLOCK } from "@modules/platform/clock"
7
+ import { CLOCK, ClockModule } from "@modules/platform/clock"
8
8
  import { Secret } from "@modules/platform/config"
9
9
  import { HttpSecurityModule } from "@modules/platform/http-security"
10
10
  import { @@Event@@Request } from "./dto/@@event@@.request"
@@ -31,6 +31,7 @@ const sign = (rawBody: Buffer, timestamp: string): string =>
31
31
  const build = async (deliveries: @@service@@) => {
32
32
  const moduleRef = await Test.createTestingModule({
33
33
  imports: [
34
+ ClockModule.register({ isGlobal: true }),
34
35
  HttpSecurityModule.register({
35
36
  allowedOrigins: [],
36
37
  rateLimit: { windowMs: 60_000, defaultLimit: 100, strictLimit: 100 },
@@ -39,10 +40,12 @@ const build = async (deliveries: @@service@@) => {
39
40
  ],
40
41
  controllers: [@@Provider@@Webhook],
41
42
  providers: [
42
- { provide: CLOCK, useValue: new FakeClock(AT) },
43
43
  { provide: @@service@@, useValue: deliveries },
44
44
  ],
45
- }).compile()
45
+ })
46
+ .overrideProvider(CLOCK)
47
+ .useValue(new FakeClock(AT))
48
+ .compile()
46
49
  return moduleRef.get(@@Provider@@Webhook)
47
50
  }
48
51
 
@@ -0,0 +1,52 @@
1
+ import { mock } from "@starci/jest-preset"
2
+ import { GraphQLError } from "graphql"
3
+ import type { ErrorsService } from "./errors.service"
4
+ import { formatGraphqlError } from "./graphql-error.mapper"
5
+
6
+ const formatted = { message: "raw", path: ["item"], locations: [{ line: 1, column: 3 }] }
7
+
8
+ describe("formatGraphqlError", () => {
9
+ it("keeps the message of a malformed operation and marks it invalid without describing it", () => {
10
+ const errors = mock<ErrorsService>()
11
+
12
+ expect(formatGraphqlError(errors, formatted, new GraphQLError("Cannot query field"))).toEqual({
13
+ message: "raw",
14
+ path: ["item"],
15
+ locations: [{ line: 1, column: 3 }],
16
+ extensions: { kind: "invalid" },
17
+ })
18
+ expect(errors.describe).not.toHaveBeenCalled()
19
+ })
20
+
21
+ it("describes the original failure of a resolver and travels its code, kind and params", () => {
22
+ const errors = mock<ErrorsService>()
23
+ const cause = new Error("not found")
24
+ errors.describe.mockReturnValue({
25
+ code: "ITEM_NOT_FOUND",
26
+ kind: "not-found",
27
+ status: 404,
28
+ params: { id: "i-1" },
29
+ })
30
+
31
+ expect(formatGraphqlError(errors, formatted, new GraphQLError("wrapped", { originalError: cause }))).toEqual({
32
+ message: "ITEM_NOT_FOUND",
33
+ path: ["item"],
34
+ locations: [{ line: 1, column: 3 }],
35
+ extensions: { code: "ITEM_NOT_FOUND", kind: "not-found", params: { id: "i-1" } },
36
+ })
37
+ expect(errors.describe).toHaveBeenCalledWith(cause)
38
+ })
39
+
40
+ it("describes a failure that is not a GraphQL error as it is", () => {
41
+ const errors = mock<ErrorsService>()
42
+ const cause = new Error("boom")
43
+ errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
44
+
45
+ expect(formatGraphqlError(errors, formatted, cause).extensions).toEqual({
46
+ code: "ERRORS_INTERNAL",
47
+ kind: "internal",
48
+ params: {},
49
+ })
50
+ expect(errors.describe).toHaveBeenCalledWith(cause)
51
+ })
52
+ })
@@ -0,0 +1,31 @@
1
+ import { GraphQLError } from "graphql"
2
+ import type { GraphQLFormattedError } from "graphql"
3
+ import type { ErrorsService } from "./errors.service"
4
+
5
+ /**
6
+ * The one GraphQL error formatter: a failure thrown by a resolver or a guard becomes `errors[].extensions.{code,kind,params}`
7
+ * through the errors service (a declared code keeps its code, anything else is masked as internal); a malformed operation
8
+ * (syntax or validation, no original error) keeps its message with kind `invalid`. The message of a coded error is the code
9
+ * until the localize plugin replaces it with the catalog text.
10
+ */
11
+ export const formatGraphqlError = (
12
+ errors: ErrorsService,
13
+ formatted: GraphQLFormattedError,
14
+ error: unknown,
15
+ ): GraphQLFormattedError => {
16
+ if (error instanceof GraphQLError && error.originalError === undefined) {
17
+ return {
18
+ message: formatted.message,
19
+ locations: formatted.locations,
20
+ path: formatted.path,
21
+ extensions: { kind: "invalid" },
22
+ }
23
+ }
24
+ const description = errors.describe(error instanceof GraphQLError ? error.originalError : error)
25
+ return {
26
+ message: description.code,
27
+ locations: formatted.locations,
28
+ path: formatted.path,
29
+ extensions: { code: description.code, kind: description.kind, params: description.params },
30
+ }
31
+ }
@@ -0,0 +1,43 @@
1
+ import { buildSchema, parse, validate } from "graphql"
2
+ import { depthLimitRule } from "./graphql-depth.policy"
3
+
4
+ const schema = buildSchema(`
5
+ type Query {
6
+ item: Item
7
+ }
8
+
9
+ type Item {
10
+ value: String
11
+ child: Item
12
+ }
13
+ `)
14
+
15
+ const validateDepth = (source: string, maxDepth = 2) => validate(schema, parse(source), [depthLimitRule(maxDepth)])
16
+
17
+ describe("depthLimitRule", () => {
18
+ it("accepts an operation nested exactly to the depth limit", () => {
19
+ const errors = validateDepth(`
20
+ query {
21
+ item {
22
+ value
23
+ }
24
+ }
25
+ `)
26
+
27
+ expect(errors).toEqual([])
28
+ })
29
+
30
+ it("rejects the first selection set deeper than the limit", () => {
31
+ const errors = validateDepth(`
32
+ query {
33
+ item {
34
+ child {
35
+ value
36
+ }
37
+ }
38
+ }
39
+ `)
40
+
41
+ expect(errors.map((error) => error.message)).toEqual(["Selection depth exceeds 2."])
42
+ })
43
+ })
@@ -0,0 +1,21 @@
1
+ import { GraphQLError } from "graphql"
2
+ import type { ASTVisitor, ValidationContext } from "graphql"
3
+
4
+ /** A validation rule that refuses an operation whose selection sets nest deeper than `maxDepth` (each fragment counts on its own). */
5
+ export const depthLimitRule =
6
+ (maxDepth: number) =>
7
+ (context: ValidationContext): ASTVisitor => {
8
+ let depth = 0
9
+ return {
10
+ SelectionSet: {
11
+ enter: () => {
12
+ depth += 1
13
+ if (depth === maxDepth + 1)
14
+ context.reportError(new GraphQLError(`Selection depth exceeds ${maxDepth}.`))
15
+ },
16
+ leave: () => {
17
+ depth -= 1
18
+ },
19
+ },
20
+ }
21
+ }
@@ -0,0 +1,12 @@
1
+ import type { Request, Response } from "express"
2
+
3
+ /** The GraphQL context every operation runs with: the HTTP request it arrived on and its response. */
4
+ export interface GraphqlContext {
5
+ /** The HTTP request: the guards read its headers and the principal they establish on it. */
6
+ readonly req: Request
7
+ /** The HTTP response. */
8
+ readonly res: Response
9
+ }
10
+
11
+ /** The deepest selection nesting an operation may have. */
12
+ export const GRAPHQL_DEPTH_MAX = 10
@@ -0,0 +1,7 @@
1
+ import { ConfigurableModuleBuilder } from "@nestjs/common"
2
+ import type { GraphqlOptions } from "./graphql.options"
3
+
4
+ /** The configurable-module base of the graphql capability; `isGlobal` is decided by the app root. */
5
+ export const { ConfigurableModuleClass, OPTIONS_TYPE } = new ConfigurableModuleBuilder<GraphqlOptions>()
6
+ .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
7
+ .build()
@@ -0,0 +1,49 @@
1
+ import { ApolloDriver } from "@nestjs/apollo"
2
+ import type { ApolloDriverConfig } from "@nestjs/apollo"
3
+ import { Module } from "@nestjs/common"
4
+ import type { DynamicModule } from "@nestjs/common"
5
+ import { GraphQLModule as NestGraphQLModule } from "@nestjs/graphql"
6
+ import type { Request, Response } from "express"
7
+ import { ERRORS_SERVICE, formatGraphqlError } from "@modules/platform/errors"
8
+ import type { ErrorsService } from "@modules/platform/errors"
9
+ import { REQUEST_LOCALE } from "@modules/platform/i18n"
10
+ import type { RequestLocale } from "@modules/platform/i18n"
11
+ import { GRAPHQL_DEPTH_MAX } from "./graphql.contracts"
12
+ import type { GraphqlContext } from "./graphql.contracts"
13
+ import { depthLimitRule } from "./graphql-depth.policy"
14
+ import { ConfigurableModuleClass, OPTIONS_TYPE } from "./graphql.module-definition"
15
+ import { localizeErrorsPlugin } from "./localize-errors.mapper"
16
+
17
+ @Module({})
18
+ /**
19
+ * The one code-first Apollo server of the api app, the same in both editions: the schema from the resolvers the feature
20
+ * transports provide, the shared error formatter, a depth limit and the localize plugin. The schema snapshot is
21
+ * `npm run contract:emit` (contracts/<app>/schema.graphql), never written at boot.
22
+ */
23
+ export class GraphqlModule extends ConfigurableModuleClass {
24
+ /** Registers the capability once in the api app; a feature's resolvers join the schema by being providers. */
25
+ static register(options: typeof OPTIONS_TYPE): DynamicModule {
26
+ const base = super.register(options)
27
+ return {
28
+ ...base,
29
+ imports: [
30
+ ...(base.imports ?? []),
31
+ NestGraphQLModule.forRootAsync<ApolloDriverConfig>({
32
+ driver: ApolloDriver,
33
+ inject: [ERRORS_SERVICE, REQUEST_LOCALE],
34
+ useFactory: (errors: ErrorsService, requestLocale: RequestLocale) => ({
35
+ autoSchemaFile: true,
36
+ sortSchema: true,
37
+ path: "/graphql",
38
+ playground: false,
39
+ introspection: true,
40
+ context: ({ req, res }: { req: Request; res: Response }): GraphqlContext => ({ req, res }),
41
+ formatError: (formatted, error) => formatGraphqlError(errors, formatted, error),
42
+ validationRules: [depthLimitRule(GRAPHQL_DEPTH_MAX)],
43
+ plugins: [localizeErrorsPlugin(errors, requestLocale)],
44
+ }),
45
+ }),
46
+ ],
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,2 @@
1
+ /** The graphql capability takes no options; the empty record keeps `isGlobal` assignable through the configurable module. */
2
+ export type GraphqlOptions = Record<never, never>
@@ -0,0 +1 @@
1
+ export { GraphqlModule } from "./graphql.module"