@starci/hfs 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/ci/cli.mjs +64 -0
  4. package/ci/github.mjs +41 -0
  5. package/ci/secret.mjs +28 -0
  6. package/ci/vars.mjs +33 -0
  7. package/ci/verify.mjs +36 -0
  8. package/db/backup-receipt.mjs +129 -0
  9. package/db/dump.mjs +66 -0
  10. package/db/migrate.mjs +8 -5
  11. package/db/post-cutover.mjs +4 -4
  12. package/db/reset.mjs +125 -0
  13. package/emit/contracts.mjs +2 -2
  14. package/emit/db-types.mjs +100 -7
  15. package/lint/run.mjs +30 -8
  16. package/package.json +2 -1
  17. package/release/ci-red.mjs +70 -0
  18. package/release/gate.mjs +53 -13
  19. package/release/index.mjs +8 -6
  20. package/release/push.mjs +1 -1
  21. package/report/sonar.mjs +1 -1
  22. package/runtime/config.example.yaml +7 -6
  23. package/runtime/engine/config.mjs +10 -18
  24. package/runtime/engine/removed-vocabulary.mjs +0 -2
  25. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  26. package/runtime/knowledge/hfs/rules.yaml +505 -971
  27. package/runtime/knowledge/hfs/slots.yaml +13 -24
  28. package/runtime/modules/kernel/failure-codes.yaml +60 -22
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  30. package/runtime/modules/models/runtimes.yaml +4 -6
  31. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  32. package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
  33. package/runtime/scripts/api/git/lib.mjs +3 -3
  34. package/runtime/scripts/api/sops/lib.mjs +1 -1
  35. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  36. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  37. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture.mjs +1 -1
  41. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  42. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  46. package/runtime/scripts/hfs/param-names.mjs +1 -1
  47. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  48. package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
  49. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  50. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  52. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  53. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  56. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  57. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  58. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  60. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  61. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  62. package/runtime/scripts/hfs/slots.mjs +2 -2
  63. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  64. package/runtime/scripts/lib/env.mjs +2 -2
  65. package/runtime/scripts/lib/git.mjs +1 -1
  66. package/runtime/scripts/lib/i18n.mjs +0 -2
  67. package/runtime/scripts/lib/language.mjs +1 -1
  68. package/runtime/scripts/lib/roots.mjs +2 -4
  69. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  70. package/scaffold/add-fe-overlay.mjs +0 -121
  71. package/scaffold/add-fe-page.mjs +0 -268
  72. package/scaffold/supabase-ports.mjs +1 -1
  73. package/src/main.mjs +17 -6
  74. package/stack/age-install.mjs +1 -1
  75. package/stack/cli.mjs +2 -2
  76. package/stack/context.mjs +14 -2
  77. package/stack/digest.mjs +1 -1
  78. package/stack/docker-transport.mjs +93 -15
  79. package/stack/edge-alias.mjs +1 -1
  80. package/stack/image-scan.mjs +1 -1
  81. package/stack/ingress-create.mjs +2 -2
  82. package/stack/ingress.mjs +3 -3
  83. package/stack/release-store.mjs +12 -7
  84. package/stack/remote.mjs +16 -8
  85. package/stack/sealed.mjs +5 -5
  86. package/stack/ssh-session.mjs +178 -0
  87. package/sync/ci-commands.mjs +103 -1
  88. package/sync/format.mjs +1 -1
  89. package/sync/hygiene.mjs +1 -1
  90. package/sync/index.mjs +43 -18
  91. package/sync/quality-services.mjs +61 -0
  92. package/sync/readme-badges.mjs +44 -0
  93. package/sync/stack-images.mjs +31 -5
  94. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  95. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  96. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  97. package/templates/app/ci-workflows/install-steps.yml +21 -0
  98. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  99. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  100. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  101. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  102. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  103. package/templates/app/quality-config/codecov.yml +6 -6
  104. package/templates/app/quality-config/sonar-project.properties +1 -0
  105. package/templates/app/readme-badges/README.md +1 -0
  106. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  107. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  108. package/templates/app/skeleton/README.md +6 -0
  109. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  110. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  111. package/templates/app/skeleton-lite/README.md +6 -0
  112. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
  113. package/upgrade/index.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.5.0 - 2026-10-17
4
+ - Added: consumer-class CI declarations, `app ci` custody verbs and R296-R298; manifest-driven quality files and split cached CI with immutable action pins.
5
+ - Added: release audit/local Sonar and main-CI protection; encrypted outside-repository DB dump and armed linked reset with backup/auth/storage validation.
6
+ - Fixed: DB types refuse stale migration history.
7
+
8
+ ## 4.4.1 - 2026-10-17
9
+ - Changed: the bundled runtime copies follow the runtime of 1.0.0-alpha.19 (owner ruling no-runtime-specs, 2026-10-17): the failure codes, the slot manifest (`exampleNa`) and the rule catalog (`specNa`, `exampleNa`) carry the removal of the runtime's own specs; package behavior is unchanged. The managed codecov.yml, the CI coverage upload and the hfs sync render stay (Codecov is kept for apps and examples); internal exports that only the deleted runtime specs read are no longer exported.
10
+
3
11
  ## 4.4.0 - 2026-10-15
4
12
  - Fixed: a swarm service on a shared external edge network no longer keeps the implicit short service name as an alias (`docker stack deploy` adds `api` for nivo_api on every network, so another tenant resolving `http://api` reached the wrong stack). `starci app stack deploy` and rollback reconcile the edge attachment to exactly the declared aliases right after `docker stack deploy` (`STACK_DEPLOY_EDGE_ALIAS` when they still differ), `stack verify` refuses an undeclared alias on an edge-attached service, and the deploy preflight and `stack doctor` extend `STACK_DEPLOY_ALIAS_SHARED` to a short name that another container or service of the edge network already resolves (`STACK_DEPLOY_EDGE_UNREADABLE` when the network cannot be inspected).
5
13
  - Added: R295 `BE_MODULE_DEFINITION_MISSING`: every Nest module has a module definition (owner ruling 2026-10-15 `owner:module-definition-everywhere`). The slot manifest allows `<x>.module-definition.ts` and `<x>.options.ts` beside every feature, transport, cli group, queue, reactor and job module; the generators of `starci app add` and `starci app new` write the three files and register the module as `X.register({})`.
package/README.md CHANGED
@@ -185,7 +185,7 @@ The upload authenticates with GitHub's OIDC token: the managed `ci.yml` job has
185
185
  editing a list), plus the catalog slice of every code `starci app check` can emit: its own and the machine's (`ARCHITECTURE_RULE_IDS`,
186
186
  derived from the machine's rule id lists). After changing any of those files, `knowledge/hfs/slots.yaml`,
187
187
  `knowledge/hfs/canon-pins.yaml`, `knowledge/patterns/fe/folder.yaml` or the catalog entries of those codes, run `starci release sync-runtime`;
188
- `tests/packages-hfs/hfs-cli.spec.mjs` fails on a stale copy. Bump `version` here and in the pin when the behaviour changes.
188
+ `starci release sync-runtime --check` fails on a stale copy. Bump `version` here and in the pin when the behaviour changes.
189
189
 
190
190
  The examples gate `starci runtime check --only example-architecture` runs `starci app lint` of this CLI at the root of every `examples/*`
191
191
  app with an `hfs.json` (each `examples/<app>` root) and fails on any finding or any tool that could not run;
package/ci/cli.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
4
+ import { readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
5
+ import { github, refuse } from './github.mjs';
6
+ import { setSecret } from './secret.mjs';
7
+ import { setVariable, syncVariables } from './vars.mjs';
8
+ import { verifyCi } from './verify.mjs';
9
+
10
+ const COMMANDS = {
11
+ 'secret set': ['env', 'dependabot', 'repo'],
12
+ 'var set': ['env', 'value', 'repo'],
13
+ 'var sync': ['execute', 'repo'],
14
+ verify: ['json', 'repo'],
15
+ };
16
+ const BOOLEAN = new Set(['dependabot', 'execute', 'json']);
17
+
18
+ /** Invalid options never echo argv, because an extra argument may itself contain a secret. */
19
+ function parse(argv) {
20
+ const command = argv[0] === 'verify' ? 'verify' : argv.slice(0, 2).join(' ');
21
+ const allowed = COMMANDS[command];
22
+ if (!allowed) throw refuse('CI_USAGE', 'use secret set, var set, var sync or verify');
23
+ const positional = [], options = {};
24
+ for (let i = command === 'verify' ? 1 : 2; i < argv.length; i += 1) {
25
+ const arg = argv[i], match = /^--([^=]+)(?:=(.*))?$/s.exec(arg);
26
+ if (!match && !arg.startsWith('-')) { positional.push(arg); continue; }
27
+ const key = match?.[1];
28
+ if (!allowed.includes(key) || Object.hasOwn(options, key)) throw refuse(command === 'secret set' ? 'CI_VALUE_ARGV' : 'CI_USAGE', 'unsupported or repeated option');
29
+ if (BOOLEAN.has(key)) {
30
+ if (match[2] !== undefined) throw refuse('CI_USAGE', 'boolean flags take no value');
31
+ options[key] = true;
32
+ } else {
33
+ const value = match[2] ?? argv[++i];
34
+ if (value === undefined || value.startsWith('--') || (key !== 'value' && !value.length)) throw refuse('CI_USAGE', 'an option value is required');
35
+ options[key] = value;
36
+ }
37
+ }
38
+ const named = command.endsWith(' set');
39
+ if (positional.length !== (named ? 1 : 0)) throw refuse(command === 'secret set' && positional.length > 1 ? 'CI_VALUE_ARGV' : 'CI_USAGE', 'provide the declared name only for set');
40
+ if (named && (!/^[A-Z][A-Z0-9_]*$/.test(positional[0]) || positional[0].startsWith('GITHUB_'))) throw refuse('CI_NAME_UNDECLARED', 'a declared uppercase CI name is required');
41
+ if (options.dependabot && options.env) throw refuse('CI_SCOPE_MISMATCH', 'Dependabot secrets have repository scope');
42
+ return { command, options, name: positional[0] };
43
+ }
44
+
45
+ /** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
46
+ export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
47
+ try {
48
+ const parsed = parse(argv);
49
+ let declaration, sealed;
50
+ try {
51
+ declaration = readCiDeclaration(parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8')));
52
+ sealed = sealedKeyNames(cwd, { fs });
53
+ } catch { throw refuse('CI_DECLARATION_INVALID', 'the manifest or sealed key names could not be read'); }
54
+ if (declaration.problems.length) throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
55
+ const context = { ...parsed, declaration, sealed, gh: github({ cwd, env, spawn: tools.spawn }), stdin, stdout };
56
+ if (parsed.command === 'secret set') return await setSecret(context);
57
+ if (parsed.command === 'var set') return setVariable(context);
58
+ if (parsed.command === 'var sync') return syncVariables(context);
59
+ return verifyCi(context);
60
+ } catch (error) {
61
+ stderr(`${error.code ? error.message : 'CI_USAGE: CI command could not complete'}\n`);
62
+ return 2;
63
+ }
64
+ }
package/ci/github.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ /** Refusals carry only fixed public diagnostics, never child output or secret input. */
4
+ export function refuse(code, message) {
5
+ const error = new Error(`${code}: ${message}`);
6
+ error.code = code;
7
+ return error;
8
+ }
9
+
10
+ /** All GitHub access uses argument arrays and captured output; secret bodies travel only on stdin. */
11
+ export function github({ cwd, spawn = spawnSync, env = process.env }) {
12
+ function run(args, input) {
13
+ let result;
14
+ try { result = spawn('gh', args, { cwd, env, encoding: 'utf8', shell: false, stdio: ['pipe', 'pipe', 'pipe'], ...(input === undefined ? {} : { input }) }); }
15
+ catch { throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI could not run; check installation and authentication'); }
16
+ if (result.error || result.status !== 0) throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI failed; check authentication and repository access');
17
+ return result.stdout;
18
+ }
19
+ function json(args) {
20
+ try { return JSON.parse(run(args)); }
21
+ catch (error) {
22
+ if (error.code) throw error;
23
+ throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI returned invalid JSON');
24
+ }
25
+ }
26
+ function repository(explicit) {
27
+ const repo = explicit ?? json(['repo', 'view', '--json', 'nameWithOwner'])?.nameWithOwner;
28
+ if (typeof repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw refuse('CI_GITHUB_UNREADABLE', 'a repository owner/name is required');
29
+ return repo;
30
+ }
31
+ return { run, json, repository };
32
+ }
33
+
34
+ /** Repository, environment and Dependabot stores are distinct even when their names coincide. */
35
+ export function scopeArgs(options) {
36
+ return options.dependabot ? ['--app', 'dependabot'] : options.env ? ['--env', options.env] : [];
37
+ }
38
+
39
+ export function scopeName(options) {
40
+ return options.dependabot ? 'dependabot' : options.env ? `environment:${options.env}` : 'repository';
41
+ }
package/ci/secret.mjs ADDED
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs';
2
+ import { refuse, scopeArgs, scopeName } from './github.mjs';
3
+
4
+ /** A declared name must belong to the requested store before a secret body is read. */
5
+ export function declaredEntry(entries, name, options) {
6
+ const entry = entries.find((item) => item.name === name);
7
+ if (!entry) throw refuse('CI_NAME_UNDECLARED', 'the name is not declared for this CI class');
8
+ const environment = entry.scope === 'environment' ? entry.environment : undefined;
9
+ if (environment !== options.env || (options.dependabot && options.env)) throw refuse('CI_SCOPE_MISMATCH', 'the requested scope differs from the declaration');
10
+ return entry;
11
+ }
12
+
13
+ /** Secret input is read once, stripped of one trailing newline, and passed only to the child stdin. */
14
+ export async function setSecret({ declaration, sealed, gh, options, name, stdin, stdout }) {
15
+ if (sealed.some((item) => item.key === name)) throw refuse('CI_SECRET_SEALED', 'the name belongs to a sealed app secret');
16
+ declaredEntry(options.dependabot ? declaration.dependabotSecrets : declaration.secrets, name, options);
17
+ let value;
18
+ if (stdin === undefined) {
19
+ if (process.stdin.isTTY) throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
20
+ value = fs.readFileSync(0, 'utf8');
21
+ } else value = typeof stdin === 'function' ? await stdin() : stdin;
22
+ value = String(value ?? '').replace(/\r?\n$/, '');
23
+ if (!value.length) throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
24
+ const repo = gh.repository(options.repo);
25
+ gh.run(['secret', 'set', name, ...scopeArgs(options), '--repo', repo], value);
26
+ stdout(`set secret ${name} (${scopeName(options)})\n`);
27
+ return 0;
28
+ }
package/ci/vars.mjs ADDED
@@ -0,0 +1,33 @@
1
+ import { refuse, scopeArgs, scopeName } from './github.mjs';
2
+ import { declaredEntry } from './secret.mjs';
3
+
4
+ /** Public CI values may come from argv or the manifest, but two supplied values must agree. */
5
+ function valueOf(entry, supplied) {
6
+ const declared = entry.value === undefined ? undefined : String(entry.value);
7
+ if (supplied !== undefined && declared !== undefined && supplied !== declared) throw refuse('CI_VAR_DRIFT', 'the supplied value differs from the manifest');
8
+ const value = supplied ?? declared;
9
+ if (value === undefined) throw refuse('CI_VALUE_EMPTY', 'a public variable value is required');
10
+ return value;
11
+ }
12
+
13
+ function writeVariable(gh, repo, entry, value, options, stdout) {
14
+ gh.run(['variable', 'set', entry.name, '--body', value, ...scopeArgs(options), '--repo', repo]);
15
+ stdout(`set variable ${entry.name} (${scopeName(options)})\n`);
16
+ }
17
+
18
+ export function setVariable({ declaration, gh, options, name, stdout }) {
19
+ const entry = declaredEntry(declaration.vars, name, options), value = valueOf(entry, options.value);
20
+ writeVariable(gh, gh.repository(options.repo), entry, value, options, stdout);
21
+ return 0;
22
+ }
23
+
24
+ /** Sync validates the whole plan first and performs no GitHub access until execution is requested. */
25
+ export function syncVariables({ declaration, gh, options, stdout }) {
26
+ const plan = declaration.vars.map((entry) => ({ entry, value: valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
27
+ const repo = options.execute && plan.length ? gh.repository(options.repo) : undefined;
28
+ for (const item of plan) {
29
+ if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
30
+ else stdout(`would set variable ${item.entry.name} (${scopeName(item.options)})\n`);
31
+ }
32
+ return 0;
33
+ }
package/ci/verify.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { refuse, scopeArgs, scopeName } from './github.mjs';
2
+
3
+ /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
4
+ export function verifyCi({ declaration, sealed, gh, options, stdout }) {
5
+ const repo = gh.repository(options.repo), stores = [];
6
+ const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
7
+ for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
8
+ for (const kind of scope.dependabot ? ['secret'] : ['secret', 'variable']) {
9
+ const rows = gh.json([kind, 'list', '--json', kind === 'secret' ? 'name' : 'name,value', ...scopeArgs(scope), '--repo', repo]);
10
+ if (!Array.isArray(rows) || rows.some((row) => typeof row?.name !== 'string' || (kind === 'variable' && typeof row.value !== 'string'))) throw refuse('CI_GITHUB_UNREADABLE', 'GitHub CLI returned an invalid name list');
11
+ stores.push({ scope, kind, rows });
12
+ }
13
+ }
14
+ const findings = [], sealedNames = new Set(sealed.map((item) => item.key));
15
+ const declarations = (store) => store.scope.dependabot ? declaration.dependabotSecrets : store.kind === 'secret' ? declaration.secrets : declaration.vars;
16
+ const matches = (entry, scope) => (entry.scope === 'environment' ? entry.environment : undefined) === scope.env;
17
+ const add = (code, name, scope) => findings.push({ code, name, scope: scopeName(scope) });
18
+ for (const store of stores) {
19
+ const entries = declarations(store), actual = new Map(store.rows.map((row) => [row.name, row]));
20
+ for (const entry of entries.filter((item) => matches(item, store.scope))) {
21
+ const row = actual.get(entry.name);
22
+ if (!row && !(store.kind === 'secret' && entry.optional)) add(store.kind === 'secret' ? 'CI_SECRET_MISSING' : 'CI_VAR_MISSING', entry.name, store.scope);
23
+ else if (row && store.kind === 'variable' && entry.value !== undefined && row.value !== String(entry.value)) add('CI_VAR_DRIFT', entry.name, store.scope);
24
+ }
25
+ for (const row of store.rows) {
26
+ if (store.kind === 'secret' && sealedNames.has(row.name)) add('APP_SECRET_IN_CI', row.name, store.scope);
27
+ if (entries.some((entry) => entry.name === row.name && matches(entry, store.scope))) continue;
28
+ const sameKind = store.kind === 'secret' ? [...declaration.secrets, ...declaration.dependabotSecrets] : declaration.vars;
29
+ add(sameKind.some((entry) => entry.name === row.name) ? 'CI_SCOPE_MISMATCH' : store.kind === 'secret' ? 'CI_SECRET_UNDECLARED' : 'CI_VAR_UNDECLARED', row.name, store.scope);
30
+ }
31
+ }
32
+ if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings }, null, 2)}\n`);
33
+ else if (!findings.length) stdout('CI verification passed\n');
34
+ else for (const finding of findings) stdout(`${finding.code}: ${finding.name} (${finding.scope})\n`);
35
+ return findings.length ? 1 : 0;
36
+ }
@@ -0,0 +1,129 @@
1
+ // backup-receipt.mjs - encrypted database backup custody, shared by dump and reset; tool output never enters diagnostics.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
6
+ import { readDeclaration } from '../release/index.mjs';
7
+ import { resolveTarget, runCli as migrationCli, targetArgs } from './migrate.mjs';
8
+ import { failure, refusal } from '../stack/errors.mjs';
9
+
10
+ export const RECEIPT_SCHEMA = 'starci/db-backup@1';
11
+ export function sha256(file) {
12
+ const hash = createHash('sha256');
13
+ const fd = fs.openSync(file, 'r');
14
+ const chunk = Buffer.alloc(1024 * 1024);
15
+ try {
16
+ let bytes;
17
+ while ((bytes = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) hash.update(chunk.subarray(0, bytes));
18
+ } finally { fs.closeSync(fd); }
19
+ return hash.digest('hex');
20
+ }
21
+ const inside = (root, file) => { const rel = path.relative(root, file); return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel)); };
22
+
23
+ /** Backup/restore SQL can hold row values, so even the migration runner's scrubbed stderr must be withheld. */
24
+ export function runCli(session, args) {
25
+ try { return migrationCli(session, args); }
26
+ catch { throw failure('DB_BACKUP_CLI_FAILED', `supabase ${args.slice(0, 2).join(' ')} failed; output withheld`); }
27
+ }
28
+
29
+ /** Resolves existing ancestors before checking containment, including symlinked output directories and Git worktree marker files. */
30
+ export function outsideRepository(root, output) {
31
+ let ancestor = path.resolve(output);
32
+ const tail = [];
33
+ while (!fs.existsSync(ancestor)) { tail.unshift(path.basename(ancestor)); ancestor = path.dirname(ancestor); }
34
+ const resolved = path.join(fs.realpathSync(ancestor), ...tail);
35
+ if (inside(fs.realpathSync(root), resolved)) throw refusal('DB_DUMP_INSIDE_REPO', '--out must be outside the app repository and every Git worktree');
36
+ for (let dir = resolved; ; dir = path.dirname(dir)) {
37
+ if (fs.existsSync(path.join(dir, '.git'))) throw refusal('DB_DUMP_INSIDE_REPO', '--out must be outside every Git worktree');
38
+ if (dir === path.dirname(dir)) break;
39
+ }
40
+ return resolved;
41
+ }
42
+
43
+ /** Uses migrate's linked/password/URL resolution; local is explicitly selected and never silently defaults to linked. */
44
+ export function backupSession(ctx, options) {
45
+ const database = readDeclaration(ctx.cwd).database ?? {};
46
+ const selections = [options.linked === true, options.local === true, options.urlEnv !== undefined].filter(Boolean).length;
47
+ if (selections !== 1) throw refusal('DB_USAGE', 'select exactly one of --linked, --local or --url-env');
48
+ const target = options.local ? { kind: 'local', ref: null, env: {}, secrets: [], how: 'local database' }
49
+ : resolveTarget(ctx, { projectRef: null, password: null, ...database }, options);
50
+ if (target.kind === 'linked' && !/^[a-z0-9]{10,40}$/u.test(target.ref)) throw refusal('DB_NOT_LINKED', 'linked project ref is invalid');
51
+ const project = path.basename(ctx.cwd).replace(/[^A-Za-z0-9_-]/gu, '-') || 'database';
52
+ return { ctx, target, database, project };
53
+ }
54
+
55
+ export const backupArgs = (session) => session.target.kind === 'local' ? ['--local', '--workdir', session.ctx.cwd] : targetArgs(session.target, session.ctx.cwd);
56
+
57
+ /** Queries via the same injected Supabase runner; malformed output is withheld. */
58
+ export function queryRows(session, sql) {
59
+ let rows;
60
+ try { rows = JSON.parse(runCli(session, ['db', 'query', sql, '--output', 'json', '--agent', 'no', ...backupArgs(session)]).stdout); }
61
+ catch { throw failure('DB_BACKUP_QUERY_FAILED', 'database inventory query failed; output withheld'); }
62
+ if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw failure('DB_BACKUP_QUERY_FAILED', 'database inventory is not a rows array');
63
+ return rows;
64
+ }
65
+
66
+ /** Executes local tar/age through ctx.spawn, the same seam used by the stack's age installer; never relays tool errors containing data. */
67
+ export function backupTool(ctx, command, args, options = {}) {
68
+ const result = ctx.spawn(command, args, { cwd: ctx.cwd, env: ctx.env, ...options });
69
+ if (result.error || result.status !== 0) throw failure('DB_BACKUP_TOOL_FAILED', `${command} failed; output withheld`);
70
+ return result;
71
+ }
72
+
73
+ /** Reads public recipients only from creation_rules age fields, including nested SOPS key_groups. */
74
+ export function backupRecipients(root) {
75
+ let rules;
76
+ try { rules = parseYaml(fs.readFileSync(path.join(root, '.sops.yaml'), 'utf8')).creation_rules; } catch { rules = []; }
77
+ const found = [];
78
+ const visit = (value) => {
79
+ if (!value || typeof value !== 'object') return;
80
+ for (const [key, item] of Object.entries(value)) {
81
+ if (key === 'age') found.push(...(JSON.stringify(item).match(/age1[0-9a-z]{50,}/gu) ?? []));
82
+ else if (typeof item === 'object') visit(item);
83
+ }
84
+ };
85
+ visit(rules);
86
+ if (!found.length) throw refusal('DB_BACKUP_NO_RECIPIENT', 'no age recipient in .sops.yaml creation_rules');
87
+ return [...new Set(found)];
88
+ }
89
+
90
+ /** Header and size checks prove an age envelope exists; proving decryption requires the owner's identity separately. */
91
+ function checkArchive(file) {
92
+ const size = fs.statSync(file).size;
93
+ const fd = fs.openSync(file, 'r');
94
+ const header = Buffer.alloc(22);
95
+ try { fs.readSync(fd, header, 0, header.length, 0); } finally { fs.closeSync(fd); }
96
+ if (size < 100 || !header.toString('ascii').startsWith('age-encryption.org/v1\n')) throw failure('DB_BACKUP_ARCHIVE_INVALID', 'encrypted archive has no valid age header or payload');
97
+ return size;
98
+ }
99
+
100
+ export function encryptFile(ctx, input, output, recipients) {
101
+ backupTool(ctx, 'age', [...recipients.flatMap((recipient) => ['-r', recipient]), '-o', output, input]);
102
+ return checkArchive(output);
103
+ }
104
+
105
+ /** Validates a complete, recent, full-data receipt and its encrypted bytes before any destructive reset call. */
106
+ export function validateReceipt(session, receiptPath) {
107
+ if (!receiptPath) throw refusal('DB_RESET_BACKUP_MISSING', '--backup must name an encrypted backup receipt');
108
+ let receipt;
109
+ try { receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch { throw refusal('DB_RESET_BACKUP_INVALID', 'backup receipt is missing or invalid JSON'); }
110
+ const valid = receipt && receipt.schema === RECEIPT_SCHEMA && receipt.target === 'linked' && receipt.schemaOnly === false
111
+ && typeof receipt.project === 'string' && typeof receipt.ref === 'string' && typeof receipt.file === 'string'
112
+ && path.basename(receipt.file) === receipt.file && receipt.file.endsWith('.age') && Number.isSafeInteger(receipt.bytes) && receipt.bytes >= 100
113
+ && /^[a-f0-9]{64}$/u.test(receipt.sha256 ?? '') && Array.isArray(receipt.parts)
114
+ && ['roles.sql', 'schema.sql', 'auth-storage-schema.sql', 'data.sql', 'auth-storage-data.sql', 'rowcounts.json'].every((part) => receipt.parts.includes(part))
115
+ && Number.isSafeInteger(receipt.rowcounts?.tables) && receipt.rowcounts.tables >= 0 && /^\d+$/u.test(receipt.rowcounts?.rows ?? '')
116
+ && typeof receipt.at === 'string' && Number.isFinite(Date.parse(receipt.at));
117
+ if (!valid) throw refusal('DB_RESET_BACKUP_INVALID', 'backup receipt is not a full starci/db-backup@1 linked backup');
118
+ if (receipt.ref !== session.target.ref) throw refusal('DB_RESET_BACKUP_PROJECT', 'backup targets a different project ref');
119
+ const maxAge = session.database.reset?.backupMaxAgeHours ?? 24;
120
+ if (!Number.isFinite(maxAge) || maxAge <= 0) throw refusal('DB_CONFIG_INVALID', 'database.reset.backupMaxAgeHours must be positive');
121
+ const age = session.ctx.now() - Date.parse(receipt.at);
122
+ if (age < 0 || age > maxAge * 3600000) throw refusal('DB_RESET_BACKUP_STALE', 'backup is future-dated or older than the allowed backup age');
123
+ const file = path.resolve(path.dirname(receiptPath), receipt.file);
124
+ if (!fs.existsSync(file)) throw refusal('DB_RESET_BACKUP_ARCHIVE_MISSING', 'backup archive is absent');
125
+ try {
126
+ if (checkArchive(file) !== receipt.bytes || sha256(file) !== receipt.sha256) throw new Error('mismatch');
127
+ } catch { throw refusal('DB_RESET_BACKUP_HASH', 'backup archive size, header or sha256 differs from the receipt'); }
128
+ return receipt;
129
+ }
package/db/dump.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // dump.mjs - full Supabase backup, encrypted outside Git; every plaintext part is removed even when a tool fails.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { runVerb } from '../release/index.mjs';
5
+ import { failure, refusal } from '../stack/errors.mjs';
6
+ import { RECEIPT_SCHEMA, outsideRepository, backupSession, backupArgs, backupRecipients, backupTool, encryptFile, queryRows, sha256, runCli } from './backup-receipt.mjs';
7
+
8
+ /** Counts every ordinary table with quoted identifiers in a read-only transaction, including auth and storage. */
9
+ export function rowcounts(session) {
10
+ const tables = queryRows(session, "select schemaname, tablename from pg_catalog.pg_tables where schemaname not in ('pg_catalog', 'information_schema') order by schemaname, tablename;");
11
+ const quote = (name) => `"${String(name).replaceAll('"', '""')}"`;
12
+ return tables.map((table) => {
13
+ if (typeof table.schemaname !== 'string' || typeof table.tablename !== 'string') throw failure('DB_BACKUP_QUERY_FAILED', 'invalid table inventory');
14
+ const rows = queryRows(session, `begin read only; select count(*)::text as count from ${quote(table.schemaname)}.${quote(table.tablename)}; commit;`);
15
+ if (rows.length !== 1 || !/^\d+$/u.test(String(rows[0].count))) throw failure('DB_BACKUP_QUERY_FAILED', 'invalid rowcount');
16
+ return { schema: table.schemaname, table: table.tablename, count: String(rows[0].count) };
17
+ });
18
+ }
19
+
20
+ /** Writes only ciphertext and its receipt permanently; a receipt is published only after header, size and hash checks. */
21
+ function dumpDatabase(ctx, options = {}) {
22
+ if (!options.out) throw refusal('DB_USAGE', '--out is required');
23
+ const out = outsideRepository(ctx.cwd, path.resolve(ctx.cwd, options.out));
24
+ const session = backupSession(ctx, options);
25
+ const recipients = backupRecipients(ctx.cwd);
26
+ fs.mkdirSync(out, { recursive: true, mode: 0o700 });
27
+ const at = new Date(ctx.now()).toISOString();
28
+ const stamp = at.replace(/[-:.]/gu, '');
29
+ const name = `${session.project}-${stamp}`;
30
+ const dir = path.join(out, name);
31
+ fs.mkdirSync(dir, { mode: 0o700 });
32
+ const scratch = path.join(dir, '.plaintext');
33
+ const archive = path.join(dir, `${name}.tar.age`);
34
+ const receiptPath = path.join(dir, `${name}.receipt.json`);
35
+ let complete = false;
36
+ try {
37
+ fs.mkdirSync(scratch, { mode: 0o700 });
38
+ const jobs = [['roles.sql', ['--role-only']], ['schema.sql', []], ['auth-storage-schema.sql', ['--schema', 'auth,storage']]];
39
+ if (!options.schemaOnly) jobs.push(['data.sql', ['--data-only']], ['auth-storage-data.sql', ['--data-only', '--schema', 'auth,storage']]);
40
+ for (const [file, flags] of jobs) runCli(session, ['db', 'dump', ...flags, '--file', path.join(scratch, file), ...backupArgs(session)]);
41
+ const counts = rowcounts(session);
42
+ fs.writeFileSync(path.join(scratch, 'rowcounts.json'), `${JSON.stringify(counts, null, 2)}\n`, { mode: 0o600 });
43
+ const parts = [...jobs.map(([file]) => file), 'rowcounts.json'];
44
+ const tar = path.join(scratch, 'backup.tar');
45
+ backupTool(ctx, 'tar', ['-cf', tar, '-C', scratch, ...parts]);
46
+ const bytes = encryptFile(ctx, tar, archive, recipients);
47
+ const receipt = { schema: RECEIPT_SCHEMA, project: session.project, target: session.target.kind, ref: session.target.ref ?? null,
48
+ at, file: path.basename(archive), bytes, sha256: sha256(archive), parts,
49
+ rowcounts: { tables: counts.length, rows: counts.reduce((sum, row) => sum + BigInt(row.count), 0n).toString(), tablesByName: counts }, schemaOnly: options.schemaOnly === true };
50
+ fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
51
+ complete = true;
52
+ return { ok: true, lines: [JSON.stringify(receipt, null, 2), `receipt: ${receiptPath}`, 'age header, size and sha256 verified; decryption requires the owner identity'], data: { receipt, receiptPath } };
53
+ } finally {
54
+ fs.rmSync(scratch, { recursive: true, force: true });
55
+ if (!complete) { fs.rmSync(archive, { force: true }); fs.rmSync(receiptPath, { force: true }); }
56
+ }
57
+ }
58
+
59
+ export function dbDumpMain(argv, io = {}) {
60
+ return runVerb({ label: 'starci app db dump', name: 'db dump', code: 'DB_USAGE', io, argv,
61
+ spec: { boolean: ['--linked', '--local', '--schema-only', '--json'], value: ['--url-env', '--out'] },
62
+ run: (ctx, flags, positional) => {
63
+ if (positional.length) throw refusal('DB_USAGE', 'dump takes flags only');
64
+ return dumpDatabase(ctx, { linked: flags.linked, local: flags.local, urlEnv: flags['url-env'], out: flags.out, schemaOnly: flags['schema-only'] });
65
+ } });
66
+ }
package/db/migrate.mjs CHANGED
@@ -43,6 +43,8 @@ const POST_CHECKS = {
43
43
  };
44
44
  const postOk = (value) => plain(value) && Object.keys(value).every((key) => POST_FIELDS.includes(key) && POST_CHECKS[key](value[key]));
45
45
 
46
+ const resetOk = (value) => plain(value) && Object.keys(value).every((key) => key === 'backupMaxAgeHours' && Number.isFinite(value[key]) && value[key] > 0);
47
+
46
48
  const CHECKS = {
47
49
  migrationsDir: [dirOk, 'must be a relative directory inside the app'],
48
50
  archiveDir: [dirOk, 'must be a relative directory inside the app'],
@@ -53,10 +55,11 @@ const CHECKS = {
53
55
  restamp: [listOf(pairOk), 'must be a list of { from, to } fourteen-digit stamps'],
54
56
  password: [passwordOk, 'must be { env, member, key } naming a sealed value'],
55
57
  postCutover: [postOk, `must use only ${POST_FIELDS.join(', ')} with valid values`],
58
+ reset: [resetOk, 'must be a mapping with only optional finite positive backupMaxAgeHours'],
56
59
  };
57
60
 
58
61
  /** Judges the `database:` block. Returns { config } or { problems }; `baseline` is the one required field. */
59
- export function normalizeDatabase(block) {
62
+ function normalizeDatabase(block) {
60
63
  if (!plain(block)) return { problems: ['database: must be a mapping'] };
61
64
  const problems = [];
62
65
  for (const [key, value] of Object.entries(block)) {
@@ -66,7 +69,7 @@ export function normalizeDatabase(block) {
66
69
  if (block.baseline === undefined) problems.push('database.baseline: is required');
67
70
  if (problems.length > 0) return { problems };
68
71
  const config = {
69
- migrationsDir: 'supabase/migrations', archiveDir: null, standard: null, heldBack: [], projectRef: null, restamp: [], password: null, postCutover: null, ...block,
72
+ migrationsDir: 'supabase/migrations', archiveDir: null, standard: null, heldBack: [], projectRef: null, restamp: [], password: null, postCutover: null, reset: null, ...block,
70
73
  };
71
74
  return { config };
72
75
  }
@@ -159,7 +162,7 @@ function tableVersions(text) {
159
162
  }
160
163
 
161
164
  /** Remote (and local) versions from `supabase migration list`: the JSON form, or the older table. */
162
- export const parseMigrationList = (text) => jsonVersions(text) ?? tableVersions(text);
165
+ const parseMigrationList = (text) => jsonVersions(text) ?? tableVersions(text);
163
166
 
164
167
  /** The versions a `db push --dry-run` would apply. */
165
168
  export const pendingVersions = (text) => [...new Set([...text.matchAll(PENDING)].map((match) => match[1]))];
@@ -245,7 +248,7 @@ export const listRemote = (session) => parseMigrationList(runCli(session, ['migr
245
248
  /* ---- the plan */
246
249
 
247
250
  /** Pure: what to do given the remote history. `done` means baseline + forward are present and nothing else is. */
248
- export function planMigration(model, remote) {
251
+ function planMigration(model, remote) {
249
252
  const done = [...model.pushVersions].every((version) => remote.has(version)) && [...remote].every((version) => model.allowed.has(version));
250
253
  const sim = new Set(remote);
251
254
  const repairs = model.decl.restamp.filter((pair) => sim.has(pair.from));
@@ -329,7 +332,7 @@ function applyPlan(session, plan) {
329
332
  * `starci app db migrate`. options: { execute, linked, urlEnv }. Dry run reads the remote history once and prints the plan; --execute (with
330
333
  * STARCI_DB_ARMED=1) applies it. Already baseline + forward: `nothing to do`, no mutating call, ok.
331
334
  */
332
- export async function migrateDatabase(ctx, decl, options = {}) {
335
+ async function migrateDatabase(ctx, decl, options = {}) {
333
336
  const model = buildModel(ctx.cwd, decl);
334
337
  const session = { ctx, model, target: resolveTarget(ctx, decl, options) };
335
338
  const remote = listRemote(session);
@@ -19,7 +19,7 @@ const MS_PER_HOUR = 3.6e6;
19
19
  const DEFAULT_MIN_AGE_HOURS = 48;
20
20
 
21
21
  /** Makes a DROP signature comparable with pg_proc's regprocedure text. */
22
- export function normalizeSignature(signature, schema) {
22
+ function normalizeSignature(signature, schema) {
23
23
  const lowered = signature.trim().toLowerCase();
24
24
  const bare = lowered.startsWith(`${schema}.`) ? lowered.slice(schema.length + 1) : lowered;
25
25
  const words = bare
@@ -36,7 +36,7 @@ function droppedSignature(statement) {
36
36
  }
37
37
 
38
38
  /** The signatures a migration drops: every `DROP FUNCTION [IF EXISTS] <signature>;` statement. */
39
- export function dropSignatures(sql, schema) {
39
+ function dropSignatures(sql, schema) {
40
40
  return sql.replace(/--[^\n]*/gu, '').split(';').flatMap((statement) => {
41
41
  const signature = droppedSignature(statement.trim());
42
42
  return signature === null ? [] : [normalizeSignature(signature, schema)];
@@ -48,7 +48,7 @@ const settingsOf = (post) => ({
48
48
  });
49
49
 
50
50
  /** The read-only pg_proc query of the declared function families (identifiers are validated, so nothing free-form reaches the SQL). */
51
- export function preflightSql({ schema, prefixes, names }) {
51
+ function preflightSql({ schema, prefixes, names }) {
52
52
  const escaped = (prefix) => prefix.replaceAll('_', String.raw`\_`);
53
53
  const clauses = [...names.map((name) => `proname = '${name}'`), ...prefixes.map((prefix) => `proname like '${escaped(prefix)}%'`)];
54
54
  return `select oid::regprocedure::text as signature from pg_proc where pronamespace = '${schema}'::regnamespace and (${clauses.join(' or ')})`;
@@ -104,7 +104,7 @@ function applyHeld(session, remote) {
104
104
  * `starci app db post-cutover`. options: { execute, linked, urlEnv, greenAt, ownerConfirms, query }. Dry run: remote read plus the read-only preflight.
105
105
  * --execute: gate open and STARCI_DB_ARMED=1, then `db push --include-all` of exactly the held-back versions.
106
106
  */
107
- export async function postCutoverDatabase(ctx, decl, options = {}) {
107
+ async function postCutoverDatabase(ctx, decl, options = {}) {
108
108
  const model = buildModel(ctx.cwd, decl);
109
109
  if (model.held.length === 0) return { ok: true, lines: ['no held-back migration in the repository. Nothing to do.'], data: { held: [], done: true, executed: false } };
110
110
  if (decl.postCutover === null) throw refusal('DB_POSTCUTOVER_UNDECLARED', 'database.postCutover is not declared: the function preflight needs it (an empty mapping declares no family)');