@starci/hfs 4.9.11 → 4.10.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 (219) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +7 -9
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +1 -1
  5. package/db/dump.mjs +11 -10
  6. package/db/entity-list.mjs +1 -1
  7. package/db/migrate.mjs +83 -259
  8. package/db/new.mjs +46 -0
  9. package/db/post-cutover.mjs +13 -17
  10. package/db/postgres.mjs +80 -0
  11. package/emit/contracts.mjs +5 -9
  12. package/package.json +1 -1
  13. package/release/gate.mjs +3 -15
  14. package/runtime/knowledge/hfs/canon-pins.yaml +44 -24
  15. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  16. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  17. package/runtime/knowledge/hfs/rules.yaml +177 -155
  18. package/runtime/knowledge/hfs/slots.yaml +164 -172
  19. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  20. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  21. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  22. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  23. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  24. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  25. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  26. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  28. package/runtime/modules/kernel/command-policy.yaml +1 -1
  29. package/runtime/modules/kernel/failure-codes.yaml +65 -115
  30. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  31. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  33. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  34. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  36. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  37. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  38. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  40. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  41. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  42. package/runtime/scripts/hfs/check.mjs +6 -6
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  45. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  46. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  47. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  48. package/runtime/scripts/hfs/manifest-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  50. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  52. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  53. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  54. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  55. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  56. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  57. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  58. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  60. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  61. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  62. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  63. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  64. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  65. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  66. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  67. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  68. package/runtime/scripts/hfs/slots.mjs +8 -2
  69. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  70. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  71. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  72. package/scaffold/add-app.mjs +2 -10
  73. package/scaffold/add-cli-lite.mjs +6 -6
  74. package/scaffold/add-compose.mjs +1 -1
  75. package/scaffold/add-inbox-lite.mjs +4 -8
  76. package/scaffold/add-plan.mjs +1 -1
  77. package/scaffold/add-queue-lite.mjs +16 -24
  78. package/scaffold/add-table.mjs +34 -26
  79. package/scaffold/add.mjs +2 -2
  80. package/scaffold/app.mjs +19 -86
  81. package/scaffold/lite-exports.mjs +1 -1
  82. package/src/main.mjs +19 -20
  83. package/stack/target-binding.mjs +0 -60
  84. package/stack/urgent-floor.mjs +2 -2
  85. package/sync/index.mjs +32 -8
  86. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  87. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  88. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  89. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  90. package/templates/app/gitignore-lite +0 -3
  91. package/templates/app/package-scripts/db.json +9 -0
  92. package/templates/app/package-scripts/package.json +0 -1
  93. package/templates/app/package-scripts-lite/package.json +1 -1
  94. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  95. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  96. package/templates/app/skeleton/README.md +1 -1
  97. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  98. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  99. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  100. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  101. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  102. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  103. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  104. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  105. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  106. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  107. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  108. package/templates/app/skeleton-lite/README.md +11 -11
  109. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  110. package/templates/app/tool-config-lite/prettierignore +0 -1
  111. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  112. package/templates/be/inbox/database-client.ts.tpl +13 -4
  113. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  114. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  115. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  116. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  117. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  118. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  119. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  120. package/templates/be/inbox/service.ts.tpl +1 -2
  121. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  122. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  123. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  124. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  125. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  126. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  127. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  128. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  129. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  130. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  131. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  132. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  133. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  134. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  135. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  136. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  137. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  138. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  139. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  140. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  141. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  142. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  143. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  144. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +6 -6
  145. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  146. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  147. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  148. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  149. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  150. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  151. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  152. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  153. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  154. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  158. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  159. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  160. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  161. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  162. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  163. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  164. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  165. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  166. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  167. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  168. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  169. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  170. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  171. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  172. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  173. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  174. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  175. package/templates/be/table/entities.index.ts.tpl +4 -0
  176. package/templates/be/table/entity.ts.tpl +21 -0
  177. package/templates/be/table/index.ts.tpl +1 -0
  178. package/templates/be/table/module.ts.tpl +1 -1
  179. package/templates/be/table/service.api.ts.tpl +10 -11
  180. package/templates/be/table/service.ts.tpl +5 -6
  181. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  182. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  183. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  184. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  185. package/db/local-migrate.mjs +0 -194
  186. package/db/reset.mjs +0 -125
  187. package/emit/db-types.mjs +0 -271
  188. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  189. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  190. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  191. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  192. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  193. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  194. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  195. package/scaffold/supabase-ports.mjs +0 -26
  196. package/templates/app/package-scripts/supabase.json +0 -7
  197. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  198. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  199. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  200. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  201. package/templates/be/inbox/sql.ts.tpl +0 -16
  202. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  203. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  204. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  205. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  206. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  207. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  208. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  209. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  210. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  211. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  212. package/templates/be/table/rows.ts.tpl +0 -4
  213. package/templates/be/table/sql.api.ts.tpl +0 -7
  214. package/templates/be/table/sql.ts.tpl +0 -4
  215. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  216. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  217. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  218. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  219. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
@@ -5,8 +5,8 @@ import type { Request } from "express"
5
5
  import { mock } from "@starci/jest-preset"
6
6
  import type { Principal } from "@modules/platform/cqrs"
7
7
  import { ok, refused } from "@modules/platform/primitives"
8
- import { SupabaseErrorCode } from "@modules/integrations/supabase"
9
- import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
8
+ import { PassportErrorCode } from "@modules/integrations/passport"
9
+ import type { VerifyAccessToken } from "@modules/integrations/passport"
10
10
  import { AuthGuard } from "./auth.guard"
11
11
  import type { CredentialMetadata, CredentialPrincipal } from "./credential.contracts"
12
12
  import { IdentityErrorCode } from "./errors/identity.error"
@@ -26,7 +26,7 @@ interface GuardRequest {
26
26
  }
27
27
 
28
28
  const build = async (metadata: PublicMetadata | undefined, credential?: CredentialMetadata) => {
29
- const verifier = mock<{ verify: VerifySupabaseAccessToken }>()
29
+ const verifier = mock<{ verify: VerifyAccessToken }>()
30
30
  const reflector = mock<Reflector>()
31
31
  reflector.getAllAndOverride.mockImplementation((key: unknown) =>
32
32
  key === CREDENTIAL_KEY ? credential : key === PUBLIC_KEY ? metadata : undefined,
@@ -129,7 +129,7 @@ describe("AuthGuard credential doors", () => {
129
129
 
130
130
  it("refuses a door that is not public when the token fails verification", async () => {
131
131
  const { guard, verify } = await build(undefined)
132
- verify.mockResolvedValue(refused(SupabaseErrorCode.AccessTokenInvalid))
132
+ verify.mockResolvedValue(refused(PassportErrorCode.AccessTokenInvalid))
133
133
  const { context, request } = contextOf("bearer forged-token")
134
134
 
135
135
  await expect(guard.canActivate(context)).rejects.toThrow(IdentityErrorCode.Unauthenticated)
@@ -1,7 +1,7 @@
1
1
  import { Injectable } from "@nestjs/common"
2
2
  import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
3
  import type { Reflector } from "@nestjs/core"
4
- import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
4
+ import type { VerifyAccessToken } from "@modules/integrations/passport"
5
5
  import { InjectReflector } from "@modules/platform/composition"
6
6
  import type { Principal } from "@modules/platform/cqrs"
7
7
  import { requestOf } from "@modules/platform/http-security"
@@ -20,12 +20,12 @@ const bearerToken = (authorization: string | undefined): string | undefined => {
20
20
  @Injectable()
21
21
  /**
22
22
  * The fourth app guard and default-deny gate: a credential door requires the matching principal from `CredentialGuard`,
23
- * a public door states its reason, and every other door requires a verified Supabase principal. It never calls another guard.
23
+ * a public door states its reason, and every other door requires a principal verified from the app's own access token. It never calls another guard.
24
24
  */
25
25
  export class AuthGuard implements CanActivate {
26
26
  constructor(
27
27
  @InjectReflector() private readonly reflector: Reflector,
28
- @InjectAccessTokenVerifier() private readonly verifyAccessToken: VerifySupabaseAccessToken,
28
+ @InjectAccessTokenVerifier() private readonly verifyAccessToken: VerifyAccessToken,
29
29
  ) {}
30
30
 
31
31
  /** Verifies session tokens only for doors without public or credential metadata, then applies the admission policy. */
@@ -6,7 +6,7 @@ declare module "express-serve-static-core" {
6
6
  interface Request {
7
7
  /** The identity `CredentialGuard` authenticated on a door marked `@Credential`; never a session principal. */
8
8
  credentialPrincipal?: CredentialPrincipal
9
- /** The authenticated Supabase caller, when the door is not public. */
9
+ /** The authenticated caller, when the door is not public. */
10
10
  principal?: Principal
11
11
  }
12
12
  }
@@ -2,7 +2,7 @@ import { SetMetadata, applyDecorators, createParamDecorator } from "@nestjs/comm
2
2
  import type { ExecutionContext } from "@nestjs/common"
3
3
  import { injector } from "@modules/platform/composition"
4
4
  import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
5
- import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
5
+ import type { VerifyAccessToken } from "@modules/integrations/passport"
6
6
  import { ServerToServer, requestOf } from "@modules/platform/http-security"
7
7
  import type { Principal } from "@modules/platform/cqrs"
8
8
  import type { CredentialAuthenticator, CredentialMetadata } from "./credential.contracts"
@@ -13,15 +13,15 @@ import type { PublicMetadata } from "./identity.contracts"
13
13
  /** Token of the access-token verifier the composition root hands to identity. */
14
14
  export const ACCESS_TOKEN_VERIFIER: unique symbol = Symbol("domain.identity.access-token-verifier")
15
15
 
16
- /** Injects the access-token verifier identity was registered with. Parameter type: VerifySupabaseAccessToken. */
17
- export const InjectAccessTokenVerifier = (): TypedParameterDecorator<VerifySupabaseAccessToken> =>
18
- injector<VerifySupabaseAccessToken>(ACCESS_TOKEN_VERIFIER)
16
+ /** Injects the access-token verifier identity was registered with. Parameter type: VerifyAccessToken. */
17
+ export const InjectAccessTokenVerifier = (): TypedParameterDecorator<VerifyAccessToken> =>
18
+ injector<VerifyAccessToken>(ACCESS_TOKEN_VERIFIER)
19
19
 
20
20
  /** Metadata key of `@Public`. */
21
21
  export const PUBLIC_KEY = "domain.identity.public"
22
22
 
23
23
  /**
24
- * Opens a door to anonymous callers, stating why; every other door needs a credential or a verified Supabase session.
24
+ * Opens a door to anonymous callers, stating why; every other door needs a credential or a verified session.
25
25
  * A signed webhook proves its caller by its signature, so it owes no browser origin.
26
26
  */
27
27
  export const Public = (metadata: PublicMetadata): DoorDecorator =>
@@ -46,7 +46,7 @@ export const InjectCredentialAuthenticator = (): TypedParameterDecorator<Credent
46
46
  export const Credential = (metadata: CredentialMetadata): DoorDecorator =>
47
47
  applyDecorators(SetMetadata(CREDENTIAL_KEY, metadata), ServerToServer())
48
48
 
49
- /** The authenticated Supabase caller established by the default-deny guard. */
49
+ /** The authenticated caller established by the default-deny guard. */
50
50
  export const CurrentPrincipal = createParamDecorator((_data: unknown, context: ExecutionContext): Principal => {
51
51
  const principal = requestOf(context).principal
52
52
  if (!principal) throw new IdentityError({ code: IdentityErrorCode.Unauthenticated })
@@ -1,11 +1,11 @@
1
1
  import type { Type } from "@nestjs/common"
2
- import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
2
+ import type { VerifyAccessToken } from "@modules/integrations/passport"
3
3
  import type { CredentialAuthenticator } from "./credential.contracts"
4
4
 
5
5
  /** The identity capability's credential and session verification options. */
6
6
  export interface IdentityOptions {
7
7
  /** The app-owned authenticator of alternate credentials; a credential door fails closed while it is absent. */
8
8
  readonly credentialAuthenticator?: Type<CredentialAuthenticator>
9
- /** Cryptographically verifies a Supabase access token before it can become a principal. */
10
- readonly verifyAccessToken: VerifySupabaseAccessToken
9
+ /** Cryptographically verifies one of the app's own access tokens before it can become a principal. */
10
+ readonly verifyAccessToken: VerifyAccessToken
11
11
  }
@@ -0,0 +1,4 @@
1
+ export { createObjectStorage } from "./object-storage.client"
2
+ export { parseObjectStorageConfig } from "./object-storage.config"
3
+ export { InjectObjectStorage, OBJECT_STORAGE } from "./object-storage.decorators"
4
+ export type { ObjectStorage, PresignedRequest, ObjectStorageOptions } from "./object-storage.options"
@@ -0,0 +1,22 @@
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
+ })
@@ -0,0 +1,25 @@
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
+ }
@@ -0,0 +1,12 @@
1
+ import type { EnvSource } from "@modules/platform/config"
2
+ import type { ObjectStorageOptions } from "./object-storage.options"
3
+
4
+ /** Reads the object storage configuration with no credential or endpoint defaults; secrets read `<KEY>_FILE` first. */
5
+ export const parseObjectStorageConfig = (env: EnvSource): ObjectStorageOptions => ({
6
+ endpoint: env.url("STORAGE_ENDPOINT"),
7
+ region: env.string("STORAGE_REGION"),
8
+ bucket: env.string("STORAGE_BUCKET"),
9
+ accessKeyId: env.secret("STORAGE_ACCESS_KEY_ID"),
10
+ secretAccessKey: env.secret("STORAGE_SECRET_ACCESS_KEY"),
11
+ presignTtlSeconds: env.int("STORAGE_PRESIGN_TTL_SECONDS", 300),
12
+ })
@@ -0,0 +1,9 @@
1
+ import { injector } from "@modules/platform/composition"
2
+ import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { ObjectStorage } from "./object-storage.options"
4
+
5
+ /** Token of the object storage port. */
6
+ export const OBJECT_STORAGE: unique symbol = Symbol("integrations.object-storage")
7
+
8
+ /** Injects the object storage port. Parameter type: ObjectStorage. */
9
+ export const InjectObjectStorage = (): TypedParameterDecorator<ObjectStorage> => injector<ObjectStorage>(OBJECT_STORAGE)
@@ -0,0 +1,35 @@
1
+ import type { Secret } from "@modules/platform/config"
2
+
3
+ /** S3-compatible object storage (MinIO in lite) parsed once at API boot. */
4
+ export interface ObjectStorageOptions {
5
+ /** The S3 endpoint origin. */
6
+ readonly endpoint: string
7
+ /** The region the endpoint signs for. */
8
+ readonly region: string
9
+ /** The bucket of the app's objects. */
10
+ readonly bucket: string
11
+ /** The access key id. */
12
+ readonly accessKeyId: Secret
13
+ /** The secret access key. */
14
+ readonly secretAccessKey: Secret
15
+ /** The lifetime of a presigned URL in seconds. */
16
+ readonly presignTtlSeconds: number
17
+ }
18
+
19
+ /** One presigned request: the URL the browser calls and when it stops working. */
20
+ export interface PresignedRequest {
21
+ /** The presigned URL. */
22
+ readonly url: string
23
+ /** The expiry instant, ISO-8601. */
24
+ readonly expiresAt: string
25
+ }
26
+
27
+ /** The object storage port: presigned PUT and GET of one key and a server-side delete. */
28
+ export interface ObjectStorage {
29
+ /** A presigned PUT of `key` with its content type fixed. */
30
+ presignPut(key: string, contentType: string): Promise<PresignedRequest>
31
+ /** A presigned GET of `key`. */
32
+ presignGet(key: string): Promise<PresignedRequest>
33
+ /** Deletes `key`. */
34
+ remove(key: string): Promise<void>
35
+ }
@@ -0,0 +1,22 @@
1
+ import { DomainError } from "@modules/platform/errors"
2
+ import type { ErrorKind } from "@modules/platform/errors"
3
+
4
+ /** Codes of the passport integration. */
5
+ export enum PassportErrorCode {
6
+ /** The access token failed signature or required-claim verification. */
7
+ AccessTokenInvalid = "PASSPORT_ACCESS_TOKEN_INVALID",
8
+ /** The email and password do not match an account. */
9
+ CredentialsRefused = "PASSPORT_CREDENTIALS_REFUSED",
10
+ /** The OAuth state or PKCE verifier of a callback does not match the request that started it. */
11
+ OAuthStateInvalid = "PASSPORT_OAUTH_STATE_INVALID",
12
+ }
13
+
14
+ /** How each passport integration code travels. */
15
+ export const PASSPORT_ERROR_KINDS: Record<PassportErrorCode, ErrorKind> = {
16
+ [PassportErrorCode.AccessTokenInvalid]: "unauthenticated",
17
+ [PassportErrorCode.CredentialsRefused]: "unauthenticated",
18
+ [PassportErrorCode.OAuthStateInvalid]: "unauthenticated",
19
+ }
20
+
21
+ /** The one error class of the passport integration. */
22
+ export class PassportError extends DomainError<PassportErrorCode> {}
@@ -0,0 +1,12 @@
1
+ export { parsePassportConfig } from "./passport.config"
2
+ export { OWN_TOKEN_VERIFIER, PASSPORT_OPTIONS, PASSWORD_ACCOUNTS } from "./passport.decorators"
3
+ export { PASSPORT_ERROR_KINDS, PassportErrorCode } from "./errors/passport.error"
4
+ export { createTokenKit, tokenKit } from "./passport.tokens"
5
+ export type { TokenKit } from "./passport.tokens"
6
+ export { hashPassword, verifyPassword } from "./passport.password"
7
+ export { clearRefreshCookie, newRefreshToken, refreshTokenHash, setRefreshCookie } from "./passport.cookie"
8
+ export { GoogleStrategy } from "./strategies/google.strategy"
9
+ export type { GoogleIdentity } from "./strategies/google.strategy"
10
+ export { LocalStrategy } from "./strategies/local.strategy"
11
+ export type { FindPasswordAccount, PasswordAccount } from "./strategies/local.strategy"
12
+ export type { OwnPrincipal, PassportOptions, VerifyAccessToken } from "./passport.options"
@@ -0,0 +1,23 @@
1
+ import type { EnvSource } from "@modules/platform/config"
2
+ import type { PassportOptions } from "./passport.options"
3
+
4
+ /** Reads the passport configuration with no credential or localhost defaults; secrets read `<KEY>_FILE` first. */
5
+ 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
+ },
23
+ })
@@ -0,0 +1,29 @@
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
+ const attributes = (options: RefreshCookieOptions): CookieOptions => ({
15
+ httpOnly: true,
16
+ secure: true,
17
+ sameSite: "strict",
18
+ path: options.path,
19
+ })
20
+
21
+ /** Sets the rotated refresh token: httpOnly, Secure, SameSite=Strict and sent to the refresh door only. */
22
+ export const setRefreshCookie = (response: Response, options: RefreshCookieOptions, token: string): void => {
23
+ response.cookie(options.name, token, { ...attributes(options), maxAge: options.ttlSeconds * 1000 })
24
+ }
25
+
26
+ /** Clears the refresh cookie on sign-out or on a detected reuse. */
27
+ export const clearRefreshCookie = (response: Response, options: RefreshCookieOptions): void => {
28
+ response.clearCookie(options.name, attributes(options))
29
+ }
@@ -0,0 +1,22 @@
1
+ import { injector } from "@modules/platform/composition"
2
+ import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { PassportOptions, VerifyAccessToken } from "./passport.options"
4
+ import type { FindPasswordAccount } from "./strategies/local.strategy"
5
+
6
+ /** Token of the passport options. */
7
+ export const PASSPORT_OPTIONS: unique symbol = Symbol("integrations.passport.options")
8
+ /** Token of the verifier of the app's own access tokens. */
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")
12
+
13
+ /** Injects the passport options. Parameter type: PassportOptions. */
14
+ export const InjectPassportOptions = (): TypedParameterDecorator<PassportOptions> => injector<PassportOptions>(PASSPORT_OPTIONS)
15
+
16
+ /** Injects the own-token verifier. Parameter type: VerifyAccessToken. */
17
+ export const InjectOwnTokenVerifier = (): TypedParameterDecorator<VerifyAccessToken> =>
18
+ injector<VerifyAccessToken>(OWN_TOKEN_VERIFIER)
19
+
20
+ /** Injects the password-account lookup. Parameter type: FindPasswordAccount. */
21
+ export const InjectPasswordAccounts = (): TypedParameterDecorator<FindPasswordAccount> =>
22
+ injector<FindPasswordAccount>(PASSWORD_ACCOUNTS)
@@ -0,0 +1,63 @@
1
+ import type { Secret } from "@modules/platform/config"
2
+ import type { Outcome } from "@modules/platform/primitives"
3
+ import type { PassportErrorCode } from "./errors/passport.error"
4
+
5
+ /** The claims admitted from one of the app's own access tokens after every cryptographic and semantic check. */
6
+ export interface OwnPrincipal {
7
+ /** The user identifier from the verified `sub` claim (a row of auth.users). */
8
+ readonly id: string
9
+ /** The verified email claim when the user has one. */
10
+ readonly email?: string
11
+ /** The role every access token the app issues carries; any other role is refused. */
12
+ readonly role: "authenticated"
13
+ }
14
+
15
+ /** Verifies one bearer token against the app's own key set and returns only claims that passed every check. */
16
+ 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
+
55
+ /** The passport integration configuration parsed once at API boot. */
56
+ export interface PassportOptions {
57
+ /** Google OAuth 2.0. */
58
+ readonly google: GoogleOptions
59
+ /** The app's own access tokens. */
60
+ readonly tokens: TokenOptions
61
+ /** The refresh cookie. */
62
+ readonly refresh: RefreshCookieOptions
63
+ }
@@ -0,0 +1,11 @@
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)
@@ -0,0 +1,32 @@
1
+ import { exportPKCS8, generateKeyPair } from "jose"
2
+ import { Secret } from "@modules/platform/config"
3
+ import { createTokenKit } from "./passport.tokens"
4
+
5
+ const options = async () => {
6
+ const { privateKey } = await generateKeyPair("ES256", { extractable: true })
7
+ return {
8
+ issuer: "https://api.example.test",
9
+ audience: "app",
10
+ privateKey: new Secret(await exportPKCS8(privateKey)),
11
+ keyId: "k1",
12
+ accessTtlSeconds: 60,
13
+ }
14
+ }
15
+
16
+ describe("createTokenKit", () => {
17
+ it("verifies its own token against its own key set and publishes only the public key", async () => {
18
+ const kit = await createTokenKit(await options())
19
+ const verified = await kit.verify(await kit.issue({ id: "user-1", email: "a@example.test" }))
20
+
21
+ expect(verified).toEqual({ kind: "ok", value: { id: "user-1", role: "authenticated", email: "a@example.test" } })
22
+ expect(kit.jwks.keys).toHaveLength(1)
23
+ expect(kit.jwks.keys[0]).not.toHaveProperty("d")
24
+ })
25
+
26
+ it("refuses a token another key signed", async () => {
27
+ const kit = await createTokenKit(await options())
28
+ const other = await createTokenKit(await options())
29
+
30
+ expect((await kit.verify(await other.issue({ id: "user-1" }))).kind).toBe("refused")
31
+ })
32
+ })
@@ -0,0 +1,69 @@
1
+ import { createLocalJWKSet, exportJWK, importPKCS8, jwtVerify, SignJWT } from "jose"
2
+ import type { JSONWebKeySet } from "jose"
3
+ import { ok, refused } from "@modules/platform/primitives"
4
+ import { PassportErrorCode } from "./errors/passport.error"
5
+ import type { IssueAccessToken, OwnPrincipal, TokenOptions, VerifyAccessToken } from "./passport.options"
6
+
7
+ const ALGORITHM = "ES256"
8
+ const ROLE = "authenticated"
9
+
10
+ /** The app's own token kit: the signer, the verifier over its own key set, and the public JWKS the API serves. */
11
+ export interface TokenKit {
12
+ /** Signs one access token. */
13
+ readonly issue: IssueAccessToken
14
+ /** Verifies one access token against the app's own keys. */
15
+ readonly verify: VerifyAccessToken
16
+ /** The public key set served at `/.well-known/jwks.json`. */
17
+ readonly jwks: JSONWebKeySet
18
+ }
19
+
20
+ /** Builds the token kit from the ES256 private key; the verifier checks the app's own key set, never a remote one. */
21
+ export const createTokenKit = async (options: TokenOptions): Promise<TokenKit> => {
22
+ const privateKey = await importPKCS8(options.privateKey.reveal(), ALGORITHM, { extractable: true })
23
+ const { d: _private, ...publicJwk } = await exportJWK(privateKey)
24
+ const jwks: JSONWebKeySet = { keys: [{ ...publicJwk, kid: options.keyId, alg: ALGORITHM, use: "sig" }] }
25
+ const keys = createLocalJWKSet(jwks)
26
+ const issue: IssueAccessToken = (user) =>
27
+ new SignJWT({ role: ROLE, ...(user.email === undefined ? {} : { email: user.email }) })
28
+ .setProtectedHeader({ alg: ALGORITHM, kid: options.keyId })
29
+ .setSubject(user.id)
30
+ .setIssuer(options.issuer)
31
+ .setAudience(options.audience)
32
+ .setIssuedAt()
33
+ .setExpirationTime(`${String(options.accessTtlSeconds)}s`)
34
+ .sign(privateKey)
35
+ const verify: VerifyAccessToken = async (token) => {
36
+ try {
37
+ const { payload } = await jwtVerify(token, keys, {
38
+ algorithms: ["ES256"],
39
+ audience: options.audience,
40
+ issuer: options.issuer,
41
+ })
42
+ if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || payload.role !== ROLE) {
43
+ return refused(PassportErrorCode.AccessTokenInvalid)
44
+ }
45
+ const principal: OwnPrincipal = {
46
+ id: payload.sub,
47
+ role: ROLE,
48
+ ...(typeof payload.email === "string" ? { email: payload.email } : {}),
49
+ }
50
+ return ok(principal)
51
+ } catch (cause) {
52
+ return refused(PassportErrorCode.AccessTokenInvalid, { cause: String(cause) })
53
+ }
54
+ }
55
+ return { issue, verify, jwks }
56
+ }
57
+
58
+ /**
59
+ * The token kit of a composition root that registers synchronously: each call awaits the one key import started here, so a
60
+ * bad key fails the first verification instead of hiding behind a default.
61
+ */
62
+ export const tokenKit = (options: TokenOptions): { readonly issue: IssueAccessToken; readonly verify: VerifyAccessToken; readonly jwks: () => Promise<JSONWebKeySet> } => {
63
+ const kit = createTokenKit(options)
64
+ return {
65
+ issue: async (user) => (await kit).issue(user),
66
+ verify: async (token) => (await kit).verify(token),
67
+ jwks: async () => (await kit).jwks,
68
+ }
69
+ }
@@ -0,0 +1,37 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import { PassportStrategy } from "@nestjs/passport"
3
+ import { Strategy } from "passport-google-oauth20"
4
+ import type { Profile } from "passport-google-oauth20"
5
+ import { InjectPassportOptions } from "../passport.decorators"
6
+ import type { PassportOptions } from "../passport.options"
7
+ import { CookieStateStore } from "./oauth-state.store"
8
+
9
+ /** The Google identity a callback proves: the provider subject and the verified email when Google states one. */
10
+ export interface GoogleIdentity {
11
+ /** Google's stable subject id (auth.identities.provider_id). */
12
+ readonly providerId: string
13
+ /** The verified email, when present. */
14
+ readonly email?: string
15
+ }
16
+
17
+ @Injectable()
18
+ /** Google OAuth 2.0 (authorization code with state and PKCE): it proves the Google identity and never signs the user in itself. */
19
+ export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
20
+ constructor(@InjectPassportOptions() options: PassportOptions) {
21
+ super({
22
+ clientID: options.google.clientId,
23
+ clientSecret: options.google.clientSecret.reveal(),
24
+ callbackURL: options.google.callbackUrl,
25
+ scope: ["openid", "email"],
26
+ pkce: true,
27
+ state: true,
28
+ store: new CookieStateStore(new URL(options.google.callbackUrl).pathname),
29
+ })
30
+ }
31
+
32
+ /** The identity of the callback; the identity domain links it to a user and issues the session. */
33
+ validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity {
34
+ const email = profile.emails?.find((entry) => entry.verified)?.value
35
+ return { providerId: profile.id, ...(email === undefined ? {} : { email }) }
36
+ }
37
+ }
@@ -0,0 +1,36 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import { PassportStrategy } from "@nestjs/passport"
3
+ import { Strategy } from "passport-local"
4
+ import { PassportError, PassportErrorCode } from "../errors/passport.error"
5
+ import { InjectPasswordAccounts } from "../passport.decorators"
6
+ import { verifyPassword } from "../passport.password"
7
+
8
+ /** The account a password sign-in proves. */
9
+ export interface PasswordAccount {
10
+ /** The user id (auth.users.id). */
11
+ readonly id: string
12
+ /** The account email. */
13
+ readonly email: string
14
+ /** The stored bcrypt hash (auth.users.encrypted_password), null for an account without a password. */
15
+ readonly passwordHash: string | null
16
+ }
17
+
18
+ /** The lookup the identity domain provides: the account of one email, or undefined. */
19
+ export type FindPasswordAccount = (email: string) => Promise<PasswordAccount | undefined>
20
+
21
+ @Injectable()
22
+ /** Email and password sign-in: bcrypt against the stored hash; a miss and a wrong password fail alike. */
23
+ export class LocalStrategy extends PassportStrategy(Strategy, "local") {
24
+ constructor(@InjectPasswordAccounts() private readonly findAccount: FindPasswordAccount) {
25
+ super({ usernameField: "email", passwordField: "password" })
26
+ }
27
+
28
+ /** The proven account; every refusal is the same error so an email's existence never leaks. */
29
+ async validate(email: string, password: string): Promise<{ readonly id: string; readonly email: string }> {
30
+ const account = await this.findAccount(email.trim().toLowerCase())
31
+ if (!(await verifyPassword(password, account?.passwordHash ?? null)) || account === undefined) {
32
+ throw new PassportError({ code: PassportErrorCode.CredentialsRefused })
33
+ }
34
+ return { id: account.id, email: account.email }
35
+ }
36
+ }