@aurostack/stacks 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +59 -0
  2. package/cli/src/generate.mjs +13 -1
  3. package/package.json +1 -1
  4. package/templates/nest-api/files/.env.example +29 -2
  5. package/templates/nest-api/files/.env.test.example +1 -1
  6. package/templates/nest-api/files/compose.yml +16 -0
  7. package/templates/nest-api/files/package.json +18 -3
  8. package/templates/nest-api/files/src/app.module.ts +9 -1
  9. package/templates/nest-api/files/src/app.setup.ts +5 -6
  10. package/templates/nest-api/files/src/common/__tests__/temporal.service.spec.ts +66 -0
  11. package/templates/nest-api/files/src/common/common.module.ts +4 -3
  12. package/templates/nest-api/files/src/common/controllers/index.ts +0 -1
  13. package/templates/nest-api/files/src/common/modules/index.ts +0 -1
  14. package/templates/nest-api/files/src/common/modules/logger.module.ts +2 -6
  15. package/templates/nest-api/files/src/common/services/config.service.ts +43 -5
  16. package/templates/nest-api/files/src/common/services/index.ts +1 -0
  17. package/templates/nest-api/files/src/common/services/temporal.service.ts +66 -0
  18. package/templates/nest-api/files/src/instrumentation.ts +117 -0
  19. package/templates/nest-api/files/src/main.ts +3 -1
  20. package/templates/nest-api/files/src/telemetry/__tests__/config.spec.ts +67 -0
  21. package/templates/nest-api/files/src/telemetry/config.ts +62 -0
  22. package/templates/nest-api/template.json +36 -10
  23. package/templates/node-worker/files/.env.example +27 -0
  24. package/templates/node-worker/files/Dockerfile +25 -2
  25. package/templates/node-worker/files/Dockerfile.dev +25 -2
  26. package/templates/node-worker/files/ecosystem.config.cjs +2 -0
  27. package/templates/node-worker/files/package.json +23 -4
  28. package/templates/node-worker/files/src/config.ts +23 -0
  29. package/templates/node-worker/files/src/index.ts +13 -0
  30. package/templates/node-worker/files/src/instrumentation.ts +110 -0
  31. package/templates/node-worker/files/src/telemetry/config.ts +61 -0
  32. package/templates/node-worker/files/src/temporal/activities/index.ts +14 -0
  33. package/templates/node-worker/files/src/temporal/worker.ts +84 -0
  34. package/templates/node-worker/files/src/temporal/workflows/index.ts +23 -0
  35. package/templates/node-worker/files/src/utils/worker.ts +9 -1
  36. package/templates/node-worker/template.json +43 -1
  37. package/templates/py-worker/files/.env.example +28 -0
  38. package/templates/py-worker/files/config.py +32 -1
  39. package/templates/py-worker/files/requirements.txt +16 -0
  40. package/templates/py-worker/files/temporal/__init__.py +0 -0
  41. package/templates/py-worker/files/temporal/activities.py +13 -0
  42. package/templates/py-worker/files/temporal/workflows.py +38 -0
  43. package/templates/py-worker/files/utils/telemetry.py +154 -0
  44. package/templates/py-worker/files/utils/temporal.py +55 -0
  45. package/templates/py-worker/files/utils/worker.py +12 -1
  46. package/templates/py-worker/files/worker.py +16 -0
  47. package/templates/py-worker/template.json +36 -0
  48. package/templates/react-app/derive.sh +2 -1
  49. package/templates/react-app/files/.env.example +11 -0
  50. package/templates/react-app/files/package.json +2 -0
  51. package/templates/react-app/files/src/lib/env.ts +10 -0
  52. package/templates/react-app/files/src/main.tsx +4 -0
  53. package/templates/react-app/files/src/shared/auth/provider.tsx +2 -0
  54. package/templates/react-app/files/src/shared/layouts/error-boundary.tsx +2 -0
  55. package/templates/react-app/files/src/shared/telemetry/index.ts +80 -0
  56. package/templates/react-app/template.json +14 -0
  57. package/templates/react-monorepo/files/apps/admin/.env.example +11 -0
  58. package/templates/react-monorepo/files/apps/admin/package.json +1 -0
  59. package/templates/react-monorepo/files/apps/admin/src/lib/env.ts +10 -0
  60. package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
  61. package/templates/react-monorepo/files/apps/auth/.env.example +11 -0
  62. package/templates/react-monorepo/files/apps/auth/package.json +1 -0
  63. package/templates/react-monorepo/files/apps/auth/src/lib/env.ts +10 -0
  64. package/templates/react-monorepo/files/apps/auth/src/main.tsx +4 -0
  65. package/templates/react-monorepo/files/apps/client/.env.example +11 -0
  66. package/templates/react-monorepo/files/apps/client/package.json +1 -0
  67. package/templates/react-monorepo/files/apps/client/src/lib/env.ts +10 -0
  68. package/templates/react-monorepo/files/apps/client/src/main.tsx +4 -0
  69. package/templates/react-monorepo/files/apps/landing/.env.example +11 -0
  70. package/templates/react-monorepo/files/apps/landing/package.json +1 -0
  71. package/templates/react-monorepo/files/apps/landing/src/lib/env.ts +10 -0
  72. package/templates/react-monorepo/files/apps/landing/src/main.tsx +4 -0
  73. package/templates/react-monorepo/files/packages/auth/package.json +1 -0
  74. package/templates/react-monorepo/files/packages/auth/src/provider.tsx +2 -0
  75. package/templates/react-monorepo/files/packages/layouts/package.json +1 -0
  76. package/templates/react-monorepo/files/packages/layouts/src/error-boundary.tsx +2 -0
  77. package/templates/react-monorepo/files/packages/telemetry/eslint.config.mjs +3 -0
  78. package/templates/react-monorepo/files/packages/telemetry/package.json +20 -0
  79. package/templates/react-monorepo/files/packages/telemetry/src/index.ts +80 -0
  80. package/templates/react-monorepo/files/packages/telemetry/tsconfig.json +4 -0
  81. package/templates/react-monorepo/template.json +46 -0
  82. package/templates/nest-api/files/src/common/controllers/metrics.controller.ts +0 -21
  83. package/templates/nest-api/files/src/common/interceptors/index.ts +0 -1
  84. package/templates/nest-api/files/src/common/interceptors/metrics.interceptor.ts +0 -37
  85. package/templates/nest-api/files/src/common/modules/metrics.module.ts +0 -28
package/README.md CHANGED
@@ -115,6 +115,65 @@ The shared code is literally the same files: `templates/react-app/derive.sh`
115
115
  re-flattens `packages/*` into `src/shared/*` and rewrites the import specifiers,
116
116
  so a fix in one lands in the other. See that template's README.
117
117
 
118
+ ### Observability
119
+
120
+ Every template ships with OpenTelemetry (backend) or OpenObserve RUM (browser)
121
+ wired in and switched on by default (`--without observability` for `nest-api`,
122
+ `--without telemetry` for the rest). Nothing is sent until a project is
123
+ connected; unset, the SDKs are never even loaded.
124
+
125
+ | Template | Sends |
126
+ |---|---|
127
+ | `nest-api` | Traces (HTTP, GraphQL, Prisma, pg, ioredis, BullMQ jobs), logs with trace ids, runtime/HTTP/queue metrics |
128
+ | `node-worker` | Job traces, Prisma/Redis spans, logs, metrics |
129
+ | `py-worker` | One trace per job, Redis spans, logs, metrics |
130
+ | `react-app` / `react-monorepo` | Page views, errors (including the error boundary's), slow resources, user actions, the signed-in user id, console errors as logs. API calls carry `traceparent`, so a click links to the backend trace it caused |
131
+
132
+ To connect a project to its OpenObserve organization:
133
+
134
+ 1. Create an organization for the project in OpenObserve.
135
+ 2. **Backend:** from IAM → Ingestion Tokens, copy the org's ingestion token
136
+ (`o2oi_…`) and set `OPENOBSERVE_ORG` and `OPENOBSERVE_TOKEN`
137
+ (`OPENOBSERVE_URL` defaults to `https://o2.aurostack.co`). Each service
138
+ writes to its own stream, named after `OTEL_SERVICE_NAME`.
139
+ 3. **Frontend:** from Ingestion → RUM, copy the RUM token and set
140
+ `VITE_OPENOBSERVE_ORG` and `VITE_OPENOBSERVE_CLIENT_TOKEN`. It is a
141
+ write-only token built to ship in a bundle. Never put the backend's
142
+ ingestion token there.
143
+ 4. Add each app origin to the instance's `ZO_CORS_ALLOWED_ORIGINS`, or the
144
+ browser's RUM posts are blocked.
145
+
146
+ Any other OTLP backend works too: set `OTEL_EXPORTER_OTLP_ENDPOINT` (and
147
+ `_HEADERS`) instead of the `OPENOBSERVE_*` variables. `OTEL_SDK_DISABLED=true`
148
+ turns telemetry off. Session replay is off by default
149
+ (`sessionReplaySampleRate` in `initTelemetry`). The first request or two of a
150
+ page load go out before the RUM SDK has started, so they carry no trace header.
151
+ Source-map upload for readable browser stack traces needs OpenObserve
152
+ Enterprise.
153
+
154
+ ### Background work: BullMQ or Temporal
155
+
156
+ BullMQ is built in. Temporal is opt-in (`--with temporal`) for the backend
157
+ templates, alongside the queues rather than instead of them:
158
+
159
+ - **BullMQ** for fire-and-forget jobs: send an email, resize an image, a
160
+ cron-style sweep.
161
+ - **Temporal** for work that spans many steps, waits (minutes to months, or
162
+ for a signal), or must resume exactly where it stopped after a crash or
163
+ deploy: onboarding sequences, payments and refunds, multi-service sagas.
164
+
165
+ | Template | `--with temporal` adds |
166
+ |---|---|
167
+ | `nest-api` | `TemporalService` (a lazily-connected client for starting and querying workflows) and Temporal's dev server in `compose.yml` (UI on `:8233`) |
168
+ | `node-worker` | A Temporal worker next to the BullMQ consumers, with an example workflow and activity in `src/temporal`. Its image switches to Debian: Temporal's native core doesn't run on Alpine |
169
+ | `py-worker` | The same in Python (`temporal/`), with a span per workflow and activity when telemetry is on |
170
+
171
+ Every template reads the same settings: `TEMPORAL_ADDRESS`,
172
+ `TEMPORAL_NAMESPACE`, `TEMPORAL_TASK_QUEUE`, and for production mTLS the
173
+ `TEMPORAL_TLS_CA`, `TEMPORAL_TLS_CERT` and `TEMPORAL_TLS_KEY` PEMs. Locally
174
+ they default to the dev server with no TLS. The API starts workflows on the
175
+ task queue a worker polls: `main` for node-worker, `python` for py-worker.
176
+
118
177
  ## How it works
119
178
 
120
179
  Templates are **subtractive**. `templates/<name>/files` is a complete, runnable
@@ -197,7 +197,19 @@ export function generate(manifest, target, options) {
197
197
 
198
198
  const prune = prunes.get(destRel);
199
199
  if (prune && path.basename(destRel) === 'package.json') {
200
- const result = prunePackageJson(content, prune);
200
+ // Keys name packages as the template spells them (`@acme/telemetry`),
201
+ // but the content has already been through the rename map.
202
+ const renamed = (keys) =>
203
+ new Set(
204
+ [...keys].map((key) =>
205
+ applyReplacements(key, replacements, enabled, evalExpr)
206
+ )
207
+ );
208
+ const result = prunePackageJson(content, {
209
+ dependencies: renamed(prune.dependencies),
210
+ devDependencies: renamed(prune.devDependencies),
211
+ scripts: prune.scripts
212
+ });
201
213
  content = result.raw;
202
214
  report.pruned += result.removed;
203
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurostack/stacks",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold new projects from house templates: a NestJS API, React app or Turborepo monorepo, and Node or Python workers. Subtractive templates plus a zero-dependency generator.",
5
5
  "license": "MIT",
6
6
  "author": "Aurostack (https://github.com/aurostack-org)",
@@ -31,6 +31,17 @@ OAUTH_GOOGLE_CLIENT_SECRET=''
31
31
  LOG_LEVEL='debug'
32
32
 
33
33
  # @feature:start observability
34
+ # OpenTelemetry → OpenObserve: traces, logs and metrics. Leave empty and
35
+ # telemetry stays off (local development, tests). Per project:
36
+ # OPENOBSERVE_ORG the project's organization identifier
37
+ # OPENOBSERVE_TOKEN that org's ingestion token (IAM → Ingestion Tokens, o2oi_…)
38
+ # OPENOBSERVE_STREAM defaults to the service name (OTEL_SERVICE_NAME).
39
+ # Any other OTLP backend: set OTEL_EXPORTER_OTLP_ENDPOINT/HEADERS instead.
40
+ OPENOBSERVE_URL='https://o2.aurostack.co'
41
+ OPENOBSERVE_ORG=''
42
+ OPENOBSERVE_TOKEN=''
43
+ OTEL_SERVICE_NAME='acme-api'
44
+
34
45
  # Limits past which /health reports down. Size them to the container's real
35
46
  # limits: an orchestrator restarts instances that fail this check.
36
47
  HEALTH_HEAP_MAX_MB='300'
@@ -40,8 +51,8 @@ HEALTH_DISK_THRESHOLD='0.8'
40
51
  HEALTH_DISK_PATH='/'
41
52
  # @feature:end
42
53
 
43
- # @feature:start openapi, queue, observability
44
- # Guards /docs, /openapi-json, /dashboard and /metrics.
54
+ # @feature:start openapi, queue
55
+ # Guards /docs, /openapi-json and /dashboard.
45
56
  BASIC_AUTH_USER='admin'
46
57
  BASIC_AUTH_PASS='Secret@1'
47
58
  # @feature:end
@@ -72,6 +83,22 @@ S3_ACCESS_KEY_ID=''
72
83
  S3_SECRET_ACCESS_KEY=''
73
84
  # @feature:end
74
85
 
86
+ # @feature:start temporal
87
+ # Temporal: durable workflows. Locally, the dev server in compose.yml (no TLS;
88
+ # UI on http://localhost:8233). In production, the server address, the
89
+ # project's namespace and its mTLS client certificate (plus the CA that signed
90
+ # the server's), as PEM. Escaped \n are accepted, so each fits on one line.
91
+ TEMPORAL_ADDRESS='localhost:7233'
92
+ TEMPORAL_NAMESPACE='default'
93
+ # Where workflows started here are queued. The worker that runs them must poll
94
+ # the same queue (its TEMPORAL_TASK_QUEUE); a mismatch is silent, workflows
95
+ # just sit waiting.
96
+ TEMPORAL_TASK_QUEUE='main'
97
+ TEMPORAL_TLS_CA=''
98
+ TEMPORAL_TLS_CERT=''
99
+ TEMPORAL_TLS_KEY=''
100
+ # @feature:end
101
+
75
102
  # @feature:start rate-limit
76
103
  RATE_LIMIT_ENABLED='true'
77
104
  # Throttler TTLs are in milliseconds.
@@ -26,7 +26,7 @@ OAUTH_GOOGLE_CLIENT_SECRET=''
26
26
 
27
27
  LOG_LEVEL='silent'
28
28
 
29
- # @feature:start openapi, queue, observability
29
+ # @feature:start openapi, queue
30
30
  BASIC_AUTH_USER='admin'
31
31
  BASIC_AUTH_PASS='Secret@1'
32
32
  # @feature:end
@@ -34,6 +34,22 @@ services:
34
34
  ports:
35
35
  - 6379:6379
36
36
  # @feature:end
37
+ # @feature:start temporal
38
+ # Temporal's development server (the CLI's `server start-dev`): gRPC on 7233,
39
+ # Web UI on http://localhost:8233, state kept in the temporal volume.
40
+ temporal:
41
+ container_name: acme-temporal
42
+ image: temporalio/temporal:1.9.1
43
+ restart: always
44
+ command: server start-dev --ip 0.0.0.0 --db-filename /home/temporal/temporal.db
45
+ ports:
46
+ - 7233:7233
47
+ - 8233:8233
48
+ volumes:
49
+ # The image runs as `temporal`; mounting its home keeps the volume writable.
50
+ - temporal:/home/temporal
51
+ # @feature:end
37
52
 
38
53
  volumes:
39
54
  database:
55
+ temporal: # @feature temporal
@@ -71,29 +71,44 @@
71
71
  "@nestjs/terminus": "^12.1.0",
72
72
  "@nestjs/throttler": "^6.7.1",
73
73
  "@nestjs/websockets": "^12.1.0",
74
+ "@opentelemetry/api": "^1.9.1",
75
+ "@opentelemetry/auto-instrumentations-node": "^0.80.0",
76
+ "@opentelemetry/core": "^2.11.0",
77
+ "@opentelemetry/exporter-logs-otlp-proto": "^0.222.0",
78
+ "@opentelemetry/exporter-metrics-otlp-proto": "^0.222.0",
79
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.222.0",
80
+ "@opentelemetry/instrumentation": "^0.222.0",
81
+ "@opentelemetry/instrumentation-express": "^0.70.0",
82
+ "@opentelemetry/resources": "^2.11.0",
83
+ "@opentelemetry/sdk-logs": "^0.222.0",
84
+ "@opentelemetry/sdk-metrics": "^2.11.0",
85
+ "@opentelemetry/sdk-node": "^0.222.0",
86
+ "@opentelemetry/semantic-conventions": "^1.43.0",
74
87
  "@paralleldrive/cuid2": "^3.3.0",
75
88
  "@prisma/adapter-pg": "^7.4.2",
76
89
  "@prisma/client": "^7.4.2",
90
+ "@prisma/instrumentation": "^7.10.0",
77
91
  "@react-email/components": "^0.0.7",
78
92
  "@react-email/render": "^0.0.7",
79
93
  "@scalar/nestjs-api-reference": "^1.2.22",
80
94
  "@socket.io/redis-adapter": "^8",
95
+ "@temporalio/client": "^1.24.0",
81
96
  "@thallesp/nestjs-better-auth": "^2.8.0",
82
- "@willsoto/nestjs-prometheus": "^6.1.1",
83
97
  "axios": "^1.4.0",
84
98
  "better-auth": "^1.7.6",
85
- "bullmq": "^5.79.2",
99
+ "bullmq": "^6.3.9",
100
+ "bullmq-otel": "^2.0.1",
86
101
  "express": "^5.2.1",
87
102
  "express-basic-auth": "^1.2.1",
88
103
  "graphql": "^16.12.0",
89
104
  "helmet": "^7.0.0",
105
+ "import-in-the-middle": "^3.5.1",
90
106
  "ioredis": "^5.4.1",
91
107
  "moment": "^2.29.4",
92
108
  "nestjs-pino": "^5.2.0",
93
109
  "nodemailer": "^8.0.11",
94
110
  "pino": "^10.3.1",
95
111
  "pino-http": "^11.0.0",
96
- "prom-client": "^15.1.3",
97
112
  "randomstring": "^1.3.0",
98
113
  "react": "^18.2.0",
99
114
  "react-email": "^1.9.4",
@@ -4,6 +4,7 @@ import { HttpModule } from '@nestjs/axios'; // @feature http-client
4
4
  import { ScheduleModule } from '@nestjs/schedule'; // @feature scheduler
5
5
  import { MailerModule } from '@nestjs-modules/mailer'; // @feature mail
6
6
  import { BullModule } from '@nestjs/bullmq'; // @feature queue
7
+ import { BullMQOtel } from 'bullmq-otel'; // @feature queue
7
8
  import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; // @feature rate-limit
8
9
  import { AuthModule, AuthGuard } from '@thallesp/nestjs-better-auth';
9
10
  import { betterAuth } from 'better-auth';
@@ -204,7 +205,14 @@ import { OriginBuilder } from 'common/misc';
204
205
  },
205
206
  defaultJobOptions: {
206
207
  removeOnComplete: true
207
- }
208
+ },
209
+ // Job spans for producers and workers; a no-op unless the
210
+ // OpenTelemetry SDK is running (see src/instrumentation.ts).
211
+ telemetry: new BullMQOtel({
212
+ tracerName: 'acme-api',
213
+ meterName: 'acme-api',
214
+ enableMetrics: true
215
+ })
208
216
  })
209
217
  }),
210
218
  BullBoardModule.forRoot({
@@ -10,7 +10,7 @@ import { SwaggerModule } from '@nestjs/swagger'; // @feature openapi
10
10
  import { HttpAdapterHost, Reflector } from '@nestjs/core';
11
11
  import helmet from 'helmet';
12
12
  import express from 'express';
13
- import expressBasicAuth from 'express-basic-auth'; // @feature openapi, queue, observability
13
+ import expressBasicAuth from 'express-basic-auth'; // @feature openapi, queue
14
14
  import { apiReference } from '@scalar/nestjs-api-reference'; // @feature openapi
15
15
  import { AuthService } from '@thallesp/nestjs-better-auth'; // @feature openapi
16
16
  import { Logger as PinoLogger } from 'nestjs-pino';
@@ -117,11 +117,11 @@ export const enableHelmet = (app: INestApplication) => {
117
117
  app.use(helmet());
118
118
  };
119
119
 
120
- // @feature:start openapi, queue, observability
120
+ // @feature:start openapi, queue
121
121
  /**
122
122
  * Lock the operator-facing surfaces behind basic auth. Every path added here is
123
- * something that leaks internals if left open — the API reference, the queue
124
- * dashboard, the metrics scrape endpoint — so a new one belongs in this list on
123
+ * something that leaks internals if left open — the API reference and the queue
124
+ * dashboard — so a new one belongs in this list on
125
125
  * the same commit that mounts it.
126
126
  */
127
127
  export const enableBasicAuth = (app: INestApplication) => {
@@ -132,8 +132,7 @@ export const enableBasicAuth = (app: INestApplication) => {
132
132
  [
133
133
  '/docs', // @feature openapi
134
134
  '/openapi-json', // @feature openapi
135
- '/dashboard', // @feature queue
136
- '/metrics' // @feature observability
135
+ '/dashboard' // @feature queue
137
136
  ],
138
137
  expressBasicAuth({
139
138
  users: { [user]: password },
@@ -0,0 +1,66 @@
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { CustomConfigService, TemporalService, temporalTls } from 'common/services';
3
+
4
+ const tls = { ca: '', cert: '', key: '' };
5
+
6
+ describe('TemporalService', () => {
7
+ let service: TemporalService;
8
+
9
+ beforeAll(async () => {
10
+ const module: TestingModule = await Test.createTestingModule({
11
+ providers: [
12
+ TemporalService,
13
+ {
14
+ provide: CustomConfigService,
15
+ useValue: {
16
+ temporal: {
17
+ address: 'localhost:7233',
18
+ namespace: 'acme',
19
+ taskQueue: 'main',
20
+ tls
21
+ }
22
+ }
23
+ }
24
+ ]
25
+ }).compile();
26
+
27
+ service = module.get(TemporalService);
28
+ });
29
+
30
+ it('builds a client for the configured namespace without connecting', () => {
31
+ expect(service.client.options.namespace).toBe('acme');
32
+ expect(service.taskQueue).toBe('main');
33
+ });
34
+
35
+ it('closes cleanly even if it never connected', async () => {
36
+ await expect(service.onApplicationShutdown()).resolves.toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe('temporalTls', () => {
41
+ const cert = '-----BEGIN CERTIFICATE-----\\nMIIB\\n-----END CERTIFICATE-----';
42
+ const key = '-----BEGIN PRIVATE KEY-----\\nMIGH\\n-----END PRIVATE KEY-----';
43
+
44
+ it('is plaintext when nothing is set', () => {
45
+ expect(temporalTls(tls)).toBeNull();
46
+ });
47
+
48
+ it('builds a client certificate pair, unescaping one-line PEM', () => {
49
+ const result = temporalTls({ ca: cert, cert, key });
50
+ expect(result?.clientCertPair?.crt.toString()).toBe(
51
+ '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
52
+ );
53
+ expect(result?.clientCertPair?.key.toString()).toContain('\nMIGH\n');
54
+ expect(result?.serverRootCACertificate?.toString()).toContain('\nMIIB\n');
55
+ });
56
+
57
+ it('omits the CA when only the client pair is set (publicly trusted server)', () => {
58
+ const result = temporalTls({ ca: '', cert, key });
59
+ expect(result?.serverRootCACertificate).toBeUndefined();
60
+ expect(result?.clientCertPair).toBeDefined();
61
+ });
62
+
63
+ it('rejects a certificate without its key', () => {
64
+ expect(() => temporalTls({ ...tls, cert })).toThrow(/set together/);
65
+ });
66
+ });
@@ -2,7 +2,6 @@ import { Global, Module } from '@nestjs/common';
2
2
  import { ConfigModule, ConfigService } from '@nestjs/config';
3
3
  import { TerminusModule } from '@nestjs/terminus'; // @feature observability
4
4
  import { LoggerModule } from './modules';
5
- import { MetricsModule } from './modules'; // @feature observability
6
5
  // The queue module itself is generic; here it is only needed to register the
7
6
  // `mail` queue, so it rides on the mail feature.
8
7
  import { QueueModule } from './modules'; // @feature mail
@@ -16,6 +15,7 @@ import { CacheService } from './services'; // @feature cache
16
15
  import { MailService } from './services'; // @feature mail
17
16
  import { RedisThrottlerStorage } from './services'; // @feature rate-limit
18
17
  import { FeatureFlagService } from './services'; // @feature feature-flags
18
+ import { TemporalService } from './services'; // @feature temporal
19
19
  import { HealthController } from './controllers'; // @feature observability
20
20
  import { MailProcessor } from './processors'; // @feature mail
21
21
  import { FeatureFlagGuard } from './guards'; // @feature feature-flags
@@ -37,7 +37,6 @@ import Config from './services/config.service';
37
37
  LoggerModule,
38
38
  // @feature:start observability
39
39
  TerminusModule,
40
- MetricsModule,
41
40
  // @feature:end
42
41
  QueueModule.register('mail') // @feature mail
43
42
  ],
@@ -56,6 +55,7 @@ import Config from './services/config.service';
56
55
  RedisThrottlerStorage, // @feature rate-limit
57
56
  FeatureFlagService, // @feature feature-flags
58
57
  FeatureFlagGuard, // @feature feature-flags
58
+ TemporalService, // @feature temporal
59
59
  PrismaHealthIndicator // @feature observability
60
60
  ],
61
61
  exports: [
@@ -66,7 +66,8 @@ import Config from './services/config.service';
66
66
  CacheService, // @feature cache
67
67
  MailService, // @feature mail
68
68
  RedisThrottlerStorage, // @feature rate-limit
69
- FeatureFlagService // @feature feature-flags
69
+ FeatureFlagService, // @feature feature-flags
70
+ TemporalService // @feature temporal
70
71
  ]
71
72
  })
72
73
  export class CommonModule {}
@@ -1,2 +1 @@
1
- export * from './metrics.controller'; // @feature observability
2
1
  export * from './health.controller'; // @feature observability
@@ -1,3 +1,2 @@
1
- export * from './metrics.module'; // @feature observability
2
1
  export * from './logger.module';
3
2
  export * from './queue.module'; // @feature queue
@@ -19,10 +19,7 @@ import {
19
19
  config: CustomConfigService,
20
20
  gen: GeneratorService
21
21
  ) => ({
22
- exclude: [
23
- { method: RequestMethod.ALL, path: 'health' },
24
- { method: RequestMethod.ALL, path: 'metrics' }
25
- ],
22
+ exclude: [{ method: RequestMethod.ALL, path: 'health' }],
26
23
  pinoHttp: {
27
24
  level: config.logger.level,
28
25
  transport:
@@ -41,8 +38,7 @@ import {
41
38
  '/dashboard',
42
39
  '/openapi',
43
40
  '/openapi-json',
44
- '/docs',
45
- '/metrics'
41
+ '/docs'
46
42
  ]; // Bull Board + API docs, all mounted outside Nest
47
43
  return muted.some((p) => path === p || path.startsWith(p + '/'));
48
44
  }
@@ -54,7 +54,7 @@ namespace Config {
54
54
  }
55
55
  // @feature:end
56
56
 
57
- // @feature:start openapi, queue, observability
57
+ // @feature:start openapi, queue
58
58
  export interface BasicAuth {
59
59
  user: string;
60
60
  password: string;
@@ -114,6 +114,17 @@ namespace Config {
114
114
  }
115
115
  // @feature:end
116
116
 
117
+ // @feature:start temporal
118
+ export interface Temporal {
119
+ address: string;
120
+ namespace: string;
121
+ /** Where workflows started from here are queued (the worker polls it). */
122
+ taskQueue: string;
123
+ /** PEM; all empty means plaintext (the local dev server). */
124
+ tls: { ca: string; cert: string; key: string };
125
+ }
126
+ // @feature:end
127
+
117
128
  export interface OAuthGoogle {
118
129
  clientId: string;
119
130
  clientSecret: string;
@@ -129,13 +140,14 @@ namespace Config {
129
140
  health: Health; // @feature observability
130
141
  database: Database;
131
142
  redis: Redis; // @feature cache
132
- basicAuth: BasicAuth; // @feature openapi, queue, observability
143
+ basicAuth: BasicAuth; // @feature openapi, queue
133
144
  betterAuth: BetterAuth;
134
145
  growthbook: Growthbook; // @feature feature-flags
135
146
  superuser: Superuser;
136
147
  smtp: SMTP; // @feature mail
137
148
  s3: S3; // @feature media
138
149
  rateLimit: RateLimit; // @feature rate-limit
150
+ temporal: Temporal; // @feature temporal
139
151
  oauth: OAuth;
140
152
  }
141
153
 
@@ -173,7 +185,7 @@ namespace Config {
173
185
  HEALTH_DISK_THRESHOLD: num(z.number().min(0).max(1)).default(0.8),
174
186
  HEALTH_DISK_PATH: str().default('/'),
175
187
  // @feature:end
176
- // @feature:start openapi, queue, observability
188
+ // @feature:start openapi, queue
177
189
  BASIC_AUTH_USER: str(),
178
190
  BASIC_AUTH_PASS: str(),
179
191
  // @feature:end
@@ -207,6 +219,14 @@ namespace Config {
207
219
  RATE_LIMIT_AUTH_MAX: num().default(100),
208
220
  RATE_LIMIT_IP_HEADERS: str().default('x-forwarded-for'),
209
221
  // @feature:end
222
+ // @feature:start temporal
223
+ TEMPORAL_ADDRESS: str().default('localhost:7233'),
224
+ TEMPORAL_NAMESPACE: str().default('default'),
225
+ TEMPORAL_TASK_QUEUE: str().default('main'),
226
+ TEMPORAL_TLS_CA: z.string().default(''),
227
+ TEMPORAL_TLS_CERT: z.string().default(''),
228
+ TEMPORAL_TLS_KEY: z.string().default(''),
229
+ // @feature:end
210
230
  // @feature:start feature-flags
211
231
  GROWTHBOOK_API_HOST: str(),
212
232
  GROWTHBOOK_CLIENT_KEY: str()
@@ -249,7 +269,7 @@ namespace Config {
249
269
  password: env.REDIS_PASSWORD || ''
250
270
  },
251
271
  // @feature:end
252
- // @feature:start openapi, queue, observability
272
+ // @feature:start openapi, queue
253
273
  basicAuth: {
254
274
  user: env.BASIC_AUTH_USER || 'user',
255
275
  password: env.BASIC_AUTH_PASS || 'password'
@@ -305,6 +325,18 @@ namespace Config {
305
325
  .filter(Boolean)
306
326
  },
307
327
  // @feature:end
328
+ // @feature:start temporal
329
+ temporal: {
330
+ address: env.TEMPORAL_ADDRESS || 'localhost:7233',
331
+ namespace: env.TEMPORAL_NAMESPACE || 'default',
332
+ taskQueue: env.TEMPORAL_TASK_QUEUE || 'main',
333
+ tls: {
334
+ ca: env.TEMPORAL_TLS_CA || '',
335
+ cert: env.TEMPORAL_TLS_CERT || '',
336
+ key: env.TEMPORAL_TLS_KEY || ''
337
+ }
338
+ },
339
+ // @feature:end
308
340
  oauth: {
309
341
  google: {
310
342
  clientId: env.OAUTH_GOOGLE_CLIENT_ID || '',
@@ -342,7 +374,7 @@ export class CustomConfigService {
342
374
  }
343
375
  // @feature:end
344
376
 
345
- // @feature:start openapi, queue, observability
377
+ // @feature:start openapi, queue
346
378
  get basicAuth() {
347
379
  return this.config.get('basicAuth', { infer: true });
348
380
  }
@@ -380,6 +412,12 @@ export class CustomConfigService {
380
412
  }
381
413
  // @feature:end
382
414
 
415
+ // @feature:start temporal
416
+ get temporal() {
417
+ return this.config.get('temporal', { infer: true });
418
+ }
419
+ // @feature:end
420
+
383
421
  get oauth() {
384
422
  return this.config.get('oauth', { infer: true });
385
423
  }
@@ -8,3 +8,4 @@ export * from './mail.service'; // @feature mail
8
8
  export * from './throttler-storage.service'; // @feature rate-limit
9
9
  export * from './auth-rate-limit.storage'; // @feature rate-limit
10
10
  export * from './feature-flag.service'; // @feature feature-flags
11
+ export * from './temporal.service'; // @feature temporal
@@ -0,0 +1,66 @@
1
+ import { Injectable, OnApplicationShutdown } from '@nestjs/common';
2
+ import { Client, Connection, type TLSConfig } from '@temporalio/client';
3
+ import { CustomConfigService } from './config.service';
4
+ import type Config from './config.service';
5
+
6
+ /**
7
+ * A Temporal client for starting and querying workflows; a worker (node-worker
8
+ * or py-worker) polling the same namespace and task queue runs them.
9
+ *
10
+ * The connection is lazy: nothing is dialled until the first call, so the API
11
+ * boots, and serves everything else, while Temporal is unreachable.
12
+ *
13
+ * ```ts
14
+ * const handle = await this.temporal.client.workflow.start('example', {
15
+ * taskQueue: this.temporal.taskQueue,
16
+ * workflowId: `example-${user.id}`, // one run per id: a natural dedupe key
17
+ * args: [{ name: user.name }]
18
+ * });
19
+ * const result = await handle.result(); // or return handle.workflowId
20
+ * ```
21
+ *
22
+ * Reach for Temporal over the BullMQ queue when the work spans many steps,
23
+ * waits (for minutes to months, or on a signal), or must resume exactly where
24
+ * it stopped after a crash. A fire-and-forget job is still a queue job.
25
+ */
26
+ @Injectable()
27
+ export class TemporalService implements OnApplicationShutdown {
28
+ readonly client: Client;
29
+ /** The default task queue, from TEMPORAL_TASK_QUEUE. */
30
+ readonly taskQueue: string;
31
+ private readonly connection: Connection;
32
+
33
+ constructor(config: CustomConfigService) {
34
+ const { address, namespace, taskQueue, tls } = config.temporal;
35
+ this.connection = Connection.lazy({ address, tls: temporalTls(tls) });
36
+ this.client = new Client({ connection: this.connection, namespace });
37
+ this.taskQueue = taskQueue;
38
+ }
39
+
40
+ async onApplicationShutdown() {
41
+ await this.connection.close();
42
+ }
43
+ }
44
+
45
+ /**
46
+ * mTLS settings from PEM strings; none at all means plaintext (`null`).
47
+ * Escaped `\n` are unescaped, so a certificate fits on one line of an env
48
+ * file.
49
+ */
50
+ export function temporalTls({
51
+ ca,
52
+ cert,
53
+ key
54
+ }: Config.Temporal['tls']): TLSConfig | null {
55
+ if (!ca && !cert && !key) return null;
56
+ if (!cert || !key) {
57
+ throw new Error(
58
+ 'TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must be set together'
59
+ );
60
+ }
61
+ const pem = (value: string) => Buffer.from(value.replace(/\\n/g, '\n'));
62
+ return {
63
+ ...(ca && { serverRootCACertificate: pem(ca) }),
64
+ clientCertPair: { crt: pem(cert), key: pem(key) }
65
+ };
66
+ }