@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.
- package/CHANGELOG.md +33 -0
- package/README.md +20 -8
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +12 -11
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +84 -260
- package/db/new.mjs +46 -0
- package/db/post-cutover.mjs +13 -17
- package/db/postgres.mjs +80 -0
- package/emit/contracts.mjs +5 -9
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +82 -31
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
- package/runtime/knowledge/hfs/design-truths.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/rules.yaml +250 -157
- package/runtime/knowledge/hfs/slots.yaml +199 -174
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
- package/runtime/knowledge/patterns/be/projections.yaml +7 -5
- package/runtime/knowledge/patterns/be/queues.yaml +8 -8
- package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
- package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
- package/runtime/knowledge/patterns/be/saga.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +86 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +8 -8
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
- package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
- package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
- package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database.mjs +36 -39
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
- package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +7 -4
- package/scaffold/add-app.mjs +2 -10
- package/scaffold/add-cli-lite.mjs +6 -6
- package/scaffold/add-compose.mjs +1 -1
- package/scaffold/add-inbox-lite.mjs +4 -8
- package/scaffold/add-plan.mjs +1 -1
- package/scaffold/add-queue-lite.mjs +16 -24
- package/scaffold/add-table.mjs +34 -26
- package/scaffold/add.mjs +2 -2
- package/scaffold/app.mjs +19 -86
- package/scaffold/lite-exports.mjs +1 -1
- package/src/main.mjs +24 -22
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/sync/index.mjs +32 -8
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
- package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
- package/templates/app/gitignore-lite +0 -3
- package/templates/app/package-scripts/db.json +9 -0
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
- package/templates/app/skeleton-lite/README.md +11 -11
- package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
- package/templates/app/tool-config-lite/prettierignore +0 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
- package/templates/be/inbox/database-client.ts.tpl +13 -4
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
- package/templates/be/inbox/platform/migration.sql.tpl +22 -0
- package/templates/be/inbox/service.spec.ts.tpl +2 -2
- package/templates/be/inbox/service.ts.tpl +1 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
- package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
- package/templates/be/table/entities.index.ts.tpl +4 -0
- package/templates/be/table/entity.ts.tpl +21 -0
- package/templates/be/table/index.ts.tpl +1 -0
- package/templates/be/table/module.ts.tpl +1 -1
- package/templates/be/table/service.api.ts.tpl +10 -11
- package/templates/be/table/service.ts.tpl +5 -6
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
- package/db/local-migrate.mjs +0 -194
- package/db/reset.mjs +0 -125
- package/emit/db-types.mjs +0 -271
- package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
- package/runtime/scripts/api/supabase/database-push.mjs +0 -5
- package/runtime/scripts/api/supabase/database-query.mjs +0 -5
- package/runtime/scripts/api/supabase/lib.mjs +0 -55
- package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
- package/scaffold/supabase-ports.mjs +0 -26
- package/templates/app/package-scripts/supabase.json +0 -7
- package/templates/app/skeleton-lite/supabase/config.toml +0 -48
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
- package/templates/be/inbox/sql.ts.tpl +0 -16
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
- package/templates/be/table/rows.ts.tpl +0 -4
- package/templates/be/table/sql.api.ts.tpl +0 -7
- package/templates/be/table/sql.ts.tpl +0 -4
- package/templates/fe/auth/supabase-client.ts.tpl +0 -38
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
- /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 {
|
|
5
|
-
import {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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<
|
|
52
|
-
private readonly verifyAccessToken:
|
|
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 {
|
|
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:
|
|
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
|
-
/**
|
|
11
|
-
|
|
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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
30
|
-
const { guard, context } = await build(method, {
|
|
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("
|
|
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(
|
|
57
|
-
|
|
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
|
|
18
|
-
*
|
|
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(
|
|
34
|
+
constructor(
|
|
35
|
+
@InjectReflector() private readonly reflector: Reflector,
|
|
36
|
+
@InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
|
|
37
|
+
) {}
|
|
22
38
|
|
|
23
|
-
/** Refuses a state-changing request
|
|
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
|
|
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.
|
|
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 = (
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
/**
|
|
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
|
|
48
|
-
|
|
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
|
|
61
|
-
|
|
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
|
}
|
|
@@ -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 {
|
|
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
|
|
14
|
+
/** The least-privilege PostgreSQL connection to the app's own database. */
|
|
14
15
|
readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
|
|
15
|
-
/**
|
|
16
|
-
readonly
|
|
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
|
-
|
|
27
|
+
passport: parsePassportConfig(env),
|
|
28
|
+
objectStorage: parseObjectStorageConfig(env),
|
|
25
29
|
})
|