@starci/eslint-canon-be 3.0.8 → 3.0.9

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 (147) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +12 -6
  3. package/ambient-id.mjs +90 -0
  4. package/cli.mjs +181 -0
  5. package/comments.mjs +18 -21
  6. package/config-owner.mjs +20 -4
  7. package/connections.mjs +12 -12
  8. package/cookies.mjs +106 -0
  9. package/cqrs.mjs +22 -26
  10. package/data-access.mjs +9 -28
  11. package/default-deny.mjs +2 -1
  12. package/e2e-flow.mjs +4 -4
  13. package/event-bus.mjs +237 -0
  14. package/feature-thin.mjs +237 -0
  15. package/idempotency.mjs +3 -1
  16. package/index.mjs +22 -1
  17. package/infra-owner.mjs +1 -1
  18. package/injection.mjs +67 -22
  19. package/input-bounds.mjs +2 -1
  20. package/jobs.mjs +214 -0
  21. package/lib/ast.mjs +7 -0
  22. package/lib/config.mjs +1 -1
  23. package/lib/db-writes.mjs +1 -1
  24. package/lib/doors.mjs +81 -0
  25. package/lib/edition.mjs +45 -0
  26. package/lib/entity-writes.mjs +35 -0
  27. package/lib/path.mjs +0 -6
  28. package/lib/persistence.mjs +11 -18
  29. package/lib/ports.mjs +5 -2
  30. package/lib/specifier.mjs +27 -0
  31. package/lib/transactions.mjs +37 -0
  32. package/lib/transport-slots.mjs +8 -3
  33. package/lib/unit-spec.mjs +63 -2
  34. package/module-layering.mjs +3 -24
  35. package/module-shape.mjs +2 -2
  36. package/naming.mjs +2 -2
  37. package/package.json +3 -1
  38. package/project-graph.mjs +5 -2
  39. package/projections.mjs +117 -0
  40. package/query-safety.mjs +1 -1
  41. package/realtime.mjs +181 -0
  42. package/runtime/engine/config.mjs +494 -0
  43. package/runtime/engine/invalid-config.mjs +15 -0
  44. package/runtime/engine/orca-config.mjs +27 -0
  45. package/runtime/knowledge/hfs/canon-pins.yaml +160 -29
  46. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  47. package/runtime/knowledge/hfs/slots.yaml +912 -120
  48. package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
  49. package/runtime/knowledge/sonar-gate.yaml +52 -18
  50. package/runtime/modules/models/registry.yaml +591 -0
  51. package/runtime/modules/models/runtimes.yaml +701 -0
  52. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  53. package/runtime/scripts/api/git/config-get.mjs +10 -0
  54. package/runtime/scripts/api/git/lib.mjs +16 -26
  55. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  56. package/runtime/scripts/api/git/remote.mjs +5 -0
  57. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  58. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  59. package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
  60. package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
  61. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
  62. package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
  63. package/runtime/scripts/hfs/architecture/config.mjs +9 -6
  64. package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
  65. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  66. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  67. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  68. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  69. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  70. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
  71. package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
  72. package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
  73. package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
  74. package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
  75. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
  76. package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
  77. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  78. package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
  79. package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
  80. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
  81. package/runtime/scripts/hfs/architecture/index.mjs +17 -4
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
  85. package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
  86. package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
  87. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
  88. package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
  89. package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
  90. package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
  91. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  92. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  93. package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
  94. package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
  95. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  96. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  97. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  98. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  99. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  100. package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
  101. package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
  102. package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
  103. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
  104. package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
  105. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  106. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  107. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  108. package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
  109. package/runtime/scripts/hfs/param-names.mjs +18 -0
  110. package/runtime/scripts/hfs/path-findings.mjs +1 -1
  111. package/runtime/scripts/hfs/project-graph.mjs +3 -3
  112. package/runtime/scripts/hfs/repo-identity.mjs +9 -6
  113. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  114. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
  115. package/runtime/scripts/hfs/rules/read.mjs +1 -1
  116. package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
  117. package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
  118. package/runtime/scripts/hfs/slots.mjs +78 -108
  119. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  120. package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
  121. package/runtime/scripts/hfs/view.mjs +3 -2
  122. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  123. package/runtime/scripts/lib/git.mjs +36 -0
  124. package/runtime/scripts/lib/i18n.mjs +86 -0
  125. package/runtime/scripts/lib/json.mjs +13 -3
  126. package/runtime/scripts/lib/language.mjs +24 -5
  127. package/runtime/scripts/lib/list.mjs +20 -0
  128. package/runtime/scripts/lib/path-key.mjs +57 -2
  129. package/runtime/scripts/lib/read-text.mjs +34 -0
  130. package/runtime/scripts/lib/regex.mjs +4 -0
  131. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  132. package/runtime/scripts/lib/sops-envelope.mjs +58 -0
  133. package/runtime/scripts/lib/stack-services.mjs +7 -31
  134. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  135. package/runtime/scripts/lib/walk.mjs +4 -3
  136. package/schema-authority.mjs +14 -12
  137. package/service-deps.mjs +7 -13
  138. package/service-isolation.mjs +72 -0
  139. package/size-budget.mjs +1 -1
  140. package/test-world.mjs +5 -4
  141. package/testing.mjs +27 -24
  142. package/transport.mjs +12 -9
  143. package/unit-spec.mjs +1 -1
  144. package/user-copy.mjs +5 -4
  145. package/webhooks.mjs +173 -0
  146. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  147. package/runtime/scripts/lib/test-secrets.mjs +0 -116
package/webhooks.mjs ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The rules that hold the webhook kind (R182 `BE_WEBHOOK_SHAPE`, R183 `BE_WEBHOOK_UNVERIFIED`).
3
+ *
4
+ * A provider webhook (`src/features/webhooks/<provider>/transport/http/<provider>.webhook.ts`, slot `be.feature.webhooks.http`) is a signed intake door. It
5
+ * proves the delivery came from the provider, then hands it to exactly ONE domain intake method, which records it and
6
+ * publishes the event inside its own transaction (`eventBus.publish(event, tx)` is called only from `modules/domain`). The door
7
+ * writes nothing and decides nothing:
8
+ *
9
+ * - `webhook-shape` (R182): the door is one `@Controller` with one `@Post` handler, `@Public({ reason: PublicReason.SignedWebhook })`;
10
+ * it injects only the `WebhookSignatureService` of `platform/http-security` and services of `modules/domain`; its handler has no
11
+ * branch, loop or `try`, makes exactly one call on a domain service, calls on the signature service only `verify`, and returns
12
+ * nothing.
13
+ * - `webhook-verify-first` (R183): the first statement of the handler is the unconditional `verify` call, so no path reaches the
14
+ * intake with an unproven delivery.
15
+ *
16
+ * What a receiver is comes from its TYPE (where the class is declared); what a decorator is comes from the import that binds it.
17
+ * No rule here tests a directory spelled in a path pattern or a variable name.
18
+ */
19
+ import { walk } from "./lib/ast.mjs"
20
+ import { callsOf, decoratorsFrom, hasClassDecorator, injectedMembers, methodsDecoratedBy } from "./lib/doors.mjs"
21
+ import { hfsOf } from "./lib/hfs.mjs"
22
+ import { decoratorArguments, decoratorCallee, importOf } from "./lib/import-source.mjs"
23
+ import { baseName, enumMemberOf, isOwnedType } from "./lib/ports.mjs"
24
+ import { typeOrigins } from "./lib/types.mjs"
25
+
26
+ /** The slot of provider webhook doors. */
27
+ const SLOT = "be.feature.webhooks.http"
28
+
29
+ /** The HTTP verbs of `@nestjs/common`; only `Post` is a webhook method. */
30
+ const HTTP_VERBS = ["Get", "Post", "Put", "Patch", "Delete", "All", "Options", "Head"]
31
+
32
+ /** Statements and expressions that are a decision or a repetition: none of them belongs in a door. */
33
+ const DECISION_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement"])
34
+
35
+ /** The `WebhookSignatureService` of `platform/http-security`. */
36
+ const isSignatureType = (context, node) => isOwnedType(context, node, { name: "WebhookSignatureService", capability: "http-security", tier: "platform" })
37
+
38
+ /** A service of `modules/domain`: a class declared in a `<name>.service.ts` of the domain tier. */
39
+ const isDomainServiceType = (context, hfs, node) =>
40
+ typeOrigins(context, node).some((origin) => origin.module === null && hfs.tierOf(origin.file) === "domain" && baseName(origin.file).endsWith(".service.ts"))
41
+
42
+ /** The `@Controller` classes of a webhook file, with their handler methods. */
43
+ const doorsOf = (context, program) => {
44
+ const doors = []
45
+ walk(program, (node) => {
46
+ if (node.type !== "ClassDeclaration") return
47
+ if (hasClassDecorator(context, node, "@nestjs/common", "Controller")) doors.push(node)
48
+ })
49
+ return doors
50
+ }
51
+
52
+ /** The route methods of a controller: every method carrying an HTTP verb decorator. */
53
+ const routeMethods = (context, controller) => methodsDecoratedBy(context, controller, "@nestjs/common", HTTP_VERBS)
54
+
55
+ /** The handler of a webhook door: its `@Post` method, or null when it has none. */
56
+ const handlerOf = (context, controller) => methodsDecoratedBy(context, controller, "@nestjs/common", "Post")[0] ?? null
57
+
58
+ /** True when the method carries `@Public({ reason: PublicReason.SignedWebhook })`: the reason is read from the enum member the argument is. */
59
+ const isSignedWebhookDoor = (context, method) =>
60
+ (method.decorators ?? []).some((decorator) => {
61
+ const callee = decoratorCallee(decorator)
62
+ if (callee?.type !== "Identifier" || importOf(context, callee) === null) return false
63
+ const options = decoratorArguments(decorator)[0]
64
+ if (options?.type !== "ObjectExpression") return false
65
+ const reason = options.properties.find((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "reason")
66
+ const member = reason ? enumMemberOf(context, reason.value) : null
67
+ return member !== null && member.enumName === "PublicReason" && member.member === "SignedWebhook"
68
+ })
69
+
70
+ /** A provider webhook door: one signed `@Post`, the signature proof, one domain intake call, nothing else. */
71
+ export const webhookShape = {
72
+ meta: {
73
+ type: "problem",
74
+ docs: { description: "A webhook door is one `@Controller` with one `@Public({ reason: PublicReason.SignedWebhook })` `@Post` handler that injects only the `WebhookSignatureService` and domain services, verifies, makes exactly one domain call and returns nothing." },
75
+ schema: [],
76
+ messages: {
77
+ noDoor: "A webhook file declares one `@Controller` class: the door of the provider. Nothing else lives in `<provider>.webhook.ts`.",
78
+ injects: "`{{what}}` is injected into a webhook door. A webhook injects only the `WebhookSignatureService` of `platform/http-security` and services of `modules/domain`; a bus, a repository, an `EntityManager` or a queue never reaches it. The intake is one domain service method that records the delivery and publishes the event inside its own transaction.",
79
+ route: "A webhook door has exactly one `@Post` handler (this class has {{count}} route methods{{verbs}}). One provider endpoint is one door; another route belongs in its own provider folder or in an api feature.",
80
+ notPublic: "`{{name}}` is not `@Public({ reason: PublicReason.SignedWebhook })`. The signature is the authentication of a webhook: it is public by that declaration and no other, so the open doors stay readable and the strict rate tier applies.",
81
+ branch: "`{{what}}` is a decision or a loop in a webhook door. The door proves the delivery and hands it over: a refusal is the thrown error of `verify`, and every other decision belongs in the domain intake method.",
82
+ call: "`{{what}}` is a call a webhook door does not make. The handler calls `verify` of the `WebhookSignatureService` once and one method of a domain service once; parsing, mapping and publishing belong in the domain service.",
83
+ intakeCalls: "`{{name}}` calls domain services {{count}} times. A webhook hands the verified delivery to exactly one domain intake method (it records the delivery and publishes the event in one transaction); a second call is orchestration that belongs in that method.",
84
+ returns: "`{{name}}` returns a value. A webhook acknowledges with its status and an empty body; it returns nothing of the domain to the provider.",
85
+ },
86
+ },
87
+ create(context) {
88
+ const hfs = hfsOf(context)
89
+ if (hfs.slotOf(context.filename) !== SLOT || !baseName(context.filename).endsWith(".webhook.ts")) return {}
90
+ return {
91
+ "Program:exit"(program) {
92
+ const doors = doorsOf(context, program)
93
+ if (doors.length !== 1) {
94
+ context.report({ node: program, messageId: "noDoor" })
95
+ return
96
+ }
97
+ const door = doors[0]
98
+ for (const { node, annotation } of injectedMembers(context, door)) {
99
+ if (!annotation || !(isSignatureType(context, annotation) || isDomainServiceType(context, hfs, annotation))) context.report({ node, messageId: "injects", data: { what: context.sourceCode.getText(node) } })
100
+ }
101
+ const routes = routeMethods(context, door)
102
+ const handler = handlerOf(context, door)
103
+ if (routes.length !== 1 || handler === null) {
104
+ const verbs = routes.length > 0 ? ` (${routes.map((route) => decoratorsFrom(context, route, "@nestjs/common", HTTP_VERBS).map((decorator) => decoratorCallee(decorator).name ?? "route").join("+")).join(", ")})` : ""
105
+ context.report({ node: door, messageId: "route", data: { count: String(routes.length), verbs } })
106
+ return
107
+ }
108
+ const name = handler.key.type === "Identifier" ? handler.key.name : "the handler"
109
+ if (!isSignedWebhookDoor(context, handler)) context.report({ node: handler.key, messageId: "notPublic", data: { name } })
110
+ walk(handler.value.body, (child) => {
111
+ if (DECISION_NODES.has(child.type)) context.report({ node: child, messageId: "branch", data: { what: child.type === "LogicalExpression" ? child.operator : child.type.replace(/(Statement|Expression)$/, "").toLowerCase() } })
112
+ else if (child.type === "ReturnStatement" && child.argument) context.report({ node: child, messageId: "returns", data: { name } })
113
+ })
114
+ let intake = 0
115
+ for (const { call, receiver, method } of callsOf(handler)) {
116
+ if (receiver !== null && isSignatureType(context, receiver) && method === "verify") continue
117
+ if (receiver !== null && isDomainServiceType(context, hfs, receiver)) {
118
+ intake += 1
119
+ continue
120
+ }
121
+ context.report({ node: call, messageId: "call", data: { what: context.sourceCode.getText(call.callee) } })
122
+ }
123
+ if (intake !== 1) context.report({ node: handler.key, messageId: "intakeCalls", data: { name, count: String(intake) } })
124
+ },
125
+ }
126
+ },
127
+ }
128
+
129
+ /** The first statement of the handler is the unconditional signature proof. */
130
+ export const webhookVerifyFirst = {
131
+ meta: {
132
+ type: "problem",
133
+ docs: { description: "The first statement of a webhook handler is the unconditional `verify` call of the `WebhookSignatureService` (signature and replay window), before anything else runs." },
134
+ schema: [],
135
+ messages: {
136
+ first: "`{{name}}` does not start with `verify` of the `WebhookSignatureService`. The first statement of a webhook handler is `this.<signature>.verify({ rawBody, signature, timestamp })` (awaited when it is async): a delivery that fails the signature or the replay window throws before any other code runs, so no path reaches the intake unproven.",
137
+ },
138
+ },
139
+ create(context) {
140
+ const hfs = hfsOf(context)
141
+ if (hfs.slotOf(context.filename) !== SLOT || !baseName(context.filename).endsWith(".webhook.ts")) return {}
142
+ return {
143
+ "Program:exit"(program) {
144
+ for (const door of doorsOf(context, program)) {
145
+ const handler = handlerOf(context, door)
146
+ if (handler === null) continue
147
+ const first = handler.value.body?.body[0]
148
+ let expression = first?.type === "ExpressionStatement" ? first.expression : null
149
+ if (expression?.type === "AwaitExpression") expression = expression.argument
150
+ const proven = expression?.type === "CallExpression"
151
+ && expression.callee.type === "MemberExpression"
152
+ && !expression.callee.computed
153
+ && expression.callee.property.type === "Identifier"
154
+ && expression.callee.property.name === "verify"
155
+ && isSignatureType(context, expression.callee.object)
156
+ if (!proven) context.report({ node: handler.key, messageId: "first", data: { name: handler.key.type === "Identifier" ? handler.key.name : "the handler" } })
157
+ }
158
+ },
159
+ }
160
+ },
161
+ }
162
+
163
+ /** The rules this law contributes to the plugin. */
164
+ export const rules = {
165
+ "webhook-shape": webhookShape,
166
+ "webhook-verify-first": webhookVerifyFirst,
167
+ }
168
+
169
+ /** Every rule of this law at `error`. */
170
+ export const recommended = {
171
+ "starci-be/webhook-shape": "error",
172
+ "starci-be/webhook-verify-first": "error",
173
+ }
@@ -1,16 +0,0 @@
1
- // decrypt.mjs — one `sops` decryption: the custody reads (scripts/api/sops/exec-env.mjs execEnv) and the test
2
- // secrets (scripts/lib/test-secrets.mjs testSecret). The plaintext stays in this process's memory (stdout over a pipe).
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
6
-
7
- /**
8
- * Run `<bin> <args>` with SOPS_AGE_KEY_FILE defaulted to ~/.starci/master.identity. {status, stdout, stderr, error}.
9
- */
10
- export function sopsDecrypt(bin, args, { env = process.env, cwd = undefined, maxBuffer = 16 * 1024 * 1024 } = {}) {
11
- const r = spawnSync(bin, args, {
12
- cwd, encoding: 'utf8', windowsHide: true, maxBuffer,
13
- env: { ...env, SOPS_AGE_KEY_FILE: env.SOPS_AGE_KEY_FILE || path.join(os.homedir(), '.starci', 'master.identity') },
14
- });
15
- return { status: r.status, stdout: r.stdout, stderr: r.stderr, error: r.error ?? null };
16
- }
@@ -1,116 +0,0 @@
1
- #!/usr/bin/env node
2
- // test-secrets.mjs — test credentials follow the product repository's own `.starcistacks` convention (owner ruling
3
- // push-scan-test-secrets-encrypted, 2026-09-28: they work exactly like the stack custody and a plaintext password is never pushed).
4
- // There is no store of the runtime's own: a test credential lives at `<repo>/.starcistacks/<stack>/secrets/test/<name>`,
5
- // its plaintext git-ignored by the repo's `.starcistacks/**` rules and only the sops-encrypted twin `<name>.enc`
6
- // committed. It is written with the repository's existing command (`node scripts/stack-secret.mjs set
7
- // <stack>/secrets/test/<name>`, npm run secret:set), which encrypts against the recipients `.sops.yaml` names and
8
- // removes the plaintext; this module only READS: the local plaintext when present, else the `.enc` decrypted by sops
9
- // with the shared age identity (SOPS_AGE_KEY_FILE, default ~/.starci/master.identity) into memory.
10
- // A credential that need not be stable across runs (a disposable account registered per run) is generated per run
11
- // and stored nowhere. A value is never logged.
12
- //
13
- // node scripts/lib/test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal] prints nothing unless --reveal
14
- //
15
- // In a script: import { testSecret } from '<runtime>/scripts/lib/test-secrets.mjs';
16
- // const password = testSecret('login-capture-password', { repo });
17
- // The push secret scan (scripts/supervisor/push-mains.mjs) passes a `.enc` only when isSopsEnvelope holds.
18
- import fs from 'node:fs';
19
- import path from 'node:path';
20
- import { sopsDecrypt } from '../api/sops/decrypt.mjs';
21
- import { fileURLToPath } from 'node:url';
22
- import { parseYaml } from '../../engine/yaml.mjs';
23
-
24
- const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
25
- const STACK = /^[a-z0-9][a-z0-9-]{0,31}$/;
26
- /** One sops-encrypted leaf: `ENC[AES256_GCM,data:...,iv:...,tag:...,type:...]` (data may be empty). */
27
- const SOPS_VALUE = /^ENC\[AES256_GCM,data:[A-Za-z0-9+/=]*,iv:[A-Za-z0-9+/=]+,tag:[A-Za-z0-9+/=]+,type:[a-z]+\]$/;
28
-
29
- /** Where test credential `name` of `stack` lives in `repo`: {plain, enc, rel}. `rel` is the path the repo's
30
- * `stack-secret.mjs set` takes. */
31
- export function testSecretPaths(name, { repo, stack = 'dev' } = {}) {
32
- if (!NAME.test(String(name ?? ''))) throw new Error(`test secret name must match ${NAME} (got ${JSON.stringify(name)})`);
33
- if (!STACK.test(String(stack ?? ''))) throw new Error(`stack must match ${STACK} (got ${JSON.stringify(stack)})`);
34
- if (!repo) throw new Error('test secret needs {repo}: the product repository whose .starcistacks holds it');
35
- const rel = `${stack}/secrets/test/${name}`;
36
- const plain = path.join(path.resolve(String(repo)), '.starcistacks', stack, 'secrets', 'test', name);
37
- return { plain, enc: `${plain}.enc`, rel };
38
- }
39
-
40
- /** The command that stores a test credential, in the product repository's own tooling. */
41
- export const setCommand = (name = '<name>', stack = 'dev') => `node scripts/stack-secret.mjs set ${stack}/secrets/test/${name}`;
42
-
43
- /** sops' format for a plaintext path, as the repository's stack-secret.mjs formatFor decides it. */
44
- export const sopsFormatFor = (file) => (file.endsWith('.env') ? 'dotenv' : /\.json$/.test(file) ? 'json' : /\.(ya?ml|kubeconfig)$/.test(file) ? 'yaml' : 'binary');
45
-
46
- /** The sops binary: PATH, then winget's Links and Packages (spawn without a shell ignores PATHEXT). */
47
- export function resolveSops(env = process.env) {
48
- const win = process.platform === 'win32';
49
- const names = win ? ['sops.exe', 'sops'] : ['sops'];
50
- const dirs = String(env.PATH ?? '').split(win ? ';' : ':').filter(Boolean);
51
- if (win && env.LOCALAPPDATA) {
52
- const winget = path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
53
- dirs.push(path.join(winget, 'Links'));
54
- const packages = path.join(winget, 'Packages');
55
- try { for (const e of fs.readdirSync(packages)) if (/sops/i.test(e)) dirs.push(path.join(packages, e)); } catch { /* none */ }
56
- }
57
- for (const dir of dirs) for (const n of names) { const f = path.join(dir, n); try { if (fs.statSync(f).isFile()) return f; } catch { /* next */ } }
58
- return null;
59
- }
60
-
61
- /** Read test credential `name`: the local plaintext when present, else its committed `.enc` through sops. */
62
- export function testSecret(name, { repo, stack = 'dev', env = process.env, sops = null } = {}) {
63
- const { plain, enc, rel } = testSecretPaths(name, { repo, stack });
64
- if (fs.existsSync(plain)) return fs.readFileSync(plain, 'utf8').replace(/\r?\n$/, '');
65
- if (!fs.existsSync(enc)) throw new Error(`test secret ${name} is not in .starcistacks/${rel}.enc; store it with \`${setCommand(name, stack)}\` in ${repo}`);
66
- const bin = sops ?? resolveSops(env);
67
- if (!bin) throw new Error('sops is not installed (Windows: winget install Mozilla.SOPS)');
68
- const format = sopsFormatFor(plain);
69
- const r = sopsDecrypt(bin, ['--decrypt', '--input-type', format, '--output-type', format, enc], { cwd: path.resolve(String(repo)), env, maxBuffer: 1024 * 1024 });
70
- if (r.status !== 0) throw new Error(`test secret ${name}: sops could not decrypt .starcistacks/${rel}.enc (${String(r.stderr ?? r.error?.message ?? '').trim().split(/\r?\n/).pop()})`);
71
- return String(r.stdout).replace(/\r?\n$/, '');
72
- }
73
-
74
- /** Every leaf outside the `sops` block is an ENC[...] value (or null); the block itself carries a mac. */
75
- const allEncrypted = (node) => {
76
- if (node === null) return true;
77
- if (Array.isArray(node)) return node.every(allEncrypted);
78
- if (typeof node === 'object') return Object.values(node).every(allEncrypted);
79
- return typeof node === 'string' && SOPS_VALUE.test(node);
80
- };
81
- const sopsTree = (doc) => !!doc && typeof doc === 'object' && !Array.isArray(doc) && !!doc.sops && typeof doc.sops === 'object'
82
- && typeof doc.sops.mac === 'string' && SOPS_VALUE.test(doc.sops.mac)
83
- && Object.keys(doc).length > 1 && Object.entries(doc).every(([k, v]) => k === 'sops' || allEncrypted(v));
84
-
85
- /**
86
- * True only for a sops-encrypted file with no plaintext value: binary/json form ({"data": ENC[...], "sops": {...}}),
87
- * yaml form (every value ENC[...] plus the sops block) or dotenv form (KEY=ENC[...] lines plus sops_* metadata).
88
- * The sops metadata must carry its mac. A key kept plaintext through `unencrypted_suffix` does not pass.
89
- */
90
- export function isSopsEnvelope(text) {
91
- const src = String(text ?? '').replace(/^/, '');
92
- if (!src.trim()) return false;
93
- try { return sopsTree(JSON.parse(src)); } catch { /* not JSON */ }
94
- const lines = src.split(/\r?\n/).filter((l) => l.trim() && !/^\s*#/.test(l));
95
- if (lines.length && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))) {
96
- const pairs = lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]);
97
- const meta = pairs.filter(([k]) => k.startsWith('sops_'));
98
- const vals = pairs.filter(([k]) => !k.startsWith('sops_'));
99
- return vals.length > 0 && vals.every(([, v]) => SOPS_VALUE.test(v)) && meta.some(([k, v]) => k === 'sops_mac' && SOPS_VALUE.test(v));
100
- }
101
- try { return sopsTree(parseYaml(src)); } catch { return false; }
102
- }
103
-
104
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
105
- const [cmd, name, ...rest] = process.argv.slice(2);
106
- const opt = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
107
- try {
108
- if (cmd === 'get') {
109
- const value = testSecret(name, { repo: opt('--repo'), stack: opt('--stack') ?? 'dev' });
110
- if (rest.includes('--reveal')) process.stdout.write(`${value}\n`);
111
- } else {
112
- console.error(`usage: test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal]\nstore one with the product repo's own command: ${setCommand()}`);
113
- process.exitCode = 2;
114
- }
115
- } catch (error) { console.error(String(error?.message ?? error)); process.exitCode = 1; }
116
- }