@starci/hfs 5.1.0 → 5.2.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 (199) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +10 -1
  3. package/db/postgres.mjs +9 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +30 -9
  6. package/runtime/knowledge/hfs/fe-stack.yaml +7 -7
  7. package/runtime/knowledge/hfs/fork-images.yaml +7 -0
  8. package/runtime/knowledge/hfs/rules.yaml +55 -7
  9. package/runtime/knowledge/hfs/slots.yaml +75 -24
  10. package/runtime/modules/kernel/failure-codes.yaml +62 -0
  11. package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
  12. package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
  13. package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
  14. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
  15. package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
  16. package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
  17. package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
  18. package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
  19. package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
  20. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
  21. package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
  22. package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
  23. package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
  24. package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
  25. package/runtime/scripts/hfs/secret.mjs +10 -7
  26. package/runtime/scripts/hfs/slots.mjs +1 -1
  27. package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
  28. package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
  29. package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
  30. package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
  31. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  32. package/scaffold/add-module.d.mts +16 -0
  33. package/scaffold/add-module.mjs +232 -0
  34. package/scaffold/add-queue-lite.mjs +49 -0
  35. package/scaffold/add.mjs +5 -0
  36. package/scaffold/app.mjs +7 -7
  37. package/scaffold/module-registry.d.mts +19 -0
  38. package/scaffold/module-registry.mjs +84 -0
  39. package/src/main.mjs +23 -2
  40. package/stack/cli.mjs +6 -1
  41. package/stack/context.d.mts +15 -0
  42. package/stack/context.mjs +1 -0
  43. package/stack/local-cli.d.mts +25 -0
  44. package/stack/local-cli.mjs +89 -0
  45. package/stack/local-config.d.mts +17 -0
  46. package/stack/local-config.mjs +37 -0
  47. package/stack/local-custody.d.mts +24 -0
  48. package/stack/local-custody.mjs +109 -0
  49. package/stack/local-doctor.d.mts +6 -0
  50. package/stack/local-doctor.mjs +49 -0
  51. package/stack/local-down.d.mts +8 -0
  52. package/stack/local-down.mjs +20 -0
  53. package/stack/local-host.d.mts +42 -0
  54. package/stack/local-host.mjs +163 -0
  55. package/stack/local-logs.d.mts +8 -0
  56. package/stack/local-logs.mjs +32 -0
  57. package/stack/local-ports.d.mts +7 -0
  58. package/stack/local-ports.mjs +39 -0
  59. package/stack/local-prepare.d.mts +22 -0
  60. package/stack/local-prepare.mjs +62 -0
  61. package/stack/local-run.d.mts +71 -0
  62. package/stack/local-run.mjs +200 -0
  63. package/stack/local-state.d.mts +35 -0
  64. package/stack/local-state.mjs +29 -0
  65. package/stack/local-status.d.mts +6 -0
  66. package/stack/local-status.mjs +36 -0
  67. package/stack/local-up.d.mts +11 -0
  68. package/stack/local-up.mjs +166 -0
  69. package/stack/local-verify.d.mts +6 -0
  70. package/stack/local-verify.mjs +21 -0
  71. package/sync/index.d.mts +13 -12
  72. package/sync/index.mjs +83 -28
  73. package/templates/app/module/jest.config.js.tpl +8 -0
  74. package/templates/app/module/src/index.ts.tpl +2 -0
  75. package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
  76. package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
  77. package/templates/app/module/src/manifest/index.ts.tpl +1 -0
  78. package/templates/app/module/src/public/index.ts.tpl +2 -0
  79. package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
  80. package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
  81. package/templates/app/module/src/template/index.ts.tpl +1 -0
  82. package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
  83. package/templates/app/module/src/workbench/index.ts.tpl +2 -0
  84. package/templates/app/package-scripts/package.json +1 -1
  85. package/templates/app/package-scripts-lite/package.json +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +76 -70
  87. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +23 -13
  88. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +25 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -26
  90. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +2 -1
  92. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +57 -14
  93. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +11 -0
  94. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
  95. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
  96. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
  97. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
  98. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
  99. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
  100. package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
  101. package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
  103. package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
  104. package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
  105. package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
  106. package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
  107. package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
  108. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +13 -0
  109. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +16 -0
  110. package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
  111. package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
  112. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +13 -0
  113. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
  114. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
  115. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +90 -0
  116. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
  117. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
  118. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
  119. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +28 -0
  120. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +145 -0
  121. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +95 -0
  122. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +38 -0
  123. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +54 -0
  124. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
  125. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
  126. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
  127. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +17 -0
  128. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
  129. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
  130. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
  131. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
  132. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +78 -0
  133. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +87 -0
  134. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +44 -0
  135. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
  136. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
  137. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +10 -5
  138. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +34 -0
  139. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
  140. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +47 -2
  141. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -0
  142. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +24 -0
  143. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
  144. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
  145. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
  146. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +12 -0
  147. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
  148. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
  149. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
  150. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
  151. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
  152. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
  153. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
  154. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
  158. package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
  159. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
  160. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  161. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -4
  162. package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
  163. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
  164. package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
  165. package/templates/fe/common/site-shell.tsx.partial +3 -3
  166. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
  167. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
  168. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
  169. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
  170. package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
  171. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
  172. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -1
  173. package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
  174. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
  175. package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
  176. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
  177. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
  178. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
  179. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
  180. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
  181. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
  182. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
  183. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
  184. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
  185. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
  186. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
  187. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
  188. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
  189. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
  190. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
  191. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
  192. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
  193. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
  194. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
  195. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
  196. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
  197. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
  198. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
  199. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
@@ -1,57 +1,17 @@
1
- // identity.mjs - the back-end identity machine. Under lite the one token verifier is be.integrations.passport: jose's jwtVerify
2
- // there checks the app's OWN key set (createLocalJWKSet over the keys it signs with, never createRemoteJWKSet), pins issuer,
3
- // audience and algorithms, and no file decodes a token unverified or reads authorization claims from a request body (R329).
4
- // In both editions every jwtVerify call holds the JWT policy (R260, jwtPolicyProblems).
1
+ // identity.mjs - the back-end identity machine. Under lite the one token verifier is @starci/auth, composed by be.integrations.passport:
2
+ // no app file calls jose's jwtVerify, decodes a token unverified or reads authorization claims from a request body (R329), and the
3
+ // passport integration imports @starci/auth/server. In both editions every jwtVerify call holds the JWT policy (R260, jwtPolicyProblems).
5
4
  import path from 'node:path';
6
5
  import { machineKit } from './machine-ast.mjs';
7
- import { fromConfig, importedFrom, jwtPolicyProblems, reportAt, unwrap } from './jwt-ast.mjs';
6
+ import { jwtPolicyProblems, reportAt, unwrap } from './jwt-ast.mjs';
8
7
  import { accessName } from '../../lib/ts-ast.mjs';
9
8
  const PASSPORT_SLOT = 'be.integrations.passport';
9
+ const PACKAGE_ENTRY = '@starci/auth/server';
10
10
  const BE_JWT_VERIFIED = 'BE_AUTH_JWT_OWN_JWKS';
11
11
  /** The rule ids the back-end identity machine reports. */
12
12
  export const IDENTITY_RULE_IDS = Object.freeze(['BE_AUTH_JWT_OWN_JWKS', 'BE_JWT_ALG_PINNED']);
13
13
  const JWT_MODULES = new Set(['jose', 'jsonwebtoken', 'jwt-decode']);
14
14
  const CLAIM_NAMES = new Set(['role', 'roles', 'membership', 'memberships']);
15
- const objectLiteral = (kit, checker, node) => {
16
- const current = unwrap(kit.ts, node);
17
- if (!current)
18
- return null;
19
- if (kit.ts.isObjectLiteralExpression(current))
20
- return current;
21
- if (!kit.ts.isIdentifier(current))
22
- return null;
23
- for (const declaration of kit.declarationsOf(checker, current)) {
24
- if (kit.ts.isVariableDeclaration(declaration) && declaration.initializer) {
25
- const value = unwrap(kit.ts, declaration.initializer);
26
- if (kit.ts.isObjectLiteralExpression(value))
27
- return value;
28
- }
29
- }
30
- return null;
31
- };
32
- const staticStrings = (kit, checker, node) => {
33
- const current = unwrap(kit.ts, node);
34
- if (kit.ts.isStringLiteralLike(current))
35
- return [current.text];
36
- if (kit.ts.isArrayLiteralExpression(current)) {
37
- const literals = current.elements.filter((element) => kit.ts.isStringLiteralLike(element));
38
- if (literals.length === current.elements.length)
39
- return literals.map((element) => element.text);
40
- }
41
- const value = kit.stringValue(checker, current);
42
- return value === null ? null : [value];
43
- };
44
- const jwksArgument = (kit, checker, node) => {
45
- const current = unwrap(kit.ts, node);
46
- if (!current)
47
- return false;
48
- if (kit.ts.isCallExpression(current))
49
- return importedFrom(kit, checker, current.expression, new Set(['jose']), new Set(['createLocalJWKSet']));
50
- if (!kit.ts.isIdentifier(current))
51
- return false;
52
- return kit.declarationsOf(checker, current).some((declaration) => kit.ts.isVariableDeclaration(declaration) && declaration.initializer
53
- && jwksArgument(kit, checker, declaration.initializer));
54
- };
55
15
  const importedCall = (kit, checker, call) => {
56
16
  const direct = kit.importBinding(checker, call.expression);
57
17
  if (direct?.module === 'jwt-decode' && direct.name === 'default')
@@ -84,41 +44,16 @@ const fromRequestBody = (kit, checker, node, seen = new Set()) => {
84
44
  return kit.declarationsOf(checker, current).some(declaration => kit.ts.isVariableDeclaration(declaration) && declaration.initializer
85
45
  && fromRequestBody(kit, checker, declaration.initializer, seen));
86
46
  };
87
- function verifyJwtCall(kit, checker, file, node, counters, violations) {
88
- counters.verifyCalls += 1;
89
- if (file.slot !== PASSPORT_SLOT) {
90
- reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `jwtVerify is called outside ${PASSPORT_SLOT}; the integration is the one token verifier.`);
91
- return true;
92
- }
93
- const options = objectLiteral(kit, checker, node.arguments[2]);
94
- const property = (name) => options ? kit.propertyOf(options, name) : null;
95
- const values = (name) => {
96
- const entry = property(name);
97
- return entry ? staticStrings(kit, checker, kit.valueOfProperty(entry)) : null;
98
- };
99
- const issuer = property('issuer');
100
- const audience = values('audience');
101
- const algorithms = values('algorithms');
102
- // The audience is pinned by a literal or, as R260 accepts, by typed configuration (the skeleton's TokenOptions.audience).
103
- const audienceEntry = property('audience');
104
- const pinnedAudience = (audience !== null && audience.length > 0)
105
- || (audienceEntry != null && fromConfig(kit.ts, checker, kit.valueOfProperty(audienceEntry)));
106
- const good = jwksArgument(kit, checker, node.arguments[1]) && issuer != null && pinnedAudience
107
- && algorithms !== null && algorithms.length > 0;
108
- if (!good) {
109
- reportAt(violations, kit, BE_JWT_VERIFIED, file, node, 'jwtVerify must check the app\'s own key set (createLocalJWKSet(...) over the keys integrations/passport signs with) and pin issuer, audience and a non-empty algorithms list; jwtVerify then enforces signature and exp.');
110
- }
111
- else
112
- counters.verifiedCalls += 1;
113
- return false;
114
- }
115
47
  function inspectJwtCall(kit, checker, file, node, counters, violations) {
116
48
  const binding = importedCall(kit, checker, node);
117
49
  if (binding && JWT_MODULES.has(binding.module) && ['decode', 'decodeJwt', 'jwtDecode'].includes(binding.name)) {
118
50
  reportAt(violations, kit, BE_JWT_VERIFIED, file, node, `${binding.module}.${binding.name} decodes a token without proving its signature; verify it through integrations/passport.`, { module: binding.module, method: binding.name });
119
51
  }
120
- if (binding?.module === 'jose' && binding.name === 'jwtVerify')
121
- return verifyJwtCall(kit, checker, file, node, counters, violations);
52
+ if (binding?.module === 'jose' && binding.name === 'jwtVerify') {
53
+ counters.verifyCalls += 1;
54
+ reportAt(violations, kit, BE_JWT_VERIFIED, file, node, 'jwtVerify is called in the app; @starci/auth verifies the own access tokens, reached through ' + PASSPORT_SLOT + '.');
55
+ return true;
56
+ }
122
57
  return false;
123
58
  }
124
59
  function inspectRequestClaims(kit, file, node, checker, violations) {
@@ -146,18 +81,18 @@ function checkOwnJwks(input) {
146
81
  const violations = [];
147
82
  const integrations = [...input.graph.files.values()].filter((file) => file.slot === PASSPORT_SLOT);
148
83
  if (!integrations.length)
149
- return { violations, coverage: { status: 'not-applicable', integrationFiles: 0, verifiedCalls: 0 } };
150
- const counters = { verifyCalls: 0, verifiedCalls: 0 };
84
+ return { violations, coverage: { status: 'not-applicable', integrationFiles: 0, verifyCalls: 0 } };
85
+ const counters = { verifyCalls: 0 };
151
86
  for (const file of input.graph.files.values()) {
152
87
  const checker = kit.checkerOf(file.sourceFile);
153
88
  kit.walk(file.sourceFile, (node) => inspectBackendNode(kit, checker, file, node, counters, violations));
154
89
  }
155
- if (counters.verifyCalls === 0) {
156
- const anchor = integrations.find((file) => path.posix.basename(file.rel).endsWith('.tokens.ts')) ?? integrations[0];
157
- if (anchor)
158
- reportAt(violations, kit, BE_JWT_VERIFIED, anchor, anchor.sourceFile, `${PASSPORT_SLOT} has no jose.jwtVerify call backed by createLocalJWKSet over the app's own keys.`);
159
- }
160
- return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls: counters.verifyCalls, verifiedCalls: counters.verifiedCalls } };
90
+ const composes = integrations.some((file) => file.sourceFile.statements.some((statement) => kit.ts.isImportDeclaration(statement)
91
+ && kit.ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === PACKAGE_ENTRY));
92
+ const anchor = integrations.find((file) => path.posix.basename(file.rel) === 'index.ts') ?? integrations[0];
93
+ if (!composes && anchor)
94
+ reportAt(violations, kit, BE_JWT_VERIFIED, anchor, anchor.sourceFile, PASSPORT_SLOT + ' does not import ' + PACKAGE_ENTRY + ', the one verifier of the own access tokens.');
95
+ return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls: counters.verifyCalls, composesPackage: composes } };
161
96
  }
162
97
  /** R260 BE_JWT_ALG_PINNED: every jose jwtVerify call pins a literal secure algorithm list and a configured issuer, and binds the principal by a configured audience or the authenticated role. */
163
98
  export function checkJwtPolicy(input) {
@@ -15,8 +15,7 @@ import type { ArchitectureViolation, CheckInput } from './hfs-graph.mjs';
15
15
  * - no file outside the client folder and the stream reader folder (`fe.transport.stream`, `modules/api/sse/clients/`) references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
16
16
  * reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
17
17
  * client at all every such reference is a finding that says the repository has no client;
18
- * - axios is imported only by the api owner (modules/api, the api package) for its rest calls and by the auth door
19
- * (modules/auth/auth-door.ts) for the app's own auth doors; every other HTTP library is a
18
+ * - axios is imported only by the api owner (modules/api, the api package) for its rest calls; every other HTTP library is a
20
19
  * second transport wherever it is imported, by static import, `import()` or `require()`;
21
20
  * - an `ApolloClient` is constructed only inside the client folder;
22
21
  * - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`, `graphql/subscriptions/subscription-*.ts`) imports the client (or the api
@@ -17,8 +17,7 @@ import { propertyValue } from './function-spec-providers.mjs';
17
17
  * - no file outside the client folder and the stream reader folder (`fe.transport.stream`, `modules/api/sse/clients/`) references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
18
18
  * reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
19
19
  * client at all every such reference is a finding that says the repository has no client;
20
- * - axios is imported only by the api owner (modules/api, the api package) for its rest calls and by the auth door
21
- * (modules/auth/auth-door.ts) for the app's own auth doors; every other HTTP library is a
20
+ * - axios is imported only by the api owner (modules/api, the api package) for its rest calls; every other HTTP library is a
22
21
  * second transport wherever it is imported, by static import, `import()` or `require()`;
23
22
  * - an `ApolloClient` is constructed only inside the client folder;
24
23
  * - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`, `graphql/subscriptions/subscription-*.ts`) imports the client (or the api
@@ -105,17 +104,15 @@ function isApolloClientConstruction(ts, kit, checker, node) {
105
104
  const binding = kit.importBinding(checker, node.expression);
106
105
  return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
107
106
  }
108
- /** The one auth door of the session issuer's client (`modules/auth/auth-door.ts`, slot fe.modules.auth): it calls the app's own auth doors with axios. */
109
- const isAuthDoor = (file) => file.slot === 'fe.modules.auth' && path.posix.basename(file.rel) === 'auth-door.ts';
110
107
  function inspectLibraryImport({ ts, file, node, transport, report }) {
111
108
  const specifier = moduleSpecifierOf(ts, node);
112
109
  if (!specifier || !ts.isStringLiteralLike(specifier))
113
110
  return;
114
111
  const library = specifier.text.split('/')[0];
115
- if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && (API_OWNER_SLOTS.has(file.slot) || isAuthDoor(file))))
112
+ if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && (API_OWNER_SLOTS.has(file.slot))))
116
113
  return;
117
114
  const where = library === REST_LIBRARY
118
- ? 'axios belongs to the api owner (modules/api/rest, the api package) and the auth door (modules/auth/auth-door.ts) only: call their functions'
115
+ ? 'axios belongs to the api owner (modules/api/rest, the api package) only: call their functions'
119
116
  : `the transport is Apollo GraphQL (${transport}) with axios for the rest calls of modules/api/rest`;
120
117
  report(file, node, `${specifier.text} is a second HTTP transport in ${file.rel}; ${where}.`, { library: specifier.text });
121
118
  }
@@ -64,6 +64,7 @@ export interface Slot {
64
64
  role?: string;
65
65
  importedBy?: string[];
66
66
  rewrittenBy?: string;
67
+ managedOnly?: boolean;
67
68
  logic?: string[];
68
69
  generatedBy?: string;
69
70
  }
@@ -121,7 +121,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
121
121
  /** The kind of a parsed manifest: `kind`, or app when absent (knowledge/hfs/slots.yaml carries none). */
122
122
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
123
123
  const strList = (v) => stringList(v);
124
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'role', 'importedBy', 'rewrittenBy', 'logic'];
124
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'role', 'importedBy', 'rewrittenBy', 'logic', 'managedOnly'];
125
125
  /** The roles a file slot may play that a rule reads (`role`): the entity registry of persistence.md BE-PERSISTENCE-3 (architecture/entity-registry.mjs). */
126
126
  const SLOT_ROLES = ['entity-registry'];
127
127
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
@@ -235,6 +235,8 @@ function slotListProblems(slot, at) {
235
235
  bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
236
236
  if (slot.budget !== undefined && !budgetValid(slot.budget))
237
237
  bad.push(`${at}: budget must map names to positive integers`);
238
+ if (slot.managedOnly !== undefined && (slot.managedOnly !== true || slot.managedBy === undefined))
239
+ bad.push(`${at}: managedOnly is true and needs managedBy: the slot only lists files sync renders, and the slot owning the path judges them`);
238
240
  for (const key of ['managedBy', 'liteManagedBy'])
239
241
  if (slot[key] !== undefined && !NAME.test(String(slot[key])))
240
242
  bad.push(`${at}: ${key} must be a template id`);
@@ -33,6 +33,8 @@ import { stacksIngressFindings } from './stacks-ingress.mjs';
33
33
  import { stacksLiteBackingFindings } from './stacks-lite-backing.mjs';
34
34
  import { stackToolingFindings } from './stack-tooling.mjs';
35
35
  import { stacksDeployFindings } from './stacks-deploy.mjs';
36
+ import { stacksLocalFindings } from './stacks-local.mjs';
37
+ import { stacksServiceImageFindings } from './stacks-service-images.mjs';
36
38
  import { stacksAuthCookieDomainFindings } from './stacks-auth-cookie-domain.mjs';
37
39
  import { ciCustodyFindings } from './ci-custody.mjs';
38
40
  import { workflowContextsFindings } from './workflow-contexts.mjs';
@@ -49,6 +51,7 @@ export const A15_CODES = [
49
51
  'HFS_WORKFLOW_CONTEXT',
50
52
  'CI_STANDARD',
51
53
  'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
54
+ 'STACKS_LOCAL_CONFIG_INVALID', 'STACKS_DEV_UNDECLARED', 'STACKS_RUNBOOK_DOCKER_COMMAND',
52
55
  'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BOOT_PROOF_TIMEOUT', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_AUTH_COOKIE_DOMAIN_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_REDIS_PERSISTENCE', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
53
56
  // codes-end
54
57
  ];
@@ -87,6 +90,8 @@ export const FINDERS = [
87
90
  stacksLiteBackingFindings,
88
91
  stackToolingFindings,
89
92
  stacksDeployFindings,
93
+ stacksLocalFindings,
94
+ stacksServiceImageFindings,
90
95
  stacksAuthCookieDomainFindings,
91
96
  ciCustodyFindings,
92
97
  // finders-end
@@ -0,0 +1,7 @@
1
+ import type { Repo } from './rule-types.mjs';
2
+ /** Findings of the dev environments of the app root. */
3
+ export declare function stacksLocalFindings({ repoRoot, repo, isRoot }: {
4
+ repoRoot: string;
5
+ repo: Repo;
6
+ isRoot: boolean;
7
+ }): import("./rule-types.mjs").Finding[];
@@ -0,0 +1,71 @@
1
+ // stacks-local.mjs - R372 and R373: the dev environment of an app is declared runnable and its runbook names the harness verb.
2
+ // R372 STACKS_LOCAL_CONFIG_INVALID every docker-compose environment carries a `local` block that the dev verbs of `starci app stack`
3
+ // normalise (scripts/hfs/stack-local-config.mjs), and its compose services exist in the compose files;
4
+ // STACKS_DEV_UNDECLARED a lite app declares at least one docker-compose environment (the dev kind of PROJECT-STACKS-3).
5
+ // R373 STACKS_RUNBOOK_DOCKER_COMMAND the runbook of a docker-compose environment names `starci app stack up --env <env>` and carries no
6
+ // `docker build|run|compose|create|start|stop|pull|push` or `docker stack deploy` command (rules.md rule 8).
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
10
+ import { findStackDeclaration, readDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
11
+ import { normalizeLocal } from '../stack-local-config.mjs';
12
+ import { found, readJson } from './read.mjs';
13
+ const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
14
+ const ENV_NAME = /^[a-z0-9][a-z0-9-]*$/u;
15
+ const DOCKER_COMMAND = /^\s*(?:[$>]\s*)?(?:\w+=\S+\s+)*docker(?:-compose)?\s+(?:build|buildx|run|compose|create|start|stop|rm|pull|push|stack\s+deploy|stack\s+rm|service)\b/u;
16
+ const mapping = (value) => (plain(value) ? value : {});
17
+ function composeServices(envDir, spec) {
18
+ const names = new Set();
19
+ for (const relative of Array.isArray(spec.composeFiles) ? spec.composeFiles : []) {
20
+ if (typeof relative !== 'string' || path.isAbsolute(relative) || relative.split(/[\\/]/u).includes('..'))
21
+ continue;
22
+ for (const name of Object.keys(mapping(readDeclaration(path.join(envDir, relative)).doc?.services)))
23
+ names.add(name);
24
+ }
25
+ return names;
26
+ }
27
+ function localFindings({ repoRoot, project, environment, spec }) {
28
+ const at = `environments.${environment}.local`;
29
+ if (spec.local === undefined)
30
+ return [found('STACKS_LOCAL_CONFIG_INVALID', DECLARATION, `environments.${environment} runs docker-compose and declares no local block: declare the ports, services, processes and probes \`starci app stack up --env ${environment}\` runs`)];
31
+ const { config, problems } = normalizeLocal(spec.local, { project, env: environment });
32
+ const findings = problems.map((problem) => found('STACKS_LOCAL_CONFIG_INVALID', DECLARATION, `${at}.${problem.at}: ${problem.message}`));
33
+ if (config) {
34
+ const services = composeServices(path.join(repoRoot, STACK_ROOT, environment), spec);
35
+ for (const service of config.services)
36
+ if (!services.has(service.name))
37
+ findings.push(found('STACKS_LOCAL_CONFIG_INVALID', DECLARATION, `${at}.services: ${service.name} is not a service of the compose files of ${environment}`));
38
+ }
39
+ return findings;
40
+ }
41
+ function runbookFindings({ repoRoot, environment, spec }) {
42
+ const relative = typeof spec.runbook === 'string' ? spec.runbook : 'README.md';
43
+ const file = path.join(repoRoot, STACK_ROOT, environment, relative);
44
+ const display = path.relative(repoRoot, file).split(path.sep).join('/');
45
+ let text;
46
+ try {
47
+ text = fs.readFileSync(file, 'utf8');
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ const findings = text.split(/\r?\n/u).map((line, index) => ({ line, index })).filter(({ line }) => DOCKER_COMMAND.test(line) || /`docker\s+(?:build|run|compose|create|start|stop|pull|push|stack)\b/u.test(line))
53
+ .map(({ index }) => found('STACKS_RUNBOOK_DOCKER_COMMAND', `${display}:${index + 1}`, `the runbook of ${environment} carries a docker command; Docker is run by the harness: name \`starci app stack up --env ${environment}\` instead`));
54
+ if (!text.includes(`starci app stack up --env ${environment}`))
55
+ findings.push(found('STACKS_RUNBOOK_DOCKER_COMMAND', display, `the runbook of ${environment} does not name \`starci app stack up --env ${environment}\`, the one command that brings the environment up`));
56
+ return findings;
57
+ }
58
+ /** Findings of the dev environments of the app root. */
59
+ export function stacksLocalFindings({ repoRoot, repo, isRoot }) {
60
+ if (repo?.kind !== 'app' || !['lite', 'full'].some((value) => value === repo.edition) || isRoot === false)
61
+ return [];
62
+ const { doc } = findStackDeclaration(repoRoot);
63
+ if (!doc)
64
+ return [];
65
+ const project = String(readJson(repoRoot, 'hfs.json')?.project ?? 'app');
66
+ const judged = Object.entries(mapping(mapping(doc).environments)).filter(([environment, spec]) => ENV_NAME.test(environment) && plain(spec) && spec.runtime === 'docker-compose');
67
+ const findings = judged.flatMap(([environment, spec]) => [...localFindings({ repoRoot, project, environment, spec }), ...runbookFindings({ repoRoot, environment, spec })]);
68
+ if (repo.edition === 'lite' && judged.length === 0)
69
+ findings.push(found('STACKS_DEV_UNDECLARED', DECLARATION, 'a lite app declares its dev environment: an environment with runtime docker-compose and a local block, run by `starci app stack up --env dev` (PROJECT-STACKS-3, PROJECT-STACKS-6)'));
70
+ return findings;
71
+ }
@@ -99,6 +99,9 @@ function liteEnvironmentFindings({ doc, environments, deployments }) {
99
99
  for (const [environment, spec] of Object.entries(environments)) {
100
100
  if (!Array.isArray(spec?.composeFiles) || !spec.composeFiles.length)
101
101
  continue;
102
+ // The dev kind (PROJECT-STACKS-3): docker-compose sources under infra/compose/ with the local block `starci app stack up` runs; judged by R372.
103
+ if (spec.runtime === 'docker-compose' && spec.local !== undefined && spec.composeFiles.every((file) => typeof file === 'string' && file.startsWith('infra/compose/')))
104
+ continue;
102
105
  if (spec.runtime !== 'docker-swarm' || spec.composeFiles.some((file) => typeof file !== 'string' || !file.startsWith('infra/swarm/')))
103
106
  findings.push(found(CODE, DECLARATION, `${DECLARATION}: environments.${environment} lite delivery must use runtime: docker-swarm and infra/swarm sources`));
104
107
  }
@@ -111,11 +114,13 @@ function undeclaredSwarmFindings(deployments, environments, sources) {
111
114
  return found(CODE, file, `${file}: Swarm deployment ${reason}`);
112
115
  });
113
116
  }
117
+ /** The component ids a docker-swarm environment binds: the only ones the lite refusal of backing images judges (a dev compose environment runs its own MinIO, Redis and agent runtime). */
118
+ const swarmBoundComponents = (environments) => new Set(Object.values(environments).filter((spec) => spec?.runtime === 'docker-swarm').flatMap((spec) => Object.keys(mapping(spec.components))));
114
119
  /** Declared Swarm sources are checked for both editions; lite additionally refuses local managed backing images. */
115
120
  export function stacksRuntimeFindings({ repoRoot, files, doc, edition = 'full' }) {
116
121
  const findings = [], lite = edition === 'lite', components = mapping(doc?.components), environments = mapping(doc?.environments);
117
122
  const deployments = swarmDeploymentFiles(repoRoot, files), sources = new Set();
118
- findings.push(...swarmInventoryIssues({ components, lite }).map((issue) => issueFinding(DECLARATION, issue)));
123
+ findings.push(...swarmInventoryIssues({ components, lite, swarm: swarmBoundComponents(environments) }).map((issue) => issueFinding(DECLARATION, issue)));
119
124
  if (lite)
120
125
  findings.push(...liteEnvironmentFindings({ doc, environments, deployments }));
121
126
  for (const [environment, entry] of Object.entries(environments)) {
@@ -0,0 +1,12 @@
1
+ import { found } from './read.mjs';
2
+ import type { Repo } from './rule-types.mjs';
3
+ type Finding = ReturnType<typeof found>;
4
+ /** The fork repositories the harness owns, by service name. */
5
+ export declare function forkImages(): Record<string, string>;
6
+ /** Findings of R374 for the app root. */
7
+ export declare function stacksServiceImageFindings({ repoRoot, repo, isRoot }: {
8
+ repoRoot: string;
9
+ repo: Repo;
10
+ isRoot: boolean;
11
+ }): Finding[];
12
+ export {};
@@ -0,0 +1,120 @@
1
+ // stacks-service-images.mjs - R374: a customised service runs from an image the app builds, never from an upstream image with code mounted into it.
2
+ // STACKS_CODE_BIND_MOUNT a stack or compose file bind-mounts code, a script, a plugin or a writable repository path into a service container.
3
+ // Allowed: a named volume, a secret or config grant (not volumes), a bind under the data root (`${data}`, `${STACK_DATA_DIR}`) or a least grant (`${STACK_GRANT_<NAME>}`),
4
+ // the docker socket read-only, and a read-only plain data file (json, yaml, toml, conf, ini, properties, txt, pem, crt, csv).
5
+ // STACKS_IMAGE_BASE_UNPINNED a Dockerfile of `.starcistacks/images/<name>/` has a FROM that is not digest-pinned.
6
+ // STACKS_IMAGE_BASE_NOT_FORK the image folder of a service the harness owns a fork image for (knowledge/hfs/fork-images.yaml) does not end FROM that fork.
7
+ // STACKS_IMAGE_ENV_DIVERGES a service that has an image folder runs a literal third-party image in some environment: every environment runs the
8
+ // image built from the folder (compose `build.context`, or a project-owned or variable image reference), so dev and production share it.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
12
+ import { parseYaml } from '../../../engine/yaml.mjs';
13
+ import { findStackDeclaration, readDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
14
+ import { found, readJson } from './read.mjs';
15
+ const FORKS = new URL('../../../knowledge/hfs/fork-images.yaml', import.meta.url);
16
+ const ENV_NAME = /^[a-z0-9][a-z0-9-]*$/u;
17
+ const DATA_ROOT = /^(?:\$\{(?:data|STACK_DATA_DIR|STACK_GRANT_[A-Z0-9_]+)\}|\$(?:data|STACK_DATA_DIR|STACK_GRANT_[A-Z0-9_]+))(?:[\\/]|$)/u;
18
+ const DATA_FILE = /\.(?:json|ya?ml|toml|conf|cnf|cfg|ini|properties|txt|pem|crt|csv)$/iu;
19
+ const SOCKET = '/var/run/docker.sock';
20
+ const mapping = (value) => (plain(value) ? value : {});
21
+ /** The fork repositories the harness owns, by service name. */
22
+ export function forkImages() {
23
+ return mapping(mapping(parseYaml(fs.readFileSync(FORKS, 'utf8'))).images);
24
+ }
25
+ /** One volume entry as { source, readOnly } when it is a bind mount (a host path), else null. */
26
+ function bindOf(entry) {
27
+ if (typeof entry === 'string') {
28
+ const [source = '', , mode = ''] = entry.split(':');
29
+ if (!/^(?:\.|\/|~|\$\{|\$[A-Za-z])/u.test(source))
30
+ return null;
31
+ return { source, readOnly: mode.split(',').includes('ro') };
32
+ }
33
+ const long = mapping(entry);
34
+ if (long.type !== 'bind' || typeof long.source !== 'string')
35
+ return null;
36
+ return { source: long.source, readOnly: long.read_only === true };
37
+ }
38
+ /** Whether a bind mount is a data volume or plain read-only config data, the line R374 draws. */
39
+ const allowedBind = ({ source, readOnly }) => DATA_ROOT.test(source) || (readOnly && (source === SOCKET || DATA_FILE.test(source)));
40
+ const baseOf = (reference) => reference.replace(/@sha256:[0-9a-f]+$/iu, '').replace(/:[^/:]+$/u, '');
41
+ function bindFindings(display, environment, services) {
42
+ return Object.entries(services).flatMap(([name, service]) => (Array.isArray(mapping(service).volumes) ? mapping(service).volumes : [])
43
+ .map(bindOf).filter((bind) => bind !== null && !allowedBind(bind))
44
+ .map((bind) => found('STACKS_CODE_BIND_MOUNT', display, `${environment}: service ${name} bind-mounts ${bind.source}; code, plugins and scripts are baked into an image built from .starcistacks/images/${name}/ (FROM the pinned base, or the harness fork image), and only data volumes, secrets and read-only plain config data are mounted`)));
45
+ }
46
+ function dockerfileFindings(repoRoot, forks) {
47
+ const images = path.join(repoRoot, STACK_ROOT, 'images');
48
+ if (!fs.existsSync(images))
49
+ return [];
50
+ return fs.readdirSync(images, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
51
+ const display = `${STACK_ROOT}/images/${entry.name}/Dockerfile`;
52
+ let text;
53
+ try {
54
+ text = fs.readFileSync(path.join(images, entry.name, 'Dockerfile'), 'utf8');
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ const stages = new Set();
60
+ const bases = [];
61
+ const findings = [];
62
+ for (const line of text.split(/\r?\n/u)) {
63
+ const from = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?/iu.exec(line);
64
+ if (!from)
65
+ continue;
66
+ const ref = from[1];
67
+ if (from[2])
68
+ stages.add(from[2].toLowerCase());
69
+ if (stages.has(ref.toLowerCase()) || ref === 'scratch')
70
+ continue;
71
+ bases.push(ref);
72
+ if (!/@sha256:[0-9a-f]{64}/iu.test(ref))
73
+ findings.push(found('STACKS_IMAGE_BASE_UNPINNED', display, `FROM ${ref} is not pinned by digest (name@sha256:...)`));
74
+ }
75
+ const fork = forks[entry.name];
76
+ const last = bases.at(-1);
77
+ if (fork !== undefined && last !== undefined && baseOf(last) !== fork)
78
+ findings.push(found('STACKS_IMAGE_BASE_NOT_FORK', display, `the harness owns the StarCi fork image ${fork} for ${entry.name}; the final stage must be FROM ${fork}@sha256:..., not ${baseOf(last)}`));
79
+ return findings;
80
+ });
81
+ }
82
+ /** Whether a service's image/build is the one built from its image folder, or a project-owned or variable reference. */
83
+ function builtFromFolder(service, name, project) {
84
+ const build = service.build;
85
+ const context = typeof build === 'string' ? build : typeof mapping(build).context === 'string' ? String(mapping(build).context) : '';
86
+ if (context !== '' && context.replaceAll('\\', '/').includes(`images/${name}`))
87
+ return true;
88
+ const image = typeof service.image === 'string' ? service.image : '';
89
+ return image.includes('${') || image.startsWith(`${project}/`) || image.startsWith(`${project}-`) || image.includes(`/${project}/`);
90
+ }
91
+ /** Findings of R374 for the app root. */
92
+ export function stacksServiceImageFindings({ repoRoot, repo, isRoot }) {
93
+ if (repo?.kind !== 'app' || !['lite', 'full'].some((value) => value === repo.edition) || isRoot === false)
94
+ return [];
95
+ const { doc } = findStackDeclaration(repoRoot);
96
+ if (!doc)
97
+ return [];
98
+ const project = String(readJson(repoRoot, 'hfs.json')?.project ?? 'app');
99
+ const folders = path.join(repoRoot, STACK_ROOT, 'images');
100
+ const withFolder = new Set(fs.existsSync(folders) ? fs.readdirSync(folders, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : []);
101
+ const findings = dockerfileFindings(repoRoot, forkImages());
102
+ for (const [environment, spec] of Object.entries(mapping(mapping(doc).environments))) {
103
+ if (!ENV_NAME.test(environment) || !plain(spec))
104
+ continue;
105
+ const files = Array.isArray(spec.composeFiles) ? spec.composeFiles : [];
106
+ for (const relative of files) {
107
+ if (typeof relative !== 'string' || path.isAbsolute(relative) || relative.split(/[\\/]/u).includes('..'))
108
+ continue;
109
+ const file = path.join(repoRoot, STACK_ROOT, environment, relative);
110
+ const display = path.relative(repoRoot, file).split(path.sep).join('/');
111
+ const services = mapping(readDeclaration(file).doc?.services);
112
+ findings.push(...bindFindings(display, environment, services));
113
+ for (const [name, service] of Object.entries(services)) {
114
+ if (withFolder.has(name) && !builtFromFolder(mapping(service), name, project))
115
+ findings.push(found('STACKS_IMAGE_ENV_DIVERGES', display, `${environment}: service ${name} has an image folder but runs ${String(mapping(service).image)}; every environment runs the image built from .starcistacks/images/${name}/ so dev and production share one image`));
116
+ }
117
+ }
118
+ }
119
+ return findings;
120
+ }
@@ -0,0 +1,11 @@
1
+ import type { SecretSops } from "./secret-sops.mjs";
2
+ import type { CustodyGenerator } from "./stack-local-config.mjs";
3
+ /** One generated value (or a private/public pair) by its kind; the `password` kind is URL- and SQL-safe. */
4
+ export declare function generateValues(entry: CustodyGenerator, random?: (bytes: number) => Buffer): Record<string, string>;
5
+ /** Seals the missing dev values of `environment`; a dry run unless `execute`. Returns the exit code. */
6
+ export declare function seedDevCustody(repoRoot: string, environment: string, { execute, sops, stdout, random }: {
7
+ execute: boolean;
8
+ sops: () => SecretSops;
9
+ stdout: (text: string) => unknown;
10
+ random?: (bytes: number) => Buffer;
11
+ }): number;
@@ -0,0 +1,79 @@
1
+ // secret-dev-init.mjs - `starci app secret init --env <dev env> [--execute]`: creates the dev-only values the environment's `local.custody`
2
+ // declares (PROJECT-STACKS-5: sealed only, owner-armed, created once, never replaced). It refuses an environment that is not a docker-compose
3
+ // dev environment (vps and production never get generated values), skips a key its member already holds, and refuses a half-present pair
4
+ // instead of minting a replacement. Values are generated in memory and sealed in one write per member; names are all it prints.
5
+ import fs from "node:fs";
6
+ import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto";
7
+ import { envelopeOf } from "./secret-document.mjs";
8
+ import { SecretError } from "./secret-errors.mjs";
9
+ import { fileOf, secretsDirectory, writeSecret } from "./secret-store.mjs";
10
+ import { declareKeys } from "./secret-standard.mjs";
11
+ import { isLocalEnvironment, loadLocalConfig } from "../../../stack/local-config.mjs";
12
+ /** One generated value (or a private/public pair) by its kind; the `password` kind is URL- and SQL-safe. */
13
+ export function generateValues(entry, random = randomBytes) {
14
+ if (entry.kind === "hex")
15
+ return { [entry.key]: random(entry.bytes).toString("hex") };
16
+ if (entry.kind === "base64")
17
+ return { [entry.key]: random(entry.bytes).toString("base64") };
18
+ if (entry.kind === "password")
19
+ return { [entry.key]: random(entry.bytes).toString("base64url") };
20
+ if (entry.kind === "uuid")
21
+ return { [entry.key]: randomUUID() };
22
+ if (entry.kind === "rsa-pem" || entry.kind === "ec-pem" || entry.kind === "ed25519-pem") {
23
+ const pair = entry.kind === "rsa-pem"
24
+ ? generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } })
25
+ : entry.kind === "ec-pem"
26
+ ? generateKeyPairSync("ec", { namedCurve: "P-256", privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } })
27
+ : generateKeyPairSync("ed25519", { privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } });
28
+ return { [entry.key]: pair.privateKey, ...(entry.publicKey ? { [entry.publicKey]: pair.publicKey } : {}) };
29
+ }
30
+ throw new SecretError(`the generator ${entry.kind} is unknown`);
31
+ }
32
+ /** Seals the missing dev values of `environment`; a dry run unless `execute`. Returns the exit code. */
33
+ export function seedDevCustody(repoRoot, environment, { execute, sops, stdout, random = randomBytes }) {
34
+ if (!isLocalEnvironment(repoRoot, environment))
35
+ throw new SecretError(`secret init --env ${environment}: only a docker-compose dev environment gets generated values; a vps or production value is set by the owner with secret set (PROJECT-STACKS-5)`);
36
+ const local = loadLocalConfig({ repoRoot, env: environment });
37
+ if (local.custody.length === 0)
38
+ throw new SecretError(`environment ${environment} declares no local.custody: nothing to generate; the owner seals its values with secret set`);
39
+ const directory = secretsDirectory(repoRoot, environment);
40
+ const byMember = new Map();
41
+ for (const entry of local.custody)
42
+ byMember.set(entry.member, [...(byMember.get(entry.member) ?? []), entry]);
43
+ const writes = [];
44
+ for (const [member, entries] of byMember) {
45
+ const file = fileOf(directory, member);
46
+ const held = fs.existsSync(file) ? envelopeOf(fs.readFileSync(file, "utf8")).keys : [];
47
+ const values = {};
48
+ for (const entry of entries) {
49
+ const names = [entry.key, ...(entry.publicKey ? [entry.publicKey] : [])];
50
+ const have = names.filter((name) => held.includes(name));
51
+ if (have.length === names.length) {
52
+ stdout(`kept ${environment}/${member} ${entry.key} (already sealed, never replaced)\n`);
53
+ continue;
54
+ }
55
+ if (have.length > 0)
56
+ throw new SecretError(`${environment}/${member} holds ${have.join(", ")} but not the rest of its pair ${names.join(", ")}: a replacement is never minted for an existing sealed value; the owner removes the pair with secret unset and runs init again`);
57
+ Object.assign(values, generateValues(entry, random));
58
+ }
59
+ if (Object.keys(values).length)
60
+ writes.push({ file, member, values });
61
+ }
62
+ for (const write of writes) {
63
+ const names = Object.keys(write.values);
64
+ if (!execute) {
65
+ stdout(`would seal ${environment}/${write.member} (${names.join(", ")})\n`);
66
+ continue;
67
+ }
68
+ writeSecret({ file: write.file, values: write.values, age: [], sops: sops() });
69
+ stdout(`sealed ${environment}/${write.member} (${names.join(", ")})\n`);
70
+ const declaration = declareKeys(repoRoot, environment, write.member, names);
71
+ if (declaration.declared.length)
72
+ stdout(`declared ${declaration.declared.join(", ")} in environments.${environment}.secrets of application-stacks.yaml\n`);
73
+ }
74
+ if (writes.length === 0)
75
+ stdout("nothing to create: every declared dev value is sealed\n");
76
+ else if (!execute)
77
+ stdout("dry run: nothing was written; arm it with --execute (the owner's yes)\n");
78
+ return 0;
79
+ }