@starci/hfs 4.2.17 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/be/api.yaml +64 -1
- package/runtime/knowledge/patterns/be/cli.yaml +23 -5
- package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/modules/kernel/failure-codes.yaml +15 -4
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
- package/runtime/scripts/api/sops/seal.mjs +1 -1
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/check.mjs +18 -26
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
- package/runtime/scripts/hfs/rules/database.mjs +2 -2
- package/runtime/scripts/hfs/rules/edition.mjs +5 -5
- package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
- package/runtime/scripts/hfs/secret-document.mjs +97 -0
- package/runtime/scripts/hfs/secret-errors.mjs +20 -0
- package/runtime/scripts/hfs/secret-files.mjs +124 -0
- package/runtime/scripts/hfs/secret-init.mjs +192 -0
- package/runtime/scripts/hfs/secret-output.mjs +100 -0
- package/runtime/scripts/hfs/secret-sops.mjs +98 -0
- package/runtime/scripts/hfs/secret-store.mjs +161 -0
- package/runtime/scripts/hfs/secret.mjs +135 -195
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/hfs/tree-findings.mjs +22 -0
- package/runtime/scripts/lib/roots.mjs +59 -0
- package/runtime/scripts/lib/swarm-stack.mjs +139 -0
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- package/scaffold/add-cli-lite.mjs +10 -11
- package/scaffold/add-inbox-lite.mjs +15 -84
- package/scaffold/add-plan.mjs +183 -0
- package/scaffold/add-table.mjs +44 -5
- package/scaffold/add.mjs +33 -166
- package/scaffold/app.mjs +5 -5
- package/scaffold/image.mjs +2 -2
- package/src/main.mjs +12 -6
- package/sync/index.mjs +23 -16
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/hooks/husky/pre-commit +7 -12
- package/templates/app/skeleton/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.sops.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
- package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton-lite/README.md +4 -2
- package/templates/app/starciwork.gitignore-lite +6 -0
- package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
- package/templates/be/inbox/database-client.ts.tpl +7 -22
- package/templates/be/inbox/service.spec.ts.tpl +16 -20
- package/templates/be/inbox/service.ts.tpl +6 -10
- package/templates/be/inbox/sql.ts.tpl +2 -2
- package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
- package/templates/be/inbox/webhook-door.ts.tpl +4 -3
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
- package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
- package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
- package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
- package/upgrade/file-state.mjs +68 -0
- package/upgrade/index.mjs +7 -56
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// stacks.mjs - HFS_STACKS_SHAPE (R10):
|
|
1
|
+
// stacks.mjs - HFS_STACKS_SHAPE (R10): standard stack shape, Sonar owner and deployment runtime consistency.
|
|
2
2
|
// - every tracked path under the app root's `.starcistacks/` is one the app.starcistacks slot allows (its `allows` list is the shape:
|
|
3
|
-
// application-stacks.yaml and <env>/{README.md, environment.json, infra/{compose,k8s,terraform}, runtime/{config,env},
|
|
3
|
+
// application-stacks.yaml and <env>/{README.md, environment.json, infra/{compose,swarm,k8s,terraform}, runtime/{config,env},
|
|
4
4
|
// secrets/<slug>.enc, seeds}); a `.enc` outside <env>/secrets/ is never allowed, `runtime/files/`, a root `DESIGN.md`,
|
|
5
5
|
// `deployment.json` and `k8s/` are not in the list, so they fall out of it;
|
|
6
6
|
// - the declaration (read by scripts/lib/stack-declaration.mjs, the one reader of it) states a sonar service, a local
|
|
7
7
|
// Sonar is owned by the host (`stack.owner: host`, root `.claude/ext/sonar`).
|
|
8
|
-
// The
|
|
8
|
+
// The lite overlay admits Swarm compute and managed Supabase backing. Runtime rules are shared with the stack gates.
|
|
9
|
+
// The declaration's quality/delivery services contract (custody, CI wiring, project keys) stays check-starcistacks's.
|
|
9
10
|
import { braceVariants, globExpression } from '../../lib/glob.mjs';
|
|
10
11
|
import { declaredStack, findStackDeclaration, STACK_ROOT, text } from '../../lib/stack-declaration.mjs';
|
|
11
12
|
import { found } from './read.mjs';
|
|
13
|
+
import { hostCrontabFindings, stacksRuntimeFindings } from './stacks-runtime.mjs';
|
|
12
14
|
|
|
13
15
|
const STACKS_SHAPE = 'HFS_STACKS_SHAPE';
|
|
14
16
|
const STACKS_SLOT = 'app.starcistacks';
|
|
@@ -30,11 +32,10 @@ function treeFindings(files, allowed, prefix) {
|
|
|
30
32
|
return findings;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
function declarationFindings(
|
|
35
|
+
function declarationFindings(files, prefix, declaration) {
|
|
34
36
|
const declared = `${prefix}application-stacks.yaml`;
|
|
35
37
|
if (!files.includes(declared)) return [];
|
|
36
38
|
const findings = [];
|
|
37
|
-
const declaration = findStackDeclaration(repoRoot);
|
|
38
39
|
if (declaration.error) return [shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
|
|
39
40
|
const services = declaration.doc?.services;
|
|
40
41
|
if (services === undefined || typeof services.sonar !== 'object' || services.sonar === null) {
|
|
@@ -55,7 +56,11 @@ export function stacksFindings({ repoRoot, files, resolver }) {
|
|
|
55
56
|
const slot = resolver.slot(STACKS_SLOT);
|
|
56
57
|
if (!slot) return [];
|
|
57
58
|
const prefix = `${STACK_ROOT}/`;
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const declaration = findStackDeclaration(repoRoot);
|
|
60
|
+
return [
|
|
61
|
+
...treeFindings(files, allowedExpressions(slot.allows ?? []), prefix),
|
|
62
|
+
...declarationFindings(files, prefix, declaration),
|
|
63
|
+
...hostCrontabFindings({ repoRoot, files }),
|
|
64
|
+
...(declaration.doc ? stacksRuntimeFindings({ repoRoot, files, doc: declaration.doc, edition: resolver.repo?.edition }) : []),
|
|
65
|
+
];
|
|
61
66
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// secret-document.mjs - envelope inspection, plaintext serialization and named-value validation.
|
|
2
|
+
import { SecretError } from "./secret-errors.mjs";
|
|
3
|
+
|
|
4
|
+
const KEY = /^[A-Za-z_]\w*$/u;
|
|
5
|
+
const WHITESPACE = String.raw`\s`;
|
|
6
|
+
const REQUIRED_INDENT = `${WHITESPACE}+`;
|
|
7
|
+
const OPTIONAL_DASH = "-?";
|
|
8
|
+
const OPTIONAL_SPACING = `${WHITESPACE}*`;
|
|
9
|
+
const RECIPIENT_NAME = "recipient:";
|
|
10
|
+
const RECIPIENT_VALUE = String.raw`(\S+)`;
|
|
11
|
+
const RECIPIENT_LINE = new RegExp(
|
|
12
|
+
`^${REQUIRED_INDENT}${OPTIONAL_DASH}${OPTIONAL_SPACING}${RECIPIENT_NAME}${OPTIONAL_SPACING}${RECIPIENT_VALUE}`,
|
|
13
|
+
"gmu",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/** The envelope format of a sealed secret, read from its text: json, yaml or dotenv. */
|
|
17
|
+
export function formatOf(text) {
|
|
18
|
+
const body = String(text).trimStart();
|
|
19
|
+
if (body.startsWith("{")) return "json";
|
|
20
|
+
if (/^sops:\s*$/mu.test(text)) return "yaml";
|
|
21
|
+
return "dotenv";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The key names a sealed document holds and the age recipients it is sealed to, read without decrypting anything. */
|
|
25
|
+
export function envelopeOf(text) {
|
|
26
|
+
const format = formatOf(text);
|
|
27
|
+
if (format === "json") {
|
|
28
|
+
const doc = JSON.parse(text);
|
|
29
|
+
return {
|
|
30
|
+
format,
|
|
31
|
+
keys: Object.keys(doc).filter((name) => name !== "sops"),
|
|
32
|
+
recipients: (doc.sops?.age ?? [])
|
|
33
|
+
.map((entry) => entry.recipient)
|
|
34
|
+
.filter(Boolean),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (format === "yaml") {
|
|
38
|
+
const keys = [...text.matchAll(/^([A-Za-z_]\w*):/gmu)]
|
|
39
|
+
.map((match) => match[1])
|
|
40
|
+
.filter((name) => name !== "sops");
|
|
41
|
+
return {
|
|
42
|
+
format,
|
|
43
|
+
keys,
|
|
44
|
+
recipients: [...text.matchAll(RECIPIENT_LINE)].map((match) => match[1]),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const keys = [...text.matchAll(/^([A-Za-z_]\w*)=/gmu)]
|
|
48
|
+
.map((match) => match[1])
|
|
49
|
+
.filter((name) => !name.startsWith("sops_"));
|
|
50
|
+
return {
|
|
51
|
+
format,
|
|
52
|
+
keys,
|
|
53
|
+
recipients: [
|
|
54
|
+
...text.matchAll(/^sops_age__list_\d+__map_recipient=(\S+)/gmu),
|
|
55
|
+
].map((match) => match[1]),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** SOPS dotenv escapes LF as backslash-n; literal backslash-n has no distinct representation. */
|
|
60
|
+
export function plaintextOf(format, map) {
|
|
61
|
+
if (format === "json") return `${JSON.stringify(map)}\n`;
|
|
62
|
+
if (format === "yaml")
|
|
63
|
+
return (
|
|
64
|
+
Object.entries(map)
|
|
65
|
+
.map(([name, value]) => name + ": " + JSON.stringify(value))
|
|
66
|
+
.join("\n") + "\n"
|
|
67
|
+
);
|
|
68
|
+
for (const value of Object.values(map))
|
|
69
|
+
if (/\r|\\n/u.test(value))
|
|
70
|
+
throw new SecretError(
|
|
71
|
+
"dotenv cannot preserve a carriage return or literal backslash-n; use an LF line break instead",
|
|
72
|
+
);
|
|
73
|
+
return (
|
|
74
|
+
Object.entries(map)
|
|
75
|
+
.map(
|
|
76
|
+
([name, value]) => name + "=" + value.replaceAll("\n", String.raw`\n`),
|
|
77
|
+
)
|
|
78
|
+
.join("\n") + "\n"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function validateKey(key) {
|
|
83
|
+
if (!KEY.test(key ?? "") || key.startsWith("sops_"))
|
|
84
|
+
throw new SecretError(
|
|
85
|
+
"KEY must be an environment variable name outside the reserved sops_ namespace",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function validateMap(map) {
|
|
90
|
+
if (!map || typeof map !== "object" || Array.isArray(map))
|
|
91
|
+
throw new SecretError("sops returned an invalid secret map");
|
|
92
|
+
for (const [key, value] of Object.entries(map)) {
|
|
93
|
+
validateKey(key);
|
|
94
|
+
if (typeof value !== "string")
|
|
95
|
+
throw new SecretError("secret values must be strings");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// secret-errors.mjs - sanitized public errors shared by app secret operations.
|
|
2
|
+
|
|
3
|
+
export class SecretError extends Error {}
|
|
4
|
+
|
|
5
|
+
export const publicStorage = (action) => {
|
|
6
|
+
try {
|
|
7
|
+
return action();
|
|
8
|
+
} catch (error) {
|
|
9
|
+
throw new SecretError(
|
|
10
|
+
error.code ? `private storage failed (${error.code})` : error.message,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function secretErrorMessage(error) {
|
|
16
|
+
if (error instanceof SecretError) return error.message;
|
|
17
|
+
if (error instanceof SyntaxError) return "invalid secret document";
|
|
18
|
+
const code = error.code ? ` (${error.code})` : "";
|
|
19
|
+
return `operation failed${code}; no secret values were printed`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// secret-files.mjs — private files for app secrets, always outside the repository.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/** The platform's SOPS age identity and StarCi local data roots. */
|
|
8
|
+
export function secretRoots(env = process.env, platform = process.platform) {
|
|
9
|
+
const home = env.USERPROFILE || env.HOME || os.homedir();
|
|
10
|
+
const support = path.join(home, "Library", "Application Support");
|
|
11
|
+
let config;
|
|
12
|
+
if (platform === "win32")
|
|
13
|
+
config = env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
14
|
+
else if (platform === "darwin") config = support;
|
|
15
|
+
else config = env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
16
|
+
let local;
|
|
17
|
+
if (platform === "win32")
|
|
18
|
+
local = env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
19
|
+
else if (platform === "darwin") local = support;
|
|
20
|
+
else local = env.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
21
|
+
return {
|
|
22
|
+
identity: path.resolve(config, "sops", "age", "keys.txt"),
|
|
23
|
+
local: path.resolve(local, "StarCi", "secrets"),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const inside = (root, target) => {
|
|
28
|
+
const relative = path.relative(root, target);
|
|
29
|
+
return (
|
|
30
|
+
relative === "" ||
|
|
31
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
32
|
+
relative !== ".." &&
|
|
33
|
+
!path.isAbsolute(relative))
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Reject repository custody, including a path spelled through a symlink or junction. */
|
|
38
|
+
export function outsideRepository(repoRoot, target) {
|
|
39
|
+
const root = fs.realpathSync(repoRoot);
|
|
40
|
+
let ancestor = path.resolve(target);
|
|
41
|
+
while (!fs.existsSync(ancestor)) {
|
|
42
|
+
const parent = path.dirname(ancestor);
|
|
43
|
+
if (parent === ancestor)
|
|
44
|
+
throw new Error("cannot resolve the private storage root");
|
|
45
|
+
ancestor = parent;
|
|
46
|
+
}
|
|
47
|
+
const resolved = path.resolve(
|
|
48
|
+
fs.realpathSync(ancestor),
|
|
49
|
+
path.relative(ancestor, path.resolve(target)),
|
|
50
|
+
);
|
|
51
|
+
if (
|
|
52
|
+
inside(root, resolved) ||
|
|
53
|
+
inside(path.resolve(repoRoot), path.resolve(target))
|
|
54
|
+
)
|
|
55
|
+
throw new Error("private storage must be outside the repository");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Create private directories without following a symlink at any storage component. */
|
|
59
|
+
export function privateDirectory(repoRoot, directory) {
|
|
60
|
+
outsideRepository(repoRoot, directory);
|
|
61
|
+
const missing = [];
|
|
62
|
+
for (let at = directory; !fs.existsSync(at); at = path.dirname(at))
|
|
63
|
+
missing.push(at);
|
|
64
|
+
for (const at of missing.reverse()) fs.mkdirSync(at, { mode: 0o700 });
|
|
65
|
+
for (let at = directory; ; at = path.dirname(at)) {
|
|
66
|
+
const stat = fs.lstatSync(at);
|
|
67
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
68
|
+
throw new Error("private storage must use ordinary directories");
|
|
69
|
+
if (path.dirname(at) === at) break;
|
|
70
|
+
}
|
|
71
|
+
outsideRepository(repoRoot, directory);
|
|
72
|
+
if (process.platform !== "win32") fs.chmodSync(directory, 0o700);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The declared project owns a separate environment directory on the local machine. */
|
|
76
|
+
export function materializedDirectory(repoRoot, environment, env, platform) {
|
|
77
|
+
let project;
|
|
78
|
+
const declaration = path.join(repoRoot, "hfs.json");
|
|
79
|
+
if (fs.existsSync(declaration))
|
|
80
|
+
project = JSON.parse(fs.readFileSync(declaration, "utf8")).project;
|
|
81
|
+
if (!project && fs.existsSync(path.join(repoRoot, "package.json")))
|
|
82
|
+
project = JSON.parse(
|
|
83
|
+
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"),
|
|
84
|
+
).name;
|
|
85
|
+
project ||= path.basename(repoRoot);
|
|
86
|
+
if (
|
|
87
|
+
typeof project !== "string" ||
|
|
88
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(project) ||
|
|
89
|
+
project === ".."
|
|
90
|
+
)
|
|
91
|
+
throw new Error("the project name must be a safe local directory name");
|
|
92
|
+
const directory = path.join(
|
|
93
|
+
secretRoots(env, platform).local,
|
|
94
|
+
project,
|
|
95
|
+
environment,
|
|
96
|
+
);
|
|
97
|
+
privateDirectory(repoRoot, directory);
|
|
98
|
+
return directory;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Atomically publish one KEY file; an existing link or hard link is never overwritten. */
|
|
102
|
+
export function writeSecretFile(directory, key, value) {
|
|
103
|
+
const file = path.join(directory, key);
|
|
104
|
+
try {
|
|
105
|
+
const stat = fs.lstatSync(file);
|
|
106
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)
|
|
107
|
+
throw new Error("a private KEY file must be an ordinary file");
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error.code !== "ENOENT") throw error;
|
|
110
|
+
}
|
|
111
|
+
const temporary = path.join(directory, `.${key}-${randomUUID()}.tmp`);
|
|
112
|
+
try {
|
|
113
|
+
fs.writeFileSync(temporary, value, {
|
|
114
|
+
flag: "wx",
|
|
115
|
+
mode: 0o600,
|
|
116
|
+
flush: true,
|
|
117
|
+
});
|
|
118
|
+
fs.renameSync(temporary, file);
|
|
119
|
+
if (process.platform !== "win32") fs.chmodSync(file, 0o600);
|
|
120
|
+
} finally {
|
|
121
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
122
|
+
}
|
|
123
|
+
return file;
|
|
124
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// secret-init.mjs — initialize the public app rule and an age identity outside Git.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseYaml, stringifyYaml } from "../../engine/yaml.mjs";
|
|
5
|
+
import { resolveSops } from "../api/sops/resolve-sops.mjs";
|
|
6
|
+
import { resolveRealTool } from "../api/process/resolve-real-tool.mjs";
|
|
7
|
+
import { runProgram } from "../api/process/run-program.mjs";
|
|
8
|
+
import { claimFile } from "../api/fs/claim-file.mjs";
|
|
9
|
+
import {
|
|
10
|
+
outsideRepository,
|
|
11
|
+
privateDirectory,
|
|
12
|
+
secretRoots,
|
|
13
|
+
} from "./secret-files.mjs";
|
|
14
|
+
|
|
15
|
+
const PATH_RULE = String.raw`\.starcistacks[/\\][^/\\]+[/\\]secrets[/\\][^/\\]+\.enc$`;
|
|
16
|
+
|
|
17
|
+
/** Select the first matching public age rule, including for SOPS's isolated CI identity path. */
|
|
18
|
+
export function configuredRecipients(repoRoot, file) {
|
|
19
|
+
let config;
|
|
20
|
+
try {
|
|
21
|
+
config = parseYaml(
|
|
22
|
+
fs.readFileSync(path.join(repoRoot, ".sops.yaml"), "utf8"),
|
|
23
|
+
);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"a valid .sops.yaml is required; run starci app secret init",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const relative = path.relative(repoRoot, file).split(path.sep).join("/");
|
|
30
|
+
if (!Array.isArray(config?.creation_rules))
|
|
31
|
+
throw new Error(
|
|
32
|
+
".sops.yaml requires creation_rules; run starci app secret init",
|
|
33
|
+
);
|
|
34
|
+
const rule = config.creation_rules.find((entry) => {
|
|
35
|
+
try {
|
|
36
|
+
return (
|
|
37
|
+
!entry.path_regex ||
|
|
38
|
+
new RegExp(entry.path_regex).test(relative) ||
|
|
39
|
+
new RegExp(entry.path_regex).test(relative.replaceAll("/", "\\"))
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(".sops.yaml has an invalid path_regex");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const recipients =
|
|
46
|
+
typeof rule?.age === "string"
|
|
47
|
+
? rule.age.split(",").map((value) => value.trim())
|
|
48
|
+
: [];
|
|
49
|
+
if (
|
|
50
|
+
!recipients.length ||
|
|
51
|
+
recipients.some((value) => !/^age1[a-z0-9]+$/.test(value))
|
|
52
|
+
)
|
|
53
|
+
throw new Error(
|
|
54
|
+
".sops.yaml must supply public age recipients for this secret; run starci app secret init",
|
|
55
|
+
);
|
|
56
|
+
return recipients;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve required tools without exposing subprocess output (which can contain private data). */
|
|
60
|
+
export function secretTools(env, tools = {}) {
|
|
61
|
+
const sops = (tools.resolveSops ?? resolveSops)(env);
|
|
62
|
+
if (!sops)
|
|
63
|
+
throw new Error(
|
|
64
|
+
"sops is not installed; install SOPS and put sops on PATH (Windows: winget install Mozilla.SOPS)",
|
|
65
|
+
);
|
|
66
|
+
const resolve = tools.resolveRealTool ?? resolveRealTool;
|
|
67
|
+
const age = resolve("age", { env });
|
|
68
|
+
const keygen = resolve("age-keygen", { env });
|
|
69
|
+
if (!age || !keygen)
|
|
70
|
+
throw new Error(
|
|
71
|
+
"age or age-keygen is not installed; install age and put age and age-keygen on PATH",
|
|
72
|
+
);
|
|
73
|
+
return { sops, keygen, run: tools.runProgram ?? runProgram };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readInitializationConfig(configFile) {
|
|
77
|
+
let config = {};
|
|
78
|
+
try {
|
|
79
|
+
if (fs.existsSync(configFile))
|
|
80
|
+
config = parseYaml(fs.readFileSync(configFile, "utf8")) ?? {};
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(
|
|
83
|
+
".sops.yaml is invalid; repair it before initializing secrets",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
typeof config !== "object" ||
|
|
88
|
+
Array.isArray(config) ||
|
|
89
|
+
(config.creation_rules !== undefined &&
|
|
90
|
+
!Array.isArray(config.creation_rules))
|
|
91
|
+
)
|
|
92
|
+
throw new Error(".sops.yaml must hold a creation_rules list");
|
|
93
|
+
return config;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Reuse an existing matching age rule; initialization must not rotate an app's recipient. */
|
|
97
|
+
export function initializeSecrets(repoRoot, { env, platform, tools, stdout }) {
|
|
98
|
+
const tool = secretTools(env, tools);
|
|
99
|
+
const configFile = path.join(repoRoot, ".sops.yaml");
|
|
100
|
+
const config = readInitializationConfig(configFile);
|
|
101
|
+
const rules = config.creation_rules ?? [];
|
|
102
|
+
const canonical = rules.find(
|
|
103
|
+
(rule) =>
|
|
104
|
+
rule?.path_regex === PATH_RULE &&
|
|
105
|
+
typeof rule.age === "string" &&
|
|
106
|
+
rule.age.trim(),
|
|
107
|
+
);
|
|
108
|
+
const existing =
|
|
109
|
+
canonical ??
|
|
110
|
+
rules.find((rule) => {
|
|
111
|
+
if (typeof rule?.age !== "string" || !rule.age.trim()) return false;
|
|
112
|
+
try {
|
|
113
|
+
return (
|
|
114
|
+
!rule.path_regex ||
|
|
115
|
+
new RegExp(rule.path_regex).test(
|
|
116
|
+
".starcistacks/production/secrets/app.enc",
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (
|
|
124
|
+
existing?.age
|
|
125
|
+
.split(",")
|
|
126
|
+
.some((value) => !/^age1[a-z0-9]+$/.test(value.trim()))
|
|
127
|
+
)
|
|
128
|
+
throw new Error(
|
|
129
|
+
".sops.yaml must name valid public age recipients; initialization never prints private key material",
|
|
130
|
+
);
|
|
131
|
+
const identity = secretRoots(env, platform).identity;
|
|
132
|
+
outsideRepository(repoRoot, identity);
|
|
133
|
+
privateDirectory(repoRoot, path.dirname(identity));
|
|
134
|
+
const held = claimFile(identity);
|
|
135
|
+
if (!held.ok)
|
|
136
|
+
throw new Error(
|
|
137
|
+
"the age identity is being initialized; retry after that operation settles",
|
|
138
|
+
);
|
|
139
|
+
let recipient;
|
|
140
|
+
try {
|
|
141
|
+
if (!fs.existsSync(identity)) {
|
|
142
|
+
const generated = tool.run(tool.keygen, ["-o", identity], {
|
|
143
|
+
env,
|
|
144
|
+
cwd: repoRoot,
|
|
145
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
146
|
+
});
|
|
147
|
+
if (generated.error || generated.status !== 0)
|
|
148
|
+
throw new Error(
|
|
149
|
+
"age-keygen could not create the private identity outside the repository",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const stat = fs.lstatSync(identity);
|
|
153
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)
|
|
154
|
+
throw new Error("the age identity must be an ordinary private file");
|
|
155
|
+
if (process.platform !== "win32") fs.chmodSync(identity, 0o600);
|
|
156
|
+
const publicKey = tool.run(tool.keygen, ["-y", identity], {
|
|
157
|
+
env,
|
|
158
|
+
cwd: repoRoot,
|
|
159
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
160
|
+
});
|
|
161
|
+
if (publicKey.error || publicKey.status !== 0)
|
|
162
|
+
throw new Error(
|
|
163
|
+
"age-keygen could not read the public recipient from the existing identity",
|
|
164
|
+
);
|
|
165
|
+
const recipients = String(publicKey.stdout ?? "")
|
|
166
|
+
.trim()
|
|
167
|
+
.split(/\s+/);
|
|
168
|
+
if (
|
|
169
|
+
!recipients.length ||
|
|
170
|
+
recipients.some((value) => !/^age1[a-z0-9]+$/.test(value))
|
|
171
|
+
)
|
|
172
|
+
throw new Error("age-keygen returned an invalid public recipient");
|
|
173
|
+
recipient = existing?.age ?? recipients.join(",");
|
|
174
|
+
} finally {
|
|
175
|
+
held.release();
|
|
176
|
+
}
|
|
177
|
+
const exact = rules.findIndex((rule) => rule?.path_regex === PATH_RULE);
|
|
178
|
+
if (exact >= 0) rules[exact] = { ...rules[exact], age: recipient };
|
|
179
|
+
// SOPS uses the first matching rule: keep any existing environment-specific recipients in force.
|
|
180
|
+
else rules.push({ path_regex: PATH_RULE, age: recipient });
|
|
181
|
+
config.creation_rules = rules;
|
|
182
|
+
fs.writeFileSync(
|
|
183
|
+
configFile,
|
|
184
|
+
`# Initialized by starci app secret init; private age identities stay outside the repo.\n${stringifyYaml(config)}`,
|
|
185
|
+
);
|
|
186
|
+
stdout(`secrets initialized; public recipient: ${recipient}\n`);
|
|
187
|
+
if (existing)
|
|
188
|
+
stdout(
|
|
189
|
+
"existing app recipient retained; use its matching age identity for decryption\n",
|
|
190
|
+
);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// secret-output.mjs - list and materialize secret names and private KEY files.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { byCodeUnit } from "../lib/list.mjs";
|
|
5
|
+
import { materializedDirectory, writeSecretFile } from "./secret-files.mjs";
|
|
6
|
+
import { envelopeOf, validateKey } from "./secret-document.mjs";
|
|
7
|
+
import { fileOf, readMap, sealedFiles } from "./secret-store.mjs";
|
|
8
|
+
import { SecretError, publicStorage } from "./secret-errors.mjs";
|
|
9
|
+
|
|
10
|
+
function validateWindowsKeys(map) {
|
|
11
|
+
const names = new Set();
|
|
12
|
+
for (const key of Object.keys(map)) {
|
|
13
|
+
const normalized = key.toUpperCase();
|
|
14
|
+
if (names.has(normalized))
|
|
15
|
+
throw new SecretError(
|
|
16
|
+
"KEY names differing only by case cannot be materialized on Windows",
|
|
17
|
+
);
|
|
18
|
+
if (/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/.test(normalized))
|
|
19
|
+
throw new SecretError(
|
|
20
|
+
"a KEY uses a reserved Windows file name and cannot be materialized",
|
|
21
|
+
);
|
|
22
|
+
names.add(normalized);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function exportValues(
|
|
27
|
+
map,
|
|
28
|
+
{
|
|
29
|
+
repoRoot,
|
|
30
|
+
environment,
|
|
31
|
+
env,
|
|
32
|
+
platform,
|
|
33
|
+
stdout,
|
|
34
|
+
fileOutput = stdout,
|
|
35
|
+
reveal = false,
|
|
36
|
+
toFile = true,
|
|
37
|
+
},
|
|
38
|
+
) {
|
|
39
|
+
if (toFile && platform === "win32") validateWindowsKeys(map);
|
|
40
|
+
const directory = toFile
|
|
41
|
+
? publicStorage(() =>
|
|
42
|
+
materializedDirectory(repoRoot, environment, env, platform),
|
|
43
|
+
)
|
|
44
|
+
: null;
|
|
45
|
+
for (const key of Object.keys(map).sort(byCodeUnit)) {
|
|
46
|
+
if (toFile) {
|
|
47
|
+
const file = publicStorage(() =>
|
|
48
|
+
writeSecretFile(directory, key, map[key]),
|
|
49
|
+
);
|
|
50
|
+
fileOutput(`${key}_FILE=${file}\n`);
|
|
51
|
+
}
|
|
52
|
+
if (reveal) stdout(`${key}=${map[key]}\n`);
|
|
53
|
+
}
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listSecrets(directory, stdout) {
|
|
58
|
+
const keys = new Set();
|
|
59
|
+
for (const name of sealedFiles(directory)) {
|
|
60
|
+
for (const item of envelopeOf(
|
|
61
|
+
fs.readFileSync(path.join(directory, name), "utf8"),
|
|
62
|
+
).keys) {
|
|
63
|
+
validateKey(item);
|
|
64
|
+
keys.add(item);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const item of [...keys].sort(byCodeUnit)) stdout(`${item}\n`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function materializeSecrets(directory, sops, output) {
|
|
72
|
+
const map = Object.create(null);
|
|
73
|
+
for (const name of sealedFiles(directory)) {
|
|
74
|
+
for (const [item, value] of Object.entries(
|
|
75
|
+
readMap(fileOf(directory, name.slice(0, -4)), sops),
|
|
76
|
+
)) {
|
|
77
|
+
if (Object.hasOwn(map, item))
|
|
78
|
+
throw new SecretError(
|
|
79
|
+
`duplicate KEY ${item} across secret slugs; each materialized KEY needs one owner`,
|
|
80
|
+
);
|
|
81
|
+
map[item] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return exportValues(map, output);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getSecret(file, key, sops, opts, output, stderr) {
|
|
88
|
+
let map = readMap(file, sops);
|
|
89
|
+
if (key !== undefined) {
|
|
90
|
+
if (!Object.hasOwn(map, key))
|
|
91
|
+
throw new SecretError(`the secret has no KEY ${key}`);
|
|
92
|
+
map = { [key]: map[key] };
|
|
93
|
+
}
|
|
94
|
+
return exportValues(map, {
|
|
95
|
+
...output,
|
|
96
|
+
fileOutput: stderr,
|
|
97
|
+
reveal: opts.reveal,
|
|
98
|
+
toFile: opts["to-file"] === true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// secret-sops.mjs - runtime SOPS adapter and age identity selection.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runProgram } from "../api/process/run-program.mjs";
|
|
5
|
+
import { resolveRealTool } from "../api/process/resolve-real-tool.mjs";
|
|
6
|
+
import { decrypt as sopsDecrypt } from "../api/sops/decrypt.mjs";
|
|
7
|
+
import { seal as sopsSeal } from "../api/sops/seal.mjs";
|
|
8
|
+
import { invocationDir } from "../lib/roots.mjs";
|
|
9
|
+
import { configuredRecipients, secretTools } from "./secret-init.mjs";
|
|
10
|
+
import { outsideRepository, secretRoots } from "./secret-files.mjs";
|
|
11
|
+
import { SecretError, publicStorage } from "./secret-errors.mjs";
|
|
12
|
+
|
|
13
|
+
const sopsInvocation = Object.freeze({ runProgram, resolveRealTool });
|
|
14
|
+
|
|
15
|
+
/** The default sops seam: the runtime's sops api, which finds the binary on this machine. */
|
|
16
|
+
export function defaultSops(
|
|
17
|
+
env = process.env,
|
|
18
|
+
repoRoot = invocationDir(),
|
|
19
|
+
platform = process.platform,
|
|
20
|
+
tools,
|
|
21
|
+
) {
|
|
22
|
+
const { sops: binary } = publicStorage(() => secretTools(env, tools));
|
|
23
|
+
const selectedEnv = { ...env };
|
|
24
|
+
const checkIdentity = (file) =>
|
|
25
|
+
publicStorage(() => {
|
|
26
|
+
outsideRepository(repoRoot, file);
|
|
27
|
+
if (fs.existsSync(file)) {
|
|
28
|
+
const stat = fs.lstatSync(file);
|
|
29
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)
|
|
30
|
+
throw new Error(
|
|
31
|
+
"the age identity must be an ordinary private file without hard links",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
for (const [name, value] of Object.entries(env))
|
|
36
|
+
if (/^SOPS_AGE_KEY_FILE$/i.test(name) && value) {
|
|
37
|
+
if (!path.isAbsolute(value))
|
|
38
|
+
throw new SecretError(
|
|
39
|
+
"SOPS_AGE_KEY_FILE must be an absolute path outside the repository",
|
|
40
|
+
);
|
|
41
|
+
checkIdentity(value);
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
!Object.keys(env).some((name) => /^SOPS_AGE_KEY(?:_FILE)?$/i.test(name))
|
|
45
|
+
) {
|
|
46
|
+
const identity = secretRoots(env, platform).identity;
|
|
47
|
+
checkIdentity(identity);
|
|
48
|
+
if (fs.existsSync(identity)) selectedEnv.SOPS_AGE_KEY_FILE = identity;
|
|
49
|
+
}
|
|
50
|
+
const refuse = (what, result) => {
|
|
51
|
+
if (result.error?.identityRefusal)
|
|
52
|
+
return Object.assign(new SecretError(result.error.message), {
|
|
53
|
+
identityRefusal: result.error.identityRefusal,
|
|
54
|
+
});
|
|
55
|
+
if (result.error?.code === "SOPS_MISSING")
|
|
56
|
+
return new SecretError(result.error.message);
|
|
57
|
+
return new SecretError(
|
|
58
|
+
`sops could not ${what} (exit ${String(result.status)})`,
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
decrypt(file, format) {
|
|
63
|
+
const result = sopsDecrypt(
|
|
64
|
+
binary,
|
|
65
|
+
["decrypt", "--input-type", format, "--output-type", "dotenv", file],
|
|
66
|
+
{ env: selectedEnv, cwd: repoRoot, invocation: sopsInvocation },
|
|
67
|
+
);
|
|
68
|
+
if (result.status !== 0)
|
|
69
|
+
throw refuse(
|
|
70
|
+
`decrypt ${path.basename(file)}; is the age identity installed?`,
|
|
71
|
+
result,
|
|
72
|
+
);
|
|
73
|
+
const map = Object.create(null);
|
|
74
|
+
for (const line of String(result.stdout).split(/\r?\n/u)) {
|
|
75
|
+
if (!line || line.startsWith("#")) continue;
|
|
76
|
+
const match = /^([A-Za-z_]\w*)=(.*)$/u.exec(line);
|
|
77
|
+
if (!match || Object.hasOwn(map, match[1]))
|
|
78
|
+
throw new SecretError("sops returned an invalid dotenv document");
|
|
79
|
+
map[match[1]] = match[2].replaceAll(String.raw`\n`, "\n");
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
},
|
|
83
|
+
seal(request) {
|
|
84
|
+
const recipients = request.recipients.length
|
|
85
|
+
? request.recipients
|
|
86
|
+
: publicStorage(() =>
|
|
87
|
+
configuredRecipients(repoRoot, request.filenameOverride),
|
|
88
|
+
);
|
|
89
|
+
const result = sopsSeal(
|
|
90
|
+
binary,
|
|
91
|
+
{ ...request, recipients },
|
|
92
|
+
{ env: selectedEnv, cwd: repoRoot, invocation: sopsInvocation },
|
|
93
|
+
);
|
|
94
|
+
if (result.status !== 0) throw refuse("seal the secret", result);
|
|
95
|
+
return result.stdout;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|