@starci/hfs 4.9.9 → 4.9.11
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 +20 -0
- package/README.md +14 -0
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +1 -1
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +1 -1
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +81 -18
- 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 +16 -6
- package/runtime/knowledge/hfs/rules.yaml +74 -3
- package/runtime/knowledge/hfs/slots.yaml +36 -3
- package/runtime/modules/kernel/failure-codes.yaml +21 -0
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/check.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/src/main.mjs +7 -4
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- 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/src/modules/domain/identity/identity.decorators.ts +18 -8
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
|
@@ -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
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { SetMetadata, createParamDecorator } from "@nestjs/common"
|
|
1
|
+
import { SetMetadata, applyDecorators, createParamDecorator } from "@nestjs/common"
|
|
2
2
|
import type { ExecutionContext } from "@nestjs/common"
|
|
3
3
|
import { injector } from "@modules/platform/composition"
|
|
4
|
-
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
4
|
+
import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
|
|
5
5
|
import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
|
|
6
|
-
import { requestOf } from "@modules/platform/http-security"
|
|
6
|
+
import { ServerToServer, requestOf } from "@modules/platform/http-security"
|
|
7
7
|
import type { Principal } from "@modules/platform/cqrs"
|
|
8
8
|
import type { CredentialAuthenticator, CredentialMetadata } from "./credential.contracts"
|
|
9
9
|
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
10
|
+
import { PublicReason } from "./identity.contracts"
|
|
10
11
|
import type { PublicMetadata } from "./identity.contracts"
|
|
11
12
|
|
|
12
13
|
/** Token of the access-token verifier the composition root hands to identity. */
|
|
@@ -19,8 +20,14 @@ export const InjectAccessTokenVerifier = (): TypedParameterDecorator<VerifySupab
|
|
|
19
20
|
/** Metadata key of `@Public`. */
|
|
20
21
|
export const PUBLIC_KEY = "domain.identity.public"
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Opens a door to anonymous callers, stating why; every other door needs a credential or a verified Supabase session.
|
|
25
|
+
* A signed webhook proves its caller by its signature, so it owes no browser origin.
|
|
26
|
+
*/
|
|
27
|
+
export const Public = (metadata: PublicMetadata): DoorDecorator =>
|
|
28
|
+
metadata.reason === PublicReason.SignedWebhook
|
|
29
|
+
? applyDecorators(SetMetadata(PUBLIC_KEY, metadata), ServerToServer())
|
|
30
|
+
: applyDecorators(SetMetadata(PUBLIC_KEY, metadata))
|
|
24
31
|
|
|
25
32
|
/** Metadata key of `@Credential`. */
|
|
26
33
|
export const CREDENTIAL_KEY: unique symbol = Symbol("domain.identity.credential")
|
|
@@ -32,9 +39,12 @@ export const CREDENTIAL_AUTHENTICATOR: unique symbol = Symbol("domain.identity.c
|
|
|
32
39
|
export const InjectCredentialAuthenticator = (): TypedParameterDecorator<CredentialAuthenticator> =>
|
|
33
40
|
injector<CredentialAuthenticator>(CREDENTIAL_AUTHENTICATOR)
|
|
34
41
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. The
|
|
44
|
+
* credential travels in a header no browser attaches by itself, so the door owes no browser origin.
|
|
45
|
+
*/
|
|
46
|
+
export const Credential = (metadata: CredentialMetadata): DoorDecorator =>
|
|
47
|
+
applyDecorators(SetMetadata(CREDENTIAL_KEY, metadata), ServerToServer())
|
|
38
48
|
|
|
39
49
|
/** The authenticated Supabase caller established by the default-deny guard. */
|
|
40
50
|
export const CurrentPrincipal = createParamDecorator((_data: unknown, context: ExecutionContext): Principal => {
|
|
@@ -4,7 +4,7 @@ export { parseHttpSecurityConfig } from "./http-security.config"
|
|
|
4
4
|
export { HttpSecurityModule } from "./http-security.module"
|
|
5
5
|
export type { HttpSecurityOptions } from "./http-security.options"
|
|
6
6
|
export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
|
|
7
|
-
export { OriginGuard } from "./origin.guard"
|
|
7
|
+
export { OriginGuard, ServerToServer } from "./origin.guard"
|
|
8
8
|
export { RateLimitGuard } from "./rate-limit.guard"
|
|
9
9
|
export { applyHttpSecurity } from "./edge-security.service"
|
|
10
10
|
export { ReadinessService } from "./readiness.service"
|