@starci/hfs 4.2.17 → 4.3.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 (108) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +7 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  5. package/runtime/knowledge/hfs/rules.yaml +41 -13
  6. package/runtime/knowledge/hfs/slots.yaml +99 -43
  7. package/runtime/knowledge/patterns/be/api.yaml +64 -1
  8. package/runtime/knowledge/patterns/be/cli.yaml +23 -5
  9. package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
  10. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  11. package/runtime/modules/kernel/failure-codes.yaml +15 -4
  12. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  13. package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
  14. package/runtime/scripts/api/sops/seal.mjs +1 -1
  15. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  16. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  17. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  18. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  19. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  20. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  21. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  22. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  23. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  24. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  25. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  26. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  27. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  28. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  29. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  30. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  31. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  32. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  33. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  34. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  35. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  36. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  38. package/runtime/scripts/hfs/check.mjs +18 -26
  39. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  40. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  41. package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
  42. package/runtime/scripts/hfs/rules/database.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/edition.mjs +5 -5
  44. package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
  47. package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
  48. package/runtime/scripts/hfs/secret-document.mjs +97 -0
  49. package/runtime/scripts/hfs/secret-errors.mjs +20 -0
  50. package/runtime/scripts/hfs/secret-files.mjs +124 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +192 -0
  52. package/runtime/scripts/hfs/secret-output.mjs +100 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +98 -0
  54. package/runtime/scripts/hfs/secret-store.mjs +161 -0
  55. package/runtime/scripts/hfs/secret.mjs +135 -195
  56. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  57. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  58. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  59. package/runtime/scripts/hfs/tree-findings.mjs +22 -0
  60. package/runtime/scripts/lib/roots.mjs +59 -0
  61. package/runtime/scripts/lib/swarm-stack.mjs +139 -0
  62. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  63. package/scaffold/add-cli-lite.mjs +10 -11
  64. package/scaffold/add-inbox-lite.mjs +15 -84
  65. package/scaffold/add-plan.mjs +183 -0
  66. package/scaffold/add-table.mjs +44 -5
  67. package/scaffold/add.mjs +33 -166
  68. package/scaffold/app.mjs +5 -5
  69. package/scaffold/image.mjs +2 -2
  70. package/src/main.mjs +12 -6
  71. package/sync/index.mjs +23 -16
  72. package/templates/app/gitignore +1 -0
  73. package/templates/app/gitignore-lite +1 -0
  74. package/templates/app/hooks/husky/pre-commit +7 -12
  75. package/templates/app/skeleton/.sops.yaml +1 -1
  76. package/templates/app/skeleton-lite/.sops.yaml +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
  78. package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
  79. package/templates/app/skeleton-lite/README.md +4 -2
  80. package/templates/app/starciwork.gitignore-lite +6 -0
  81. package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
  82. package/templates/be/inbox/database-client.ts.tpl +7 -22
  83. package/templates/be/inbox/service.spec.ts.tpl +16 -20
  84. package/templates/be/inbox/service.ts.tpl +6 -10
  85. package/templates/be/inbox/sql.ts.tpl +2 -2
  86. package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
  87. package/templates/be/inbox/webhook-door.ts.tpl +4 -3
  88. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
  89. package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
  90. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
  91. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
  92. package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
  93. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
  94. package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
  95. package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
  96. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
  97. package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
  98. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
  99. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
  100. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
  101. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
  102. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
  103. package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
  104. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
  105. package/upgrade/file-state.mjs +68 -0
  106. package/upgrade/index.mjs +7 -56
  107. package/runtime/scripts/api/git/log.mjs +0 -5
  108. package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
@@ -1,3 +1,3 @@
1
1
  # Lite secrets have encrypted twins only under .starcistacks/<environment>/secrets.
2
- # Set a project-owned age recipient before encrypting the first secret.
2
+ # Run `starci app secret init` to set a project-owned age recipient before encrypting the first secret.
3
3
  creation_rules: []
@@ -0,0 +1,16 @@
1
+ # The stack declaration of the lite app, at the app root beside be/, fe/ and .starciwork.
2
+ # Production compute is authored for an operated environment; the starter invents no deployment.
3
+ schema: starci/application-stacks@1
4
+ backing:
5
+ supabase:
6
+ provider: supabase
7
+ managed: true
8
+ services:
9
+ sonar:
10
+ provider: sonarqube
11
+ mode: local
12
+ qualityGate: {{sonarGate}}
13
+ stack:
14
+ owner: host
15
+ root: .claude/ext/sonar
16
+ environment: dev
@@ -0,0 +1,9 @@
1
+ schema: work/workspace@1
2
+ id: {{project}}
3
+ project: {{project}}
4
+ description: The Work records of {{project}}, one StarCi app with its back end in be/ and its front end in fe/.
5
+ repositories:
6
+ - role: be
7
+ name: be
8
+ - role: fe
9
+ name: fe
@@ -16,7 +16,7 @@ TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, Supabas
16
16
  - `be/`: the API, shared platform capabilities, verified identity admission, and privileged Supabase integrations.
17
17
  - `fe/`: the web app; the only Supabase client is the Auth client of `src/modules/auth`, data goes through `src/modules/api`.
18
18
  - `.starcistacks/`: value-free environment metadata and encrypted-secret custody.
19
- - `.starciwork/`: product workspace, brand, and shell records.
19
+ - `.starciwork/`: `.gitignore`, `workspace.yaml` project metadata and repo roles, and optional `brand/` drawing records, references, and assets.
20
20
 
21
21
  ## Development
22
22
 
@@ -40,4 +40,6 @@ Create a forward-only migration under `supabase/migrations`, keep each table's R
40
40
 
41
41
  ## Work
42
42
 
43
- The product's Work records live in `.starciwork`.
43
+ Brand records live in `.starciwork/brand/`. `.starciwork/workspace.yaml` supplies the project id and repo roles so Work tools
44
+ can locate the app root and map brand records to the front end. Lite permits this metadata without brand records; the tree
45
+ is required only when brand records exist, and workspace metadata remains optional.
@@ -0,0 +1,6 @@
1
+ # StarCi lite .starciwork holds only workspace.yaml and brand/ (plus .gitignore).
2
+ # Runtime output lives in runtime.sqlite rows and blobs outside the repository.
3
+ /*
4
+ !/.gitignore
5
+ !/workspace.yaml
6
+ !/brand/
@@ -2,46 +2,41 @@ import { fakeTransaction, mockEntityManager } from "@starci/jest-preset"
2
2
  import type { EntityManager } from "typeorm"
3
3
  import { sql } from "./database.sql"
4
4
  import { DatabaseErrorCode } from "./errors/database.error"
5
- import { acceptWebhookDelivery } from "./webhook-inbox.client"
5
+ import { recordWebhookDelivery } from "./webhook-inbox.client"
6
6
 
7
- // The client runs whatever claim and completion statements its caller owns; opaque function calls stand in for them here.
8
- const INSERT = sql`SELECT id FROM claim_spec_inbox($1, $2, $3::jsonb)`
9
- const COMPLETE = sql`SELECT id FROM complete_spec_inbox($1)`
7
+ // The caller owns the conflict-safe pending-row insert; an opaque function call stands in for it here.
8
+ const INSERT = sql`SELECT id FROM record_spec_inbox($1, $2, $3::jsonb)`
10
9
  const PAYLOAD = { id: "delivery-1" }
11
10
 
12
- const accept = (entityManager: EntityManager, deliveryId: string | undefined, process: (manager: EntityManager) => Promise<void>) =>
13
- acceptWebhookDelivery({ entityManager, insert: INSERT, complete: COMPLETE, provider: "spec", deliveryId, payload: PAYLOAD, process })
11
+ const record = (entityManager: EntityManager, deliveryId: string | undefined) =>
12
+ recordWebhookDelivery({ entityManager, insert: INSERT, provider: "spec", deliveryId, payload: PAYLOAD })
14
13
 
15
- describe("acceptWebhookDelivery", () => {
16
- it("claims a new delivery, processes it and marks it complete in one committed transaction", async () => {
17
- const transaction = fakeTransaction(mockEntityManager({ query: [[INSERT, [{ id: "inbox-1" }]], [COMPLETE, [{ id: "inbox-1" }]]] }))
18
- const process = jest.fn<Promise<void>, [EntityManager]>().mockResolvedValue(undefined)
14
+ describe("recordWebhookDelivery", () => {
15
+ it("records a new delivery and commits before any business processing", async () => {
16
+ const transaction = fakeTransaction(mockEntityManager({ query: [INSERT, [{ id: "inbox-1" }]] }))
19
17
 
20
- await accept(transaction.em, " delivery-1 ", process)
18
+ await record(transaction.em, " delivery-1 ")
21
19
 
22
- expect(process).toHaveBeenCalledTimes(1)
20
+ expect(transaction.em.query).toHaveBeenCalledTimes(1)
23
21
  expect(transaction.em.query).toHaveBeenCalledWith(INSERT, ["spec", "delivery-1", JSON.stringify(PAYLOAD)])
24
- expect(transaction.em.query).toHaveBeenCalledWith(COMPLETE, ["inbox-1"])
25
22
  expect(transaction.commits).toBe(1)
26
23
  })
27
24
 
28
- it("acknowledges a duplicate without processing it again", async () => {
25
+ it("acknowledges a duplicate without any completion or business write", async () => {
29
26
  const transaction = fakeTransaction(mockEntityManager({ query: [INSERT, []] }))
30
- const process = jest.fn<Promise<void>, [EntityManager]>()
31
27
 
32
- await accept(transaction.em, "delivery-1", process)
28
+ await record(transaction.em, "delivery-1")
33
29
 
34
- expect(process).not.toHaveBeenCalled()
35
30
  expect(transaction.em.query).toHaveBeenCalledTimes(1)
31
+ expect(transaction.commits).toBe(1)
36
32
  })
37
33
 
38
34
  it.each([["no", undefined], ["a blank", " "]])("refuses %s delivery id before opening a transaction", async (_label, deliveryId) => {
39
35
  const transaction = fakeTransaction(mockEntityManager())
40
- const process = jest.fn<Promise<void>, [EntityManager]>()
41
36
 
42
- await expect(accept(transaction.em, deliveryId, process)).rejects.toMatchObject({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
37
+ await expect(record(transaction.em, deliveryId)).rejects.toMatchObject({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
43
38
 
44
39
  expect(transaction.outcomes).toEqual([])
45
- expect(process).not.toHaveBeenCalled()
40
+ expect(transaction.em.query).not.toHaveBeenCalled()
46
41
  })
47
42
  })
@@ -2,49 +2,34 @@ import type { EntityManager } from "typeorm"
2
2
  import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
3
3
  import type { SqlText } from "./database.sql"
4
4
 
5
- interface WebhookInboxStatements {
6
- readonly insert: SqlText
7
- readonly complete: SqlText
8
- }
9
-
10
- interface AcceptWebhookDelivery extends WebhookInboxStatements {
5
+ interface RecordWebhookDelivery {
11
6
  readonly entityManager: EntityManager
7
+ readonly insert: SqlText
12
8
  readonly provider: string
13
9
  readonly deliveryId: string | undefined
14
10
  readonly payload: object
15
- readonly process: (manager: EntityManager) => Promise<void>
16
- }
17
-
18
- interface InboxIdentity {
19
- readonly id: string
20
11
  }
21
12
 
22
13
  /**
23
- * Claims and processes one delivery in one EntityManager transaction. A conflict returns without calling `process`, so
24
- * a redelivery receives the original empty acknowledgement and cannot repeat the domain mutation.
14
+ * Records one pending delivery in one EntityManager transaction. The conflict-safe insert keeps redelivery a no-op.
15
+ * Business processing and the completion marker belong to the reactor or job that consumes the committed row.
25
16
  */
26
- export const acceptWebhookDelivery = async ({
17
+ export const recordWebhookDelivery = async ({
27
18
  entityManager,
28
19
  insert,
29
- complete,
30
20
  provider,
31
21
  deliveryId,
32
22
  payload,
33
- process,
34
- }: AcceptWebhookDelivery): Promise<void> => {
23
+ }: RecordWebhookDelivery): Promise<void> => {
35
24
  const normalizedDeliveryId = deliveryId?.trim()
36
25
  if (!normalizedDeliveryId) {
37
26
  throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
38
27
  }
39
28
  await entityManager.transaction(async (manager) => {
40
- const claimed = await manager.query<Array<InboxIdentity>>(insert, [
29
+ await manager.query(insert, [
41
30
  provider,
42
31
  normalizedDeliveryId,
43
32
  JSON.stringify(payload),
44
33
  ])
45
- const identity = claimed[0]
46
- if (identity === undefined) return
47
- await process(manager)
48
- await manager.query(complete, [identity.id])
49
34
  })
50
35
  }
@@ -1,46 +1,42 @@
1
- import { fakeTransaction, mock, mockEntityManager } from "@starci/jest-preset"
1
+ import { fakeTransaction, mockEntityManager } from "@starci/jest-preset"
2
2
  import { Test } from "@nestjs/testing"
3
- import { @@service@@ } from "@@serviceModule@@"
4
3
  import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
5
- import { COMPLETE_@@providerUpper@@_INBOX, INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
4
+ import { INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
6
5
  import { @@Provider@@InboxService } from "./@@provider@@-inbox.service"
7
6
 
8
7
  const DELIVERY = { id: "delivery-1" }
9
8
 
10
- const build = async (claimed: ReadonlyArray<{ readonly id: string }>) => {
11
- const entityManager = mockEntityManager({
12
- query: [[INSERT_@@providerUpper@@_INBOX, claimed], [COMPLETE_@@providerUpper@@_INBOX, claimed]],
13
- })
9
+ const build = async (recorded: ReadonlyArray<{ readonly id: string }>) => {
10
+ const entityManager = mockEntityManager({ query: [INSERT_@@providerUpper@@_INBOX, recorded] })
14
11
  const transaction = fakeTransaction(entityManager)
15
- const deliveries = mock<@@service@@>()
16
12
  const moduleRef = await Test.createTestingModule({
17
13
  providers: [
18
14
  @@Provider@@InboxService,
19
15
  { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
20
- { provide: @@service@@, useValue: deliveries },
21
16
  ],
22
17
  }).compile()
23
- return { inbox: moduleRef.get(@@Provider@@InboxService), deliveries, transaction }
18
+ return { inbox: moduleRef.get(@@Provider@@InboxService), entityManager, transaction }
24
19
  }
25
20
 
26
21
  describe("@@Provider@@InboxService", () => {
27
- describe("accept@@Provider@@Delivery", () => {
28
- it("claims a new @@provider@@ delivery and runs the domain intake inside the claiming transaction", async () => {
29
- const { inbox, deliveries, transaction } = await build([{ id: "inbox-1" }])
22
+ describe("record@@Provider@@Delivery", () => {
23
+ it("records a pending @@provider@@ delivery without business processing or a completion write", async () => {
24
+ const { inbox, entityManager, transaction } = await build([{ id: "inbox-1" }])
30
25
 
31
- await inbox.accept@@Provider@@Delivery("delivery-1", DELIVERY)
26
+ await inbox.record@@Provider@@Delivery("delivery-1", DELIVERY)
32
27
 
33
- expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledTimes(1)
34
- expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledWith(DELIVERY, expect.anything())
28
+ expect(entityManager.query).toHaveBeenCalledTimes(1)
29
+ expect(entityManager.query).toHaveBeenCalledWith(INSERT_@@providerUpper@@_INBOX, ["@@provider@@", "delivery-1", JSON.stringify(DELIVERY)])
35
30
  expect(transaction.commits).toBe(1)
36
31
  })
37
32
 
38
- it("acknowledges a duplicate @@provider@@ delivery without running the domain intake again", async () => {
39
- const { inbox, deliveries } = await build([])
33
+ it("acknowledges a duplicate @@provider@@ delivery without further work", async () => {
34
+ const { inbox, entityManager, transaction } = await build([])
40
35
 
41
- await inbox.accept@@Provider@@Delivery("delivery-1", DELIVERY)
36
+ await inbox.record@@Provider@@Delivery("delivery-1", DELIVERY)
42
37
 
43
- expect(deliveries.accept@@Provider@@Delivery).not.toHaveBeenCalled()
38
+ expect(entityManager.query).toHaveBeenCalledTimes(1)
39
+ expect(transaction.commits).toBe(1)
44
40
  })
45
41
  })
46
42
  })
@@ -1,8 +1,7 @@
1
1
  import { Injectable } from "@nestjs/common"
2
2
  import type { EntityManager } from "typeorm"
3
- import { @@service@@ } from "@@serviceModule@@"
4
- import { acceptWebhookDelivery, InjectPrimaryEntityManager } from "@modules/platform/database"
5
- import { COMPLETE_@@providerUpper@@_INBOX, INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
3
+ import { InjectPrimaryEntityManager, recordWebhookDelivery } from "@modules/platform/database"
4
+ import { INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
6
5
 
7
6
  interface @@Provider@@Delivery {
8
7
  readonly id: string
@@ -11,23 +10,20 @@ interface @@Provider@@Delivery {
11
10
  type @@Provider@@DeliveryId = string | undefined
12
11
 
13
12
  @Injectable()
14
- /** Claims each @@provider@@ delivery once, then runs the domain intake and completion marker in the same transaction. */
13
+ /** Records each @@provider@@ delivery once. Pending rows are consumed by a separate reactor or job after commit. */
15
14
  export class @@Provider@@InboxService {
16
15
  constructor(
17
16
  @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
18
- private readonly deliveries: @@service@@,
19
17
  ) {}
20
18
 
21
- /** A duplicate is acknowledged without invoking the domain intake a second time. */
22
- accept@@Provider@@Delivery(deliveryId: @@Provider@@DeliveryId, delivery: @@Provider@@Delivery): Promise<void> {
23
- return acceptWebhookDelivery({
19
+ /** A duplicate is acknowledged without creating another pending row; this intake never dispatches a command. */
20
+ record@@Provider@@Delivery(deliveryId: @@Provider@@DeliveryId, delivery: @@Provider@@Delivery): Promise<void> {
21
+ return recordWebhookDelivery({
24
22
  entityManager: this.entityManager,
25
23
  insert: INSERT_@@providerUpper@@_INBOX,
26
- complete: COMPLETE_@@providerUpper@@_INBOX,
27
24
  provider: "@@provider@@",
28
25
  deliveryId,
29
26
  payload: delivery,
30
- process: (manager) => this.deliveries.accept@@Provider@@Delivery(delivery, manager),
31
27
  })
32
28
  }
33
29
  }
@@ -1,12 +1,12 @@
1
1
  import { sql } from "@modules/platform/database"
2
2
 
3
- /** Claims one @@provider@@ delivery and returns its durable inbox identity only to the winning transaction. */
3
+ /** Records one pending @@provider@@ delivery and returns its durable inbox identity only to the winning transaction. */
4
4
  export const INSERT_@@providerUpper@@_INBOX = sql`INSERT INTO public.@@table@@ (provider, delivery_id, payload)
5
5
  VALUES ($1, $2, $3::jsonb)
6
6
  ON CONFLICT (provider, delivery_id) DO NOTHING
7
7
  RETURNING id`
8
8
 
9
- /** Marks the claimed delivery complete before the same transaction commits. */
9
+ /** The reactor or job marks a recorded delivery complete in the transaction that applies its effect. Never called by the intake. */
10
10
  export const COMPLETE_@@providerUpper@@_INBOX = sql`WITH completed AS (
11
11
  UPDATE public.@@table@@
12
12
  SET processed_at = now()
@@ -57,8 +57,8 @@ describe("@@Provider@@Webhook", () => {
57
57
 
58
58
  await door.receive(REQUEST, sign(RAW_BODY, SIGNED_AT), SIGNED_AT, DELIVERY_ID, DELIVERY)
59
59
 
60
- expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledTimes(1)
61
- expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledWith(DELIVERY_ID, DELIVERY)
60
+ expect(deliveries.record@@Provider@@Delivery).toHaveBeenCalledTimes(1)
61
+ expect(deliveries.record@@Provider@@Delivery).toHaveBeenCalledWith(DELIVERY_ID, DELIVERY)
62
62
  })
63
63
 
64
64
  it("never reaches the inbox when the signature is not the provider's", async () => {
@@ -69,7 +69,7 @@ describe("@@Provider@@Webhook", () => {
69
69
  door.receive(REQUEST, sign(Buffer.from("other body"), SIGNED_AT), SIGNED_AT, DELIVERY_ID, DELIVERY),
70
70
  ).rejects.toMatchObject({ code: "HTTP_SECURITY_WEBHOOK_SIGNATURE_INVALID" })
71
71
 
72
- expect(deliveries.accept@@Provider@@Delivery).not.toHaveBeenCalled()
72
+ expect(deliveries.record@@Provider@@Delivery).not.toHaveBeenCalled()
73
73
  })
74
74
  })
75
75
  })
@@ -10,7 +10,8 @@ import { @@Event@@Request } from "./dto/@@event@@.request"
10
10
  @Controller("webhooks/@@provider@@")
11
11
  /**
12
12
  * POST /webhooks/@@provider@@, the signed intake door of the @@provider@@ notifier. The signature and replay window are proven
13
- * first; the verified delivery goes to one domain intake that claims its inbox row and processes it in one transaction.
13
+ * first; the verified delivery goes to one domain intake that records a pending inbox row in its transaction. A separate
14
+ * reactor or job consumes the committed row and dispatches the command.
14
15
  */
15
16
  export class @@Provider@@Webhook {
16
17
  constructor(
@@ -18,7 +19,7 @@ export class @@Provider@@Webhook {
18
19
  private readonly deliveries: @@Provider@@InboxService,
19
20
  ) {}
20
21
 
21
- /** Acknowledges with 204 and no body: a duplicate receives the same acknowledgement without being processed again. */
22
+ /** Acknowledges with 204 and no body: a duplicate receives the same acknowledgement without another inbox row. */
22
23
  @Post()
23
24
  @HttpCode(HttpStatus.NO_CONTENT)
24
25
  @Public({ reason: PublicReason.SignedWebhook })
@@ -31,6 +32,6 @@ export class @@Provider@@Webhook {
31
32
  @Body() delivery: @@Event@@Request,
32
33
  ): Promise<void> {
33
34
  this.signature.verify({ provider: "@@provider@@", rawBody: request.rawBody, signature, timestamp })
34
- await this.deliveries.accept@@Provider@@Delivery(deliveryId, delivery)
35
+ await this.deliveries.record@@Provider@@Delivery(deliveryId, delivery)
35
36
  }
36
37
  }
@@ -56,8 +56,8 @@ describe("@@Provider@@Webhook", () => {
56
56
 
57
57
  await door.receive(REQUEST, sign(RAW_BODY, SIGNED_AT), SIGNED_AT, DELIVERY)
58
58
 
59
- expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledTimes(1)
60
- expect(deliveries.accept@@Provider@@Delivery).toHaveBeenCalledWith(DELIVERY)
59
+ expect(deliveries.record@@Provider@@Delivery).toHaveBeenCalledTimes(1)
60
+ expect(deliveries.record@@Provider@@Delivery).toHaveBeenCalledWith(DELIVERY)
61
61
  })
62
62
 
63
63
  it("never reaches the intake when the signature is not the provider's", async () => {
@@ -68,7 +68,7 @@ describe("@@Provider@@Webhook", () => {
68
68
  door.receive(REQUEST, sign(Buffer.from("other body"), SIGNED_AT), SIGNED_AT, DELIVERY),
69
69
  ).rejects.toMatchObject({ code: "HTTP_SECURITY_WEBHOOK_SIGNATURE_INVALID" })
70
70
 
71
- expect(deliveries.accept@@Provider@@Delivery).not.toHaveBeenCalled()
71
+ expect(deliveries.record@@Provider@@Delivery).not.toHaveBeenCalled()
72
72
  })
73
73
  })
74
74
  })
@@ -10,8 +10,8 @@ import { @@Event@@Request } from "./dto/@@event@@.request"
10
10
  @Controller("webhooks/@@provider@@")
11
11
  /**
12
12
  * POST /webhooks/@@provider@@, the signed intake door of the @@provider@@ notifier. The signature and the replay window are proven
13
- * first; the verified delivery goes to exactly one intake method of @@service@@ (`accept@@Provider@@Delivery`), which records it
14
- * once and publishes the event in its own transaction.
13
+ * first; the verified delivery goes to exactly one intake method of @@service@@ (`record@@Provider@@Delivery`), which records it
14
+ * once in the inbox and writes its outbox event in one transaction. A reactor or job dispatches the command after commit.
15
15
  */
16
16
  export class @@Provider@@Webhook {
17
17
  constructor(
@@ -31,6 +31,6 @@ export class @@Provider@@Webhook {
31
31
  @Body() delivery: @@Event@@Request,
32
32
  ): Promise<void> {
33
33
  this.signature.verify({ provider: "@@provider@@", rawBody: request.rawBody, signature, timestamp })
34
- await this.deliveries.accept@@Provider@@Delivery(delivery)
34
+ await this.deliveries.record@@Provider@@Delivery(delivery)
35
35
  }
36
36
  }
@@ -0,0 +1,21 @@
1
+ import type { EnvSource } from "@modules/platform/config"
2
+ import { parseServerConfig } from "@modules/platform/config"
3
+ import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
4
+ import { parseHttpSecurityConfig } from "@modules/platform/http-security"
5
+
6
+ /** The {{app}} environment values required before composition. */
7
+ export interface {{appPascal}}Env {
8
+ /** The HTTP listener. */
9
+ readonly server: ReturnType<typeof parseServerConfig>
10
+ /** The origin allowlist and rate limits. */
11
+ readonly httpSecurity: ReturnType<typeof parseHttpSecurityConfig>
12
+ /** The primary database connection. */
13
+ readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
14
+ }
15
+
16
+ /** Reads the {{app}} environment; missing or invalid values fail boot, and secrets use `_FILE` first. */
17
+ export const read{{appPascal}}Env = (env: EnvSource): {{appPascal}}Env => ({
18
+ server: parseServerConfig(env),
19
+ httpSecurity: parseHttpSecurityConfig(env),
20
+ database: parsePrimaryDatabaseConfig(env),
21
+ })
@@ -2,7 +2,7 @@ import type { HttpSecurityOptions } from "@modules/platform/http-security"
2
2
  import type { ServerOptions } from "@modules/platform/config"
3
3
  import type { DatabaseConnectionConfig } from "@modules/platform/database"
4
4
 
5
- /** Everything the {{app}} app needs from its environment, parsed once by `main.ts` and handed to `AppModule.register`. */
5
+ /** Everything the {{app}} app needs from its environment, parsed by `{{app}}.env.ts` and handed to `AppModule.register`. */
6
6
  export interface {{appPascal}}Options {
7
7
  /** The HTTP listener. */
8
8
  readonly server: ServerOptions
@@ -2,55 +2,23 @@ import { Module } from "@nestjs/common"
2
2
  import type { DynamicModule } from "@nestjs/common"
3
3
  import { APP_FILTER, APP_GUARD } from "@nestjs/core"
4
4
  import { SystemHealthHttpModule } from "@features/api/system-health"
5
- import { AuthGuard, IDENTITY_ERROR_KINDS, IDENTITY_MESSAGES, IdentityModule } from "@modules/domain/identity"
5
+ import { AuthGuard, IdentityModule } from "@modules/domain/identity"
6
6
  import { LivenessModule } from "@modules/domain/liveness"
7
- import { NOTE_ERROR_KINDS, NoteModule, noteEntities, noteMigrations } from "@modules/domain/note"
8
- import { ClockModule } from "@modules/platform/clock"
9
- import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
10
- import { CqrsModule } from "@modules/platform/cqrs"
11
- import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
12
- import { ERRORS_MESSAGES, ErrorsFilter, ErrorsModule } from "@modules/platform/errors"
13
- import {
14
- HTTP_SECURITY_ERROR_KINDS,
15
- HTTP_SECURITY_MESSAGES,
16
- HttpSecurityModule,
17
- OriginGuard,
18
- RateLimitGuard,
19
- } from "@modules/platform/http-security"
20
- import { I18nModule } from "@modules/platform/i18n"
21
- import { LoggingModule } from "@modules/platform/logging"
7
+ import { NoteModule } from "@modules/domain/note"
8
+ import { ErrorsFilter } from "@modules/platform/errors"
9
+ import { OriginGuard, RateLimitGuard } from "@modules/platform/http-security"
10
+ import { platformModules } from "./compose/platform"
22
11
  import type { {{appPascal}}Options } from "./{{app}}.options"
23
12
 
24
13
  @Module({})
25
- /** Composition root of the {{app}} app: every capability is registered once, app-wide, and the three guards run in a fixed order. */
14
+ /** Composition root of the {{app}} app: it spreads the composition groups of `compose/`, and the three guards run in a fixed order. */
26
15
  export class AppModule {
27
16
  /** Builds the module from options parsed once at boot; nothing here reads the environment. */
28
17
  static register(options: {{appPascal}}Options): DynamicModule {
29
18
  return {
30
19
  module: AppModule,
31
20
  imports: [
32
- ClockModule.register({ isGlobal: true }),
33
- LoggingModule.register({ isGlobal: true }),
34
- I18nModule.register({
35
- isGlobal: true,
36
- bundles: [ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
37
- }),
38
- ErrorsModule.register({
39
- isGlobal: true,
40
- kinds: [
41
- CONFIG_ERROR_KINDS,
42
- DATABASE_ERROR_KINDS,
43
- HTTP_SECURITY_ERROR_KINDS,
44
- IDENTITY_ERROR_KINDS,
45
- NOTE_ERROR_KINDS,
46
- ],
47
- }),
48
- CqrsModule.register({ isGlobal: true }),
49
- HttpSecurityModule.register({ isGlobal: true, ...options.httpSecurity }),
50
- DatabaseModule.register({
51
- isGlobal: true,
52
- connections: [{ ...options.database, entities: noteEntities, migrations: noteMigrations }],
53
- }),
21
+ ...platformModules(options),
54
22
  IdentityModule.register({ isGlobal: true }),
55
23
  LivenessModule.register({ isGlobal: true }),
56
24
  NoteModule.register({ isGlobal: true }),
@@ -0,0 +1,42 @@
1
+ import type { DynamicModule } from "@nestjs/common"
2
+ import { IDENTITY_ERROR_KINDS, IDENTITY_MESSAGES } from "@modules/domain/identity"
3
+ import { NOTE_ERROR_KINDS, noteEntities, noteMigrations } from "@modules/domain/note"
4
+ import { ClockModule } from "@modules/platform/clock"
5
+ import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
6
+ import { CqrsModule } from "@modules/platform/cqrs"
7
+ import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
8
+ import { ERRORS_MESSAGES, ErrorsModule } from "@modules/platform/errors"
9
+ import { HTTP_SECURITY_ERROR_KINDS, HTTP_SECURITY_MESSAGES, HttpSecurityModule } from "@modules/platform/http-security"
10
+ import { I18nModule } from "@modules/platform/i18n"
11
+ import { LoggingModule } from "@modules/platform/logging"
12
+ import type { {{appPascal}}Options } from "../{{app}}.options"
13
+
14
+ /**
15
+ * The platform area of the {{app}} app: every platform capability, registered once and app-wide. A composition
16
+ * group holds one area's module list and nothing else (no logic, no environment read); `app.module.ts` spreads it
17
+ * into its imports.
18
+ */
19
+ export const platformModules = (options: {{appPascal}}Options): Array<DynamicModule> => [
20
+ ClockModule.register({ isGlobal: true }),
21
+ LoggingModule.register({ isGlobal: true }),
22
+ I18nModule.register({
23
+ isGlobal: true,
24
+ bundles: [ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
25
+ }),
26
+ ErrorsModule.register({
27
+ isGlobal: true,
28
+ kinds: [
29
+ CONFIG_ERROR_KINDS,
30
+ DATABASE_ERROR_KINDS,
31
+ HTTP_SECURITY_ERROR_KINDS,
32
+ IDENTITY_ERROR_KINDS,
33
+ NOTE_ERROR_KINDS,
34
+ ],
35
+ }),
36
+ CqrsModule.register({ isGlobal: true }),
37
+ HttpSecurityModule.register({ isGlobal: true, ...options.httpSecurity }),
38
+ DatabaseModule.register({
39
+ isGlobal: true,
40
+ connections: [{ ...options.database, entities: noteEntities, migrations: noteMigrations }],
41
+ }),
42
+ ]
@@ -1,18 +1,12 @@
1
1
  import { NestFactory } from "@nestjs/core"
2
2
  import { SystemClockService } from "@modules/platform/clock"
3
- import { EnvSource, parseServerConfig } from "@modules/platform/config"
4
- import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
5
- import { parseHttpSecurityConfig } from "@modules/platform/http-security"
3
+ import { EnvSource } from "@modules/platform/config"
6
4
  import { createJsonLogger, LoggingLogEvent } from "@modules/platform/logging"
7
5
  import { AppModule } from "./app.module"
8
- import type { {{appPascal}}Options } from "./{{app}}.options"
6
+ import { read{{appPascal}}Env } from "./{{app}}.env"
9
7
 
10
8
  /** Reads the environment once, composes the app from the parsed options and serves until a shutdown signal. */
11
9
  const bootstrap = async (): Promise<void> => {
12
10
  const env = EnvSource.fromProcess()
13
- const options: {{appPascal}}Options = {
14
- server: parseServerConfig(env),
15
- httpSecurity: parseHttpSecurityConfig(env),
16
- database: parsePrimaryDatabaseConfig(env),
17
- }
11
+ const options = read{{appPascal}}Env(env)
18
12
  {{> be/common/main-listen.ts.partial}}
@@ -0,0 +1,13 @@
1
+ import type { EnvSource, Secret } from "@modules/platform/config"
2
+ import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
3
+
4
+ /** The cli's primary database environment values, before entities and migrations are bound. */
5
+ export interface CliEnv {
6
+ /** The logical connection name declared in `hfs.json`. */
7
+ readonly name: string
8
+ /** The primary database connection URL, read through the secret source. */
9
+ readonly url: Secret
10
+ }
11
+
12
+ /** Reads the required cli connection before composition; its secret uses `_FILE` first and has no default. */
13
+ export const readCliEnv = (env: EnvSource): CliEnv => parsePrimaryDatabaseConfig(env)
@@ -1,7 +1,5 @@
1
1
  import { noteEntities, noteMigrations } from "@modules/domain/note"
2
- import type { EnvSource } from "@modules/platform/config"
3
- import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
4
- import type { DatabaseConnectionOptions } from "@modules/platform/database"
2
+ import type { DatabaseConnectionConfig, DatabaseConnectionOptions } from "@modules/platform/database"
5
3
 
6
4
  /** Everything the cli app needs from its environment: every connection with the entities and migrations of its owners. */
7
5
  export interface CliAppOptions {
@@ -10,13 +8,13 @@ export interface CliAppOptions {
10
8
  }
11
9
 
12
10
  /** The primary database: the notes. */
13
- export const primaryConnectionOf = (env: EnvSource): DatabaseConnectionOptions => ({
14
- ...parsePrimaryDatabaseConfig(env),
11
+ export const primaryConnectionOf = (primary: DatabaseConnectionConfig): DatabaseConnectionOptions => ({
12
+ ...primary,
15
13
  entities: noteEntities,
16
14
  migrations: noteMigrations,
17
15
  })
18
16
 
19
- /** Parses the cli options from the environment: one entry per connection, each built by its own function above. */
20
- export const parseCliAppOptions = (env: EnvSource): CliAppOptions => ({
21
- connections: [primaryConnectionOf(env)],
17
+ /** Binds the parsed cli configuration to each connection's entities and migrations. */
18
+ export const createCliAppOptions = (primary: DatabaseConnectionConfig): CliAppOptions => ({
19
+ connections: [primaryConnectionOf(primary)],
22
20
  })
@@ -4,12 +4,13 @@ import { SystemClockService } from "@modules/platform/clock"
4
4
  import { EnvSource } from "@modules/platform/config"
5
5
  import { createJsonLogger, LoggingLogEvent } from "@modules/platform/logging"
6
6
  import { AppModule } from "./app.module"
7
- import { parseCliAppOptions } from "./cli.options"
7
+ import { readCliEnv } from "./cli.env"
8
+ import { createCliAppOptions } from "./cli.options"
8
9
 
9
10
  /** The command line of the back end: reads the environment once and runs the command its arguments name (`cli migrate run`). */
10
11
  async function bootstrap(): Promise<void> {
11
12
  const logger = createJsonLogger(new SystemClockService())
12
- await CommandFactory.run(AppModule.register(parseCliAppOptions(EnvSource.fromProcess())), {
13
+ await CommandFactory.run(AppModule.register(createCliAppOptions(readCliEnv(EnvSource.fromProcess()))), {
13
14
  logger: false,
14
15
  errorHandler: (error: Error) => {
15
16
  logger.error(LoggingLogEvent.StartupFailed, error, { service: "cli" })