@starci/hfs 4.9.11 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +7 -9
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +1 -1
- package/db/dump.mjs +11 -10
- package/db/entity-list.mjs +1 -1
- package/db/migrate.mjs +83 -259
- package/db/new.mjs +46 -0
- package/db/post-cutover.mjs +13 -17
- package/db/postgres.mjs +80 -0
- package/emit/contracts.mjs +5 -9
- package/package.json +1 -1
- package/release/gate.mjs +3 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +44 -24
- package/runtime/knowledge/hfs/design-truths.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/rules.yaml +177 -155
- package/runtime/knowledge/hfs/slots.yaml +164 -172
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
- package/runtime/knowledge/patterns/be/projections.yaml +7 -5
- package/runtime/knowledge/patterns/be/queues.yaml +8 -8
- package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
- package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
- package/runtime/knowledge/patterns/be/saga.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +65 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +6 -6
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
- package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +4 -4
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
- package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
- package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database.mjs +36 -39
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
- package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +7 -4
- package/scaffold/add-app.mjs +2 -10
- package/scaffold/add-cli-lite.mjs +6 -6
- package/scaffold/add-compose.mjs +1 -1
- package/scaffold/add-inbox-lite.mjs +4 -8
- package/scaffold/add-plan.mjs +1 -1
- package/scaffold/add-queue-lite.mjs +16 -24
- package/scaffold/add-table.mjs +34 -26
- package/scaffold/add.mjs +2 -2
- package/scaffold/app.mjs +19 -86
- package/scaffold/lite-exports.mjs +1 -1
- package/src/main.mjs +19 -20
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +2 -2
- package/sync/index.mjs +32 -8
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
- package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
- package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
- package/templates/app/gitignore-lite +0 -3
- package/templates/app/package-scripts/db.json +9 -0
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
- package/templates/app/skeleton-lite/README.md +11 -11
- package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
- package/templates/app/tool-config-lite/prettierignore +0 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
- package/templates/be/inbox/database-client.ts.tpl +13 -4
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
- package/templates/be/inbox/platform/migration.sql.tpl +22 -0
- package/templates/be/inbox/service.spec.ts.tpl +2 -2
- package/templates/be/inbox/service.ts.tpl +1 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
- package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +6 -6
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
- package/templates/be/table/entities.index.ts.tpl +4 -0
- package/templates/be/table/entity.ts.tpl +21 -0
- package/templates/be/table/index.ts.tpl +1 -0
- package/templates/be/table/module.ts.tpl +1 -1
- package/templates/be/table/service.api.ts.tpl +10 -11
- package/templates/be/table/service.ts.tpl +5 -6
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
- package/db/local-migrate.mjs +0 -194
- package/db/reset.mjs +0 -125
- package/emit/db-types.mjs +0 -271
- package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
- package/runtime/scripts/api/supabase/database-push.mjs +0 -5
- package/runtime/scripts/api/supabase/database-query.mjs +0 -5
- package/runtime/scripts/api/supabase/lib.mjs +0 -55
- package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
- package/scaffold/supabase-ports.mjs +0 -26
- package/templates/app/package-scripts/supabase.json +0 -7
- package/templates/app/skeleton-lite/supabase/config.toml +0 -48
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
- package/templates/be/inbox/sql.ts.tpl +0 -16
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
- package/templates/be/table/rows.ts.tpl +0 -4
- package/templates/be/table/sql.api.ts.tpl +0 -7
- package/templates/be/table/sql.ts.tpl +0 -4
- package/templates/fe/auth/supabase-client.ts.tpl +0 -38
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
- /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common"
|
|
2
|
+
import type { EntityManager } from "typeorm"
|
|
3
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
4
|
+
import type { Clock } from "@modules/platform/clock"
|
|
5
|
+
import { JobsError, JobsErrorCode } from "./errors/jobs.error"
|
|
6
|
+
import { callDbFunction } from "@modules/platform/database"
|
|
7
|
+
import { isRunKey } from "./jobs.contracts"
|
|
8
|
+
import type {
|
|
9
|
+
AdvanceWrite,
|
|
10
|
+
ClaimedJob,
|
|
11
|
+
ClaimParams,
|
|
12
|
+
ClaimResult,
|
|
13
|
+
FailWrite,
|
|
14
|
+
GuardedWrite,
|
|
15
|
+
RunKey,
|
|
16
|
+
} from "./jobs.contracts"
|
|
17
|
+
import { InjectJobsManagers } from "./jobs.decorators"
|
|
18
|
+
import type { JobClaims } from "./jobs.port"
|
|
19
|
+
|
|
20
|
+
/** The record job_claim returns (a bigint arrives as text). */
|
|
21
|
+
interface ClaimedRecord {
|
|
22
|
+
/** The id of the job row. */
|
|
23
|
+
readonly id: string
|
|
24
|
+
/** The kind of the job. */
|
|
25
|
+
readonly kind: string
|
|
26
|
+
/** The fencing token after the claim. */
|
|
27
|
+
readonly fencing_token: string
|
|
28
|
+
/** The last recorded step, or null. */
|
|
29
|
+
readonly current_step: string | null
|
|
30
|
+
/** The payload, as the database returns the jsonb value. */
|
|
31
|
+
readonly payload: object
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Injectable()
|
|
35
|
+
/**
|
|
36
|
+
* The one owner of the job row, through the job_* functions of its migration (R334). A claim is one atomic upsert that bumps the fencing token in the store, so two workers can never
|
|
37
|
+
* hold the same token; every other write is `WHERE fencing_token = <expected>`, so a worker that lost its claim (a zombie) changes
|
|
38
|
+
* nothing and learns it from `JobFencedOut`.
|
|
39
|
+
*/
|
|
40
|
+
export class JobClaimService implements JobClaims {
|
|
41
|
+
constructor(
|
|
42
|
+
@InjectJobsManagers() private readonly managers: ReadonlyArray<EntityManager>,
|
|
43
|
+
@InjectClock() private readonly clock: Clock,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
/** Claims the delivery; null when the job is done or another worker holds a claim that has not expired. */
|
|
47
|
+
async claim(params: ClaimParams): Promise<ClaimResult> {
|
|
48
|
+
const now = this.clock.now()
|
|
49
|
+
const rows = await callDbFunction<ClaimedRecord>(this.writer(), "job_claim", [
|
|
50
|
+
params.kind,
|
|
51
|
+
params.jobKey,
|
|
52
|
+
params.workerId,
|
|
53
|
+
new Date(now.getTime() + params.leaseMs),
|
|
54
|
+
JSON.stringify(params.payload),
|
|
55
|
+
now,
|
|
56
|
+
])
|
|
57
|
+
const row = rows[0]
|
|
58
|
+
if (row === undefined) return null
|
|
59
|
+
// The durable delivery supplies the same request id on every retry; the job fence identifies a claim separately.
|
|
60
|
+
return {
|
|
61
|
+
jobId: row.id,
|
|
62
|
+
correlationId: params.correlationId,
|
|
63
|
+
kind: row.kind,
|
|
64
|
+
fencingToken: Number(row.fencing_token),
|
|
65
|
+
currentStep: row.current_step,
|
|
66
|
+
payload: row.payload,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Records a finished step under the token the caller holds. */
|
|
71
|
+
advance(write: AdvanceWrite): Promise<void> {
|
|
72
|
+
return this.guarded("job_advance", [write.jobId, write.expectedFencingToken, write.step, this.clock.now()], write)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Completes the job under the token the caller holds. */
|
|
76
|
+
complete(write: GuardedWrite): Promise<void> {
|
|
77
|
+
return this.guarded("job_complete", [write.jobId, write.expectedFencingToken, this.clock.now()], write)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Marks the job failed under the token the caller holds, so BullMQ can retry it. */
|
|
81
|
+
fail(write: FailWrite): Promise<void> {
|
|
82
|
+
return this.guarded("job_fail", [write.jobId, write.expectedFencingToken, write.reason, this.clock.now()], write)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The idempotency key of one step of this claim: it includes the token, so a zombie's repeat is a different key than the live worker's. */
|
|
86
|
+
runKey(job: ClaimedJob, step: string): RunKey {
|
|
87
|
+
const key = `${job.jobId}:${step}:${job.fencingToken}`
|
|
88
|
+
if (!isRunKey(key))
|
|
89
|
+
throw new JobsError({ code: JobsErrorCode.RunKeyInvalid, params: { jobId: job.jobId, step } })
|
|
90
|
+
return key
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The entity manager of the connection that holds the job table. */
|
|
94
|
+
private writer(): EntityManager {
|
|
95
|
+
const [manager] = this.managers
|
|
96
|
+
if (manager === undefined) throw new JobsError({ code: JobsErrorCode.ConnectionMissing })
|
|
97
|
+
return manager
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Runs a guarded function that updates only when the token still matches; no row back means a newer worker owns the job. */
|
|
101
|
+
private async guarded(
|
|
102
|
+
fn: "job_advance" | "job_complete" | "job_fail",
|
|
103
|
+
parameters: Array<unknown>,
|
|
104
|
+
write: GuardedWrite,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const rows = await callDbFunction<object>(this.writer(), fn, parameters)
|
|
107
|
+
if (rows.length === 0) {
|
|
108
|
+
throw new JobsError({
|
|
109
|
+
code: JobsErrorCode.FencedOut,
|
|
110
|
+
params: { jobId: write.jobId, token: write.expectedFencingToken },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -42,3 +42,54 @@ with check (kind is not null);
|
|
|
42
42
|
|
|
43
43
|
revoke all on table public.jobs from public, anon, authenticated;
|
|
44
44
|
grant select, insert, update on table public.jobs to app_be;
|
|
45
|
+
|
|
46
|
+
-- The claim and the guarded writes, called through callDbFunction of platform/database (R334). A claim inserts a new key with
|
|
47
|
+
-- token 1 or claims a failed or expired one again, bumping the token; a done job or a live claim returns no row. Every other
|
|
48
|
+
-- write matches only the token the caller holds, so a worker that lost its claim changes nothing and gets no row back.
|
|
49
|
+
create function public.job_claim(p_kind varchar, p_job_key varchar, p_worker varchar, p_lease_end timestamptz, p_payload jsonb, p_now timestamptz)
|
|
50
|
+
returns table (id uuid, kind varchar, fencing_token bigint, current_step varchar, payload jsonb)
|
|
51
|
+
language sql
|
|
52
|
+
as $$
|
|
53
|
+
insert into public.jobs as j (kind, job_key, status, fencing_token, claimed_by, lease_expires_at, payload, created_at, updated_at)
|
|
54
|
+
values (p_kind, p_job_key, 'running', 1, p_worker, p_lease_end, p_payload, p_now, p_now)
|
|
55
|
+
on conflict (job_key) do update
|
|
56
|
+
set fencing_token = j.fencing_token + 1, claimed_by = p_worker, lease_expires_at = p_lease_end, status = 'running', error = null, updated_at = p_now
|
|
57
|
+
where j.status = 'failed' or (j.status = 'running' and j.lease_expires_at < p_now)
|
|
58
|
+
returning j.id, j.kind, j.fencing_token, j.current_step, j.payload
|
|
59
|
+
$$;
|
|
60
|
+
|
|
61
|
+
create function public.job_advance(p_id uuid, p_token bigint, p_step varchar, p_now timestamptz)
|
|
62
|
+
returns table (id uuid)
|
|
63
|
+
language sql
|
|
64
|
+
as $$
|
|
65
|
+
update public.jobs as j set current_step = p_step, updated_at = p_now
|
|
66
|
+
where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
|
|
67
|
+
returning j.id
|
|
68
|
+
$$;
|
|
69
|
+
|
|
70
|
+
create function public.job_complete(p_id uuid, p_token bigint, p_now timestamptz)
|
|
71
|
+
returns table (id uuid)
|
|
72
|
+
language sql
|
|
73
|
+
as $$
|
|
74
|
+
update public.jobs as j set status = 'done', lease_expires_at = null, updated_at = p_now
|
|
75
|
+
where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
|
|
76
|
+
returning j.id
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
create function public.job_fail(p_id uuid, p_token bigint, p_reason text, p_now timestamptz)
|
|
80
|
+
returns table (id uuid)
|
|
81
|
+
language sql
|
|
82
|
+
as $$
|
|
83
|
+
update public.jobs as j set status = 'failed', error = p_reason, lease_expires_at = null, updated_at = p_now
|
|
84
|
+
where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
|
|
85
|
+
returning j.id
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
revoke all on function public.job_claim(varchar, varchar, varchar, timestamptz, jsonb, timestamptz) from public;
|
|
89
|
+
grant execute on function public.job_claim(varchar, varchar, varchar, timestamptz, jsonb, timestamptz) to app_be;
|
|
90
|
+
revoke all on function public.job_advance(uuid, bigint, varchar, timestamptz) from public;
|
|
91
|
+
grant execute on function public.job_advance(uuid, bigint, varchar, timestamptz) to app_be;
|
|
92
|
+
revoke all on function public.job_complete(uuid, bigint, timestamptz) from public;
|
|
93
|
+
grant execute on function public.job_complete(uuid, bigint, timestamptz) to app_be;
|
|
94
|
+
revoke all on function public.job_fail(uuid, bigint, text, timestamptz) from public;
|
|
95
|
+
grant execute on function public.job_fail(uuid, bigint, text, timestamptz) to app_be;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
2
|
+
import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
|
|
3
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
4
|
+
import { QueueOutboxService } from "./queue-outbox.service"
|
|
5
|
+
|
|
6
|
+
// The statement callDbFunction of platform/database sends for queue_write.
|
|
7
|
+
const INSERT_QUEUE_ROW = "SELECT * FROM queue_write($1, $2, $3, $4)"
|
|
8
|
+
import { QueueErrorCode } from "./errors/queue.error"
|
|
9
|
+
|
|
10
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
11
|
+
|
|
12
|
+
const build = async () => {
|
|
13
|
+
const moduleRef = await Test.createTestingModule({
|
|
14
|
+
providers: [QueueOutboxService, { provide: CLOCK, useValue: new FakeClock(AT) }],
|
|
15
|
+
}).compile()
|
|
16
|
+
return { outbox: moduleRef.get(QueueOutboxService) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("QueueOutboxService", () => {
|
|
20
|
+
describe("write", () => {
|
|
21
|
+
it("writes the job as a row of the outbox of the transaction it is given", async () => {
|
|
22
|
+
const { outbox } = await build()
|
|
23
|
+
const tx = fakeTransaction(mockEntityManager({ query: [INSERT_QUEUE_ROW, []] }))
|
|
24
|
+
|
|
25
|
+
await tx.em.transaction((manager) => outbox.write(manager, "mail", { id: "m-1" }, "request-1"))
|
|
26
|
+
|
|
27
|
+
expect(tx.em.query).toHaveBeenCalledWith(INSERT_QUEUE_ROW, [
|
|
28
|
+
"mail",
|
|
29
|
+
JSON.stringify({ id: "m-1" }),
|
|
30
|
+
new Date(AT),
|
|
31
|
+
"request-1",
|
|
32
|
+
])
|
|
33
|
+
expect(tx.commits).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("leaves no row behind when the transaction rolls back", async () => {
|
|
37
|
+
const { outbox } = await build()
|
|
38
|
+
const tx = fakeTransaction(mockEntityManager({ query: [INSERT_QUEUE_ROW, []] }))
|
|
39
|
+
const failure = new Error("the change failed")
|
|
40
|
+
|
|
41
|
+
await expect(
|
|
42
|
+
tx.em.transaction(async (manager) => {
|
|
43
|
+
await outbox.write(manager, "mail", { id: "m-1" }, "request-1")
|
|
44
|
+
return Promise.reject(failure)
|
|
45
|
+
}),
|
|
46
|
+
).rejects.toBe(failure)
|
|
47
|
+
|
|
48
|
+
expect(tx.rollbacks).toBe(1)
|
|
49
|
+
expect(tx.committedWrites).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("rejects an unsafe correlation id before writing any SQL", async () => {
|
|
53
|
+
const { outbox } = await build()
|
|
54
|
+
const tx = fakeTransaction(mockEntityManager())
|
|
55
|
+
await expect(
|
|
56
|
+
tx.em.transaction((manager) => outbox.write(manager, "mail", {}, "bad\nid")),
|
|
57
|
+
).rejects.toMatchObject({ code: QueueErrorCode.CorrelationInvalid })
|
|
58
|
+
expect(tx.em.query).not.toHaveBeenCalled()
|
|
59
|
+
expect(tx.rollbacks).toBe(1)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common"
|
|
2
|
+
import type { EntityManager } from "typeorm"
|
|
3
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
4
|
+
import type { Clock } from "@modules/platform/clock"
|
|
5
|
+
import { isCorrelationId } from "@modules/platform/logging"
|
|
6
|
+
import type { QueueOutbox } from "./queue.port"
|
|
7
|
+
import { QueueError, QueueErrorCode } from "./errors/queue.error"
|
|
8
|
+
import { callDbFunction } from "@modules/platform/database"
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
/** The writing side of the queues: a job is a row of the outbox of the caller's transaction, so it exists exactly when the change that needs it commits. */
|
|
12
|
+
export class QueueOutboxService implements QueueOutbox {
|
|
13
|
+
constructor(@InjectClock() private readonly clock: Clock) {}
|
|
14
|
+
|
|
15
|
+
/** Writes the job in the outbox of the transaction `tx`; the relay adds it to BullMQ after the commit. */
|
|
16
|
+
async write(tx: EntityManager, queue: string, payload: object, correlationId: string): Promise<void> {
|
|
17
|
+
if (!isCorrelationId(correlationId)) throw new QueueError({ code: QueueErrorCode.CorrelationInvalid })
|
|
18
|
+
await callDbFunction(tx, "queue_write", [queue, JSON.stringify(payload), this.clock.now(), correlationId])
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -30,3 +30,38 @@ with check (queue is not null);
|
|
|
30
30
|
|
|
31
31
|
revoke all on table public.queue from public, anon, authenticated;
|
|
32
32
|
grant select, insert, update on table public.queue to app_be;
|
|
33
|
+
|
|
34
|
+
-- The outbox write, the relay's locked read of the waiting rows and its mark, called through callDbFunction of platform/database
|
|
35
|
+
-- (R334). The read locks the rows it returns for the caller's transaction and skips rows another relay holds.
|
|
36
|
+
create function public.queue_write(p_queue varchar, p_payload jsonb, p_created_at timestamptz, p_correlation_id varchar)
|
|
37
|
+
returns void
|
|
38
|
+
language sql
|
|
39
|
+
as $$
|
|
40
|
+
insert into public.queue (queue, payload, created_at, correlation_id) values (p_queue, p_payload, p_created_at, p_correlation_id)
|
|
41
|
+
$$;
|
|
42
|
+
|
|
43
|
+
create function public.queue_waiting(p_batch integer)
|
|
44
|
+
returns table (id uuid, queue varchar, payload jsonb, "correlationId" varchar)
|
|
45
|
+
language sql
|
|
46
|
+
as $$
|
|
47
|
+
select q.id, q.queue, q.payload, coalesce(q.correlation_id, q.id::text)
|
|
48
|
+
from public.queue as q
|
|
49
|
+
where q.sent_at is null
|
|
50
|
+
order by q.created_at, q.id
|
|
51
|
+
limit p_batch
|
|
52
|
+
for update skip locked
|
|
53
|
+
$$;
|
|
54
|
+
|
|
55
|
+
create function public.queue_mark_sent(p_ids uuid[], p_sent_at timestamptz)
|
|
56
|
+
returns void
|
|
57
|
+
language sql
|
|
58
|
+
as $$
|
|
59
|
+
update public.queue set sent_at = p_sent_at where id = any(p_ids)
|
|
60
|
+
$$;
|
|
61
|
+
|
|
62
|
+
revoke all on function public.queue_write(varchar, jsonb, timestamptz, varchar) from public;
|
|
63
|
+
grant execute on function public.queue_write(varchar, jsonb, timestamptz, varchar) to app_be;
|
|
64
|
+
revoke all on function public.queue_waiting(integer) from public;
|
|
65
|
+
grant execute on function public.queue_waiting(integer) to app_be;
|
|
66
|
+
revoke all on function public.queue_mark_sent(uuid[], timestamptz) from public;
|
|
67
|
+
grant execute on function public.queue_mark_sent(uuid[], timestamptz) to app_be;
|
|
@@ -9,7 +9,10 @@ import { OutboxLogEvent } from "@modules/platform/outbox"
|
|
|
9
9
|
import type { QueueOptions } from "./queue.options"
|
|
10
10
|
import { QueueRelayService } from "./queue-relay.service"
|
|
11
11
|
import type { QueueTransport } from "./queue-transport.port"
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
// The statements callDbFunction of platform/database sends for the relay's functions.
|
|
14
|
+
const SELECT_WAITING_ROWS = "SELECT * FROM queue_waiting($1)"
|
|
15
|
+
const MARK_ROWS_SENT = "SELECT * FROM queue_mark_sent($1, $2)"
|
|
13
16
|
|
|
14
17
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
15
18
|
|
|
@@ -9,8 +9,19 @@ import { OutboxRelayPolicy } from "@modules/platform/outbox"
|
|
|
9
9
|
import { InjectQueueOptions, InjectQueueRelayManagers, InjectQueueTransport } from "./queue.decorators"
|
|
10
10
|
import type { QueueOptions } from "./queue.options"
|
|
11
11
|
import type { QueueTransport } from "./queue-transport.port"
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import { callDbFunction } from "@modules/platform/database"
|
|
13
|
+
|
|
14
|
+
/** One waiting row queue_waiting returns. */
|
|
15
|
+
interface QueueRow {
|
|
16
|
+
/** The correlation id recorded by the producer's transaction. */
|
|
17
|
+
readonly correlationId: string
|
|
18
|
+
/** The row id: it is the BullMQ job id, so a repeated relay pass adds nothing. */
|
|
19
|
+
readonly id: string
|
|
20
|
+
/** The queue the job goes to. */
|
|
21
|
+
readonly queue: string
|
|
22
|
+
/** The payload, as the database returns the jsonb value. */
|
|
23
|
+
readonly payload: object
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
@Injectable()
|
|
16
27
|
/**
|
|
@@ -32,8 +43,8 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
|
|
|
32
43
|
super()
|
|
33
44
|
}
|
|
34
45
|
|
|
35
|
-
protected select(tx: EntityManager): Promise<Array<QueueRow>> {
|
|
36
|
-
return tx
|
|
46
|
+
protected async select(tx: EntityManager): Promise<Array<QueueRow>> {
|
|
47
|
+
return [...(await callDbFunction<QueueRow>(tx, "queue_waiting", [this.options.relayBatch]))]
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
protected async deliver(rows: ReadonlyArray<QueueRow>): Promise<void> {
|
|
@@ -42,7 +53,7 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
|
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
protected async mark(tx: EntityManager, rows: ReadonlyArray<QueueRow>): Promise<void> {
|
|
45
|
-
await tx
|
|
56
|
+
await callDbFunction(tx, "queue_mark_sent", [rows.map((row) => row.id), this.clock.now()])
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
protected idle(): Promise<void> {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Test } from "@nestjs/testing"
|
|
2
2
|
import { MESSAGE_MAPPING_METADATA } from "@nestjs/websockets/constants"
|
|
3
3
|
import { @@service@@ } from "@@serviceModule@@"
|
|
4
|
-
import type {
|
|
5
|
-
import {
|
|
4
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
5
|
+
import { PassportErrorCode } from "@modules/integrations/passport"
|
|
6
6
|
import type { Principal } from "@modules/platform/cqrs"
|
|
7
7
|
import { ok, refused } from "@modules/platform/primitives"
|
|
8
8
|
import { REALTIME_HUB } from "@modules/platform/realtime"
|
|
@@ -79,7 +79,7 @@ const socketOf = (id: string, auth: Readonly<Record<string, unknown>> = { access
|
|
|
79
79
|
return { socket, pushed }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const build = async (hub: RealtimeHub, verify:
|
|
82
|
+
const build = async (hub: RealtimeHub, verify: VerifyAccessToken) => {
|
|
83
83
|
const moduleRef = await Test.createTestingModule({
|
|
84
84
|
imports: [@@Channel@@WebsocketModule.register({ verifyAccessToken: verify })],
|
|
85
85
|
})
|
|
@@ -96,7 +96,7 @@ const build = async (hub: RealtimeHub, verify: VerifySupabaseAccessToken) => {
|
|
|
96
96
|
describe("@@Channel@@Gateway", () => {
|
|
97
97
|
it("builds the topic only from the verified principal, ignoring handshake principal and topic claims", async () => {
|
|
98
98
|
const { hub, topics } = hubAnswering()
|
|
99
|
-
const verify:
|
|
99
|
+
const verify: VerifyAccessToken = jest.fn().mockResolvedValue(ok(principal))
|
|
100
100
|
const { transport, adapter } = await build(hub, verify)
|
|
101
101
|
const { socket } = socketOf("socket-1", { accessToken: "signed-token", principal: { id: "p-2" }, topic: "foreign" })
|
|
102
102
|
|
|
@@ -111,7 +111,7 @@ describe("@@Channel@@Gateway", () => {
|
|
|
111
111
|
|
|
112
112
|
it("isolates another authenticated principal's topic", async () => {
|
|
113
113
|
const { hub, topics } = hubAnswering()
|
|
114
|
-
const verify:
|
|
114
|
+
const verify: VerifyAccessToken = jest.fn().mockResolvedValue(ok({ id: "p-2", role: "authenticated" }))
|
|
115
115
|
const { transport, adapter } = await build(hub, verify)
|
|
116
116
|
const { socket } = socketOf("socket-2")
|
|
117
117
|
|
|
@@ -126,7 +126,7 @@ describe("@@Channel@@Gateway", () => {
|
|
|
126
126
|
it("pushes a hub frame automatically and releases the iterator on disconnect", async () => {
|
|
127
127
|
const frame = { id: "x-1" }
|
|
128
128
|
const { hub, released } = hubAnswering(frame)
|
|
129
|
-
const verify:
|
|
129
|
+
const verify: VerifyAccessToken = jest.fn().mockResolvedValue(ok(principal))
|
|
130
130
|
const { transport, adapter } = await build(hub, verify)
|
|
131
131
|
const { socket, pushed } = socketOf("socket-1")
|
|
132
132
|
|
|
@@ -142,7 +142,7 @@ describe("@@Channel@@Gateway", () => {
|
|
|
142
142
|
|
|
143
143
|
it.each([{}, { accessToken: "" }, { accessToken: 42 }])("refuses a missing or malformed access token without invoking the verifier", async (auth) => {
|
|
144
144
|
const { hub, topics } = hubAnswering()
|
|
145
|
-
const verify:
|
|
145
|
+
const verify: VerifyAccessToken = jest.fn()
|
|
146
146
|
const { adapter } = await build(hub, verify)
|
|
147
147
|
const { socket } = socketOf("socket-1", auth)
|
|
148
148
|
socket.data.principal = principal
|
|
@@ -156,7 +156,7 @@ describe("@@Channel@@Gateway", () => {
|
|
|
156
156
|
|
|
157
157
|
it("refuses a rejected token and clears a prior principal", async () => {
|
|
158
158
|
const { hub, topics } = hubAnswering()
|
|
159
|
-
const verify:
|
|
159
|
+
const verify: VerifyAccessToken = jest.fn().mockResolvedValue(refused(PassportErrorCode.AccessTokenInvalid))
|
|
160
160
|
const { adapter } = await build(hub, verify)
|
|
161
161
|
const { socket } = socketOf("socket-1")
|
|
162
162
|
socket.data.principal = principal
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Module } from "@nestjs/common"
|
|
2
2
|
import type { OnModuleDestroy, OnModuleInit } from "@nestjs/common"
|
|
3
3
|
import type { @@Channel@@Frame } from "@@serviceModule@@"
|
|
4
|
-
import type {
|
|
4
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
5
5
|
import { injector } from "@modules/platform/composition"
|
|
6
6
|
import type { Principal } from "@modules/platform/cqrs"
|
|
7
7
|
import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from "./@@channel@@-websocket.module-definition"
|
|
@@ -48,8 +48,8 @@ export class @@Channel@@WebsocketModule extends ConfigurableModuleClass implemen
|
|
|
48
48
|
|
|
49
49
|
constructor(
|
|
50
50
|
private readonly gateway: @@Channel@@Gateway,
|
|
51
|
-
@injector<
|
|
52
|
-
private readonly verifyAccessToken:
|
|
51
|
+
@injector<VerifyAccessToken>(ACCESS_TOKEN_VERIFIER)
|
|
52
|
+
private readonly verifyAccessToken: VerifyAccessToken,
|
|
53
53
|
) {
|
|
54
54
|
super()
|
|
55
55
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
2
2
|
|
|
3
3
|
/** The options of the @@channel@@ socket transport: the cryptographic verifier that authenticates each connection. */
|
|
4
4
|
export interface @@Channel@@WebsocketOptions {
|
|
5
5
|
/** Verifies the access token of a handshake with the same verifier identity uses. */
|
|
6
|
-
readonly verifyAccessToken:
|
|
6
|
+
readonly verifyAccessToken: VerifyAccessToken
|
|
7
7
|
}
|
|
@@ -2,7 +2,8 @@ import type { EnvSource } from "@modules/platform/config"
|
|
|
2
2
|
import { parseServerConfig } from "@modules/platform/config"
|
|
3
3
|
import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
|
|
4
4
|
import { parseHttpSecurityConfig } from "@modules/platform/http-security"
|
|
5
|
-
import {
|
|
5
|
+
import { parsePassportConfig } from "@modules/integrations/passport"
|
|
6
|
+
import { parseObjectStorageConfig } from "@modules/integrations/object-storage"
|
|
6
7
|
|
|
7
8
|
/** The API environment values required before composition. */
|
|
8
9
|
export interface ApiEnv {
|
|
@@ -10,10 +11,12 @@ export interface ApiEnv {
|
|
|
10
11
|
readonly server: ReturnType<typeof parseServerConfig>
|
|
11
12
|
/** The origin allowlist and rate limits. */
|
|
12
13
|
readonly httpSecurity: ReturnType<typeof parseHttpSecurityConfig>
|
|
13
|
-
/** The least-privilege PostgreSQL connection to the
|
|
14
|
+
/** The least-privilege PostgreSQL connection to the app's own database. */
|
|
14
15
|
readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
|
|
15
|
-
/**
|
|
16
|
-
readonly
|
|
16
|
+
/** Google OAuth, the app's own access tokens and the refresh cookie. */
|
|
17
|
+
readonly passport: ReturnType<typeof parsePassportConfig>
|
|
18
|
+
/** S3-compatible object storage. */
|
|
19
|
+
readonly objectStorage: ReturnType<typeof parseObjectStorageConfig>
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/** Reads the API environment; missing or invalid values fail boot, and secrets use `_FILE` first. */
|
|
@@ -21,5 +24,6 @@ export const readApiEnv = (env: EnvSource): ApiEnv => ({
|
|
|
21
24
|
server: parseServerConfig(env),
|
|
22
25
|
httpSecurity: parseHttpSecurityConfig(env),
|
|
23
26
|
database: parsePrimaryDatabaseConfig(env),
|
|
24
|
-
|
|
27
|
+
passport: parsePassportConfig(env),
|
|
28
|
+
objectStorage: parseObjectStorageConfig(env),
|
|
25
29
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PassportOptions } from "@modules/integrations/passport"
|
|
2
|
+
import type { ObjectStorageOptions } from "@modules/integrations/object-storage"
|
|
2
3
|
import type { ServerOptions } from "@modules/platform/config"
|
|
3
4
|
import type { DatabaseConnectionOptions } from "@modules/platform/database"
|
|
4
5
|
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
@@ -9,8 +10,10 @@ export interface ApiOptions {
|
|
|
9
10
|
readonly server: ServerOptions
|
|
10
11
|
/** The origin allowlist and rate limits. */
|
|
11
12
|
readonly httpSecurity: HttpSecurityOptions
|
|
12
|
-
/** The least-privilege PostgreSQL connection to the
|
|
13
|
+
/** The least-privilege PostgreSQL connection to the app's own database. */
|
|
13
14
|
readonly database: DatabaseConnectionOptions
|
|
14
|
-
/**
|
|
15
|
-
readonly
|
|
15
|
+
/** Google OAuth, the app's own access tokens and the refresh cookie. */
|
|
16
|
+
readonly passport: PassportOptions
|
|
17
|
+
/** S3-compatible object storage. */
|
|
18
|
+
readonly objectStorage: ObjectStorageOptions
|
|
16
19
|
}
|
|
@@ -2,7 +2,7 @@ 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 { AuthGuard, CredentialGuard } from "@modules/domain/identity"
|
|
5
|
-
import {
|
|
5
|
+
import { tokenKit } from "@modules/integrations/passport"
|
|
6
6
|
import { ErrorsFilter } from "@modules/platform/errors"
|
|
7
7
|
import { OriginGuard, RateLimitGuard } from "@modules/platform/http-security"
|
|
8
8
|
import { platformModules } from "./compose/platform"
|
|
@@ -18,18 +18,18 @@ import type { ApiOptions } from "./api.options"
|
|
|
18
18
|
export class AppModule {
|
|
19
19
|
/** Builds the module from options parsed once at boot; nothing here reads the environment. */
|
|
20
20
|
static register(options: ApiOptions): DynamicModule {
|
|
21
|
-
const
|
|
21
|
+
const tokens = tokenKit(options.passport.tokens)
|
|
22
22
|
return {
|
|
23
23
|
module: AppModule,
|
|
24
24
|
imports: [
|
|
25
25
|
...platformModules(options),
|
|
26
|
-
...domainModules(
|
|
26
|
+
...domainModules(tokens.verify),
|
|
27
27
|
...systemModules(),
|
|
28
28
|
...apiModules(),
|
|
29
29
|
...webhooksModules(),
|
|
30
30
|
],
|
|
31
31
|
providers: [
|
|
32
|
-
...integrationProviders(options
|
|
32
|
+
...integrationProviders(options, tokens.verify),
|
|
33
33
|
{ provide: APP_FILTER, useClass: ErrorsFilter },
|
|
34
34
|
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
|
35
35
|
{ provide: APP_GUARD, useClass: OriginGuard },
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { DynamicModule, Type } from "@nestjs/common"
|
|
2
2
|
import { IdentityModule } from "@modules/domain/identity"
|
|
3
3
|
import { LivenessModule } from "@modules/domain/liveness"
|
|
4
|
-
import type {
|
|
4
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
5
5
|
|
|
6
|
-
/** The domain capabilities of the API, with the app's
|
|
7
|
-
export const domainModules = (verifyAccessToken:
|
|
6
|
+
/** The domain capabilities of the API, with the verifier of the app's own access tokens. */
|
|
7
|
+
export const domainModules = (verifyAccessToken: VerifyAccessToken): Array<Type<unknown> | DynamicModule> => [
|
|
8
8
|
IdentityModule.register({ isGlobal: true, verifyAccessToken }),
|
|
9
9
|
LivenessModule.register({ isGlobal: true }),
|
|
10
10
|
]
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import type { Provider } from "@nestjs/common"
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from "@modules/integrations/supabase"
|
|
7
|
-
import type { SupabaseOptions, VerifySupabaseAccessToken } from "@modules/integrations/supabase"
|
|
2
|
+
import { OWN_TOKEN_VERIFIER, PASSPORT_OPTIONS } from "@modules/integrations/passport"
|
|
3
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
4
|
+
import { OBJECT_STORAGE, createObjectStorage } from "@modules/integrations/object-storage"
|
|
5
|
+
import type { ApiOptions } from "../api.options"
|
|
8
6
|
|
|
9
|
-
/** The
|
|
10
|
-
export const integrationProviders = (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
{ provide: SUPABASE_ADMIN_CLIENT, useValue: createSupabaseAdminClient(options) },
|
|
15
|
-
{ provide: SUPABASE_ACCESS_TOKEN_VERIFIER, useValue: verifyAccessToken },
|
|
7
|
+
/** The passport and object-storage integration ports bound once for this app. */
|
|
8
|
+
export const integrationProviders = (options: ApiOptions, verifyAccessToken: VerifyAccessToken): Array<Provider> => [
|
|
9
|
+
{ provide: PASSPORT_OPTIONS, useValue: options.passport },
|
|
10
|
+
{ provide: OWN_TOKEN_VERIFIER, useValue: verifyAccessToken },
|
|
11
|
+
{ provide: OBJECT_STORAGE, useValue: createObjectStorage(options.objectStorage) },
|
|
16
12
|
]
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { DynamicModule } from "@nestjs/common"
|
|
2
2
|
import { IDENTITY_ERROR_KINDS, IDENTITY_MESSAGES } from "@modules/domain/identity"
|
|
3
|
-
import {
|
|
3
|
+
import { PASSPORT_ERROR_KINDS } from "@modules/integrations/passport"
|
|
4
4
|
import { ClockModule } from "@modules/platform/clock"
|
|
5
5
|
import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
|
|
6
6
|
import { CqrsModule } from "@modules/platform/cqrs"
|
|
7
7
|
import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
|
|
8
|
+
import { PRIMARY_ENTITIES } from "@modules/platform/database/entities"
|
|
8
9
|
import { ERRORS_MESSAGES, ErrorsModule } from "@modules/platform/errors"
|
|
9
10
|
import { HTTP_SECURITY_ERROR_KINDS, HTTP_SECURITY_MESSAGES, HttpSecurityModule } from "@modules/platform/http-security"
|
|
10
11
|
import { I18nModule } from "@modules/platform/i18n"
|
|
@@ -37,7 +38,7 @@ export const platformModules = (options: ApiOptions): Array<DynamicModule> => [
|
|
|
37
38
|
DATABASE_ERROR_KINDS,
|
|
38
39
|
HTTP_SECURITY_ERROR_KINDS,
|
|
39
40
|
IDENTITY_ERROR_KINDS,
|
|
40
|
-
|
|
41
|
+
PASSPORT_ERROR_KINDS,
|
|
41
42
|
],
|
|
42
43
|
}),
|
|
43
44
|
CqrsModule.register({ isGlobal: true }),
|
|
@@ -48,5 +49,6 @@ export const platformModules = (options: ApiOptions): Array<DynamicModule> => [
|
|
|
48
49
|
DatabaseModule.register({
|
|
49
50
|
isGlobal: true,
|
|
50
51
|
connections: [options.database],
|
|
52
|
+
entities: PRIMARY_ENTITIES,
|
|
51
53
|
}),
|
|
52
54
|
]
|
|
@@ -5,8 +5,8 @@ import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
|
|
|
5
5
|
export interface CliEnv {
|
|
6
6
|
/** The logical connection name declared in `hfs.json`. */
|
|
7
7
|
readonly name: string
|
|
8
|
-
/** The
|
|
9
|
-
readonly provider: "
|
|
8
|
+
/** The PostgreSQL provider. */
|
|
9
|
+
readonly provider: "postgres"
|
|
10
10
|
/** The least-privilege database connection URL, read through the secret source. */
|
|
11
11
|
readonly url: Secret
|
|
12
12
|
/** The isolated PostgreSQL schema. */
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { DynamicModule } from "@nestjs/common"
|
|
2
2
|
import { ClockModule } from "@modules/platform/clock"
|
|
3
3
|
import { DatabaseModule } from "@modules/platform/database"
|
|
4
|
+
import { PRIMARY_ENTITIES } from "@modules/platform/database/entities"
|
|
4
5
|
import { IdsModule } from "@modules/platform/ids"
|
|
5
6
|
import { HTTP_ERROR_KINDS, HTTP_MESSAGES, HttpModule } from "@modules/platform/http"
|
|
6
7
|
import { LoggingModule } from "@modules/platform/logging"
|
|
7
8
|
import type { CliAppOptions } from "../cli.options"
|
|
8
9
|
|
|
9
|
-
/** The shared clock, logger and least-privilege connection used by
|
|
10
|
+
/** The shared clock, logger and least-privilege connection used by the operator commands. */
|
|
10
11
|
export const platformModules = (options: CliAppOptions): Array<DynamicModule> => [
|
|
11
12
|
ClockModule.register({ isGlobal: true }),
|
|
12
13
|
IdsModule.register({ isGlobal: true }),
|
|
13
14
|
LoggingModule.register({ isGlobal: true }),
|
|
14
15
|
HttpModule.register({ isGlobal: true }),
|
|
15
|
-
DatabaseModule.register({ isGlobal: true, connections: options.connections }),
|
|
16
|
+
DatabaseModule.register({ isGlobal: true, connections: options.connections, entities: PRIMARY_ENTITIES }),
|
|
16
17
|
]
|