@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,4 +1,4 @@
1
- import { Heading, TextAction } from "@starci/grammar/common"
1
+ import { Link, Page } from "@starci/grammar/common"
2
2
 
3
3
  /** The words of a page that does not exist and the way back. */
4
4
  type NotFoundNoticeProps = {
@@ -10,10 +10,7 @@ type NotFoundNoticeProps = {
10
10
 
11
11
  /** A missing page: the message and the way back to the app's front door. */
12
12
  export const NotFoundNotice = (props: NotFoundNoticeProps) => (
13
- <>
14
- <Heading level={1}>{props.title}</Heading>
15
- <TextAction appearance="inline" href={props.homeHref}>
16
- {props.homeLabel}
17
- </TextAction>
18
- </>
13
+ <Page header={{ title: props.title }} measure="reading">
14
+ <Link href={props.homeHref}>{props.homeLabel}</Link>
15
+ </Page>
19
16
  )
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from "react"
2
- import { GrammarRoot, PageContainer, TextAction, TopBar, WorkspaceShell } from "@starci/grammar/common"
3
- import { siteShellClassNames } from "./classNames"
2
+ import { GrammarRoot, Link, TopBar, WorkspaceShell } from "@starci/grammar/common"
4
3
 
5
4
  /** What an app's chrome hands the shell: the brand, its home link and the routed body. */
6
5
  type SiteShellProps = {
@@ -14,6 +13,6 @@ type SiteShellProps = {
14
13
 
15
14
  /**
16
15
  * The document chrome both apps share: Grammar's Common boundary, the brand header (the top bar carrying the wordmark that links
17
- * home), then the routed body in the shell's main landmark at reading measure.
16
+ * home), then the routed body in the shell's main landmark (each page draws its own measure).
18
17
  */
19
18
  {{> fe/common/site-shell.tsx.partial}}
@@ -1,4 +1,4 @@
1
- import { Heading, TextAction } from "@starci/grammar/common"
1
+ import { Link, Page } from "@starci/grammar/common"
2
2
 
3
3
  /** The words of a missing page and the way home. */
4
4
  type NotFoundNoticeProps = {
@@ -9,10 +9,7 @@ type NotFoundNoticeProps = {
9
9
 
10
10
  /** A missing page with one destination back to the app's front door. */
11
11
  export const NotFoundNotice = (props: NotFoundNoticeProps) => (
12
- <>
13
- <Heading level={1}>{props.title}</Heading>
14
- <TextAction appearance="inline" href={props.homeHref}>
15
- {props.homeLabel}
16
- </TextAction>
17
- </>
12
+ <Page header={{ title: props.title }} measure="reading">
13
+ <Link href={props.homeHref}>{props.homeLabel}</Link>
14
+ </Page>
18
15
  )
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from "react"
2
- import { GrammarRoot, PageContainer, TextAction, TopBar, WorkspaceShell } from "@starci/grammar/common"
3
- import { siteShellClassNames } from "./classNames"
2
+ import { GrammarRoot, Link, TopBar, WorkspaceShell } from "@starci/grammar/common"
4
3
 
5
4
  /** What the app chrome hands the shell. */
6
5
  type SiteShellProps = {
@@ -1,16 +0,0 @@
1
- {
2
- admin 127.0.0.1:2019
3
- auto_https off
4
- }
5
-
6
- http://dev.{{project}}.example.invalid {
7
- handle /graphql* {
8
- reverse_proxy api:3001
9
- }
10
- handle /health/* {
11
- reverse_proxy api:3001
12
- }
13
- handle {
14
- reverse_proxy web:3000
15
- }
16
- }
@@ -1,11 +0,0 @@
1
- # Development prerequisite record
2
-
3
- Status: pending owner execution. This record states intended configuration and contains no completed operational receipt.
4
-
5
- ## DNS
6
-
7
- The development operator replaces `dev.{{project}}.example.invalid` with a locally owned development name, updates the manifest, Caddyfile and public configuration together, and resolves the name to the Swarm node. Record the actual resolver mapping and dated verification here.
8
-
9
- ## TLS
10
-
11
- The local development route uses HTTP on ingress port 80 with `auto_https off`. The development operator owns this local-only choice and both external ingress volumes. Record any change to locally issued TLS, certificate custody and verification here before exposing the development stack outside the local network.
@@ -1,7 +0,0 @@
1
- # Development recovery record
2
-
3
- Status: pending owner execution. No backup or restore has been run by scaffolding.
4
-
5
- The development operator owns recovery of the db service's volume `{{project}}-dev-db-data` and both external ingress volumes, `{{project}}-dev-ingress-data` and `{{project}}-dev-ingress-config-data`. Retain seven daily encrypted off-host exports or snapshots for each target.
6
-
7
- Before relying on the backup policy, restore a database export and both volume snapshots into isolated infrastructure. Record the actual off-host destination, encryption custody, backup artifact digests, restoration time and operator. Verify database connectivity and the API readiness endpoint, then verify the restored ingress configuration and state. Attach the dated result here without credentials.
@@ -1,6 +0,0 @@
1
- schema: starci/release-pointer@1
2
- environment: dev
3
- status: pending-owner-release
4
- release: null
5
- verification: null
6
- # Write a real release digest and dated health receipt only after convergence and verification.
@@ -1,8 +0,0 @@
1
- # Illustrative inventory only. Replace application digests with verified published images.
2
- schema: starci/images-lock@1
3
- environment: dev
4
- status: pending-owner-release
5
- images:
6
- api: "{{project}}/api:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
7
- web: "{{project}}/web:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
8
- ingress: caddy:2.10.2@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
@@ -1,6 +0,0 @@
1
- schema: starci/release-pointer@1
2
- environment: dev
3
- status: no-previous-release
4
- release: null
5
- verification: null
6
- # Before moving current, retain the last accepted release here for rollback.
@@ -1,186 +0,0 @@
1
- # Illustrative development model. Replace all-zero application digests with verified published release digests before use.
2
- services:
3
- api:
4
- image: "{{project}}/api:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
5
- environment:
6
- NODE_ENV: development
7
- PORT: "3001"
8
- HTTP_SECURITY_ALLOWED_ORIGINS: http://dev.{{project}}.example.invalid
9
- PRIMARY_DB_URL_FILE: /run/secrets/primary-db-url
10
- PRIMARY_DB_SCHEMA: public
11
- GOOGLE_CLIENT_ID: REPLACE_WITH_GOOGLE_OAUTH_CLIENT_ID
12
- GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google-client-secret
13
- GOOGLE_CALLBACK_URL: http://dev.{{project}}.example.invalid/auth/google/callback
14
- AUTH_JWT_ISSUER: http://dev.{{project}}.example.invalid
15
- AUTH_JWT_AUDIENCE: {{project}}
16
- AUTH_JWT_KEY_ID: dev-1
17
- AUTH_JWT_PRIVATE_KEY_FILE: /run/secrets/auth-jwt-private-key
18
- STORAGE_ENDPOINT: https://storage.{{project}}.example.invalid
19
- STORAGE_REGION: us-east-1
20
- STORAGE_BUCKET: {{project}}-dev
21
- STORAGE_ACCESS_KEY_ID_FILE: /run/secrets/storage-access-key-id
22
- STORAGE_SECRET_ACCESS_KEY_FILE: /run/secrets/storage-secret-access-key
23
- secrets:
24
- - source: primary-db-url
25
- target: primary-db-url
26
- - source: google-client-secret
27
- target: google-client-secret
28
- - source: auth-jwt-private-key
29
- target: auth-jwt-private-key
30
- - source: storage-access-key-id
31
- target: storage-access-key-id
32
- - source: storage-secret-access-key
33
- target: storage-secret-access-key
34
- networks: [app]
35
- healthcheck:
36
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
37
- interval: 30s
38
- timeout: 5s
39
- start_period: 20s
40
- retries: 3
41
- deploy:
42
- replicas: 1
43
- placement:
44
- constraints: ["node.labels.{{project}} == dev"]
45
- update_config:
46
- parallelism: 1
47
- order: start-first
48
- failure_action: rollback
49
- monitor: 30s
50
- # Stateless and served behind the ingress: roll back start-first so the serving task stays up until the old release is healthy.
51
- rollback_config:
52
- parallelism: 1
53
- order: start-first
54
- restart_policy:
55
- condition: on-failure
56
- delay: 5s
57
- web:
58
- image: "{{project}}/web:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
59
- environment:
60
- NODE_ENV: development
61
- PORT: "3000"
62
- HOSTNAME: 0.0.0.0
63
- NEXT_PUBLIC_SITE_URL: http://dev.{{project}}.example.invalid
64
- networks: [app]
65
- healthcheck:
66
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
67
- interval: 30s
68
- timeout: 5s
69
- start_period: 20s
70
- retries: 3
71
- deploy:
72
- replicas: 1
73
- placement:
74
- constraints: ["node.labels.{{project}} == dev"]
75
- update_config:
76
- parallelism: 1
77
- order: start-first
78
- failure_action: rollback
79
- monitor: 30s
80
- # Stateless and served behind the ingress: roll back start-first so the serving task stays up until the old release is healthy.
81
- rollback_config:
82
- parallelism: 1
83
- order: start-first
84
- restart_policy:
85
- condition: on-failure
86
- delay: 5s
87
- db:
88
- image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
89
- environment:
90
- POSTGRES_DB: postgres
91
- POSTGRES_PASSWORD_FILE: /run/secrets/db-password
92
- secrets:
93
- - source: db-password
94
- target: db-password
95
- volumes:
96
- - db-data:/var/lib/postgresql/data
97
- networks: [app]
98
- healthcheck:
99
- test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1"]
100
- interval: 10s
101
- timeout: 5s
102
- start_period: 30s
103
- retries: 5
104
- deploy:
105
- replicas: 1
106
- placement:
107
- constraints: ["node.labels.{{project}} == dev"]
108
- update_config:
109
- parallelism: 1
110
- order: stop-first
111
- failure_action: rollback
112
- monitor: 30s
113
- # The database owns a data volume and is single-writer: never run two at once, so rollback stays stop-first.
114
- rollback_config:
115
- parallelism: 1
116
- order: stop-first
117
- restart_policy:
118
- condition: on-failure
119
- delay: 5s
120
- ingress:
121
- image: caddy:2.10.2@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
122
- configs:
123
- - source: ingress-config
124
- target: /etc/caddy/Caddyfile
125
- ports:
126
- - { target: 80, published: 80, protocol: tcp, mode: ingress }
127
- volumes:
128
- - ingress-data:/data
129
- - ingress-config-data:/config
130
- networks: [app]
131
- healthcheck:
132
- test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
133
- interval: 30s
134
- timeout: 5s
135
- retries: 3
136
- deploy:
137
- replicas: 1
138
- placement:
139
- constraints: ["node.labels.{{project}} == dev"]
140
- update_config:
141
- parallelism: 1
142
- order: stop-first
143
- failure_action: rollback
144
- monitor: 30s
145
- # The ingress owns published ports and a certificate volume: never run two at once, so rollback stays stop-first.
146
- rollback_config:
147
- parallelism: 1
148
- order: stop-first
149
- restart_policy:
150
- condition: on-failure
151
- delay: 5s
152
- networks:
153
- app:
154
- driver: overlay
155
- secrets:
156
- primary-db-url:
157
- external: true
158
- name: "{{project}}-dev-primary-db-url-v1"
159
- db-password:
160
- external: true
161
- name: "{{project}}-dev-db-password-v1"
162
- google-client-secret:
163
- external: true
164
- name: "{{project}}-dev-google-client-secret-v1"
165
- auth-jwt-private-key:
166
- external: true
167
- name: "{{project}}-dev-auth-jwt-private-key-v1"
168
- storage-access-key-id:
169
- external: true
170
- name: "{{project}}-dev-storage-access-key-id-v1"
171
- storage-secret-access-key:
172
- external: true
173
- name: "{{project}}-dev-storage-secret-access-key-v1"
174
- configs:
175
- ingress-config:
176
- file: ./Caddyfile
177
- volumes:
178
- db-data:
179
- external: true
180
- name: "{{project}}-dev-db-data"
181
- ingress-data:
182
- external: true
183
- name: "{{project}}-dev-ingress-data"
184
- ingress-config-data:
185
- external: true
186
- name: "{{project}}-dev-ingress-config-data"
@@ -1,22 +0,0 @@
1
- import { Secret } from "@modules/platform/config"
2
- import { createObjectStorage } from "./object-storage.client"
3
-
4
- const options = {
5
- endpoint: "http://127.0.0.1:9000",
6
- region: "us-east-1",
7
- bucket: "app",
8
- accessKeyId: new Secret("access"),
9
- secretAccessKey: new Secret("secret"),
10
- presignTtlSeconds: 300,
11
- }
12
-
13
- describe("createObjectStorage", () => {
14
- it("presigns a path-style PUT of one key in the bucket, valid for the configured lifetime", async () => {
15
- const storage = createObjectStorage(options, () => new Date("2026-10-29T00:00:00.000Z"))
16
- const request = await storage.presignPut("users/u1/avatar.png", "image/png")
17
-
18
- expect(new URL(request.url).pathname).toBe("/app/users/u1/avatar.png")
19
- expect(new URL(request.url).searchParams.get("X-Amz-Expires")).toBe("300")
20
- expect(request.expiresAt).toBe("2026-10-29T00:05:00.000Z")
21
- })
22
- })
@@ -1,25 +0,0 @@
1
- import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
2
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
3
- import type { ObjectStorage, PresignedRequest, ObjectStorageOptions } from "./object-storage.options"
4
-
5
- /** The one S3 client of the app over S3-compatible storage (path-style, as MinIO serves it). */
6
- export const createObjectStorage = (options: ObjectStorageOptions, now: () => Date = () => new Date()): ObjectStorage => {
7
- const client = new S3Client({
8
- endpoint: options.endpoint,
9
- region: options.region,
10
- forcePathStyle: true,
11
- credentials: { accessKeyId: options.accessKeyId.reveal(), secretAccessKey: options.secretAccessKey.reveal() },
12
- })
13
- const expiresAt = (): string => new Date(now().getTime() + options.presignTtlSeconds * 1000).toISOString()
14
- const presign = async (command: PutObjectCommand | GetObjectCommand): Promise<PresignedRequest> => ({
15
- url: await getSignedUrl(client, command, { expiresIn: options.presignTtlSeconds }),
16
- expiresAt: expiresAt(),
17
- })
18
- return {
19
- presignPut: (key, contentType) => presign(new PutObjectCommand({ Bucket: options.bucket, Key: key, ContentType: contentType })),
20
- presignGet: (key) => presign(new GetObjectCommand({ Bucket: options.bucket, Key: key })),
21
- remove: async (key) => {
22
- await client.send(new DeleteObjectCommand({ Bucket: options.bucket, Key: key }))
23
- },
24
- }
25
- }
@@ -1,104 +0,0 @@
1
- import { Test } from "@nestjs/testing"
2
- import type { Request, Response } from "express"
3
- import { mock } from "@starci/jest-preset"
4
- import { clearRefreshCookie, newRefreshToken, refreshTokenHash, setRefreshCookie } from "./passport.cookie"
5
- import { CookieStateStore } from "./strategies/oauth-state.store"
6
-
7
- const COOKIE: unique symbol = Symbol("passport.cookie")
8
-
9
- const OPTIONS = { name: "refresh", path: "/auth/refresh", ttlSeconds: 600 }
10
-
11
- const build = async () => {
12
- const moduleRef = await Test.createTestingModule({
13
- providers: [{ provide: COOKIE, useValue: { clearRefreshCookie, newRefreshToken, refreshTokenHash, setRefreshCookie } }],
14
- }).compile()
15
- return moduleRef.get<{
16
- clearRefreshCookie: typeof clearRefreshCookie
17
- newRefreshToken: typeof newRefreshToken
18
- refreshTokenHash: typeof refreshTokenHash
19
- setRefreshCookie: typeof setRefreshCookie
20
- }>(COOKIE)
21
- }
22
-
23
- describe("refresh tokens", () => {
24
- it("stores the sha256 of a token, never the token", async () => {
25
- const subject = await build()
26
-
27
- expect(subject.refreshTokenHash("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
28
- })
29
-
30
- it("mints a 256-bit base64url token paired with its own hash", async () => {
31
- const subject = await build()
32
- const minted = subject.newRefreshToken()
33
-
34
- expect(Buffer.from(minted.token, "base64url")).toHaveLength(32)
35
- expect(minted.hash).toBe(subject.refreshTokenHash(minted.token))
36
- expect(subject.newRefreshToken().token).not.toBe(minted.token)
37
- })
38
- })
39
-
40
- describe("refresh cookie", () => {
41
- it("sets the rotated token httpOnly, Secure, SameSite=Strict on the refresh path for its lifetime", async () => {
42
- const subject = await build()
43
- const response = mock<Response>()
44
-
45
- subject.setRefreshCookie(response, OPTIONS, "token-1")
46
-
47
- expect(response.cookie).toHaveBeenCalledWith("refresh", "token-1", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh", maxAge: 600_000 })
48
- })
49
-
50
- it("clears the cookie with the attributes it was set with", async () => {
51
- const subject = await build()
52
- const response = mock<Response>()
53
-
54
- subject.clearRefreshCookie(response, OPTIONS)
55
-
56
- expect(response.clearCookie).toHaveBeenCalledWith("refresh", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh" })
57
- })
58
-
59
- it("stays host-only: no Domain attribute is set or cleared when no cookie domain is configured", async () => {
60
- const subject = await build()
61
- const response = mock<Response>()
62
-
63
- subject.setRefreshCookie(response, OPTIONS, "token-1")
64
- subject.clearRefreshCookie(response, OPTIONS)
65
-
66
- expect(response.cookie).toHaveBeenCalledWith("refresh", "token-1", expect.not.objectContaining({ domain: expect.anything() }))
67
- expect(response.clearCookie).toHaveBeenCalledWith("refresh", expect.not.objectContaining({ domain: expect.anything() }))
68
- })
69
-
70
- it("sets the rotated token with Domain=<cookieDomain> so a sibling host can forward it", async () => {
71
- const subject = await build()
72
- const response = mock<Response>()
73
-
74
- subject.setRefreshCookie(response, { ...OPTIONS, cookieDomain: "example.com" }, "token-1")
75
-
76
- expect(response.cookie).toHaveBeenCalledWith("refresh", "token-1", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh", domain: "example.com", maxAge: 600_000 })
77
- })
78
-
79
- it("clears the cookie with the same Domain it was set with, or the browser keeps it", async () => {
80
- const subject = await build()
81
- const response = mock<Response>()
82
-
83
- subject.clearRefreshCookie(response, { ...OPTIONS, cookieDomain: "example.com" })
84
-
85
- expect(response.clearCookie).toHaveBeenCalledWith("refresh", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh", domain: "example.com" })
86
- })
87
- })
88
-
89
- describe("oauth state cookie", () => {
90
- const request = (response: Response): Request => ({ res: response, headers: {} }) as unknown as Request
91
-
92
- it("is host-only without a cookie domain and carries Domain=<cookieDomain> with one, on set and on clear", () => {
93
- const hostOnly = mock<Response>()
94
- const shared = mock<Response>()
95
-
96
- new CookieStateStore("/auth/google/callback").store(request(hostOnly), "verifier", undefined, undefined, () => undefined)
97
- new CookieStateStore("/auth/google/callback", "example.com").store(request(shared), "verifier", undefined, undefined, () => undefined)
98
- new CookieStateStore("/auth/google/callback", "example.com").verify(request(shared), "state", () => undefined)
99
-
100
- expect(hostOnly.cookie).toHaveBeenCalledWith("oauth_state", expect.stringMatching(/^[A-Za-z0-9_-]{32}[.]verifier$/), expect.not.objectContaining({ domain: expect.anything() }))
101
- expect(shared.cookie).toHaveBeenCalledWith("oauth_state", expect.stringMatching(/^[A-Za-z0-9_-]{32}[.]verifier$/), expect.objectContaining({ domain: "example.com" }))
102
- expect(shared.clearCookie).toHaveBeenCalledWith("oauth_state", expect.objectContaining({ domain: "example.com" }))
103
- })
104
- })
@@ -1,34 +0,0 @@
1
- import { createHash, randomBytes } from "node:crypto"
2
- import type { CookieOptions, Response } from "express"
3
- import type { RefreshCookieOptions } from "./passport.options"
4
-
5
- /** A fresh opaque refresh token (256 bits) and the sha256 the session store keeps instead of it. */
6
- export const newRefreshToken = (): { readonly token: string; readonly hash: string } => {
7
- const token = randomBytes(32).toString("base64url")
8
- return { token, hash: refreshTokenHash(token) }
9
- }
10
-
11
- /** The sha256 a refresh token is stored and looked up by; the token itself is never stored. */
12
- export const refreshTokenHash = (token: string): string => createHash("sha256").update(token).digest("hex")
13
-
14
- /** `Domain=<cookieDomain>` when the app configured one; absent (host-only) otherwise. Setting and clearing must agree on it. */
15
- export const domainAttribute = (cookieDomain: string | undefined): Pick<CookieOptions, "domain"> =>
16
- cookieDomain === undefined ? {} : { domain: cookieDomain }
17
-
18
- const attributes = (options: RefreshCookieOptions): CookieOptions => ({
19
- httpOnly: true,
20
- secure: true,
21
- sameSite: "strict",
22
- path: options.path,
23
- ...domainAttribute(options.cookieDomain),
24
- })
25
-
26
- /** Sets the rotated refresh token: httpOnly, Secure, SameSite=Strict and sent to the refresh door only. */
27
- export const setRefreshCookie = (response: Response, options: RefreshCookieOptions, token: string): void => {
28
- response.cookie(options.name, token, { ...attributes(options), maxAge: options.ttlSeconds * 1000 })
29
- }
30
-
31
- /** Clears the refresh cookie on sign-out or on a detected reuse. */
32
- export const clearRefreshCookie = (response: Response, options: RefreshCookieOptions): void => {
33
- response.clearCookie(options.name, attributes(options))
34
- }
@@ -1,28 +0,0 @@
1
- import { Test } from "@nestjs/testing"
2
- import { hashPassword, verifyPassword } from "./passport.password"
3
-
4
- const PASSWORD: unique symbol = Symbol("passport.password")
5
-
6
- const build = async () => {
7
- const moduleRef = await Test.createTestingModule({
8
- providers: [{ provide: PASSWORD, useValue: { hashPassword, verifyPassword } }],
9
- }).compile()
10
- return moduleRef.get<{ hashPassword: typeof hashPassword; verifyPassword: typeof verifyPassword }>(PASSWORD)
11
- }
12
-
13
- describe("hashPassword and verifyPassword", () => {
14
- it("hashes at bcrypt cost 12 and verifies only the password it hashed", async () => {
15
- const subject = await build()
16
- const stored = await subject.hashPassword("correct horse")
17
-
18
- expect(stored.slice(0, 7)).toBe("$2b$12$")
19
- expect(await subject.verifyPassword("correct horse", stored)).toBe(true)
20
- expect(await subject.verifyPassword("wrong horse", stored)).toBe(false)
21
- })
22
-
23
- it("never matches an account that has no password hash", async () => {
24
- const subject = await build()
25
-
26
- expect(await subject.verifyPassword("correct horse", null)).toBe(false)
27
- })
28
- })
@@ -1,11 +0,0 @@
1
- import { compare, hash } from "bcryptjs"
2
-
3
- /** The bcrypt cost of a new password hash; existing `$2a$`/`$2b$` hashes (auth.users.encrypted_password) verify at their own cost. */
4
- const COST = 12
5
-
6
- /** Hashes a new password with bcrypt. */
7
- export const hashPassword = (password: string): Promise<string> => hash(password, COST)
8
-
9
- /** True when `password` matches the stored bcrypt hash; a missing hash never matches. */
10
- export const verifyPassword = async (password: string, stored: string | null): Promise<boolean> =>
11
- stored === null ? false : compare(password, stored)
@@ -1,88 +0,0 @@
1
- import { Test } from "@nestjs/testing"
2
- import { exportPKCS8, generateKeyPair, SignJWT } from "jose"
3
- import { Secret } from "@modules/platform/config"
4
- import { PassportErrorCode } from "./errors/passport.error"
5
- import { createTokenKit, tokenKit } from "./passport.tokens"
6
-
7
- const TOKENS: unique symbol = Symbol("passport.tokens")
8
-
9
- type SigningKey = Awaited<ReturnType<typeof generateKeyPair>>["privateKey"]
10
-
11
- const ISSUER = "https://api.example.test"
12
- const AUDIENCE = "app"
13
-
14
- const build = async () => {
15
- const moduleRef = await Test.createTestingModule({
16
- providers: [{ provide: TOKENS, useValue: { createTokenKit, tokenKit } }],
17
- }).compile()
18
- return moduleRef.get<{ createTokenKit: typeof createTokenKit; tokenKit: typeof tokenKit }>(TOKENS)
19
- }
20
-
21
- const keyPair = async () => {
22
- const { privateKey } = await generateKeyPair("ES256", { extractable: true })
23
- return { privateKey, options: { issuer: ISSUER, audience: AUDIENCE, privateKey: new Secret(await exportPKCS8(privateKey)), keyId: "k1", accessTtlSeconds: 60 } }
24
- }
25
-
26
- /** A token the kit's own key signs with the given claims, so only the claim checks can refuse it. */
27
- const ownSigned = (privateKey: SigningKey, claims: Record<string, unknown>, { subject = "user-1", expires = true } = {}) => {
28
- const jwt = new SignJWT(claims).setProtectedHeader({ alg: "ES256", kid: "k1" }).setIssuer(ISSUER).setAudience(AUDIENCE).setIssuedAt()
29
- if (subject !== "") jwt.setSubject(subject)
30
- if (expires) jwt.setExpirationTime("60s")
31
- return jwt.sign(privateKey)
32
- }
33
-
34
- describe("createTokenKit", () => {
35
- it("verifies its own token with an email against its own key set and publishes only the public key", async () => {
36
- const subject = await build()
37
- const { options } = await keyPair()
38
- const kit = await subject.createTokenKit(options)
39
-
40
- expect(await kit.verify(await kit.issue({ id: "user-1", email: "a@example.test" }))).toEqual({ kind: "ok", value: { id: "user-1", role: "authenticated", email: "a@example.test" } })
41
- expect(kit.jwks.keys).toHaveLength(1)
42
- expect(kit.jwks.keys[0]).toMatchObject({ kid: "k1", alg: "ES256", use: "sig", kty: "EC", crv: "P-256" })
43
- expect(kit.jwks.keys[0]).not.toHaveProperty("d")
44
- })
45
-
46
- it("issues a token without an email claim for a user who has none", async () => {
47
- const subject = await build()
48
- const { options } = await keyPair()
49
- const kit = await subject.createTokenKit(options)
50
-
51
- expect(await kit.verify(await kit.issue({ id: "user-2" }))).toEqual({ kind: "ok", value: { id: "user-2", role: "authenticated" } })
52
- })
53
-
54
- it("refuses a token another key signed, naming the failed signature", async () => {
55
- const subject = await build()
56
- const kit = await subject.createTokenKit((await keyPair()).options)
57
- const other = await subject.createTokenKit((await keyPair()).options)
58
-
59
- expect(await kit.verify(await other.issue({ id: "user-1" }))).toEqual({
60
- kind: "refused",
61
- code: PassportErrorCode.AccessTokenInvalid,
62
- params: { cause: "JWSSignatureVerificationFailed: signature verification failed" },
63
- })
64
- })
65
-
66
- it("refuses a correctly signed token whose subject, expiry or role is not the one it issues", async () => {
67
- const subject = await build()
68
- const { privateKey, options } = await keyPair()
69
- const kit = await subject.createTokenKit(options)
70
- const invalid = { kind: "refused", code: PassportErrorCode.AccessTokenInvalid }
71
-
72
- expect(await kit.verify(await ownSigned(privateKey, { role: "authenticated" }, { subject: "" }))).toEqual(invalid)
73
- expect(await kit.verify(await ownSigned(privateKey, { role: "authenticated" }, { expires: false }))).toEqual(invalid)
74
- expect(await kit.verify(await ownSigned(privateKey, { role: "service_role" }))).toEqual(invalid)
75
- expect(await kit.verify(await ownSigned(privateKey, { role: "authenticated", email: 7 }))).toEqual({ kind: "ok", value: { id: "user-1", role: "authenticated" } })
76
- })
77
- })
78
-
79
- describe("tokenKit", () => {
80
- it("signs, verifies and publishes through the one key import it starts", async () => {
81
- const subject = await build()
82
- const { options } = await keyPair()
83
- const kit = subject.tokenKit(options)
84
-
85
- expect(await kit.verify(await kit.issue({ id: "user-3", email: "c@example.test" }))).toEqual({ kind: "ok", value: { id: "user-3", role: "authenticated", email: "c@example.test" } })
86
- expect((await kit.jwks()).keys.map((key) => key.kid)).toEqual(["k1"])
87
- })
88
- })