@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
@@ -0,0 +1,189 @@
1
+ // compile-guard.mjs - HFS_SYNC_GROUP_HELD: `starci app sync --execute` never writes a render that leaves the app uncompilable. Before it writes
2
+ // a managed TypeScript source (or deletes a retired one), the side's own TypeScript compiles the app with the writes laid over the disk and
3
+ // compares the diagnostics of every file they touch, and of every file that imports one, with the same files on disk. A managed group whose
4
+ // writes add an error (a template that reads a name the app does not provide yet, or drops one the app still imports) is held whole: none
5
+ // of its files is written, the rest of the render is, and the sync names the group, its files and the errors, so the app provides what the
6
+ // render reads and runs sync again. A side whose TypeScript is not installed cannot be compiled; its writes go through and say so.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
10
+ /** The refusal code of a held group. */
11
+ export const GROUP_HELD = 'HFS_SYNC_GROUP_HELD';
12
+ /** The pseudo-group of the retired files a sync deletes. */
13
+ export const RETIRED_GROUP = 'retired';
14
+ /** How many errors a held group prints. */
15
+ const SHOWN_ERRORS = 20;
16
+ const SOURCE = /\.tsx?$/;
17
+ /** Whether `file` (app-relative) is TypeScript source a side compiles. */
18
+ export const isGuardedSource = (file) => SOURCE.test(file) && !file.endsWith('.d.ts') && (file.startsWith('be/') || file.startsWith('fe/'));
19
+ /** The tsconfig that compiles an app-relative source: `be/tsconfig.json`, or the front-end app's or package's own; null when there is none. */
20
+ export function projectOf(root, file) {
21
+ const parts = file.split('/');
22
+ const project = parts[0] === 'be' ? 'be/tsconfig.json' : parts.length > 3 ? `fe/${parts[1]}/${parts[2]}/tsconfig.json` : null;
23
+ return project !== null && fs.existsSync(path.join(root, project)) ? project : null;
24
+ }
25
+ /** One side's compilation: its compiler, options and root files, the overlay of writes keyed the way the host compares file names. */
26
+ class Side {
27
+ #ts;
28
+ #root;
29
+ #options;
30
+ #rootNames;
31
+ #baseline = new Map();
32
+ #program;
33
+ constructor(ts, root, project) {
34
+ this.#ts = ts;
35
+ this.#root = root;
36
+ const file = path.join(root, project);
37
+ const read = ts.readConfigFile(file, ts.sys.readFile);
38
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(file), { noEmit: true }, file);
39
+ this.#options = { ...parsed.options, noEmit: true };
40
+ this.#rootNames = parsed.fileNames;
41
+ }
42
+ /** The name the host compares: absolute, forward slashes, lower case where the file system ignores case. */
43
+ key(file) {
44
+ const absolute = path.resolve(this.#root, file).replaceAll('\\', '/');
45
+ return this.#ts.sys.useCaseSensitiveFileNames ? absolute : absolute.toLowerCase();
46
+ }
47
+ #host(overlay) {
48
+ const host = this.#ts.createCompilerHost(this.#options, true);
49
+ const { readFile, fileExists, getSourceFile } = host;
50
+ host.readFile = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) ?? undefined : readFile.call(host, name));
51
+ host.fileExists = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) !== null : fileExists.call(host, name));
52
+ host.getSourceFile = (name, language, onError, create) => {
53
+ if (!overlay.has(this.key(name)))
54
+ return getSourceFile.call(host, name, language, onError, create);
55
+ const text = overlay.get(this.key(name));
56
+ return text === null || text === undefined ? undefined : this.#ts.createSourceFile(name, text, language, true);
57
+ };
58
+ return host;
59
+ }
60
+ #programOf(overlay, added) {
61
+ const rootNames = [...this.#rootNames.filter((name) => overlay.get(this.key(name)) !== null), ...added];
62
+ return this.#ts.createProgram({ rootNames, options: this.#options, host: this.#host(overlay), ...(this.#program === undefined ? {} : { oldProgram: this.#program }) });
63
+ }
64
+ /** The files of the program that import one of `targets` (keys), each with the targets it imports. */
65
+ importers(targets, overlay) {
66
+ const host = this.#host(overlay);
67
+ const cache = this.#ts.createModuleResolutionCache(this.#root, (name) => this.key(name), this.#options);
68
+ const found = new Map();
69
+ for (const name of this.#rootNames) {
70
+ const text = host.readFile(name);
71
+ if (text === undefined)
72
+ continue;
73
+ for (const imported of this.#ts.preProcessFile(text, true, true).importedFiles) {
74
+ const resolved = this.#ts.resolveModuleName(imported.fileName, name, this.#options, host, cache).resolvedModule?.resolvedFileName;
75
+ if (resolved === undefined || !targets.has(this.key(resolved)))
76
+ continue;
77
+ const into = found.get(this.key(name)) ?? new Set();
78
+ into.add(this.key(resolved));
79
+ found.set(this.key(name), into);
80
+ }
81
+ }
82
+ return found;
83
+ }
84
+ /** The errors of `files` (keys) as `path TScode message`, each file once, in the program with `overlay` laid over the disk. */
85
+ errors(files, overlay, added) {
86
+ const program = this.#programOf(overlay, added);
87
+ if (overlay.size === 0)
88
+ this.#program = program;
89
+ const byKey = new Map(program.getSourceFiles().map((source) => [this.key(source.fileName), source]));
90
+ const answer = new Map();
91
+ for (const file of files) {
92
+ const source = byKey.get(file);
93
+ const relative = path.relative(this.#root, source?.fileName ?? file).replaceAll('\\', '/');
94
+ const found = source === undefined ? [] : [...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source)];
95
+ answer.set(file, found.map((diagnostic) => `${relative}${source === undefined || diagnostic.start === undefined ? '' : `:${String(source.getLineAndCharacterOfPosition(diagnostic.start).line + 1)}`} TS${String(diagnostic.code)} ${this.#ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`));
96
+ }
97
+ return answer;
98
+ }
99
+ /** The errors of `files` on disk, computed once per file. */
100
+ baseline(files) {
101
+ const missing = files.filter((file) => !this.#baseline.has(file));
102
+ if (missing.length > 0)
103
+ for (const [file, found] of this.errors(missing, new Map(), []))
104
+ this.#baseline.set(file, found);
105
+ return new Map(files.map((file) => [file, this.#baseline.get(file) ?? []]));
106
+ }
107
+ }
108
+ /** An error without its line, so a line moved by the write is the same error. */
109
+ const unplaced = (error) => error.replace(/^([^ ]*?):\d+ /, '$1 ');
110
+ /** The errors of `after` that `before` does not hold, counted as a multiset of unplaced errors. */
111
+ function added(before, after) {
112
+ const left = new Map();
113
+ for (const error of before)
114
+ left.set(unplaced(error), (left.get(unplaced(error)) ?? 0) + 1);
115
+ return after.filter((error) => {
116
+ const count = left.get(unplaced(error)) ?? 0;
117
+ left.set(unplaced(error), count - 1);
118
+ return count <= 0;
119
+ });
120
+ }
121
+ /** The held groups of one side: compile with every write of the groups still in play, hold the groups that add an error, again until none does. */
122
+ function judgeSide(side, writes) {
123
+ const held = [];
124
+ let open = [...writes];
125
+ while (open.length > 0) {
126
+ const overlay = new Map(open.map((write) => [side.key(write.path), write.text]));
127
+ const groupOf = new Map(open.map((write) => [side.key(write.path), write.group]));
128
+ // An importer is found on the disk with the new files added, so the importer of a file the writes delete is found too.
129
+ const importers = side.importers(new Set(overlay.keys()), new Map([...overlay].filter(([, text]) => text !== null)));
130
+ const files = [...new Set([...[...overlay.keys()].filter((file) => overlay.get(file) !== null), ...importers.keys()])];
131
+ const before = side.baseline(files);
132
+ const after = side.errors(files, overlay, open.filter((write) => write.text !== null).map((write) => path.resolve(write.path)));
133
+ const errors = new Map();
134
+ for (const file of files) {
135
+ const fresh = added(before.get(file) ?? [], after.get(file) ?? []);
136
+ if (fresh.length === 0)
137
+ continue;
138
+ const groups = groupOf.has(file) ? [groupOf.get(file)] : [...(importers.get(file) ?? [])].map((target) => groupOf.get(target));
139
+ for (const group of groups)
140
+ errors.set(group, [...(errors.get(group) ?? []), ...fresh]);
141
+ }
142
+ if (errors.size === 0)
143
+ break;
144
+ for (const [group, found] of errors)
145
+ held.push({ group, files: open.filter((write) => write.group === group).map((write) => write.path), errors: [...new Set(found)] });
146
+ open = open.filter((write) => !errors.has(write.group));
147
+ }
148
+ return held;
149
+ }
150
+ /**
151
+ * The groups `starci app sync --execute` holds back at `root`: every write of a TypeScript source is compiled, per side, with the side's own
152
+ * TypeScript (`loadTs`, the app's install by default); a side with no tsconfig compiles nothing, a side whose TypeScript is not installed is
153
+ * named in `unchecked`.
154
+ */
155
+ export function guardWrites(root, writes, loadTs = (dir) => loadTypescript(dir, root)) {
156
+ const verdict = { held: [], unchecked: [] };
157
+ const bySide = new Map();
158
+ for (const write of writes.filter((candidate) => isGuardedSource(candidate.path))) {
159
+ const project = projectOf(root, write.path);
160
+ if (project !== null)
161
+ bySide.set(project, [...(bySide.get(project) ?? []), { ...write, path: path.join(root, write.path) }]);
162
+ }
163
+ for (const [project, sideWrites] of bySide) {
164
+ const ts = loadTs(path.join(root, path.dirname(project)));
165
+ if (ts === null) {
166
+ verdict.unchecked.push(project);
167
+ continue;
168
+ }
169
+ for (const group of judgeSide(new Side(ts, root, project), sideWrites)) {
170
+ const relative = group.files.map((file) => path.relative(root, file).replaceAll('\\', '/'));
171
+ const known = verdict.held.find((entry) => entry.group === group.group);
172
+ if (known === undefined)
173
+ verdict.held.push({ ...group, files: relative });
174
+ else
175
+ verdict.held.splice(verdict.held.indexOf(known), 1, { group: group.group, files: [...known.files, ...relative], errors: [...known.errors, ...group.errors] });
176
+ }
177
+ }
178
+ return verdict;
179
+ }
180
+ /** The lines sync prints for a held group: what is held and why, the first errors, and what to do. */
181
+ export function heldLines(group) {
182
+ const more = group.errors.length > SHOWN_ERRORS ? [` ... ${String(group.errors.length - SHOWN_ERRORS)} more`] : [];
183
+ return [
184
+ `${GROUP_HELD} ${group.group}: ${String(group.files.length)} files not written, the render adds ${String(group.errors.length)} type errors to the app (${group.files.join(', ')})`,
185
+ ...group.errors.slice(0, SHOWN_ERRORS).map((error) => ` ${error}`),
186
+ ...more,
187
+ ` make the app provide what the render reads (each error names it), then run "npx --no-install starci app sync --execute" again`,
188
+ ];
189
+ }
package/sync/hygiene.mjs CHANGED
@@ -45,6 +45,8 @@ const PLAINTEXT_NAME = /(^|\/)(\.env(\..*)?|[^/]*\.(pem|key|identity|age))$/;
45
45
  /** The app root's work tree and stack tree, app-relative (`starci app check --staged` runs at the app root). */
46
46
  const WORK = '.starciwork/';
47
47
  const STACKS = '.starcistacks/';
48
+ /** The committed check cache: written by the CLI, judged by what its files hold (secret-records.mjs through the secret judgement), never by a `secrets/` folder in its path. */
49
+ const CACHE = '.starcistacks/cache/';
48
50
  const GUARDED = (file) => file.startsWith(WORK) || file.startsWith(STACKS);
49
51
  /** The subset of `files` git ignores (as if untracked), asked in one call: a Set of paths. */
50
52
  function ignoredAmong(cwd, files) {
@@ -67,7 +69,7 @@ export function hygieneFindings(files, ignored) {
67
69
  if (file.startsWith(WORK) && ignored.has(file)) {
68
70
  findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
69
71
  }
70
- if (file.startsWith(STACKS) && !/\.enc(?:\.yaml)?$/.test(file) && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
72
+ if (file.startsWith(STACKS) && !file.startsWith(CACHE) && !/\.enc(?:\.yaml)?$/.test(file) && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
71
73
  if (file.includes('/secrets/'))
72
74
  findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
73
75
  else if (PLAINTEXT_NAME.test(file))
package/sync/index.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type RepoView } from '../runtime/scripts/hfs/slots.mjs';
2
2
  import type { AppRepo } from '../runtime/scripts/hfs/slot-app-view.mjs';
3
3
  import type { Slot, SlotManifest } from '../runtime/scripts/hfs/manifest-shape.mjs';
4
+ import { guardWrites, type GuardedWrite } from './compile-guard.mjs';
4
5
  import type { ImageApp } from './stack-images.mjs';
5
6
  export declare const TEMPLATES_DIR: string;
6
7
  /** The scopes a target is rendered for: the app root, then each side. */
@@ -22,6 +23,7 @@ export interface Target {
22
23
  template: string;
23
24
  mode: string;
24
25
  slot?: string;
26
+ group?: string;
25
27
  markdown?: boolean;
26
28
  }
27
29
  /** The markers around a managed block. */
@@ -39,6 +41,8 @@ export interface RenderedTarget {
39
41
  scripts?: Record<string, unknown>;
40
42
  markdown?: boolean | undefined;
41
43
  markers?: Markers;
44
+ /** The managed group of a slot's file (`managedBy`), which sync writes or holds whole (compile-guard.mjs). */
45
+ group?: string;
42
46
  }
43
47
  /** What comparing a rendered target with the disk answers. */
44
48
  export interface TargetCheck {
@@ -140,6 +144,8 @@ export declare function sonarScope(presets: Presets | null | undefined): {
140
144
  tests: string[];
141
145
  exclusions: string[];
142
146
  };
147
+ /** The managed ESLint config at the app root that lints the root workspace packages. */
148
+ export declare const PACKAGES_LINT_FILE = "eslint.config.mjs";
143
149
  export declare function appSource(root: string, { parseYaml, notices }?: {
144
150
  parseYaml?: ParseYaml | undefined;
145
151
  notices?: string[] | undefined;
@@ -227,9 +233,13 @@ export declare function renderRepo(root: string, { presets, parseYaml, notices,
227
233
  export declare function retiredFiles(root: string, hfs: {
228
234
  edition?: string;
229
235
  } | null | undefined): string[];
230
- export declare function runSync(argv: string[], { cwd, out, presets, parseYaml }?: {
236
+ /** The writes of `targets` and the deletions of `retired` the compile guard judges: every file sync would change, with its managed group. */
237
+ export declare function guardedWrites(root: string, targets: readonly RenderedTarget[], retired: readonly string[]): GuardedWrite[];
238
+ export declare function runSync(argv: string[], { cwd, out, presets, parseYaml, loadTs }?: {
231
239
  cwd?: string;
232
240
  out?: (line: string) => unknown;
233
241
  presets?: Presets | undefined;
234
242
  parseYaml?: ParseYaml | undefined;
243
+ /** The TypeScript of a side folder the compile guard compiles with; the app's own install by default. */
244
+ loadTs?: Parameters<typeof guardWrites>[2];
235
245
  }): Promise<number>;
package/sync/index.mjs CHANGED
@@ -29,6 +29,7 @@ import { feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
29
29
  import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs/rules/docker.mjs';
30
30
  import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
31
31
  import { readDeclaredSonarKey } from './sonar-key.mjs';
32
+ import { RETIRED_GROUP, guardWrites, heldLines, isGuardedSource } from './compile-guard.mjs';
32
33
  import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
33
34
  import { DB_DOCKERFILE, DB_IMAGE_APP, RETIRED_DB_DOCKERFILE } from '../db/image.mjs';
34
35
  import { byCodeUnit } from '../report/order.mjs';
@@ -99,7 +100,7 @@ function slotTargets(slot, scope, edition, manifest, hasTemplate, enabled) {
99
100
  const template = managedTemplate(scope, group, file, hasTemplate);
100
101
  if (!template)
101
102
  throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${scope}, but templates/{${scope},common}/${group} has no template for it`);
102
- return { path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id };
103
+ return { path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id, group };
103
104
  });
104
105
  }
105
106
  function unlistedTargets(scope, edition) {
@@ -237,6 +238,9 @@ export function sonarScope(presets) {
237
238
  const tests = preset.filter(glob => !glob.endsWith('/**'));
238
239
  return { tests, exclusions: [...new Set([...preset, '**/.next/**', '**/node_modules/**'])] };
239
240
  }
241
+ /** The managed ESLint config at the app root that lints the root workspace packages. */
242
+ export const PACKAGES_LINT_FILE = 'eslint.config.mjs';
243
+ const LINE = '\n';
240
244
  const SOURCE_FILE = /\.[cm]?ts$/;
241
245
  const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
242
246
  /**
@@ -396,6 +400,30 @@ const moduleExclusions = (dirs) => dirs.flatMap(dir => [`${dir}/src/workbench/**
396
400
  function registryTargets(app, source) {
397
401
  return registryFiles(app.project, source?.modules ?? []).map((file) => ({ path: file.path, scope: APP_SCOPE, mode: 'file', content: file.content, hash: hashOf(file.content) }));
398
402
  }
403
+ /**
404
+ * The root `eslint.config.mjs` of an app that has root workspace packages (packages/agent-sdk, packages/module-<name>, the registry): no side config
405
+ * reaches `packages/`, so this managed file lints their source by subpath. The back-end canon judges everything but the front-end subpaths
406
+ * (`src/workbench/` outside its contract, `src/public/`), the front-end canon and the selected Grammar family those, each over the package view of
407
+ * the app root. An app without a root package has no such file; the family factory for packages is the family's `<name>GrammarPackagesConfig`.
408
+ */
409
+ function packagesLintTargets(app, source) {
410
+ if (!(source?.modules?.length || source?.otherPackages?.length))
411
+ return [];
412
+ const grammar = grammarLintFor(app.sides.fe.grammar);
413
+ const factory = grammar.package ? grammar.factory.replace(/Config$/, 'PackagesConfig') : null;
414
+ const content = [
415
+ 'import { loadPackagesHfs as loadBePackages, starciBePackagesConfig } from "@starci/eslint-canon-be"',
416
+ 'import { loadPackagesHfs as loadFePackages, starciFePackagesConfig } from "@starci/eslint-canon-fe"',
417
+ ...(grammar.package ? [`import { ${factory} } from "${grammar.package}"`] : []),
418
+ '',
419
+ 'const be = loadBePackages(import.meta.url)',
420
+ 'const fe = loadFePackages(import.meta.url)',
421
+ '',
422
+ `export default [...(await starciBePackagesConfig({ hfs: be })), ...starciFePackagesConfig({ hfs: fe })${grammar.package ? `, ...${factory}({ hfs: fe })` : ''}]`,
423
+ '',
424
+ ].join(LINE);
425
+ return [{ path: PACKAGES_LINT_FILE, scope: APP_SCOPE, mode: 'file', content, hash: hashOf(content) }];
426
+ }
399
427
  export function variables(app, scope, presets, sonarKey, manifest = loadSlotManifest(), source = { files: [], read: () => '' }) {
400
428
  const stack = source.stack ?? { images: [], passEnv: [] };
401
429
  const fe = app.sides.fe;
@@ -429,7 +457,9 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
429
457
  typecheck: `${modulesFirst}npm run codegen --silent && tsc -p be/tsconfig.json && turbo run typecheck`,
430
458
  // The typed lint reads the types the fe packages declare (their dist), so the packages are built first.
431
459
  lintPrefix: `${modulesFirst}${packages ? 'npm run codegen --silent && turbo run build --filter=./fe/packages/* && ' : 'npm run codegen --silent && '}`,
432
- moduleTests: modules.length ? ' && npm run test:modules' : '',
460
+ // The unit run, the back-end build and every CI job that imports a module package find it built (build:modules precedes them); the logic of a package is held at 100 per file by its own jest run, a step of the fast job.
461
+ modulesFirst,
462
+ moduleTestStep: modules.length ? ' - name: module packages\n run: npm run test:modules\n' : '',
433
463
  nodeMajor: String(NODE_MAJOR),
434
464
  // The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
435
465
  serverAgeIdentity: SERVER_AGE_IDENTITY,
@@ -503,7 +533,7 @@ function renderOnce(hfs, presets, { sonarKey, readTemplate = readBundled, manife
503
533
  declaredTargets.push({ path: 'README.md', mode: 'block', markdown: true, template: 'app/readme-badges/README.md' });
504
534
  const targets = declaredTargets
505
535
  .filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
506
- return [...(scope === APP_SCOPE ? registryTargets(app, source) : []), ...targets.map((target) => {
536
+ return [...(scope === APP_SCOPE ? [...registryTargets(app, source), ...packagesLintTargets(app, source)] : []), ...targets.map((target) => {
507
537
  const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
508
538
  ? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
509
539
  : readTemplate(target.template);
@@ -519,7 +549,7 @@ function renderOnce(hfs, presets, { sonarKey, readTemplate = readBundled, manife
519
549
  const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
520
550
  const spacing = target.markdown ? '\n' : '';
521
551
  const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
522
- return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
552
+ return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed), ...(target.group === undefined ? {} : { group: target.group }) };
523
553
  })];
524
554
  });
525
555
  }
@@ -657,6 +687,8 @@ const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
657
687
  /** The files a lite app's sync retires, each with the reason the dry run prints. */
658
688
  const LITE_RETIRED_FILES = Object.freeze({
659
689
  [RETIRED_DB_DOCKERFILE]: `the db image moved to ${DB_DOCKERFILE} (ruling lite-db-image-in-stacks)`,
690
+ 'be/src/modules/domain/identity/persistence/entities/auth-user.entity.ts': 'the sign-in reaches accounts through the account doors (be/src/modules/domain/identity/persistence/account-doors.port.ts); the application role holds no grant on schema auth',
691
+ 'be/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts': 'the sign-in reaches accounts through the account doors (be/src/modules/domain/identity/persistence/account-doors.port.ts); the application role holds no grant on schema auth',
660
692
  '.github/workflows/db-deploy.yml': 'the stack deploy migrates the database (a verified backup, the expand migrations before the services, the contract migrations after the accept; ruling app-cicd-auto-deploy)',
661
693
  });
662
694
  /** Why a retired file goes: a retired file's own reason, else the folder's. */
@@ -683,6 +715,16 @@ export function retiredFiles(root, hfs) {
683
715
  walk(file);
684
716
  return found;
685
717
  }
718
+ /** The writes of `targets` and the deletions of `retired` the compile guard judges: every file sync would change, with its managed group. */
719
+ export function guardedWrites(root, targets, retired) {
720
+ const changed = checkTargets(root, targets).filter(result => result.status !== 'ok' && isGuardedSource(result.path));
721
+ const writes = changed.map((result) => {
722
+ const target = targets.find(candidate => candidate.path === result.path);
723
+ const file = path.join(root, target.path);
724
+ return { path: target.path, group: target.group ?? target.path, text: writtenContent(target, fs.existsSync(file) ? lf(fs.readFileSync(file, 'utf8')) : '') };
725
+ });
726
+ return [...writes, ...retired.map((file) => ({ path: file, group: RETIRED_GROUP, text: null }))];
727
+ }
686
728
  /** `starci app sync [--execute] [--cwd <dir>]` at the app root: the dry run reports drift, --execute writes; returns 0 clean, 1 drift, 2 usage. */
687
729
  function syncOptions(argv) {
688
730
  const write = argv.includes('--execute');
@@ -707,7 +749,7 @@ function reportSyncCheck(root, targets, out, retired = []) {
707
749
  out(`starci app sync: ${results.length - bad.length} of ${results.length} in sync${bad.length ? '; run "npx --no-install starci app sync --execute"' : ''}`);
708
750
  return bad.length || retired.length ? 1 : 0;
709
751
  }
710
- export async function runSync(argv, { cwd = process.cwd(), out = (line) => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
752
+ export async function runSync(argv, { cwd = process.cwd(), out = (line) => process.stdout.write(`${line}\n`), presets, parseYaml, loadTs } = {}) {
711
753
  const options = syncOptions(argv);
712
754
  if (!options) {
713
755
  out('usage: starci app sync [--execute] [--cwd <dir>]');
@@ -723,18 +765,27 @@ export async function runSync(argv, { cwd = process.cwd(), out = (line) => proce
723
765
  for (const notice of new Set(notices))
724
766
  out(notice);
725
767
  if (write) {
726
- const written = writeTargets(root, targets);
768
+ const { held, unchecked } = guardWrites(root, guardedWrites(root, targets, retired), loadTs);
769
+ const holds = new Set(held.flatMap(group => group.files));
770
+ const free = targets.filter(target => !holds.has(target.path));
771
+ const written = writeTargets(root, free);
772
+ const removed = retired.filter(file => !holds.has(file));
727
773
  for (const file of written)
728
774
  out(`wrote ${file}`);
729
- for (const file of retired) {
775
+ for (const file of removed) {
730
776
  fs.rmSync(path.join(root, file));
731
777
  out(`removed ${file} (retired)`);
732
778
  }
733
779
  for (const dir of LITE_RETIRED_DIRS)
734
- if (retired.length && fs.existsSync(path.join(root, dir)) && !fs.readdirSync(path.join(root, dir)).length)
780
+ if (removed.length && fs.existsSync(path.join(root, dir)) && !fs.readdirSync(path.join(root, dir)).length)
735
781
  fs.rmdirSync(path.join(root, dir));
736
- out(`starci app sync: ${written.length} written, ${targets.length - written.length} already in sync`);
737
- return 0;
782
+ for (const project of unchecked)
783
+ out(`notice: the writes compiled by ${project} were not type-checked: its TypeScript is not installed (run npm install, then "npx --no-install starci app check")`);
784
+ for (const group of held)
785
+ for (const line of heldLines(group))
786
+ out(line);
787
+ out(`starci app sync: ${written.length} written, ${free.length - written.length} already in sync${holds.size ? `, ${holds.size} held` : ''}`);
788
+ return held.length ? 1 : 0;
738
789
  }
739
790
  return reportSyncCheck(root, targets, out, retired);
740
791
  }
@@ -0,0 +1,115 @@
1
+ -- The account doors of a lite app: the only way app_be reaches auth.users and auth.identities (it holds no grant on schema auth). Eight
2
+ -- SECURITY DEFINER functions with a pinned search_path, EXECUTE revoked from public and the browser roles and granted to app_be alone: an
3
+ -- account comes back only for an exact address or id, a hash only for an exact address, and no door writes role or app metadata; the hash
4
+ -- is written only by a new account and by be_auth_set_password.
5
+ -- The text is the canon owner of the doors (templates/app/auth-doors.sql.partial); the back end calls them through persistence/account-doors.port.ts.
6
+
7
+ -- Looks up an address in lower case; the bootstrap index serves email as written, not lower(email).
8
+ create index if not exists users_email_lower_idx on auth.users (lower(email)) where is_anonymous = false;
9
+
10
+ -- A person by sign-in address (lower-cased), with the stored bcrypt hash so the back end can verify it.
11
+ CREATE FUNCTION public.be_auth_user_by_email(p_email text)
12
+ RETURNS TABLE(id uuid, email text, encrypted_password text, email_confirmed_at timestamp with time zone, disabled boolean)
13
+ LANGUAGE sql STABLE SECURITY DEFINER
14
+ SET search_path TO ''
15
+ AS $$
16
+ select u.id, u.email::text, u.encrypted_password::text, u.email_confirmed_at,
17
+ (u.deleted_at is not null or (u.banned_until is not null and u.banned_until > now()))
18
+ from auth.users u
19
+ where lower(u.email) = p_email and u.is_anonymous = false
20
+ order by u.created_at asc nulls last
21
+ limit 1;
22
+ $$;
23
+
24
+ -- One person by id.
25
+ CREATE FUNCTION public.be_auth_user_by_id(p_user uuid)
26
+ RETURNS TABLE(id uuid, email text, last_sign_in_at timestamp with time zone, meta jsonb, disabled boolean)
27
+ LANGUAGE sql STABLE SECURITY DEFINER
28
+ SET search_path TO ''
29
+ AS $$
30
+ select u.id, u.email::text, u.last_sign_in_at, coalesce(u.raw_user_meta_data, '{}'::jsonb),
31
+ (u.deleted_at is not null or (u.banned_until is not null and u.banned_until > now()))
32
+ from auth.users u where u.id = p_user;
33
+ $$;
34
+
35
+ -- The person a Google account is linked to, if any.
36
+ CREATE FUNCTION public.be_auth_user_by_google(p_provider_id text) RETURNS uuid
37
+ LANGUAGE sql STABLE SECURITY DEFINER
38
+ SET search_path TO ''
39
+ AS $$
40
+ select i.user_id from auth.identities i where i.provider = 'google' and i.provider_id = p_provider_id limit 1;
41
+ $$;
42
+
43
+ -- Creates a person; a null hash is an invited or Google-only account that has no password yet.
44
+ CREATE FUNCTION public.be_auth_create_user(p_id uuid, p_email text, p_hash text, p_confirmed boolean, p_meta jsonb, p_now timestamp with time zone)
45
+ RETURNS uuid
46
+ LANGUAGE sql VOLATILE SECURITY DEFINER
47
+ SET search_path TO ''
48
+ AS $$
49
+ insert into auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, invited_at,
50
+ raw_app_meta_data, raw_user_meta_data, created_at, updated_at)
51
+ values ('00000000-0000-0000-0000-000000000000', p_id, 'authenticated', 'authenticated', p_email, p_hash,
52
+ case when p_confirmed then p_now end, case when p_hash is null and not p_confirmed then p_now end,
53
+ '{"provider":"email","providers":["email"]}'::jsonb, p_meta, p_now, p_now)
54
+ returning id;
55
+ $$;
56
+
57
+ -- Links a Google account to a person.
58
+ CREATE FUNCTION public.be_auth_link_google(p_user uuid, p_provider_id text, p_data jsonb, p_now timestamp with time zone) RETURNS void
59
+ LANGUAGE sql VOLATILE SECURITY DEFINER
60
+ SET search_path TO ''
61
+ AS $$
62
+ insert into auth.identities (provider_id, user_id, identity_data, provider, last_sign_in_at, created_at, updated_at)
63
+ values (p_provider_id, p_user, p_data, 'google', p_now, p_now, p_now)
64
+ on conflict (provider_id, provider) do update set last_sign_in_at = p_now, updated_at = p_now;
65
+ $$;
66
+
67
+ -- Records a sign-in; a verified Google address also confirms the mailbox; profile keys only fill what the person has not set.
68
+ CREATE FUNCTION public.be_auth_touch_user(p_user uuid, p_now timestamp with time zone, p_confirm boolean, p_meta jsonb) RETURNS void
69
+ LANGUAGE sql VOLATILE SECURITY DEFINER
70
+ SET search_path TO ''
71
+ AS $$
72
+ update auth.users
73
+ set last_sign_in_at = p_now,
74
+ updated_at = p_now,
75
+ email_confirmed_at = case when p_confirm then coalesce(email_confirmed_at, p_now) else email_confirmed_at end,
76
+ raw_user_meta_data = p_meta || coalesce(raw_user_meta_data, '{}'::jsonb)
77
+ where id = p_user;
78
+ $$;
79
+
80
+ -- Stamps the confirmation of a mailbox (a spent confirmation link); a mailbox that is already confirmed keeps its date and the sign-in record is untouched.
81
+ CREATE FUNCTION public.be_auth_confirm_user(p_user uuid, p_now timestamp with time zone) RETURNS void
82
+ LANGUAGE sql VOLATILE SECURITY DEFINER
83
+ SET search_path TO ''
84
+ AS $$
85
+ update auth.users set email_confirmed_at = coalesce(email_confirmed_at, p_now), updated_at = p_now where id = p_user;
86
+ $$;
87
+
88
+ -- Sets a new password hash; when p_confirm is true (a spent reset link proved the mailbox) the address is confirmed too.
89
+ CREATE FUNCTION public.be_auth_set_password(p_user uuid, p_hash text, p_confirm boolean, p_now timestamp with time zone) RETURNS void
90
+ LANGUAGE sql VOLATILE SECURITY DEFINER
91
+ SET search_path TO ''
92
+ AS $$
93
+ update auth.users
94
+ set encrypted_password = p_hash,
95
+ email_confirmed_at = case when p_confirm then coalesce(email_confirmed_at, p_now) else email_confirmed_at end,
96
+ updated_at = p_now
97
+ where id = p_user;
98
+ $$;
99
+
100
+ REVOKE EXECUTE ON FUNCTION public.be_auth_user_by_email(text) FROM PUBLIC, anon, authenticated;
101
+ GRANT EXECUTE ON FUNCTION public.be_auth_user_by_email(text) TO app_be;
102
+ REVOKE EXECUTE ON FUNCTION public.be_auth_user_by_id(uuid) FROM PUBLIC, anon, authenticated;
103
+ GRANT EXECUTE ON FUNCTION public.be_auth_user_by_id(uuid) TO app_be;
104
+ REVOKE EXECUTE ON FUNCTION public.be_auth_user_by_google(text) FROM PUBLIC, anon, authenticated;
105
+ GRANT EXECUTE ON FUNCTION public.be_auth_user_by_google(text) TO app_be;
106
+ REVOKE EXECUTE ON FUNCTION public.be_auth_create_user(uuid, text, text, boolean, jsonb, timestamp with time zone) FROM PUBLIC, anon, authenticated;
107
+ GRANT EXECUTE ON FUNCTION public.be_auth_create_user(uuid, text, text, boolean, jsonb, timestamp with time zone) TO app_be;
108
+ REVOKE EXECUTE ON FUNCTION public.be_auth_link_google(uuid, text, jsonb, timestamp with time zone) FROM PUBLIC, anon, authenticated;
109
+ GRANT EXECUTE ON FUNCTION public.be_auth_link_google(uuid, text, jsonb, timestamp with time zone) TO app_be;
110
+ REVOKE EXECUTE ON FUNCTION public.be_auth_touch_user(uuid, timestamp with time zone, boolean, jsonb) FROM PUBLIC, anon, authenticated;
111
+ GRANT EXECUTE ON FUNCTION public.be_auth_touch_user(uuid, timestamp with time zone, boolean, jsonb) TO app_be;
112
+ REVOKE EXECUTE ON FUNCTION public.be_auth_confirm_user(uuid, timestamp with time zone) FROM PUBLIC, anon, authenticated;
113
+ GRANT EXECUTE ON FUNCTION public.be_auth_confirm_user(uuid, timestamp with time zone) TO app_be;
114
+ REVOKE EXECUTE ON FUNCTION public.be_auth_set_password(uuid, text, boolean, timestamp with time zone) FROM PUBLIC, anon, authenticated;
115
+ GRANT EXECUTE ON FUNCTION public.be_auth_set_password(uuid, text, boolean, timestamp with time zone) TO app_be;
@@ -92,7 +92,7 @@ jobs:
92
92
  run: npm run typecheck
93
93
  env:
94
94
  NODE_OPTIONS: {{nodeOptions}}
95
-
95
+ {{moduleTestStep}}
96
96
  lint:
97
97
  needs: plan
98
98
  if: ${{ needs.plan.outputs.checks == 'true' }}
@@ -92,7 +92,7 @@ jobs:
92
92
  run: npm run typecheck
93
93
  env:
94
94
  NODE_OPTIONS: {{nodeOptions}}
95
-
95
+ {{moduleTestStep}}
96
96
  lint:
97
97
  needs: plan
98
98
  if: ${{ needs.plan.outputs.checks == 'true' }}
@@ -1,4 +1,4 @@
1
- "build:be": "cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
1
+ "build:be": "{{modulesFirst}}cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
2
2
  "build:fe": "{{buildFe}}",
3
3
  "codegen": "node scripts/codegen.mjs",
4
4
  "codegen:graphql": "node scripts/codegen.mjs --graphql",
@@ -4,7 +4,7 @@
4
4
  {{appScripts}}
5
5
  {{> app/package-scripts/core.json}}
6
6
  "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
7
- "test": "cd be && jest --selectProjects unit --coverage{{moduleTests}}",
7
+ "test": "{{modulesFirst}}cd be && jest --selectProjects unit --coverage",
8
8
  "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
9
9
  "test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
10
10
  "test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
@@ -4,7 +4,7 @@
4
4
  {{appScripts}}
5
5
  {{> app/package-scripts/core.json}}
6
6
  "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
7
- "test": "cd be && jest --selectProjects unit --coverage{{moduleTests}}",
7
+ "test": "{{modulesFirst}}cd be && jest --selectProjects unit --coverage",
8
8
  "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
9
9
  {{> app/package-scripts/db.json}}
10
10
  {{backingScripts}}
@@ -58,8 +58,11 @@ environments:
58
58
  db: 54322
59
59
  api: 3001
60
60
  web: 3000
61
+ mail: 1025
62
+ mailui: 8025
61
63
  services:
62
64
  - {name: db, wait: healthy}
65
+ - {name: mail, wait: healthy}
63
66
  database:
64
67
  service: db
65
68
  port: db
@@ -97,6 +100,11 @@ environments:
97
100
  AUTH_WEB_ORIGIN: "http://localhost:${port.web}"
98
101
  AUTH_JWT_AUDIENCE: {{project}}
99
102
  AUTH_JWT_KEY_ID: dev-1
103
+ MAIL_HOST: 127.0.0.1
104
+ MAIL_PORT: "${port.mail}"
105
+ MAIL_SECURITY: none
106
+ MAIL_FROM: no-reply@{{project}}.example.invalid
107
+ MAIL_FROM_NAME: {{project}}
100
108
  STORAGE_ENDPOINT: "http://127.0.0.1:9000"
101
109
  STORAGE_REGION: us-east-1
102
110
  STORAGE_BUCKET: {{project}}-dev
@@ -120,6 +128,7 @@ environments:
120
128
  origins:
121
129
  - {name: web, url: "http://localhost:${port.web}"}
122
130
  - {name: api, url: "http://localhost:${port.api}"}
131
+ - {name: mail, url: "http://localhost:${port.mailui}"}
123
132
  production:
124
133
  status: supported
125
134
  runtime: docker-swarm
@@ -2,7 +2,7 @@
2
2
 
3
3
  The dev environment is one command: `starci app stack up --env dev --execute`. It checks the machine, opens the dev custody, runs the setup
4
4
  commands, starts the dependencies in Docker Linux containers (the `db` service: the official postgres image plus pgvector, built from
5
- `.starcistacks/images/db/Dockerfile`), gives the environment its own database role, migrates, runs the seed, starts the api and web
5
+ `.starcistacks/images/db/Dockerfile`; the `mail` service: Mailpit, the dev mail sink), gives the environment its own database role, migrates, runs the seed, starts the api and web
6
6
  processes natively and waits for readiness. What it runs is the `local` block of `environments.dev` in `.starcistacks/application-stacks.yaml`
7
7
  (PROJECT-STACKS-6 in contexts/project/stacks.md); this runbook never carries a docker command, an agent never runs docker by hand.
8
8
 
@@ -26,5 +26,7 @@ another environment.
26
26
  | down | `starci app stack down --env dev --execute`: ends the processes and stops the compose project; volumes and sealed values stay. |
27
27
  | verification | `starci app stack verify --env dev`: the declared liveness and readiness probes after a real start, then `npm run lint` from the app root. |
28
28
 
29
+ **Mail.** The confirmation and reset mails of the sign-in go to the `mail` service on its SMTP port (`MAIL_HOST`, `MAIL_PORT`, `MAIL_SECURITY: none`: a plain channel that only the loopback sink accepts) and nothing leaves the machine. Open the web UI at the `mail` origin `up` prints (`http://localhost:8025` by default), sign up in the web app and follow the link from the message there. A deployed environment names a real SMTP server and `MAIL_SECURITY: starttls` or `tls`.
30
+
29
31
  The database volume `db-data` is rebuildable development data: `starci app db migrate` recreates its schema from `db/bootstrap.sql` and
30
32
  `db/migrations`. Key names of the environment are in `.starcistacks/dev/runtime/env/KEYS.md`.