@starci/hfs 5.2.0 → 5.4.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 (223) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +2 -1
  3. package/cache/hfs-family.d.mts +2 -3
  4. package/cache/hfs-family.mjs +4 -9
  5. package/cache/secret-records.d.mts +7 -0
  6. package/cache/secret-records.mjs +12 -0
  7. package/cache/session.mjs +4 -2
  8. package/codemod/account-doors.d.mts +36 -0
  9. package/codemod/account-doors.mjs +134 -0
  10. package/codemod/app-prettier.d.mts +5 -0
  11. package/codemod/app-prettier.mjs +27 -0
  12. package/codemod/explicit-surface.mjs +1 -25
  13. package/codemod/main.mjs +35 -1
  14. package/codemod/module-definition.d.mts +59 -0
  15. package/codemod/module-definition.mjs +257 -0
  16. package/codemod/quality-binding.d.mts +33 -0
  17. package/codemod/quality-binding.mjs +38 -0
  18. package/codemod/secrets-cache.d.mts +35 -0
  19. package/codemod/secrets-cache.mjs +70 -0
  20. package/codemod/sops-rules.d.mts +34 -0
  21. package/codemod/sops-rules.mjs +69 -0
  22. package/codemod/theme-tokens.mjs +48 -2
  23. package/lint/run.d.mts +6 -0
  24. package/lint/run.mjs +63 -10
  25. package/package.json +1 -1
  26. package/runtime/knowledge/hfs/canon-pins.yaml +43 -10
  27. package/runtime/knowledge/hfs/fe-stack.yaml +3 -3
  28. package/runtime/knowledge/hfs/rules.yaml +13 -9
  29. package/runtime/knowledge/hfs/slots.yaml +105 -12
  30. package/runtime/modules/kernel/failure-codes.yaml +5 -4
  31. package/runtime/scripts/hfs/architecture/config.d.mts +4 -0
  32. package/runtime/scripts/hfs/architecture/config.mjs +25 -1
  33. package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
  35. package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
  36. package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
  37. package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
  38. package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
  39. package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
  40. package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
  41. package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
  43. package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
  44. package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
  45. package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
  46. package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
  47. package/runtime/scripts/hfs/secret-errors.mjs +8 -0
  48. package/runtime/scripts/hfs/secret-init.d.mts +6 -0
  49. package/runtime/scripts/hfs/secret-init.mjs +23 -0
  50. package/runtime/scripts/hfs/secret-sops.mjs +9 -1
  51. package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
  52. package/runtime/scripts/hfs/secret-standard.mjs +106 -0
  53. package/runtime/scripts/hfs/secret.mjs +5 -1
  54. package/runtime/scripts/hfs/view.d.mts +23 -0
  55. package/runtime/scripts/hfs/view.mjs +68 -0
  56. package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
  57. package/runtime/scripts/lib/declaration-surface.mjs +55 -1
  58. package/runtime/scripts/lib/secret-records.d.mts +13 -0
  59. package/runtime/scripts/lib/secret-records.mjs +83 -0
  60. package/scaffold/add-inbox-lite.mjs +2 -2
  61. package/scaffold/add-queue-lite.mjs +1 -3
  62. package/scaffold/app.d.mts +2 -2
  63. package/scaffold/app.mjs +10 -12
  64. package/scaffold/lite-exports.mjs +1 -15
  65. package/scripts/sync-bundles.d.mts +8 -0
  66. package/stack/images/openclaw-runtime/Dockerfile +18 -0
  67. package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
  68. package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
  69. package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
  70. package/stack/local-up.mjs +3 -0
  71. package/stack/runtime-layers.d.mts +21 -0
  72. package/stack/runtime-layers.mjs +67 -0
  73. package/sync/compile-guard.d.mts +36 -0
  74. package/sync/compile-guard.mjs +189 -0
  75. package/sync/hygiene.mjs +3 -1
  76. package/sync/index.d.mts +11 -1
  77. package/sync/index.mjs +61 -10
  78. package/templates/app/auth-doors.sql.partial +115 -0
  79. package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
  80. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
  81. package/templates/app/package-scripts/core.json +1 -1
  82. package/templates/app/package-scripts/package.json +1 -1
  83. package/templates/app/package-scripts-lite/package.json +1 -1
  84. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +9 -0
  85. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +3 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +19 -1
  87. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +3 -0
  88. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +5 -0
  89. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +4 -0
  90. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +27 -5
  91. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +33 -0
  92. package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
  93. package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
  94. package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
  95. package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
  96. package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
  97. package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
  98. package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
  99. package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
  100. package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
  101. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
  102. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +3 -10
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +6 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +3 -10
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +56 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
  107. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
  108. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
  109. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
  110. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
  111. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +30 -30
  112. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +7 -16
  113. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +113 -49
  114. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +42 -53
  115. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
  116. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
  117. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
  118. package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
  119. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
  120. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
  121. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  122. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +3 -10
  123. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +6 -3
  124. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +6 -1
  125. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +17 -0
  126. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +6 -0
  127. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +29 -33
  128. package/templates/be/inbox/module-definition.ts.tpl +2 -9
  129. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
  130. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  131. package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
  132. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
  133. package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
  134. package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
  135. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
  136. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
  137. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
  138. package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
  139. package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
  140. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
  141. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
  142. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
  143. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
  144. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
  145. package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
  146. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
  147. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
  148. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
  149. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
  150. package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
  151. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
  152. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
  153. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
  154. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
  155. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
  156. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
  157. package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
  158. package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
  159. package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
  160. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
  161. package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
  162. package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
  163. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
  164. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
  165. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
  166. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
  167. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
  168. package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
  169. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
  170. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  171. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  172. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +3 -1
  173. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +6 -0
  174. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +3 -0
  175. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +1 -0
  176. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
  177. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +4 -0
  178. package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
  179. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
  180. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
  181. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
  182. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
  183. package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
  184. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +2 -9
  185. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +1 -1
  186. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  187. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
  188. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -0
  189. package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
  190. package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
  191. package/templates/be/table/module-definition.ts.tpl +2 -9
  192. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
  193. package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
  194. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
  195. package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
  196. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
  197. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
  198. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
  199. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
  200. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
  201. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
  202. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
  203. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
  204. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
  205. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
  207. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
  208. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
  209. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
  210. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
  211. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
  212. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
  213. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
  214. package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
  215. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
  216. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
  217. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
  218. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
  219. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
  220. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
  221. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
  222. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +0 -38
  223. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +0 -54
@@ -1,4 +1,4 @@
1
- // R255-R256 inspect the final database access state: the application role, forced RLS, policies, views and storage prefixes.
1
+ // R255-R256 inspect the final database access state: the application role (its attributes and every grant on schema auth), forced RLS, policies, views and storage prefixes.
2
2
  import { found } from './read.mjs';
3
3
  import { parseSql } from '../sql/pg-parse.mjs';
4
4
  import { eachInOrder } from '../../lib/in-order.mjs';
@@ -20,6 +20,10 @@ const AND = ['AND', 'EXPR'].join('_');
20
20
  const OR = ['OR', 'EXPR'].join('_');
21
21
  const NOT = ['NOT', 'EXPR'].join('_');
22
22
  const SEQUENCE = ['OBJECT', 'SEQUENCE'].join('_');
23
+ const SCHEMA = ['OBJECT', 'SCHEMA'].join('_');
24
+ const TABLE = ['OBJECT', 'TABLE'].join('_');
25
+ const ALL_IN_SCHEMA = ['ACL', 'TARGET', 'ALL', 'IN', 'SCHEMA'].join('_');
26
+ const ACCOUNT_SCHEMA = 'auth';
23
27
  const MATVIEW = ['OBJECT', 'MATVIEW'].join('_');
24
28
  const note = (code, source, message, extra = {}) => found(code, source.file, `${source.file}:${source.line} ${message}`, { line: source.line, ...extra });
25
29
  function someNode(node, predicate) {
@@ -157,6 +161,39 @@ function roleFindings(analyses, appRole) {
157
161
  }
158
162
  return findings;
159
163
  }
164
+ /** What one grant statement names inside schema auth: the schema, a table or sequence of it, every table or sequence in it. */
165
+ function authTargets(grant) {
166
+ if (grant.objtype === SCHEMA)
167
+ return grant.objects.filter((object) => sval(object) === ACCOUNT_SCHEMA).map(() => `schema ${ACCOUNT_SCHEMA}`);
168
+ if (![TABLE, SEQUENCE].includes(grant.objtype))
169
+ return [];
170
+ if (grant.targtype === ALL_IN_SCHEMA)
171
+ return grant.objects.filter((object) => sval(object) === ACCOUNT_SCHEMA).map(() => `all ${grant.objtype === TABLE ? 'tables' : 'sequences'} in schema ${ACCOUNT_SCHEMA}`);
172
+ return grant.objects.filter((object) => object.RangeVar?.schemaname === ACCOUNT_SCHEMA).map((object) => relationName(object.RangeVar));
173
+ }
174
+ /**
175
+ * R255: the application role holds no privilege on schema auth, on a table or sequence in it, or by default privilege for the
176
+ * schema. The migrations are applied in order, so a grant that a later migration revokes (the contract step of an app that moved
177
+ * to the identity account doors) is not held; what remains at the end is reported at the grant that left it.
178
+ */
179
+ function authSchemaFindings(analyses, appRole) {
180
+ const held = new Map();
181
+ for (const { file, facts } of analyses) {
182
+ const statements = [
183
+ ...facts.grants.map((grant) => ({ grant, targets: authTargets(grant) })),
184
+ ...facts.defaultPrivileges.map((grant) => ({ grant, targets: grant.schemas.includes(ACCOUNT_SCHEMA) ? [`default privileges in schema ${ACCOUNT_SCHEMA}`] : [] })),
185
+ ].filter(({ grant }) => grant.grantees.includes(appRole));
186
+ for (const { grant, targets } of statements) {
187
+ for (const target of targets) {
188
+ const state = held.get(target) ?? { target, privileges: new Set() };
189
+ updatePrivileges(state, grant.grant, grant.all ? ['all'] : grant.privileges);
190
+ held.set(target, { ...state, file, line: grant.line });
191
+ }
192
+ }
193
+ }
194
+ return [...held.values()].filter((state) => state.privileges.size > 0)
195
+ .map((state) => note(DB_APP_ROLE, state, `${appRole} holds a grant on ${state.target}; reach accounts through the identity account doors (SECURITY DEFINER functions), never through a grant on schema ${ACCOUNT_SCHEMA}; an app on the old sign-in glue runs "starci app codemod account-doors", "starci app sync --execute" and "starci app codemod account-doors --contract" in one release`, { target: state.target }));
196
+ }
160
197
  function roleStatementFindings(statement, appRole) {
161
198
  const { type, node } = statement;
162
199
  if (!['CreateRoleStmt', 'AlterRoleStmt'].includes(type))
@@ -299,6 +336,7 @@ export async function databaseAccessFindings({ analyses, exposed, db = {} }) {
299
336
  return [
300
337
  ...policyFindings(policies, appRole, helpers),
301
338
  ...roleFindings(analyses, appRole),
339
+ ...authSchemaFindings(analyses, appRole),
302
340
  ...forceFindings(analyses, declared, appRole),
303
341
  ...viewFindings(analyses, grants, exposed),
304
342
  ...storagePrefixFindings(policies, helpers),
@@ -1,6 +1,10 @@
1
1
  import type { Parsed, Repo } from './rule-types.mjs';
2
2
  /** The npm workspaces of every app, in this order. */
3
3
  export declare const WORKSPACES: readonly string[];
4
+ /** The workspace of the app's own root packages (slot app.packages: packages/agent-sdk, packages/module-<name>), added after the fe ones once one is tracked. */
5
+ export declare const ROOT_PACKAGES_WORKSPACE = "packages/*";
6
+ /** The workspaces the tracked tree `files` calls for: the fe ones, then `packages/*` when a root package tracks its package.json. */
7
+ export declare const workspacesFor: (files: readonly string[]) => readonly string[];
4
8
  /** The scripts of every fe app workspace, exactly: turbo runs build, dev and typecheck; start serves a build. The lint is the root's (`starci app check --gate lint`), never a workspace script. */
5
9
  export declare const FE_APP_SCRIPTS: Readonly<Record<string, string>>;
6
10
  /** The package manager every app root declares (turbo reads it to parse the lockfile); the starci app scaffold writes it. */
@@ -1,6 +1,6 @@
1
1
  // monorepo.mjs - every product is a monorepo, even a single app: the app root holds one package.json and lockfile whose npm
2
2
  // workspaces are the fe apps and packages (turbo runs their tasks), and the back end is a Nest monorepo of be/apps/<app>.
3
- // HFS_MONO_WORKSPACES the root package.json: `workspaces` exactly fe/apps/* and fe/packages/*, `packageManager` npm at an
3
+ // HFS_MONO_WORKSPACES the root package.json: `workspaces` exactly fe/apps/* and fe/packages/* (and packages/* once the app tracks a root workspace package, slot app.packages), `packageManager` npm at an
4
4
  // exact version, turbo a root devDependency (its version is the canon pin); turbo.json is app.task-graph
5
5
  // HFS_MONO_FE_WORKSPACE every fe app's package.json is @<project>/<app>, private, with exactly the workspace scripts; every
6
6
  // fe package's package.json is private with build and typecheck scripts and the workspace lint
@@ -22,6 +22,10 @@ const MONO_NEST_PROJECTS = 'HFS_MONO_NEST_PROJECTS';
22
22
  const MONO_WORKSPACE_DEP = 'HFS_MONO_WORKSPACE_DEP';
23
23
  /** The npm workspaces of every app, in this order. */
24
24
  export const WORKSPACES = Object.freeze(['fe/apps/*', 'fe/packages/*']);
25
+ /** The workspace of the app's own root packages (slot app.packages: packages/agent-sdk, packages/module-<name>), added after the fe ones once one is tracked. */
26
+ export const ROOT_PACKAGES_WORKSPACE = 'packages/*';
27
+ /** The workspaces the tracked tree `files` calls for: the fe ones, then `packages/*` when a root package tracks its package.json. */
28
+ export const workspacesFor = (files) => (files.some((file) => /^packages\/[^/]+\/package\.json$/.test(file)) ? [...WORKSPACES, ROOT_PACKAGES_WORKSPACE] : WORKSPACES);
25
29
  /** The scripts of every fe app workspace, exactly: turbo runs build, dev and typecheck; start serves a build. The lint is the root's (`starci app check --gate lint`), never a workspace script. */
26
30
  export const FE_APP_SCRIPTS = Object.freeze({ build: 'next build', dev: 'next dev', start: 'next start', typecheck: 'tsc --noEmit' });
27
31
  /** The scripts every fe package workspace has (its build and typecheck commands are its own); it has no lint script. */
@@ -49,13 +53,14 @@ function workspacesOf(files) {
49
53
  return [...folders].sort(byCodeUnit);
50
54
  }
51
55
  /** R143: the root package.json declares the workspaces, the package manager and turbo. */
52
- function rootFindings(repoRoot) {
56
+ function rootFindings(repoRoot, files) {
53
57
  const pkg = readJson(repoRoot, MANIFEST);
54
58
  if (!pkg)
55
59
  return [];
56
60
  const findings = [];
57
- if (!sameList(pkg.workspaces, WORKSPACES))
58
- findings.push(found(MONO_WORKSPACES, MANIFEST, `${MANIFEST} declares workspaces ${JSON.stringify(pkg.workspaces ?? null)}; every app is a monorepo whose npm workspaces are exactly ${JSON.stringify(WORKSPACES)} (each fe app and package has its own package.json; be has none).`, { workspaces: pkg.workspaces ?? null }));
61
+ const wanted = workspacesFor(files);
62
+ if (!sameList(pkg.workspaces, wanted))
63
+ findings.push(found(MONO_WORKSPACES, MANIFEST, `${MANIFEST} declares workspaces ${JSON.stringify(pkg.workspaces ?? null)}; every app is a monorepo whose npm workspaces are exactly ${JSON.stringify(wanted)} (each fe app and package has its own package.json; be has none; ${ROOT_PACKAGES_WORKSPACE} is listed when the app tracks a root package).`, { workspaces: pkg.workspaces ?? null }));
59
64
  if (!PACKAGE_MANAGER_FORM.test(String(pkg.packageManager ?? '')))
60
65
  findings.push(found(MONO_WORKSPACES, MANIFEST, `${MANIFEST} declares packageManager ${JSON.stringify(pkg.packageManager ?? null)}; turbo needs \`npm@<exact version>\` there.`, { packageManager: pkg.packageManager ?? null }));
61
66
  if (typeof pkg.devDependencies?.turbo !== 'string')
@@ -234,7 +239,7 @@ export function monorepoFindings({ repoRoot, files, repo }) {
234
239
  const workspaces = workspacesOf(files);
235
240
  const beApps = repo.sides.be.apps;
236
241
  return [
237
- ...rootFindings(repoRoot),
242
+ ...rootFindings(repoRoot, files),
238
243
  ...workspaceManifestFindings(repoRoot, repo.project, workspaces),
239
244
  ...nestFindings(repoRoot, files, beApps),
240
245
  ...workspaceDepFindings(repoRoot, files, workspaces),
@@ -8,6 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { FORBIDDEN_FILES, secretHitsInFile } from '../../lib/secret-patterns.mjs';
11
+ import { SECRETS_CACHE_FILE, secretRecordFaults } from '../../lib/secret-records.mjs';
11
12
  import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
12
13
  import { found, readText } from './read.mjs';
13
14
  const PLAINTEXT_SECRET = 'HFS_PLAINTEXT_SECRET';
@@ -28,6 +29,14 @@ export function untrackedSecretFindings({ repoRoot, files, repo }) {
28
29
  }
29
30
  return [found(PLAINTEXT_SECRET, 'secrets.env', 'secrets.env is an untracked plaintext secret at the lite app root; seal it with starci app secret set --env <env> into .starcistacks/<env>/secrets/<slug>.enc and remove the plaintext file; read materialized values by *_FILE', { level: 'warning', pattern: 'untracked-secrets-env' })];
30
31
  }
32
+ /** The findings of a file of the committed secrets cache: it is judged by what it holds (codes, paths, positions and digests, secret-records.mjs), never by the `secrets/` folder it sits in. */
33
+ function cacheRecordFindings(file, text) {
34
+ const faults = secretRecordFaults(text);
35
+ if (faults.length === 0)
36
+ return [];
37
+ const more = faults.length > 1 ? ` (and ${faults.length - 1} more)` : '';
38
+ return [found(PLAINTEXT_SECRET, file, `${file}: ${faults[0]}${more}; the secrets cache holds codes, paths, positions and digests only, so a file that holds more may carry secret material; regenerate it with starci app check --gate secrets, or migrate it with starci app codemod secrets-cache --execute`, { pattern: 'secrets-cache-record' })];
39
+ }
31
40
  /**
32
41
  * The findings of R06 for one tracked file that no forbidden slot claims: a secret by being (an env file, a key file), an `.enc` that is
33
42
  * no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
@@ -39,6 +48,8 @@ export function secretFileFindings({ file, text, manifestText }) {
39
48
  return [found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret by being (${being.name}); a secret is committed only as a sops envelope at .starcistacks/<env>/secrets/<slug>.enc`, { pattern: being.name })];
40
49
  if (text === null || text === undefined || NOT_SCANNED.test(file))
41
50
  return [];
51
+ if (SECRETS_CACHE_FILE.test(file))
52
+ return cacheRecordFindings(file, text);
42
53
  if (/\.enc(?:\.yaml)?$/.test(file))
43
54
  return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
44
55
  const findings = [];
@@ -1,4 +1,7 @@
1
+ /** A refusal with a message safe to print; `code` names it when a caller or a spec keys on it. */
1
2
  export declare class SecretError extends Error {
3
+ code: string | undefined;
4
+ constructor(message: string, code?: string);
2
5
  }
3
6
  export declare const publicStorage: <T>(action: () => T) => T;
4
7
  export declare function secretErrorMessage(caught: unknown): string;
@@ -1,11 +1,19 @@
1
1
  // secret-errors.mjs - sanitized public errors shared by app secret operations.
2
+ /** A refusal with a message safe to print; `code` names it when a caller or a spec keys on it. */
2
3
  export class SecretError extends Error {
4
+ code;
5
+ constructor(message, code) {
6
+ super(message);
7
+ this.code = code;
8
+ }
3
9
  }
4
10
  export const publicStorage = (action) => {
5
11
  try {
6
12
  return action();
7
13
  }
8
14
  catch (caught) {
15
+ if (caught instanceof SecretError)
16
+ throw caught;
9
17
  // reason: a thrown value is untyped; storage failures are Node errors carrying an optional code.
10
18
  const error = caught;
11
19
  throw new SecretError(error.code ? `private storage failed (${error.code})` : error.message);
@@ -72,6 +72,12 @@ export declare function decryptionIdentity(repoRoot: string, env: NodeJS.Process
72
72
  export declare function ageIdentityEnv(repoRoot: string, env: NodeJS.ProcessEnv, tools?: SecretToolOverrides): NodeJS.ProcessEnv;
73
73
  /** Select the first matching public age rule, including for SOPS's isolated CI identity path. */
74
74
  export declare function configuredRecipients(repoRoot: string, file: string): string[];
75
+ /**
76
+ * Whether sops itself finds a creation rule for `file`: the first rule whose path_regex matches the path as written (sops reads the file's own
77
+ * name, so a `.enc.yaml` member is not matched by a rule written for the legacy `.enc` spelling, which configuredRecipients tolerates when it
78
+ * only reads recipients). A rule with no path_regex matches everything.
79
+ */
80
+ export declare function sopsFindsRule(repoRoot: string, file: string): boolean;
75
81
  /** Resolve required tools without exposing subprocess output (which can contain private data). */
76
82
  export declare function secretTools(env: NodeJS.ProcessEnv, tools?: SecretToolOverrides): SecretTools;
77
83
  /**
@@ -154,6 +154,29 @@ export function configuredRecipients(repoRoot, file) {
154
154
  throw new Error(".sops.yaml must supply public age recipients for this secret; run starci app secret init");
155
155
  return recipients;
156
156
  }
157
+ /**
158
+ * Whether sops itself finds a creation rule for `file`: the first rule whose path_regex matches the path as written (sops reads the file's own
159
+ * name, so a `.enc.yaml` member is not matched by a rule written for the legacy `.enc` spelling, which configuredRecipients tolerates when it
160
+ * only reads recipients). A rule with no path_regex matches everything.
161
+ */
162
+ export function sopsFindsRule(repoRoot, file) {
163
+ let config; // reason: parsed YAML boundary, read from disk
164
+ try {
165
+ config = parseYaml(fs.readFileSync(path.join(repoRoot, ".sops.yaml"), "utf8"));
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ const relative = path.relative(repoRoot, file).split(path.sep).join("/");
171
+ return (config?.creation_rules ?? []).some((entry) => {
172
+ try {
173
+ return !entry.path_regex || new RegExp(entry.path_regex).test(relative) || new RegExp(entry.path_regex).test(relative.replaceAll("/", "\\"));
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ });
179
+ }
157
180
  /** Resolve required tools without exposing subprocess output (which can contain private data). */
158
181
  export function secretTools(env, tools = {}) {
159
182
  const sops = (tools.resolveSops ?? resolveSops)(env);
@@ -10,7 +10,7 @@ import { updateKeys as sopsUpdateKeys } from "../api/sops/update-keys.mjs";
10
10
  import { isLegacyName } from "../lib/sealed-member.mjs";
11
11
  import { invocationDir } from "../lib/roots.mjs";
12
12
  import { envelopeOf } from "./secret-document.mjs";
13
- import { configuredRecipients, decryptionIdentity, secretTools, } from "./secret-init.mjs";
13
+ import { configuredRecipients, sopsFindsRule, decryptionIdentity, secretTools, } from "./secret-init.mjs";
14
14
  import { outsideRepository } from "./secret-files.mjs";
15
15
  import { SecretError, publicStorage } from "./secret-errors.mjs";
16
16
  const sopsInvocation = Object.freeze({ runProgram, resolveRealTool });
@@ -83,9 +83,17 @@ function identityMissing(repoRoot, identity, file) {
83
83
  * the existing member is sealed to, so no rewrite (set, migrate, split, an edit) drops a recipient such as the deploy server's.
84
84
  * `ruled` says whether a rule of the app's .sops.yaml matches the file. Refused, as the rule refuses, when none names one.
85
85
  */
86
+ /** The refusal for a member no creation rule of `.sops.yaml` reaches, naming the path and the way to the per-environment rules. */
87
+ function unruledMessage(repoRoot, file) {
88
+ const relative = path.relative(repoRoot, file).split(path.sep).join("/");
89
+ const environment = /^\.starcistacks\/([^/]+)\//.exec(relative)?.[1] ?? "<env>";
90
+ return `SECRET_SOPS_RULE_MISSING: no creation rule of .sops.yaml matches ${relative} (a rule written for the legacy .enc spelling does not match .enc.yaml), so sops cannot seal it; run starci app secret init --env ${environment}, which writes the per-environment rules, or starci app codemod sops-rules --execute to migrate the file`;
91
+ }
86
92
  function sealRecipients(repoRoot, file, named) {
87
93
  try {
88
94
  const rule = configuredRecipients(repoRoot, file);
95
+ if (!sopsFindsRule(repoRoot, file))
96
+ throw new SecretError(unruledMessage(repoRoot, file), "SECRET_SOPS_RULE_MISSING");
89
97
  return { recipients: named.length ? named : rule, ruled: true };
90
98
  }
91
99
  catch (error) {
@@ -59,6 +59,25 @@ export declare function sshTransportMembers(spec: EnvironmentSpec | undefined):
59
59
  * RECIPIENT_DRIFT, UNDECLARED_KEY (only where application-stacks.yaml declares the environment), UNTRACKED. Names only.
60
60
  */
61
61
  export declare function auditSecrets(repoRoot: string, environment?: string | null): AuditFinding[];
62
+ /** What `secret set` did with a member that is a service credential (tooling, never an app runtime secret): the keys it bound, or the remedy to apply by hand. */
63
+ export interface ToolingOutcome {
64
+ bound: string[];
65
+ remedy: string;
66
+ }
67
+ /** One sealed tooling member `bindToolingMembers` judged: what it bound, the remedy when it could not, and the runtime entries an earlier `secret set` declared for it. */
68
+ export interface ToolingMember {
69
+ environment: string;
70
+ member: string;
71
+ service: string;
72
+ outcome: ToolingOutcome;
73
+ runtimeEntries: string[];
74
+ }
75
+ /**
76
+ * The tooling members of the app (a sealed `.starcistacks/<env>/secrets/<member>.enc.yaml` that a service of application-stacks.yaml owns): each is bound
77
+ * to its service when `write` and the binding is missing in the shape the template renders. The runtime `secrets:` entries an earlier `secret set` declared for
78
+ * the member are named, not removed: the owner deletes them (CI custody refuses the token while one stands).
79
+ */
80
+ export declare function bindToolingMembers(repoRoot: string, write: boolean): ToolingMember[];
62
81
  /**
63
82
  * Declares each of `keys` of `member` in environment `environment`'s `secrets:` of application-stacks.yaml, one entry per key
64
83
  * (`name: <kebab of the key>, member, key, where: secrets/<member>`), so the key is materialized as its own `<KEY>_FILE`. A key
@@ -68,6 +87,7 @@ export declare function auditSecrets(repoRoot: string, environment?: string | nu
68
87
  export declare function declareKeys(repoRoot: string, environment: string, member: string, keys: string[]): {
69
88
  declared: string[];
70
89
  byHand: string[];
90
+ tooling?: ToolingOutcome;
71
91
  };
72
92
  /**
73
93
  * Records `recipient` as `environments.<environment>.deploy.remote.serverRecipient` of application-stacks.yaml: the public age
@@ -160,6 +160,107 @@ function childLine(lines, block, key) {
160
160
  return at;
161
161
  return -1;
162
162
  }
163
+ /** The service of `services:` that `member` is the sealed credential of: named like the member (`sonar`), or one whose credential custody path is the member's file. */
164
+ function toolingServiceOf(document, member) {
165
+ const services = (document["services"] ?? {});
166
+ for (const [name, service] of Object.entries(services)) {
167
+ if (service === null || typeof service !== "object")
168
+ continue;
169
+ const owns = (service.credentials ?? []).some((credential) => new RegExp(`/${member}\\.enc(?:\\.yaml)?$`, "u").test(credential?.custody?.path ?? ""));
170
+ if (name === member || owns)
171
+ return { name, service };
172
+ }
173
+ return null;
174
+ }
175
+ /**
176
+ * A tooling member (sonar.enc.yaml: the analysis token a CI job reads) is a credential of a service, not a secret the app reads at runtime, so it never
177
+ * becomes an `environments.<env>.secrets` entry (CI custody refuses a runtime-granted CI secret, APP_SECRET_IN_CI). Its binding is the service's
178
+ * `credentials` entry (custody names the sealed file) and the `ci.secrets` entry that reads it. A missing binding of the shape the template renders
179
+ * (`credentials: []`) is written; any other shape returns the exact binding to add by hand.
180
+ */
181
+ function bindTooling(file, text, document, repository, environment, member, keys, found) {
182
+ const bound = new Set((found.service.credentials ?? []).map((credential) => credential.key));
183
+ const missing = keys.filter((key) => !bound.has(key));
184
+ if (!missing.length)
185
+ return { bound: [], remedy: "" };
186
+ const project = found.service.projects?.[0]?.key ?? repository;
187
+ const custodyPath = `.starcistacks/${environment}/secrets/${member}.enc.yaml`;
188
+ const first = !(found.service.credentials ?? []).length;
189
+ const credentials = missing.map((key, index) => ({ id: first && index === 0 ? "analysis" : key.toLowerCase().replaceAll("_", "-"), project, key, custody: { repository, path: custodyPath } }));
190
+ const ciSecrets = credentials.map((credential) => ({ name: credential.key, credential: credential.id }));
191
+ const remedy = `${member} is a tooling credential of services.${found.name}, not an app runtime secret, so it is not declared in environments.${environment}.secrets; bind it by hand: services.${found.name}.credentials: ${JSON.stringify(credentials)} and services.${found.name}.ci.secrets: ${JSON.stringify(ciSecrets)}`;
192
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
193
+ const lines = text.replace(/\r\n/gu, "\n").split("\n");
194
+ const root = lines.findIndex((line) => /^services:\s*(?:#.*)?$/u.test(line));
195
+ const serviceLine = root < 0 ? -1 : childLine(lines, blockOf(lines, root), found.name);
196
+ const credentialsLine = serviceLine < 0 ? -1 : childLine(lines, blockOf(lines, serviceLine), "credentials");
197
+ if (credentialsLine < 0 || !/^ *credentials:\s*\[\s*\]\s*(?:#.*)?$/u.test(lines[credentialsLine]) || !first)
198
+ return { bound: [], remedy };
199
+ const pad = indentOf(lines[credentialsLine]);
200
+ const ciLine = childLine(lines, blockOf(lines, serviceLine), "ci");
201
+ const expected = structuredClone(document);
202
+ const target = expected["services"][found.name];
203
+ target.credentials = credentials;
204
+ if (target.ci !== undefined)
205
+ target.ci.secrets = [...(target.ci.secrets ?? []), ...ciSecrets];
206
+ if (target.ci !== undefined) {
207
+ const ci = ciLine < 0 ? "" : lines[ciLine];
208
+ const secrets = `[${ciSecrets.map((entry) => `{name: ${entry.name}, credential: ${entry.credential}}`).join(", ")}]`;
209
+ if (/secrets:\s*\[\s*\]/u.test(ci))
210
+ lines[ciLine] = ci.replace(/secrets:\s*\[\s*\]/u, `secrets: ${secrets}`);
211
+ else if (/^ *ci:\s*\{.*\}\s*$/u.test(ci) && !/secrets:/u.test(ci))
212
+ lines[ciLine] = ci.replace(/\}\s*$/u, `, secrets: ${secrets}}`);
213
+ else
214
+ return { bound: [], remedy };
215
+ }
216
+ const block = credentials.flatMap((credential) => [`${" ".repeat(pad + 2)}- id: ${credential.id}`, `${" ".repeat(pad + 4)}project: ${JSON.stringify(credential.project)}`, `${" ".repeat(pad + 4)}key: ${credential.key}`, `${" ".repeat(pad + 4)}custody: {repository: ${JSON.stringify(credential.custody.repository)}, path: ${credential.custody.path}}`]);
217
+ lines.splice(credentialsLine, 1, `${" ".repeat(pad)}credentials:`, ...block);
218
+ const edited = lines.join(newline);
219
+ let parsed;
220
+ try {
221
+ parsed = parseYaml(edited);
222
+ }
223
+ catch {
224
+ parsed = undefined;
225
+ }
226
+ if (JSON.stringify(parsed) !== JSON.stringify(expected))
227
+ return { bound: [], remedy };
228
+ fs.writeFileSync(file, edited);
229
+ return { bound: missing, remedy: "" };
230
+ }
231
+ /**
232
+ * The tooling members of the app (a sealed `.starcistacks/<env>/secrets/<member>.enc.yaml` that a service of application-stacks.yaml owns): each is bound
233
+ * to its service when `write` and the binding is missing in the shape the template renders. The runtime `secrets:` entries an earlier `secret set` declared for
234
+ * the member are named, not removed: the owner deletes them (CI custody refuses the token while one stands).
235
+ */
236
+ export function bindToolingMembers(repoRoot, write) {
237
+ const file = path.join(repoRoot, ".starcistacks", "application-stacks.yaml");
238
+ if (!fs.existsSync(file))
239
+ return [];
240
+ const text = fs.readFileSync(file, "utf8");
241
+ const document = parseYaml(text) ?? {}; // reason: parsed YAML boundary, read from disk
242
+ const repository = repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(document)) });
243
+ const found = [];
244
+ for (const environment of Object.keys(document.environments ?? {})) {
245
+ const directory = path.join(repoRoot, ".starcistacks", environment, "secrets");
246
+ if (!fs.existsSync(directory))
247
+ continue;
248
+ for (const name of fs.readdirSync(directory).filter((entry) => entry.endsWith(MEMBER_SUFFIX)).sort()) {
249
+ const member = name.slice(0, -MEMBER_SUFFIX.length);
250
+ const service = toolingServiceOf(document, member);
251
+ if (service === null)
252
+ continue;
253
+ const keys = envelopeOf(fs.readFileSync(path.join(directory, name), "utf8")).keys;
254
+ const entries = Array.isArray(document.environments?.[environment]?.secrets) ? document.environments?.[environment]?.secrets : [];
255
+ const runtimeEntries = entries.filter((entry) => memberOfEntry(entry) === member).map((entry) => String(entry.name ?? entry.key ?? member));
256
+ const outcome = write
257
+ ? bindTooling(file, fs.readFileSync(file, "utf8"), parseYaml(fs.readFileSync(file, "utf8")) ?? {}, repository, environment, member, keys, service)
258
+ : { bound: [], remedy: (service.service.credentials ?? []).length ? "" : `services.${service.name} has no credential for ${member}` };
259
+ found.push({ environment, member, service: service.name, outcome, runtimeEntries });
260
+ }
261
+ }
262
+ return found;
263
+ }
163
264
  /**
164
265
  * Declares each of `keys` of `member` in environment `environment`'s `secrets:` of application-stacks.yaml, one entry per key
165
266
  * (`name: <kebab of the key>, member, key, where: secrets/<member>`), so the key is materialized as its own `<KEY>_FILE`. A key
@@ -175,6 +276,11 @@ export function declareKeys(repoRoot, environment, member, keys) {
175
276
  const spec = document.environments?.[environment];
176
277
  if (!spec)
177
278
  return { declared: [], byHand: [] };
279
+ const tooling = toolingServiceOf(document, member);
280
+ if (tooling !== null) {
281
+ const outcome = bindTooling(file, text, document, repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(document)) }), environment, member, keys, tooling);
282
+ return outcome.bound.length || outcome.remedy ? { declared: [], byHand: [], tooling: outcome } : { declared: [], byHand: [] };
283
+ }
178
284
  const serviceKeys = declaredServiceKeys(document, repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(document)) }), environment, member);
179
285
  const missing = keys.filter((key) => !declaredKeys(spec, member, keys).includes(key) && !serviceKeys.includes(key));
180
286
  if (!missing.length)
@@ -193,7 +193,11 @@ function fromFiles(entries, keys) {
193
193
  return files;
194
194
  }
195
195
  /** What `set` declared in the environment's stack `secrets:`, or the keys to declare by hand; nothing when all were declared. */
196
- function declarationLine(environment, { declared, byHand }) {
196
+ function declarationLine(environment, { declared, byHand, tooling }) {
197
+ if (tooling?.bound.length)
198
+ return `bound ${tooling.bound.join(", ")} to the service credentials and ci.secrets of application-stacks.yaml (a tooling member is not a runtime secret)\n`;
199
+ if (tooling?.remedy)
200
+ return `${tooling.remedy}\n`;
197
201
  if (declared.length)
198
202
  return `declared ${declared.join(", ")} in environments.${environment}.secrets of application-stacks.yaml\n`;
199
203
  if (byHand.length)
@@ -43,3 +43,26 @@ export declare const declaredHfsView: ({ runtimeRoot, declaration, repoRoot, sid
43
43
  repoRoot: string;
44
44
  side: string;
45
45
  }) => HfsView;
46
+ /**
47
+ * The view of the root packages of the app at `repoRoot` (the app root, where the root `eslint.config.mjs` lives), for the laws of `side`.
48
+ *
49
+ * @param {{ runtimeRoot: string, repoRoot: string, side: string }} input - Where the manifest copy lives, the app root, and the side whose laws apply.
50
+ * @returns {object} The frozen view.
51
+ */
52
+ export declare const openPackagesHfsView: ({ runtimeRoot, repoRoot, side }: {
53
+ runtimeRoot: string;
54
+ repoRoot: string;
55
+ side: "be" | "fe";
56
+ }) => HfsView;
57
+ /**
58
+ * The view of the root packages of an in-memory app declaration (rule tests): no file is read except the manifest.
59
+ *
60
+ * @param {{ runtimeRoot: string, declaration: object, repoRoot: string, side: string }} input - Manifest root, the app hfs.json object, the app root and the side.
61
+ * @returns {object} The frozen view.
62
+ */
63
+ export declare const declaredPackagesHfsView: ({ runtimeRoot, declaration, repoRoot, side }: {
64
+ runtimeRoot: string;
65
+ declaration: Parsed;
66
+ repoRoot: string;
67
+ side: "be" | "fe";
68
+ }) => HfsView;
@@ -5,6 +5,7 @@
5
5
  import path from 'node:path';
6
6
  import { allowsFile } from './allows.mjs';
7
7
  import { openHfs } from './slots.mjs';
8
+ import { ROOT_PACKAGE_SLOTS } from './architecture/root-packages.mjs';
8
9
  const posix = (p) => String(p).replaceAll('\\', '/');
9
10
  const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(text);
10
11
  /**
@@ -69,3 +70,70 @@ export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root
69
70
  * @returns {object} The frozen view.
70
71
  */
71
72
  export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => hfsView(openHfs({ root: runtimeRoot, declaration, side }), repoRoot);
73
+ /**
74
+ * The view of the root workspace packages of an app (`packages/<pkg>/src/**`, slots app.packages and app.module-registry), as the lint of one
75
+ * side sees them: the rule parameters, the apps and the Grammar of `side` (a package is judged by the laws of the side whose subpath a file
76
+ * sits in), and the paths of the APP ROOT (`repoRoot` is the app root, not a side folder). The slots answer for the root paths; a file of a
77
+ * package is of the tier `package` and has no owner: the owner laws (one `index.ts` per owner root) are the module folders', a package's public
78
+ * surface being the `exports` of its manifest.
79
+ *
80
+ * @param {object} opened - The result of `openHfs` for the app root (profile app, both sides resolved).
81
+ * @param {string} repoRoot - The absolute app root.
82
+ * @param {string} side - `be` or `fe`: the side whose laws the file is judged by.
83
+ * @returns {object} The frozen view: the profile of the side over the paths of the app root.
84
+ */
85
+ function packagesView(opened, repoRoot, side) {
86
+ const scope = opened.sides?.[side];
87
+ const sideRepo = opened.repo.sides?.[side];
88
+ if (scope === undefined || sideRepo === undefined)
89
+ throw new Error('the view of the root packages needs the declaration of an app with both sides');
90
+ const rel = (file) => {
91
+ const text = posix(file);
92
+ return isAbsoluteAny(text) ? posix(path.relative(repoRoot, file)) : text.replace(/^\.\//, '');
93
+ };
94
+ const cache = new Map();
95
+ const classify = (file) => {
96
+ const key = rel(file);
97
+ if (!cache.has(key))
98
+ cache.set(key, opened.classifyPath(key));
99
+ return cache.get(key);
100
+ };
101
+ const packageOf = (file) => {
102
+ const slot = classify(file).slot;
103
+ const relative = rel(file);
104
+ return slot !== undefined && ROOT_PACKAGE_SLOTS.has(slot) ? relative.split('/').slice(0, 2).join('/') : null;
105
+ };
106
+ return Object.freeze({
107
+ repoRoot,
108
+ profile: side,
109
+ side,
110
+ edition: opened.repo.edition ?? 'full',
111
+ ...(sideRepo.grammar === undefined ? {} : { grammar: sideRepo.grammar }),
112
+ apps: sideRepo.apps,
113
+ connections: sideRepo.connections,
114
+ patterns: Object.freeze([...(sideRepo.patterns ?? [])]),
115
+ ruleParams: scope.ruleParams(),
116
+ relative: rel,
117
+ classify,
118
+ slotOf: (file) => classify(file).slot ?? null,
119
+ tierOf: (file) => (packageOf(file) === null ? null : 'package'),
120
+ // A package is no owner of the module laws (one index.ts at the owner root): its public surface is the `exports` of its manifest, so no file of it has an owner.
121
+ ownerOf: () => null,
122
+ allows: (file) => allowsFile(opened, rel(file)),
123
+ slot: (id) => opened.slot(id),
124
+ });
125
+ }
126
+ /**
127
+ * The view of the root packages of the app at `repoRoot` (the app root, where the root `eslint.config.mjs` lives), for the laws of `side`.
128
+ *
129
+ * @param {{ runtimeRoot: string, repoRoot: string, side: string }} input - Where the manifest copy lives, the app root, and the side whose laws apply.
130
+ * @returns {object} The frozen view.
131
+ */
132
+ export const openPackagesHfsView = ({ runtimeRoot, repoRoot, side }) => packagesView(openHfs({ root: runtimeRoot, repoRoot }), repoRoot, side);
133
+ /**
134
+ * The view of the root packages of an in-memory app declaration (rule tests): no file is read except the manifest.
135
+ *
136
+ * @param {{ runtimeRoot: string, declaration: object, repoRoot: string, side: string }} input - Manifest root, the app hfs.json object, the app root and the side.
137
+ * @returns {object} The frozen view.
138
+ */
139
+ export const declaredPackagesHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => packagesView(openHfs({ root: runtimeRoot, declaration }), repoRoot, side);
@@ -26,6 +26,10 @@ export declare const DECLARATION: RegExp;
26
26
  export declare const TYPESCRIPT: RegExp;
27
27
  /** Whether `file` is a TypeScript source (not a declaration file) the declaration emit reads. */
28
28
  export declare const isEmittableSource: (file: string) => boolean;
29
+ /** The package whose module-definition helpers return a type they state in their own declaration (R368 accepts a destructured export of their call). */
30
+ export declare const STATED_SURFACE_PACKAGE = "@starci/nest-kit";
31
+ /** The helpers of that package: each is called with the options type as a type argument and returns the explicitly typed definition. */
32
+ export declare const STATED_SURFACE_HELPERS: ReadonlySet<string>;
29
33
  /**
30
34
  * The declaration emit of one TypeScript source and the exports it could not write explicitly (a type inferred from bodies and other files), or null
31
35
  * when the compiler has no `transpileDeclaration` (before TypeScript 5.5). A declaration whose only diagnostics are components returning JSX has the
@@ -55,6 +55,52 @@ function returnsOnlyJsx(ts, source, start) {
55
55
  ts.forEachChild(fn.body, visit);
56
56
  return only;
57
57
  }
58
+ /** The package whose module-definition helpers return a type they state in their own declaration (R368 accepts a destructured export of their call). */
59
+ export const STATED_SURFACE_PACKAGE = '@starci/nest-kit';
60
+ /** The helpers of that package: each is called with the options type as a type argument and returns the explicitly typed definition. */
61
+ export const STATED_SURFACE_HELPERS = new Set(['defineConfigurableModule', 'defineCapabilityModule']);
62
+ /** The local names under which `source` imports a stated-surface helper from its package (`import { defineConfigurableModule as define }` gives `define`). */
63
+ function statedHelperNames(ts, source) {
64
+ const names = new Set();
65
+ for (const statement of source.statements) {
66
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== STATED_SURFACE_PACKAGE)
67
+ continue;
68
+ const bindings = statement.importClause?.namedBindings;
69
+ if (bindings === undefined || !ts.isNamedImports(bindings))
70
+ continue;
71
+ for (const element of bindings.elements)
72
+ if (STATED_SURFACE_HELPERS.has((element.propertyName ?? element.name).text))
73
+ names.add(element.name.text);
74
+ }
75
+ return names;
76
+ }
77
+ /**
78
+ * Whether the destructured export the diagnostic at `start` points at is `export const { A, B } = helper<Options>()` for a stated-surface helper:
79
+ * each name has the type the helper's declaration gives it, written there, so the file states its surface by naming the helper and the options
80
+ * type, exactly as `export const A: Host["A"] = built.A` states it through `Host`.
81
+ */
82
+ function statesSurfaceThroughHelper(ts, source, start) {
83
+ const helpers = statedHelperNames(ts, source);
84
+ if (helpers.size === 0)
85
+ return null;
86
+ let innermost = source;
87
+ const descend = (node) => {
88
+ if (start < node.getFullStart() || start >= node.end)
89
+ return;
90
+ innermost = node;
91
+ ts.forEachChild(node, descend);
92
+ };
93
+ ts.forEachChild(source, descend);
94
+ for (let node = innermost; node !== undefined && node !== source; node = node.parent) {
95
+ if (!ts.isVariableDeclaration(node))
96
+ continue;
97
+ const call = node.initializer;
98
+ const stated = ts.isObjectBindingPattern(node.name) && call !== undefined && ts.isCallExpression(call) && ts.isIdentifier(call.expression) && helpers.has(call.expression.text)
99
+ && (call.typeArguments?.length ?? 0) > 0 && node.name.elements.every((element) => ts.isIdentifier(element.name) && element.dotDotDotToken === undefined);
100
+ return stated && call !== undefined ? call.getText(source) : null;
101
+ }
102
+ return null;
103
+ }
58
104
  /** What to annotate for each isolated-declaration diagnostic code; any other code asks for the exported value's type. */
59
105
  const ANNOTATE = new Map([
60
106
  [9007, 'return type'], [9008, 'return type'], [9009, 'accessor type'], [9010, 'variable type'], [9011, 'parameter type'], [9012, 'property type'],
@@ -98,7 +144,14 @@ export function declarationSurface(ts, file, text, module) {
98
144
  return { outputText: declared.outputText, problems: [] };
99
145
  const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
100
146
  const problems = [];
147
+ // The emit writes `any` for a destructured export; the call that states its type joins the digest, so a change of the options type reaches the importers.
148
+ const stated = new Set();
101
149
  for (const diagnostic of diagnostics) {
150
+ const call = diagnostic.code === 9019 && diagnostic.start !== undefined ? statesSurfaceThroughHelper(ts, source, diagnostic.start) : null;
151
+ if (call !== null) {
152
+ stated.add(call);
153
+ continue;
154
+ }
102
155
  const exempt = (diagnostic.code === 9007 || diagnostic.code === 9008) && diagnostic.start !== undefined && returnsOnlyJsx(ts, source, diagnostic.start);
103
156
  if (exempt)
104
157
  continue;
@@ -108,5 +161,6 @@ export function declarationSurface(ts, file, text, module) {
108
161
  reason: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n').split('\n')[0] ?? '',
109
162
  });
110
163
  }
111
- return { outputText: declared.outputText, problems };
164
+ const outputText = stated.size === 0 ? declared.outputText : `${declared.outputText}${[...stated].sort().map((call) => `// stated by ${call}\n`).join('')}`;
165
+ return { outputText, problems };
112
166
  }