@podosoft/podokit 0.15.0 → 0.16.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 +17 -0
- package/dist/add.d.ts +4 -0
- package/dist/add.js +59 -1
- package/dist/create.js +8 -1
- package/dist/deploy-profile.d.ts +92 -0
- package/dist/deploy-profile.js +603 -0
- package/dist/deploy-render.d.ts +18 -0
- package/dist/deploy-render.js +651 -0
- package/dist/deploy.d.ts +65 -0
- package/dist/deploy.js +851 -0
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +29 -5
- package/dist/index.d.ts +8 -0
- package/dist/index.js +147 -1
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +18 -1
- package/dist/lockfile.js +1 -0
- package/dist/remove.js +7 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/README.md +19 -8
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
- package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
- package/dist/templates/modules/bullmq/module.manifest.json +4 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
- package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
- package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
- package/dist/templates/modules/redis/module.manifest.json +16 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
- package/dist/templates/modules/sse/module.manifest.json +16 -3
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/README.md +12 -8
- package/dist/templates/todo/apps/api/Dockerfile +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/todo/apps/web/Dockerfile +2 -2
- package/dist/templates/todo/compose.dev.yaml +3 -5
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/todo/dot-env.example +6 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
- package/package.json +2 -2
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
|
|
2
2
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
3
3
|
import { DataSource } from "typeorm";
|
|
4
|
+
import { ReadinessService, type ReadinessStatus } from "./readiness.service";
|
|
5
|
+
|
|
6
|
+
interface ReadinessResponse {
|
|
7
|
+
status: "ready";
|
|
8
|
+
db: "up";
|
|
9
|
+
checks: Record<string, ReadinessStatus>;
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
@Controller("health")
|
|
6
13
|
export class HealthController {
|
|
7
|
-
constructor(
|
|
14
|
+
constructor(
|
|
15
|
+
@InjectDataSource() private readonly dataSource: DataSource,
|
|
16
|
+
private readonly readinessService: ReadinessService,
|
|
17
|
+
) {}
|
|
8
18
|
|
|
9
19
|
@Get()
|
|
10
20
|
liveness(): { status: string; uptime: number; timestamp: string } {
|
|
@@ -12,12 +22,17 @@ export class HealthController {
|
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
@Get("ready")
|
|
15
|
-
async readiness(): Promise<
|
|
25
|
+
async readiness(): Promise<ReadinessResponse> {
|
|
26
|
+
let db: ReadinessStatus = "up";
|
|
16
27
|
try {
|
|
17
28
|
await this.dataSource.query("SELECT 1");
|
|
18
|
-
return { status: "ready", db: "up" };
|
|
19
29
|
} catch {
|
|
20
|
-
|
|
30
|
+
db = "down";
|
|
31
|
+
}
|
|
32
|
+
const checks = await this.readinessService.run();
|
|
33
|
+
if (db === "down" || Object.values(checks).includes("down")) {
|
|
34
|
+
throw new ServiceUnavailableException({ status: "degraded", db, checks });
|
|
21
35
|
}
|
|
36
|
+
return { status: "ready", db: "up", checks };
|
|
22
37
|
}
|
|
23
38
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { Module } from "@nestjs/common";
|
|
1
|
+
import { Global, Module } from "@nestjs/common";
|
|
2
2
|
import { HealthController } from "./health.controller";
|
|
3
|
+
import { ReadinessService } from "./readiness.service";
|
|
3
4
|
|
|
5
|
+
@Global()
|
|
4
6
|
@Module({
|
|
5
7
|
controllers: [HealthController],
|
|
8
|
+
providers: [ReadinessService],
|
|
9
|
+
exports: [ReadinessService],
|
|
6
10
|
})
|
|
7
11
|
export class HealthModule {}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { ReadinessService } from "./readiness.service";
|
|
3
|
+
|
|
4
|
+
describe("ReadinessService", () => {
|
|
5
|
+
it("runs registered checks and removes them with the returned callback", async () => {
|
|
6
|
+
const service = new ReadinessService();
|
|
7
|
+
const unregister = service.register("cache", () => Promise.resolve());
|
|
8
|
+
service.register("events", () => Promise.reject(new Error("unavailable")));
|
|
9
|
+
|
|
10
|
+
await expect(service.run()).resolves.toEqual({ cache: "up", events: "down" });
|
|
11
|
+
unregister();
|
|
12
|
+
await expect(service.run()).resolves.toEqual({ events: "down" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects duplicate names", () => {
|
|
16
|
+
const service = new ReadinessService();
|
|
17
|
+
service.register("cache", () => Promise.resolve());
|
|
18
|
+
expect(() => service.register("cache", () => Promise.resolve())).toThrow(
|
|
19
|
+
'Readiness check "cache" is already registered',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
|
|
3
|
+
export type ReadinessStatus = "up" | "down";
|
|
4
|
+
export type ReadinessCheck = () => Promise<void>;
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class ReadinessService {
|
|
8
|
+
private readonly checks = new Map<string, ReadinessCheck>();
|
|
9
|
+
|
|
10
|
+
register(name: string, check: ReadinessCheck): () => void {
|
|
11
|
+
if (this.checks.has(name)) {
|
|
12
|
+
throw new Error(`Readiness check "${name}" is already registered`);
|
|
13
|
+
}
|
|
14
|
+
this.checks.set(name, check);
|
|
15
|
+
return () => {
|
|
16
|
+
this.checks.delete(name);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async run(): Promise<Record<string, ReadinessStatus>> {
|
|
21
|
+
const results = await Promise.all(
|
|
22
|
+
[...this.checks].map(async ([name, check]): Promise<[string, ReadinessStatus]> => {
|
|
23
|
+
try {
|
|
24
|
+
await check();
|
|
25
|
+
return [name, "up"];
|
|
26
|
+
} catch {
|
|
27
|
+
return [name, "down"];
|
|
28
|
+
}
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
return Object.fromEntries(results);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -19,11 +19,11 @@ ENV NODE_ENV=production
|
|
|
19
19
|
# Keep the adapter-node proxy above the 2 MiB application upload policy so the
|
|
20
20
|
# API can return stable validation codes after accounting for multipart framing.
|
|
21
21
|
ENV BODY_SIZE_LIMIT=3M
|
|
22
|
-
RUN addgroup -S app && adduser -S
|
|
22
|
+
RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
|
|
23
23
|
COPY --from=build /app/node_modules ./node_modules
|
|
24
24
|
COPY --from=deps /app/apps/web ./apps/web
|
|
25
25
|
COPY --from=build /app/apps/web/build ./apps/web/build
|
|
26
26
|
WORKDIR /app/apps/web
|
|
27
|
-
USER
|
|
27
|
+
USER 100:101
|
|
28
28
|
EXPOSE 3000
|
|
29
29
|
CMD ["node", "build"]
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
# Containerized development environment.
|
|
2
2
|
#
|
|
3
|
-
# podo dev watch
|
|
4
|
-
# podo dev watch --profile cache --profile storage \
|
|
5
|
-
# --profile queue # with modules
|
|
3
|
+
# podo dev watch # installed modules activate their required profiles
|
|
6
4
|
#
|
|
7
5
|
# A user-level PodoKit gateway owns loopback :80 and routes each *.localhost host
|
|
8
6
|
# to this stack. The legacy per-project Traefik service is opt-in only.
|
|
9
7
|
# api/postgres/redis/minio have NO published ports — they talk by service name
|
|
10
8
|
# on the internal network, so this stack never collides with other projects.
|
|
11
9
|
#
|
|
12
|
-
# Core services (traefik, web, api, postgres) always run.
|
|
13
|
-
#
|
|
10
|
+
# Core services (traefik, web, api, postgres) always run. PodoKit activates
|
|
11
|
+
# module-specific profiles from .podokit/manifest.json:
|
|
14
12
|
# --profile cache redis (redis / rate-limit / bullmq modules)
|
|
15
13
|
# --profile storage minio (object-storage-s3 / file-upload modules)
|
|
16
14
|
# --profile queue worker (bullmq / job-progress modules)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-deploy
|
|
3
|
+
description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit deployment profile and confirmation hash. Use when an agent needs to initialize a .podokit/deploy profile, diagnose a target cluster, preview a release, perform an approved deployment, inspect rollout state, verify public endpoints, or prepare an application rollback.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deploy a PodoKit application
|
|
7
|
+
|
|
8
|
+
1. Run `npx @podosoft/podokit status`, `npx @podosoft/podokit diff`, and `npx @podosoft/podokit doctor`. Stop if managed-file conflicts or unsupported framework versions could make the image unreliable.
|
|
9
|
+
2. Read [references/profile.md](references/profile.md) before creating or changing a deployment profile.
|
|
10
|
+
3. Run `npx @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing Secret values.
|
|
11
|
+
4. Run `npx @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
|
|
12
|
+
5. Show the user the context, cluster fingerprint result, namespace, Helm release, exact images, current revision and status, actions, migration warning, and plan hash.
|
|
13
|
+
6. Do not apply until the user explicitly approves that exact plan.
|
|
14
|
+
7. Apply with `npx @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
|
|
15
|
+
8. Run `npx @podosoft/podokit deploy status --profile <name> --json` and `npx @podosoft/podokit deploy verify --profile <name> --json`.
|
|
16
|
+
9. Report the exact images, Helm revision, ready replicas, restart totals, and verification result.
|
|
17
|
+
|
|
18
|
+
For rollback, read [references/rollback.md](references/rollback.md), preview the target revision without `--confirm`, show the new plan hash, and obtain explicit approval.
|
|
19
|
+
|
|
20
|
+
Never create or rotate cluster credentials, registry credentials, DNS, virtual machines, Kubernetes Secrets, or backup repositories. Never put a password, token, private key, connection URL containing credentials, or Secret value in a profile, command argument, log, or report.
|
package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Deployment profile
|
|
2
|
+
|
|
3
|
+
The profile at `.podokit/deploy/<name>.json` is application-owned and contains no Secret values.
|
|
4
|
+
|
|
5
|
+
Initialize it with an explicit kubeconfig context:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @podosoft/podokit deploy init --profile production --context production --host app.example.com
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Review these fields before planning:
|
|
12
|
+
|
|
13
|
+
- `target`: context, cluster fingerprint, namespace, release
|
|
14
|
+
- `release`: API/web repositories and stable SemVer tag pattern
|
|
15
|
+
- `exposure`: Ingress or NodePort; Ingress routes every public path to web
|
|
16
|
+
- `workloads`: API/web replicas and resources plus an optional worker
|
|
17
|
+
- `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage
|
|
18
|
+
- `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
|
|
19
|
+
- `runtimeConfig`: non-sensitive environment values only
|
|
20
|
+
- `verification`: HTTPS origin and checks with exact statuses and optional JSON fields
|
|
21
|
+
|
|
22
|
+
The cluster namespace and referenced Secrets must already exist. PodoKit asks `kubectl` to output only Secret key names and never prints or stores their values; the Kubernetes client still materializes the Secret while evaluating that output.
|
|
23
|
+
|
|
24
|
+
Required keys:
|
|
25
|
+
|
|
26
|
+
| Secret | Required keys |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| API | Profile-declared keys plus enabled dependency connection keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `REDIS_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
|
29
|
+
| web | Only explicitly declared web keys; omitted by default |
|
|
30
|
+
| PostgreSQL dependency | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` |
|
|
31
|
+
| Redis dependency | `REDIS_PASSWORD` |
|
|
32
|
+
| Object storage dependency | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
|
33
|
+
| image pull | A `kubernetes.io/dockerconfigjson` Secret |
|
|
34
|
+
|
|
35
|
+
Use `inCluster` when the generated Helm release should manage a dependency, `external` when application configuration points to an existing service, and `disabled` only when the application does not require it.
|
|
36
|
+
|
|
37
|
+
Use the same stable SemVer tag for API and web. `latest`, branch tags, and empty tags are rejected. Planning resolves every deployed tag to an immutable registry digest through Docker Buildx.
|
package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Application rollback
|
|
2
|
+
|
|
3
|
+
Inspect status and Helm history before choosing a revision:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @podosoft/podokit deploy status --profile production --json
|
|
7
|
+
helm history <release> --namespace <namespace> --kube-context <context>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Preview a rollback:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx @podosoft/podokit deploy rollback --profile production --revision <number>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Show the target revision, current revision, context, namespace, warning, and generated plan hash. Obtain explicit approval, then pass that exact hash with `--confirm`.
|
|
17
|
+
|
|
18
|
+
Rollback changes only the application Helm release. It does not reverse database migrations, change the dependency release, delete PersistentVolumeClaims, or delete Secrets. If the old application is incompatible with the current database schema, do not roll back; prepare a forward fix instead.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-deploy
|
|
3
|
+
description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit profile and confirmation hash. Use for Kubernetes deployment and rollback work in a generated PodoKit project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PodoKit deployment
|
|
7
|
+
|
|
8
|
+
Read and follow the canonical skill at `../../../.agents/skills/podokit-deploy/SKILL.md`.
|
|
@@ -21,3 +21,9 @@ BODY_SIZE_LIMIT=3M
|
|
|
21
21
|
# job-progress, sse). Start it with: docker compose --profile cache up
|
|
22
22
|
REDIS_HOST=localhost
|
|
23
23
|
REDIS_PORT=6379
|
|
24
|
+
# REDIS_URL takes precedence over the individual settings below.
|
|
25
|
+
# REDIS_URL=rediss://user:password@cache.example.com:6380/0
|
|
26
|
+
REDIS_DB=0
|
|
27
|
+
# REDIS_USERNAME=
|
|
28
|
+
# REDIS_PASSWORD=
|
|
29
|
+
REDIS_TLS=false
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
import { site, type SiteSettings } from "$lib/site.svelte";
|
|
5
5
|
import { applyTheme, themeConfigFromSettings } from "$lib/site/apply-theme";
|
|
6
6
|
import SessionIdleTimeout from "$lib/components/session-idle-timeout.svelte";
|
|
7
|
+
// Optional modules add global runtime components here without taking
|
|
8
|
+
// ownership of the application's root layout.
|
|
9
|
+
// podokit:begin:site-runtime-imports
|
|
10
|
+
// podokit:end:site-runtime-imports
|
|
7
11
|
|
|
8
12
|
const loadedSite = $derived(
|
|
9
13
|
(page.data as { site?: Partial<SiteSettings> | null }).site,
|
|
@@ -42,3 +46,5 @@
|
|
|
42
46
|
|
|
43
47
|
<ModeWatcher />
|
|
44
48
|
<SessionIdleTimeout timeoutMinutes={sessionIdleTimeoutMinutes} />
|
|
49
|
+
<!-- podokit:begin:site-runtime-components -->
|
|
50
|
+
<!-- podokit:end:site-runtime-components -->
|
package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { ApiKeyVerifier } from "./api-key-verifier";
|
|
3
|
+
|
|
4
|
+
const originalApiKeys = process.env.API_KEYS;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
if (originalApiKeys === undefined) {
|
|
8
|
+
delete process.env.API_KEYS;
|
|
9
|
+
} else {
|
|
10
|
+
process.env.API_KEYS = originalApiKeys;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("ApiKeyVerifier", () => {
|
|
15
|
+
it("accepts trimmed configured keys and rejects other values", () => {
|
|
16
|
+
process.env.API_KEYS = " first-service-key, second-service-key , ";
|
|
17
|
+
const verifier = new ApiKeyVerifier();
|
|
18
|
+
|
|
19
|
+
expect(verifier.isValid("first-service-key")).toBe(true);
|
|
20
|
+
expect(verifier.isValid("second-service-key")).toBe(true);
|
|
21
|
+
expect(verifier.isValid("different-service-key")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects keys when the allowlist is empty", () => {
|
|
25
|
+
delete process.env.API_KEYS;
|
|
26
|
+
|
|
27
|
+
expect(new ApiKeyVerifier().isValid("any-service-key")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("compares fixed-length digests for differently sized input", () => {
|
|
31
|
+
process.env.API_KEYS = "short";
|
|
32
|
+
const verifier = new ApiKeyVerifier();
|
|
33
|
+
|
|
34
|
+
expect(verifier.isValid("a-much-longer-unconfigured-service-key")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
function digest(value: string): Buffer {
|
|
5
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class ApiKeyVerifier {
|
|
10
|
+
private readonly keyDigests = (process.env.API_KEYS ?? "")
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((key) => key.trim())
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map(digest);
|
|
15
|
+
|
|
16
|
+
isValid(provided: string): boolean {
|
|
17
|
+
const supplied = digest(provided);
|
|
18
|
+
return this.keyDigests.some((expected) => timingSafeEqual(expected, supplied));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import { type ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
3
|
+
import type { ApiKeyVerifier } from "./api-key-verifier";
|
|
4
|
+
import { ApiKeyGuard } from "./api-key.guard";
|
|
5
|
+
|
|
6
|
+
function requestContext(apiKey?: string): ExecutionContext {
|
|
7
|
+
return {
|
|
8
|
+
switchToHttp: () => ({
|
|
9
|
+
getRequest: () => ({
|
|
10
|
+
header: (name: string) => (name === "x-api-key" ? apiKey : undefined),
|
|
11
|
+
}),
|
|
12
|
+
}),
|
|
13
|
+
} as unknown as ExecutionContext;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("ApiKeyGuard", () => {
|
|
17
|
+
it("delegates a supplied key to the shared verifier", () => {
|
|
18
|
+
const verifier = {
|
|
19
|
+
isValid: jest.fn<(provided: string) => boolean>(() => true),
|
|
20
|
+
};
|
|
21
|
+
const guard = new ApiKeyGuard(verifier as unknown as ApiKeyVerifier);
|
|
22
|
+
|
|
23
|
+
expect(guard.canActivate(requestContext("service-key"))).toBe(true);
|
|
24
|
+
expect(verifier.isValid).toHaveBeenCalledWith("service-key");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects a missing key without invoking the verifier", () => {
|
|
28
|
+
const verifier = {
|
|
29
|
+
isValid: jest.fn<(provided: string) => boolean>(() => true),
|
|
30
|
+
};
|
|
31
|
+
const guard = new ApiKeyGuard(verifier as unknown as ApiKeyVerifier);
|
|
32
|
+
|
|
33
|
+
expect(() => guard.canActivate(requestContext())).toThrow(
|
|
34
|
+
new UnauthorizedException("Missing X-API-Key"),
|
|
35
|
+
);
|
|
36
|
+
expect(verifier.isValid).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a key that the verifier does not recognize", () => {
|
|
40
|
+
const verifier = {
|
|
41
|
+
isValid: jest.fn<(provided: string) => boolean>(() => false),
|
|
42
|
+
};
|
|
43
|
+
const guard = new ApiKeyGuard(verifier as unknown as ApiKeyVerifier);
|
|
44
|
+
|
|
45
|
+
expect(() => guard.canActivate(requestContext("invalid-key"))).toThrow(
|
|
46
|
+
new UnauthorizedException("Invalid API key"),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
3
2
|
import type { Request } from "express";
|
|
3
|
+
import { ApiKeyVerifier } from "./api-key-verifier";
|
|
4
4
|
|
|
5
5
|
// Validates the X-API-Key header against the API_KEYS allowlist.
|
|
6
6
|
@Injectable()
|
|
7
7
|
export class ApiKeyGuard implements CanActivate {
|
|
8
|
-
private readonly
|
|
9
|
-
.split(",")
|
|
10
|
-
.map((key) => key.trim())
|
|
11
|
-
.filter(Boolean);
|
|
8
|
+
constructor(private readonly verifier: ApiKeyVerifier) {}
|
|
12
9
|
|
|
13
10
|
canActivate(context: ExecutionContext): boolean {
|
|
14
11
|
const request = context.switchToHttp().getRequest<Request>();
|
|
@@ -16,12 +13,7 @@ export class ApiKeyGuard implements CanActivate {
|
|
|
16
13
|
if (!provided) {
|
|
17
14
|
throw new UnauthorizedException("Missing X-API-Key");
|
|
18
15
|
}
|
|
19
|
-
|
|
20
|
-
const valid = this.keys.some((key) => {
|
|
21
|
-
const expected = Buffer.from(key);
|
|
22
|
-
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
|
|
23
|
-
});
|
|
24
|
-
if (!valid) {
|
|
16
|
+
if (!this.verifier.isValid(provided)) {
|
|
25
17
|
throw new UnauthorizedException("Invalid API key");
|
|
26
18
|
}
|
|
27
19
|
return true;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Module } from "@nestjs/common";
|
|
2
2
|
import { MachineController } from "../machine/machine.controller";
|
|
3
3
|
import { ApiKeyGuard } from "./api-key.guard";
|
|
4
|
+
import { ApiKeyVerifier } from "./api-key-verifier";
|
|
4
5
|
|
|
5
6
|
@Module({
|
|
6
7
|
controllers: [MachineController],
|
|
7
|
-
providers: [ApiKeyGuard],
|
|
8
|
+
providers: [ApiKeyGuard, ApiKeyVerifier],
|
|
9
|
+
exports: [ApiKeyVerifier],
|
|
8
10
|
})
|
|
9
11
|
export class ApiKeyModule {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-key-auth",
|
|
3
|
-
"description": "API-key auth for machine/service clients (X-API-Key), separate from user sessions. Provides an ApiKeyGuard and
|
|
3
|
+
"description": "API-key auth for machine/service clients (X-API-Key), separate from user sessions. Provides an ApiKeyGuard and shared ApiKeyVerifier that compare SHA-256 key digests in constant time. Requires auth.",
|
|
4
4
|
"requires": ["auth"],
|
|
5
5
|
"targetApp": "api",
|
|
6
6
|
"env": [
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function authSecret(): string {
|
|
2
|
+
const value = process.env.BETTER_AUTH_SECRET?.trim();
|
|
3
|
+
if (
|
|
4
|
+
process.env.NODE_ENV === "production" &&
|
|
5
|
+
(!value || value.startsWith("change-me-"))
|
|
6
|
+
) {
|
|
7
|
+
throw new Error("BETTER_AUTH_SECRET must be set to a non-placeholder value in production");
|
|
8
|
+
}
|
|
9
|
+
const resolved = value || "development-only-auth-secret-min-32-characters";
|
|
10
|
+
if (resolved.length < 32) {
|
|
11
|
+
throw new Error("BETTER_AUTH_SECRET must contain at least 32 characters");
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function authBaseUrl(): string {
|
|
17
|
+
const value = process.env.BETTER_AUTH_URL?.trim();
|
|
18
|
+
if (process.env.NODE_ENV === "production" && !value) {
|
|
19
|
+
throw new Error("BETTER_AUTH_URL must be set in production");
|
|
20
|
+
}
|
|
21
|
+
const resolved = value || "http://localhost:5002";
|
|
22
|
+
let url: URL;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(resolved);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error("BETTER_AUTH_URL must be a valid URL");
|
|
27
|
+
}
|
|
28
|
+
if (process.env.NODE_ENV === "production" && url.protocol !== "https:") {
|
|
29
|
+
throw new Error("BETTER_AUTH_URL must use HTTPS in production");
|
|
30
|
+
}
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { authBaseUrl, authSecret } from "./auth-environment";
|
|
3
|
+
|
|
4
|
+
const originalEnvironment = {
|
|
5
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
6
|
+
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
|
|
7
|
+
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const [key, value] of Object.entries(originalEnvironment)) {
|
|
12
|
+
if (value === undefined) delete process.env[key];
|
|
13
|
+
else process.env[key] = value;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("authentication production environment", () => {
|
|
18
|
+
it("rejects missing and placeholder production secrets", () => {
|
|
19
|
+
process.env.NODE_ENV = "production";
|
|
20
|
+
delete process.env.BETTER_AUTH_SECRET;
|
|
21
|
+
expect(() => authSecret()).toThrow("must be set");
|
|
22
|
+
process.env.BETTER_AUTH_SECRET = "change-me-in-production-min-32-characters";
|
|
23
|
+
expect(() => authSecret()).toThrow("non-placeholder");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("requires an HTTPS production origin", () => {
|
|
27
|
+
process.env.NODE_ENV = "production";
|
|
28
|
+
delete process.env.BETTER_AUTH_URL;
|
|
29
|
+
expect(() => authBaseUrl()).toThrow("must be set");
|
|
30
|
+
process.env.BETTER_AUTH_URL = "http://app.example.com";
|
|
31
|
+
expect(() => authBaseUrl()).toThrow("must use HTTPS");
|
|
32
|
+
process.env.BETTER_AUTH_URL = "https://app.example.com";
|
|
33
|
+
expect(authBaseUrl()).toBe("https://app.example.com");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -12,6 +12,7 @@ import { passkey } from "@better-auth/passkey";
|
|
|
12
12
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
13
13
|
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
|
|
14
14
|
import { runUserDeletedHandlers } from "./user-delete-handlers";
|
|
15
|
+
import { authBaseUrl, authSecret } from "./auth-environment";
|
|
15
16
|
// podokit:begin:auth-imports
|
|
16
17
|
// podokit:end:auth-imports
|
|
17
18
|
|
|
@@ -214,8 +215,8 @@ export function buildAuth(config: AuthConfig) {
|
|
|
214
215
|
...(session ? { session } : {}),
|
|
215
216
|
plugins,
|
|
216
217
|
hooks,
|
|
217
|
-
secret:
|
|
218
|
-
baseURL:
|
|
218
|
+
secret: authSecret(),
|
|
219
|
+
baseURL: authBaseUrl(),
|
|
219
220
|
advanced: {
|
|
220
221
|
ipAddress: {
|
|
221
222
|
// The API sits behind the SvelteKit server proxy, which forwards the
|
|
@@ -2,12 +2,22 @@ import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
|
|
|
2
2
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
3
3
|
import { DataSource } from "typeorm";
|
|
4
4
|
import { Public } from "@thallesp/nestjs-better-auth";
|
|
5
|
+
import { ReadinessService, type ReadinessStatus } from "./readiness.service";
|
|
6
|
+
|
|
7
|
+
interface ReadinessResponse {
|
|
8
|
+
status: "ready";
|
|
9
|
+
db: "up";
|
|
10
|
+
checks: Record<string, ReadinessStatus>;
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
// Health checks stay public under the global auth guard.
|
|
7
14
|
@Public()
|
|
8
15
|
@Controller("health")
|
|
9
16
|
export class HealthController {
|
|
10
|
-
constructor(
|
|
17
|
+
constructor(
|
|
18
|
+
@InjectDataSource() private readonly dataSource: DataSource,
|
|
19
|
+
private readonly readinessService: ReadinessService,
|
|
20
|
+
) {}
|
|
11
21
|
|
|
12
22
|
@Get()
|
|
13
23
|
liveness(): { status: string; uptime: number; timestamp: string } {
|
|
@@ -15,12 +25,17 @@ export class HealthController {
|
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
@Get("ready")
|
|
18
|
-
async readiness(): Promise<
|
|
28
|
+
async readiness(): Promise<ReadinessResponse> {
|
|
29
|
+
let db: ReadinessStatus = "up";
|
|
19
30
|
try {
|
|
20
31
|
await this.dataSource.query("SELECT 1");
|
|
21
|
-
return { status: "ready", db: "up" };
|
|
22
32
|
} catch {
|
|
23
|
-
|
|
33
|
+
db = "down";
|
|
34
|
+
}
|
|
35
|
+
const checks = await this.readinessService.run();
|
|
36
|
+
if (db === "down" || Object.values(checks).includes("down")) {
|
|
37
|
+
throw new ServiceUnavailableException({ status: "degraded", db, checks });
|
|
24
38
|
}
|
|
39
|
+
return { status: "ready", db: "up", checks };
|
|
25
40
|
}
|
|
26
41
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import { redisConnectionOptions, type RedisConnectionOptions } from "../config/redis.connection";
|
|
2
|
+
|
|
1
3
|
export const DEMO_QUEUE = "demo";
|
|
2
4
|
|
|
3
|
-
export function redisConnection():
|
|
4
|
-
return {
|
|
5
|
-
host: process.env.REDIS_HOST ?? "localhost",
|
|
6
|
-
port: Number(process.env.REDIS_PORT ?? 6379),
|
|
7
|
-
};
|
|
5
|
+
export function redisConnection(): RedisConnectionOptions {
|
|
6
|
+
return redisConnectionOptions(process.env, { maxRetriesPerRequest: null });
|
|
8
7
|
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"name": "bullmq",
|
|
3
3
|
"description": "Background jobs with BullMQ: a demo queue, a separate worker process, and enqueue/status endpoints (needs Redis).",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/config/redis.connection.ts"
|
|
7
|
+
],
|
|
5
8
|
"targetApp": "api",
|
|
6
9
|
"dependencies": {
|
|
7
10
|
"@nestjs/bullmq": "^11.0.4",
|
|
@@ -12,7 +15,7 @@
|
|
|
12
15
|
"dev:worker": "nest start --watch --builder swc --entryFile main-worker"
|
|
13
16
|
},
|
|
14
17
|
"env": [
|
|
15
|
-
"# bullmq uses
|
|
18
|
+
"# bullmq uses REDIS_URL or the individual Redis settings defined above"
|
|
16
19
|
],
|
|
17
20
|
"inject": [
|
|
18
21
|
{
|