@starci/hfs 4.9.9 → 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 (241) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +20 -8
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +289 -0
  5. package/db/dump.mjs +12 -11
  6. package/db/entity-list.mjs +9 -0
  7. package/db/migrate.mjs +84 -260
  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/lint/run.mjs +12 -2
  13. package/package.json +5 -3
  14. package/release/gate.mjs +82 -31
  15. package/release/index.mjs +19 -8
  16. package/release/push.mjs +4 -4
  17. package/release/records.mjs +54 -0
  18. package/release/urgent.mjs +129 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
  20. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  21. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  22. package/runtime/knowledge/hfs/rules.yaml +250 -157
  23. package/runtime/knowledge/hfs/slots.yaml +199 -174
  24. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  25. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  26. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  27. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  28. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  29. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  30. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  31. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  32. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  33. package/runtime/modules/kernel/command-policy.yaml +1 -1
  34. package/runtime/modules/kernel/failure-codes.yaml +86 -115
  35. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  36. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  37. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  39. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  40. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  41. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  43. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  44. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  45. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  46. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  47. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  48. package/runtime/scripts/hfs/check.mjs +8 -8
  49. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  50. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  51. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  52. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  53. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  54. package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
  55. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  56. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  57. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  58. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  59. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  60. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  61. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  62. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  63. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  64. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  65. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  66. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  67. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  68. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  69. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  70. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  71. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  72. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  73. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  74. package/runtime/scripts/hfs/slots.mjs +8 -2
  75. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  76. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  77. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  78. package/scaffold/add-app.mjs +2 -10
  79. package/scaffold/add-cli-lite.mjs +6 -6
  80. package/scaffold/add-compose.mjs +1 -1
  81. package/scaffold/add-inbox-lite.mjs +4 -8
  82. package/scaffold/add-plan.mjs +1 -1
  83. package/scaffold/add-queue-lite.mjs +16 -24
  84. package/scaffold/add-table.mjs +34 -26
  85. package/scaffold/add.mjs +2 -2
  86. package/scaffold/app.mjs +19 -86
  87. package/scaffold/lite-exports.mjs +1 -1
  88. package/src/main.mjs +24 -22
  89. package/stack/cli.mjs +10 -2
  90. package/stack/deploy.mjs +21 -4
  91. package/stack/target-binding.mjs +0 -60
  92. package/stack/urgent-floor.mjs +147 -0
  93. package/sync/ci-commands.mjs +3 -1
  94. package/sync/index.mjs +32 -8
  95. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  96. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  97. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  98. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  99. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  100. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  101. package/templates/app/gitignore-lite +0 -3
  102. package/templates/app/package-scripts/db.json +9 -0
  103. package/templates/app/package-scripts/package.json +0 -1
  104. package/templates/app/package-scripts-lite/package.json +1 -1
  105. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  106. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  107. package/templates/app/skeleton/README.md +1 -1
  108. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  109. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  110. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  111. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  112. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  113. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  114. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  115. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  116. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  117. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  118. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  119. package/templates/app/skeleton-lite/README.md +11 -11
  120. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  121. package/templates/app/tool-config-lite/prettierignore +0 -1
  122. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  123. package/templates/be/inbox/database-client.ts.tpl +13 -4
  124. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  125. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  126. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  127. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  128. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  129. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  130. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  131. package/templates/be/inbox/service.ts.tpl +1 -2
  132. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  133. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  134. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  135. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  136. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  137. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  138. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  139. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  140. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  141. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  142. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  143. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  144. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  145. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  146. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  147. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  148. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  149. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  150. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  151. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  152. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  153. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  154. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  155. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  156. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  157. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  158. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  159. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  160. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  161. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  162. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  163. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  164. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  165. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
  166. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  167. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  168. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  169. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  170. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  171. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  172. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  173. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  174. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  175. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  176. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  177. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  178. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  179. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  180. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  181. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  182. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  183. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  184. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  185. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  186. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  187. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  188. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  189. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  190. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  191. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  192. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  193. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  194. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  195. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  196. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  197. package/templates/be/table/entities.index.ts.tpl +4 -0
  198. package/templates/be/table/entity.ts.tpl +21 -0
  199. package/templates/be/table/index.ts.tpl +1 -0
  200. package/templates/be/table/module.ts.tpl +1 -1
  201. package/templates/be/table/service.api.ts.tpl +10 -11
  202. package/templates/be/table/service.ts.tpl +5 -6
  203. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  204. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  205. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  207. package/db/local-migrate.mjs +0 -194
  208. package/db/reset.mjs +0 -125
  209. package/emit/db-types.mjs +0 -271
  210. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  211. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  212. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  213. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  214. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  215. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  216. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  217. package/scaffold/supabase-ports.mjs +0 -26
  218. package/templates/app/package-scripts/supabase.json +0 -7
  219. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  220. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  221. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  222. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  223. package/templates/be/inbox/sql.ts.tpl +0 -16
  224. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  225. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  226. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  227. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  228. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  229. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  230. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  231. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  232. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  233. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  234. package/templates/be/table/rows.ts.tpl +0 -4
  235. package/templates/be/table/sql.api.ts.tpl +0 -7
  236. package/templates/be/table/sql.ts.tpl +0 -4
  237. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  238. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  239. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  240. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  241. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
@@ -1,8 +1,8 @@
1
1
  import { Test } from "@nestjs/testing"
2
2
  import { MESSAGE_MAPPING_METADATA } from "@nestjs/websockets/constants"
3
3
  import { @@service@@ } from "@@serviceModule@@"
4
- import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
5
- import { SupabaseErrorCode } from "@modules/integrations/supabase"
4
+ import type { VerifyAccessToken } from "@modules/integrations/passport"
5
+ import { PassportErrorCode } from "@modules/integrations/passport"
6
6
  import type { Principal } from "@modules/platform/cqrs"
7
7
  import { ok, refused } from "@modules/platform/primitives"
8
8
  import { REALTIME_HUB } from "@modules/platform/realtime"
@@ -79,7 +79,7 @@ const socketOf = (id: string, auth: Readonly<Record<string, unknown>> = { access
79
79
  return { socket, pushed }
80
80
  }
81
81
 
82
- const build = async (hub: RealtimeHub, verify: VerifySupabaseAccessToken) => {
82
+ const build = async (hub: RealtimeHub, verify: VerifyAccessToken) => {
83
83
  const moduleRef = await Test.createTestingModule({
84
84
  imports: [@@Channel@@WebsocketModule.register({ verifyAccessToken: verify })],
85
85
  })
@@ -96,7 +96,7 @@ const build = async (hub: RealtimeHub, verify: VerifySupabaseAccessToken) => {
96
96
  describe("@@Channel@@Gateway", () => {
97
97
  it("builds the topic only from the verified principal, ignoring handshake principal and topic claims", async () => {
98
98
  const { hub, topics } = hubAnswering()
99
- const verify: VerifySupabaseAccessToken = jest.fn().mockResolvedValue(ok(principal))
99
+ const verify: VerifyAccessToken = jest.fn().mockResolvedValue(ok(principal))
100
100
  const { transport, adapter } = await build(hub, verify)
101
101
  const { socket } = socketOf("socket-1", { accessToken: "signed-token", principal: { id: "p-2" }, topic: "foreign" })
102
102
 
@@ -111,7 +111,7 @@ describe("@@Channel@@Gateway", () => {
111
111
 
112
112
  it("isolates another authenticated principal's topic", async () => {
113
113
  const { hub, topics } = hubAnswering()
114
- const verify: VerifySupabaseAccessToken = jest.fn().mockResolvedValue(ok({ id: "p-2", role: "authenticated" }))
114
+ const verify: VerifyAccessToken = jest.fn().mockResolvedValue(ok({ id: "p-2", role: "authenticated" }))
115
115
  const { transport, adapter } = await build(hub, verify)
116
116
  const { socket } = socketOf("socket-2")
117
117
 
@@ -126,7 +126,7 @@ describe("@@Channel@@Gateway", () => {
126
126
  it("pushes a hub frame automatically and releases the iterator on disconnect", async () => {
127
127
  const frame = { id: "x-1" }
128
128
  const { hub, released } = hubAnswering(frame)
129
- const verify: VerifySupabaseAccessToken = jest.fn().mockResolvedValue(ok(principal))
129
+ const verify: VerifyAccessToken = jest.fn().mockResolvedValue(ok(principal))
130
130
  const { transport, adapter } = await build(hub, verify)
131
131
  const { socket, pushed } = socketOf("socket-1")
132
132
 
@@ -142,7 +142,7 @@ describe("@@Channel@@Gateway", () => {
142
142
 
143
143
  it.each([{}, { accessToken: "" }, { accessToken: 42 }])("refuses a missing or malformed access token without invoking the verifier", async (auth) => {
144
144
  const { hub, topics } = hubAnswering()
145
- const verify: VerifySupabaseAccessToken = jest.fn()
145
+ const verify: VerifyAccessToken = jest.fn()
146
146
  const { adapter } = await build(hub, verify)
147
147
  const { socket } = socketOf("socket-1", auth)
148
148
  socket.data.principal = principal
@@ -156,7 +156,7 @@ describe("@@Channel@@Gateway", () => {
156
156
 
157
157
  it("refuses a rejected token and clears a prior principal", async () => {
158
158
  const { hub, topics } = hubAnswering()
159
- const verify: VerifySupabaseAccessToken = jest.fn().mockResolvedValue(refused(SupabaseErrorCode.AccessTokenInvalid))
159
+ const verify: VerifyAccessToken = jest.fn().mockResolvedValue(refused(PassportErrorCode.AccessTokenInvalid))
160
160
  const { adapter } = await build(hub, verify)
161
161
  const { socket } = socketOf("socket-1")
162
162
  socket.data.principal = principal
@@ -1,7 +1,7 @@
1
1
  import { Module } from "@nestjs/common"
2
2
  import type { OnModuleDestroy, OnModuleInit } from "@nestjs/common"
3
3
  import type { @@Channel@@Frame } from "@@serviceModule@@"
4
- import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
4
+ import type { VerifyAccessToken } from "@modules/integrations/passport"
5
5
  import { injector } from "@modules/platform/composition"
6
6
  import type { Principal } from "@modules/platform/cqrs"
7
7
  import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from "./@@channel@@-websocket.module-definition"
@@ -48,8 +48,8 @@ export class @@Channel@@WebsocketModule extends ConfigurableModuleClass implemen
48
48
 
49
49
  constructor(
50
50
  private readonly gateway: @@Channel@@Gateway,
51
- @injector<VerifySupabaseAccessToken>(ACCESS_TOKEN_VERIFIER)
52
- private readonly verifyAccessToken: VerifySupabaseAccessToken,
51
+ @injector<VerifyAccessToken>(ACCESS_TOKEN_VERIFIER)
52
+ private readonly verifyAccessToken: VerifyAccessToken,
53
53
  ) {
54
54
  super()
55
55
  }
@@ -1,7 +1,7 @@
1
- import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
1
+ import type { VerifyAccessToken } from "@modules/integrations/passport"
2
2
 
3
3
  /** The options of the @@channel@@ socket transport: the cryptographic verifier that authenticates each connection. */
4
4
  export interface @@Channel@@WebsocketOptions {
5
5
  /** Verifies the access token of a handshake with the same verifier identity uses. */
6
- readonly verifyAccessToken: VerifySupabaseAccessToken
6
+ readonly verifyAccessToken: VerifyAccessToken
7
7
  }
@@ -1,14 +1,22 @@
1
- import { SetMetadata } from "@nestjs/common"
1
+ import { SetMetadata, applyDecorators } from "@nestjs/common"
2
2
  import { injector } from "@modules/platform/composition"
3
- import type { TypedParameterDecorator } from "@modules/platform/composition"
3
+ import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
4
+ import { ServerToServer } from "@modules/platform/http-security"
4
5
  import type { CredentialAuthenticator, CredentialMetadata } from "./credential.contracts"
6
+ import { PublicReason } from "./identity.contracts"
5
7
  import type { PublicMetadata } from "./identity.contracts"
6
8
 
7
9
  /** Metadata key of `@Public`. */
8
10
  export const PUBLIC_KEY = "domain.identity.public"
9
11
 
10
- /** Opens a door to anonymous callers, stating why; every other door needs a credential or a signed-in caller. */
11
- export const Public = (metadata: PublicMetadata): ReturnType<typeof SetMetadata> => SetMetadata(PUBLIC_KEY, metadata)
12
+ /**
13
+ * Opens a door to anonymous callers, stating why; every other door needs a credential or a signed-in caller. A
14
+ * signed webhook proves its caller by its signature, so it owes no browser origin.
15
+ */
16
+ export const Public = (metadata: PublicMetadata): DoorDecorator =>
17
+ metadata.reason === PublicReason.SignedWebhook
18
+ ? applyDecorators(SetMetadata(PUBLIC_KEY, metadata), ServerToServer())
19
+ : applyDecorators(SetMetadata(PUBLIC_KEY, metadata))
12
20
 
13
21
  /** Metadata key of `@Credential`. */
14
22
  export const CREDENTIAL_KEY: unique symbol = Symbol("domain.identity.credential")
@@ -20,6 +28,9 @@ export const CREDENTIAL_AUTHENTICATOR: unique symbol = Symbol("domain.identity.c
20
28
  export const InjectCredentialAuthenticator = (): TypedParameterDecorator<CredentialAuthenticator> =>
21
29
  injector<CredentialAuthenticator>(CREDENTIAL_AUTHENTICATOR)
22
30
 
23
- /** Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. */
24
- export const Credential = (metadata: CredentialMetadata): ReturnType<typeof SetMetadata> =>
25
- SetMetadata(CREDENTIAL_KEY, metadata)
31
+ /**
32
+ * Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. The
33
+ * credential travels in a header no browser attaches by itself, so the door owes no browser origin.
34
+ */
35
+ export const Credential = (metadata: CredentialMetadata): DoorDecorator =>
36
+ applyDecorators(SetMetadata(CREDENTIAL_KEY, metadata), ServerToServer())
@@ -8,6 +8,9 @@ export type TypedParameterDecorator<T> = ParameterDecorator & {
8
8
  readonly __injects?: T
9
9
  }
10
10
 
11
+ /** A decorator a door carries on its method or its class; one built from several metadata decorators is typed with it. */
12
+ export type DoorDecorator = MethodDecorator & ClassDecorator
13
+
11
14
  /** Builds the parameter decorator that injects `token`, typed with the value it injects. Every `Inject<Thing>()` is built with it. */
12
15
  export const injector = <T>(token: InjectionToken): TypedParameterDecorator<T> => Inject(token)
13
16
 
@@ -1,2 +1,2 @@
1
1
  export { InjectReflector, injector } from "./composition.decorators"
2
- export type { TypedParameterDecorator } from "./composition.decorators"
2
+ export type { DoorDecorator, TypedParameterDecorator } from "./composition.decorators"
@@ -37,7 +37,7 @@ describe("ErrorsFilter", () => {
37
37
  const { filter, errors, requestLocale } = await build()
38
38
  const failure = new Error("note missing")
39
39
  const request = mock<Request>({ headers: { "accept-language": "en-US,en;q=0.9" } })
40
- const response = mock<Response>()
40
+ const response = mock<Response>({ headersSent: false })
41
41
  response.status.mockReturnValue(response)
42
42
  const http = mock<ReturnType<ArgumentsHost["switchToHttp"]>>()
43
43
  http.getRequest.mockReturnValue(request)
@@ -66,4 +66,18 @@ describe("ErrorsFilter", () => {
66
66
  params: { noteId: "note-1" },
67
67
  })
68
68
  })
69
+
70
+ it("leaves a response a guard already sent as it is", async () => {
71
+ const { filter, errors } = await build()
72
+ const response = mock<Response>({ headersSent: true })
73
+ const http = mock<ReturnType<ArgumentsHost["switchToHttp"]>>()
74
+ http.getResponse.mockReturnValue(response)
75
+ const host = mock<ArgumentsHost>()
76
+ host.switchToHttp.mockReturnValue(http)
77
+
78
+ expect(filter.catch(new Error("rate limited"), host)).toBeUndefined()
79
+
80
+ expect(errors.describe).not.toHaveBeenCalled()
81
+ expect(response.status).not.toHaveBeenCalled()
82
+ })
69
83
  })
@@ -9,7 +9,8 @@ import type { ErrorsService } from "./errors.service"
9
9
  @Catch()
10
10
  /**
11
11
  * The one REST exception filter of an app: answers `{ code, kind, message, params }` with the status of the kind. In a GraphQL
12
- * context it hands the failure back untouched, so the GraphQL error formatter (platform/graphql) answers it.
12
+ * context it hands the failure back untouched, so the GraphQL error formatter (platform/graphql) answers it. A response a
13
+ * guard already answered (a redirect-only door over its rate limit) is left as it was sent.
13
14
  */
14
15
  export class ErrorsFilter implements ExceptionFilter {
15
16
  constructor(
@@ -21,17 +22,17 @@ export class ErrorsFilter implements ExceptionFilter {
21
22
  catch(exception: unknown, host: ArgumentsHost): unknown {
22
23
  if (host.getType<string>() === "graphql") return exception
23
24
  const http = host.switchToHttp()
25
+ const response = http.getResponse<Response>()
26
+ if (response.headersSent) return undefined
24
27
  const request = http.getRequest<Request>()
25
28
  const description = this.errors.describe(exception)
26
29
  const locale = this.requestLocale.of(request.headers["accept-language"])
27
- http.getResponse<Response>()
28
- .status(description.status)
29
- .json({
30
- code: description.code,
31
- kind: description.kind,
32
- message: this.errors.text(description.code, description.params, locale),
33
- params: description.params,
34
- })
30
+ response.status(description.status).json({
31
+ code: description.code,
32
+ kind: description.kind,
33
+ message: this.errors.text(description.code, description.params, locale),
34
+ params: description.params,
35
+ })
35
36
  return undefined
36
37
  }
37
38
  }
@@ -3,7 +3,7 @@ export { parseHttpSecurityConfig } from "./http-security.config"
3
3
  export { HttpSecurityModule } from "./http-security.module"
4
4
  export type { HttpSecurityOptions } from "./http-security.options"
5
5
  export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
6
- export { OriginGuard } from "./origin.guard"
6
+ export { OriginGuard, ServerToServer } from "./origin.guard"
7
7
  export { httpSecurityEntities, httpSecurityMigrations } from "./persistence/connection"
8
8
  export { requestOf } from "./execution-request.mapper"
9
9
  export { RateLimitGuard } from "./rate-limit.guard"
@@ -1,11 +1,13 @@
1
- import type { ExecutionContext } from "@nestjs/common"
1
+ import "reflect-metadata"
2
+ import type { ExecutionContext, Type } from "@nestjs/common"
2
3
  import type { Request } from "express"
4
+ import { Reflector } from "@nestjs/core"
3
5
  import { Test } from "@nestjs/testing"
4
6
  import { mock } from "@starci/jest-preset"
5
7
  import { HttpSecurityErrorCode } from "./errors/http-security.error"
6
8
  import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
7
9
  import type { HttpSecurityOptions } from "./http-security.options"
8
- import { OriginGuard } from "./origin.guard"
10
+ import { OriginGuard, ServerToServer } from "./origin.guard"
9
11
 
10
12
  const options: HttpSecurityOptions = {
11
13
  allowedOrigins: ["https://app.test"],
@@ -13,49 +15,63 @@ const options: HttpSecurityOptions = {
13
15
  webhooks: {},
14
16
  }
15
17
 
16
- const build = async (method: string, headers: Request["headers"]) => {
18
+ class OpenDoor {}
19
+
20
+ class SignedDoor {}
21
+ ServerToServer()(SignedDoor)
22
+
23
+ const build = async (method: string, headers: Request["headers"], door: Type<unknown> = OpenDoor) => {
17
24
  const request = mock<Request>({ headers, method })
18
25
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
19
26
  http.getRequest.mockReturnValue(request)
20
27
  const context = mock<ExecutionContext>()
21
28
  context.switchToHttp.mockReturnValue(http)
29
+ context.getHandler.mockReturnValue(() => undefined)
30
+ context.getClass.mockReturnValue(door)
22
31
  const moduleRef = await Test.createTestingModule({
23
- providers: [OriginGuard, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
32
+ providers: [OriginGuard, Reflector, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
24
33
  }).compile()
25
34
  return { guard: moduleRef.get(OriginGuard), context }
26
35
  }
27
36
 
28
37
  describe("OriginGuard", () => {
29
- it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method regardless of origin", async (method) => {
30
- const { guard, context } = await build(method, { origin: "https://foreign.test" })
38
+ it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method without an origin", async (method) => {
39
+ const { guard, context } = await build(method, {})
31
40
 
32
41
  expect(guard.canActivate(context)).toBe(true)
33
42
  })
34
43
 
35
- it.each([{}, { referer: "not a URL" }])(
36
- "allows a state-changing non-browser request without a usable origin (%o)",
37
- async (headers) => {
38
- const { guard, context } = await build("POST", headers)
39
-
40
- expect(guard.canActivate(context)).toBe(true)
41
- },
42
- )
43
-
44
44
  it("allows a state-changing request carrying an allowed Origin header", async () => {
45
45
  const { guard, context } = await build("POST", { origin: "https://app.test" })
46
46
 
47
47
  expect(guard.canActivate(context)).toBe(true)
48
48
  })
49
49
 
50
- it("uses the origin of an allowed Referer URL", async () => {
50
+ it("falls back to the origin of an allowed Referer when Origin is absent", async () => {
51
51
  const { guard, context } = await build("PATCH", { referer: "https://app.test/notes/one" })
52
52
 
53
53
  expect(guard.canActivate(context)).toBe(true)
54
54
  })
55
55
 
56
- it("refuses a state-changing request from an origin outside the allowlist", async () => {
57
- const { guard, context } = await build("DELETE", { origin: "https://foreign.test" })
56
+ it.each([
57
+ { name: "no Origin and no Referer", headers: {} },
58
+ { name: "a Referer that is not a URL", headers: { referer: "not a URL" } },
59
+ { name: "an Origin outside the allowlist", headers: { origin: "https://foreign.test" } },
60
+ { name: "the opaque null Origin", headers: { origin: "null" } },
61
+ { name: "a Referer outside the allowlist", headers: { referer: "https://foreign.test/form" } },
62
+ {
63
+ name: "a foreign Origin beside an allowed Referer",
64
+ headers: { origin: "https://foreign.test", referer: "https://app.test/notes/one" },
65
+ },
66
+ ])("refuses a state-changing request with $name", async ({ headers }) => {
67
+ const { guard, context } = await build("DELETE", headers)
58
68
 
59
69
  expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
60
70
  })
71
+
72
+ it("allows a state-changing request without an origin on a door marked server-to-server", async () => {
73
+ const { guard, context } = await build("POST", {}, SignedDoor)
74
+
75
+ expect(guard.canActivate(context)).toBe(true)
76
+ })
61
77
  })
@@ -1,5 +1,7 @@
1
- import { Injectable } from "@nestjs/common"
1
+ import { Injectable, SetMetadata } from "@nestjs/common"
2
2
  import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
+ import type { Reflector } from "@nestjs/core"
4
+ import { InjectReflector } from "@modules/platform/composition"
3
5
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
4
6
  import { requestOf } from "./execution-request.mapper"
5
7
  import { InjectHttpSecurityOptions } from "./http-security.decorators"
@@ -7,25 +9,44 @@ import type { HttpSecurityOptions } from "./http-security.options"
7
9
 
8
10
  const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"])
9
11
 
12
+ const SERVER_TO_SERVER_KEY = "platform.http-security.server-to-server"
13
+
14
+ /**
15
+ * Marks a door whose caller is a server proving itself on every request (a signed webhook, a header credential), so no
16
+ * browser origin is owed. Applied only by the identity decorators that carry that proof: `@Public({ reason:
17
+ * PublicReason.SignedWebhook })` and `@Credential({ kind })`.
18
+ */
19
+ export const ServerToServer = (): ReturnType<typeof SetMetadata> => SetMetadata(SERVER_TO_SERVER_KEY, true)
20
+
21
+ /** The origin a request names: its Origin header, or the origin of its Referer only when Origin is absent. */
10
22
  const originOf = (origin: string | undefined, referer: string | undefined): string | undefined => {
11
- if (origin) return origin
12
- return referer && URL.canParse(referer) ? new URL(referer).origin : undefined
23
+ if (origin !== undefined) return origin
24
+ return referer !== undefined && URL.canParse(referer) ? new URL(referer).origin : undefined
13
25
  }
14
26
 
15
27
  @Injectable()
16
28
  /**
17
- * The second app guard, the CSRF defence: a state-changing request that names an origin (browsers always do) must name
18
- * an allowed one. A request without Origin and Referer is not a browser and carries no ambient credential, so it passes.
29
+ * The second app guard, the CSRF defence: a state-changing request must name an allowed origin, by its Origin header or,
30
+ * when Origin is absent, by its Referer. A request that names none is refused, since a session cookie rides on it
31
+ * whatever sent it. A door marked `ServerToServer` proves its caller itself and owes no origin.
19
32
  */
20
33
  export class OriginGuard implements CanActivate {
21
- constructor(@InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions) {}
34
+ constructor(
35
+ @InjectReflector() private readonly reflector: Reflector,
36
+ @InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
37
+ ) {}
22
38
 
23
- /** Refuses a state-changing request from an origin outside the allowlist. */
39
+ /** Refuses a state-changing request whose origin is missing or outside the allowlist. */
24
40
  canActivate(context: ExecutionContext): boolean {
25
41
  const request = requestOf(context)
26
42
  if (SAFE_METHODS.has(request.method)) return true
43
+ const exempt = this.reflector.getAllAndOverride<boolean | undefined>(SERVER_TO_SERVER_KEY, [
44
+ context.getHandler(),
45
+ context.getClass(),
46
+ ])
47
+ if (exempt === true) return true
27
48
  const origin = originOf(request.headers.origin, request.headers.referer)
28
- if (origin === undefined || this.options.allowedOrigins.includes(origin)) return true
49
+ if (origin !== undefined && this.options.allowedOrigins.includes(origin)) return true
29
50
  throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
30
51
  }
31
52
  }
@@ -1,6 +1,6 @@
1
1
  import "reflect-metadata"
2
2
  import type { ExecutionContext, Type } from "@nestjs/common"
3
- import type { Request } from "express"
3
+ import type { Request, Response } from "express"
4
4
  import { Reflector } from "@nestjs/core"
5
5
  import { Test } from "@nestjs/testing"
6
6
  import { FakeClock, mock, mockEntityManager } from "@starci/jest-preset"
@@ -12,6 +12,7 @@ import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
12
12
  import type { HttpSecurityOptions } from "./http-security.options"
13
13
  import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
14
14
  import { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
15
+ import type { RateLimitExceeded } from "./rate-limit.guard"
15
16
 
16
17
  const AT = "2026-02-03T04:05:06.000Z"
17
18
  const RESET_AT = "2026-02-03T04:06:06.000Z"
@@ -21,9 +22,11 @@ const options: HttpSecurityOptions = {
21
22
  webhooks: {},
22
23
  }
23
24
 
24
- const build = async (entityManager: MockEntityManager, tier?: RateTier) => {
25
+ const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
25
26
  const reflector = mock<Reflector>()
26
- reflector.getAllAndOverride.mockReturnValue(tier)
27
+ reflector.getAllAndOverride.mockImplementation((key: unknown) =>
28
+ key === "platform.http-security.rate-exceeded" ? exceeded : tier,
29
+ )
27
30
  const moduleRef = await Test.createTestingModule({
28
31
  providers: [
29
32
  RateLimitGuard,
@@ -37,10 +40,17 @@ const build = async (entityManager: MockEntityManager, tier?: RateTier) => {
37
40
  }
38
41
 
39
42
  /** An execution context of one POST from `ip` on the door; the handler and the class carry the tier metadata. */
40
- const contextOf = (ip: string | undefined, handler = () => undefined, door: Type<unknown> = class Door {}) => {
43
+ const contextOf = (
44
+ ip: string | undefined,
45
+ handler = () => undefined,
46
+ door: Type<unknown> = class Door {},
47
+ response = mock<Response>(),
48
+ ) => {
41
49
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
42
50
  http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip }))
51
+ http.getResponse.mockReturnValue(response)
43
52
  const context = mock<ExecutionContext>()
53
+ context.getType.mockReturnValue("http")
44
54
  context.switchToHttp.mockReturnValue(http)
45
55
  context.getHandler.mockReturnValue(handler)
46
56
  context.getClass.mockReturnValue(door)
@@ -66,6 +76,23 @@ describe("RateLimit", () => {
66
76
  new Date(RESET_AT),
67
77
  ])
68
78
  })
79
+
80
+ it("marks a redirect-only door with its exceeded redirect beside the tier", () => {
81
+ class Door {}
82
+ RateLimit(RateTier.Strict, { redirect: "/sign-in?auth=error" })(Door)
83
+
84
+ expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
85
+ expect(Reflect.getMetadata("platform.http-security.rate-exceeded", Door)).toEqual({
86
+ redirect: "/sign-in?auth=error",
87
+ })
88
+ })
89
+
90
+ it.each(["https://foreign.test/sign-in", "//foreign.test/sign-in", "/\\foreign.test", "sign-in"])(
91
+ "refuses an exceeded redirect %s that is not a same-site path",
92
+ (redirect) => {
93
+ expect(() => RateLimit(RateTier.Strict, { redirect })).toThrow(HttpSecurityErrorCode.RequestInvalid)
94
+ },
95
+ )
69
96
  })
70
97
 
71
98
  describe("RateLimitGuard", () => {
@@ -135,3 +162,52 @@ describe("RateLimitGuard", () => {
135
162
  })
136
163
  })
137
164
  })
165
+
166
+ describe("RateLimitGuard on a redirect-only door", () => {
167
+ const EXCEEDED: RateLimitExceeded = { redirect: "/sign-in?auth=error" }
168
+
169
+ /** A response whose redirect is a plain double, so the spec reads the call without the deprecated overload. */
170
+ const responseOf = () => {
171
+ const redirect = jest.fn()
172
+ return { response: mock<Response>({ redirect }), redirect }
173
+ }
174
+
175
+ it("answers the door's own 303 when the caller is over the limit, and still refuses", async () => {
176
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
177
+ const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
178
+ const { response, redirect } = responseOf()
179
+
180
+ await expect(
181
+ guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response)),
182
+ ).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
183
+ expect(redirect).toHaveBeenCalledWith(303, "/sign-in?auth=error")
184
+ })
185
+
186
+ it("writes no redirect while the caller is under the limit", async () => {
187
+ const entityManager = mockEntityManager({
188
+ query: [
189
+ [HIT_RATE_WINDOW, [{ hits: 1 }]],
190
+ [PRUNE_RATE_WINDOWS, []],
191
+ ],
192
+ })
193
+ const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
194
+ const { response, redirect } = responseOf()
195
+
196
+ await expect(guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response))).resolves.toBe(
197
+ true,
198
+ )
199
+ expect(redirect).not.toHaveBeenCalled()
200
+ })
201
+
202
+ it("refuses a GraphQL operation over the limit without writing a redirect", async () => {
203
+ const entityManager = mockEntityManager({ query: [HIT_RATE_WINDOW, [{ hits: 2 }]] })
204
+ const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
205
+ const { response, redirect } = responseOf()
206
+ const context = contextOf("127.0.0.1", () => undefined, class Door {}, response)
207
+ context.getType.mockReturnValue("graphql")
208
+ context.getArgs.mockReturnValue([{}, {}, { req: mock<Request>({ headers: {}, ip: "127.0.0.1" }) }, {}])
209
+
210
+ await expect(guard.canActivate(context)).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
211
+ expect(redirect).not.toHaveBeenCalled()
212
+ })
213
+ })
@@ -1,10 +1,12 @@
1
- import { Injectable, SetMetadata } from "@nestjs/common"
1
+ import { Injectable, SetMetadata, applyDecorators } from "@nestjs/common"
2
2
  import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
3
  import type { Reflector } from "@nestjs/core"
4
+ import type { Response } from "express"
4
5
  import type { EntityManager } from "typeorm"
5
6
  import { InjectClock } from "@modules/platform/clock"
6
7
  import type { Clock } from "@modules/platform/clock"
7
8
  import { InjectReflector } from "@modules/platform/composition"
9
+ import type { DoorDecorator } from "@modules/platform/composition"
8
10
  import { InjectPrimaryEntityManager } from "@modules/platform/database"
9
11
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
10
12
  import { requestOf } from "./execution-request.mapper"
@@ -21,12 +23,34 @@ export enum RateTier {
21
23
  Strict = "strict",
22
24
  }
23
25
 
26
+ /** What a door answers when its caller is over the limit, when that is not the 429 error body. */
27
+ export interface RateLimitExceeded {
28
+ /** A same-site path the door's own contract answers with a 303 (an OAuth callback's error redirect). */
29
+ readonly redirect: string
30
+ }
31
+
24
32
  const RATE_TIER_KEY = "platform.http-security.rate-tier"
33
+ const RATE_EXCEEDED_KEY = "platform.http-security.rate-exceeded"
25
34
  /** The longest window key the table stores: the tier, a colon and the caller address. */
26
35
  const KEY_MAX = 128
27
36
 
28
- /** Marks a door with a rate limit tier other than the default. */
29
- export const RateLimit = (tier: RateTier): ReturnType<typeof SetMetadata> => SetMetadata(RATE_TIER_KEY, tier)
37
+ /** A same-site path: one leading slash, never a protocol-relative `//` or `/\` that leaves the site. */
38
+ const isSameSitePath = (location: string): boolean =>
39
+ location.startsWith("/") && !location.startsWith("//") && !location.startsWith("/\\")
40
+
41
+ /**
42
+ * Marks a door with a rate limit tier other than the default. A door whose contract answers only redirects (an OAuth
43
+ * callback) names its error redirect in `exceeded`, and a caller over the limit gets that 303 instead of a 429 body.
44
+ */
45
+ export const RateLimit = (tier: RateTier, exceeded?: RateLimitExceeded): DoorDecorator => {
46
+ if (exceeded === undefined) return applyDecorators(SetMetadata(RATE_TIER_KEY, tier))
47
+ if (!isSameSitePath(exceeded.redirect))
48
+ throw new HttpSecurityError({
49
+ code: HttpSecurityErrorCode.RequestInvalid,
50
+ params: { fields: "RATE_LIMIT_REDIRECT" },
51
+ })
52
+ return applyDecorators(SetMetadata(RATE_TIER_KEY, tier), SetMetadata(RATE_EXCEEDED_KEY, exceeded))
53
+ }
30
54
 
31
55
  @Injectable()
32
56
  /**
@@ -42,13 +66,14 @@ export class RateLimitGuard implements CanActivate {
42
66
  @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
43
67
  ) {}
44
68
 
45
- /** Counts the request and refuses it when the caller is over the limit of the door tier. */
69
+ /**
70
+ * Counts the request and refuses it when the caller is over the limit of the door tier. A door that declares its
71
+ * exceeded redirect has the 303 written here; the refusal still travels, and the errors filter leaves the sent
72
+ * response as it is.
73
+ */
46
74
  async canActivate(context: ExecutionContext): Promise<boolean> {
47
- const tier =
48
- this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, [
49
- context.getHandler(),
50
- context.getClass(),
51
- ]) ?? RateTier.Default
75
+ const targets = [context.getHandler(), context.getClass()]
76
+ const tier = this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, targets) ?? RateTier.Default
52
77
  const limit =
53
78
  tier === RateTier.Strict ? this.options.rateLimit.strictLimit : this.options.rateLimit.defaultLimit
54
79
  const now = this.clock.now()
@@ -57,7 +82,10 @@ export class RateLimitGuard implements CanActivate {
57
82
  const rows: Array<RateWindowRow> = await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt])
58
83
  const hits = rows[0]?.hits ?? Number.POSITIVE_INFINITY
59
84
  if (hits === 1) await this.entityManager.query(PRUNE_RATE_WINDOWS, [now])
60
- if (hits > limit) throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
61
- return true
85
+ if (hits <= limit) return true
86
+ const exceeded = this.reflector.getAllAndOverride<RateLimitExceeded | undefined>(RATE_EXCEEDED_KEY, targets)
87
+ if (exceeded !== undefined && context.getType() === "http")
88
+ context.switchToHttp().getResponse<Response>().redirect(303, exceeded.redirect)
89
+ throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
62
90
  }
63
91
  }
@@ -18,7 +18,6 @@ RUN npm ci --ignore-scripts
18
18
 
19
19
  COPY be/tsconfig.json be/tsconfig.build.json be/
20
20
  COPY be/src be/src
21
- COPY supabase/types supabase/types
22
21
  COPY be/apps/api be/apps/api
23
22
  RUN npm run build:be
24
23
 
@@ -2,7 +2,8 @@ import type { EnvSource } from "@modules/platform/config"
2
2
  import { parseServerConfig } from "@modules/platform/config"
3
3
  import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
4
4
  import { parseHttpSecurityConfig } from "@modules/platform/http-security"
5
- import { parseSupabaseConfig } from "@modules/integrations/supabase"
5
+ import { parsePassportConfig } from "@modules/integrations/passport"
6
+ import { parseObjectStorageConfig } from "@modules/integrations/object-storage"
6
7
 
7
8
  /** The API environment values required before composition. */
8
9
  export interface ApiEnv {
@@ -10,10 +11,12 @@ export interface ApiEnv {
10
11
  readonly server: ReturnType<typeof parseServerConfig>
11
12
  /** The origin allowlist and rate limits. */
12
13
  readonly httpSecurity: ReturnType<typeof parseHttpSecurityConfig>
13
- /** The least-privilege PostgreSQL connection to the Supabase project. */
14
+ /** The least-privilege PostgreSQL connection to the app's own database. */
14
15
  readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
15
- /** Supabase Auth, Storage and token-verification configuration. */
16
- readonly supabase: ReturnType<typeof parseSupabaseConfig>
16
+ /** Google OAuth, the app's own access tokens and the refresh cookie. */
17
+ readonly passport: ReturnType<typeof parsePassportConfig>
18
+ /** S3-compatible object storage. */
19
+ readonly objectStorage: ReturnType<typeof parseObjectStorageConfig>
17
20
  }
18
21
 
19
22
  /** Reads the API environment; missing or invalid values fail boot, and secrets use `_FILE` first. */
@@ -21,5 +24,6 @@ export const readApiEnv = (env: EnvSource): ApiEnv => ({
21
24
  server: parseServerConfig(env),
22
25
  httpSecurity: parseHttpSecurityConfig(env),
23
26
  database: parsePrimaryDatabaseConfig(env),
24
- supabase: parseSupabaseConfig(env),
27
+ passport: parsePassportConfig(env),
28
+ objectStorage: parseObjectStorageConfig(env),
25
29
  })