@aurostack/stacks 0.1.0 → 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.
- package/README.md +84 -0
- package/cli/src/generate.mjs +13 -1
- package/cli/src/hooks.mjs +10 -3
- package/cli/stack.mjs +3 -0
- package/package.json +4 -1
- package/templates/nest-api/files/.env.example +29 -2
- package/templates/nest-api/files/.env.test.example +1 -1
- package/templates/nest-api/files/compose.yml +16 -0
- package/templates/nest-api/files/package.json +19 -4
- package/templates/nest-api/files/src/app.module.ts +9 -1
- package/templates/nest-api/files/src/app.setup.ts +5 -6
- package/templates/nest-api/files/src/common/__tests__/temporal.service.spec.ts +66 -0
- package/templates/nest-api/files/src/common/common.module.ts +4 -3
- package/templates/nest-api/files/src/common/controllers/index.ts +0 -1
- package/templates/nest-api/files/src/common/modules/index.ts +0 -1
- package/templates/nest-api/files/src/common/modules/logger.module.ts +2 -6
- package/templates/nest-api/files/src/common/services/config.service.ts +43 -5
- package/templates/nest-api/files/src/common/services/index.ts +1 -0
- package/templates/nest-api/files/src/common/services/temporal.service.ts +66 -0
- package/templates/nest-api/files/src/instrumentation.ts +117 -0
- package/templates/nest-api/files/src/main.ts +3 -1
- package/templates/nest-api/files/src/telemetry/__tests__/config.spec.ts +67 -0
- package/templates/nest-api/files/src/telemetry/config.ts +62 -0
- package/templates/nest-api/template.json +37 -11
- package/templates/node-worker/files/.env.example +27 -0
- package/templates/node-worker/files/.prettierignore +2 -0
- package/templates/node-worker/files/Dockerfile +25 -2
- package/templates/node-worker/files/Dockerfile.dev +25 -2
- package/templates/node-worker/files/ecosystem.config.cjs +2 -0
- package/templates/node-worker/files/package.json +23 -4
- package/templates/node-worker/files/src/config.ts +23 -0
- package/templates/node-worker/files/src/index.ts +13 -0
- package/templates/node-worker/files/src/instrumentation.ts +110 -0
- package/templates/node-worker/files/src/telemetry/config.ts +61 -0
- package/templates/node-worker/files/src/temporal/activities/index.ts +14 -0
- package/templates/node-worker/files/src/temporal/worker.ts +84 -0
- package/templates/node-worker/files/src/temporal/workflows/index.ts +23 -0
- package/templates/node-worker/files/src/utils/worker.ts +9 -1
- package/templates/node-worker/template.json +44 -2
- package/templates/py-worker/files/.env.example +28 -0
- package/templates/py-worker/files/config.py +32 -1
- package/templates/py-worker/files/requirements.txt +16 -0
- package/templates/py-worker/files/temporal/__init__.py +0 -0
- package/templates/py-worker/files/temporal/activities.py +13 -0
- package/templates/py-worker/files/temporal/workflows.py +38 -0
- package/templates/py-worker/files/utils/telemetry.py +154 -0
- package/templates/py-worker/files/utils/temporal.py +55 -0
- package/templates/py-worker/files/utils/worker.py +12 -1
- package/templates/py-worker/files/worker.py +16 -0
- package/templates/py-worker/template.json +36 -0
- package/templates/react-app/derive.sh +2 -1
- package/templates/react-app/files/.env.example +11 -0
- package/templates/react-app/files/package.json +2 -0
- package/templates/react-app/files/src/lib/env.ts +10 -0
- package/templates/react-app/files/src/main.tsx +4 -0
- package/templates/react-app/files/src/shared/auth/provider.tsx +2 -0
- package/templates/react-app/files/src/shared/layouts/error-boundary.tsx +2 -0
- package/templates/react-app/files/src/shared/telemetry/index.ts +80 -0
- package/templates/react-app/template.json +15 -1
- package/templates/react-monorepo/files/apps/admin/.env.example +11 -0
- package/templates/react-monorepo/files/apps/admin/package.json +1 -0
- package/templates/react-monorepo/files/apps/admin/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/auth/.env.example +11 -0
- package/templates/react-monorepo/files/apps/auth/package.json +1 -0
- package/templates/react-monorepo/files/apps/auth/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/auth/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/client/.env.example +11 -0
- package/templates/react-monorepo/files/apps/client/package.json +1 -0
- package/templates/react-monorepo/files/apps/client/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/client/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/landing/.env.example +11 -0
- package/templates/react-monorepo/files/apps/landing/package.json +1 -0
- package/templates/react-monorepo/files/apps/landing/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/landing/src/main.tsx +4 -0
- package/templates/react-monorepo/files/packages/auth/package.json +1 -0
- package/templates/react-monorepo/files/packages/auth/src/provider.tsx +2 -0
- package/templates/react-monorepo/files/packages/layouts/package.json +1 -0
- package/templates/react-monorepo/files/packages/layouts/src/error-boundary.tsx +2 -0
- package/templates/react-monorepo/files/packages/telemetry/eslint.config.mjs +3 -0
- package/templates/react-monorepo/files/packages/telemetry/package.json +20 -0
- package/templates/react-monorepo/files/packages/telemetry/src/index.ts +80 -0
- package/templates/react-monorepo/files/packages/telemetry/tsconfig.json +4 -0
- package/templates/react-monorepo/template.json +47 -1
- package/templates/nest-api/files/src/common/controllers/metrics.controller.ts +0 -21
- package/templates/nest-api/files/src/common/interceptors/index.ts +0 -1
- package/templates/nest-api/files/src/common/interceptors/metrics.interceptor.ts +0 -37
- package/templates/nest-api/files/src/common/modules/metrics.module.ts +0 -28
package/README.md
CHANGED
|
@@ -63,6 +63,31 @@ claude --plugin-dir ./stacks # try skill edits without reinstalling
|
|
|
63
63
|
`package.json` holds the version; the plugin manifests repeat it, and
|
|
64
64
|
`stack doctor` fails if they drift.
|
|
65
65
|
|
|
66
|
+
### Releasing
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm version patch # or minor / major: bumps package.json and the plugin
|
|
70
|
+
# manifests in one commit, tagged vX.Y.Z
|
|
71
|
+
git push --follow-tags
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The tag runs the full CI (`.github/workflows/ci.yml`: every template's key
|
|
75
|
+
variants generated with `--strict`, then typechecked, linted, built and tested,
|
|
76
|
+
plus the Docker images), then `release.yml` stages the version on npm and
|
|
77
|
+
creates the GitHub release. The Claude Code plugin updates from the same
|
|
78
|
+
commit.
|
|
79
|
+
|
|
80
|
+
A staged version goes live only once a maintainer approves it with 2FA:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npm stage list @aurostack/stacks
|
|
84
|
+
npm stage approve <stage-id> # or npmjs.com → the package → Staged Packages
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
CI authenticates as an npm **trusted publisher** (no token): repository
|
|
88
|
+
`aurostack-org/stacks`, workflow `release.yml`, environment `npm`, allowed to
|
|
89
|
+
stage only. The `npm` GitHub environment is restricted to `v*` tags.
|
|
90
|
+
|
|
66
91
|
## The templates
|
|
67
92
|
|
|
68
93
|
| Template | What you get |
|
|
@@ -90,6 +115,65 @@ The shared code is literally the same files: `templates/react-app/derive.sh`
|
|
|
90
115
|
re-flattens `packages/*` into `src/shared/*` and rewrites the import specifiers,
|
|
91
116
|
so a fix in one lands in the other. See that template's README.
|
|
92
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
|
+
|
|
93
177
|
## How it works
|
|
94
178
|
|
|
95
179
|
Templates are **subtractive**. `templates/<name>/files` is a complete, runnable
|
package/cli/src/generate.mjs
CHANGED
|
@@ -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
|
-
|
|
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/cli/src/hooks.mjs
CHANGED
|
@@ -5,14 +5,21 @@
|
|
|
5
5
|
* expression, so `yarn prisma generate` only runs for stacks that kept Prisma.
|
|
6
6
|
* A non-optional hook that fails aborts with a non-zero exit; an optional one
|
|
7
7
|
* warns and continues, because a missing `yarn` on the machine should not throw
|
|
8
|
-
* away an otherwise-correct scaffold.
|
|
8
|
+
* away an otherwise-correct scaffold. `strict` (`stack new --strict`, used in
|
|
9
|
+
* CI) makes every failure fatal.
|
|
9
10
|
*/
|
|
10
11
|
import { spawnSync } from 'node:child_process';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
import { evalExpr } from './strip.mjs';
|
|
13
14
|
import { interpolate } from './tokens.mjs';
|
|
14
15
|
|
|
15
|
-
export function runHooks(
|
|
16
|
+
export function runHooks(
|
|
17
|
+
manifest,
|
|
18
|
+
target,
|
|
19
|
+
enabled,
|
|
20
|
+
ctx,
|
|
21
|
+
{ skip = false, strict = false, log }
|
|
22
|
+
) {
|
|
16
23
|
const hooks = manifest.hooks || [];
|
|
17
24
|
const results = [];
|
|
18
25
|
|
|
@@ -41,7 +48,7 @@ export function runHooks(manifest, target, enabled, ctx, { skip = false, log })
|
|
|
41
48
|
continue;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
if (hook.optional) {
|
|
51
|
+
if (hook.optional && !strict) {
|
|
45
52
|
log(` warning "${label}" failed (exit ${res.status}) — continuing`);
|
|
46
53
|
results.push({ command, status: 'failed-optional' });
|
|
47
54
|
continue;
|
package/cli/stack.mjs
CHANGED
|
@@ -82,6 +82,7 @@ ${c.bold('new options')}
|
|
|
82
82
|
--without a,b Disable default features (repeatable)
|
|
83
83
|
--all Enable every optional feature
|
|
84
84
|
--no-hooks Skip install / prisma generate / git init
|
|
85
|
+
--strict Fail on any hook failure (for CI)
|
|
85
86
|
--force Write into a non-empty directory
|
|
86
87
|
--dry-run Print what would happen, write nothing
|
|
87
88
|
|
|
@@ -207,6 +208,7 @@ function cmdNew(args, values) {
|
|
|
207
208
|
log(c.bold(' Post-generation'));
|
|
208
209
|
runHooks(manifest, target, enabled, ctx, {
|
|
209
210
|
skip: values['no-hooks'],
|
|
211
|
+
strict: Boolean(values.strict),
|
|
210
212
|
log
|
|
211
213
|
});
|
|
212
214
|
log('');
|
|
@@ -627,6 +629,7 @@ function main() {
|
|
|
627
629
|
prune: { type: 'boolean' },
|
|
628
630
|
'dry-run': { type: 'boolean' },
|
|
629
631
|
'no-hooks': { type: 'boolean' },
|
|
632
|
+
strict: { type: 'boolean' },
|
|
630
633
|
help: { type: 'boolean', short: 'h' },
|
|
631
634
|
version: { type: 'boolean', short: 'v' }
|
|
632
635
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aurostack/stacks",
|
|
3
|
-
"version": "0.
|
|
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,9 @@
|
|
|
31
31
|
"engines": {
|
|
32
32
|
"node": ">=20.19"
|
|
33
33
|
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"version": "node scripts/sync-version.mjs && git add .claude-plugin"
|
|
36
|
+
},
|
|
34
37
|
"publishConfig": {
|
|
35
38
|
"access": "public"
|
|
36
39
|
}
|
|
@@ -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
|
|
44
|
-
# Guards /docs, /openapi-json
|
|
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.
|
|
@@ -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
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"build": "nest build",
|
|
12
12
|
"gen": "nest generate",
|
|
13
13
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
|
14
|
-
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
14
|
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" --no-error-on-unmatched-pattern",
|
|
15
15
|
"email:dev": "email dev -p 5003",
|
|
16
16
|
"secrets": "ts-node .bin/secrets",
|
|
17
17
|
"db:generate": "ts-node ./.bin/db.ts",
|
|
@@ -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": "^
|
|
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
|
|
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
|
|
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
|
|
124
|
-
* dashboard
|
|
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'
|
|
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 {}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|