@podosoft/podokit 0.14.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.
Files changed (122) hide show
  1. package/README.md +20 -0
  2. package/dist/add.d.ts +4 -0
  3. package/dist/add.js +59 -1
  4. package/dist/create.js +8 -1
  5. package/dist/deploy-profile.d.ts +92 -0
  6. package/dist/deploy-profile.js +603 -0
  7. package/dist/deploy-render.d.ts +18 -0
  8. package/dist/deploy-render.js +651 -0
  9. package/dist/deploy.d.ts +65 -0
  10. package/dist/deploy.js +851 -0
  11. package/dist/dev.d.ts +1 -0
  12. package/dist/dev.js +29 -5
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +147 -1
  15. package/dist/lib.d.ts +6 -0
  16. package/dist/lib.js +18 -1
  17. package/dist/lockfile.js +3 -0
  18. package/dist/remove.js +7 -0
  19. package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
  20. package/dist/templates/fullstack-nest-svelte/README.md +19 -8
  21. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
  22. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
  26. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
  27. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
  28. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
  32. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  38. package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
  39. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
  40. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
  41. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
  42. package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +18 -10
  47. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
  48. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
  49. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
  50. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
  52. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
  53. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
  54. package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
  59. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  60. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
  61. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
  62. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
  63. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
  64. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
  65. package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
  67. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
  68. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
  69. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
  70. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
  71. package/dist/templates/modules/bullmq/module.manifest.json +4 -1
  72. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
  73. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
  74. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
  75. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
  76. package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
  77. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
  78. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
  79. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
  80. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
  81. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
  82. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
  83. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
  84. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
  85. package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
  86. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
  87. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
  88. package/dist/templates/modules/redis/module.manifest.json +16 -1
  89. package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
  90. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
  91. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
  92. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
  93. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
  94. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
  95. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
  96. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
  97. package/dist/templates/modules/sse/module.manifest.json +16 -3
  98. package/dist/templates/todo/AGENTS.md +1 -0
  99. package/dist/templates/todo/README.md +12 -8
  100. package/dist/templates/todo/apps/api/Dockerfile +2 -2
  101. package/dist/templates/todo/apps/api/package.json +1 -0
  102. package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
  103. package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
  104. package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
  105. package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
  106. package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
  107. package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
  108. package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
  109. package/dist/templates/todo/apps/web/Dockerfile +2 -2
  110. package/dist/templates/todo/compose.dev.yaml +3 -5
  111. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  112. package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  113. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
  114. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
  115. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  116. package/dist/templates/todo/dot-env.example +6 -0
  117. package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
  118. package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
  119. package/dist/templates/todo/tests/playwright.config.ts +6 -4
  120. package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
  121. package/dist/update.js +17 -13
  122. 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(@InjectDataSource() private readonly dataSource: DataSource) {}
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<{ status: string; db: string }> {
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
- throw new ServiceUnavailableException({ status: "degraded", db: "down" });
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 app -G app
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 app
27
+ USER 100:101
28
28
  EXPOSE 3000
29
29
  CMD ["node", "build"]
@@ -2,6 +2,8 @@
2
2
  <html lang="en" class="h-full">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
+ <!-- Add viewport-fit=cover only when the application-owned layout also
6
+ handles env(safe-area-inset-*) padding for edge-to-edge content. -->
5
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
8
  <title>{{projectName}}</title>
7
9
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
@@ -1,16 +1,14 @@
1
1
  # Containerized development environment.
2
2
  #
3
- # podo dev watch # core stack
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. Module-specific
13
- # services are behind profiles — enable the ones your app uses:
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.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "PodoKit Deploy"
3
+ short_description: "Plan and verify safe Kubernetes releases"
4
+ default_prompt: "Use $podokit-deploy to inspect and plan this PodoKit Kubernetes release safely."
@@ -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.
@@ -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
@@ -20,5 +20,5 @@ spec:
20
20
  - configMapRef: { name: app-config }
21
21
  - secretRef: { name: app-secrets }
22
22
  readinessProbe:
23
- httpGet: { path: /health, port: 3000 }
23
+ httpGet: { path: /health/ready, port: 3000 }
24
24
  initialDelaySeconds: 5
@@ -18,10 +18,6 @@ spec:
18
18
  - host: example.com
19
19
  http:
20
20
  paths:
21
- - path: /api
22
- pathType: Prefix
23
- backend:
24
- service: { name: api, port: { number: 3000 } }
25
21
  - path: /
26
22
  pathType: Prefix
27
23
  backend:
@@ -1,10 +1,15 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
+ import { loadPlaywrightProjects } from "./playwright.extensions";
2
3
 
3
4
  // The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
4
5
  // the API). Start it first (see docs/testing.md) — locally via the dev harness,
5
6
  // in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
6
7
  // and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
7
8
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
9
+ const coreProjects = [
10
+ { name: "api", testMatch: /.*\.api\.spec\.ts/ },
11
+ { name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
12
+ ];
8
13
 
9
14
  export default defineConfig({
10
15
  testDir: ".",
@@ -18,8 +23,5 @@ export default defineConfig({
18
23
  trace: "on-first-retry",
19
24
  screenshot: "only-on-failure",
20
25
  },
21
- projects: [
22
- { name: "api", testMatch: /.*\.api\.spec\.ts/ },
23
- { name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
24
- ],
26
+ projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
25
27
  });
@@ -0,0 +1,44 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+ import type { TestConfig } from "@playwright/test";
5
+
6
+ type Projects = NonNullable<TestConfig["projects"]>;
7
+
8
+ const requireFromTests = createRequire(__filename);
9
+
10
+ function projectName(project: unknown): string | null {
11
+ if (typeof project !== "object" || project === null || !("name" in project)) return null;
12
+ return typeof project.name === "string" && project.name.length > 0 ? project.name : null;
13
+ }
14
+
15
+ /**
16
+ * Merge application-owned Playwright projects into PodoKit's managed config.
17
+ * The optional CommonJS file stays owned so applications can add devices and
18
+ * browsers without creating a 3-way merge hotspot in playwright.config.ts.
19
+ */
20
+ export function loadPlaywrightProjects(coreProjects: Projects): Projects {
21
+ const extensionPath = join(__dirname, "playwright.projects.cjs");
22
+ if (!existsSync(extensionPath)) return [];
23
+
24
+ const loaded = requireFromTests(extensionPath) as unknown;
25
+ if (!Array.isArray(loaded)) {
26
+ throw new Error("tests/playwright.projects.cjs must export an array of Playwright projects.");
27
+ }
28
+
29
+ const names = new Set(
30
+ coreProjects.map((project) => projectName(project)).filter((name): name is string => name !== null),
31
+ );
32
+ for (const project of loaded) {
33
+ const name = projectName(project);
34
+ if (!name) {
35
+ throw new Error("Every project in tests/playwright.projects.cjs must have a non-empty name.");
36
+ }
37
+ if (names.has(name)) {
38
+ throw new Error(`Duplicate Playwright project name: ${name}`);
39
+ }
40
+ names.add(name);
41
+ }
42
+
43
+ return loaded as Projects;
44
+ }
@@ -7,12 +7,30 @@
7
7
  import LayoutDashboardIcon from "@lucide/svelte/icons/layout-dashboard";
8
8
  import LogOutIcon from "@lucide/svelte/icons/log-out";
9
9
  import UserIcon from "@lucide/svelte/icons/user";
10
+ import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
10
11
  import { api } from "$lib/api";
11
12
  import { getI18n } from "$lib/i18n";
13
+ import { cn } from "$lib/utils";
12
14
  import type { SessionUser } from "../../app.d.ts";
13
15
  import UserAvatar from "./user-avatar.svelte";
14
16
 
15
- let { user }: { user: SessionUser | null } = $props();
17
+ type MenuSide = "top" | "right" | "bottom" | "left";
18
+ type MenuAlign = "start" | "center" | "end";
19
+ type Props = {
20
+ user: SessionUser | null;
21
+ variant?: "avatar" | "identity";
22
+ side?: MenuSide;
23
+ align?: MenuAlign;
24
+ class?: string;
25
+ };
26
+
27
+ let {
28
+ user,
29
+ variant = "avatar",
30
+ side = "bottom",
31
+ align = "end",
32
+ class: className,
33
+ }: Props = $props();
16
34
  const i18n = getI18n();
17
35
  const loginHref = $derived(withAuthRedirect("/login", currentPath(page.url)));
18
36
 
@@ -27,12 +45,25 @@
27
45
  <DropdownMenu.Root>
28
46
  <DropdownMenu.Trigger
29
47
  data-testid="account-menu"
30
- class="hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 inline-flex size-8 items-center justify-center rounded-full outline-none focus-visible:ring-3"
48
+ class={cn(
49
+ "hover:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-3",
50
+ variant === "avatar"
51
+ ? "inline-flex size-8 items-center justify-center rounded-full"
52
+ : "flex w-full items-center gap-2 rounded-md p-2 text-left",
53
+ className,
54
+ )}
31
55
  aria-label={i18n.t.nav.account}
32
56
  >
33
57
  <UserAvatar {user} class="size-8" />
58
+ {#if variant === "identity"}
59
+ <div class="min-w-0 flex-1 text-sm leading-tight">
60
+ <span class="block truncate font-medium">{user.name}</span>
61
+ <span class="text-muted-foreground block truncate text-xs">{user.email}</span>
62
+ </div>
63
+ <ChevronsUpDownIcon class="size-4 shrink-0" />
64
+ {/if}
34
65
  </DropdownMenu.Trigger>
35
- <DropdownMenu.Content align="end" class="w-56">
66
+ <DropdownMenu.Content {align} {side} class="w-56">
36
67
  <DropdownMenu.Label>
37
68
  <span class="block truncate font-medium">{user.name}</span>
38
69
  <span class="text-muted-foreground block truncate text-xs font-normal">{user.email}</span>
@@ -47,5 +78,5 @@
47
78
  </DropdownMenu.Content>
48
79
  </DropdownMenu.Root>
49
80
  {:else}
50
- <Button data-testid="sign-in-link" href={loginHref} variant="ghost">{i18n.t.auth.signIn}</Button>
81
+ <Button data-testid="sign-in-link" href={loginHref} variant="ghost" class={className}>{i18n.t.auth.signIn}</Button>
51
82
  {/if}
@@ -1,14 +1,34 @@
1
1
  <script lang="ts" module>
2
+ import { cn } from "$lib/utils";
3
+
4
+ export type DataTableBreakpoint = "sm" | "md" | "lg" | "xl";
2
5
  export type DataTableColumn<Row> = {
3
6
  key: string;
4
7
  label: string;
5
8
  sortable?: boolean;
6
9
  class?: string;
10
+ hideBelow?: DataTableBreakpoint;
7
11
  // Value used for client-side sorting (defaults to row[key]). Use for nested
8
12
  // or derived fields, e.g. (s) => s.user.email.
9
13
  value?: (row: Row) => string | number | boolean | Date | null | undefined;
10
14
  };
11
15
  export type SortState = { key: string; dir: "asc" | "desc" };
16
+ export type DataTableRowContext = {
17
+ cellClass: (columnKey: string) => string | undefined;
18
+ };
19
+
20
+ const HIDE_BELOW_CLASS: Record<DataTableBreakpoint, string> = {
21
+ sm: "max-sm:hidden",
22
+ md: "max-md:hidden",
23
+ lg: "max-lg:hidden",
24
+ xl: "max-xl:hidden",
25
+ };
26
+
27
+ export function getDataTableColumnClass<Row>(
28
+ column: DataTableColumn<Row>,
29
+ ): string | undefined {
30
+ return cn(column.class, column.hideBelow ? HIDE_BELOW_CLASS[column.hideBelow] : undefined);
31
+ }
12
32
 
13
33
  // Shared page size for all admin tables — sized so a desktop viewport shows a
14
34
  // full list at a glance (rather than the old 5-row pages). Change it here to
@@ -33,9 +53,10 @@
33
53
  type Props = {
34
54
  columns: DataTableColumn<Row>[];
35
55
  rows: Row[];
36
- row: Snippet<[Row]>; // renders the <Table.Cell>s for one row
56
+ row: Snippet<[Row, DataTableRowContext]>; // renders the <Table.Cell>s for one row
37
57
  getKey?: (row: Row, index: number) => string | number;
38
58
  empty?: string;
59
+ ariaLabel?: string;
39
60
  sort?: SortState | null;
40
61
  manualSort?: boolean;
41
62
  page?: number;
@@ -51,6 +72,7 @@
51
72
  row,
52
73
  getKey = (_r, i) => i,
53
74
  empty = "No data.",
75
+ ariaLabel,
54
76
  sort = $bindable(null),
55
77
  manualSort = false,
56
78
  page = $bindable(1),
@@ -71,6 +93,13 @@
71
93
  onChange?.({ sort, page });
72
94
  }
73
95
 
96
+ function cellClass(columnKey: string): string | undefined {
97
+ const column = columns.find((candidate) => candidate.key === columnKey);
98
+ return column ? getDataTableColumnClass(column) : undefined;
99
+ }
100
+
101
+ const rowContext: DataTableRowContext = { cellClass };
102
+
74
103
  function cmp(a: unknown, b: unknown): number {
75
104
  if (a == null && b == null) return 0;
76
105
  if (a == null) return -1;
@@ -95,12 +124,12 @@
95
124
  </script>
96
125
 
97
126
  <div class="rounded-md border">
98
- <Table.Root>
127
+ <Table.Root aria-label={ariaLabel}>
99
128
  <Table.Header>
100
129
  <Table.Row>
101
130
  {#each columns as col (col.key)}
102
131
  <Table.Head
103
- class={col.class}
132
+ class={getDataTableColumnClass(col)}
104
133
  aria-sort={sort?.key === col.key ? (sort.dir === "asc" ? "ascending" : "descending") : undefined}
105
134
  >
106
135
  {#if col.sortable}
@@ -121,7 +150,7 @@
121
150
  </Table.Header>
122
151
  <Table.Body>
123
152
  {#each view as r, i (getKey(r, i))}
124
- <Table.Row>{@render row(r)}</Table.Row>
153
+ <Table.Row>{@render row(r, rowContext)}</Table.Row>
125
154
  {:else}
126
155
  <Table.Row>
127
156
  <Table.Cell colspan={columns.length} class="text-muted-foreground py-8 text-center">{empty}</Table.Cell>
@@ -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 -->
@@ -16,6 +16,7 @@
16
16
  import { toast } from "svelte-sonner";
17
17
  import { api } from "$lib/api";
18
18
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
19
+ import { cn } from "$lib/utils";
19
20
  import type { PageData } from "./$types";
20
21
 
21
22
  let { data }: { data: PageData } = $props();
@@ -58,9 +59,15 @@
58
59
  const columns = $derived<DataTableColumn<Row>[]>([
59
60
  { key: "name", label: i18n.t.users.name, sortable: true },
60
61
  { key: "email", label: i18n.t.users.email, sortable: true },
61
- { key: "role", label: i18n.t.users.role, sortable: true },
62
- { key: "status", label: i18n.t.users.status },
63
- { key: "createdAt", label: i18n.t.users.joined, sortable: true, value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0) },
62
+ { key: "role", label: i18n.t.users.role, sortable: true, hideBelow: "md" },
63
+ { key: "status", label: i18n.t.users.status, hideBelow: "lg" },
64
+ {
65
+ key: "createdAt",
66
+ label: i18n.t.users.joined,
67
+ sortable: true,
68
+ hideBelow: "lg",
69
+ value: (u) => (u.createdAt ? new Date(u.createdAt).getTime() : 0),
70
+ },
64
71
  { key: "actions", label: "", class: "w-10" },
65
72
  ]);
66
73
  const filters = $derived<ToolbarFilter[]>([
@@ -380,13 +387,14 @@
380
387
  bind:sort
381
388
  bind:page
382
389
  perPage={PAGE_SIZE}
390
+ ariaLabel={i18n.t.users.title}
383
391
  label={fmt(i18n.t.users.total, { count: filtered.length })}
384
392
  >
385
- {#snippet row(user)}
386
- <Table.Cell class="font-medium">{user.name}</Table.Cell>
387
- <Table.Cell class="text-muted-foreground">{user.email}</Table.Cell>
388
- <Table.Cell><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
389
- <Table.Cell>
393
+ {#snippet row(user, { cellClass })}
394
+ <Table.Cell class={cn(cellClass("name"), "font-medium")}>{user.name}</Table.Cell>
395
+ <Table.Cell class={cn(cellClass("email"), "text-muted-foreground")}>{user.email}</Table.Cell>
396
+ <Table.Cell class={cellClass("role")}><Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role ?? "user"}</Badge></Table.Cell>
397
+ <Table.Cell class={cellClass("status")}>
390
398
  <div class="flex flex-wrap gap-1">
391
399
  {#if user.signupApproved === false}
392
400
  <Badge variant="secondary">{i18n.t.users.pendingApproval}</Badge>
@@ -400,8 +408,8 @@
400
408
  {/if}
401
409
  </div>
402
410
  </Table.Cell>
403
- <Table.Cell class="text-muted-foreground">{user.createdAt ? formatDateTime(user.createdAt) : "—"}</Table.Cell>
404
- <Table.Cell>
411
+ <Table.Cell class={cn(cellClass("createdAt"), "text-muted-foreground")}>{user.createdAt ? formatDateTime(user.createdAt) : "—"}</Table.Cell>
412
+ <Table.Cell class={cellClass("actions")}>
405
413
  <DropdownMenu.Root>
406
414
  <DropdownMenu.Trigger>
407
415
  {#snippet child({ props })}
@@ -13,19 +13,20 @@ put pagination outside the table.
13
13
  ```svelte
14
14
  <script lang="ts">
15
15
  import DataTable from "$lib/components/data-table.svelte";
16
+ import { cn } from "$lib/utils";
16
17
  const columns = [
17
18
  { key: "name", label: "Name", sortable: true },
18
19
  { key: "email", label: "Email", sortable: true },
19
- { key: "role", label: "Role", sortable: true }, // action/status columns: no `sortable`
20
+ { key: "role", label: "Role", sortable: true, hideBelow: "md" },
20
21
  ];
21
22
  let { rows } = $props(); // Svelte 5 runes only
22
23
  </script>
23
24
 
24
- <DataTable {columns} {rows} label="{rows.length} users">
25
- {#snippet row(item)}
26
- <Table.Cell>{item.name}</Table.Cell>
27
- <Table.Cell>{item.email}</Table.Cell>
28
- <Table.Cell>{item.role}</Table.Cell>
25
+ <DataTable {columns} {rows} ariaLabel="Users" label="{rows.length} users">
26
+ {#snippet row(item, { cellClass })}
27
+ <Table.Cell class={cellClass("name")}>{item.name}</Table.Cell>
28
+ <Table.Cell class={cellClass("email")}>{item.email}</Table.Cell>
29
+ <Table.Cell class={cn(cellClass("role"), "text-muted-foreground")}>{item.role}</Table.Cell>
29
30
  {/snippet}
30
31
  </DataTable>
31
32
  ```
@@ -34,6 +35,10 @@ put pagination outside the table.
34
35
  - **Server data** (huge lists): use `manualSort` + `manualPagination` + `total`
35
36
  + `onChange` (re-fetch with `sortBy`/`sortDirection`/`page`).
36
37
  - Nested/derived sort keys: give the column a `value` accessor.
38
+ - Responsive columns: set `hideBelow` to `sm`, `md`, `lg`, or `xl`, then apply
39
+ `cellClass(columnKey)` to the matching `Table.Cell`. Never repeat the
40
+ responsive class by hand; the shared class keeps headers and cells aligned.
41
+ - Give each table an `ariaLabel` and scope Playwright locators to that table.
37
42
  - No footer needed: `perPage={0}`.
38
43
  - **Search & filters**: put the shared `table-toolbar.svelte` (`TableToolbar`)
39
44
  above the DataTable. Filters + search apply together on the Search button/Enter