@starci/hfs 4.2.16 → 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.
- package/CHANGELOG.md +21 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/be/api.yaml +64 -1
- package/runtime/knowledge/patterns/be/cli.yaml +23 -5
- package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/modules/kernel/failure-codes.yaml +15 -4
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
- package/runtime/scripts/api/sops/seal.mjs +1 -1
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/check.mjs +18 -26
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
- package/runtime/scripts/hfs/rules/database.mjs +2 -2
- package/runtime/scripts/hfs/rules/edition.mjs +5 -5
- package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
- package/runtime/scripts/hfs/secret-document.mjs +97 -0
- package/runtime/scripts/hfs/secret-errors.mjs +20 -0
- package/runtime/scripts/hfs/secret-files.mjs +124 -0
- package/runtime/scripts/hfs/secret-init.mjs +192 -0
- package/runtime/scripts/hfs/secret-output.mjs +100 -0
- package/runtime/scripts/hfs/secret-sops.mjs +98 -0
- package/runtime/scripts/hfs/secret-store.mjs +161 -0
- package/runtime/scripts/hfs/secret.mjs +135 -195
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/hfs/tree-findings.mjs +22 -0
- package/runtime/scripts/lib/roots.mjs +59 -0
- package/runtime/scripts/lib/swarm-stack.mjs +139 -0
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- package/scaffold/add-cli-lite.mjs +10 -11
- package/scaffold/add-inbox-lite.mjs +15 -84
- package/scaffold/add-plan.mjs +183 -0
- package/scaffold/add-table.mjs +44 -5
- package/scaffold/add.mjs +33 -166
- package/scaffold/app.mjs +5 -5
- package/scaffold/image.mjs +2 -2
- package/src/main.mjs +12 -6
- package/sync/index.mjs +23 -16
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/hooks/husky/pre-commit +7 -12
- package/templates/app/skeleton/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
- package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton-lite/README.md +4 -2
- package/templates/app/starciwork.gitignore-lite +6 -0
- package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
- package/templates/be/inbox/database-client.ts.tpl +7 -22
- package/templates/be/inbox/service.spec.ts.tpl +16 -20
- package/templates/be/inbox/service.ts.tpl +6 -10
- package/templates/be/inbox/sql.ts.tpl +2 -2
- package/templates/be/inbox/webhook-door.spec.ts.tpl +5 -5
- package/templates/be/inbox/webhook-door.ts.tpl +4 -3
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
- package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
- package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
- package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
- package/upgrade/file-state.mjs +68 -0
- package/upgrade/index.mjs +7 -56
- package/runtime/scripts/api/git/log.mjs +0 -5
- 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
|
-
#
|
|
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
|
|
@@ -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/`:
|
|
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
|
-
|
|
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.
|
|
@@ -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 {
|
|
5
|
+
import { recordWebhookDelivery } from "./webhook-inbox.client"
|
|
6
6
|
|
|
7
|
-
// The
|
|
8
|
-
const INSERT = sql`SELECT id FROM
|
|
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
|
|
13
|
-
|
|
11
|
+
const record = (entityManager: EntityManager, deliveryId: string | undefined) =>
|
|
12
|
+
recordWebhookDelivery({ entityManager, insert: INSERT, provider: "spec", deliveryId, payload: PAYLOAD })
|
|
14
13
|
|
|
15
|
-
describe("
|
|
16
|
-
it("
|
|
17
|
-
const transaction = fakeTransaction(mockEntityManager({ query: [
|
|
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
|
|
18
|
+
await record(transaction.em, " delivery-1 ")
|
|
21
19
|
|
|
22
|
-
expect(
|
|
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
|
|
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
|
|
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(
|
|
37
|
+
await expect(record(transaction.em, deliveryId)).rejects.toMatchObject({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
|
|
43
38
|
|
|
44
39
|
expect(transaction.outcomes).toEqual([])
|
|
45
|
-
expect(
|
|
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
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
|
17
|
+
export const recordWebhookDelivery = async ({
|
|
27
18
|
entityManager,
|
|
28
19
|
insert,
|
|
29
|
-
complete,
|
|
30
20
|
provider,
|
|
31
21
|
deliveryId,
|
|
32
22
|
payload,
|
|
33
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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 (
|
|
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),
|
|
18
|
+
return { inbox: moduleRef.get(@@Provider@@InboxService), entityManager, transaction }
|
|
24
19
|
}
|
|
25
20
|
|
|
26
21
|
describe("@@Provider@@InboxService", () => {
|
|
27
|
-
describe("
|
|
28
|
-
it("
|
|
29
|
-
const { inbox,
|
|
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.
|
|
26
|
+
await inbox.record@@Provider@@Delivery("delivery-1", DELIVERY)
|
|
32
27
|
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
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
|
|
39
|
-
const { inbox,
|
|
33
|
+
it("acknowledges a duplicate @@provider@@ delivery without further work", async () => {
|
|
34
|
+
const { inbox, entityManager, transaction } = await build([])
|
|
40
35
|
|
|
41
|
-
await inbox.
|
|
36
|
+
await inbox.record@@Provider@@Delivery("delivery-1", DELIVERY)
|
|
42
37
|
|
|
43
|
-
expect(
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
22
|
-
|
|
23
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHmac } from "node:crypto"
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto"
|
|
2
2
|
import { FakeClock, mock } from "@starci/jest-preset"
|
|
3
3
|
import type { RawBodyRequest } from "@nestjs/common"
|
|
4
4
|
import { Test } from "@nestjs/testing"
|
|
@@ -11,7 +11,7 @@ import { @@Event@@Request } from "./dto/@@event@@.request"
|
|
|
11
11
|
import { @@Provider@@Webhook } from "./@@provider@@.webhook"
|
|
12
12
|
|
|
13
13
|
const AT = "2026-01-02T03:04:05.000Z"
|
|
14
|
-
const SECRET = "
|
|
14
|
+
const SECRET = randomBytes(24).toString("hex")
|
|
15
15
|
const SIGNED_AT = String(new Date(AT).getTime())
|
|
16
16
|
const DELIVERY_ID = "delivery-1"
|
|
17
17
|
|
|
@@ -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.
|
|
61
|
-
expect(deliveries.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
60
|
-
expect(deliveries.
|
|
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.
|
|
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@@ (`
|
|
14
|
-
* once and
|
|
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.
|
|
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
|
|
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,
|
|
5
|
+
import { AuthGuard, IdentityModule } from "@modules/domain/identity"
|
|
6
6
|
import { LivenessModule } from "@modules/domain/liveness"
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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 = (
|
|
14
|
-
...
|
|
11
|
+
export const primaryConnectionOf = (primary: DatabaseConnectionConfig): DatabaseConnectionOptions => ({
|
|
12
|
+
...primary,
|
|
15
13
|
entities: noteEntities,
|
|
16
14
|
migrations: noteMigrations,
|
|
17
15
|
})
|
|
18
16
|
|
|
19
|
-
/**
|
|
20
|
-
export const
|
|
21
|
-
connections: [primaryConnectionOf(
|
|
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 {
|
|
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(
|
|
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" })
|