@podosoft/podokit 1.1.6 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
package/dist/add.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type TemplateVars } from "@podosoft/podokit-template-engine";
2
- import { type ProviderCapability } from "@podosoft/podokit-runtime";
2
+ import { type ProviderCapability, type ProviderSelections } from "@podosoft/podokit-runtime";
3
3
  interface Injection {
4
4
  file: string;
5
5
  marker: string;
@@ -127,6 +127,8 @@ export declare function resolveModuleDir(name: string, modulesDir: string, proje
127
127
  /** Resolve a module and capture the package identity needed to replay external
128
128
  * modules during `podo update`. */
129
129
  export declare function resolveModule(name: string, modulesDir: string, projectRoot: string): ResolvedModule | null;
130
+ /** Name of the module that implements the selected provider for a capability. */
131
+ export declare function selectedProviderModule(modulesDir: string, projectRoot: string, providers: ProviderSelections, capability: ProviderCapability): string;
130
132
  /** List modules available to a project: the bundled ones under `modulesDir` plus,
131
133
  * when `projectRoot` is given, any `@podosoft/podokit-module-*` packages installed
132
134
  * in it. Bundled names take precedence on a clash. */
package/dist/add.js CHANGED
@@ -4,6 +4,7 @@ exports.SUPPORTED_MANIFEST_VERSION = void 0;
4
4
  exports.modulePackageOverlays = modulePackageOverlays;
5
5
  exports.resolveModuleDir = resolveModuleDir;
6
6
  exports.resolveModule = resolveModule;
7
+ exports.selectedProviderModule = selectedProviderModule;
7
8
  exports.listModules = listModules;
8
9
  exports.readModuleManifest = readModuleManifest;
9
10
  exports.addModule = addModule;
@@ -106,11 +107,9 @@ function assertManifestVersion(name, manifest) {
106
107
  throw new Error(`Module "${name}" declares an invalid maxApiReplicas constraint.`);
107
108
  }
108
109
  }
109
- function providerModuleName(projectRoot, modulesDir, capability) {
110
- const project = (0, lockfile_1.readManifest)(projectRoot);
111
- if (!project)
112
- throw new Error("Provider capabilities require a PodoKit project manifest.");
113
- const selected = project.providers[capability];
110
+ /** Name of the module that implements the selected provider for a capability. */
111
+ function selectedProviderModule(modulesDir, projectRoot, providers, capability) {
112
+ const selected = providers[capability];
114
113
  for (const available of listModules(modulesDir, projectRoot)) {
115
114
  const resolved = resolveModule(available.name, modulesDir, projectRoot);
116
115
  if (!resolved)
@@ -122,6 +121,12 @@ function providerModuleName(projectRoot, modulesDir, capability) {
122
121
  throw new Error(`No module provides the selected ${capability} provider "${selected}". ` +
123
122
  `Choose another provider with podo provider set ${capability} <provider> --apply.`);
124
123
  }
124
+ function providerModuleName(projectRoot, modulesDir, capability) {
125
+ const project = (0, lockfile_1.readManifest)(projectRoot);
126
+ if (!project)
127
+ throw new Error("Provider capabilities require a PodoKit project manifest.");
128
+ return selectedProviderModule(modulesDir, projectRoot, project.providers, capability);
129
+ }
125
130
  function requiredModules(projectRoot, modulesDir, manifest) {
126
131
  const capabilities = (manifest.requiresCapabilities ?? []).map((capability) => providerModuleName(projectRoot, modulesDir, capability));
127
132
  return [...new Set([...(manifest.requires ?? []), ...capabilities])];
@@ -217,14 +222,24 @@ function projectName(projectRoot) {
217
222
  const pkg = readJson((0, node_path_1.join)(projectRoot, "package.json"));
218
223
  return typeof pkg.name === "string" ? pkg.name : "app";
219
224
  }
220
- function appendEnv(projectRoot, lines) {
225
+ function appendEnv(projectRoot, lines, anchors = []) {
221
226
  const file = (0, node_path_1.join)(projectRoot, ".env.example");
222
227
  if (!(0, node_fs_1.existsSync)(file))
223
228
  return;
224
229
  const current = (0, node_fs_1.readFileSync)(file, "utf8");
225
- const missing = lines.filter((line) => !current.split("\n").includes(line));
230
+ const existing = current.split("\n");
231
+ const missing = lines.filter((line) => !existing.includes(line));
226
232
  if (missing.length === 0)
227
233
  return;
234
+ // Each module appends its own blank-line separated block. Keep that shape
235
+ // when the block belongs in front of a displaced module's block.
236
+ const wanted = new Set(anchors);
237
+ const index = existing.findIndex((line, position) => position > 0 && existing[position - 1] === "" && wanted.has(line));
238
+ if (index !== -1) {
239
+ existing.splice(index, 0, ...missing, "");
240
+ (0, node_fs_1.writeFileSync)(file, existing.join("\n"));
241
+ return;
242
+ }
228
243
  const separator = current.endsWith("\n") ? "" : "\n";
229
244
  (0, node_fs_1.writeFileSync)(file, `${current}${separator}\n${missing.join("\n")}\n`);
230
245
  }
@@ -246,6 +261,91 @@ function isApplied(projectRoot, modulesDir, module) {
246
261
  }
247
262
  return false;
248
263
  }
264
+ function injectionKey(file, marker) {
265
+ return `${file}\n${marker}`;
266
+ }
267
+ /** Modules `applyModule` would newly apply, in application order. */
268
+ function plannedModules(projectRoot, module, modulesDir, planned = new Set(), order = []) {
269
+ planned.add(module);
270
+ const resolved = resolveModule(module, modulesDir, projectRoot);
271
+ if (!resolved)
272
+ return order;
273
+ for (const required of requiredModules(projectRoot, modulesDir, readModuleManifest(resolved.dir))) {
274
+ if (planned.has(required) || isApplied(projectRoot, modulesDir, required))
275
+ continue;
276
+ plannedModules(projectRoot, required, modulesDir, planned, order);
277
+ }
278
+ order.push(module);
279
+ return order;
280
+ }
281
+ function modulePlacement(projectRoot, module, modulesDir) {
282
+ const project = (0, lockfile_1.readManifest)(projectRoot);
283
+ if (!project)
284
+ return undefined;
285
+ const installed = [...project.modules].sort((a, b) => a.order - b.order);
286
+ const installedNames = new Set(installed.map((entry) => entry.name));
287
+ const fresh = new Set(plannedModules(projectRoot, module, modulesDir).filter((name) => !installedNames.has(name)));
288
+ if (!fresh.size)
289
+ return undefined;
290
+ const provided = new Set();
291
+ for (const name of fresh) {
292
+ const resolved = resolveModule(name, modulesDir, projectRoot);
293
+ if (!resolved)
294
+ continue;
295
+ for (const capability of Object.keys(readModuleManifest(resolved.dir).provides ?? {})) {
296
+ provided.add(capability);
297
+ }
298
+ }
299
+ const vars = {
300
+ projectName: projectName(projectRoot),
301
+ ...(0, lockfile_1.manifestTemplateVars)(project),
302
+ };
303
+ const manifests = installed.map((entry) => {
304
+ const resolved = resolveModule(entry.packageName ?? entry.name, modulesDir, projectRoot);
305
+ return resolved ? readModuleManifest(resolved.dir, vars) : undefined;
306
+ });
307
+ const first = manifests.findIndex((manifest) => manifest !== undefined && ((manifest.requires ?? []).some((name) => fresh.has(name))
308
+ || (manifest.requiresCapabilities ?? []).some((capability) => provided.has(capability))));
309
+ if (first === -1)
310
+ return undefined;
311
+ const injections = new Map();
312
+ const env = [];
313
+ for (const manifest of manifests.slice(first)) {
314
+ if (!manifest)
315
+ continue;
316
+ for (const injection of manifest.inject ?? []) {
317
+ const key = injectionKey(injection.file, injection.marker);
318
+ injections.set(key, [...(injections.get(key) ?? []), injection.text]);
319
+ }
320
+ env.push(...(manifest.env ?? []));
321
+ }
322
+ return { before: installed[first].name, injections, env };
323
+ }
324
+ /** Insert wiring at its marker, or in front of the first displaced module's wiring. */
325
+ function injectAt(content, injection, anchors = []) {
326
+ if (!anchors.length || content.includes(injection.text)) {
327
+ return (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text);
328
+ }
329
+ const lines = content.split("\n");
330
+ const end = lines.findIndex((line) => line.includes(injection.marker));
331
+ if (end === -1)
332
+ throw new Error(`Marker not found: ${injection.marker}`);
333
+ const beginMarker = injection.marker.replace("podokit:end:", "podokit:begin:");
334
+ const begin = beginMarker === injection.marker
335
+ ? -1
336
+ : lines.slice(0, end).findIndex((line) => line.includes(beginMarker));
337
+ const wanted = new Set(anchors.map((text) => text.split("\n")[0].trim()));
338
+ let index = end;
339
+ for (let line = begin + 1; line < end; line += 1) {
340
+ if (wanted.has(lines[line].trim())) {
341
+ index = line;
342
+ break;
343
+ }
344
+ }
345
+ const indent = lines[end].match(/^\s*/)?.[0] ?? "";
346
+ lines.splice(index, 0, `${indent}${injection.text}`);
347
+ return lines.join("\n");
348
+ }
249
349
  /**
250
350
  * Apply a module to an existing generated project: overlay files, merge the
251
351
  * target app's package.json dependencies and scripts, append env example lines,
@@ -262,7 +362,8 @@ function addModule(options) {
262
362
  })
263
363
  .map(([path]) => path)
264
364
  : [];
265
- const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false);
365
+ const placement = modulePlacement(options.projectRoot, options.module, options.modulesDir);
366
+ const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false, placement);
266
367
  if (options.adopt && result.adopted.length) {
267
368
  releaseOwnedPaths(options.projectRoot, result.adopted);
268
369
  }
@@ -279,10 +380,10 @@ function addModule(options) {
279
380
  modulePaths: [...new Set(result.touched)],
280
381
  adoptedPaths: result.adopted,
281
382
  }
282
- : undefined, result.managedOverrides);
383
+ : undefined, result.managedOverrides, placement?.before);
283
384
  return result;
284
385
  }
285
- function applyModule(projectRoot, module, modulesDir, applied, adopt) {
386
+ function applyModule(projectRoot, module, modulesDir, applied, adopt, placement) {
286
387
  const resolved = resolveModule(module, modulesDir, projectRoot);
287
388
  if (!resolved) {
288
389
  const available = listModules(modulesDir, projectRoot).map((m) => m.name);
@@ -318,8 +419,8 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
318
419
  for (const required of requiredModules(projectRoot, modulesDir, manifest)) {
319
420
  if (applied.has(required) || isApplied(projectRoot, modulesDir, required))
320
421
  continue;
321
- const result = applyModule(projectRoot, required, modulesDir, applied, adopt);
322
- added.push(required, ...result.added);
422
+ const result = applyModule(projectRoot, required, modulesDir, applied, adopt, placement);
423
+ added.push(...result.added, required);
323
424
  ownedGlobs.push(...result.ownedGlobs);
324
425
  managedOverrides.push(...result.managedOverrides);
325
426
  preserved.push(...result.preserved);
@@ -356,7 +457,7 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
356
457
  }
357
458
  // 3) append env example lines
358
459
  if (manifest.env?.length) {
359
- appendEnv(projectRoot, manifest.env);
460
+ appendEnv(projectRoot, manifest.env, placement?.env);
360
461
  touched.push(".env.example");
361
462
  }
362
463
  // 4) inject wiring at markers
@@ -370,7 +471,8 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
370
471
  const content = (0, node_fs_1.readFileSync)(target, "utf8");
371
472
  if (injection.optional && !content.includes(injection.marker))
372
473
  continue;
373
- (0, node_fs_1.writeFileSync)(target, (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text));
474
+ const anchors = placement?.injections.get(injectionKey(injection.file, injection.marker));
475
+ (0, node_fs_1.writeFileSync)(target, injectAt(content, injection, anchors));
374
476
  touched.push(injection.file);
375
477
  }
376
478
  const instructions = (manifest.instructions ?? []).map((line) => line.replace(/<app>/g, appName));
@@ -385,6 +385,8 @@ metadata:
385
385
  ${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(value)}`).join("\n")}` : "data: {}"}
386
386
  `;
387
387
  }
388
+ // Above the API's 800 ms per-check readiness bound; the Kubernetes default is 1 s.
389
+ const API_PROBE_TIMEOUT_SECONDS = 2;
388
390
  function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
389
391
  const name = `${profile.target.release}-${kind}`;
390
392
  const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
@@ -396,14 +398,17 @@ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeC
396
398
  ? ` readinessProbe:
397
399
  httpGet: { path: /health/ready, port: 3000 }
398
400
  periodSeconds: 5
401
+ timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
399
402
  livenessProbe:
400
403
  httpGet: { path: /health, port: 3000 }
401
404
  initialDelaySeconds: 20
402
405
  periodSeconds: 10
406
+ timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
403
407
  startupProbe:
404
408
  httpGet: { path: /health, port: 3000 }
405
409
  failureThreshold: 30
406
410
  periodSeconds: 2
411
+ timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
407
412
  `
408
413
  : ` readinessProbe:
409
414
  httpGet: { path: /, port: 3000 }
package/dist/index.js CHANGED
@@ -652,6 +652,9 @@ async function main(argv) {
652
652
  try {
653
653
  if (args.apply) {
654
654
  const result = (0, update_1.applyUpdate)(process.cwd(), templatesDir, { oldTemplatesDir: args.from });
655
+ if (result.addedModules.length) {
656
+ process.stdout.write(`Added required module(s): ${result.addedModules.join(", ")}\n`);
657
+ }
655
658
  process.stdout.write(`Applied: ${result.written.length} written, ${result.moved.length} moved, ` +
656
659
  `${result.removed.length} removed, ` +
657
660
  `${result.merged.length} merged, ${result.conflicts.length} conflict.\n`);
@@ -666,6 +669,11 @@ async function main(argv) {
666
669
  const counts = (0, update_1.summarize)(plan);
667
670
  const shown = plan.changes.filter((c) => c.action !== "up-to-date" && c.action !== "skip");
668
671
  process.stdout.write(`podo update ${plan.fromVersion} -> ${plan.toVersion} (template: ${plan.template}; modules: ${plan.modules.join(", ") || "none"})\n\n`);
672
+ if (plan.addedModules.length) {
673
+ process.stdout.write("Modules to add:\n" +
674
+ plan.addedModules.map((module) => ` ${module.name} (${(0, update_1.addedModuleReason)(module)})`).join("\n") +
675
+ "\n\n");
676
+ }
669
677
  if (!shown.length) {
670
678
  process.stdout.write("Everything is up to date.\n");
671
679
  }
package/dist/lib.d.ts CHANGED
@@ -8,8 +8,8 @@ export { listModules, addModule } from "./add";
8
8
  export type { ModuleManifest, ModuleMigration, ModulePathMove, ModuleTextReplacement, AddOptions, AddResult, } from "./add";
9
9
  export { status, diff, doctor, SUPPORTED_FRAMEWORKS } from "./inspect";
10
10
  export type { StatusReport, DoctorFinding } from "./inspect";
11
- export { planUpdate, summarize } from "./update";
12
- export type { UpdatePlan, FileChange } from "./update";
11
+ export { addedModuleReason, planUpdate, summarize } from "./update";
12
+ export type { AddedModule, UpdatePlan, FileChange } from "./update";
13
13
  export { readDevConfig, renderRoute, renderRuntimeCompose, resolveDevRuntime, runDevCommand } from "./dev";
14
14
  export type { CommandRunner, DevConfig, DevRuntime } from "./dev";
15
15
  export { applyDeployment, doctorDeployment, getDeploymentStatus, inspectClusterFingerprint, planDeployment, planRollback, rollbackDeployment, verifyDeployment, } from "./deploy";
package/dist/lib.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getComposeStatus = exports.doctorComposeDeployment = exports.applyComposeDeployment = exports.renderComposeDeployment = exports.parseComposeProfile = exports.initializeComposeProfile = exports.readDeploymentDriver = exports.loadKubernetesProfile = exports.loadComposeProfile = exports.loadAnyDeploymentProfile = exports.listAnyDeploymentProfiles = exports.assertAnyReleaseTag = exports.DEPLOY_DRIVERS = exports.renderDeployment = exports.loadDeploymentProfile = exports.listDeploymentProfiles = exports.initializeDeploymentProfile = exports.deploymentProfileDigest = exports.assertReleaseTag = exports.verifyDeployment = exports.rollbackDeployment = exports.planRollback = exports.planDeployment = exports.inspectClusterFingerprint = exports.getDeploymentStatus = exports.doctorDeployment = exports.applyDeployment = exports.runDevCommand = exports.resolveDevRuntime = exports.renderRuntimeCompose = exports.renderRoute = exports.readDevConfig = exports.summarize = exports.planUpdate = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.PROVIDERS_SOURCE = exports.providerSource = exports.planProviderChange = exports.listProviders = exports.applyProviderChange = exports.create = void 0;
4
- exports.verifyAnyDeployment = exports.planAnyDeployment = exports.getAnyDeploymentStatus = exports.doctorAnyDeployment = exports.describeDeploymentTarget = exports.verifyComposeDeployment = exports.rollbackComposeDeployment = exports.planComposeRollback = exports.planComposeDeployment = exports.inspectComposeEndpointFingerprint = void 0;
3
+ exports.doctorComposeDeployment = exports.applyComposeDeployment = exports.renderComposeDeployment = exports.parseComposeProfile = exports.initializeComposeProfile = exports.readDeploymentDriver = exports.loadKubernetesProfile = exports.loadComposeProfile = exports.loadAnyDeploymentProfile = exports.listAnyDeploymentProfiles = exports.assertAnyReleaseTag = exports.DEPLOY_DRIVERS = exports.renderDeployment = exports.loadDeploymentProfile = exports.listDeploymentProfiles = exports.initializeDeploymentProfile = exports.deploymentProfileDigest = exports.assertReleaseTag = exports.verifyDeployment = exports.rollbackDeployment = exports.planRollback = exports.planDeployment = exports.inspectClusterFingerprint = exports.getDeploymentStatus = exports.doctorDeployment = exports.applyDeployment = exports.runDevCommand = exports.resolveDevRuntime = exports.renderRuntimeCompose = exports.renderRoute = exports.readDevConfig = exports.summarize = exports.planUpdate = exports.addedModuleReason = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.PROVIDERS_SOURCE = exports.providerSource = exports.planProviderChange = exports.listProviders = exports.applyProviderChange = exports.create = void 0;
4
+ exports.verifyAnyDeployment = exports.planAnyDeployment = exports.getAnyDeploymentStatus = exports.doctorAnyDeployment = exports.describeDeploymentTarget = exports.verifyComposeDeployment = exports.rollbackComposeDeployment = exports.planComposeRollback = exports.planComposeDeployment = exports.inspectComposeEndpointFingerprint = exports.getComposeStatus = void 0;
5
5
  exports.builtinTemplatesDir = builtinTemplatesDir;
6
6
  exports.builtinModulesDir = builtinModulesDir;
7
7
  // Library entry point: the reusable functions behind the `podo` CLI, for other
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "diff", { enumerable: true, get: function () { re
29
29
  Object.defineProperty(exports, "doctor", { enumerable: true, get: function () { return inspect_1.doctor; } });
30
30
  Object.defineProperty(exports, "SUPPORTED_FRAMEWORKS", { enumerable: true, get: function () { return inspect_1.SUPPORTED_FRAMEWORKS; } });
31
31
  var update_1 = require("./update");
32
+ Object.defineProperty(exports, "addedModuleReason", { enumerable: true, get: function () { return update_1.addedModuleReason; } });
32
33
  Object.defineProperty(exports, "planUpdate", { enumerable: true, get: function () { return update_1.planUpdate; } });
33
34
  Object.defineProperty(exports, "summarize", { enumerable: true, get: function () { return update_1.summarize; } });
34
35
  var dev_1 = require("./dev");
@@ -116,4 +116,6 @@ export declare function mergeOwnedGlobs(existing: string[], add: string[]): stri
116
116
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
117
117
  * survive every future recompute. No-op on projects without a manifest.
118
118
  */
119
- export declare function recordModules(projectRoot: string, moduleInputs: (string | ManifestModuleInput)[], version?: string, ownedGlobs?: string[], lockSnapshot?: RecordModulesLockSnapshot, managedOverrides?: string[]): void;
119
+ export declare function recordModules(projectRoot: string, moduleInputs: (string | ManifestModuleInput)[], version?: string, ownedGlobs?: string[], lockSnapshot?: RecordModulesLockSnapshot, managedOverrides?: string[],
120
+ /** Installed module that newly recorded modules must configure before. */
121
+ insertBefore?: string): void;
package/dist/lockfile.js CHANGED
@@ -257,29 +257,40 @@ function mergeOwnedGlobs(existing, add) {
257
257
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
258
258
  * survive every future recompute. No-op on projects without a manifest.
259
259
  */
260
- function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot, managedOverrides) {
260
+ function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot, managedOverrides,
261
+ /** Installed module that newly recorded modules must configure before. */
262
+ insertBefore) {
261
263
  const manifest = readManifest(projectRoot);
262
264
  if (!manifest)
263
265
  return;
264
266
  const stampedWith = version ?? podokitVersion();
265
- const known = new Set(manifest.modules.map((m) => m.name));
267
+ const fresh = [];
266
268
  for (const input of moduleInputs) {
267
269
  const module = typeof input === "string" ? { name: input } : input;
268
- const existing = manifest.modules.find((entry) => entry.name === module.name);
270
+ const existing = manifest.modules.find((entry) => entry.name === module.name)
271
+ ?? fresh.find((entry) => entry.name === module.name);
269
272
  if (existing) {
270
273
  existing.packageName = module.packageName ?? existing.packageName;
271
274
  existing.moduleVersion = module.moduleVersion ?? existing.moduleVersion;
272
275
  continue;
273
276
  }
274
- manifest.modules.push({
277
+ fresh.push({
275
278
  name: module.name,
276
- order: manifest.modules.length,
279
+ order: manifest.modules.length + fresh.length,
277
280
  addedWith: stampedWith,
278
281
  packageName: module.packageName,
279
282
  moduleVersion: module.moduleVersion,
280
283
  appliedMigrations: module.appliedMigrations,
281
284
  });
282
- known.add(module.name);
285
+ }
286
+ const ordered = [...manifest.modules].sort((a, b) => a.order - b.order);
287
+ const position = insertBefore ? ordered.findIndex((entry) => entry.name === insertBefore) : -1;
288
+ if (position === -1) {
289
+ manifest.modules.push(...fresh);
290
+ }
291
+ else {
292
+ ordered.splice(position, 0, ...fresh);
293
+ manifest.modules = ordered.map((entry, order) => ({ ...entry, order }));
283
294
  }
284
295
  if (ownedGlobs?.length)
285
296
  manifest.ownedGlobs = mergeOwnedGlobs(manifest.ownedGlobs, ownedGlobs);
@@ -16,8 +16,8 @@
16
16
  "migration:generate": "bun run typeorm -- migration:generate"
17
17
  },
18
18
  "dependencies": {
19
- "@podosoft/podokit-contracts": "^0.4.0",
20
- "@podosoft/podokit-runtime": "^0.2.0",
19
+ "@podosoft/podokit-contracts": "^0.5.0",
20
+ "@podosoft/podokit-runtime": "^0.3.0",
21
21
  "@elysia/cors": "^1.4.2",
22
22
  "@elysia/openapi": "^1.4.15",
23
23
  "dotenv": "^16.4.5",
@@ -5,6 +5,7 @@ const targets = [
5
5
  { entrypoint: "src/database/migrate.ts", outdir: "dist/database" },
6
6
  { entrypoint: "src/migrate.ts", outdir: "dist" },
7
7
  { entrypoint: "src/main-worker.ts", outdir: "dist" },
8
+ { entrypoint: "src/bootstrap-admin-runtime.ts", outdir: "dist" },
8
9
  ].filter((target) => existsSync(target.entrypoint));
9
10
 
10
11
  for (const target of targets) {
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, mock, type Mock } from "bun:test";
2
+ import { cachedOpenApiDocument, type OpenApiDocumentLoader } from "./app";
3
+ import type { OpenApiContribution } from "./core/services";
4
+
5
+ function baseSpec(): Response {
6
+ return Response.json({ openapi: "3.1.0", paths: { "/health": { get: {} } } });
7
+ }
8
+
9
+ function source(options: {
10
+ contributions: () => OpenApiContribution[];
11
+ routeCount?: () => number;
12
+ base?: () => Response;
13
+ }): { base: Mock<() => Promise<Response>>; load: OpenApiDocumentLoader } {
14
+ const base = mock(async () => (options.base ?? baseSpec)());
15
+ const load = cachedOpenApiDocument({
16
+ routeCount: options.routeCount ?? (() => 1),
17
+ base,
18
+ contributions: async () => options.contributions(),
19
+ });
20
+ return { base, load };
21
+ }
22
+
23
+ describe("cachedOpenApiDocument", () => {
24
+ it("reuses the merged document while the inputs are unchanged", async () => {
25
+ const auth = { document: { paths: { "/sign-in": { post: {} } } }, pathPrefix: "/api/auth" };
26
+ const { base, load } = source({ contributions: () => [{ ...auth }] });
27
+
28
+ const first = await load();
29
+ const second = await load();
30
+
31
+ expect(second).toBe(first);
32
+ expect(first).toMatchObject({
33
+ paths: { "/health": { get: {} }, "/api/auth/sign-in": { post: {} } },
34
+ });
35
+ expect(base).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it("rebuilds when a contributor returns a new document", async () => {
39
+ let auth: OpenApiContribution = {
40
+ document: { paths: { "/sign-in": { post: {} } } },
41
+ pathPrefix: "/api/auth",
42
+ };
43
+ const { base, load } = source({ contributions: () => [auth] });
44
+
45
+ await load();
46
+ auth = { document: { paths: { "/passkey": { post: {} } } }, pathPrefix: "/api/auth" };
47
+ const rebuilt = await load();
48
+
49
+ expect(rebuilt).toMatchObject({ paths: { "/api/auth/passkey": { post: {} } } });
50
+ expect(rebuilt).not.toHaveProperty(["paths", "/api/auth/sign-in"]);
51
+ expect(base).toHaveBeenCalledTimes(2);
52
+ });
53
+
54
+ it("rebuilds when routes are added", async () => {
55
+ let routes = 1;
56
+ const { base, load } = source({ contributions: () => [], routeCount: () => routes });
57
+
58
+ await load();
59
+ routes = 2;
60
+ await load();
61
+
62
+ expect(base).toHaveBeenCalledTimes(2);
63
+ });
64
+
65
+ it("returns a failed base response without caching it", async () => {
66
+ let fail = true;
67
+ const { base, load } = source({
68
+ contributions: () => [],
69
+ base: () => fail ? Response.json({ success: false }, { status: 503 }) : baseSpec(),
70
+ });
71
+
72
+ const failed = await load();
73
+ fail = false;
74
+ const recovered = await load();
75
+
76
+ if (!(failed instanceof Response)) throw new Error("Expected the failed base response");
77
+ expect(failed.status).toBe(503);
78
+ expect(recovered).toMatchObject({ openapi: "3.1.0" });
79
+ expect(base).toHaveBeenCalledTimes(2);
80
+ });
81
+ });
@@ -8,9 +8,11 @@ import {
8
8
  appContext,
9
9
  createCoreServices,
10
10
  type AppPlugin,
11
+ type OpenApiContribution,
11
12
  type PodokitModule,
12
13
  type ServiceRegistry,
13
14
  } from "./core/services";
15
+ import { isDatabaseUnavailable } from "./database/database";
14
16
  import { healthPlugin } from "./health/health.plugin";
15
17
  // podokit:begin:imports
16
18
  // podokit:end:imports
@@ -52,6 +54,47 @@ function mergeOpenApi(
52
54
  return { ...base, paths, components, tags };
53
55
  }
54
56
 
57
+ export interface OpenApiDocumentSource {
58
+ routeCount: () => number;
59
+ base: () => Promise<Response>;
60
+ contributions: () => Promise<OpenApiContribution[]>;
61
+ }
62
+
63
+ export type OpenApiDocumentLoader = () => Promise<Record<string, unknown> | Response>;
64
+
65
+ function sameContributions(left: OpenApiContribution[], right: OpenApiContribution[]): boolean {
66
+ return left.length === right.length && left.every((contribution, index) =>
67
+ contribution.document === right[index]?.document &&
68
+ contribution.pathPrefix === right[index]?.pathPrefix
69
+ );
70
+ }
71
+
72
+ /** Contributors return the same document object while their inputs are unchanged,
73
+ * so identity plus the route count (the base spec's own cache key) identifies the
74
+ * merged result without a staleness window. */
75
+ export function cachedOpenApiDocument(source: OpenApiDocumentSource): OpenApiDocumentLoader {
76
+ let cached: {
77
+ routeCount: number;
78
+ contributions: OpenApiContribution[];
79
+ document: Record<string, unknown>;
80
+ } | undefined;
81
+ return async () => {
82
+ const contributions = await source.contributions();
83
+ const routeCount = source.routeCount();
84
+ if (
85
+ cached?.routeCount === routeCount &&
86
+ sameContributions(cached.contributions, contributions)
87
+ ) {
88
+ return cached.document;
89
+ }
90
+ const response = await source.base();
91
+ if (!response.ok) return response;
92
+ const document = mergeOpenApi(record(await response.json()), contributions);
93
+ cached = { routeCount, contributions, document };
94
+ return document;
95
+ };
96
+ }
97
+
55
98
  function errorEnvelope(
56
99
  code: string,
57
100
  message: string,
@@ -124,6 +167,10 @@ const buildApp = (options: CreateAppOptions = {}) => {
124
167
  set.status = error.statusCode;
125
168
  return errorEnvelope(error.code, error.message, error.statusCode, path);
126
169
  }
170
+ if (isDatabaseUnavailable(error)) {
171
+ set.status = 503;
172
+ return errorEnvelope("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503, path);
173
+ }
127
174
  const statusCode = code === "NOT_FOUND" ? 404 : code === "VALIDATION" ? 400 : 500;
128
175
  set.status = statusCode;
129
176
  const publicCode = code === "NOT_FOUND"
@@ -144,11 +191,12 @@ const buildApp = (options: CreateAppOptions = {}) => {
144
191
  ...modules.flatMap((module) => module.plugin ? [module.plugin] : []),
145
192
  ];
146
193
  for (const plugin of plugins) app.use(plugin(context));
147
- app.get("/api-docs-json", async () => {
148
- const response = await app.handle(new Request("http://localhost/api-docs-elysia-json"));
149
- const base = record(await response.json());
150
- return mergeOpenApi(base, await context.openapi.contributions());
151
- }, {
194
+ const openApiDocument = cachedOpenApiDocument({
195
+ routeCount: () => app.routes.length,
196
+ base: () => app.handle(new Request("http://localhost/api-docs-elysia-json")),
197
+ contributions: () => context.openapi.contributions(),
198
+ });
199
+ app.get("/api-docs-json", () => openApiDocument(), {
152
200
  detail: { hide: true },
153
201
  });
154
202
  app.onStop(() => { void services.close(); });
@@ -11,6 +11,12 @@ const schema = z.object({
11
11
  POSTGRES_USER: z.string().default("podokit"),
12
12
  POSTGRES_PASSWORD: z.string().default("podokit"),
13
13
  POSTGRES_DB: z.string().default("podokit"),
14
+ DB_POOL_MAX: z.coerce.number().int().positive().default(20),
15
+ DB_CONNECT_TIMEOUT_MS: z.coerce.number().int().positive().default(10000),
16
+ DB_IDLE_TIMEOUT_MS: z.coerce.number().int().nonnegative().default(30000),
17
+ DB_AUTH_POOL_MAX: z.coerce.number().int().positive().default(10),
18
+ READINESS_CHECK_TIMEOUT_MS: z.coerce.number().int().positive().default(800),
19
+ READINESS_CACHE_MS: z.coerce.number().int().nonnegative().default(2000),
14
20
  });
15
21
 
16
22
  export type AppEnv = z.infer<typeof schema>;
@@ -35,9 +35,12 @@ const MODULE_ROUTES: Record<string, string[]> = {
35
35
  "POST /account/profile-image",
36
36
  "DELETE /account/profile-image",
37
37
  "GET /profile-images/{fileName}",
38
+ "GET /admin/sessions",
39
+ "DELETE /admin/sessions/{id}",
40
+ "GET /admin/users",
38
41
  ],
39
42
  "api-key-auth": ["GET /machine/ping"],
40
- "audit-log": ["GET /audit-logs"],
43
+ "audit-log": ["GET /audit-logs", "GET /audit-logs/actions"],
41
44
  analytics: [
42
45
  "GET /analytics/config",
43
46
  "GET /admin/analytics/config",
@@ -70,6 +73,12 @@ const MODULE_ROUTES: Record<string, string[]> = {
70
73
  bullmq: ["POST /jobs", "GET /jobs/{id}"],
71
74
  "file-upload": ["POST /files", "GET /files/{key}/url"],
72
75
  "job-progress": ["POST /progress"],
76
+ "object-storage-local": [
77
+ "GET /files/content/*",
78
+ "PUT /storage/{key}",
79
+ "GET /storage/{key}",
80
+ "GET /storage/{key}/presigned",
81
+ ],
73
82
  "object-storage-s3": [
74
83
  "PUT /storage/{key}",
75
84
  "GET /storage/{key}",
@@ -1,11 +1,20 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { validateEnv } from "../config/env.validation";
4
+ import { PROVIDERS } from "../config/providers";
2
5
  import {
3
6
  AccessPolicy,
7
+ createCoreServices,
4
8
  OpenApiRegistry,
9
+ REQUEST_IDENTITY,
5
10
  RequestGuardRegistry,
6
11
  ServiceRegistry,
7
12
  } from "./services";
8
13
 
14
+ function databaseProvider(): DatabaseProviderName {
15
+ return PROVIDERS.database;
16
+ }
17
+
9
18
  describe("ServiceRegistry", () => {
10
19
  test("awaits startup in registration order and starts only once", async () => {
11
20
  const registry = new ServiceRegistry();
@@ -88,3 +97,24 @@ describe("OpenApiRegistry", () => {
88
97
  expect(() => registry.register("auth", () => ({ document: {} }))).toThrow("already exists");
89
98
  });
90
99
  });
100
+
101
+ describe("RequestIdentity", () => {
102
+ test("treats every request as anonymous until an auth module overrides it", async () => {
103
+ const services = createCoreServices(validateEnv({
104
+ NODE_ENV: "test",
105
+ DATABASE_URL: databaseProvider() === "sqlite"
106
+ ? "sqlite://:memory:"
107
+ : "postgres://podokit:podokit@localhost:5432/podokit",
108
+ }));
109
+ const identity = services.resolve(REQUEST_IDENTITY);
110
+ const request = new Request("http://localhost/todos");
111
+ try {
112
+ expect(await identity.userId(request)).toBeUndefined();
113
+ expect(await identity.isAdmin(request)).toBe(false);
114
+ expect(await identity.roles(request)).toEqual([]);
115
+ expect(await identity.activeOrganizationId(request)).toBeUndefined();
116
+ } finally {
117
+ await services.close();
118
+ }
119
+ });
120
+ });