@starci/hfs 4.11.0 → 4.11.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +43 -1
- package/db/drift.mjs +8 -6
- package/db/image.mjs +9 -4
- package/db/migrate.mjs +3 -3
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
- package/runtime/knowledge/hfs/rules.yaml +30 -13
- package/runtime/knowledge/hfs/slots.yaml +63 -37
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/queues.yaml +2 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +17 -17
- package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
- package/runtime/scripts/api/sops/lib.mjs +30 -0
- package/runtime/scripts/api/sops/update-keys.mjs +20 -0
- package/runtime/scripts/gates/starcistacks.mjs +6 -2
- package/runtime/scripts/guards/push-scan.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
- package/runtime/scripts/hfs/db-errors.mjs +2 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
- package/runtime/scripts/hfs/db-squash.mjs +514 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
- package/runtime/scripts/hfs/example-secrets.mjs +4 -3
- package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
- package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
- package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
- package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
- package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
- package/runtime/scripts/hfs/sealed-members.mjs +10 -6
- package/runtime/scripts/hfs/secret-files.mjs +16 -0
- package/runtime/scripts/hfs/secret-init.mjs +120 -21
- package/runtime/scripts/hfs/secret-output.mjs +2 -1
- package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
- package/runtime/scripts/hfs/secret-sops.mjs +108 -9
- package/runtime/scripts/hfs/secret-standard.mjs +369 -0
- package/runtime/scripts/hfs/secret-store.mjs +23 -9
- package/runtime/scripts/hfs/secret.mjs +140 -46
- package/runtime/scripts/hfs/slots.mjs +8 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
- package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
- package/runtime/scripts/lib/sealed-member.mjs +36 -0
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +6 -4
- package/stack/age-install.mjs +4 -3
- package/stack/build.mjs +1 -1
- package/stack/config.mjs +2 -1
- package/stack/context.mjs +32 -2
- package/stack/docker-transport.mjs +2 -1
- package/stack/remote.mjs +8 -5
- package/stack/sealed.mjs +9 -8
- package/stack/server-key.mjs +296 -0
- package/stack/ssh-session.mjs +7 -1
- package/stack/target-binding.mjs +2 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +36 -10
- package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
- package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
- package/templates/app/gitignore +2 -1
- package/templates/app/gitignore-lite +3 -1
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
- package/templates/be/inbox/events.json.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
- package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
- package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
- package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
- package/templates/be/inbox/platform/migration.sql.tpl +97 -34
- package/templates/be/inbox/service.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
- package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
- package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
- package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
- package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
- package/templates/app/db-image/db/Dockerfile +0 -8
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
package/sync/index.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs
|
|
|
30
30
|
import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
|
|
31
31
|
import { readDeclaredSonarKey } from './sonar-key.mjs';
|
|
32
32
|
import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
|
|
33
|
-
import { DB_DOCKERFILE, DB_IMAGE_APP } from '../db/image.mjs';
|
|
33
|
+
import { DB_DOCKERFILE, DB_IMAGE_APP, RETIRED_DB_DOCKERFILE } from '../db/image.mjs';
|
|
34
34
|
import { byCodeUnit } from '../report/order.mjs';
|
|
35
35
|
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
36
36
|
import { ciWorkflowCommands } from './ci-commands.mjs';
|
|
@@ -40,6 +40,8 @@ import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
|
|
|
40
40
|
import { LIST } from '../runtime/scripts/lib/loosening-config.mjs';
|
|
41
41
|
import { readTruths } from '../runtime/scripts/lib/loosening-state.mjs';
|
|
42
42
|
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
43
|
+
import { bootstrapSection } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
44
|
+
import { SERVER_AGE_IDENTITY } from '../stack/server-key.mjs';
|
|
43
45
|
|
|
44
46
|
export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
|
|
45
47
|
const NODE_MAJOR = 22;
|
|
@@ -58,9 +60,10 @@ export class SyncError extends Error {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
// mode: file = whole file, block = the marked block inside a file the app also writes to, scripts = the `scripts` block of
|
|
61
|
-
// package.json
|
|
62
|
-
//
|
|
63
|
-
|
|
63
|
+
// package.json, section = the whole file but the one marked section a database verb owns (db/bootstrap.sql, whose squash section
|
|
64
|
+
// `starci app db baseline --squash` writes). A template that can hold a comment opens with {{header}}; a file whose exact content is
|
|
65
|
+
// specified (JSON, the eslint one-liner) has no header.
|
|
66
|
+
const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts', 'package-scripts-lite': 'scripts', 'db-bootstrap': 'section' });
|
|
64
67
|
const GLOB_CHARACTERS = new Set('*?<>{}[]');
|
|
65
68
|
const literalFile = file => file.length >= 2 && !file.endsWith('/') && ![...file].some(character => GLOB_CHARACTERS.has(character));
|
|
66
69
|
|
|
@@ -158,13 +161,22 @@ function trailingNewline(text) {
|
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
/**
|
|
161
|
-
* The base image of a lite app's managed db/Dockerfile (contexts/stack/pins.yaml images postgres, ruling:lite-official-postgres-image): the
|
|
162
|
-
* Docker Official postgres image by digest, to which the Dockerfile adds
|
|
164
|
+
* The base image of a lite app's managed .starcistacks/images/db/Dockerfile (contexts/stack/pins.yaml images postgres, ruling:lite-official-postgres-image): the
|
|
165
|
+
* Docker Official postgres image by digest, to which the Dockerfile adds PGVECTOR_PACKAGE from PGDG and the PGMQ release.
|
|
163
166
|
*/
|
|
164
167
|
export const DB_BASE_IMAGE = 'postgres:17.11@sha256:31ba9c94a03ada9988f2bb493a22a75eef3a185884533e5cdbcd498ea835b08f';
|
|
165
168
|
/** The PGDG package of pgvector at the exact version the db image installs (the one extension the image does not ship). */
|
|
166
169
|
export const PGVECTOR_PACKAGE = 'postgresql-17-pgvector=0.8.7-1.pgdg13+1';
|
|
167
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* The pgmq release the db image installs for the event bus (ruling:lite-pgmq-bus-bullmq-jobs): its source tarball, verified by sha256
|
|
172
|
+
* when Docker adds it. PGDG does not package pgmq; it is SQL objects only, so the Dockerfile copies its control and script files.
|
|
173
|
+
*/
|
|
174
|
+
export const PGMQ = Object.freeze({
|
|
175
|
+
version: '1.13.1',
|
|
176
|
+
source: 'https://github.com/pgmq/pgmq/archive/refs/tags/v1.13.1.tar.gz',
|
|
177
|
+
sha256: '67221f288f42f95c002a64df40f7d660e765e32df8ada0f2672ba915a14827f7',
|
|
178
|
+
});
|
|
179
|
+
/** The local tag the development db container and the drift scratch database run, built from the app's .starcistacks/images/db/Dockerfile. */
|
|
168
180
|
export const dbLocalImage = project => `${project}-db:local`;
|
|
169
181
|
/** The digest-pinned image of a lite app's declared Redis, in every environment that runs it (ruling:R212/case-10). */
|
|
170
182
|
export const REDIS_IMAGE = 'redis:7@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f';
|
|
@@ -385,10 +397,13 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
385
397
|
project: app.project,
|
|
386
398
|
appBinary: APP_BINARY,
|
|
387
399
|
appScripts: appScripts(app, source),
|
|
388
|
-
// A lite app's own database runs in development as one container of the image its db/Dockerfile builds (ruling:lite-
|
|
400
|
+
// A lite app's own database runs in development as one container of the image its .starcistacks/images/db/Dockerfile builds (ruling:lite-db-image-in-stacks).
|
|
389
401
|
dbImage: dbLocalImage(app.project),
|
|
390
402
|
dbBaseImage: DB_BASE_IMAGE,
|
|
391
403
|
pgvectorPackage: PGVECTOR_PACKAGE,
|
|
404
|
+
pgmqVersion: PGMQ.version,
|
|
405
|
+
pgmqSource: PGMQ.source,
|
|
406
|
+
pgmqSha256: PGMQ.sha256,
|
|
392
407
|
// A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
|
|
393
408
|
backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
|
|
394
409
|
// turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
|
|
@@ -398,6 +413,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
398
413
|
// The typed lint reads the types the fe packages declare (their dist, turbo.json: lint dependsOn ^build), so the packages are built first.
|
|
399
414
|
lintPrefix: packages ? 'npm run codegen --silent && turbo run build --filter=./fe/packages/* && ' : 'npm run codegen --silent && ',
|
|
400
415
|
nodeMajor: String(NODE_MAJOR),
|
|
416
|
+
// The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
|
|
417
|
+
serverAgeIdentity: SERVER_AGE_IDENTITY,
|
|
401
418
|
sonarKey: sonarKey ?? app.project,
|
|
402
419
|
sonarHostLine: sonarHostLine(quality.sonar),
|
|
403
420
|
sonarExclusions: sonarScope(presets).exclusions.join(','),
|
|
@@ -472,9 +489,10 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
472
489
|
});
|
|
473
490
|
}
|
|
474
491
|
|
|
475
|
-
// The part of a file sync manages: all of it, the marked block, or the scripts block as text.
|
|
492
|
+
// The part of a file sync manages: all of it, all of it but the verb's section, the marked block, or the scripts block as text.
|
|
476
493
|
function managedPart(mode, text, markers = { begin: BLOCK_BEGIN, end: BLOCK_END }) {
|
|
477
494
|
if (mode === 'file') return text;
|
|
495
|
+
if (mode === 'section') return bootstrapSection(text).head;
|
|
478
496
|
if (mode === 'scripts') {
|
|
479
497
|
const scripts = parseScripts(text);
|
|
480
498
|
return scripts ? scriptsText(scripts) : null;
|
|
@@ -524,6 +542,7 @@ export function writtenContent(target, existing) {
|
|
|
524
542
|
}
|
|
525
543
|
return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
|
|
526
544
|
}
|
|
545
|
+
if (target.mode === 'section') return `${target.content}${bootstrapSection(existing ?? '').section ?? ''}`;
|
|
527
546
|
if (target.mode === 'block' && existing) {
|
|
528
547
|
const current = managedPart('block', existing, target.markers);
|
|
529
548
|
if (current !== null) return existing.replace(current, () => target.content);
|
|
@@ -571,6 +590,12 @@ export async function renderRepo(root, { presets, parseYaml, notices, manifest =
|
|
|
571
590
|
|
|
572
591
|
/** The folders a lite app's sync retires (ruling lite-self-hosted-postgres-passport-minio): the generated Supabase row types nothing reads any more. */
|
|
573
592
|
const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
|
|
593
|
+
/** The files a lite app's sync retires, each with the reason --check prints. */
|
|
594
|
+
const LITE_RETIRED_FILES = Object.freeze({
|
|
595
|
+
[RETIRED_DB_DOCKERFILE]: `the db image moved to ${DB_DOCKERFILE} (ruling lite-db-image-in-stacks)`,
|
|
596
|
+
});
|
|
597
|
+
/** Why a retired file goes: a retired file's own reason, else the folder's. */
|
|
598
|
+
const retiredReason = (file) => LITE_RETIRED_FILES[file] ?? 'the lite edition generates no database types';
|
|
574
599
|
|
|
575
600
|
/** The app-relative files of the retired folders that still exist under `root` of a lite app; sync --write deletes them, --check reports them. */
|
|
576
601
|
export function retiredFiles(root, hfs) {
|
|
@@ -584,6 +609,7 @@ export function retiredFiles(root, hfs) {
|
|
|
584
609
|
else found.push(rel);
|
|
585
610
|
};
|
|
586
611
|
for (const dir of LITE_RETIRED_DIRS) walk(dir);
|
|
612
|
+
for (const file of Object.keys(LITE_RETIRED_FILES)) walk(file);
|
|
587
613
|
return found;
|
|
588
614
|
}
|
|
589
615
|
|
|
@@ -602,7 +628,7 @@ function syncOptions(argv) {
|
|
|
602
628
|
function reportSyncCheck(root, targets, out, retired = []) {
|
|
603
629
|
const results = checkTargets(root, targets);
|
|
604
630
|
const bad = results.filter(item => item.status !== 'ok');
|
|
605
|
-
for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired,
|
|
631
|
+
for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired, ${retiredReason(file)}; run "npx starci app sync --write" to delete it`);
|
|
606
632
|
for (const result of bad) {
|
|
607
633
|
const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
|
|
608
634
|
const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
# Builds the image of every app on its release tag, so a Dockerfile that no longer builds fails that release.
|
|
3
3
|
# It never pushes: publishing an image is the product's decision, not this file's.
|
|
4
|
+
# When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
|
|
5
|
+
# arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key <env> --backup github).
|
|
4
6
|
name: images
|
|
5
7
|
|
|
6
8
|
on:
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
# operation rollback redeploys a stored release; "release" is 16 lowercase hex characters, empty means the previous accepted release
|
|
8
8
|
# The environment input names a protected GitHub environment (required reviewers) that holds:
|
|
9
9
|
# vars VPS_HOST, VPS_USER (the swarm manager); a variable named in deploy.passEnv, when the stack interpolates one
|
|
10
|
-
# secrets SOPS_AGE_KEY (
|
|
10
|
+
# secrets SOPS_AGE_KEY (that environment's server identity, stored there by starci app secret server-key <env> --backup github;
|
|
11
|
+
# both jobs are bound to the environment, so they read it and never a repository secret of that name), VPS_SSH_KEY
|
|
12
|
+
# (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
|
|
11
13
|
# Nothing else is a secret: images are pushed with the workflow token, which also reads the CI verdict of the tag's commit a production deploy
|
|
12
14
|
# waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
|
|
13
15
|
name: deploy stack
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{{header}}
|
|
2
|
+
# Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
|
|
3
|
+
# stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
|
|
4
|
+
# identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
|
|
5
|
+
# owned by root; it never echoes the identity and never overwrites one.
|
|
6
|
+
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
|
|
7
|
+
# vars VPS_HOST, VPS_USER (the swarm manager)
|
|
8
|
+
# secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
|
|
9
|
+
# VPS_KNOWN_HOSTS (pinned host keys)
|
|
10
|
+
name: restore stack key
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
inputs:
|
|
15
|
+
environment:
|
|
16
|
+
description: Protected environment holding the deploy transport and the server identity backup
|
|
17
|
+
required: true
|
|
18
|
+
type: environment
|
|
19
|
+
recipient:
|
|
20
|
+
description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
|
|
21
|
+
required: true
|
|
22
|
+
type: string
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: stack-${{ inputs.environment }}
|
|
29
|
+
cancel-in-progress: false
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
restore:
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: ${{ inputs.environment }}
|
|
35
|
+
timeout-minutes: 10
|
|
36
|
+
steps:
|
|
37
|
+
- name: write the identity on the server
|
|
38
|
+
env:
|
|
39
|
+
RECIPIENT: ${{ inputs.recipient }}
|
|
40
|
+
AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
|
|
41
|
+
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
42
|
+
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
|
|
43
|
+
VPS_HOST: ${{ vars.VPS_HOST }}
|
|
44
|
+
VPS_USER: ${{ vars.VPS_USER }}
|
|
45
|
+
shell: bash
|
|
46
|
+
run: |
|
|
47
|
+
set -euo pipefail
|
|
48
|
+
[[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
|
|
49
|
+
[ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
|
|
50
|
+
umask 077
|
|
51
|
+
dir="$(mktemp -d)"
|
|
52
|
+
trap 'rm -rf "$dir"' EXIT
|
|
53
|
+
printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
|
|
54
|
+
printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
|
|
55
|
+
# The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
|
|
56
|
+
remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
|
|
57
|
+
printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
|
|
58
|
+
-o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
# Builds each image declared by the lite app. The API image is required; a front-end image is emitted only when declared.
|
|
3
3
|
# It runs on the release tag, so every image is built there, and never pushes.
|
|
4
|
+
# When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
|
|
5
|
+
# arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key <env> --backup github).
|
|
4
6
|
name: images
|
|
5
7
|
|
|
6
8
|
on:
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
# operation rollback redeploys a stored release; "release" is 16 lowercase hex characters, empty means the previous accepted release
|
|
8
8
|
# The environment input names a protected GitHub environment (required reviewers) that holds:
|
|
9
9
|
# vars VPS_HOST, VPS_USER (the swarm manager); a variable named in deploy.passEnv, when the stack interpolates one
|
|
10
|
-
# secrets SOPS_AGE_KEY (
|
|
10
|
+
# secrets SOPS_AGE_KEY (that environment's server identity, stored there by starci app secret server-key <env> --backup github;
|
|
11
|
+
# both jobs are bound to the environment, so they read it and never a repository secret of that name), VPS_SSH_KEY
|
|
12
|
+
# (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
|
|
11
13
|
# Nothing else is a secret: images are pushed with the workflow token, which also reads the CI verdict of the tag's commit a production deploy
|
|
12
14
|
# waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
|
|
13
15
|
name: deploy stack
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{{header}}
|
|
2
|
+
# Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
|
|
3
|
+
# stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
|
|
4
|
+
# identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
|
|
5
|
+
# owned by root; it never echoes the identity and never overwrites one.
|
|
6
|
+
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
|
|
7
|
+
# vars VPS_HOST, VPS_USER (the swarm manager)
|
|
8
|
+
# secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
|
|
9
|
+
# VPS_KNOWN_HOSTS (pinned host keys)
|
|
10
|
+
name: restore stack key
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
inputs:
|
|
15
|
+
environment:
|
|
16
|
+
description: Protected environment holding the deploy transport and the server identity backup
|
|
17
|
+
required: true
|
|
18
|
+
type: environment
|
|
19
|
+
recipient:
|
|
20
|
+
description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
|
|
21
|
+
required: true
|
|
22
|
+
type: string
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: stack-${{ inputs.environment }}
|
|
29
|
+
cancel-in-progress: false
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
restore:
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: ${{ inputs.environment }}
|
|
35
|
+
timeout-minutes: 10
|
|
36
|
+
steps:
|
|
37
|
+
- name: write the identity on the server
|
|
38
|
+
env:
|
|
39
|
+
RECIPIENT: ${{ inputs.recipient }}
|
|
40
|
+
AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
|
|
41
|
+
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
|
42
|
+
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
|
|
43
|
+
VPS_HOST: ${{ vars.VPS_HOST }}
|
|
44
|
+
VPS_USER: ${{ vars.VPS_USER }}
|
|
45
|
+
shell: bash
|
|
46
|
+
run: |
|
|
47
|
+
set -euo pipefail
|
|
48
|
+
[[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
|
|
49
|
+
[ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
|
|
50
|
+
umask 077
|
|
51
|
+
dir="$(mktemp -d)"
|
|
52
|
+
trap 'rm -rf "$dir"' EXIT
|
|
53
|
+
printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
|
|
54
|
+
printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
|
|
55
|
+
# The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
|
|
56
|
+
remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
|
|
57
|
+
printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
|
|
58
|
+
-o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
-- Managed by starci app sync (slot app.db.bootstrap); do not edit: run "npx starci app sync --write".
|
|
2
2
|
-- The pre-migration bootstrap of a lite app's self-hosted PostgreSQL (ruling lite-self-hosted-postgres-passport-minio), run on the official
|
|
3
|
-
-- postgres image of db/Dockerfile (ruling lite-official-postgres-image). Migrations written for Supabase reference objects GoTrue and Supabase
|
|
3
|
+
-- postgres image of .starcistacks/images/db/Dockerfile (ruling lite-official-postgres-image). Migrations written for Supabase reference objects GoTrue and Supabase
|
|
4
4
|
-- Storage created: the browser roles, the `auth` schema (auth.users, auth.identities, auth.sessions, auth.uid/jwt/role) and the `storage`
|
|
5
5
|
-- schema (storage.buckets, storage.objects). A lite app runs neither GoTrue nor storage-api: auth is the app's own (integrations/passport) and
|
|
6
|
-
-- storage is the S3 endpoint of integrations/object-storage. This file gives the migrations those objects, EMPTY, and
|
|
7
|
-
--
|
|
8
|
-
--
|
|
6
|
+
-- storage is the S3 endpoint of integrations/object-storage. This file gives the migrations those objects, EMPTY, and the `extensions` schema
|
|
7
|
+
-- every allowed extension is created in (`create extension ... with schema extensions`, the app role's search_path is `public, extensions`);
|
|
8
|
+
-- nothing else of Supabase (supabase_admin, the realtime publication) exists. It is idempotent; `starci app db migrate` applies it as the
|
|
9
|
+
-- database owner before the first migration of an empty database.
|
|
9
10
|
--
|
|
10
11
|
-- auth.users keeps the GoTrue column set so restored rows and foreign keys from public tables stay valid; encrypted_password holds bcrypt hashes.
|
|
11
12
|
-- auth.uid(), auth.role() and auth.jwt() read the request.jwt.claims setting; the API connects as app_be and sets none, so they return NULL and
|
|
12
13
|
-- every policy that names them denies the browser roles, which have no door in lite.
|
|
13
14
|
|
|
15
|
+
create schema if not exists extensions;
|
|
14
16
|
create schema if not exists auth;
|
|
15
17
|
create schema if not exists storage;
|
|
16
18
|
|
|
@@ -23,6 +25,7 @@ end
|
|
|
23
25
|
$roles$;
|
|
24
26
|
|
|
25
27
|
grant usage on schema auth, storage to anon, authenticated, service_role;
|
|
28
|
+
grant usage on schema extensions to anon, authenticated, service_role;
|
|
26
29
|
|
|
27
30
|
-- auth.users: the full GoTrue column set (a copy an earlier database carries is extended in place).
|
|
28
31
|
create table if not exists auth.users (id uuid primary key);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Managed by starci app sync (slot app.db.image); do not edit: run "npx starci app sync --write".
|
|
2
|
+
# The image of the db service of a lite app, kept with the stacks (rulings lite-official-postgres-image, lite-pgmq-bus-bullmq-jobs and
|
|
3
|
+
# lite-db-image-in-stacks): the Docker Official postgres image, pinned by digest, plus pgvector from the PGDG repository the image
|
|
4
|
+
# already configures and pgmq, the event bus, from its pinned release source verified by sha256 (pgmq is SQL objects only, so its
|
|
5
|
+
# control and script files are copied and nothing is compiled; it installs into its own schema pgmq). The contrib extensions
|
|
6
|
+
# (pgcrypto, pg_trgm, unaccent, uuid-ossp, btree_gist) ship in the image. The stack's db service, `npm run db:create`,
|
|
7
|
+
# `starci app db drift` and `starci app db baseline --squash` all build this file, with this folder as the build context.
|
|
8
|
+
FROM {{dbBaseImage}}
|
|
9
|
+
ADD --checksum=sha256:{{pgmqSha256}} {{pgmqSource}} /tmp/pgmq.tar.gz
|
|
10
|
+
RUN apt-get update \
|
|
11
|
+
&& apt-get install -y --no-install-recommends {{pgvectorPackage}} \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
13
|
+
&& tar -xzf /tmp/pgmq.tar.gz -C /tmp \
|
|
14
|
+
&& extension="$(pg_config --sharedir)/extension" \
|
|
15
|
+
&& cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/pgmq.control "$extension/" \
|
|
16
|
+
&& cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/sql/pgmq.sql "$extension/pgmq--{{pgmqVersion}}.sql" \
|
|
17
|
+
&& cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/sql/pgmq--*--*.sql "$extension/" \
|
|
18
|
+
&& rm -rf /tmp/pgmq.tar.gz /tmp/pgmq-{{pgmqVersion}}
|
package/templates/app/gitignore
CHANGED
|
@@ -7,7 +7,7 @@ schema.gql
|
|
|
7
7
|
.next/
|
|
8
8
|
next-env.d.ts
|
|
9
9
|
# .starcistacks custody at the app root: modules/schemas/stacks-layout.yaml custody.gitignoreRules, in order. Only the
|
|
10
|
-
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed *.enc are tracked.
|
|
10
|
+
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed members (*.enc.yaml, legacy *.enc) are tracked.
|
|
11
11
|
.starcistacks/**
|
|
12
12
|
!.starcistacks/**/
|
|
13
13
|
!.starcistacks/**/KEYS.md
|
|
@@ -32,6 +32,7 @@ next-env.d.ts
|
|
|
32
32
|
.starcistacks/*/infra/terraform/*.tfstate.*
|
|
33
33
|
.starcistacks/*/infra/terraform/files/
|
|
34
34
|
.starcistacks/*/infra/terraform/*.tar.gz
|
|
35
|
+
!.starcistacks/**/secrets/*.enc.yaml
|
|
35
36
|
!.starcistacks/**/*.enc
|
|
36
37
|
|
|
37
38
|
# Host-local agent credentials and app age identity never enter Git.
|
|
@@ -7,12 +7,13 @@ schema.gql
|
|
|
7
7
|
.next/
|
|
8
8
|
next-env.d.ts
|
|
9
9
|
# .starcistacks custody at the app root: modules/schemas/stacks-layout.yaml custody.gitignoreRules, in order. Only the
|
|
10
|
-
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed *.enc are tracked.
|
|
10
|
+
# declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions, the db image and sealed members (*.enc.yaml, legacy *.enc) are tracked.
|
|
11
11
|
.starcistacks/**
|
|
12
12
|
!.starcistacks/**/
|
|
13
13
|
!.starcistacks/**/KEYS.md
|
|
14
14
|
!.starcistacks/**/.gitkeep
|
|
15
15
|
!.starcistacks/application-stacks.yaml
|
|
16
|
+
!.starcistacks/images/db/Dockerfile
|
|
16
17
|
!.starcistacks/*/README.md
|
|
17
18
|
!.starcistacks/*/environment.json
|
|
18
19
|
!.starcistacks/*/seeds/**
|
|
@@ -32,6 +33,7 @@ next-env.d.ts
|
|
|
32
33
|
.starcistacks/*/infra/terraform/*.tfstate.*
|
|
33
34
|
.starcistacks/*/infra/terraform/files/
|
|
34
35
|
.starcistacks/*/infra/terraform/*.tar.gz
|
|
36
|
+
!.starcistacks/**/secrets/*.enc.yaml
|
|
35
37
|
!.starcistacks/**/*.enc
|
|
36
38
|
|
|
37
39
|
# Host-local agent credentials and app age identity never enter Git.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"db:image": "docker build --tag {{dbImage}} db",
|
|
1
|
+
"db:image": "docker build --tag {{dbImage}} .starcistacks/images/db",
|
|
2
2
|
"db:create": "npm run db:image && docker create --name {{project}}-dev-db -p 127.0.0.1:54322:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -v {{project}}-dev-db-data:/var/lib/postgresql/data {{dbImage}}",
|
|
3
3
|
"db:start": "docker start {{project}}-dev-db",
|
|
4
4
|
"db:stop": "docker stop {{project}}-dev-db",
|
|
@@ -29,7 +29,7 @@ components:
|
|
|
29
29
|
volumes: [db-data]
|
|
30
30
|
image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
31
31
|
compose: infra/swarm/stack.yaml
|
|
32
|
-
purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from db/Dockerfile, migrated by starci app db migrate.
|
|
32
|
+
purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from .starcistacks/images/db/Dockerfile, migrated by starci app db migrate.
|
|
33
33
|
required: true
|
|
34
34
|
ingress:
|
|
35
35
|
role: ingress
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Development stack
|
|
2
2
|
|
|
3
|
-
Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from
|
|
3
|
+
Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile`) starts with `npm run db:start` and takes the migrations with `npm run db:push`; storage is the S3-compatible MinIO the environment names. Every other declared dependency, such as Redis, starts through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model; Compose is an implementation detail. The app services may run in the local Docker Swarm model or through a declared native development profile while dependencies stay in Docker.
|
|
4
4
|
The application image references contain illustrative all-zero digests. Replace them with verified published release digests in the manifest, model and `.starcistacks/dev/infra/swarm/releases/images.lock.yaml` before starting the container model. Seal the local database connection and the integration credentials; a container cannot use the host's loopback address without owner-configured networking. Local source development remains available through the app scripts.
|
|
5
5
|
|
|
6
6
|
The runtime template tracks no encrypted members. Scaffolding generates the six declared members with placeholder values encrypted to this app's own recipient in `.sops.yaml`. Replace those values with `starci app secret set dev` using the matching host age identity in untracked local custody. Existing generated envelopes are decryptable with that app identity and can be updated in place. Each Docker secret contains the corresponding key's real value, and the API receives it through its declared mounted file.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Production stack
|
|
2
2
|
|
|
3
3
|
The tracked model declares Docker Swarm application services and one digest-pinned Caddy ingress.
|
|
4
|
-
The `db` service (the official postgres image plus pgvector, built from
|
|
4
|
+
The `db` service (the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile` and pushed with the release images) supplies PostgreSQL on a named volume; sign-in is the API's own (Passport) and storage is the S3-compatible MinIO the environment names. The app and its dependencies run on Docker Swarm. A Redis service runs only when `hfs.json` `sides.be.backing` declares it for a capability that needs it: one stateful replica with a digest-pinned image, a named volume with a backup entry and its password as a sealed member granted as a Swarm secret (Redis runs `--appendonly yes` with the volume at `/data`). The scaffold declares none; `starci app add queue|job` declares Redis and renders it here.
|
|
5
5
|
The model is a deployment declaration. It contains no owner credentials, real DNS receipt, completed release or restore proof. Public records under `.starcistacks/production/infra/swarm/` explicitly mark those owner-run operations as pending.
|
|
6
6
|
Its application image references contain illustrative all-zero digests; they identify no published image and must be replaced before deployment.
|
|
7
7
|
|
|
@@ -8,7 +8,7 @@ The starter keeps one deployable API and one web app, with the same code structu
|
|
|
8
8
|
|
|
9
9
|
## Stack
|
|
10
10
|
|
|
11
|
-
TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service: the official postgres image plus pgvector, built from
|
|
11
|
+
TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service: the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile`), Passport for Google and password sign-in, S3-compatible storage (MinIO), npm workspaces, and Turbo for front-end tasks.
|
|
12
12
|
|
|
13
13
|
## Repository layout
|
|
14
14
|
|
|
@@ -24,7 +24,7 @@ Declare `ci.github.repository` and `ci.github.defaultBranch` in `.starcistacks/a
|
|
|
24
24
|
|
|
25
25
|
## Development
|
|
26
26
|
|
|
27
|
-
Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the image
|
|
27
|
+
Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the image `.starcistacks/images/db/Dockerfile` builds, on 127.0.0.1:54322), export `DATABASE_URL=postgresql://postgres@127.0.0.1:54322/postgres` and apply `db/bootstrap.sql` and the migrations with `npm run db:push`; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies, the database included, run on Docker Swarm; pgvector owns vector search.
|
|
28
28
|
|
|
29
29
|
```sh
|
|
30
30
|
npm install
|
|
@@ -3,6 +3,8 @@ import type { ParsedEvent } from "@modules/platform/event-bus"
|
|
|
3
3
|
|
|
4
4
|
/** A durable receipt reference; processing reads the provider payload after the intake commits. */
|
|
5
5
|
export interface @@Provider@@DeliveryRecordedPayload {
|
|
6
|
+
/** The business key a consumer's inbox claims: the receipt, once per consumer however often it is delivered. */
|
|
7
|
+
readonly dedupeKey: string
|
|
6
8
|
readonly receiptId: string
|
|
7
9
|
}
|
|
8
10
|
|
|
@@ -20,17 +22,17 @@ export class @@Provider@@DeliveryRecordedEvent extends BaseEvent {
|
|
|
20
22
|
super()
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
/** The receipt identity is stable across
|
|
24
|
-
static create(payload:
|
|
25
|
-
return new @@Provider@@DeliveryRecordedEvent(payload.receiptId, payload)
|
|
25
|
+
/** The receipt identity is stable across redeliveries and distinct from a consumer's inbox source. */
|
|
26
|
+
static create(payload: Omit<@@Provider@@DeliveryRecordedPayload, "dedupeKey">): @@Provider@@DeliveryRecordedEvent {
|
|
27
|
+
return new @@Provider@@DeliveryRecordedEvent(payload.receiptId, { dedupeKey: payload.receiptId, receiptId: payload.receiptId })
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
/** Reads
|
|
30
|
+
/** Reads the event envelope; malformed deliveries never reach a processing consumer. */
|
|
29
31
|
static parse(envelope: unknown): ParsedEvent<@@Provider@@DeliveryRecordedEvent> {
|
|
30
32
|
if (typeof envelope !== "object" || envelope === null || !("eventId" in envelope) || !("payload" in envelope)) return null
|
|
31
33
|
const { eventId, payload } = envelope
|
|
32
34
|
if (typeof eventId !== "string" || !eventId.trim() || typeof payload !== "object" || payload === null || !("receiptId" in payload)) return null
|
|
33
35
|
if (typeof payload.receiptId !== "string" || !payload.receiptId.trim()) return null
|
|
34
|
-
return new @@Provider@@DeliveryRecordedEvent(eventId, { receiptId: payload.receiptId })
|
|
36
|
+
return new @@Provider@@DeliveryRecordedEvent(eventId, { dedupeKey: payload.receiptId, receiptId: payload.receiptId })
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -1,23 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suffix of a consumer's dead-letter queue: a delivery past its consumer's maxDeliveries moves from `<topic>__<consumer>` to
|
|
3
|
+
* `<topic>__<consumer>__dead`, which an operator reads and replays (BE-EVENT-BUS-4).
|
|
4
|
+
*/
|
|
5
|
+
export const DEAD_QUEUE_SUFFIX = "__dead"
|
|
6
|
+
|
|
7
|
+
/** The deliveries a consumer gets before its message moves to the dead-letter queue, when its subscription names none. */
|
|
8
|
+
export const DEFAULT_MAX_DELIVERIES = 5
|
|
9
|
+
|
|
10
|
+
/** What every event payload carries: the business key a consumer's inbox claims, stable across redelivery and replay. */
|
|
11
|
+
export interface EventPayload {
|
|
12
|
+
/** The business identity of the fact (an order id, a receipt id): the consumer claims (consumer, dedupeKey) once. */
|
|
13
|
+
readonly dedupeKey: string
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
/** The base of each typed event in modules/events: its stable identity, name and payload. */
|
|
2
17
|
export abstract class BaseEvent {
|
|
3
|
-
/**
|
|
18
|
+
/** The stable identity of this event, carried for logs and tracing. */
|
|
4
19
|
abstract readonly eventId: string
|
|
5
|
-
/** The event name listed by the service's vendored event contract. */
|
|
20
|
+
/** The event name listed by the service's vendored event contract; it is the pgmq topic the event is sent to. */
|
|
6
21
|
abstract readonly eventName: string
|
|
7
|
-
/** The fact that a reactor
|
|
8
|
-
abstract readonly payload:
|
|
22
|
+
/** The fact that a reactor processes after commit, with its business dedupe key. */
|
|
23
|
+
abstract readonly payload: EventPayload
|
|
9
24
|
}
|
|
10
25
|
|
|
11
26
|
/** Parsing either returns the event or refuses a mismatched envelope. */
|
|
12
27
|
export type ParsedEvent<Event extends BaseEvent> = Event | null
|
|
13
28
|
|
|
14
|
-
/** The class side of a typed event, shared by publishers and
|
|
29
|
+
/** The class side of a typed event, shared by publishers and consumers. */
|
|
15
30
|
export interface EventClass<Event extends BaseEvent> {
|
|
16
31
|
/** The contract's event name. */
|
|
17
32
|
readonly eventName: string
|
|
18
33
|
/** The contract's payload version. */
|
|
19
34
|
readonly version: number
|
|
20
|
-
/** Reads the event from its
|
|
35
|
+
/** Reads the event from its envelope. */
|
|
21
36
|
parse(envelope: unknown): ParsedEvent<Event>
|
|
22
37
|
}
|
|
23
38
|
|
|
@@ -25,21 +40,25 @@ export interface EventClass<Event extends BaseEvent> {
|
|
|
25
40
|
export interface EventDelivery<Event extends BaseEvent> {
|
|
26
41
|
/** The request id carried explicitly from intake to delivery. */
|
|
27
42
|
readonly correlationId: string
|
|
28
|
-
/** The
|
|
43
|
+
/** The stable identity of the event. */
|
|
29
44
|
readonly eventId: string
|
|
45
|
+
/** The consumer's queue, `<topic>__<consumer>`: the first half of the inbox claim. */
|
|
46
|
+
readonly consumer: string
|
|
47
|
+
/** The payload's business key: the second half of the inbox claim. */
|
|
48
|
+
readonly dedupeKey: string
|
|
30
49
|
/** The typed event. */
|
|
31
50
|
readonly event: Event
|
|
32
|
-
/** The number of
|
|
51
|
+
/** The number of deliveries, including this one. */
|
|
33
52
|
readonly attempt: number
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
/** Explicit request context carried independently of the business payload. */
|
|
37
56
|
export interface EventMetadata {
|
|
38
|
-
/** The originating request or promoted
|
|
57
|
+
/** The originating request or promoted event identity. */
|
|
39
58
|
readonly correlationId: string
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
/** The typed event's
|
|
61
|
+
/** The typed event's envelope, sent once to its topic in the publisher's transaction and routed to every bound consumer queue. */
|
|
43
62
|
export interface EventEnvelope {
|
|
44
63
|
/** The originating request context, independent of the business payload. */
|
|
45
64
|
readonly metadata: EventMetadata
|
|
@@ -48,5 +67,33 @@ export interface EventEnvelope {
|
|
|
48
67
|
/** The contract's event name. */
|
|
49
68
|
readonly eventName: string
|
|
50
69
|
/** The data of the fact. */
|
|
51
|
-
readonly payload:
|
|
70
|
+
readonly payload: EventPayload
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One message of a consumer's queue, as event_bus_read returns it. */
|
|
74
|
+
export interface EventMessage {
|
|
75
|
+
/** The pgmq message id within its queue. */
|
|
76
|
+
readonly msgId: string
|
|
77
|
+
/** How many times the message has been read, including this read. */
|
|
78
|
+
readonly readCt: number
|
|
79
|
+
/** The envelope as the publisher sent it; untrusted until parsed. */
|
|
80
|
+
readonly message: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What a reactor's subscription may tune; every field is a literal of the subscription. */
|
|
84
|
+
export interface EventSubscriptionOptions {
|
|
85
|
+
/** The deliveries before the message moves to the dead-letter queue. Default DEFAULT_MAX_DELIVERIES. */
|
|
86
|
+
readonly maxDeliveries?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A delivery a consumer gave up on, as its dead-letter queue holds it. */
|
|
90
|
+
export interface DeadLetter {
|
|
91
|
+
/** The message id in the dead-letter queue: what requeue takes. */
|
|
92
|
+
readonly id: string
|
|
93
|
+
/** Why it was moved: its deliveries ran out, or its envelope was malformed. */
|
|
94
|
+
readonly reason: string
|
|
95
|
+
/** The deliveries it had. */
|
|
96
|
+
readonly readCount: number
|
|
97
|
+
/** The envelope as it was read. */
|
|
98
|
+
readonly message: unknown
|
|
52
99
|
}
|