@starci/hfs 4.2.13 → 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 +14 -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 +201 -14
  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 +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 +48 -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 +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
@@ -0,0 +1,9 @@
1
+ import type { Get{{Singular}}Request, Get{{Singular}}Result } from "../../application/get-{{singular}}.contracts"
2
+ import type { Get{{Singular}}Input } from "./dto/get-{{singular}}.input"
3
+ import type { Get{{Singular}}Type } from "./dto/get-{{singular}}.type"
4
+
5
+ /** Maps the GraphQL input to the query request. */
6
+ export const toGet{{Singular}}Request = (input: Get{{Singular}}Input): Get{{Singular}}Request => ({ id: input.id })
7
+
8
+ /** Maps the result to the GraphQL type. */
9
+ export const toGet{{Singular}}Type = (result: Get{{Singular}}Result): Get{{Singular}}Type => ({ id: result.id })
@@ -0,0 +1,10 @@
1
+ import { Query } from "@nestjs/cqrs"
2
+ import type { ExecuteParams } from "@modules/platform/cqrs"
3
+ import type { Get{{Singular}}Request, Get{{Singular}}Result } from "./get-{{singular}}.contracts"
4
+
5
+ /** Asks for one {{singular}} row the authenticated caller owns. */
6
+ export class Get{{Singular}}Query extends Query<Get{{Singular}}Result> {
7
+ constructor(readonly params: ExecuteParams<Get{{Singular}}Request>) {
8
+ super()
9
+ }
10
+ }
@@ -0,0 +1,27 @@
1
+ import { Args, Query, Resolver } from "@nestjs/graphql"
2
+ import type { QueryBus } from "@nestjs/cqrs"
3
+ import { CurrentPrincipal } from "@modules/domain/identity"
4
+ import { InjectQueryBus } from "@modules/platform/cqrs"
5
+ import type { Principal } from "@modules/platform/cqrs"
6
+ import { Get{{Singular}}Query } from "../../application/get-{{singular}}.query"
7
+ import { Get{{Singular}}Input } from "./dto/get-{{singular}}.input"
8
+ import { Get{{Singular}}Type } from "./dto/get-{{singular}}.type"
9
+ import { toGet{{Singular}}Request, toGet{{Singular}}Type } from "./get-{{singular}}.mapper"
10
+
11
+ @Resolver()
12
+ /** GraphQL door of get-{{singular}}: one {{singular}} row of the caller. */
13
+ export class Get{{Singular}}Resolver {
14
+ constructor(@InjectQueryBus() private readonly queryBus: QueryBus) {}
15
+
16
+ /** Answers one {{singular}} row the caller owns; another owner's row is not found. */
17
+ @Query(() => Get{{Singular}}Type, { name: "{{singularCamel}}" })
18
+ async {{singularCamel}}(
19
+ @CurrentPrincipal() principal: Principal,
20
+ @Args("input") input: Get{{Singular}}Input,
21
+ ): Promise<Get{{Singular}}Type> {
22
+ const result = await this.queryBus.execute(
23
+ new Get{{Singular}}Query({ request: toGet{{Singular}}Request(input), principal }),
24
+ )
25
+ return toGet{{Singular}}Type(result)
26
+ }
27
+ }
@@ -0,0 +1,9 @@
1
+ import { Field, ID, ObjectType } from "@nestjs/graphql"
2
+
3
+ @ObjectType()
4
+ /** The get-{{singular}} answer. */
5
+ export class Get{{Singular}}Type {
6
+ /** The id of the {{singular}} row. */
7
+ @Field(() => ID)
8
+ id!: string
9
+ }
@@ -0,0 +1,8 @@
1
+ import { Module } from "@nestjs/common"
2
+ import { {{Name}}ApiModule } from "../../{{name}}.module"
3
+ import { Create{{Singular}}Resolver } from "./create-{{singular}}.resolver"
4
+ import { Get{{Singular}}Resolver } from "./get-{{singular}}.resolver"
5
+
6
+ @Module({ imports: [{{Name}}ApiModule], providers: [Get{{Singular}}Resolver, Create{{Singular}}Resolver] })
7
+ /** The GraphQL transport of the {{name}} feature. */
8
+ export class {{Name}}GraphqlModule {}
@@ -0,0 +1,19 @@
1
+ import { DomainError } from "@modules/platform/errors"
2
+ import type { ErrorKind } from "@modules/platform/errors"
3
+
4
+ /** Codes of the {{name}} capability. */
5
+ export enum {{Name}}ErrorCode {
6
+ /** No row owned by the authenticated principal has the requested id. */
7
+ NotFound = "{{upper}}_NOT_FOUND",
8
+ /** The database accepted the insert of a new row but answered no id. */
9
+ NotCreated = "{{upper}}_NOT_CREATED",
10
+ }
11
+
12
+ /** How each {{name}} code travels. */
13
+ export const {{upper}}_ERROR_KINDS: Record<{{Name}}ErrorCode, ErrorKind> = {
14
+ [{{Name}}ErrorCode.NotFound]: "not-found",
15
+ [{{Name}}ErrorCode.NotCreated]: "unavailable",
16
+ }
17
+
18
+ /** The one error class of the {{name}} capability. */
19
+ export class {{Name}}Error extends DomainError<{{Name}}ErrorCode> {}
@@ -14,38 +14,30 @@ before update on public.{{tableSql}}
14
14
  for each row
15
15
  execute function private.set_updated_at();
16
16
 
17
- create policy "{{table}}_authenticated_select"
17
+ create policy "{{table}}_app_be_select"
18
18
  on public.{{tableSql}}
19
19
  for select
20
- to authenticated
21
- using (owner_id = (select auth.uid()));
20
+ to app_be
21
+ using (owner_id is not null);
22
22
 
23
- create policy "{{table}}_authenticated_insert"
23
+ create policy "{{table}}_app_be_insert"
24
24
  on public.{{tableSql}}
25
25
  for insert
26
- to authenticated
27
- with check (owner_id = (select auth.uid()));
26
+ to app_be
27
+ with check (owner_id is not null);
28
28
 
29
- create policy "{{table}}_authenticated_update"
29
+ create policy "{{table}}_app_be_update"
30
30
  on public.{{tableSql}}
31
31
  for update
32
- to authenticated
33
- using (owner_id = (select auth.uid()))
34
- with check (owner_id = (select auth.uid()));
32
+ to app_be
33
+ using (owner_id is not null)
34
+ with check (owner_id is not null);
35
35
 
36
- create policy "{{table}}_authenticated_delete"
36
+ create policy "{{table}}_app_be_delete"
37
37
  on public.{{tableSql}}
38
38
  for delete
39
- to authenticated
40
- using (owner_id = (select auth.uid()));
41
-
42
- create policy "{{table}}_app_be_select"
43
- on public.{{tableSql}}
44
- for select
45
39
  to app_be
46
40
  using (owner_id is not null);
47
41
 
48
- revoke all on table public.{{tableSql}} from public, anon;
49
- grant select, insert, delete on table public.{{tableSql}} to authenticated;
50
- grant update (updated_at) on table public.{{tableSql}} to authenticated;
51
- grant select on table public.{{tableSql}} to app_be;
42
+ revoke all on table public.{{tableSql}} from public, anon, authenticated;
43
+ grant select, insert, update, delete on table public.{{tableSql}} to app_be;
@@ -0,0 +1,30 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { EntityManager } from "typeorm"
3
+ import { InjectPrimaryEntityManager, requireOwnedRow } from "@modules/platform/database"
4
+ import { {{Name}}Error, {{Name}}ErrorCode } from "./errors/{{name}}.error"
5
+ import type { {{Name}}Row } from "./persistence/{{name}}.rows"
6
+ import { FIND_{{upper}}, INSERT_{{upper}} } from "./persistence/{{name}}.sql"
7
+
8
+ interface {{Name}}Result {
9
+ readonly id: string
10
+ }
11
+
12
+ @Injectable()
13
+ /** The {{name}} rows reached only through the shared primary EntityManager. */
14
+ export class {{Name}}Service {
15
+ constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
16
+
17
+ /** Answers one row owned by the authenticated principal; absent and denied rows share one typed not-found result. */
18
+ {{nameCamel}}(principalId: string, id: string): Promise<{{Name}}Result> {
19
+ return requireOwnedRow<Pick<{{Name}}Row, "id">>(this.entityManager, FIND_{{upper}}, id, principalId, () => {
20
+ throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotFound, params: { id } })
21
+ })
22
+ }
23
+
24
+ /** Creates one row owned by the authenticated principal and answers its id. */
25
+ async create{{Singular}}(principalId: string): Promise<{{Name}}Result> {
26
+ const [row] = await this.entityManager.query<Array<Pick<{{Name}}Row, "id">>>(INSERT_{{upper}}, [principalId])
27
+ if (row === undefined) throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotCreated })
28
+ return { id: row.id }
29
+ }
30
+ }
@@ -0,0 +1,7 @@
1
+ import { sql } from "@modules/platform/database"
2
+
3
+ /** Reads one public.{{table}} row by id and owner through a branded SQL statement. */
4
+ export const FIND_{{upper}} = sql`SELECT id FROM public.{{tableSql}} WHERE id = $1 AND owner_id = $2 LIMIT 1`
5
+
6
+ /** Inserts one public.{{table}} row owned by the principal and returns its id. */
7
+ export const INSERT_{{upper}} = sql`INSERT INTO public.{{tableSql}} (owner_id) VALUES ($1) RETURNING id`
@@ -1,35 +1,33 @@
1
1
  "use client"
2
2
 
3
3
  import { useTranslations } from "next-intl"
4
- import { useState, useTransition } from "react"
5
- import { writeSignIn } from "@/modules/db/auth/write-sign-in"
6
- import { navigation } from "@/modules/i18n"
4
+ import { useSession, useSignIn } from "@/hooks/auth"
5
+ import type { SignInFailure } from "@/hooks/auth"
7
6
  import { SignInFormBase } from "./component"
8
7
 
9
- type SignInFailure = "refused" | "not-found" | "invalid" | "unavailable"
10
-
11
8
  /** The state the form shows: working while a request runs, failed after a refusal, else ready. */
12
9
  const stateOf = (pending: boolean, failure: SignInFailure | undefined): "working" | "ready" | "failed" => {
13
10
  if (pending) return "working"
14
11
  return failure === undefined ? "ready" : "failed"
15
12
  }
16
13
 
17
- /** The interactive sign-in form wired to the schema-checked Server Action. */
14
+ /** The fields of the submitted form, or undefined when one is missing. */
15
+ const credentialsOf = (input: FormData): { readonly email: string; readonly password: string } | undefined => {
16
+ const email = input.get("email")
17
+ const password = input.get("password")
18
+ return typeof email === "string" && typeof password === "string" ? { email, password } : undefined
19
+ }
20
+
21
+ /** The sign-in form of a signed-out reader; the session effect moves the reader to signed in on success. */
18
22
  export const SignInForm = () => {
19
- const router = navigation.useRouter()
23
+ const session = useSession()
20
24
  const t = useTranslations("app.signIn")
21
- const [failure, setFailure] = useState<SignInFailure>()
22
- const [pending, startTransition] = useTransition()
25
+ const { failure, pending, signIn, reject } = useSignIn()
26
+ if (session.status !== "signed-out") return null
23
27
  const submit = (input: FormData) => {
24
- startTransition(async () => {
25
- const outcome = await writeSignIn(input)
26
- if (outcome.kind === "ok") {
27
- setFailure(undefined)
28
- router.refresh()
29
- return
30
- }
31
- setFailure(outcome.kind)
32
- })
28
+ const credentials = credentialsOf(input)
29
+ if (credentials === undefined) reject()
30
+ else signIn(credentials.email, credentials.password)
33
31
  }
34
32
  return (
35
33
  <SignInFormBase
@@ -2,16 +2,20 @@ import { Button, Text } from "@starci/grammar/common"
2
2
 
3
3
  /** Copy, state and action supplied by the connected sign-out block. */
4
4
  export interface SignOutButtonBaseProps {
5
- readonly props: { readonly label: string; readonly pending: boolean; readonly failure?: string }
5
+ readonly props: {
6
+ readonly principal: string
7
+ readonly label: string
8
+ readonly pending: boolean
9
+ }
6
10
  readonly on: { readonly press: () => void }
7
11
  }
8
12
 
9
- /** Draws the sign-out control without reading navigation, copy or database state. */
13
+ /** Draws the signed-in reader and the sign-out control without reading navigation, copy or session state. */
10
14
  export const SignOutButtonBase = (props: SignOutButtonBaseProps) => (
11
15
  <>
16
+ <Text>{props.props.principal}</Text>
12
17
  <Button variant="secondary" isPending={props.props.pending} onPress={props.on.press}>
13
18
  {props.props.label}
14
19
  </Button>
15
- {props.props.failure === undefined ? null : <Text live="assertive">{props.props.failure}</Text>}
16
20
  </>
17
21
  )
@@ -0,0 +1,23 @@
1
+ "use client"
2
+
3
+ import { useTranslations } from "next-intl"
4
+ import { useSession, useSignOut } from "@/hooks/auth"
5
+ import { SignOutButtonBase } from "./component"
6
+
7
+ /** The signed-in reader and the control that ends the browser session. */
8
+ export const SignOutButton = () => {
9
+ const session = useSession()
10
+ const t = useTranslations("app.signOut")
11
+ const { pending, signOut } = useSignOut()
12
+ if (session.status !== "signed-in") return null
13
+ return (
14
+ <SignOutButtonBase
15
+ props={{
16
+ principal: session.session?.email ?? t("signedIn"),
17
+ label: pending ? t("working") : t("submit"),
18
+ pending,
19
+ }}
20
+ on={{ press: signOut }}
21
+ />
22
+ )
23
+ }
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import { ToastProvider } from "@heroui/react"
4
+ import { ThemeProvider } from "next-themes"
5
+ import type { ReactNode } from "react"
6
+ import { SWRConfig } from "swr"
7
+ import { useAuthSessionEffect } from "@/hooks/effects"
8
+ import { SwrSideEffects } from "@/hooks/swr"
9
+ import { ReduxProvider } from "@/redux"
10
+
11
+ /** What the app providers wrap: the routed tree. */
12
+ type AppProvidersProps = { readonly children: ReactNode }
13
+
14
+ /**
15
+ * The SWR defaults of the app: no refetch on focus or reconnect (a hook that needs fresh data opts in with its own
16
+ * refreshInterval), duplicate requests collapsed for a minute, at most two retries of a failing key, and no stale data
17
+ * across a key change (the loading state shows instead).
18
+ */
19
+ const SWR_DEFAULTS = {
20
+ revalidateOnFocus: false,
21
+ revalidateOnReconnect: false,
22
+ dedupingInterval: 60_000,
23
+ errorRetryCount: 2,
24
+ keepPreviousData: false,
25
+ } as const
26
+
27
+ /** The app-wide session effect: modules/auth's session kept in redux. Renders nothing. */
28
+ const AuthSessionEffect = () => {
29
+ useAuthSessionEffect()
30
+ return null
31
+ }
32
+
33
+ /**
34
+ * The one client provider stack the locale layout mounts, outer to inner: the theme (light and dark through the `.light` /
35
+ * `.dark` class the grammar and the brand layer read, the system theme by default), the redux store, the SWR defaults, the
36
+ * app-wide effects and the HeroUI toast region modules/toast writes to. Each part is composed here, in its one owner.
37
+ */
38
+ export const AppProviders = (props: AppProvidersProps) => (
39
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
40
+ <ReduxProvider>
41
+ <SWRConfig value={SWR_DEFAULTS}>
42
+ <AuthSessionEffect />
43
+ <SwrSideEffects />
44
+ {props.children}
45
+ <ToastProvider />
46
+ </SWRConfig>
47
+ </ReduxProvider>
48
+ </ThemeProvider>
49
+ )
@@ -0,0 +1,4 @@
1
+ export { useSession } from "./useSession"
2
+ export { useSignIn } from "./useSignIn"
3
+ export type { SignInFailure } from "./useSignIn"
4
+ export { useSignOut } from "./useSignOut"
@@ -0,0 +1,6 @@
1
+ "use client"
2
+
3
+ import { useAppSelector } from "@/redux"
4
+
5
+ /** The reader's session as the app shares it (redux, kept by the auth session effect): unknown, signed in or signed out. */
6
+ export const useSession = () => useAppSelector((state) => state.session)
@@ -0,0 +1,29 @@
1
+ "use client"
2
+
3
+ import { useState, useTransition } from "react"
4
+ import { authClient } from "@/modules/auth"
5
+
6
+ /** Why a sign-in failed: refused credentials, rejected input or the provider unavailable. */
7
+ export type SignInFailure = "refused" | "invalid" | "unavailable"
8
+
9
+ /** The failure kinds modules/auth answers; any other kind reads as the provider being unavailable. */
10
+ const FAILURES: readonly SignInFailure[] = ["refused", "invalid", "unavailable"]
11
+
12
+ /** The failure of a sign-in's failure kind. */
13
+ const failureOf = (kind: string): SignInFailure => FAILURES.find((each) => each === kind) ?? "unavailable"
14
+
15
+ /**
16
+ * Signs the reader in with an email and a password through modules/auth: whether a request runs, why the last one failed,
17
+ * the sign-in itself and `reject` for input the form could not read. A success needs no state here: the session effect
18
+ * moves the reader to signed in.
19
+ */
20
+ export const useSignIn = () => {
21
+ const [failure, setFailure] = useState<SignInFailure>()
22
+ const [pending, startTransition] = useTransition()
23
+ const signIn = (email: string, password: string) =>
24
+ startTransition(async () => {
25
+ const failed = await authClient.signInWithPassword(email, password)
26
+ setFailure(failed === undefined ? undefined : failureOf(failed.kind))
27
+ })
28
+ return { failure, pending, signIn, reject: () => setFailure("invalid") }
29
+ }
@@ -0,0 +1,27 @@
1
+ "use client"
2
+
3
+ import { useTransition } from "react"
4
+ import type { GraphQLResponse } from "@/modules/api"
5
+ import { authClient } from "@/modules/auth"
6
+ import type { AuthFailure } from "@/modules/auth"
7
+ import { useGraphQLWithToast } from "@/modules/toast"
8
+
9
+ /** The app's one outcome of an auth call, so modules/toast reports it like any other write. */
10
+ const responseOf = (failure: AuthFailure | undefined): GraphQLResponse<null> =>
11
+ failure === undefined
12
+ ? { success: true, data: null }
13
+ : { success: false, error: failure.code, kind: failure.kind, message: failure.message }
14
+
15
+ /**
16
+ * Ends the reader's session through modules/auth: whether the request runs and the sign-out itself. A failure is reported by
17
+ * modules/toast; a success needs no toast, the session effect moves the reader to signed out.
18
+ */
19
+ export const useSignOut = () => {
20
+ const withToast = useGraphQLWithToast()
21
+ const [pending, startTransition] = useTransition()
22
+ const signOut = () =>
23
+ startTransition(async () => {
24
+ await withToast(async () => responseOf(await authClient.signOut()), { showSuccessToast: false })
25
+ })
26
+ return { pending, signOut }
27
+ }
@@ -0,0 +1 @@
1
+ export { useAuthSessionEffect } from "./useAuthSessionEffect"
@@ -0,0 +1,14 @@
1
+ "use client"
2
+
3
+ import { useEffect } from "react"
4
+ import { authClient } from "@/modules/auth"
5
+ import { sessionChanged, useAppDispatch } from "@/redux"
6
+
7
+ /**
8
+ * Keeps the redux session slice equal to modules/auth by subscribing to its session events: the subscription reports the
9
+ * session the reader has now, then every change. It starts no load of its own; the unsubscribe ends it.
10
+ */
11
+ export const useAuthSessionEffect = (): void => {
12
+ const dispatch = useAppDispatch()
13
+ useEffect(() => authClient.onChange((session) => dispatch(sessionChanged(session))), [dispatch])
14
+ }
@@ -0,0 +1,8 @@
1
+ "use client"
2
+
3
+ /**
4
+ * The mount point of the SWR queries that run app-wide for their side effect (hydrating redux as the route or the session
5
+ * changes) and have no component of their own. It renders nothing, so a query that changes re-renders only this
6
+ * component. A new app-wide query hook (hooks/swr/api/graphql/queries/useQuery*Swr) is called here.
7
+ */
8
+ export const SwrSideEffects = () => null
@@ -0,0 +1 @@
1
+ export { SwrSideEffects } from "./SwrSideEffects"
@@ -0,0 +1 @@
1
+ export type { GraphQLResponse } from "./types"
@@ -0,0 +1,26 @@
1
+ /** A successful operation: the value the hook hands its caller. */
2
+ export interface GraphQLSuccess<TData> {
3
+ readonly success: true
4
+ readonly data: TData
5
+ }
6
+
7
+ /**
8
+ * A failed operation: the stable code the back end answered (`extensions.code`), how it travels (`extensions.kind`:
9
+ * `unauthenticated` and `forbidden` are the refusals) and the localized message. A transport failure (network, timeout,
10
+ * abort) is `TRANSPORT_FAILED` of kind `unavailable`.
11
+ */
12
+ export interface GraphQLFailure {
13
+ readonly success: false
14
+ readonly error: string
15
+ readonly kind: string
16
+ readonly message: string
17
+ }
18
+
19
+ /** The one outcome every operation of modules/api/graphql answers; components and hooks never see an Apollo result. */
20
+ export type GraphQLResponse<TData> = GraphQLSuccess<TData> | GraphQLFailure
21
+
22
+ /** What one operation function takes: its request and the abort signal SWR hands the fetcher. */
23
+ export interface OperationParams<TRequest> {
24
+ readonly request: TRequest
25
+ readonly signal?: AbortSignal
26
+ }
@@ -0,0 +1 @@
1
+ export type { GraphQLResponse } from "./graphql"
@@ -0,0 +1,21 @@
1
+ import type { AuthClient, AuthFailure } from "./contracts"
2
+
3
+ /** The failure of every sign-in until the app wires its identity provider. */
4
+ const NOT_CONFIGURED: AuthFailure = { code: "AUTH_PROVIDER_NOT_CONFIGURED", kind: "unavailable", message: "" }
5
+
6
+ /**
7
+ * The identity-provider client of a full app: a stub with the interface every edition shares, answering "no session" until
8
+ * the app wires its IdP (OIDC with PKCE in the browser). Replace the bodies, never the interface: the Apollo links, the
9
+ * session effect and the sign-in surfaces read only AuthClient.
10
+ */
11
+ export const authClient: AuthClient = {
12
+ session: () => Promise.resolve(undefined),
13
+ accessToken: () => Promise.resolve(undefined),
14
+ refreshAccessToken: () => Promise.resolve(undefined),
15
+ signInWithPassword: () => Promise.resolve(NOT_CONFIGURED),
16
+ signOut: () => Promise.resolve(undefined),
17
+ onChange: (listener) => {
18
+ listener(undefined)
19
+ return () => undefined
20
+ },
21
+ }
@@ -0,0 +1,27 @@
1
+ /** The signed-in reader as the identity provider states it; the back end verifies the access token on every call. */
2
+ export interface AuthSession {
3
+ readonly userId: string
4
+ readonly email?: string
5
+ }
6
+
7
+ /** Why a sign-in or sign-out failed: the provider's code, its kind (`refused`, `invalid`, `unavailable`) and its message. */
8
+ export interface AuthFailure {
9
+ readonly code: string
10
+ readonly kind: string
11
+ readonly message: string
12
+ }
13
+
14
+ /**
15
+ * The identity-provider client of the app, the same interface in every edition: the session, the access token the Apollo
16
+ * link attaches, one forced refresh for the UNAUTHENTICATED replay, the sign-in and sign-out calls (each answers its failure,
17
+ * or undefined when it succeeded) and a change listener that reports the current session on subscribe, then every change.
18
+ * It never reads or writes application data; data goes through modules/api.
19
+ */
20
+ export interface AuthClient {
21
+ session(): Promise<AuthSession | undefined>
22
+ accessToken(): Promise<string | undefined>
23
+ refreshAccessToken(): Promise<string | undefined>
24
+ signInWithPassword(email: string, password: string): Promise<AuthFailure | undefined>
25
+ signOut(): Promise<AuthFailure | undefined>
26
+ onChange(listener: (session: AuthSession | undefined) => void): () => void
27
+ }
@@ -0,0 +1,2 @@
1
+ export { authClient } from "./client"
2
+ export type { AuthFailure, AuthSession } from "./contracts"
@@ -0,0 +1,52 @@
1
+ import type { GraphQLResponse } from "@/modules/api"
2
+ import { toast } from "./toast"
3
+
4
+ /** The localized labels of the toasts; the use*WithToast hooks resolve them from the catalog. */
5
+ export interface ToastMessages {
6
+ readonly successTitle: string
7
+ readonly errorTitle: string
8
+ readonly unauthorizedTitle: string
9
+ readonly unauthorizedDescription: string
10
+ readonly defaultSuccess: string
11
+ readonly defaultError: string
12
+ }
13
+
14
+ /** What a run*WithToast call takes besides the labels. */
15
+ export interface RunWithToastOptions {
16
+ /** Toast a success too (default true); an error is always toasted. */
17
+ readonly showSuccessToast?: boolean
18
+ /** The success description instead of the default one. */
19
+ readonly successMessage?: string
20
+ }
21
+
22
+ /** True when the failure is a refusal of the caller (kind `unauthenticated` or `forbidden`). */
23
+ const isRefusal = (kind: string): boolean => kind === "unauthenticated" || kind === "forbidden"
24
+
25
+ /** Toasts one GraphQLResponse: the success description, the refusal or the localized failure message. */
26
+ export const showGraphQLToast = <T>(
27
+ response: GraphQLResponse<T>,
28
+ messages: ToastMessages,
29
+ options: RunWithToastOptions = {},
30
+ ): void => {
31
+ if (response.success) {
32
+ if (options.showSuccessToast !== false)
33
+ toast.success(messages.successTitle, { description: options.successMessage ?? messages.defaultSuccess })
34
+ return
35
+ }
36
+ if (isRefusal(response.kind)) {
37
+ toast.danger(messages.unauthorizedTitle, { description: messages.unauthorizedDescription })
38
+ return
39
+ }
40
+ toast.danger(messages.errorTitle, { description: response.message || messages.defaultError })
41
+ }
42
+
43
+ /** Runs one GraphQL operation and toasts its outcome; answers the response so the caller can branch on it. */
44
+ export const runGraphQLWithToast = async <T>(
45
+ action: () => Promise<GraphQLResponse<T>>,
46
+ messages: ToastMessages,
47
+ options: RunWithToastOptions = {},
48
+ ): Promise<GraphQLResponse<T>> => {
49
+ const response = await action()
50
+ showGraphQLToast(response, messages, options)
51
+ return response
52
+ }
@@ -0,0 +1,33 @@
1
+ "use client"
2
+
3
+ import { useTranslations } from "next-intl"
4
+ import { useCallback, useMemo } from "react"
5
+ import type { GraphQLResponse } from "@/modules/api"
6
+ import { runGraphQLWithToast } from "./api"
7
+ import type { RunWithToastOptions, ToastMessages } from "./api"
8
+
9
+ /** The toast labels of the reader's locale (catalog namespace `app.toast`). */
10
+ const useToastMessages = (): ToastMessages => {
11
+ const t = useTranslations("app.toast")
12
+ return useMemo(
13
+ () => ({
14
+ successTitle: t("successTitle"),
15
+ errorTitle: t("errorTitle"),
16
+ unauthorizedTitle: t("unauthorizedTitle"),
17
+ unauthorizedDescription: t("unauthorizedDescription"),
18
+ defaultSuccess: t("defaultSuccess"),
19
+ defaultError: t("defaultError"),
20
+ }),
21
+ [t],
22
+ )
23
+ }
24
+
25
+ /** A localized runGraphQLWithToast: every GraphQL write reports its outcome through it. */
26
+ export const useGraphQLWithToast = () => {
27
+ const messages = useToastMessages()
28
+ return useCallback(
29
+ <T>(action: () => Promise<GraphQLResponse<T>>, options?: RunWithToastOptions) =>
30
+ runGraphQLWithToast(action, messages, options),
31
+ [messages],
32
+ )
33
+ }
@@ -0,0 +1 @@
1
+ export { useGraphQLWithToast } from "./hooks"