@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
@@ -0,0 +1,32 @@
1
+ import {
2
+ CheckCircleIcon,
3
+ ExclamationTriangleIcon,
4
+ InformationCircleIcon,
5
+ XCircleIcon,
6
+ } from "@heroicons/react/24/outline"
7
+ import { toast as heroToast } from "@heroui/react"
8
+ import type { ReactNode } from "react"
9
+
10
+ /** The options a status toast takes: HeroUI's, minus the variant the status already names. */
11
+ type StatusToastOptions = Parameters<typeof heroToast.success>[1]
12
+
13
+ /** The one indicator of each status, from the app's one icon set. */
14
+ const INDICATOR: Record<"success" | "danger" | "warning" | "info", ReactNode> = {
15
+ success: <CheckCircleIcon className="size-6 text-success-soft-foreground" />,
16
+ danger: <XCircleIcon className="size-6 text-danger-soft-foreground" />,
17
+ warning: <ExclamationTriangleIcon className="size-6 text-warning-soft-foreground" />,
18
+ info: <InformationCircleIcon className="size-6 text-accent" />,
19
+ }
20
+
21
+ /** The app's toast: HeroUI's imperative toast with the status indicator injected; the region is mounted by the providers. */
22
+ export const toast = {
23
+ ...heroToast,
24
+ success: (message: ReactNode, options?: StatusToastOptions) =>
25
+ heroToast.success(message, { indicator: INDICATOR.success, ...options }),
26
+ danger: (message: ReactNode, options?: StatusToastOptions) =>
27
+ heroToast.danger(message, { indicator: INDICATOR.danger, ...options }),
28
+ warning: (message: ReactNode, options?: StatusToastOptions) =>
29
+ heroToast.warning(message, { indicator: INDICATOR.warning, ...options }),
30
+ info: (message: ReactNode, options?: StatusToastOptions) =>
31
+ heroToast.info(message, { indicator: INDICATOR.info, ...options }),
32
+ }
@@ -0,0 +1,2 @@
1
+ /** Declares stylesheets loaded by the Next bundler; non-CSS side-effect imports remain checked (noUncheckedSideEffectImports). */
2
+ declare module "*.css" {}
@@ -0,0 +1,11 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { Provider } from "react-redux"
5
+ import { store } from "./store"
6
+
7
+ /** What the store provider wraps: the routed tree. */
8
+ type ReduxProviderProps = { readonly children: ReactNode }
9
+
10
+ /** Mounts the app's one store above every route (components/providers/AppProviders). */
11
+ export const ReduxProvider = (props: ReduxProviderProps) => <Provider store={store}>{props.children}</Provider>
@@ -0,0 +1,8 @@
1
+ import { useDispatch, useSelector } from "react-redux"
2
+ import type { AppDispatch, RootState } from "./store"
3
+
4
+ /** The typed dispatch of the app's store. */
5
+ export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
6
+
7
+ /** The typed selector of the app's store. */
8
+ export const useAppSelector = useSelector.withTypes<RootState>()
@@ -0,0 +1,3 @@
1
+ export { useAppDispatch, useAppSelector } from "./hooks"
2
+ export { ReduxProvider } from "./ReduxProvider"
3
+ export { sessionChanged } from "./slices/session"
@@ -0,0 +1,26 @@
1
+ import { createSlice } from "@reduxjs/toolkit"
2
+ import type { PayloadAction } from "@reduxjs/toolkit"
3
+ import type { AuthSession } from "@/modules/auth"
4
+
5
+ /** The reader's session as the app shares it: unknown until modules/auth answers, then signed in or signed out. */
6
+ export interface SessionState {
7
+ readonly status: "unknown" | "signed-in" | "signed-out"
8
+ readonly session?: AuthSession
9
+ }
10
+
11
+ const initialState: SessionState = { status: "unknown" }
12
+
13
+ /** The session slice: written only by the auth session effect (hooks/effects), read by any component through useAppSelector. */
14
+ export const sessionSlice = createSlice({
15
+ name: "session",
16
+ initialState,
17
+ reducers: {
18
+ sessionChanged: (_state, action: PayloadAction<AuthSession | undefined>): SessionState =>
19
+ action.payload === undefined ? { status: "signed-out" } : { status: "signed-in", session: action.payload },
20
+ },
21
+ })
22
+
23
+ /** The one action of the session slice: modules/auth reported a session (or none). */
24
+ export const { sessionChanged } = sessionSlice.actions
25
+ /** The reducer the store mounts under `session`. */
26
+ export const sessionReducer = sessionSlice.reducer
@@ -0,0 +1,14 @@
1
+ import { configureStore } from "@reduxjs/toolkit"
2
+ import { sessionReducer } from "./slices/session"
3
+
4
+ /** The one store of the app: app-wide shared state only (feature-local state lives in hooks/zustand). */
5
+ export const store = configureStore({
6
+ reducer: {
7
+ session: sessionReducer,
8
+ },
9
+ })
10
+
11
+ /** The state of the store, for useAppSelector. */
12
+ export type RootState = ReturnType<typeof store.getState>
13
+ /** The dispatch of the store, for useAppDispatch. */
14
+ export type AppDispatch = typeof store.dispatch
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react"
2
2
  import { LocaleShell } from "@{{project}}/ui"
3
+ import { AppProviders } from "@/components/providers/AppProviders"
3
4
  import { AppLayout, appLayoutMetadata } from "@/features/layouts/AppLayout"
4
5
  import { appI18n } from "@/modules/i18n"
5
6
  import "../globals.css"
@@ -12,10 +13,12 @@ type LayoutProps = {
12
13
  /** The localized document title of this route segment. */
13
14
  export const generateMetadata = async (props: LayoutProps) => appLayoutMetadata((await props.params).locale)
14
15
 
15
- /** The locale segment's layout slot: the document of the address's language around the product app's chrome. */
16
+ /** The locale segment's layout slot: the document of the address's language, the app's client providers and its chrome. */
16
17
  const Layout = async (props: LayoutProps) => (
17
18
  <LocaleShell {...await appI18n.readLocaleSegment(props.params)}>
18
- <AppLayout content={props.children} />
19
+ <AppProviders>
20
+ <AppLayout content={props.children} />
21
+ </AppProviders>
19
22
  </LocaleShell>
20
23
  )
21
24
 
@@ -1,4 +1,6 @@
1
1
  import { Heading } from "@starci/grammar/common"
2
+ import { SignInForm } from "@/components/blocks/SignInForm"
3
+ import { SignOutButton } from "@/components/blocks/SignOutButton"
2
4
 
3
5
  /** Props for {@link AppHomePageBase}. */
4
6
  export type AppHomePageBaseProps = {
@@ -6,5 +8,11 @@ export type AppHomePageBaseProps = {
6
8
  readonly props: { readonly title: string }
7
9
  }
8
10
 
9
- /** Draw the front door of the product app. */
10
- export const AppHomePageBase = (props: AppHomePageBaseProps) => <Heading level={1}>{props.props.title}</Heading>
11
+ /** Draw the front door of the product app: the sign-in form for a signed-out reader, the reader and sign-out once signed in. */
12
+ export const AppHomePageBase = (props: AppHomePageBaseProps) => (
13
+ <>
14
+ <Heading level={1}>{props.props.title}</Heading>
15
+ <SignInForm />
16
+ <SignOutButton />
17
+ </>
18
+ )
@@ -8,7 +8,7 @@ export const appHomeMetadata = async (): Promise<Metadata> => {
8
8
  return { title: t("title") }
9
9
  }
10
10
 
11
- /** The first page of the product app: a server component, so no catalog is shipped for it. */
11
+ /** The first page of the product app: a server component; the session surface it draws runs on the client. */
12
12
  export const AppHomePage = async () => {
13
13
  const t = await getTranslations("app.home")
14
14
  return <AppHomePageBase props={{ title: t("title") }} />
@@ -7,6 +7,29 @@
7
7
  "home": {
8
8
  "title": "Trang chủ"
9
9
  },
10
+ "signIn": {
11
+ "title": "Đăng nhập",
12
+ "email": "Email",
13
+ "password": "Mật khẩu",
14
+ "submit": "Tiếp tục",
15
+ "working": "Đang đăng nhập",
16
+ "refused": "Thông tin đăng nhập không đúng.",
17
+ "invalid": "Hãy nhập email và mật khẩu hợp lệ.",
18
+ "unavailable": "Chưa thể đăng nhập. Hãy thử lại sau."
19
+ },
20
+ "signOut": {
21
+ "signedIn": "Đã đăng nhập",
22
+ "submit": "Đăng xuất",
23
+ "working": "Đang đăng xuất"
24
+ },
25
+ "toast": {
26
+ "successTitle": "Thành công",
27
+ "errorTitle": "Lỗi",
28
+ "unauthorizedTitle": "Chưa đăng nhập",
29
+ "unauthorizedDescription": "Hãy đăng nhập để tiếp tục.",
30
+ "defaultSuccess": "Đã hoàn tất.",
31
+ "defaultError": "Đã xảy ra lỗi."
32
+ },
10
33
  "loading": {
11
34
  "message": "Đang tải"
12
35
  },
@@ -0,0 +1,2 @@
1
+ /** Declares stylesheets loaded by the Next bundler; non-CSS side-effect imports remain checked (noUncheckedSideEffectImports). */
2
+ declare module "*.css" {}
@@ -0,0 +1,51 @@
1
+ import { createClient } from "@supabase/supabase-js"
2
+ import type { Session, SupabaseClient } from "@supabase/supabase-js"
3
+ import { supabaseAnonKey, supabaseUrl } from "@/modules/config"
4
+ import type { AuthClient, AuthFailure, AuthSession } from "./contracts"
5
+
6
+ let client: SupabaseClient | undefined
7
+
8
+ /**
9
+ * The one Supabase client of the front end, used for Supabase Auth only (the PKCE flow runs in the browser: a returning
10
+ * redirect's code is exchanged by `detectSessionInUrl`, no route handler). It never calls `.from()`, `.rpc()` or storage:
11
+ * data goes through modules/api to the back end, which verifies the access token.
12
+ */
13
+ const supabase = (): SupabaseClient =>
14
+ (client ??= createClient(supabaseUrl(), supabaseAnonKey(), {
15
+ auth: { flowType: "pkce", detectSessionInUrl: true, persistSession: true, autoRefreshToken: true },
16
+ }))
17
+
18
+ const sessionOf = (session: Session | null): AuthSession | undefined =>
19
+ session === null
20
+ ? undefined
21
+ : { userId: session.user.id, ...(session.user.email === undefined ? {} : { email: session.user.email }) }
22
+
23
+ /** A Supabase Auth error as the provider reports it: its HTTP status, its code and its message. */
24
+ type ProviderError = { readonly status?: number; readonly code?: string; readonly message: string }
25
+
26
+ /** The failure kind of a Supabase Auth error: refused credentials, rejected input, or the provider unavailable. */
27
+ const kindOf = (status: number | undefined): string => {
28
+ if (status === 400 || status === 401 || status === 403) return "refused"
29
+ return status === 422 ? "invalid" : "unavailable"
30
+ }
31
+
32
+ /** The failure of a Supabase Auth call, or undefined when it succeeded. */
33
+ const failureOf = (error: ProviderError | null): AuthFailure | undefined =>
34
+ error === null
35
+ ? undefined
36
+ : { code: error.code ?? String(error.status ?? "auth"), kind: kindOf(error.status), message: error.message }
37
+
38
+ /** The identity-provider client of a lite app: Supabase Auth in the browser. */
39
+ export const authClient: AuthClient = {
40
+ session: async () => sessionOf((await supabase().auth.getSession()).data.session),
41
+ accessToken: async () => (await supabase().auth.getSession()).data.session?.access_token,
42
+ refreshAccessToken: async () => (await supabase().auth.refreshSession()).data.session?.access_token,
43
+ signInWithPassword: async (email, password) =>
44
+ failureOf((await supabase().auth.signInWithPassword({ email, password })).error),
45
+ signOut: async () => failureOf((await supabase().auth.signOut()).error),
46
+ // Supabase reports INITIAL_SESSION on subscribe, then every change: the subscription alone keeps the session current.
47
+ onChange: (listener) => {
48
+ const { data } = supabase().auth.onAuthStateChange((_event, session) => listener(sessionOf(session)))
49
+ return () => data.subscription.unsubscribe()
50
+ },
51
+ }
@@ -2,9 +2,8 @@
2
2
 
3
3
  import { I18nProvider } from "@heroui/react"
4
4
  import { NextIntlClientProvider } from "next-intl"
5
- import { useEffect, type ReactNode } from "react"
6
- import { subscribeToAuthRefresh } from "@/modules/db/browser"
7
- import { navigation } from "@/modules/i18n"
5
+ import type { ReactNode } from "react"
6
+ import { AppProviders } from "@/components/providers/AppProviders"
8
7
 
9
8
  /** What the provider stack takes from the locale layout. */
10
9
  type ProvidersProps = {
@@ -14,13 +13,11 @@ type ProvidersProps = {
14
13
  readonly children: ReactNode
15
14
  }
16
15
 
17
- /** The copy, React Aria and auth-refresh contexts above every route. */
18
- export const Providers = (props: ProvidersProps) => {
19
- const router = navigation.useRouter()
20
- useEffect(() => subscribeToAuthRefresh(() => router.refresh()), [router])
21
- return (
22
- <NextIntlClientProvider locale={props.locale} messages={props.messages} timeZone={props.timeZone}>
23
- <I18nProvider locale={props.locale}>{props.children}</I18nProvider>
24
- </NextIntlClientProvider>
25
- )
26
- }
16
+ /** The copy and React Aria contexts above every route, around the app's client providers (components/providers). */
17
+ export const Providers = (props: ProvidersProps) => (
18
+ <NextIntlClientProvider locale={props.locale} messages={props.messages} timeZone={props.timeZone}>
19
+ <I18nProvider locale={props.locale}>
20
+ <AppProviders>{props.children}</AppProviders>
21
+ </I18nProvider>
22
+ </NextIntlClientProvider>
23
+ )
@@ -1,14 +1,17 @@
1
- import { Heading, Text } from "@starci/grammar/common"
1
+ import { Heading } from "@starci/grammar/common"
2
+ import { SignInForm } from "@/components/blocks/SignInForm"
3
+ import { SignOutButton } from "@/components/blocks/SignOutButton"
2
4
 
3
- /** Props for the signed-in home page. */
5
+ /** Props for the home page: its title. */
4
6
  export type AppHomePageBaseProps = {
5
- readonly props: { readonly title: string; readonly principal: string }
7
+ readonly props: { readonly title: string }
6
8
  }
7
9
 
8
- /** Draws the signed-in front door. */
10
+ /** Draws the front door: the sign-in form for a signed-out reader, the reader and sign-out once signed in. */
9
11
  export const AppHomePageBase = (props: AppHomePageBaseProps) => (
10
12
  <>
11
13
  <Heading level={1}>{props.props.title}</Heading>
12
- <Text>{props.props.principal}</Text>
14
+ <SignInForm />
15
+ <SignOutButton />
13
16
  </>
14
17
  )
@@ -1,8 +1,5 @@
1
1
  import type { Metadata } from "next"
2
2
  import { getTranslations } from "next-intl/server"
3
- import { SignInForm } from "@/components/blocks/SignInForm"
4
- import { SignOutButton } from "@/components/blocks/SignOutButton"
5
- import { readSession } from "@/modules/db"
6
3
  import { AppHomePageBase } from "./component"
7
4
 
8
5
  /** The document title of the home page. */
@@ -11,20 +8,8 @@ export const appHomeMetadata = async (): Promise<Metadata> => {
11
8
  return { title: t("title") }
12
9
  }
13
10
 
14
- /** The front door: sign-in for an anonymous request, otherwise the verified principal. */
11
+ /** The front door (the session is client-side, drawn by the blocks the page composes). */
15
12
  export const AppHomePage = async () => {
16
- const session = await readSession()
17
- if (session.kind !== "ok") return <SignInForm />
18
13
  const t = await getTranslations("app.home")
19
- return (
20
- <>
21
- <AppHomePageBase
22
- props={{
23
- title: t("title"),
24
- principal: session.value.email ?? t("signedIn"),
25
- }}
26
- />
27
- <SignOutButton />
28
- </>
29
- )
14
+ return <AppHomePageBase props={{ title: t("title") }} />
30
15
  }
@@ -4,17 +4,10 @@ const required = (name: string, value: string | undefined): string => {
4
4
  return value
5
5
  }
6
6
 
7
- /** The public origin used for metadata and same-origin redirects. */
7
+ /** The public origin used for metadata. */
8
8
  export const siteUrl = (): string => required("NEXT_PUBLIC_SITE_URL", process.env.NEXT_PUBLIC_SITE_URL)
9
- /** The public Supabase project origin. */
9
+ /** The public Supabase project origin, read by modules/auth only. */
10
10
  export const supabaseUrl = (): string => required("NEXT_PUBLIC_SUPABASE_URL", process.env.NEXT_PUBLIC_SUPABASE_URL)
11
- /** The public anonymous key; RLS remains the authority for every request. */
11
+ /** The public anonymous key of Supabase Auth; the back end verifies every access token it is sent. */
12
12
  export const supabaseAnonKey = (): string =>
13
13
  required("NEXT_PUBLIC_SUPABASE_ANON_KEY", process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
14
-
15
- /** Security attributes applied whenever the app writes a Supabase session cookie. */
16
- export const sessionCookieOptions = {
17
- httpOnly: true,
18
- secure: process.env.NODE_ENV === "production",
19
- sameSite: "lax",
20
- } as const
@@ -1,8 +1,2 @@
1
- import { createNavigation } from "next-intl/navigation"
2
- import { routing } from "./routing"
3
-
4
1
  /** The locale served at the unprefixed path and used by the global error boundary. */
5
2
  export { DEFAULT_LOCALE, PRODUCT_TIME_ZONE, routing } from "./routing"
6
-
7
- /** Locale-aware navigation for client components. */
8
- export const navigation = createNavigation(routing)
@@ -5,8 +5,7 @@
5
5
  "brand": "{{project}}"
6
6
  },
7
7
  "home": {
8
- "title": "Home",
9
- "signedIn": "Signed in"
8
+ "title": "Home"
10
9
  },
11
10
  "signIn": {
12
11
  "title": "Sign in",
@@ -15,17 +14,21 @@
15
14
  "submit": "Continue",
16
15
  "working": "Signing in",
17
16
  "refused": "The credentials were refused.",
18
- "not-found": "The credentials were refused.",
19
17
  "invalid": "Enter a valid email and password.",
20
18
  "unavailable": "Sign-in is unavailable. Try again shortly."
21
19
  },
22
20
  "signOut": {
21
+ "signedIn": "Signed in",
23
22
  "submit": "Sign out",
24
- "working": "Signing out",
25
- "refused": "The session has already ended.",
26
- "not-found": "The session has already ended.",
27
- "invalid": "The session could not be ended.",
28
- "unavailable": "Sign-out is unavailable. Try again shortly."
23
+ "working": "Signing out"
24
+ },
25
+ "toast": {
26
+ "successTitle": "Done",
27
+ "errorTitle": "Error",
28
+ "unauthorizedTitle": "Not signed in",
29
+ "unauthorizedDescription": "Sign in to continue.",
30
+ "defaultSuccess": "The change was saved.",
31
+ "defaultError": "Something went wrong."
29
32
  },
30
33
  "loading": {
31
34
  "message": "Loading"
@@ -1,7 +1,4 @@
1
1
  /** Every internal destination the web app links to, named once. */
2
2
  export const APP_ROUTES = {
3
3
  home: "/",
4
- authCallback: "/auth/callback",
5
4
  } as const
6
-
7
- export { safeNextPath } from "./safe-next-path"
@@ -1,12 +1,10 @@
1
1
  import createMiddleware from "next-intl/middleware"
2
2
  import { NextResponse } from "next/server"
3
3
  import type { NextRequest } from "next/server"
4
- import { sessionCookieOptions } from "@/modules/config"
5
- import { refreshSession } from "@/modules/db"
6
4
  import { routing } from "@/modules/i18n"
7
- import { APP_ROUTES } from "@/modules/routes"
8
5
 
9
- const UNNEGOTIATED_ROOTS: ReadonlySet<string> = new Set(["auth", "health", "_next", "_vercel"])
6
+ /** The paths outside locale routing: the liveness route and the framework's own assets. */
7
+ const UNNEGOTIATED_ROOTS: ReadonlySet<string> = new Set(["health", "_next", "_vercel"])
10
8
  const negotiate = createMiddleware(routing)
11
9
 
12
10
  const firstSegment = (pathname: string): string => pathname.split("/")[1] ?? ""
@@ -16,28 +14,14 @@ const isUnnegotiated = (pathname: string): boolean => {
16
14
  return UNNEGOTIATED_ROOTS.has(firstSegment(pathname)) || (last.includes(".") && !last.endsWith("."))
17
15
  }
18
16
 
19
- const isPublic = (pathname: string): boolean =>
20
- pathname === "/" || pathname === `/${routing.defaultLocale}` || isUnnegotiated(pathname)
17
+ /**
18
+ * Locale routing only. The session lives in the browser (modules/auth) and the back end verifies the access token of
19
+ * every call, so the proxy neither reads nor refreshes it.
20
+ */
21
+ export const proxy = (request: NextRequest): NextResponse =>
22
+ isUnnegotiated(request.nextUrl.pathname) ? NextResponse.next() : negotiate(request)
21
23
 
22
- /** Refreshes the Supabase session, gates private paths with verified claims, then returns locale routing. */
23
- export const proxy = async (request: NextRequest): Promise<NextResponse> => {
24
- const response = isUnnegotiated(request.nextUrl.pathname) ? NextResponse.next() : negotiate(request)
25
- const principal = await refreshSession(request, response)
26
- if (principal.kind === "ok" || isPublic(request.nextUrl.pathname)) return response
27
- const destination = request.nextUrl.clone()
28
- destination.pathname = APP_ROUTES.home
29
- destination.searchParams.set("next", request.nextUrl.pathname)
30
- const redirect = NextResponse.redirect(destination)
31
- for (const cookie of response.cookies.getAll()) {
32
- redirect.cookies.set(cookie.name, cookie.value, {
33
- ...cookie,
34
- ...sessionCookieOptions,
35
- })
36
- }
37
- return redirect
38
- }
39
-
40
- /** Routes that pass through session refresh and locale negotiation. */
24
+ /** Routes that pass through locale negotiation. */
41
25
  export const config = {
42
26
  matcher: "/((?!_next/static|_next/image|favicon.ico).*)",
43
27
  }
@@ -0,0 +1,25 @@
1
+ import { gql } from "@apollo/client"
2
+ import type { TypedDocumentNode } from "@apollo/client"
3
+ import type { Create{{Singular}}Mutation, Create{{Singular}}MutationVariables } from "../__generated__/graphql"
4
+ import { createAuthApolloClient, graphqlResponseOf } from "../clients"
5
+ import type { GraphQLResponse, OperationParams } from "../types"
6
+ import type { MutateCreate{{Singular}}Data, MutateCreate{{Singular}}Request } from "./types/create-{{singular}}"
7
+
8
+ /** The `create{{Singular}}` mutation of the back end (be/src/features/api/{{name}}, create-{{singular}}). */
9
+ const MUTATION_CREATE_{{singularUpper}}: TypedDocumentNode<Create{{Singular}}Mutation, Create{{Singular}}MutationVariables> = gql`
10
+ mutation Create{{Singular}} {
11
+ create{{Singular}} {
12
+ id
13
+ }
14
+ }
15
+ `
16
+
17
+ /** Creates one {{singular}} row owned by the signed-in reader. */
18
+ export const mutateCreate{{Singular}} = async ({
19
+ request,
20
+ signal,
21
+ }: OperationParams<MutateCreate{{Singular}}Request>): Promise<GraphQLResponse<MutateCreate{{Singular}}Data>> =>
22
+ graphqlResponseOf(
23
+ await createAuthApolloClient({ signal }).mutate({ mutation: MUTATION_CREATE_{{singularUpper}}, variables: request }),
24
+ (data) => data.create{{Singular}},
25
+ )
@@ -0,0 +1,7 @@
1
+ import type { Create{{Singular}}Mutation, Create{{Singular}}MutationVariables } from "../../__generated__/graphql"
2
+
3
+ /** What mutateCreate{{Singular}} takes: the variables of the `create{{Singular}}` mutation (none: the caller owns the row). */
4
+ export type MutateCreate{{Singular}}Request = Create{{Singular}}MutationVariables
5
+
6
+ /** What mutateCreate{{Singular}} answers: the `create{{Singular}}` field. */
7
+ export type MutateCreate{{Singular}}Data = Create{{Singular}}Mutation["create{{Singular}}"]
@@ -0,0 +1,25 @@
1
+ import { gql } from "@apollo/client"
2
+ import type { TypedDocumentNode } from "@apollo/client"
3
+ import type { {{Singular}}Query, {{Singular}}QueryVariables } from "../__generated__/graphql"
4
+ import { createAuthApolloClient, graphqlResponseOf } from "../clients"
5
+ import type { GraphQLResponse, OperationParams } from "../types"
6
+ import type { Query{{Singular}}Data, Query{{Singular}}Request } from "./types/{{singular}}"
7
+
8
+ /** The `{{singularCamel}}` query of the back end (be/src/features/api/{{name}}, get-{{singular}}). */
9
+ const QUERY_{{singularUpper}}: TypedDocumentNode<{{Singular}}Query, {{Singular}}QueryVariables> = gql`
10
+ query {{Singular}}($input: Get{{Singular}}Input!) {
11
+ {{singularCamel}}(input: $input) {
12
+ id
13
+ }
14
+ }
15
+ `
16
+
17
+ /** Reads one {{singular}} row of the signed-in reader. */
18
+ export const query{{Singular}} = async ({
19
+ request,
20
+ signal,
21
+ }: OperationParams<Query{{Singular}}Request>): Promise<GraphQLResponse<Query{{Singular}}Data>> =>
22
+ graphqlResponseOf(
23
+ await createAuthApolloClient({ signal }).query({ query: QUERY_{{singularUpper}}, variables: { input: request } }),
24
+ (data) => data.{{singularCamel}},
25
+ )
@@ -0,0 +1,7 @@
1
+ import type { {{Singular}}Query, {{Singular}}QueryVariables } from "../../__generated__/graphql"
2
+
3
+ /** What query{{Singular}} takes: the input of the `{{singularCamel}}` query (generated from the back end's contract). */
4
+ export type Query{{Singular}}Request = {{Singular}}QueryVariables["input"]
5
+
6
+ /** What query{{Singular}} answers: the `{{singularCamel}}` field. */
7
+ export type Query{{Singular}}Data = {{Singular}}Query["{{singularCamel}}"]
@@ -0,0 +1,21 @@
1
+ import { useSWRConfig } from "swr"
2
+ import useSWRMutation from "swr/mutation"
3
+ import { mutateCreate{{Singular}} } from "@/modules/api/graphql/mutations/mutation-create-{{singular}}"
4
+ import { QUERY_{{singularUpper}}_SWR_KEY } from "../queries/useQuery{{Singular}}Swr"
5
+
6
+ /** The SWR key of the create-{{singular}} mutation. */
7
+ export const MUTATE_CREATE_{{singularUpper}}_SWR_KEY = "MUTATE_CREATE_{{singularUpper}}_SWR"
8
+
9
+ /**
10
+ * Creates one {{singular}} row; answers the GraphQLResponse so the caller reports it through modules/toast
11
+ * (useGraphQLWithToast). A success primes the {{singular}} query of the new row, the resource the mutation is tied to.
12
+ */
13
+ export const useMutateCreate{{Singular}}Swr = () => {
14
+ const { mutate } = useSWRConfig()
15
+ return useSWRMutation(MUTATE_CREATE_{{singularUpper}}_SWR_KEY, async () => {
16
+ const response = await mutateCreate{{Singular}}({ request: {} })
17
+ if (response.success)
18
+ await mutate([QUERY_{{singularUpper}}_SWR_KEY, response.data.id], response.data, { revalidate: false })
19
+ return response
20
+ })
21
+ }
@@ -0,0 +1,17 @@
1
+ import useSWR from "swr"
2
+ import { query{{Singular}} } from "@/modules/api/graphql/queries/query-{{singular}}"
3
+ import type { Query{{Singular}}Data } from "@/modules/api/graphql/queries/types/{{singular}}"
4
+
5
+ /** The SWR key of the {{singular}} query; the full key carries the row id, the identity of its result. */
6
+ export const QUERY_{{singularUpper}}_SWR_KEY = "QUERY_{{singularUpper}}_SWR"
7
+
8
+ /** One {{singular}} row of the signed-in reader by id; no id, no request. A failure is thrown as the GraphQLResponse's message. */
9
+ export const useQuery{{Singular}}Swr = (id: string | undefined) =>
10
+ useSWR(
11
+ id === undefined ? null : ([QUERY_{{singularUpper}}_SWR_KEY, id] as const),
12
+ async ([, rowId]): Promise<Query{{Singular}}Data> => {
13
+ const response = await query{{Singular}}({ request: { id: rowId } })
14
+ if (!response.success) throw new Error(response.message, { cause: response })
15
+ return response.data
16
+ },
17
+ )
@@ -0,0 +1,39 @@
1
+ import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client"
2
+ import { authClient } from "@/modules/auth"
3
+ import {
4
+ createAttachAccessTokenLink,
5
+ createAttachLocaleLink,
6
+ createErrorLink,
7
+ createHttpLink,
8
+ createRefreshTokenLink,
9
+ createRetryLink,
10
+ createTimeoutLink,
11
+ defaultOptions,
12
+ } from "../links"
13
+ import type { ErrorReporter } from "../links/error"
14
+
15
+ /** What the authenticated client takes: the abort signal of its operations and where the error link reports (none: it stays silent). */
16
+ export interface CreateAuthApolloClientOptions {
17
+ readonly signal?: AbortSignal
18
+ readonly report?: ErrorReporter
19
+ }
20
+
21
+ /**
22
+ * The authenticated Apollo client. The link chain runs outer to inner:
23
+ * retry (transient network failures) -> error (report) -> timeout -> refresh (one token refresh and replay on
24
+ * UNAUTHENTICATED) -> locale (`x-locale`) -> access token (`Authorization: Bearer`, from modules/auth) -> http (terminal).
25
+ */
26
+ export const createAuthApolloClient = ({ signal, report }: CreateAuthApolloClientOptions = {}) =>
27
+ new ApolloClient({
28
+ link: ApolloLink.from([
29
+ createRetryLink(),
30
+ createErrorLink(report),
31
+ createTimeoutLink(),
32
+ createRefreshTokenLink(() => authClient.refreshAccessToken()),
33
+ createAttachLocaleLink(),
34
+ createAttachAccessTokenLink(() => authClient.accessToken()),
35
+ createHttpLink({ signal }),
36
+ ]),
37
+ cache: new InMemoryCache(),
38
+ defaultOptions,
39
+ })
@@ -0,0 +1,2 @@
1
+ export { createAuthApolloClient } from "./auth"
2
+ export { createNoAuthApolloClient } from "./no-auth"