@tulipes/cli 0.1.1 → 0.2.0-rc.1

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 (209) hide show
  1. package/README.md +10 -7
  2. package/dist/agent.d.ts +12 -0
  3. package/dist/agent.js +29 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/app-core.d.ts +8 -6
  6. package/dist/app-core.js +23 -14
  7. package/dist/app-core.js.map +1 -1
  8. package/dist/bin.js +3 -3
  9. package/dist/bin.js.map +1 -1
  10. package/dist/config-check.d.ts +8 -0
  11. package/dist/config-check.js +43 -0
  12. package/dist/config-check.js.map +1 -0
  13. package/dist/config-import.d.ts +20 -0
  14. package/dist/config-import.js +133 -0
  15. package/dist/config-import.js.map +1 -0
  16. package/dist/dev.d.ts +4 -4
  17. package/dist/dev.js +7 -10
  18. package/dist/dev.js.map +1 -1
  19. package/dist/init.d.ts +6 -11
  20. package/dist/init.js +11 -6422
  21. package/dist/init.js.map +1 -1
  22. package/dist/inspection.d.ts +9 -8
  23. package/dist/inspection.js +10 -11
  24. package/dist/inspection.js.map +1 -1
  25. package/dist/main.js +82 -11
  26. package/dist/main.js.map +1 -1
  27. package/dist/migrate.d.ts +30 -0
  28. package/dist/migrate.js +772 -0
  29. package/dist/migrate.js.map +1 -0
  30. package/dist/new-module.js +84 -43
  31. package/dist/new-module.js.map +1 -1
  32. package/dist/routes.d.ts +3 -5
  33. package/dist/routes.js +4 -6
  34. package/dist/routes.js.map +1 -1
  35. package/dist/spec.d.ts +2 -5
  36. package/dist/spec.js +13 -6
  37. package/dist/spec.js.map +1 -1
  38. package/dist/sync.d.ts +3 -11
  39. package/dist/sync.js +81 -20
  40. package/dist/sync.js.map +1 -1
  41. package/dist/template.d.ts +58 -0
  42. package/dist/template.js +244 -0
  43. package/dist/template.js.map +1 -0
  44. package/dist/update.js +4 -4
  45. package/package.json +17 -16
  46. package/templates/AGENTS.md +68 -31
  47. package/templates/CLAUDE.md +5 -4
  48. package/templates/app/.envs/.env.development +18 -0
  49. package/templates/app/.envs/.env.production +19 -0
  50. package/templates/app/.envs/.env.staging +19 -0
  51. package/templates/app/README.md +327 -0
  52. package/templates/app/app.ts +3 -0
  53. package/templates/app/config/app.config.ts +73 -0
  54. package/templates/app/docs/agent-manifest.json +724 -0
  55. package/templates/app/ecosystem.config.cjs +60 -0
  56. package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
  57. package/templates/app/modules/agent/i18n/en.json +5 -0
  58. package/templates/app/modules/agent/i18n/fr.json +5 -0
  59. package/templates/app/modules/agent/module.acl.ts +14 -0
  60. package/templates/app/modules/agent/package.json +21 -0
  61. package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
  62. package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
  63. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
  64. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
  65. package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
  66. package/templates/app/modules/auth/helpers/access-check.ts +42 -0
  67. package/templates/app/modules/auth/helpers/auth.ts +48 -0
  68. package/templates/app/modules/auth/helpers/documents.ts +57 -0
  69. package/templates/app/modules/auth/helpers/passport.ts +180 -0
  70. package/templates/app/modules/auth/helpers/passwords.ts +58 -0
  71. package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
  72. package/templates/app/modules/auth/helpers/tokens.ts +329 -0
  73. package/templates/app/modules/auth/i18n/en.json +19 -0
  74. package/templates/app/modules/auth/i18n/fr.json +19 -0
  75. package/templates/app/modules/auth/lib/index.ts +12 -0
  76. package/templates/app/modules/auth/models/credential.model.ts +31 -0
  77. package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
  78. package/templates/app/modules/auth/models/session.model.ts +42 -0
  79. package/templates/app/modules/auth/module.acl.ts +41 -0
  80. package/templates/app/modules/auth/module.config.ts +30 -0
  81. package/templates/app/modules/auth/package.json +61 -0
  82. package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
  83. package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
  84. package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
  85. package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
  86. package/templates/app/modules/config/helpers/audit.ts +41 -0
  87. package/templates/app/modules/config/helpers/describe.ts +72 -0
  88. package/templates/app/modules/config/i18n/en.json +6 -0
  89. package/templates/app/modules/config/i18n/fr.json +6 -0
  90. package/templates/app/modules/config/models/config-audit.model.ts +26 -0
  91. package/templates/app/modules/config/module.acl.ts +12 -0
  92. package/templates/app/modules/config/package.json +23 -0
  93. package/templates/app/modules/config/routes/config.routes.ts +37 -0
  94. package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
  95. package/templates/app/modules/config/tests/config.test.ts +126 -0
  96. package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
  97. package/templates/app/modules/core/helpers/cache/index.ts +60 -0
  98. package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
  99. package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
  100. package/templates/app/modules/core/helpers/cache/types.ts +37 -0
  101. package/templates/app/modules/core/helpers/lazy.ts +13 -0
  102. package/templates/app/modules/core/helpers/logger/index.ts +115 -0
  103. package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
  104. package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
  105. package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
  106. package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
  107. package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
  108. package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
  109. package/templates/app/modules/core/lib/index.ts +34 -0
  110. package/templates/app/modules/core/module.config.ts +24 -0
  111. package/templates/app/modules/core/package.json +80 -0
  112. package/templates/app/modules/core/routes/core.routes.ts +24 -0
  113. package/templates/app/modules/core/tests/core.test.ts +19 -0
  114. package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
  115. package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
  116. package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
  117. package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
  118. package/templates/app/modules/hello/helpers/greeting.ts +8 -0
  119. package/templates/app/modules/hello/i18n/en.json +8 -0
  120. package/templates/app/modules/hello/i18n/fr.json +8 -0
  121. package/templates/app/modules/hello/models/greeting.model.ts +24 -0
  122. package/templates/app/modules/hello/module.acl.ts +16 -0
  123. package/templates/app/modules/hello/module.config.ts +14 -0
  124. package/templates/app/modules/hello/package.json +24 -0
  125. package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
  126. package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
  127. package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
  128. package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
  129. package/templates/app/modules/hello/tests/hello.test.ts +19 -0
  130. package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
  131. package/templates/app/modules/security/helpers/hardening.ts +65 -0
  132. package/templates/app/modules/security/helpers/proxy.ts +16 -0
  133. package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
  134. package/templates/app/modules/security/module.config.ts +59 -0
  135. package/templates/app/modules/security/package.json +26 -0
  136. package/templates/app/modules/security/routes/security.routes.ts +42 -0
  137. package/templates/app/modules/security/tests/security.test.ts +174 -0
  138. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
  139. package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
  140. package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
  141. package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
  142. package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
  143. package/templates/app/modules/settings/i18n/en.json +3 -0
  144. package/templates/app/modules/settings/i18n/fr.json +3 -0
  145. package/templates/app/modules/settings/module.acl.ts +12 -0
  146. package/templates/app/modules/settings/package.json +21 -0
  147. package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
  148. package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
  149. package/templates/app/modules/settings/tests/settings.test.ts +409 -0
  150. package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
  151. package/templates/app/modules/users/i18n/en.json +14 -0
  152. package/templates/app/modules/users/i18n/fr.json +14 -0
  153. package/templates/app/modules/users/models/user.model.ts +18 -0
  154. package/templates/app/modules/users/module.acl.ts +10 -0
  155. package/templates/app/modules/users/module.config.ts +11 -0
  156. package/templates/app/modules/users/package.json +24 -0
  157. package/templates/app/modules/users/queues/users.queues.ts +31 -0
  158. package/templates/app/modules/users/routes/users.routes.ts +60 -0
  159. package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
  160. package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
  161. package/templates/app/nginx/development.conf +44 -0
  162. package/templates/app/nginx/production.conf +75 -0
  163. package/templates/app/nginx/staging.conf +51 -0
  164. package/templates/app/package.json +58 -0
  165. package/templates/app/scripts/count-greetings.ts +29 -0
  166. package/templates/app/scripts/deploy-nginx.ts +143 -0
  167. package/templates/app/scripts/list-users.ts +34 -0
  168. package/templates/app/tests/app.test.ts +28 -0
  169. package/templates/app/tests/support.ts +36 -0
  170. package/templates/app/tsconfig.json +28 -0
  171. package/templates/app/vitest.config.ts +6 -0
  172. package/templates/app/worker.ts +6 -0
  173. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
  174. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
  175. package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
  176. package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
  177. package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
  178. package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
  179. package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
  180. package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
  181. package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
  182. package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
  183. package/templates/minimal/README.md +21 -0
  184. package/templates/minimal/app.ts +3 -0
  185. package/templates/minimal/config/app.config.ts +16 -0
  186. package/templates/minimal/modules/agent/module.acl.ts +3 -0
  187. package/templates/minimal/modules/agent/package.json +14 -0
  188. package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
  189. package/templates/minimal/modules/core/module.acl.ts +4 -0
  190. package/templates/minimal/modules/core/package.json +21 -0
  191. package/templates/minimal/modules/hello/i18n/en.json +3 -0
  192. package/templates/minimal/modules/hello/module.acl.ts +2 -0
  193. package/templates/minimal/modules/hello/package.json +11 -0
  194. package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
  195. package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
  196. package/templates/minimal/modules/security/package.json +15 -0
  197. package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
  198. package/templates/minimal/package.json +38 -0
  199. package/templates/minimal/tests/app.test.ts +52 -0
  200. package/templates/minimal/tsconfig.json +22 -0
  201. package/templates/minimal/vitest.config.ts +5 -0
  202. package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
  203. package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
  204. package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
  205. package/templates/versions.json +11 -0
  206. package/dist/minimal.d.ts +0 -2
  207. package/dist/minimal.js +0 -2200
  208. package/dist/minimal.js.map +0 -1
  209. /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
@@ -0,0 +1,89 @@
1
+ import type { Request, RequestHandler, Response } from "express";
2
+ import type { Ctx } from "@tulipes/core/boot";
3
+ import { SettingsConflictError, SettingsValidationError } from "@tulipes/core/config";
4
+ import { HttpError, ValidationError } from "@tulipes/core/http";
5
+
6
+ import { auditChange } from "../helpers/audit.ts";
7
+ import { describeModule } from "../helpers/describe.ts";
8
+
9
+ /**
10
+ * The console: what every module is configured with, and the one write —
11
+ * a settings patch, validated by the module's schema, refused on a stale
12
+ * version, audited before it lands, applied in this process at once and
13
+ * in every other replica through the store's watch. Nothing here edits
14
+ * the environment: a variable is what boot needs before the store answers
15
+ * or a secret, and neither is edited at runtime.
16
+ */
17
+ export class ConfigController {
18
+ readonly #ctx: Ctx;
19
+
20
+ constructor(ctx: Ctx) {
21
+ this.#ctx = ctx;
22
+ }
23
+
24
+ /** Every module, in load order. */
25
+ app(): RequestHandler {
26
+ return (_req: Request, res: Response) => {
27
+ res.respond({
28
+ data: {
29
+ app: this.#ctx.config.app ?? {},
30
+ store: this.#ctx.settings.status(),
31
+ modules: this.#ctx.modules.map((module) => describeModule(this.#ctx, module.name)),
32
+ },
33
+ });
34
+ };
35
+ }
36
+
37
+ module(): RequestHandler {
38
+ return (req: Request, res: Response) => {
39
+ const described = describeModule(this.#ctx, String(req.params.module));
40
+ if (!described) throw new HttpError(404, "noSuchModule", { field: "module" });
41
+ res.respond({ data: described });
42
+ };
43
+ }
44
+
45
+ change(): RequestHandler {
46
+ return async (req: Request, res: Response) => {
47
+ const namespace = String(req.params.module);
48
+ const { values, version } = req.body as { values: Record<string, unknown>; version?: number };
49
+ if (!this.#ctx.settings.namespaces().includes(namespace)) {
50
+ throw new HttpError(404, "noSuchModule", { field: "module" });
51
+ }
52
+
53
+ const current = this.#ctx.settings.describe(namespace).stored.version;
54
+ if (version !== undefined && version !== current) {
55
+ throw new HttpError(409, "staleVersion", {
56
+ code: "STALE_VERSION",
57
+ vars: { version: String(version), current: String(current) },
58
+ details: { current },
59
+ defaultMessage: `version ${version} is stale; the store holds version ${current}`,
60
+ });
61
+ }
62
+
63
+ // Acknowledged before anything changes: identifiers only, never values.
64
+ await auditChange(this.#ctx, req, { module: namespace, keys: Object.keys(values), version: current });
65
+
66
+ const user = req.auth.user!;
67
+ try {
68
+ const written = await this.#ctx.settings.set(namespace, values, { id: String(user._id), label: user.email });
69
+ res.respond({
70
+ data: { module: namespace, values: written.values, version: written.version, requires_restart: written.requiresRestart },
71
+ ...(written.requiresRestart.length > 0 && { meta: { action: "restart_required" } }),
72
+ });
73
+ } catch (error) {
74
+ if (error instanceof SettingsValidationError) {
75
+ throw new ValidationError(error.issues.map((issue) => ({ field: issue.path.map(String).join(".") || "values", message: issue.message })));
76
+ }
77
+ if (error instanceof SettingsConflictError) {
78
+ throw new HttpError(409, "staleVersion", {
79
+ code: "STALE_VERSION",
80
+ vars: { version: String(error.expectedVersion), current: String(error.current?.version ?? 0) },
81
+ details: { current: error.current?.version ?? 0 },
82
+ defaultMessage: error.message,
83
+ });
84
+ }
85
+ throw error;
86
+ }
87
+ };
88
+ }
89
+ }
@@ -0,0 +1,41 @@
1
+ import type { Request } from "express";
2
+ import type { Ctx } from "@tulipes/core/boot";
3
+ import { HttpError } from "@tulipes/core/http";
4
+
5
+ /**
6
+ * Persist before applying a change, independent of LOG_LEVEL and with a
7
+ * majority acknowledgment: a write nobody can account for is worse than a
8
+ * write that did not happen. Only identifiers enter the record — the
9
+ * namespace, the field names, the version the caller read — never values.
10
+ * Failure is deliberately terminal: the caller awaits this before acting.
11
+ */
12
+ export async function auditChange(
13
+ ctx: Ctx,
14
+ req: Request,
15
+ change: { module: string; keys: string[]; version: number },
16
+ ): Promise<void> {
17
+ const user = req.auth.user;
18
+ if (!user) throw new HttpError(401, "unauthenticated");
19
+
20
+ try {
21
+ const Audit = ctx.models!.get("ConfigAudit");
22
+ await new Audit({
23
+ action: "settings.change_requested",
24
+ module: change.module,
25
+ keys: change.keys,
26
+ version: change.version,
27
+ actor: String(user._id),
28
+ actor_role: user.role,
29
+ ip: req.ip,
30
+ app_env: ctx.Environment.appEnv,
31
+ mode: ctx.mode,
32
+ recorded_at: new Date(),
33
+ }).save({ w: "majority", j: true, wtimeout: 5000 });
34
+ } catch {
35
+ // Sink/validation errors may contain record content or connection details.
36
+ throw new HttpError(503, "auditUnavailable", {
37
+ code: "AUDIT_UNAVAILABLE",
38
+ defaultMessage: "The audit record could not be acknowledged; the change was not applied",
39
+ });
40
+ }
41
+ }
@@ -0,0 +1,72 @@
1
+ import type { Ctx } from "@tulipes/core/boot";
2
+
3
+ /**
4
+ * Everything the framework knows about one module, for the console: the
5
+ * manifest, the variables it declares (names, types, sources — a secret's
6
+ * value never, any value only as what the process resolved), its settings
7
+ * with their schema, live values and stored version, and its endpoints
8
+ * with the roles that reach them. Nothing is invented: it all comes from
9
+ * the booted context, so the console cannot describe an app different
10
+ * from the one running.
11
+ */
12
+ export function describeModule(ctx: Ctx, name: string): Record<string, unknown> | undefined {
13
+ const manifest = ctx.modules.find((entry) => entry.name === name);
14
+ if (!manifest) return undefined;
15
+
16
+ const variables = ctx.Environment.store.describe()
17
+ .filter((variable) => variable.owner === name)
18
+ .map((variable) => ({
19
+ name: variable.spec.name,
20
+ type: variable.spec.type,
21
+ description: variable.spec.description,
22
+ group: variable.spec.group ?? null,
23
+ required: variable.spec.required ?? false,
24
+ enum: variable.spec.enum ?? null,
25
+ default: variable.spec.type === "secret" ? null : (variable.spec.default ?? null),
26
+ source: variable.source,
27
+ is_secret: variable.isSecret,
28
+ is_set: variable.isSet,
29
+ // What this process resolved; a secret is never here.
30
+ value: variable.isSecret ? null : (variable.value ?? null),
31
+ }));
32
+
33
+ const settings = ctx.settings.namespaces().includes(name) ? ctx.settings.describe(name) : undefined;
34
+ const grants = ctx.acl?.describe() ?? [];
35
+ const routes = ctx.routes.all()
36
+ .filter((record) => record.module === name)
37
+ .map((record) => ({
38
+ id: record.info.id,
39
+ method: record.method.toUpperCase(),
40
+ path: record.path,
41
+ name: record.info.name ?? null,
42
+ roles: ctx.routes.checker ? "dynamic" : rolesFor(grants, record.info.id),
43
+ }));
44
+
45
+ return {
46
+ name: manifest.name,
47
+ tier: manifest.tier,
48
+ priority: manifest.priority,
49
+ depends_on: manifest.dependsOn,
50
+ variables,
51
+ settings: settings
52
+ ? {
53
+ schema: settings.schema,
54
+ values: settings.values,
55
+ version: settings.stored.version,
56
+ updated_at: settings.stored.updated_at,
57
+ updated_by: settings.stored.updated_by,
58
+ restart_fields: settings.restart,
59
+ pending_restart: settings.pendingRestart,
60
+ }
61
+ : null,
62
+ routes,
63
+ };
64
+ }
65
+
66
+ function rolesFor(grants: { role: string; resource: string }[], id: string): string[] {
67
+ const namespace = `${id.split(":")[0]}:*`;
68
+ const roles = grants
69
+ .filter(({ resource }) => resource === id || resource === "*" || resource === namespace)
70
+ .map(({ role }) => role);
71
+ return [...new Set(roles)].sort();
72
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "noSuchModule": "No such module",
3
+ "invalidModuleName": "Module names are lowercase kebab-case",
4
+ "staleVersion": "Version {{version}} is stale; the store holds version {{current}}",
5
+ "auditUnavailable": "The audit record could not be acknowledged; the change was not applied"
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "noSuchModule": "Module introuvable",
3
+ "invalidModuleName": "Les noms de modules sont en kebab-case minuscule",
4
+ "staleVersion": "La version {{version}} est périmée ; le dépôt est à la version {{current}}",
5
+ "auditUnavailable": "L’enregistrement d’audit n’a pas pu être confirmé ; la modification n’a pas été appliquée"
6
+ }
@@ -0,0 +1,26 @@
1
+ import { Schema } from "mongoose";
2
+ import type { ModelDef } from "@tulipes/mongoose";
3
+
4
+ /**
5
+ * A requested settings change, acknowledged before it is applied. Only the
6
+ * namespace, the field names and the version travel — never a value, so
7
+ * the record can be read by anyone who may read the audit without seeing
8
+ * what was written.
9
+ */
10
+ const configAuditSchema = new Schema({
11
+ action: { type: String, required: true, enum: ["settings.change_requested"] },
12
+ module: { type: String, required: true },
13
+ keys: { type: [String], required: true },
14
+ version: { type: Number, required: true },
15
+ actor: { type: String, required: true },
16
+ actor_role: { type: String, required: true },
17
+ ip: String,
18
+ app_env: { type: String, required: true },
19
+ mode: { type: String, required: true },
20
+ recorded_at: { type: Date, required: true },
21
+ }, { collection: "config_audit_events", strict: "throw" });
22
+
23
+ // No automatic retention: operators own archival/retention for this audit store.
24
+ configAuditSchema.index({ actor: 1, recorded_at: -1 });
25
+ configAuditSchema.index({ module: 1, recorded_at: -1 });
26
+ export default { name: "ConfigAudit", schema: configAuditSchema } satisfies ModelDef;
@@ -0,0 +1,12 @@
1
+ import type { AclBuilder } from "@tulipes/core/acl";
2
+
3
+ /**
4
+ * The console. Reading what the app is configured with is an
5
+ * administrator's job; changing it is the deployment's, so writes stay
6
+ * with `sysadmin`. The boundary is real because the auth module never
7
+ * grants `admin` the `*` wildcard.
8
+ */
9
+ export default function configAcl(acl: AclBuilder): void {
10
+ acl.allow("admin", "config:read", "config:describe");
11
+ acl.allow("sysadmin", "config:*");
12
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@app/config",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "tulipes": {
7
+ "tier": "app",
8
+ "priority": 910,
9
+ "dependsOn": [
10
+ "auth",
11
+ "core"
12
+ ]
13
+ },
14
+ "dependencies": {
15
+ "@app/auth": "workspace:*",
16
+ "@app/core": "workspace:*",
17
+ "@tulipes/core": "workspace:*",
18
+ "@tulipes/mongoose": "workspace:*",
19
+ "express": "^5",
20
+ "mongoose": "^8",
21
+ "zod": "^3.25"
22
+ }
23
+ }
@@ -0,0 +1,37 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+
4
+ import { ConfigController } from "../controllers/config.controllers.ts";
5
+ import { changeBody, moduleParam } from "../schemas/config.schemas.ts";
6
+
7
+ export default function configRoutes(ctx: BackendCtx): void {
8
+ const { rai, routes, config } = ctx;
9
+ const router = Router();
10
+ const console = new ConfigController(ctx);
11
+
12
+ router.get("/", rai({
13
+ id: "config:read",
14
+ name: "Describe the app",
15
+ description: "Every module in load order: manifest, declared variables (names, types, sources; secrets never), settings with schema, live values and stored version, endpoints with the roles that reach them",
16
+ folder: "config",
17
+ }), console.app());
18
+
19
+ router.get("/:module", rai({
20
+ id: "config:describe",
21
+ name: "Describe one module",
22
+ description: "The same description for one module",
23
+ folder: "config",
24
+ params: moduleParam,
25
+ }), console.module());
26
+
27
+ router.patch("/:module", rai({
28
+ id: "config:write",
29
+ name: "Change a module's settings",
30
+ description: "A patch over the stored values, validated by the module's schema; a stale version is refused; applied at once in every replica, except fields tagged restart, which apply on the next boot",
31
+ folder: "config",
32
+ params: moduleParam,
33
+ body: changeBody,
34
+ }), console.change());
35
+
36
+ routes.mount(router, `${config.api!.prefix}/config`);
37
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from "zod/v4";
2
+
3
+ /** Validated by rai(), and described in the generated spec and the agent manifest. */
4
+ export const moduleParam = z.object({
5
+ module: z.string().regex(/^[a-z][a-z0-9-]*$/, "invalidModuleName"),
6
+ });
7
+
8
+ export const changeBody = z.object({
9
+ /** A patch over the stored values; the module's own schema validates the result. */
10
+ values: z.record(z.string(), z.unknown()),
11
+ /** The version read before editing; a different stored version refuses the write. */
12
+ version: z.number().int().positive().optional(),
13
+ });
@@ -0,0 +1,126 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
3
+ import { z } from "zod/v4";
4
+ import { afterAll, beforeAll, expect, test, vi } from "vitest";
5
+ import { boot } from "@tulipes/core/boot";
6
+ import { useTestApp } from "@tulipes/testing";
7
+
8
+ import { testEnv, testSettings } from "../../../tests/support.ts";
9
+
10
+ const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
11
+ const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
12
+
13
+
14
+ const tokens = z.object({
15
+ data: z.object({ access_token: z.string(), refresh_token: z.string() }),
16
+ });
17
+
18
+ async function post(path: string, body: unknown, token?: string) {
19
+ return fetch(app.base + path, {
20
+ method: "POST",
21
+ headers: {
22
+ "content-type": "application/json",
23
+ ...(token ? { authorization: "Bearer " + token } : {}),
24
+ },
25
+ body: JSON.stringify(body),
26
+ });
27
+ }
28
+
29
+ async function account() {
30
+ const credentials = {
31
+ email: randomBytes(12).toString("hex") + "@example.test",
32
+ password: randomBytes(24).toString("hex"),
33
+ };
34
+
35
+ const response = await post("/api/v1/auth/register", credentials);
36
+
37
+ expect(response.status).toBe(201);
38
+
39
+ const body = await response.json();
40
+
41
+ const { user } = z
42
+ .object({ data: z.object({ user: z.object({ id: z.string() }) }) })
43
+ .parse(body).data;
44
+
45
+ return { ...tokens.parse(body).data, ...credentials, userId: user.id };
46
+ }
47
+
48
+ async function get(path: string, token: string) {
49
+ return fetch(app.base + path, { headers: { authorization: "Bearer " + token } });
50
+ }
51
+
52
+ async function sysadmin() {
53
+ const user = await account();
54
+
55
+ await app.handle.ctx
56
+ .models!.get("User")
57
+ .updateOne({ _id: user.userId }, { role: "sysadmin" });
58
+
59
+ return user;
60
+ }
61
+
62
+ async function patch(path: string, body: unknown, token: string) {
63
+ return fetch(app.base + path, {
64
+ method: "PATCH",
65
+ headers: {
66
+ "content-type": "application/json",
67
+ authorization: "Bearer " + token,
68
+ },
69
+ body: JSON.stringify(body),
70
+ });
71
+ }
72
+
73
+ test("the console describes every module without a secret's value and only administrators read it", async () => {
74
+ const ordinary = await account();
75
+ const admin = await account();
76
+ await app.handle.ctx.models!.get("User").updateOne({ _id: admin.userId }, { role: "admin" });
77
+
78
+ expect((await get("/api/v1/config", ordinary.access_token)).status).toBe(403);
79
+
80
+ const response = await get("/api/v1/config", admin.access_token);
81
+ expect(response.status).toBe(200);
82
+ const { data } = (await response.json()) as { data: { store: { ready: boolean }; modules: Record<string, unknown>[] } };
83
+ expect(data.store.ready).toBe(true);
84
+ expect(data.modules.map((module) => module.name)).toEqual(app.handle.ctx.modules.map((module) => module.name));
85
+
86
+ const auth = data.modules.find((module) => module.name === "auth") as { variables: Record<string, unknown>[]; settings: { values: Record<string, unknown>; version: number }; routes: Record<string, unknown>[] };
87
+ const secret = auth.variables.find((variable) => variable.name === "JWT_ACCESS_SECRET")!;
88
+ expect(secret).toMatchObject({ is_secret: true, is_set: true, value: null, default: null });
89
+ expect(JSON.stringify(data)).not.toContain(process.env.JWT_ACCESS_SECRET);
90
+ expect(auth.settings.values).toMatchObject({ accessTtl: "15m" });
91
+ expect(auth.routes).toContainEqual(expect.objectContaining({ id: "auth:login", method: "POST", roles: expect.arrayContaining(["guest"]) }));
92
+
93
+ // The administrator may read; writing is the deployment's job.
94
+ expect((await patch("/api/v1/config/hello", { values: { greeting: "Nope" } }, admin.access_token)).status).toBe(403);
95
+ });
96
+
97
+ test("a settings write is validated by the module's schema, refused on a stale version, audited without values and applied at once", async () => {
98
+ const user = await sysadmin();
99
+ const described = (await (await get("/api/v1/config/hello", user.access_token)).json()) as { data: { settings: { version: number } } };
100
+ const before = described.data.settings.version;
101
+
102
+ const invalid = await patch("/api/v1/config/hello", { values: { greeting: "" } }, user.access_token);
103
+ expect(invalid.status).toBe(422);
104
+ expect(await invalid.json()).toMatchObject({ errors: [{ field: "greeting" }] });
105
+
106
+ const stale = await patch("/api/v1/config/hello", { values: { greeting: "Howdy" }, version: before + 7 }, user.access_token);
107
+ expect(stale.status).toBe(409);
108
+ expect(await stale.json()).toMatchObject({ errors: [{ code: "STALE_VERSION" }] });
109
+
110
+ const written = await patch("/api/v1/config/hello", { values: { greeting: "Howdy" }, version: before }, user.access_token);
111
+ expect(written.status).toBe(200);
112
+ expect(await written.json()).toMatchObject({ data: { module: "hello", values: { greeting: "Howdy" }, version: before + 1, requires_restart: [] } });
113
+
114
+ // Live in this process, through the same object every handler reads.
115
+ expect(app.handle.ctx.config.hello).toEqual({ greeting: "Howdy" });
116
+ const greeted = (await (await fetch(app.base + "/api/v1/hello")).json()) as { data: { message: string } };
117
+ expect(greeted.data.message).toMatch(/^Howdy/);
118
+
119
+ const audit = await app.handle.ctx.models!.get("ConfigAudit").find({ actor: user.userId }).sort({ recorded_at: -1 }).lean();
120
+ expect(audit[0]).toMatchObject({ action: "settings.change_requested", module: "hello", keys: ["greeting"], version: before, actor_role: "sysadmin" });
121
+ expect(JSON.stringify(audit)).not.toContain("Howdy");
122
+
123
+ expect((await patch("/api/v1/config/nope", { values: {} }, user.access_token)).status).toBe(404);
124
+
125
+ await app.handle.ctx.settings.set("hello", { greeting: "Hello" }, { id: "tests" });
126
+ });
@@ -0,0 +1,12 @@
1
+ import type { BootstrapCleanup } from "@tulipes/core/boot";
2
+
3
+ import { closeCache } from "../helpers/cache/index.ts";
4
+
5
+ /**
6
+ * The app cache opens lazily on first use, so there is nothing to open here;
7
+ * what this bootstrap contributes is the cleanup: the redis driver holds a
8
+ * connection, and core runs this after traffic drains, in reverse load order.
9
+ */
10
+ export default function cache(): BootstrapCleanup {
11
+ return () => closeCache();
12
+ }
@@ -0,0 +1,60 @@
1
+ import { Redis } from "ioredis";
2
+ import type { Environment } from "@tulipes/core/env";
3
+
4
+ import { MemoryCache } from "./memory-cache.ts";
5
+ import { RedisCache } from "./redis-cache.ts";
6
+ import type { Cache } from "./types.ts";
7
+
8
+ export type { Cache } from "./types.ts";
9
+ export { BaseCache } from "./types.ts";
10
+ export { MemoryCache } from "./memory-cache.ts";
11
+ export { RedisCache } from "./redis-cache.ts";
12
+
13
+ let active: { cache: Cache; redis?: Redis } | undefined;
14
+
15
+ /**
16
+ * The app's one cache, picked from the environment: redis when REDIS_URL is
17
+ * declared, in-process memory otherwise (tests, minimal setups). Lazily
18
+ * created, process-wide singleton.
19
+ */
20
+ export function appCache(environment: Environment): Cache {
21
+ if (active) return active.cache;
22
+
23
+ if (environment.store.has("REDIS_URL")) {
24
+ // Fail a command fast instead of queueing it and retrying for seconds: the
25
+ // cache is consulted on every request, so a redis outage must cost a miss,
26
+ // not a stalled request. RedisCache turns the rejection into that miss.
27
+ const redis = new Redis(String(environment.get("REDIS_URL")), {
28
+ maxRetriesPerRequest: 1,
29
+ enableOfflineQueue: false,
30
+ });
31
+ // ioredis makes an unhandled "error" event an uncaught exception; the cache
32
+ // reports what it actually affects, when it affects it.
33
+ redis.on("error", () => {});
34
+ active = { cache: new RedisCache(redis), redis };
35
+ } else {
36
+ active = { cache: new MemoryCache() };
37
+ }
38
+ return active.cache;
39
+ }
40
+
41
+ /**
42
+ * The cache bootstrap's cleanup: the redis driver holds a connection. QUIT
43
+ * is the orderly close, but with the offline queue off it is refused on a
44
+ * client that is not ready — a process stopping while the first connection
45
+ * is still being made, or during an outage — so that client is dropped.
46
+ */
47
+ export async function closeCache(): Promise<void> {
48
+ const redis = active?.redis;
49
+ active = undefined;
50
+ if (!redis) return;
51
+ if (redis.status === "ready") {
52
+ try {
53
+ await redis.quit();
54
+ return;
55
+ } catch {
56
+ // fall through: the connection went away between the check and QUIT
57
+ }
58
+ }
59
+ redis.disconnect();
60
+ }
@@ -0,0 +1,53 @@
1
+ import { BaseCache } from "./types.ts";
2
+
3
+ interface Entry {
4
+ value: unknown;
5
+ /** Epoch ms when this entry expires, or `null` for no expiry. */
6
+ expiresAt: number | null;
7
+ }
8
+
9
+ /**
10
+ * In-process cache backed by a `Map`. For tests and local runs without
11
+ * Redis — not shared across processes (each worker has its own store).
12
+ *
13
+ * Bounded: past `maxEntries` the least-recently-used key is evicted. LRU
14
+ * order rides on Map insertion order — hits re-insert their key at the tail.
15
+ */
16
+ export class MemoryCache extends BaseCache {
17
+ readonly #store = new Map<string, Entry>();
18
+ readonly #maxEntries: number;
19
+
20
+ constructor(maxEntries = 10_000) {
21
+ super();
22
+ this.#maxEntries = maxEntries;
23
+ }
24
+
25
+ async get<T>(key: string): Promise<T | null> {
26
+ const entry = this.#store.get(key);
27
+ if (!entry) return null;
28
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
29
+ this.#store.delete(key);
30
+ return null;
31
+ }
32
+ // Refresh recency: delete + re-set moves the key to the Map's tail.
33
+ this.#store.delete(key);
34
+ this.#store.set(key, entry);
35
+ return entry.value as T;
36
+ }
37
+
38
+ async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
39
+ const expiresAt =
40
+ ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
41
+ this.#store.delete(key);
42
+ this.#store.set(key, { value, expiresAt });
43
+
44
+ while (this.#store.size > this.#maxEntries) {
45
+ // Oldest key = head of the Map's insertion order.
46
+ this.#store.delete(this.#store.keys().next().value!);
47
+ }
48
+ }
49
+
50
+ async del(key: string): Promise<void> {
51
+ this.#store.delete(key);
52
+ }
53
+ }
@@ -0,0 +1,68 @@
1
+ import type { Redis } from "ioredis";
2
+ import { BaseCache } from "./types.ts";
3
+
4
+ /**
5
+ * Redis-backed cache. Values are JSON-serialized; keys are namespaced by
6
+ * `prefix` so cache keys never collide with other Redis users in the same
7
+ * db (BullMQ especially) and can be cleared without a FLUSHDB.
8
+ */
9
+ export class RedisCache extends BaseCache {
10
+ readonly #redis: Redis;
11
+ readonly #prefix: string;
12
+
13
+ constructor(redis: Redis, prefix = "cache:") {
14
+ super();
15
+ this.#redis = redis;
16
+ this.#prefix = prefix;
17
+ }
18
+
19
+ #key(key: string): string {
20
+ return `${this.#prefix}${key}`;
21
+ }
22
+
23
+ /**
24
+ * A cache is best effort, and this one sits in global middleware: if a redis
25
+ * outage threw from here, every endpoint would answer 500 — an outage of the
26
+ * whole API caused by an optional dependency. Every operation therefore
27
+ * degrades to "no cache" and says so at most once every 30 seconds.
28
+ */
29
+ #reportedAt = 0;
30
+
31
+ #degraded(operation: string, error: unknown): void {
32
+ const now = Date.now();
33
+ if (now - this.#reportedAt < 30_000) return;
34
+ this.#reportedAt = now;
35
+ console.error(`[cache] redis ${operation} failed; serving without cache: ${(error as Error).message}`);
36
+ }
37
+
38
+ async get<T>(key: string): Promise<T | null> {
39
+ try {
40
+ const raw = await this.#redis.get(this.#key(key));
41
+ return raw === null ? null : (JSON.parse(raw) as T);
42
+ } catch (error) {
43
+ this.#degraded("get", error);
44
+ return null;
45
+ }
46
+ }
47
+
48
+ async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
49
+ const raw = JSON.stringify(value);
50
+ try {
51
+ if (ttlSeconds && ttlSeconds > 0) {
52
+ await this.#redis.set(this.#key(key), raw, "EX", ttlSeconds);
53
+ } else {
54
+ await this.#redis.set(this.#key(key), raw);
55
+ }
56
+ } catch (error) {
57
+ this.#degraded("set", error);
58
+ }
59
+ }
60
+
61
+ async del(key: string): Promise<void> {
62
+ try {
63
+ await this.#redis.del(this.#key(key));
64
+ } catch (error) {
65
+ this.#degraded("del", error);
66
+ }
67
+ }
68
+ }