@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +14 -0
  3. package/db/drift.mjs +289 -0
  4. package/db/dump.mjs +1 -1
  5. package/db/entity-list.mjs +9 -0
  6. package/db/migrate.mjs +1 -1
  7. package/lint/run.mjs +12 -2
  8. package/package.json +5 -3
  9. package/release/gate.mjs +81 -18
  10. package/release/index.mjs +19 -8
  11. package/release/push.mjs +4 -4
  12. package/release/records.mjs +54 -0
  13. package/release/urgent.mjs +129 -0
  14. package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
  15. package/runtime/knowledge/hfs/rules.yaml +74 -3
  16. package/runtime/knowledge/hfs/slots.yaml +36 -3
  17. package/runtime/modules/kernel/failure-codes.yaml +21 -0
  18. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  19. package/runtime/scripts/hfs/check.mjs +2 -2
  20. package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
  21. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  22. package/src/main.mjs +7 -4
  23. package/stack/cli.mjs +10 -2
  24. package/stack/deploy.mjs +21 -4
  25. package/stack/urgent-floor.mjs +147 -0
  26. package/sync/ci-commands.mjs +3 -1
  27. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  28. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  29. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  30. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  31. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  32. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  33. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  34. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  35. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  36. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  37. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  38. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  39. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
  40. 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
- /** 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
  }
@@ -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
- /** Opens a door to anonymous callers, stating why; every other door needs a credential or a verified Supabase session. */
23
- export const Public = (metadata: PublicMetadata): ReturnType<typeof SetMetadata> => SetMetadata(PUBLIC_KEY, metadata)
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
- /** Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. */
36
- export const Credential = (metadata: CredentialMetadata): ReturnType<typeof SetMetadata> =>
37
- SetMetadata(CREDENTIAL_KEY, metadata)
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"