@starci/hfs 5.1.0 → 5.2.0

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 (199) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +10 -1
  3. package/db/postgres.mjs +9 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +30 -9
  6. package/runtime/knowledge/hfs/fe-stack.yaml +7 -7
  7. package/runtime/knowledge/hfs/fork-images.yaml +7 -0
  8. package/runtime/knowledge/hfs/rules.yaml +55 -7
  9. package/runtime/knowledge/hfs/slots.yaml +75 -24
  10. package/runtime/modules/kernel/failure-codes.yaml +62 -0
  11. package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
  12. package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
  13. package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
  14. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
  15. package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
  16. package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
  17. package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
  18. package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
  19. package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
  20. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
  21. package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
  22. package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
  23. package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
  24. package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
  25. package/runtime/scripts/hfs/secret.mjs +10 -7
  26. package/runtime/scripts/hfs/slots.mjs +1 -1
  27. package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
  28. package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
  29. package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
  30. package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
  31. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  32. package/scaffold/add-module.d.mts +16 -0
  33. package/scaffold/add-module.mjs +232 -0
  34. package/scaffold/add-queue-lite.mjs +49 -0
  35. package/scaffold/add.mjs +5 -0
  36. package/scaffold/app.mjs +7 -7
  37. package/scaffold/module-registry.d.mts +19 -0
  38. package/scaffold/module-registry.mjs +84 -0
  39. package/src/main.mjs +23 -2
  40. package/stack/cli.mjs +6 -1
  41. package/stack/context.d.mts +15 -0
  42. package/stack/context.mjs +1 -0
  43. package/stack/local-cli.d.mts +25 -0
  44. package/stack/local-cli.mjs +89 -0
  45. package/stack/local-config.d.mts +17 -0
  46. package/stack/local-config.mjs +37 -0
  47. package/stack/local-custody.d.mts +24 -0
  48. package/stack/local-custody.mjs +109 -0
  49. package/stack/local-doctor.d.mts +6 -0
  50. package/stack/local-doctor.mjs +49 -0
  51. package/stack/local-down.d.mts +8 -0
  52. package/stack/local-down.mjs +20 -0
  53. package/stack/local-host.d.mts +42 -0
  54. package/stack/local-host.mjs +163 -0
  55. package/stack/local-logs.d.mts +8 -0
  56. package/stack/local-logs.mjs +32 -0
  57. package/stack/local-ports.d.mts +7 -0
  58. package/stack/local-ports.mjs +39 -0
  59. package/stack/local-prepare.d.mts +22 -0
  60. package/stack/local-prepare.mjs +62 -0
  61. package/stack/local-run.d.mts +71 -0
  62. package/stack/local-run.mjs +200 -0
  63. package/stack/local-state.d.mts +35 -0
  64. package/stack/local-state.mjs +29 -0
  65. package/stack/local-status.d.mts +6 -0
  66. package/stack/local-status.mjs +36 -0
  67. package/stack/local-up.d.mts +11 -0
  68. package/stack/local-up.mjs +166 -0
  69. package/stack/local-verify.d.mts +6 -0
  70. package/stack/local-verify.mjs +21 -0
  71. package/sync/index.d.mts +13 -12
  72. package/sync/index.mjs +83 -28
  73. package/templates/app/module/jest.config.js.tpl +8 -0
  74. package/templates/app/module/src/index.ts.tpl +2 -0
  75. package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
  76. package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
  77. package/templates/app/module/src/manifest/index.ts.tpl +1 -0
  78. package/templates/app/module/src/public/index.ts.tpl +2 -0
  79. package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
  80. package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
  81. package/templates/app/module/src/template/index.ts.tpl +1 -0
  82. package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
  83. package/templates/app/module/src/workbench/index.ts.tpl +2 -0
  84. package/templates/app/package-scripts/package.json +1 -1
  85. package/templates/app/package-scripts-lite/package.json +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +76 -70
  87. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +23 -13
  88. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +25 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -26
  90. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +2 -1
  92. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +57 -14
  93. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +11 -0
  94. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
  95. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
  96. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
  97. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
  98. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
  99. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
  100. package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
  101. package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
  103. package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
  104. package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
  105. package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
  106. package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
  107. package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
  108. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +13 -0
  109. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +16 -0
  110. package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
  111. package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
  112. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +13 -0
  113. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
  114. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
  115. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +90 -0
  116. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
  117. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
  118. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
  119. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +28 -0
  120. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +145 -0
  121. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +95 -0
  122. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +38 -0
  123. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +54 -0
  124. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
  125. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
  126. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
  127. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +17 -0
  128. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
  129. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
  130. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
  131. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
  132. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +78 -0
  133. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +87 -0
  134. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +44 -0
  135. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
  136. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
  137. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +10 -5
  138. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +34 -0
  139. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
  140. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +47 -2
  141. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -0
  142. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +24 -0
  143. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
  144. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
  145. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
  146. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +12 -0
  147. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
  148. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
  149. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
  150. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
  151. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
  152. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
  153. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
  154. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
  158. package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
  159. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
  160. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  161. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -4
  162. package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
  163. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
  164. package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
  165. package/templates/fe/common/site-shell.tsx.partial +3 -3
  166. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
  167. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
  168. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
  169. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
  170. package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
  171. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
  172. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -1
  173. package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
  174. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
  175. package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
  176. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
  177. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
  178. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
  179. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
  180. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
  181. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
  182. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
  183. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
  184. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
  185. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
  186. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
  187. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
  188. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
  189. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
  190. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
  191. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
  192. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
  193. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
  194. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
  195. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
  196. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
  197. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
  198. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
  199. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
@@ -1,24 +1,46 @@
1
1
  import type { EnvSource } from "@modules/platform/config"
2
2
  import type { PassportOptions } from "./passport.options"
3
3
 
4
- /** Reads the passport configuration with no credential or localhost defaults; secrets read `<KEY>_FILE` first. */
4
+ const ACCESS_TTL_SECONDS = 900
5
+ const REFRESH_TTL_SECONDS = 2_592_000
6
+ const BCRYPT_COST = 10
7
+ const LOGIN_ATTEMPTS = 10
8
+ const RESET_ATTEMPTS = 5
9
+ const ATTEMPT_WINDOW_MS = 900_000
10
+
11
+ /** The Google client, or null when the deployment declares none (the Google door is then unavailable). */
12
+ const readGoogle = (env: EnvSource): PassportOptions["google"] =>
13
+ env.has("GOOGLE_CLIENT_ID")
14
+ ? {
15
+ clientId: env.string("GOOGLE_CLIENT_ID"),
16
+ clientSecret: env.secret("GOOGLE_CLIENT_SECRET"),
17
+ callbackUrl: env.url("GOOGLE_CALLBACK_URL"),
18
+ }
19
+ : null
20
+
21
+ /** The key set: the signing key, and the previous public key while its access tokens are still valid (a rotation). */
22
+ const readKeys = (env: EnvSource): PassportOptions["keys"] => {
23
+ const previous = env.optional("AUTH_JWT_PREVIOUS_PUBLIC_KEY")
24
+ return [
25
+ { privateKey: env.secret("AUTH_JWT_PRIVATE_KEY"), keyId: env.optional("AUTH_JWT_KEY_ID") },
26
+ ...(previous === undefined ? [] : [{ publicKey: previous, keyId: env.optional("AUTH_JWT_PREVIOUS_KEY_ID") }]),
27
+ ]
28
+ }
29
+
30
+ /** Reads the sign-in configuration with no credential or localhost defaults; secrets read `<KEY>_FILE` first. */
5
31
  export const parsePassportConfig = (env: EnvSource): PassportOptions => ({
6
- google: {
7
- clientId: env.string("GOOGLE_CLIENT_ID"),
8
- clientSecret: env.secret("GOOGLE_CLIENT_SECRET"),
9
- callbackUrl: env.url("GOOGLE_CALLBACK_URL"),
10
- },
11
- tokens: {
12
- issuer: env.url("AUTH_JWT_ISSUER"),
13
- audience: env.string("AUTH_JWT_AUDIENCE"),
14
- privateKey: env.secret("AUTH_JWT_PRIVATE_KEY"),
15
- keyId: env.string("AUTH_JWT_KEY_ID"),
16
- accessTtlSeconds: env.int("AUTH_ACCESS_TTL_SECONDS", 900),
17
- },
18
- refresh: {
19
- name: "refresh_token",
20
- path: "/auth/refresh",
21
- ttlSeconds: env.int("AUTH_REFRESH_TTL_SECONDS", 2_592_000),
22
- cookieDomain: env.optionalDomain("AUTH_COOKIE_DOMAIN"),
32
+ issuer: env.url("AUTH_JWT_ISSUER"),
33
+ audience: env.optional("AUTH_JWT_AUDIENCE"),
34
+ keys: readKeys(env),
35
+ accessTtlSeconds: env.int("AUTH_ACCESS_TTL_SECONDS", ACCESS_TTL_SECONDS),
36
+ refreshTtlSeconds: env.int("AUTH_REFRESH_TTL_SECONDS", REFRESH_TTL_SECONDS),
37
+ bcryptCost: env.int("AUTH_BCRYPT_COST", BCRYPT_COST),
38
+ returnOrigin: new URL(env.url("AUTH_WEB_ORIGIN")).origin,
39
+ google: readGoogle(env),
40
+ cookie: { name: "refresh_token", domain: env.optionalDomain("AUTH_COOKIE_DOMAIN") },
41
+ budget: {
42
+ loginAttempts: env.int("AUTH_LOGIN_ATTEMPTS", LOGIN_ATTEMPTS),
43
+ resetAttempts: env.int("AUTH_RESET_ATTEMPTS", RESET_ATTEMPTS),
44
+ windowMs: env.duration("AUTH_ATTEMPT_WINDOW", ATTEMPT_WINDOW_MS),
23
45
  },
24
46
  })
@@ -1,14 +1,20 @@
1
+ import { AUTH_DOORS_SERVICE, AUTH_PASSWORDS, AUTH_SESSIONS, AUTH_SIGN_IN_BUDGET } from "@starci/auth/server"
1
2
  import { injector } from "@modules/platform/composition"
2
3
  import type { TypedParameterDecorator } from "@modules/platform/composition"
3
4
  import type { PassportOptions, VerifyAccessToken } from "./passport.options"
4
- import type { FindPasswordAccount } from "./strategies/local.strategy"
5
5
 
6
6
  /** Token of the passport options. */
7
7
  export const PASSPORT_OPTIONS: unique symbol = Symbol("integrations.passport.options")
8
8
  /** Token of the verifier of the app's own access tokens. */
9
9
  export const OWN_TOKEN_VERIFIER: unique symbol = Symbol("integrations.passport.own-token-verifier")
10
- /** Token of the password-account lookup the identity domain provides. */
11
- export const PASSWORD_ACCOUNTS: unique symbol = Symbol("integrations.passport.password-accounts")
10
+ /** Token of the session store the identity domain implements over the package's tables. */
11
+ export const PASSPORT_SESSION_STORE: unique symbol = Symbol("integrations.passport.session-store")
12
+ /** Token of the account directory the identity domain implements over the user table. */
13
+ export const PASSPORT_ACCOUNTS: unique symbol = Symbol("integrations.passport.accounts")
14
+ /** Token of the rate counter the identity domain implements over its attempts table. */
15
+ export const PASSPORT_RATE_COUNTER: unique symbol = Symbol("integrations.passport.rate-counter")
16
+ /** Token of the sign-in decisions (password and Google) the identity domain makes. */
17
+ export const PASSPORT_AUTHENTICATOR: unique symbol = Symbol("integrations.passport.authenticator")
12
18
 
13
19
  /** Injects the passport options. Parameter type: PassportOptions. */
14
20
  export const InjectPassportOptions = (): TypedParameterDecorator<PassportOptions> => injector<PassportOptions>(PASSPORT_OPTIONS)
@@ -17,6 +23,12 @@ export const InjectPassportOptions = (): TypedParameterDecorator<PassportOptions
17
23
  export const InjectOwnTokenVerifier = (): TypedParameterDecorator<VerifyAccessToken> =>
18
24
  injector<VerifyAccessToken>(OWN_TOKEN_VERIFIER)
19
25
 
20
- /** Injects the password-account lookup. Parameter type: FindPasswordAccount. */
21
- export const InjectPasswordAccounts = (): TypedParameterDecorator<FindPasswordAccount> =>
22
- injector<FindPasswordAccount>(PASSWORD_ACCOUNTS)
26
+ /** Token of the sign-in doors. */
27
+ export const PASSPORT_DOORS: typeof AUTH_DOORS_SERVICE = AUTH_DOORS_SERVICE
28
+ /** Token of the rotating sessions. */
29
+ export const PASSPORT_SESSIONS: typeof AUTH_SESSIONS = AUTH_SESSIONS
30
+ /** Token of the password hasher. */
31
+ export const PASSPORT_PASSWORDS: typeof AUTH_PASSWORDS = AUTH_PASSWORDS
32
+ /** Token of the failures-only sign-in budget. */
33
+ export const PASSPORT_SIGN_IN_BUDGET: typeof AUTH_SIGN_IN_BUDGET = AUTH_SIGN_IN_BUDGET
34
+
@@ -0,0 +1,73 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { AUTH_ERROR_CODES, AUTH_ERROR_KINDS } from "@starci/auth/contracts"
3
+ import { generateTestKeyPair } from "@starci/auth/testing"
4
+ import { PASSPORT_ERROR_KINDS, PassportErrorCode } from "./errors/passport.error"
5
+ import { passportKit } from "./passport.kit"
6
+ import type { PassportOptions } from "./passport.options"
7
+
8
+ const KIT: unique symbol = Symbol("passport.kit")
9
+
10
+ const FAR_FUTURE = 32_503_680_000_000
11
+ const LONG_AGO = 0
12
+
13
+ const build = async () => {
14
+ const moduleRef = await Test.createTestingModule({ providers: [{ provide: KIT, useValue: { passportKit } }] }).compile()
15
+ return moduleRef.get<{ passportKit: typeof passportKit }>(KIT).passportKit
16
+ }
17
+
18
+ const optionsFor = (keys: PassportOptions["keys"]): PassportOptions => ({
19
+ issuer: "https://api.example.test",
20
+ audience: undefined,
21
+ keys,
22
+ accessTtlSeconds: 60,
23
+ refreshTtlSeconds: 3600,
24
+ reuseGraceMs: undefined,
25
+ bcryptCost: 4,
26
+ returnOrigin: "https://app.example.test",
27
+ google: null,
28
+ cookie: { name: "refresh_token" },
29
+ budget: { loginAttempts: 10, resetAttempts: 5, windowMs: 900_000 },
30
+ })
31
+
32
+ describe("PassportErrorCode", () => {
33
+ it("names every PASSPORT wire code of @starci/auth with the same kind", () => {
34
+ const wire = Object.values(AUTH_ERROR_CODES).filter((code) => code.startsWith("PASSPORT_"))
35
+
36
+ expect(Object.values(PassportErrorCode).sort()).toEqual(wire.sort())
37
+ for (const code of Object.values(PassportErrorCode)) expect(PASSPORT_ERROR_KINDS[code]).toBe(AUTH_ERROR_KINDS[code])
38
+ })
39
+ })
40
+
41
+ describe("passportKit", () => {
42
+ it("verifies a token its own signer issued and builds the principal from it", async () => {
43
+ const create = await build()
44
+ const kit = create(optionsFor([{ privateKey: generateTestKeyPair().privateKey, keyId: "k1" }]))
45
+ const { token } = await kit.tokens.sign({ sub: "user-1", email: "a@example.test", sessionId: "s-1" }, new Date(FAR_FUTURE))
46
+
47
+ expect(await kit.verify(token)).toEqual({ kind: "ok", value: { id: "user-1", role: "authenticated", sessionId: "s-1", email: "a@example.test" } })
48
+ expect((await kit.tokens.jwks()).keys).toHaveLength(1)
49
+ })
50
+
51
+ it("refuses an expired token, a token of another key and a token that is no token", async () => {
52
+ const create = await build()
53
+ const kit = create(optionsFor([{ privateKey: generateTestKeyPair().privateKey }]))
54
+ const stranger = create(optionsFor([{ privateKey: generateTestKeyPair().privateKey }]))
55
+ const expired = await kit.tokens.sign({ sub: "user-1", sessionId: "s-1" }, new Date(LONG_AGO))
56
+ const foreign = await stranger.tokens.sign({ sub: "user-1", sessionId: "s-1" }, new Date(FAR_FUTURE))
57
+
58
+ for (const token of [expired.token, foreign.token, "not-a-token"]) {
59
+ expect(await kit.verify(token)).toEqual({ kind: "refused", code: PassportErrorCode.AccessTokenInvalid })
60
+ }
61
+ })
62
+
63
+ it("keeps verifying the token of a retired key while its public key stays in the set", async () => {
64
+ const create = await build()
65
+ const retired = generateTestKeyPair()
66
+ const before = create(optionsFor([{ privateKey: retired.privateKey, keyId: "old" }]))
67
+ const { token } = await before.tokens.sign({ sub: "user-1", sessionId: "s-1" }, new Date(FAR_FUTURE))
68
+ const after = create(optionsFor([{ privateKey: generateTestKeyPair().privateKey, keyId: "new" }, { publicKey: retired.publicKey, keyId: "old" }]))
69
+
70
+ expect((await after.verify(token)).kind).toBe("ok")
71
+ expect((await after.tokens.jwks()).keys.map((key) => key["kid"])).toEqual(["new", "old"])
72
+ })
73
+ })
@@ -0,0 +1,26 @@
1
+ import { AuthPrincipalVerifier, AuthTokens } from "@starci/auth/server"
2
+ import { ok, refused } from "@modules/platform/primitives"
3
+ import { PassportErrorCode } from "./errors/passport.error"
4
+ import type { PassportOptions, VerifyAccessToken } from "./passport.options"
5
+
6
+ /** The signer of the app's own access tokens (it also serves the JWKS) and the verifier `domain/identity` turns into a principal. */
7
+ export interface PassportKit {
8
+ /** Signs access tokens and publishes every key of the set. */
9
+ readonly tokens: AuthTokens
10
+ /** Verifies one bearer token against the app's own key set. */
11
+ readonly verify: VerifyAccessToken
12
+ }
13
+
14
+ /**
15
+ * Builds the kit from the typed options. The key set is imported on the first use, so a bad key fails the first verification
16
+ * instead of hiding behind a default; the package verifies against its own key set only, never a remote one.
17
+ */
18
+ export const passportKit = (options: PassportOptions): PassportKit => {
19
+ const tokens = new AuthTokens(options)
20
+ const verifier = new AuthPrincipalVerifier(tokens)
21
+ const verify: VerifyAccessToken = async (token) => {
22
+ const verified = await verifier.verify(token)
23
+ return verified.kind === "ok" ? ok(verified.value) : refused(PassportErrorCode.AccessTokenInvalid)
24
+ }
25
+ return { tokens, verify }
26
+ }
@@ -1,68 +1,39 @@
1
- import type { Secret } from "@modules/platform/config"
1
+ import type { AuthOptions } from "@starci/auth/server"
2
2
  import type { Outcome } from "@modules/platform/primitives"
3
3
  import type { PassportErrorCode } from "./errors/passport.error"
4
4
 
5
+ /**
6
+ * The typed settings of the sign-in, parsed once at boot by `passport.config.ts` and handed to @starci/auth: the signing keys
7
+ * (the first with a private key signs; every key verifies and is published as the JWKS), the access and refresh lifetimes, the
8
+ * Google client, the refresh cookie and the sign-in budget. The stores the package needs (sessions, accounts, rate counter)
9
+ * are not settings: `domain/identity` provides them.
10
+ */
11
+ export type PassportOptions = Pick<
12
+ AuthOptions,
13
+ | "issuer"
14
+ | "audience"
15
+ | "keys"
16
+ | "accessTtlSeconds"
17
+ | "refreshTtlSeconds"
18
+ | "reuseGraceMs"
19
+ | "bcryptCost"
20
+ | "returnOrigin"
21
+ | "google"
22
+ | "cookie"
23
+ | "budget"
24
+ >
25
+
5
26
  /** The claims admitted from one of the app's own access tokens after every cryptographic and semantic check. */
6
27
  export interface OwnPrincipal {
7
28
  /** The user identifier from the verified `sub` claim (a row of auth.users). */
8
29
  readonly id: string
9
30
  /** The verified email claim when the user has one. */
10
31
  readonly email?: string
32
+ /** The device session the token belongs to, when the token names one. */
33
+ readonly sessionId?: string
11
34
  /** The role every access token the app issues carries; any other role is refused. */
12
35
  readonly role: "authenticated"
13
36
  }
14
37
 
15
38
  /** Verifies one bearer token against the app's own key set and returns only claims that passed every check. */
16
39
  export type VerifyAccessToken = (token: string) => Promise<Outcome<OwnPrincipal, PassportErrorCode>>
17
-
18
- /** Issues one short-lived access token for a signed-in user. */
19
- export type IssueAccessToken = (user: { readonly id: string; readonly email?: string }) => Promise<string>
20
-
21
- /** Google OAuth 2.0 client configuration (authorization code with state and PKCE). */
22
- export interface GoogleOptions {
23
- /** The OAuth client id. */
24
- readonly clientId: string
25
- /** The OAuth client secret. */
26
- readonly clientSecret: Secret
27
- /** The absolute callback URL of the back end's Google callback door. */
28
- readonly callbackUrl: string
29
- }
30
-
31
- /** The app's own asymmetric access tokens and the JWKS that publishes their public key. */
32
- export interface TokenOptions {
33
- /** The issuer (`iss`), the app's public API origin. */
34
- readonly issuer: string
35
- /** The audience (`aud`) every token names. */
36
- readonly audience: string
37
- /** The ES256 private key in PKCS#8 PEM; its public half is served as the JWKS. */
38
- readonly privateKey: Secret
39
- /** The key id (`kid`) of the signing key. */
40
- readonly keyId: string
41
- /** The access-token lifetime in seconds. */
42
- readonly accessTtlSeconds: number
43
- }
44
-
45
- /** The rotating refresh token: an opaque value held only in an httpOnly, Secure, SameSite=Strict cookie. */
46
- export interface RefreshCookieOptions {
47
- /** The cookie name. */
48
- readonly name: string
49
- /** The path the browser sends the cookie to: the refresh door only. */
50
- readonly path: string
51
- /** The refresh-token lifetime in seconds. */
52
- readonly ttlSeconds: number
53
- /**
54
- * The bare registrable domain every cookie passport sets and clears carries as `Domain` (`example.com`, no scheme,
55
- * no port), so a web app on another subdomain can forward the cookie to this API. Unset keeps the cookies host-only.
56
- */
57
- readonly cookieDomain?: string | undefined
58
- }
59
-
60
- /** The passport integration configuration parsed once at API boot. */
61
- export interface PassportOptions {
62
- /** Google OAuth 2.0. */
63
- readonly google: GoogleOptions
64
- /** The app's own access tokens. */
65
- readonly tokens: TokenOptions
66
- /** The refresh cookie. */
67
- readonly refresh: RefreshCookieOptions
68
- }
@@ -0,0 +1,31 @@
1
+ import { AuthModule } from "@starci/auth/server"
2
+ import type { AccountDirectory, RateCounter, SessionStore } from "@starci/auth/server"
3
+ import type { DynamicModule } from "@nestjs/common"
4
+ import { CLOCK } from "@modules/platform/clock"
5
+ import type { Clock } from "@modules/platform/clock"
6
+ import { IDS } from "@modules/platform/ids"
7
+ import type { Ids } from "@modules/platform/ids"
8
+ import { PASSPORT_ACCOUNTS, PASSPORT_AUTHENTICATOR, PASSPORT_RATE_COUNTER, PASSPORT_SESSION_STORE } from "./passport.decorators"
9
+ import type { PassportOptions } from "./passport.options"
10
+
11
+ /** Whether the app root registers the sign-in package globally: the root decides, never the module. */
12
+ export interface PassportRegistration {
13
+ /** True when every module of the app may inject what the package provides, without importing it. */
14
+ readonly isGlobal: boolean
15
+ }
16
+
17
+ /** The sign-in package as a Nest module: the typed options, the clock and ids of the app, and the stores the identity domain provides. */
18
+ export const passportModule = (options: PassportOptions, registration: PassportRegistration): DynamicModule =>
19
+ AuthModule.registerAsync({
20
+ isGlobal: registration.isGlobal,
21
+ authenticator: PASSPORT_AUTHENTICATOR,
22
+ inject: [PASSPORT_SESSION_STORE, PASSPORT_ACCOUNTS, PASSPORT_RATE_COUNTER, CLOCK, IDS],
23
+ useFactory: (sessions: SessionStore, accounts: AccountDirectory, rate: RateCounter, clock: Clock, ids: Ids) => ({
24
+ ...options,
25
+ sessions,
26
+ accounts,
27
+ rate,
28
+ clock,
29
+ ids,
30
+ }),
31
+ })
@@ -1,4 +1,4 @@
1
1
  export type { Principal, PublicExecuteParams } from "./cqrs.contracts"
2
- export { InjectQueryBus } from "./cqrs.decorators"
2
+ export { InjectCommandBus, InjectQueryBus } from "./cqrs.decorators"
3
3
  export { ICQRSHandler } from "./cqrs.handler"
4
4
  export { CqrsModule } from "./cqrs.module"
@@ -1,3 +1,4 @@
1
+ import { identityEntities } from "@modules/domain/identity"
1
2
  import { httpSecurityEntities } from "@modules/platform/http-security"
2
3
  import type { EntityClass } from "./database.options"
3
4
 
@@ -7,4 +8,4 @@ import type { EntityClass } from "./database.options"
7
8
  * file, from the deep path `@modules/platform/database/entities`; DatabaseModule and the database barrel never do, since the
8
9
  * capability barrels import the database capability back.
9
10
  */
10
- export const PRIMARY_ENTITIES: ReadonlyArray<EntityClass> = [...httpSecurityEntities]
11
+ export const PRIMARY_ENTITIES: ReadonlyArray<EntityClass> = [...httpSecurityEntities, ...identityEntities]
@@ -6,7 +6,7 @@ export type { HttpSecurityOptions } from "./http-security.options"
6
6
  export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
7
7
  export { OriginGuard, ServerToServer } from "./origin.guard"
8
8
  export { httpSecurityEntities } from "./persistence/entities"
9
- export { RateLimitGuard } from "./rate-limit.guard"
9
+ export { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
10
10
  export { applyHttpSecurity } from "./edge-security.service"
11
11
  export { ReadinessService } from "./readiness.service"
12
12
  export { InjectReadiness } from "./readiness.decorators"
@@ -27,9 +27,16 @@ export const buildWiringProof = (): WiringProof => ({
27
27
  schema: "public",
28
28
  },
29
29
  passport: {
30
- google: { clientId: "fixture", clientSecret: new Secret("fixture-only"), callbackUrl: "https://api.fixture.example/auth/google/callback" },
31
- tokens: { issuer: "https://api.fixture.example", audience: "fixture", privateKey: new Secret("fixture-only"), keyId: "k1", accessTtlSeconds: 900 },
32
- refresh: { name: "refresh_token", path: "/auth/refresh", ttlSeconds: 2592000 },
30
+ issuer: "https://api.fixture.example",
31
+ audience: "fixture",
32
+ keys: [{ privateKey: new Secret("fixture-only"), keyId: "k1" }],
33
+ accessTtlSeconds: 900,
34
+ refreshTtlSeconds: 2592000,
35
+ bcryptCost: 4,
36
+ returnOrigin: "https://app.fixture.example",
37
+ google: null,
38
+ cookie: { name: "refresh_token" },
39
+ budget: { loginAttempts: 10, resetAttempts: 5, windowMs: 900000 },
33
40
  },
34
41
  objectStorage: {
35
42
  endpoint: "https://storage.fixture.example",
@@ -37,7 +44,7 @@ export const buildWiringProof = (): WiringProof => ({
37
44
  bucket: "fixture",
38
45
  accessKeyId: new Secret("fixture-only"),
39
46
  secretAccessKey: new Secret("fixture-only"),
40
- presignTtlSeconds: 300,
47
+ defaultExpiresIn: 300,
41
48
  },
42
49
  },
43
50
  overrides: [
@@ -10,23 +10,23 @@ describe("system-health: the {{app}} app serves once its database is migrated (e
10
10
  const world = useTestWorld({ apps: ["{{app}}"] })
11
11
 
12
12
  it("answers liveness and readiness over the migrated {{connection}} database", async () => {
13
- const live = await world.apps{{appAccess}}.api.get("/health/live")
14
- const ready = await world.apps{{appAccess}}.api.get("/health/ready")
13
+ const live = await world.apps{{appAccess}}?.api.get("/health/live")
14
+ const ready = await world.apps{{appAccess}}?.api.get("/health/ready")
15
15
  const applied = await appliedMigrations(world.db{{connectionAccess}}, {{migrationsConst}})
16
16
 
17
- expect(live.status).toBe(200)
18
- expect(ready.status).toBe(200)
19
- expect(ready.body).toEqual({ ok: true })
17
+ expect(live?.status).toBe(200)
18
+ expect(ready?.status).toBe(200)
19
+ expect(ready?.body).toEqual({ ok: true })
20
20
  expect(applied.length).toBeGreaterThan(0)
21
21
  })
22
22
 
23
23
  it("answers 503 on readiness while the database is cut, and liveness stays up", async () => {
24
24
  await world.interruptDatabase(async () => {
25
- const ready = await world.apps{{appAccess}}.api.get("/health/ready")
26
- const live = await world.apps{{appAccess}}.api.get("/health/live")
25
+ const ready = await world.apps{{appAccess}}?.api.get("/health/ready")
26
+ const live = await world.apps{{appAccess}}?.api.get("/health/live")
27
27
 
28
- expect(ready.status).toBe(503)
29
- expect(live.status).toBe(200)
28
+ expect(ready?.status).toBe(503)
29
+ expect(live?.status).toBe(200)
30
30
  })
31
31
  })
32
32
  })
@@ -1,6 +1,5 @@
1
1
  import type { EntityManager } from "typeorm"
2
2
  import { sql } from "@modules/platform/database"
3
- import type { SqlText } from "@modules/platform/database"
4
3
 
5
4
  /** One row of a connection's migration ledger: the name of a migration the cli migrate step applied. */
6
5
  export interface AppliedMigrationRow {
@@ -8,8 +7,9 @@ export interface AppliedMigrationRow {
8
7
  readonly name: string
9
8
  }
10
9
  {{migrationQueries}}
11
- /** Reads the ledger of one connection with one of the queries above. */
10
+ /** Reads the ledger of one connection with one of the queries above; a connection the world did not wire fails the spec. */
12
11
  export const appliedMigrations = (
13
- manager: EntityManager,
12
+ manager: EntityManager | undefined,
14
13
  query: ReturnType<typeof sql>,
15
- ): Promise<ReadonlyArray<AppliedMigrationRow>> => manager.query(query)
14
+ ): Promise<ReadonlyArray<AppliedMigrationRow>> =>
15
+ manager === undefined ? Promise.reject(new Error("The test world wired no such connection")) : manager.query(query)
@@ -0,0 +1,26 @@
1
+ // Managed by starci app sync (slot fe.modules.auth.client); do not edit: run "npx starci app sync --execute".
2
+ import { createAuthClient } from "@starci/auth/client"
3
+ import { apiUrl } from "@/modules/config"
4
+ import type { AuthClient, AuthFailure, AuthSession } from "./contracts"
5
+
6
+ /** The one instance of the front end: the doors of the app's own back end, the access token in its memory, one shared refresh. */
7
+ const auth = createAuthClient({ apiOrigin: () => apiUrl() })
8
+
9
+ const sessionOf = (user: { readonly id: string; readonly email: string } | null): AuthSession | undefined =>
10
+ user === null ? undefined : { userId: user.id, ...(user.email === "" ? {} : { email: user.email }) }
11
+
12
+ /** The lite session issuer's client: `@starci/auth/client` behind the interface every edition shares. */
13
+ export const authClient: AuthClient = {
14
+ session: async () => sessionOf(await auth.user()),
15
+ accessToken: () => auth.accessToken(),
16
+ refreshAccessToken: () => auth.renew(),
17
+ signInWithPassword: async (email, password): Promise<AuthFailure | undefined> => {
18
+ const outcome = await auth.signIn({ email, password })
19
+ return outcome.success ? undefined : { code: outcome.error, kind: outcome.message, message: "" }
20
+ },
21
+ signOut: async () => {
22
+ await auth.signOut()
23
+ return undefined
24
+ },
25
+ onChange: (listener) => auth.subscribe(() => listener(sessionOf(auth.getSnapshot().user))),
26
+ }
@@ -1,10 +1,10 @@
1
1
  export const SiteShell = (props: SiteShellProps) => (
2
- <GrammarRoot className={siteShellClassNames.frame}>
2
+ <GrammarRoot>
3
3
  <WorkspaceShell
4
4
  viewport="flow"
5
5
  primaryLabel={props.brand}
6
- header={<TopBar brand={<TextAction href={props.homeHref}>{props.brand}</TextAction>} />}
7
- primary={<PageContainer measure="reading">{props.children}</PageContainer>}
6
+ header={<TopBar brand={<Link href={props.homeHref}>{props.brand}</Link>} />}
7
+ primary={props.children}
8
8
  />
9
9
  </GrammarRoot>
10
10
  )
@@ -1,11 +1,15 @@
1
1
  "use client"
2
2
 
3
3
  import { ToastProvider } from "@heroui/react"
4
+ import { viewerCacheKey, viewerSwrConfig } from "@starci/next-data"
5
+ import type { ViewerId } from "@starci/next-data"
4
6
  import { ThemeProvider } from "next-themes"
5
7
  import type { ReactNode } from "react"
6
- import { SWRConfig } from "swr"
8
+ import { SWRConfig, useSWRConfig } from "swr"
9
+ import { useViewerId } from "@/hooks/auth"
7
10
  import { useAuthSessionEffect } from "@/hooks/effects"
8
11
  import { SwrSideEffects } from "@/hooks/swr"
12
+ import { viewerCache } from "@/modules/viewer-cache"
9
13
  import { ReduxProvider } from "@/redux"
10
14
 
11
15
  /** What the app providers wrap: the routed tree. */
@@ -25,25 +29,44 @@ const SWR_DEFAULTS = {
25
29
  } as const
26
30
 
27
31
  /** The app-wide session effect: modules/auth's session kept in redux. Renders nothing. */
28
- const AuthSessionEffect = () => {
32
+ const AuthSessionEffect = (): null => {
29
33
  useAuthSessionEffect()
30
34
  return null
31
35
  }
32
36
 
37
+ /** Points the writes of viewerCache at the SWR cache around it. Renders nothing. */
38
+ const ViewerCacheBinding = (): null => {
39
+ viewerCache.bind(useSWRConfig().mutate)
40
+ return null
41
+ }
42
+
43
+ /**
44
+ * Each viewer gets their own SWR cache (FE-BOUNDARY-3): a change of person remounts the config on a fresh Map, and
45
+ * `viewerSwrConfig` lists pollingRestart so a read whose refreshInterval stopped at 0 polls again for newer data.
46
+ */
47
+ const renderViewerScope = (viewerId: ViewerId, children: ReactNode): ReactNode => (
48
+ <SWRConfig key={viewerCacheKey(viewerId)} value={viewerSwrConfig(SWR_DEFAULTS)}>
49
+ <ViewerCacheBinding />
50
+ <SwrSideEffects />
51
+ {children}
52
+ <ToastProvider />
53
+ </SWRConfig>
54
+ )
55
+
56
+ /** The SWR config of the viewer on screen around the routed tree. */
57
+ const ViewerSwrScope = (props: AppProvidersProps): ReactNode => renderViewerScope(useViewerId(), props.children)
58
+
33
59
  /**
34
60
  * 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.
61
+ * `.dark` class the grammar and the brand layer read, the system theme by default), the redux store, the session effect, the
62
+ * SWR config keyed by the viewer, the app-wide effects and the HeroUI toast region modules/toast writes to. Each part is
63
+ * composed here, in its one owner.
37
64
  */
38
65
  export const AppProviders = (props: AppProvidersProps) => (
39
66
  <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
40
67
  <ReduxProvider>
41
- <SWRConfig value={SWR_DEFAULTS}>
42
- <AuthSessionEffect />
43
- <SwrSideEffects />
44
- {props.children}
45
- <ToastProvider />
46
- </SWRConfig>
68
+ <AuthSessionEffect />
69
+ <ViewerSwrScope>{props.children}</ViewerSwrScope>
47
70
  </ReduxProvider>
48
71
  </ThemeProvider>
49
72
  )
@@ -2,3 +2,4 @@ export { useSession } from "./useSession"
2
2
  export { useSignIn } from "./useSignIn"
3
3
  export type { SignInFailure } from "./useSignIn"
4
4
  export { useSignOut } from "./useSignOut"
5
+ export { useViewerId } from "./useViewerId"
@@ -0,0 +1,11 @@
1
+ "use client"
2
+
3
+ import type { ViewerId } from "@starci/next-data"
4
+ import { useAppSelector } from "@/redux"
5
+
6
+ /** The person the session belongs to: undefined until modules/auth answers, null when signed out (the viewer of @starci/next-data). */
7
+ export const useViewerId = (): ViewerId => {
8
+ const session = useAppSelector((state) => state.session)
9
+ if (session.status === "unknown") return undefined
10
+ return session.session?.userId ?? null
11
+ }
@@ -21,15 +21,18 @@ const INDICATOR: Record<"success" | "danger" | "warning" | "info", ReactNode> =
21
21
  /** The app's toast: HeroUI's, with the four status methods wrapped. */
22
22
  type AppToast = typeof heroToast
23
23
 
24
- /** The app's toast: HeroUI's imperative toast with the status indicator injected; the region is mounted by the providers. */
25
- export const toast: AppToast = {
26
- ...heroToast,
27
- success: (message: ReactNode, options?: StatusToastOptions) =>
28
- heroToast.success(message, { indicator: INDICATOR.success, ...options }),
29
- danger: (message: ReactNode, options?: StatusToastOptions) =>
30
- heroToast.danger(message, { indicator: INDICATOR.danger, ...options }),
31
- warning: (message: ReactNode, options?: StatusToastOptions) =>
32
- heroToast.warning(message, { indicator: INDICATOR.warning, ...options }),
33
- info: (message: ReactNode, options?: StatusToastOptions) =>
34
- heroToast.info(message, { indicator: INDICATOR.info, ...options }),
35
- }
24
+ /** The app's toast: HeroUI's imperative toast, callable as it is, with the four status methods wrapped to inject the status indicator; the region is mounted by the providers. */
25
+ export const toast: AppToast = Object.assign(
26
+ (message: ReactNode, options?: Parameters<typeof heroToast>[1]) => heroToast(message, options),
27
+ heroToast,
28
+ {
29
+ success: (message: ReactNode, options?: StatusToastOptions) =>
30
+ heroToast.success(message, { indicator: INDICATOR.success, ...options }),
31
+ danger: (message: ReactNode, options?: StatusToastOptions) =>
32
+ heroToast.danger(message, { indicator: INDICATOR.danger, ...options }),
33
+ warning: (message: ReactNode, options?: StatusToastOptions) =>
34
+ heroToast.warning(message, { indicator: INDICATOR.warning, ...options }),
35
+ info: (message: ReactNode, options?: StatusToastOptions) =>
36
+ heroToast.info(message, { indicator: INDICATOR.info, ...options }),
37
+ },
38
+ )
@@ -0,0 +1,5 @@
1
+ import { createViewerCache } from "@starci/next-data"
2
+ import type { ViewerCache } from "@starci/next-data"
3
+
4
+ /** The writes to the cache of the viewer on screen: the one instance of the app, bound by the client providers. */
5
+ export const viewerCache: ViewerCache = createViewerCache()
@@ -9,7 +9,7 @@ export interface SessionState {
9
9
  }
10
10
 
11
11
  /** The reducers of the session slice. */
12
- interface SessionReducers {
12
+ type SessionReducers = {
13
13
  sessionChanged: CaseReducer<SessionState, PayloadAction<AuthSession | undefined>>
14
14
  }
15
15
 
@@ -53,7 +53,7 @@ export interface AppI18n {
53
53
  * module: a client component reads the default locale from `./routing`.
54
54
  */
55
55
  export const createAppI18n = (app: string, loadMessages: LoadMessages): AppI18n => ({
56
- navigation: createNavigation(routing),
56
+ navigation: createNavigation<ProductRouting["locales"], "as-needed">(routing),
57
57
  /**
58
58
  * The locale is the `[locale]` route param read through `next/root-params` (`experimental.rootParams` in the app's next.config.ts,
59
59
  * FE-I18N-1), never the deprecated `requestLocale`. An unrecognised locale resolves to the default before the loader runs, so a