@starci/hfs 4.11.12 → 4.11.15
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 +31 -0
- package/README.md +1 -1
- package/db/migrate.mjs +1 -1
- package/db/postgres.mjs +38 -3
- package/package.json +1 -1
- package/release/index.mjs +6 -5
- package/release/lint-baseline.mjs +123 -0
- package/release/urgent.mjs +19 -4
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
- package/runtime/scripts/api/sops/lib.mjs +25 -3
- package/runtime/scripts/api/sops/seal.mjs +4 -3
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
- package/runtime/scripts/hfs/secret-init.mjs +15 -0
- package/runtime/scripts/hfs/secret-sops.mjs +65 -33
- package/runtime/scripts/lib/sops-envelope.mjs +36 -11
- package/scaffold/add-compose.mjs +3 -0
- package/scaffold/add-inbox-lite.mjs +45 -23
- package/scaffold/lite-exports.mjs +13 -0
- package/src/main.mjs +5 -5
- package/stack/age-install.mjs +10 -2
- package/stack/cli.mjs +5 -3
- package/stack/context.mjs +2 -2
- package/stack/deploy.mjs +6 -2
- package/stack/remote.mjs +29 -8
- package/stack/sealed.mjs +15 -11
- package/stack/server-key.mjs +31 -7
- package/stack/urgent-floor.mjs +101 -7
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
- package/templates/be/inbox/entities.index.ts.tpl +4 -0
- package/templates/be/inbox/entity.ts.tpl +29 -0
- package/templates/be/inbox/index.ts.tpl +1 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
- package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
- package/templates/be/inbox/service.spec.ts.tpl +39 -15
- package/templates/be/inbox/service.ts.tpl +13 -13
- package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
- package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
- package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
- package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
- package/templates/be/inbox/database-client.ts.tpl +0 -45
package/stack/context.mjs
CHANGED
|
@@ -57,7 +57,7 @@ const realSleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
|
|
|
57
57
|
* stdout, stderr text writers
|
|
58
58
|
* fetch the HTTP seam of verify (url, init) -> Response
|
|
59
59
|
* pipe (source, sink) -> Promise<{ source, sink, stderr }>: one process's stdout streamed into another's stdin (realPipe)
|
|
60
|
-
* sops the secret seam (decrypt(file, format) -> map; decryptBinary(file) -> text); `io.sops`, else on first use `io.openSops()` or the app's own sops/age tools
|
|
60
|
+
* sops the secret seam (decrypt(file, format) -> map; decryptBinary(file) -> text); `io.sops`, else on first use `io.openSops()` or the app's own sops/age tools with the age identity decryptionIdentity finds (`io.hostIdentity` the harness CLI's secret.env)
|
|
61
61
|
*/
|
|
62
62
|
export function stackContext(io = {}) {
|
|
63
63
|
const cwd = io.cwd ?? process.cwd();
|
|
@@ -78,7 +78,7 @@ export function stackContext(io = {}) {
|
|
|
78
78
|
pipe: io.pipe ?? realPipe,
|
|
79
79
|
/** The secret seam: the app's own sops/age tools, created on first use so verbs that open no custody never need them. */
|
|
80
80
|
get sops() {
|
|
81
|
-
sops ??= io.openSops?.() ?? defaultSops(env, cwd);
|
|
81
|
+
sops ??= io.openSops?.() ?? defaultSops(env, cwd, io.platform ?? process.platform, undefined, io.hostIdentity ?? null);
|
|
82
82
|
return sops;
|
|
83
83
|
},
|
|
84
84
|
});
|
package/stack/deploy.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
|
|
4
4
|
// Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
|
|
5
5
|
// An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
|
|
6
|
-
// backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails
|
|
6
|
+
// backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails; its
|
|
7
|
+
// first start (`firstStart`) proves the database empty and migrates it inside its own container, a `first-start` step between edge and converge.
|
|
7
8
|
//
|
|
8
9
|
// `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
|
|
9
10
|
// module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
|
|
@@ -197,6 +198,9 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
|
|
|
197
198
|
const failed = await runSteps([
|
|
198
199
|
['deploy', () => stackDeploy(ctx, config, path.join(out, 'stack.yaml'))],
|
|
199
200
|
['edge', async () => d.reconcileEdge(ctx, config, path.join(out, 'stack.yaml'))],
|
|
201
|
+
// The first start of a self-hosted database (urgent-floor.mjs): its schema is bootstrapped and migrated once the database runs, before the
|
|
202
|
+
// services that need it are waited for.
|
|
203
|
+
...(options.firstStartStep ? [['first-start', options.firstStartStep]] : []),
|
|
200
204
|
['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds })],
|
|
201
205
|
...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
|
|
202
206
|
['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
|
|
@@ -245,7 +249,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
245
249
|
};
|
|
246
250
|
if (options.urgent === true) {
|
|
247
251
|
return await urgentDeploy(ctx, config, options, {
|
|
248
|
-
render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: () => deployExecute(ctx, config, options, d, out, cleanups), sha256: fileSha256,
|
|
252
|
+
render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: (extra = {}) => deployExecute(ctx, config, { ...options, ...extra }, d, out, cleanups), sha256: fileSha256,
|
|
249
253
|
}, d.floor);
|
|
250
254
|
}
|
|
251
255
|
if (options.execute !== true) return await dryRun();
|
package/stack/remote.mjs
CHANGED
|
@@ -64,24 +64,36 @@ function sshCommandArgv(sshCmd) {
|
|
|
64
64
|
return parts;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* The key each sealed SSH member of `deploy.remote.sealed` holds (contexts/project/stacks.md PROJECT-STACKS-5): the one owner of
|
|
69
|
+
* these names. `sshKey` names the member whose `privateKey` is the PEM private key, `knownHosts` the member whose `knownHosts` is
|
|
70
|
+
* the pinned known_hosts text.
|
|
71
|
+
*/
|
|
72
|
+
export const SEALED_SSH_KEYS = Object.freeze({ sshKey: 'privateKey', knownHosts: 'knownHosts' });
|
|
73
|
+
|
|
74
|
+
/** A legacy binary member is the file itself; a JSON object around it ({ data | <key> }, the re-sealed shape of `starci app secret rename`) is unwrapped. */
|
|
75
|
+
function unwrap(text, key) {
|
|
69
76
|
const body = lfOnly(text);
|
|
70
77
|
const trimmed = body.trim();
|
|
71
78
|
if (!trimmed.startsWith('{')) return body;
|
|
72
79
|
let parsed;
|
|
73
80
|
try { parsed = JSON.parse(trimmed); } catch { return body; }
|
|
74
|
-
for (const name of ['data',
|
|
81
|
+
for (const name of ['data', key]) if (typeof parsed[name] === 'string') return lfOnly(parsed[name]);
|
|
75
82
|
return body;
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/** The sops format a sealed member is read as: a `.enc.yaml` member is YAML, a legacy `.enc` one dotenv. */
|
|
86
|
+
const memberFormat = (file) => (isLegacyName(file) ? 'dotenv' : 'yaml');
|
|
87
|
+
|
|
88
|
+
/** Opens one member through the sops seam; a failure is refused naming the member and the seam's own reason (format, recipients, identity). */
|
|
78
89
|
function openMember(slug, read) {
|
|
79
90
|
try { return read(); } catch (error) { throw refusal('STACK_REMOTE_SEALED', `cannot open the sealed member ${slug}: ${error.message}`); }
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
function opsAccount(ctx, config) {
|
|
83
94
|
const slug = config.remote.sealed.ops;
|
|
84
|
-
const
|
|
95
|
+
const file = memberFile(config.secretsDir, slug);
|
|
96
|
+
const map = openMember(slug, () => ctx.sops.decrypt(file, memberFormat(file)));
|
|
85
97
|
for (const name of ['VPS_HOST', 'VPS_USER']) {
|
|
86
98
|
const value = map[name];
|
|
87
99
|
if (typeof value !== 'string' || !ACCOUNT.test(value) || value.startsWith('-')) throw refusal('STACK_REMOTE_OPS', `${slug} must hold ${name} (letters, digits, . _ @ -)`);
|
|
@@ -89,8 +101,17 @@ function opsAccount(ctx, config) {
|
|
|
89
101
|
return `${map.VPS_USER}@${map.VPS_HOST}`;
|
|
90
102
|
}
|
|
91
103
|
|
|
92
|
-
|
|
93
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The text a sealed SSH member holds under `key` (SEALED_SSH_KEYS): a `.enc.yaml` member is read as YAML and the key taken from
|
|
106
|
+
* its map; a legacy `.enc` member is the sops binary or JSON envelope it was sealed as.
|
|
107
|
+
*/
|
|
108
|
+
function sshMember(ctx, config, slug, key) {
|
|
109
|
+
const file = memberFile(config.secretsDir, slug);
|
|
110
|
+
if (isLegacyName(file)) return unwrap(openMember(slug, () => ctx.sops.decryptBinary(file)), key);
|
|
111
|
+
const map = openMember(slug, () => ctx.sops.decrypt(file, 'yaml'));
|
|
112
|
+
if (typeof map[key] !== 'string' || map[key] === '')
|
|
113
|
+
throw refusal('STACK_REMOTE_SEALED', `the sealed member ${slug} holds no ${key} (it holds ${Object.keys(map).join(', ') || 'no key'}): seal it with starci app secret set ${config.env ?? '<env>'} ${slug} ${key} --from-file ${key}=<file>`);
|
|
114
|
+
return lfOnly(map[key]);
|
|
94
115
|
}
|
|
95
116
|
|
|
96
117
|
const withNewline = (text) => `${text.trim()}\n`;
|
|
@@ -107,9 +128,9 @@ export function openSealedSession(ctx, config) {
|
|
|
107
128
|
const account = opsAccount(ctx, config);
|
|
108
129
|
if (config.nativeTarget && account !== `${config.nativeTarget.user}@${config.nativeTarget.host}`)
|
|
109
130
|
throw refusal('STACK_REMOTE_OPS', 'the sealed SSH account differs from the accepted native target');
|
|
110
|
-
const key =
|
|
131
|
+
const key = sshMember(ctx, config, sealed.sshKey, SEALED_SSH_KEYS.sshKey);
|
|
111
132
|
if (!key.includes('PRIVATE KEY')) throw refusal('STACK_REMOTE_KEY', `${sealed.sshKey} does not hold a PEM private key`);
|
|
112
|
-
const known =
|
|
133
|
+
const known = sshMember(ctx, config, sealed.knownHosts, SEALED_SSH_KEYS.knownHosts).trim();
|
|
113
134
|
assertStackAccount(config.nativeTarget, account, known);
|
|
114
135
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-ssh-'));
|
|
115
136
|
const keyFile = path.join(dir, 'id');
|
package/stack/sealed.mjs
CHANGED
|
@@ -5,7 +5,6 @@ import { createHash, createHmac } from 'node:crypto';
|
|
|
5
5
|
import { memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { secretRoots } from '../runtime/scripts/hfs/secret-files.mjs';
|
|
9
8
|
import { readMap } from '../runtime/scripts/hfs/secret-store.mjs';
|
|
10
9
|
import { refusal } from './errors.mjs';
|
|
11
10
|
|
|
@@ -52,27 +51,32 @@ function secretRuntimeName(stack, key, value, versionKey) {
|
|
|
52
51
|
return name;
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
const
|
|
56
|
-
const AGE_HINT = 'load the age identity into SOPS_AGE_KEY (the secret.env of the owner machine, or the CI secret) and retry';
|
|
54
|
+
const AGE_HINT = 'add SOPS_AGE_KEY to .claude/secret.env of the app or the harness secret.env of the owner machine (the CI secret in a workflow) and retry';
|
|
57
55
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
/**
|
|
57
|
+
* True when the sops seam found an age identity (secret-init.mjs decryptionIdentity, the one lookup every decrypt uses). A seam
|
|
58
|
+
* that reports no `identity` (a spec double) decides for itself.
|
|
59
|
+
*/
|
|
60
|
+
export function hasAgeIdentity(ctx) {
|
|
61
|
+
const { identity } = ctx.sops;
|
|
62
|
+
return identity === undefined || identity.source !== null;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/** Refuses up front, before any work, when sealed custody must be opened and no age identity is reachable. */
|
|
65
66
|
export function requireAgeIdentity(ctx, config, purpose) {
|
|
66
|
-
if (!hasAgeIdentity(ctx
|
|
67
|
+
if (!hasAgeIdentity(ctx)) throw refusal('STACK_AGE_IDENTITY_MISSING', `${purpose} needs the sealed members, and no age identity is available (looked in ${ctx.sops.identity.looked.join(', ')}): ${AGE_HINT}`);
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Opens one sealed member (`<secretsDir>/<member>.enc.yaml`, or a legacy `.enc`) into a { KEY: value } map. A failure is refused
|
|
72
|
+
* naming the member and the seam's reason (its format, recipients and the identity tried); no value.
|
|
73
|
+
*/
|
|
70
74
|
export function openMember(ctx, config, member) {
|
|
71
75
|
try {
|
|
72
76
|
const map = readMap(memberFile(config.secretsDir, member), ctx.sops);
|
|
73
77
|
return Object.fromEntries(Object.entries(map).map(([key, value]) => [key, typeof value === 'string' && !value.includes(String.fromCodePoint(0)) ? lfText(value) : value]));
|
|
74
|
-
} catch {
|
|
75
|
-
throw refusal('STACK_SECRET_MEMBER_UNREADABLE', `the sealed member ${member} cannot be opened${hasAgeIdentity(ctx
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw refusal('STACK_SECRET_MEMBER_UNREADABLE', `the sealed member ${member} cannot be opened: ${error.message}${hasAgeIdentity(ctx) ? '' : `; ${AGE_HINT}`}`);
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
package/stack/server-key.mjs
CHANGED
|
@@ -42,6 +42,22 @@ const RECIPIENT = /^age1[a-z0-9]+$/u;
|
|
|
42
42
|
/** A command run as root: directly when the deploy user is root, else over passwordless sudo, the script read from stdin. */
|
|
43
43
|
const AS_ROOT = 'if [ "$(id -u)" -eq 0 ]; then sh -s; else sudo -n sh -s; fi';
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The directories, after root's PATH, where age-keygen is looked for: sudo's secure_path (AlmaLinux 10, RHEL) leaves out
|
|
47
|
+
* /usr/local/bin, where the static installer puts it, so the presence check and every call resolve it the same way, as root.
|
|
48
|
+
*/
|
|
49
|
+
export const AGE_KEYGEN_DIRS = ['/usr/local/bin', '/usr/bin', '/bin'];
|
|
50
|
+
|
|
51
|
+
/** The shell lines that set k to age-keygen's absolute path as root sees it, or exit 127 naming it missing. Every root script holds them. */
|
|
52
|
+
export const RESOLVE_AGE_KEYGEN = [
|
|
53
|
+
'k="$(command -v age-keygen 2>/dev/null || true)"',
|
|
54
|
+
`for d in ${AGE_KEYGEN_DIRS.join(' ')}; do if [ -z "$k" ] && [ -x "$d/age-keygen" ]; then k="$d/age-keygen"; fi; done`,
|
|
55
|
+
'if [ -z "$k" ]; then echo "age-keygen is not installed" >&2; exit 127; fi',
|
|
56
|
+
].join('\n');
|
|
57
|
+
|
|
58
|
+
/** The root script that only resolves age-keygen and prints its path: the presence check, run exactly as the calls run. */
|
|
59
|
+
export const ageKeygenScript = () => ['set -eu', RESOLVE_AGE_KEYGEN, String.raw`printf '%s\n' "$k"`].join('\n');
|
|
60
|
+
|
|
45
61
|
/**
|
|
46
62
|
* The GitHub Environment secret that holds an environment's server identity. It carries the name the deploy workflows read
|
|
47
63
|
* (secrets.SOPS_AGE_KEY), scoped to the Environment <env>: for a job bound to <env> it overrides a repository secret of that name.
|
|
@@ -54,24 +70,25 @@ export function ensureIdentityScript(file) {
|
|
|
54
70
|
return [
|
|
55
71
|
'set -eu',
|
|
56
72
|
'umask 077',
|
|
73
|
+
RESOLVE_AGE_KEYGEN,
|
|
57
74
|
`f=${quoted}`,
|
|
58
75
|
'if [ -e "$f" ] || [ -L "$f" ]; then',
|
|
59
76
|
' if [ -L "$f" ] || [ ! -f "$f" ]; then echo "the identity path is not an ordinary file" >&2; exit 4; fi',
|
|
60
77
|
' echo "identity kept" >&2',
|
|
61
78
|
'else',
|
|
62
79
|
' mkdir -p "$(dirname "$f")"',
|
|
63
|
-
'
|
|
80
|
+
' "$k" -o "$f" >/dev/null 2>&1',
|
|
64
81
|
' echo "identity created" >&2',
|
|
65
82
|
'fi',
|
|
66
83
|
'chown root:root "$f"',
|
|
67
84
|
'chmod 600 "$f"',
|
|
68
|
-
'
|
|
85
|
+
'"$k" -y "$f"',
|
|
69
86
|
].join('\n');
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
/** The shell that prints only the public key of `file`, or exits 5 when there is none. */
|
|
73
90
|
export function publicKeyScript(file) {
|
|
74
|
-
return ['set -eu', `f=${shellQuote(file)}`, '[ -f "$f" ] || exit 5', '
|
|
91
|
+
return ['set -eu', RESOLVE_AGE_KEYGEN, `f=${shellQuote(file)}`, '[ -f "$f" ] || exit 5', '"$k" -y "$f"'].join('\n');
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
/** The shell that swaps the staged `<file>.next` in, keeping the old identity as `retired`, then prints only the new public key. */
|
|
@@ -79,6 +96,7 @@ export function swapIdentityScript(file, retired) {
|
|
|
79
96
|
return [
|
|
80
97
|
'set -eu',
|
|
81
98
|
'umask 077',
|
|
99
|
+
RESOLVE_AGE_KEYGEN,
|
|
82
100
|
`f=${shellQuote(file)}`,
|
|
83
101
|
`r=${shellQuote(retired)}`,
|
|
84
102
|
'n="$f.next"',
|
|
@@ -87,7 +105,7 @@ export function swapIdentityScript(file, retired) {
|
|
|
87
105
|
'mv "$f" "$r"',
|
|
88
106
|
'mv "$n" "$f"',
|
|
89
107
|
'chmod 600 "$f" "$r"',
|
|
90
|
-
'
|
|
108
|
+
'"$k" -y "$f"',
|
|
91
109
|
].join('\n');
|
|
92
110
|
}
|
|
93
111
|
|
|
@@ -112,14 +130,20 @@ function declaredRemote(repoRoot, env) {
|
|
|
112
130
|
return remote;
|
|
113
131
|
}
|
|
114
132
|
|
|
115
|
-
/**
|
|
133
|
+
/** True when root resolves age-keygen the way every server-key script does (ageKeygenScript). */
|
|
134
|
+
const hasKeygen = (run) => asRoot(run, ageKeygenScript()).status === 0;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Ensures age-keygen on the server, checked as root exactly as it is called: stack prepare's installer owns the install (with
|
|
138
|
+
* --execute); without it the exact commands.
|
|
139
|
+
*/
|
|
116
140
|
function ensureTool(run, execute, lines) {
|
|
117
|
-
if (run
|
|
141
|
+
if (hasKeygen(run)) return true;
|
|
118
142
|
const install = `install age on the server (one of: ${Object.values(AGE_INSTALL).join('; ')})`;
|
|
119
143
|
if (!execute) { lines.push(`dry run: age-keygen is missing on the server; --execute installs it with stack prepare's installer, or ${install}`); return false; }
|
|
120
144
|
const problems = [];
|
|
121
145
|
installAge(run, { note: (text) => lines.push(text), bad: (text) => problems.push(text) });
|
|
122
|
-
if (problems.length || run
|
|
146
|
+
if (problems.length || !hasKeygen(run))
|
|
123
147
|
throw refusal('STACK_SERVER_KEY_NO_AGE', `age-keygen is not on the server and could not be installed (${problems.join('; ') || 'still missing'}): ${install}`);
|
|
124
148
|
return true;
|
|
125
149
|
}
|
package/stack/urgent-floor.mjs
CHANGED
|
@@ -10,13 +10,23 @@
|
|
|
10
10
|
// deploy the release with its readiness verify (deploy.verify); red rolls back by itself STACK_URGENT_RED_ROLLED_BACK, STACK_URGENT_ROLLBACK_FAILED,
|
|
11
11
|
// STACK_URGENT_DEPLOY_NOT_STARTED (red before the stack deploy)
|
|
12
12
|
// The public-host checks are never skipped (STACK_URGENT_SKIP_PUBLIC). A rollback cannot undo the migrations: the backup above is their way back.
|
|
13
|
+
// The first start (`--first-start --db-service <service>`, owner ruling 2026-10-29) is the floor of a self-hosted database the release itself
|
|
14
|
+
// starts: there is nothing to back up and nothing reaches it before the stack runs it, so instead of backup, decrypt and migrate before the deploy
|
|
15
|
+
// the release deploys and, between edge and converge, the floor waits for the database service's container to accept connections, proves the
|
|
16
|
+
// database empty (no migration history, no table in public) and runs migrate preview then execute inside that container (db/bootstrap.sql first,
|
|
17
|
+
// as the --db-url-env role). Every other step holds: record, rollback target, readiness verify, automatic rollback. Each failure is its own code:
|
|
18
|
+
// STACK_FIRST_START_USAGE (no --db-service), STACK_FIRST_START_NO_DATABASE (no container accepted connections in time),
|
|
19
|
+
// STACK_FIRST_START_NOT_EMPTY (a history or a public table: use the plain urgent floor), STACK_FIRST_START_MIGRATE_RED (preview or execute red);
|
|
20
|
+
// each rolls the release back to the previous one; the migrations a red first start applied stay in a database no release used before.
|
|
13
21
|
// `deps` are the collaborators (backup, decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
|
|
14
22
|
import fs from 'node:fs';
|
|
15
23
|
import os from 'node:os';
|
|
16
24
|
import path from 'node:path';
|
|
17
25
|
import { readDeclaration } from '../release/index.mjs';
|
|
18
26
|
import { releaseDir } from '../release/gate.mjs';
|
|
27
|
+
import { DEFAULT_DB_URL_ENV, dbTarget, inContainer, queryRows } from '../db/postgres.mjs';
|
|
19
28
|
import { readUrgent } from '../release/records.mjs';
|
|
29
|
+
import { lines as splitLines, tool } from './context.mjs';
|
|
20
30
|
import { refusal } from './errors.mjs';
|
|
21
31
|
|
|
22
32
|
const AGE_HEADER = 'age-encryption.org/v1\n';
|
|
@@ -54,7 +64,28 @@ async function defaultMigrate(ctx, { target, execute }) {
|
|
|
54
64
|
return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute });
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
|
|
67
|
+
/** How long a first start waits for the database service's container to accept connections. */
|
|
68
|
+
export const FIRST_START_WAIT_SECONDS = 180;
|
|
69
|
+
|
|
70
|
+
/** The running container of `service` whose server accepts connections (pg_isready), polled until `seconds` pass; undefined when none did. */
|
|
71
|
+
async function defaultDbContainer(ctx, config, { service, seconds }) {
|
|
72
|
+
const deadline = ctx.now() + seconds * 1000;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
|
|
75
|
+
const id = splitLines(found.stdout)[0];
|
|
76
|
+
if (id && tool(ctx, 'docker', ['exec', id, 'pg_isready', '-q']).status === 0) return id;
|
|
77
|
+
if (ctx.now() >= deadline) return undefined;
|
|
78
|
+
await new Promise((resolve) => { setTimeout(resolve, 3000); });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether the target database is empty: no migration history table and no table in public. */
|
|
83
|
+
async function defaultEmptiness(ctx, target) {
|
|
84
|
+
const [row] = queryRows(ctx, target, "select to_regclass('supabase_migrations.schema_migrations') is not null as history, (select count(*)::int from pg_tables where schemaname = 'public') as public_tables", { withhold: true });
|
|
85
|
+
return { empty: row?.history === false && row?.public_tables === 0, history: row?.history === true, publicTables: Number(row?.public_tables ?? -1) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate, dbContainer: defaultDbContainer, emptiness: defaultEmptiness });
|
|
58
89
|
|
|
59
90
|
/** A refused floor step: the lines so far and the code; the CLI prints both and exits 2. */
|
|
60
91
|
const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
|
|
@@ -83,6 +114,11 @@ function preconditions(ctx, config, options) {
|
|
|
83
114
|
if (!record) throw refusal('STACK_URGENT_NO_RECORD', `${options.sourceSha} has no urgent record: push it with starci app release push --urgent --reason "<text>" --execute first`);
|
|
84
115
|
if (record.pushed !== true) throw refusal('STACK_URGENT_NOT_PUSHED', `the urgent release ${record.tag} of ${options.sourceSha.slice(0, 10)} was never pushed`);
|
|
85
116
|
if (!readDeclaration(config.repoRoot ?? ctx.cwd).database) throw refusal('STACK_URGENT_NO_DATABASE', 'the floor backs up and migrates the database the `database:` block declares, and this app declares none');
|
|
117
|
+
if (options.firstStart === true) {
|
|
118
|
+
// A first start backs nothing up, so it needs no identity for a test decrypt; it needs the service its database runs as.
|
|
119
|
+
if (!options.dbService) throw refusal('STACK_FIRST_START_USAGE', '--first-start names the stack service of the database it bootstraps: add --db-service <service>');
|
|
120
|
+
return { record, identity: null };
|
|
121
|
+
}
|
|
86
122
|
const identity = ctx.env.SOPS_AGE_KEY_FILE;
|
|
87
123
|
if (!identity || !fs.existsSync(identity)) throw refusal('STACK_URGENT_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
|
|
88
124
|
return { record, identity };
|
|
@@ -122,6 +158,57 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
|
|
|
122
158
|
return null;
|
|
123
159
|
}
|
|
124
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The `first-start` step of a first start's deploy (between edge and converge): the database container, the proof it is empty, migrate preview
|
|
163
|
+
* then execute inside it. Returns { ok, lines, code } where code names the failure.
|
|
164
|
+
*/
|
|
165
|
+
async function firstStartStep(ctx, config, options, f) {
|
|
166
|
+
const service = `${config.stack}_${options.dbService}`;
|
|
167
|
+
const container = await f.dbContainer(ctx, config, { service: options.dbService, seconds: FIRST_START_WAIT_SECONDS });
|
|
168
|
+
if (!container) return { ok: false, code: 'STACK_FIRST_START_NO_DATABASE', lines: [`first start: no running container of ${service} accepted connections within ${FIRST_START_WAIT_SECONDS}s`] };
|
|
169
|
+
const target = inContainer(dbTarget(ctx, options.dbUrlEnv ?? DEFAULT_DB_URL_ENV), container);
|
|
170
|
+
const state = await f.emptiness(ctx, target);
|
|
171
|
+
if (!state.empty) {
|
|
172
|
+
const found = state.history ? 'a migration history' : `${state.publicTables < 0 ? 'an unreadable count of' : state.publicTables} table(s) in public`;
|
|
173
|
+
return { ok: false, code: 'STACK_FIRST_START_NOT_EMPTY', lines: [`first start refused: the database of ${service} holds ${found}; a database with data takes the plain urgent floor (backup first)`] };
|
|
174
|
+
}
|
|
175
|
+
const lines = [`first start: the database of ${service} is empty (no migration history, no table in public): nothing to back up`];
|
|
176
|
+
for (const execute of [false, true]) {
|
|
177
|
+
let result;
|
|
178
|
+
try { result = await f.migrate(ctx, { target: { target }, execute }); } catch (error) { result = { ok: false, lines: [String(error?.message ?? error)] }; }
|
|
179
|
+
lines.push(...(result.lines ?? []).map((line) => `first start: migrate${execute ? '' : ' preview'}: ${line}`));
|
|
180
|
+
if (!result.ok) return { ok: false, code: 'STACK_FIRST_START_MIGRATE_RED', lines: [...lines, `first start: the migration${execute ? '' : ' preview'} is red`] };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, lines };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A first start: the rollback target, then the deploy whose `first-start` step bootstraps and migrates the empty database. Returns the dry-run
|
|
187
|
+
* plan or a refused result, or { deployed } for the verdicts it shares with the plain floor.
|
|
188
|
+
*/
|
|
189
|
+
async function firstStartDeploy(ctx, config, options, run, f, lines) {
|
|
190
|
+
if (options.execute !== true) {
|
|
191
|
+
const plan = await run.dryRun();
|
|
192
|
+
return { ...plan, lines: [...lines, `floor (never skipped), first start of ${config.stack}_${options.dbService}: rollback target, deploy, then before converge: wait for the database, prove it empty, migrate preview then execute inside its container; readiness verify and automatic rollback`, ...plan.lines] };
|
|
193
|
+
}
|
|
194
|
+
const { releaseId } = await run.render();
|
|
195
|
+
lines.push(`floor: release ${releaseId} rendered`);
|
|
196
|
+
const store = await run.openStore();
|
|
197
|
+
if (!(await store.current())) return refused(lines, 'STACK_URGENT_NO_ROLLBACK_TARGET', 'no accepted previous release is stored on the manager, so a red first start could not roll back; nothing was deployed');
|
|
198
|
+
let code = null;
|
|
199
|
+
const deployed = await run.deployExecute({
|
|
200
|
+
firstStartStep: async () => {
|
|
201
|
+
const step = await firstStartStep(ctx, config, options, f);
|
|
202
|
+
code = step.code ?? null;
|
|
203
|
+
return step;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
if (!deployed.ok && code) {
|
|
207
|
+
return refused([...lines, ...deployed.lines], code, `the first start failed; ${deployed.data?.rolledBack ? `the release was rolled back to ${deployed.data.previous}` : 'the rollback did not converge: follow the stack runbook, Rollback'}`, deployed.data);
|
|
208
|
+
}
|
|
209
|
+
return { deployed };
|
|
210
|
+
}
|
|
211
|
+
|
|
125
212
|
/**
|
|
126
213
|
* An urgent deploy: the floor's preconditions, then (with execute) its guard steps, then `deployExecute` (the release, its readiness verify and the
|
|
127
214
|
* automatic rollback). `run` = { render(), openStore(), deployExecute(), dryRun(), sha256 }. Returns { ok, lines, data } or a refused result whose
|
|
@@ -131,13 +218,20 @@ export async function urgentDeploy(ctx, config, options, run, deps = {}) {
|
|
|
131
218
|
const f = { ...FLOOR_DEPS, ...deps };
|
|
132
219
|
const { record, identity } = preconditions(ctx, config, options);
|
|
133
220
|
const lines = [`urgent ${record.tag} (${options.sourceSha.slice(0, 10)}) by ${record.role}: ${record.reason}`];
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
221
|
+
let deployed;
|
|
222
|
+
if (options.firstStart === true) {
|
|
223
|
+
const first = await firstStartDeploy(ctx, config, options, run, f, lines);
|
|
224
|
+
if (!first.deployed) return { ...first, data: { ...first.data, urgent: record.tag, firstStart: true } };
|
|
225
|
+
deployed = first.deployed;
|
|
226
|
+
} else {
|
|
227
|
+
if (options.execute !== true) {
|
|
228
|
+
const plan = await run.dryRun();
|
|
229
|
+
return { ...plan, lines: [...lines, 'floor (never skipped): fresh encrypted database backup, receipt verify, test decrypt, rollback target, migrate preview then execute, deploy with readiness verify and automatic rollback', ...plan.lines] };
|
|
230
|
+
}
|
|
231
|
+
const stop = await guard(ctx, config, options, { f, run, identity, lines });
|
|
232
|
+
if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
|
|
233
|
+
deployed = await run.deployExecute();
|
|
137
234
|
}
|
|
138
|
-
const stop = await guard(ctx, config, options, { f, run, identity, lines });
|
|
139
|
-
if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
|
|
140
|
-
const deployed = await run.deployExecute();
|
|
141
235
|
const all = [...lines, ...deployed.lines];
|
|
142
236
|
if (deployed.ok) return { ...deployed, lines: all, data: { ...deployed.data, urgent: record.tag } };
|
|
143
237
|
const data = { ...deployed.data, urgent: record.tag };
|
|
@@ -53,6 +53,6 @@ jobs:
|
|
|
53
53
|
printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
|
|
54
54
|
printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
|
|
55
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"'
|
|
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 '"'"'PATH="$PATH:/usr/local/bin"; 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
57
|
printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
|
|
58
58
|
-o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
|
|
@@ -53,6 +53,6 @@ jobs:
|
|
|
53
53
|
printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
|
|
54
54
|
printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
|
|
55
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"'
|
|
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 '"'"'PATH="$PATH:/usr/local/bin"; 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
57
|
printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
|
|
58
58
|
-o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
|
|
2
|
+
|
|
3
|
+
@Entity({ schema: "public", name: "@@table@@" })
|
|
4
|
+
/** One @@provider@@ delivery receipt, a row of public.@@table@@ from its inbox migration in db/migrations; a reactor or job consumes it after commit. */
|
|
5
|
+
export class @@Provider@@InboxEntity {
|
|
6
|
+
/** The receipt id the winning claim answers. */
|
|
7
|
+
@PrimaryGeneratedColumn("uuid", { name: "id" })
|
|
8
|
+
id!: string
|
|
9
|
+
|
|
10
|
+
/** The provider the receipt belongs to; the table admits only @@provider@@. */
|
|
11
|
+
@Column({ name: "provider", type: "text" })
|
|
12
|
+
provider!: string
|
|
13
|
+
|
|
14
|
+
/** The provider's delivery id, unique with the provider. */
|
|
15
|
+
@Column({ name: "delivery_id", type: "text" })
|
|
16
|
+
deliveryId!: string
|
|
17
|
+
|
|
18
|
+
/** When the delivery was recorded; the database sets it. */
|
|
19
|
+
@Column({ name: "received_at", type: "timestamptz", insert: false, update: false })
|
|
20
|
+
receivedAt!: Date
|
|
21
|
+
|
|
22
|
+
/** The verified delivery body the consumer reads; it carries no credential. */
|
|
23
|
+
@Column({ name: "payload", type: "jsonb" })
|
|
24
|
+
payload!: object
|
|
25
|
+
|
|
26
|
+
/** When the consumer finished the delivery; null while it is pending. */
|
|
27
|
+
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
|
|
28
|
+
processedAt!: Date | null
|
|
29
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { injector } from "@modules/platform/composition"
|
|
2
2
|
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
-
import type { Inbox } from "./inbox.port"
|
|
3
|
+
import type { EntityInbox, Inbox } from "./inbox.port"
|
|
4
4
|
|
|
5
|
-
/** Token of the shared, transaction-owned inbox claims. */
|
|
5
|
+
/** Token of the shared, transaction-owned inbox claims; its provider is an EntityInbox too. */
|
|
6
6
|
export const INBOX: unique symbol = Symbol("platform.inbox")
|
|
7
7
|
|
|
8
8
|
/** Injects the Inbox port. Parameter type: Inbox. */
|
|
9
9
|
export const InjectInbox = (): TypedParameterDecorator<Inbox> => injector<Inbox>(INBOX)
|
|
10
|
+
|
|
11
|
+
/** Injects the Inbox port of a capability whose provider receipt is an entity row. Parameter type: EntityInbox. */
|
|
12
|
+
export const InjectEntityInbox = (): TypedParameterDecorator<EntityInbox> => injector<EntityInbox>(INBOX)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EntityManager } from "typeorm"
|
|
1
|
+
import type { EntityManager, EntityTarget, ObjectLiteral, QueryDeepPartialEntity } from "typeorm"
|
|
2
2
|
|
|
3
3
|
/** The authenticated scope and complete normalized body of a client operation; the key is never part of the body. */
|
|
4
4
|
export interface ClientOperation {
|
|
@@ -27,3 +27,31 @@ export interface Inbox {
|
|
|
27
27
|
/** Deletes one bounded batch of completed client claims older than the cutoff. */
|
|
28
28
|
sweepClients(cutoff: Date, tx: EntityManager): Promise<number>
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** A provider's receipt row: its entity, mapping a table with an `id` column, and the complete row, delivery key and payload included. */
|
|
32
|
+
export interface InboxEntityReceipt<Row extends ObjectLiteral> {
|
|
33
|
+
/** The provider's receipt entity. */
|
|
34
|
+
readonly entity: EntityTarget<Row>
|
|
35
|
+
/**
|
|
36
|
+
* The row the receipt holds, built from the delivery id the claim normalized (trimmed, never missing), so the receipt keys
|
|
37
|
+
* exactly what was claimed; the deferred consumer reads it, so it carries no credential.
|
|
38
|
+
*/
|
|
39
|
+
readonly row: (deliveryId: string) => QueryDeepPartialEntity<Row>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The id of the receipt row a winning claim inserted; undefined when the delivery was a repeat or its receipt already existed. */
|
|
43
|
+
export type InboxReceiptId = string | undefined
|
|
44
|
+
|
|
45
|
+
/** The inbox of a capability whose provider receipt is an entity row, so it records the receipt without raw SQL. */
|
|
46
|
+
export interface EntityInbox extends Inbox {
|
|
47
|
+
/**
|
|
48
|
+
* Claims once per source and event id, then inserts the receipt row, both in the caller transaction. Answers the id the
|
|
49
|
+
* winning insert returned; a repeated delivery, or a receipt row that already exists, inserts nothing and answers undefined.
|
|
50
|
+
*/
|
|
51
|
+
claimRow<Row extends ObjectLiteral>(
|
|
52
|
+
source: string,
|
|
53
|
+
eventId: InboxEventId,
|
|
54
|
+
tx: EntityManager,
|
|
55
|
+
receipt: InboxEntityReceipt<Row>,
|
|
56
|
+
): Promise<InboxReceiptId>
|
|
57
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IdempotencyErrorCode } from "@modules/platform/idempotency"
|
|
2
2
|
import { Test } from "@nestjs/testing"
|
|
3
3
|
import type { ClientOperation } from "./inbox.port"
|
|
4
|
-
import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
|
|
4
|
+
import { FakeClock, fakeTransaction, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
|
|
5
5
|
import { BATCH_ROWS, DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
|
|
6
6
|
import { CLOCK } from "@modules/platform/clock"
|
|
7
7
|
import { InboxError, InboxErrorCode } from "./errors/inbox.error"
|
|
@@ -29,6 +29,15 @@ const REQUEST = {
|
|
|
29
29
|
}
|
|
30
30
|
const OUTCOME = { kind: "ok", value: { orderId: "order-1" } }
|
|
31
31
|
|
|
32
|
+
/** A provider receipt entity as a capability maps its inbox table. */
|
|
33
|
+
class SpecReceiptEntity {
|
|
34
|
+
id!: string
|
|
35
|
+
provider!: string
|
|
36
|
+
deliveryId!: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RECEIPT = { entity: SpecReceiptEntity, row: (deliveryId: string) => ({ provider: "spec", deliveryId }) }
|
|
40
|
+
|
|
32
41
|
const build = async () => {
|
|
33
42
|
const module = await Test.createTestingModule({
|
|
34
43
|
providers: [PostgresInbox, { provide: CLOCK, useValue: new FakeClock("2026-02-03T04:05:06.000Z") }],
|
|
@@ -94,6 +103,61 @@ describe("PostgresInbox delivery rollback and validation", () => {
|
|
|
94
103
|
})
|
|
95
104
|
})
|
|
96
105
|
|
|
106
|
+
describe("PostgresInbox entity receipts", () => {
|
|
107
|
+
it("claims the trimmed delivery id, then inserts the receipt row it keys in the caller's transaction and answers its id", async () => {
|
|
108
|
+
const subject = await build()
|
|
109
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
|
|
110
|
+
const [insert] = queueQueryBuilders(manager, [{ execute: { raw: [{ id: "receipt-1" }] } }])
|
|
111
|
+
|
|
112
|
+
await expect(subject.claimRow("webhook:spec", " delivery-1 ", manager, RECEIPT)).resolves.toBe("receipt-1")
|
|
113
|
+
|
|
114
|
+
expect(manager.query).toHaveBeenCalledWith(CLAIM_EVENT, ["webhook:spec", "delivery-1"])
|
|
115
|
+
expect(manager.createQueryBuilder).toHaveBeenCalledWith(SpecReceiptEntity, "receipt")
|
|
116
|
+
expect(insert?.callsOf("values")).toEqual([[{ provider: "spec", deliveryId: "delivery-1" }]])
|
|
117
|
+
expect(insert?.callsOf("orIgnore")).toEqual([[]])
|
|
118
|
+
expect(insert?.callsOf("returning")).toEqual([["id"]])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("inserts no receipt for a delivery already claimed", async () => {
|
|
122
|
+
const subject = await build()
|
|
123
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, []] })
|
|
124
|
+
|
|
125
|
+
await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).resolves.toBeUndefined()
|
|
126
|
+
|
|
127
|
+
expect(manager.createQueryBuilder).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("answers undefined when the receipt row already exists", async () => {
|
|
131
|
+
const subject = await build()
|
|
132
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
|
|
133
|
+
queueQueryBuilders(manager, [{ execute: { raw: [] } }])
|
|
134
|
+
|
|
135
|
+
await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).resolves.toBeUndefined()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("refuses a returned receipt row without a text id", async () => {
|
|
139
|
+
const subject = await build()
|
|
140
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
|
|
141
|
+
queueQueryBuilders(manager, [{ execute: { raw: [{ id: 7 }] } }])
|
|
142
|
+
|
|
143
|
+
await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).rejects.toMatchObject({
|
|
144
|
+
code: DatabaseErrorCode.ReturningRowRejected,
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("refuses a missing delivery id before claiming or inserting", async () => {
|
|
149
|
+
const subject = await build()
|
|
150
|
+
const manager = mockEntityManager()
|
|
151
|
+
|
|
152
|
+
await expect(subject.claimRow("webhook:spec", undefined, manager, RECEIPT)).rejects.toMatchObject({
|
|
153
|
+
code: DatabaseErrorCode.WebhookDeliveryIdRequired,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(manager.query).not.toHaveBeenCalled()
|
|
157
|
+
expect(manager.createQueryBuilder).not.toHaveBeenCalled()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
97
161
|
describe("PostgresInbox client transaction admission", () => {
|
|
98
162
|
it("refuses a manager outside an active transaction before claiming or applying an effect", async () => {
|
|
99
163
|
const inbox = await build()
|