@podosoft/podokit 0.16.4 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/deploy-compose-exec.d.ts +93 -0
  2. package/dist/deploy-compose-exec.js +198 -0
  3. package/dist/deploy-compose-profile.d.ts +107 -0
  4. package/dist/deploy-compose-profile.js +361 -0
  5. package/dist/deploy-compose-render.d.ts +48 -0
  6. package/dist/deploy-compose-render.js +333 -0
  7. package/dist/deploy-compose-sync.d.ts +144 -0
  8. package/dist/deploy-compose-sync.js +412 -0
  9. package/dist/deploy-compose.d.ts +98 -0
  10. package/dist/deploy-compose.js +667 -0
  11. package/dist/deploy-dispatch.d.ts +33 -0
  12. package/dist/deploy-dispatch.js +58 -0
  13. package/dist/deploy-driver.d.ts +25 -0
  14. package/dist/deploy-driver.js +78 -0
  15. package/dist/deploy-profile.d.ts +3 -10
  16. package/dist/deploy-profile.js +82 -291
  17. package/dist/deploy-schema.d.ts +56 -0
  18. package/dist/deploy-schema.js +261 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +178 -6
  21. package/dist/lib.d.ts +9 -0
  22. package/dist/lib.js +30 -1
  23. package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
  24. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
  25. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
  26. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
  27. package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -1
  30. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  32. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  38. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  39. package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
  40. package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
  41. package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
  42. package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
  43. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
  44. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
  45. package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +15 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +5 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +5 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
  55. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +20 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  60. package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
  61. package/dist/templates/todo/AGENTS.md +2 -1
  62. package/dist/templates/todo/Dockerfile.dev +34 -3
  63. package/dist/templates/todo/apps/api/Dockerfile +68 -7
  64. package/dist/templates/todo/apps/web/Dockerfile +60 -8
  65. package/dist/templates/todo/apps/web/server.js +31 -0
  66. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  67. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -1
  68. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
  69. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  70. package/dist/templates/todo/apps/web/vite.config.ts +17 -7
  71. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  72. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
  73. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  74. package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  75. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  76. package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  77. package/dist/templates/todo/dot-dockerignore +7 -0
  78. package/dist/templates/todo/dot-env.example +7 -0
  79. package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
  80. package/dist/templates/todo/infra/k3s/README.md +18 -0
  81. package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
  82. package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
  83. package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
  84. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ # Example k3s manifests
2
+
3
+ These are **illustrative**. `podo deploy` does not read or apply them.
4
+
5
+ For a real deployment, initialize a profile and let PodoKit render the manifests it
6
+ will actually apply — with image digests, probes, disruption budgets, a migration
7
+ Job, and a confirmation hash:
8
+
9
+ ```bash
10
+ podo deploy init --profile production --context production --host app.example.com
11
+ podo deploy render --profile production --release v1.2.3
12
+ ```
13
+
14
+ Keep these files if you want a starting point for hand-managed manifests, or delete
15
+ the directory if you deploy with `podo deploy`. If you do apply them by hand, replace
16
+ every `example.com` and `ghcr.io/example/...` placeholder first, and note that the
17
+ image tags below are placeholders too: the deployment tooling rejects `latest`,
18
+ branch tags, and empty tags, and so should you.
@@ -1,3 +1,4 @@
1
+ # Example only. See README.md in this directory; `podo deploy` renders its own.
1
2
  apiVersion: apps/v1
2
3
  kind: Deployment
3
4
  metadata:
@@ -13,7 +14,8 @@ spec:
13
14
  spec:
14
15
  containers:
15
16
  - name: api
16
- image: ghcr.io/example/podokit-api:latest
17
+ # Placeholder. Use one immutable, stable SemVer tag for api and web.
18
+ image: ghcr.io/example/podokit-api:v0.0.0
17
19
  ports:
18
20
  - containerPort: 3000
19
21
  envFrom:
@@ -22,3 +24,6 @@ spec:
22
24
  readinessProbe:
23
25
  httpGet: { path: /health/ready, port: 3000 }
24
26
  initialDelaySeconds: 5
27
+ livenessProbe:
28
+ httpGet: { path: /health, port: 3000 }
29
+ initialDelaySeconds: 15
@@ -1,3 +1,4 @@
1
+ # Example only. See README.md in this directory; `podo deploy` renders its own.
1
2
  apiVersion: traefik.io/v1alpha1
2
3
  kind: Middleware
3
4
  metadata:
@@ -14,6 +15,13 @@ metadata:
14
15
  annotations:
15
16
  traefik.ingress.kubernetes.io/router.middlewares: podokit-compression@kubernetescrd
16
17
  spec:
18
+ # Name the controller explicitly rather than relying on a default IngressClass.
19
+ ingressClassName: traefik
20
+ # app-config sets an https:// origin, so the route has to terminate TLS. Create the
21
+ # Secret out of band (cert-manager, or an existing certificate) and name it here.
22
+ tls:
23
+ - hosts: [example.com]
24
+ secretName: app-tls
17
25
  rules:
18
26
  - host: example.com
19
27
  http:
@@ -1,3 +1,4 @@
1
+ # Example only. See README.md in this directory; `podo deploy` renders its own.
1
2
  apiVersion: apps/v1
2
3
  kind: Deployment
3
4
  metadata:
@@ -13,8 +14,17 @@ spec:
13
14
  spec:
14
15
  containers:
15
16
  - name: web
16
- image: ghcr.io/example/podokit-web:latest
17
+ # Placeholder. Use one immutable, stable SemVer tag for api and web.
18
+ image: ghcr.io/example/podokit-web:v0.0.0
17
19
  ports:
18
20
  - containerPort: 3000
19
21
  envFrom:
20
22
  - configMapRef: { name: app-config }
23
+ # Without these a rolling update sends traffic to a process that has not
24
+ # bound its port yet.
25
+ readinessProbe:
26
+ httpGet: { path: /, port: 3000 }
27
+ initialDelaySeconds: 5
28
+ livenessProbe:
29
+ httpGet: { path: /, port: 3000 }
30
+ initialDelaySeconds: 15
@@ -9,6 +9,7 @@
9
9
  import * as Table from "$lib/components/ui/table";
10
10
  import * as Dialog from "$lib/components/ui/dialog";
11
11
  import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
12
+ import { deviceLabel } from "$lib/device-label";
12
13
  import { toast } from "svelte-sonner";
13
14
  import { api } from "$lib/api";
14
15
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
@@ -218,9 +219,16 @@
218
219
  // Security — change password
219
220
  let currentPassword = $state("");
220
221
  let newPassword = $state("");
222
+ let confirmNewPassword = $state("");
221
223
  let changing = $state(false);
222
224
  async function changePassword(event: SubmitEvent): Promise<void> {
223
225
  event.preventDefault();
226
+ // Checked here rather than by the API, which only sees one new password and
227
+ // has no way to know it is not the one that was meant.
228
+ if (newPassword !== confirmNewPassword) {
229
+ toast.error(i18n.t.account.passwordMismatch);
230
+ return;
231
+ }
224
232
  changing = true;
225
233
  const { error } = await api.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
226
234
  changing = false;
@@ -229,6 +237,7 @@
229
237
  toast.success(i18n.t.account.changed);
230
238
  currentPassword = "";
231
239
  newPassword = "";
240
+ confirmNewPassword = "";
232
241
  await loadSessions();
233
242
  }
234
243
  }
@@ -382,7 +391,7 @@
382
391
  let sessionsSort = $state<SortState | null>(null);
383
392
  let busy = $state(false);
384
393
  const sessionsColumns: DataTableColumn<Session>[] = [
385
- { key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => s.userAgent ?? "" },
394
+ { key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
386
395
  { key: "ipAddress", label: i18n.t.sessions.ip, sortable: true, value: (s) => s.ipAddress ?? "" },
387
396
  { key: "createdAt", label: i18n.t.sessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
388
397
  { key: "status", label: i18n.t.sessions.status },
@@ -632,6 +641,10 @@
632
641
  <Label for="new">{i18n.t.account.newPassword}</Label>
633
642
  <Input id="new" type="password" bind:value={newPassword} required autocomplete="new-password" />
634
643
  </div>
644
+ <div class="flex flex-col gap-2">
645
+ <Label for="new-confirm">{i18n.t.account.confirmNewPassword}</Label>
646
+ <Input id="new-confirm" type="password" bind:value={confirmNewPassword} required autocomplete="new-password" />
647
+ </div>
635
648
  <Button type="submit" class="w-fit" disabled={changing}>{changing ? i18n.t.account.updating : i18n.t.account.update}</Button>
636
649
  </form>
637
650
  </Card.Content>
@@ -793,7 +806,7 @@
793
806
  >
794
807
  {#snippet row(s)}
795
808
  {@const isCurrent = s.id === data.currentSessionId}
796
- <Table.Cell class="max-w-xs truncate">{s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
809
+ <Table.Cell class="max-w-xs truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
797
810
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
798
811
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
799
812
  <Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deviceLabel } from "./device-label";
3
+
4
+ describe("deviceLabel", () => {
5
+ it("names the browser and the machine", () => {
6
+ expect(
7
+ deviceLabel(
8
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
9
+ ),
10
+ ).toBe("Chrome · macOS");
11
+ expect(
12
+ deviceLabel(
13
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
14
+ ),
15
+ ).toBe("Chrome · Windows");
16
+ });
17
+
18
+ // Each of these contains an earlier engine's token, so a naive check in the wrong
19
+ // order reports every one of them as Chrome or Safari.
20
+ it("does not mistake a derivative for the engine it embeds", () => {
21
+ expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0")).toBe(
22
+ "Edge · Windows",
23
+ );
24
+ expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0) Chrome/141.0.0.0 Safari/537.36 OPR/121.0.0.0")).toBe(
25
+ "Opera · Windows",
26
+ );
27
+ expect(
28
+ deviceLabel("Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/141.0.0.0 SamsungBrowser/28.0 Safari/537.36"),
29
+ ).toBe("Samsung Internet · Android");
30
+ expect(
31
+ deviceLabel("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15"),
32
+ ).toBe("Safari · macOS");
33
+ });
34
+
35
+ it("names an iPhone and an iPad rather than the engine's host platform", () => {
36
+ expect(
37
+ deviceLabel("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1"),
38
+ ).toBe("Safari · iPhone");
39
+ // iPadOS reports Macintosh in the same string, so order decides this one.
40
+ expect(
41
+ deviceLabel("Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1"),
42
+ ).toBe("Safari · iPad");
43
+ expect(deviceLabel("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) CriOS/141.0.0.0 Mobile/15E148 Safari/604.1")).toBe(
44
+ "Chrome · iPhone",
45
+ );
46
+ });
47
+
48
+ it("gives up on something that is not a browser, so the caller can show it raw", () => {
49
+ // The exact value is the whole point for these: "node" is the symptom of a
50
+ // proxy that forgot to forward the header, and a CLI's name identifies it.
51
+ expect(deviceLabel("node")).toBeNull();
52
+ expect(deviceLabel("curl/8.7.1")).toBeNull();
53
+ expect(deviceLabel("")).toBeNull();
54
+ expect(deviceLabel(null)).toBeNull();
55
+ expect(deviceLabel(undefined)).toBeNull();
56
+ });
57
+
58
+ it("reports whichever half it can read", () => {
59
+ expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")).toBe("Windows");
60
+ expect(deviceLabel("Firefox/142.0")).toBe("Firefox");
61
+ });
62
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A readable device name for a session's user agent.
3
+ *
4
+ * The sessions tables label this column "device", and a raw user agent does not
5
+ * answer that question — truncated to fit a cell, every modern browser starts with
6
+ * the same "Mozilla/5.0 (…" and the part that differs is off the end. This reduces
7
+ * it to the two things someone scanning their own sessions is looking for: which
8
+ * browser, on which machine.
9
+ *
10
+ * Returns null when the string is not a browser at all — a CLI, a script, or the
11
+ * proxy's own default. The caller shows the raw value then, because for those the
12
+ * exact string is the only useful information.
13
+ */
14
+
15
+ /** Order matters: every one of these also contains an earlier engine's token. */
16
+ const BROWSERS: Array<[RegExp, string]> = [
17
+ [/\bEdg(?:e|A|iOS)?\//, "Edge"],
18
+ [/\bOPR\/|\bOpera\//, "Opera"],
19
+ [/\bSamsungBrowser\//, "Samsung Internet"],
20
+ [/\bFirefox\/|\bFxiOS\//, "Firefox"],
21
+ [/\bCriOS\//, "Chrome"],
22
+ [/\bChrome\/|\bChromium\//, "Chrome"],
23
+ [/\bSafari\//, "Safari"],
24
+ ];
25
+
26
+ /** iPadOS reports as Macintosh, so the iPad check has to come before macOS. */
27
+ const PLATFORMS: Array<[RegExp, string]> = [
28
+ [/\biPhone\b/, "iPhone"],
29
+ [/\biPad\b/, "iPad"],
30
+ [/\bAndroid\b/, "Android"],
31
+ [/\bCrOS\b/, "ChromeOS"],
32
+ [/\bWindows NT\b/, "Windows"],
33
+ [/\bMacintosh\b|\bMac OS X\b/, "macOS"],
34
+ [/\bLinux\b/, "Linux"],
35
+ ];
36
+
37
+ function match(candidates: Array<[RegExp, string]>, value: string): string | null {
38
+ for (const [pattern, label] of candidates) {
39
+ if (pattern.test(value)) return label;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export function deviceLabel(userAgent: string | null | undefined): string | null {
45
+ if (!userAgent) return null;
46
+ const browser = match(BROWSERS, userAgent);
47
+ const platform = match(PLATFORMS, userAgent);
48
+ if (browser && platform) return `${browser} · ${platform}`;
49
+ return browser ?? platform;
50
+ }
@@ -53,6 +53,8 @@
53
53
  "email": "Email",
54
54
  "emailOrUsername": "Email or username",
55
55
  "password": "Password",
56
+ "confirmPassword": "Confirm password",
57
+ "passwordMismatch": "Passwords do not match",
56
58
  "forgot": "Forgot?",
57
59
  "signIn": "Sign in",
58
60
  "signingIn": "Signing in…",
@@ -93,6 +95,7 @@
93
95
  "requestFailed": "Request failed",
94
96
  "newTitle": "Set a new password",
95
97
  "newPassword": "New password",
98
+ "confirmNewPassword": "Confirm new password",
96
99
  "updatePassword": "Update password",
97
100
  "updating": "Updating…",
98
101
  "missingToken": "Missing or invalid reset token.",
@@ -528,6 +531,8 @@
528
531
  "changePassword": "Change password",
529
532
  "currentPassword": "Current password",
530
533
  "newPassword": "New password",
534
+ "confirmNewPassword": "Confirm new password",
535
+ "passwordMismatch": "Passwords do not match",
531
536
  "update": "Update password",
532
537
  "updating": "Updating…",
533
538
  "changed": "Password changed",
@@ -53,6 +53,8 @@
53
53
  "email": "이메일",
54
54
  "emailOrUsername": "이메일 또는 사용자명",
55
55
  "password": "비밀번호",
56
+ "confirmPassword": "비밀번호 확인",
57
+ "passwordMismatch": "비밀번호가 일치하지 않습니다",
56
58
  "forgot": "비밀번호 찾기",
57
59
  "signIn": "로그인",
58
60
  "signingIn": "로그인 중…",
@@ -92,6 +94,7 @@
92
94
  "requestFailed": "요청에 실패했습니다",
93
95
  "newTitle": "새 비밀번호 설정",
94
96
  "newPassword": "새 비밀번호",
97
+ "confirmNewPassword": "새 비밀번호 확인",
95
98
  "updatePassword": "비밀번호 변경",
96
99
  "updating": "변경 중…",
97
100
  "missingToken": "재설정 토큰이 없거나 유효하지 않습니다.",
@@ -528,6 +531,8 @@
528
531
  "changePassword": "비밀번호 변경",
529
532
  "currentPassword": "현재 비밀번호",
530
533
  "newPassword": "새 비밀번호",
534
+ "confirmNewPassword": "새 비밀번호 확인",
535
+ "passwordMismatch": "비밀번호가 일치하지 않습니다",
531
536
  "update": "비밀번호 변경",
532
537
  "updating": "변경 중…",
533
538
  "changed": "비밀번호를 변경했습니다",
@@ -9,6 +9,7 @@
9
9
  import { toast } from "svelte-sonner";
10
10
  import { api } from "$lib/api";
11
11
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
12
+ import { deviceLabel } from "$lib/device-label";
12
13
 
13
14
  const i18n = getI18n();
14
15
 
@@ -43,7 +44,7 @@
43
44
 
44
45
  const columns = $derived<DataTableColumn<Row>[]>([
45
46
  { key: "user", label: i18n.t.adminSessions.user, sortable: true, value: (r) => r.userName },
46
- { key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => r.userAgent },
47
+ { key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => deviceLabel(r.userAgent) ?? r.userAgent ?? "" },
47
48
  { key: "ip", label: i18n.t.adminSessions.ip, sortable: true, value: (r) => r.ipAddress },
48
49
  { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true },
49
50
  { key: "expiresAt", label: i18n.t.adminSessions.expires, sortable: true, value: (r) => (r.expiresAt ? new Date(r.expiresAt).getTime() : 0) },
@@ -134,7 +135,7 @@
134
135
  </div>
135
136
  <div class="text-muted-foreground text-xs">{r.userEmail}</div>
136
137
  </Table.Cell>
137
- <Table.Cell class="max-w-xs truncate">{r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
138
+ <Table.Cell class="max-w-xs truncate" title={r.userAgent ?? ""}>{deviceLabel(r.userAgent) ?? r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
138
139
  <Table.Cell class="text-muted-foreground">{r.ipAddress ?? "—"}</Table.Cell>
139
140
  <Table.Cell class="text-muted-foreground">{formatDateTime(r.createdAt)}</Table.Cell>
140
141
  <Table.Cell class="text-muted-foreground">{r.expiresAt ? formatDateTime(r.expiresAt) : "—"}</Table.Cell>
@@ -17,6 +17,7 @@
17
17
  import { api } from "$lib/api";
18
18
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
19
19
  import { cn } from "$lib/utils";
20
+ import { deviceLabel } from "$lib/device-label";
20
21
  import type { PageData } from "./$types";
21
22
 
22
23
  let { data }: { data: PageData } = $props();
@@ -194,7 +195,7 @@
194
195
  let mSessionsPage = $state(1);
195
196
  let mSessionsSort = $state<SortState | null>(null);
196
197
  const mSessionsColumns: DataTableColumn<Session>[] = [
197
- { key: "userAgent", label: i18n.t.adminSessions.device, sortable: true },
198
+ { key: "userAgent", label: i18n.t.adminSessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
198
199
  { key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
199
200
  { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
200
201
  { key: "actions", label: "", class: "w-10" },
@@ -557,7 +558,7 @@
557
558
  empty={i18n.t.adminSessions.empty}
558
559
  >
559
560
  {#snippet row(s)}
560
- <Table.Cell class="max-w-40 truncate">{s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
561
+ <Table.Cell class="max-w-40 truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
561
562
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
562
563
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
563
564
  <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
@@ -11,14 +11,21 @@
11
11
 
12
12
  const i18n = getI18n();
13
13
  let password = $state("");
14
+ let confirmPassword = $state("");
14
15
  let error = $state<string | null>(null);
15
16
  let loading = $state(false);
16
17
  const token = $derived(page.url.searchParams.get("token") ?? "");
17
18
 
18
19
  async function submit(event: SubmitEvent): Promise<void> {
19
20
  event.preventDefault();
20
- loading = true;
21
21
  error = null;
22
+ // The token is single-use: a mistyped password here is not a retry, it is
23
+ // another round trip through the mailbox to get a link that still works.
24
+ if (password !== confirmPassword) {
25
+ error = i18n.t.auth.passwordMismatch;
26
+ return;
27
+ }
28
+ loading = true;
22
29
  const { error: authError } = await api.auth.resetPassword({ newPassword: password, token });
23
30
  loading = false;
24
31
  if (authError) {
@@ -45,6 +52,10 @@
45
52
  <Label for="password">{i18n.t.auth.newPassword}</Label>
46
53
  <Input id="password" type="password" bind:value={password} required autocomplete="new-password" />
47
54
  </div>
55
+ <div class="flex flex-col gap-2">
56
+ <Label for="confirm-password">{i18n.t.auth.confirmNewPassword}</Label>
57
+ <Input id="confirm-password" type="password" bind:value={confirmPassword} required autocomplete="new-password" />
58
+ </div>
48
59
  <Button type="submit" disabled={loading || !token}>{loading ? i18n.t.auth.updating : i18n.t.auth.updatePassword}</Button>
49
60
  </form>
50
61
  </Card.Content>
@@ -18,14 +18,22 @@
18
18
  let name = $state("");
19
19
  let email = $state("");
20
20
  let password = $state("");
21
+ let confirmPassword = $state("");
21
22
  let error = $state<string | null>(null);
22
23
  let loading = $state(false);
23
24
  const redirectTo = (): string => safeAuthRedirect(page.url.searchParams.get("redirect"));
24
25
 
25
26
  async function submit(event: SubmitEvent): Promise<void> {
26
27
  event.preventDefault();
27
- loading = true;
28
28
  error = null;
29
+ // Before the request, not after: a typo here costs the account. Sign-up is the
30
+ // one password entry with no way back — the address is not verified yet, so the
31
+ // reset link that would rescue it goes somewhere the user cannot read.
32
+ if (password !== confirmPassword) {
33
+ error = i18n.t.auth.passwordMismatch;
34
+ return;
35
+ }
36
+ loading = true;
29
37
  // callbackURL: where the verification link lands once confirmed (absolute so
30
38
  // it returns to the web app, not the API origin). Ignored when verification is off.
31
39
  const { error: authError } = await api.auth.signUp.email({
@@ -85,6 +93,10 @@
85
93
  <Label for="password">{i18n.t.auth.password}</Label>
86
94
  <Input id="password" type="password" bind:value={password} required autocomplete="new-password" />
87
95
  </div>
96
+ <div class="flex flex-col gap-2">
97
+ <Label for="confirm-password">{i18n.t.auth.confirmPassword}</Label>
98
+ <Input id="confirm-password" type="password" bind:value={confirmPassword} required autocomplete="new-password" />
99
+ </div>
88
100
  <Button type="submit" disabled={loading}>{loading ? i18n.t.auth.creating : i18n.t.auth.create}</Button>
89
101
  </form>
90
102
  </Card.Content>
@@ -1,6 +1,31 @@
1
1
  export type Account = { name: string; email: string; password: string };
2
- export const ADMIN = { name: "Admin", email: "admin@example.com", password: "Podokit3e-Str0ng!pw" };
3
- export const USER = { name: "Normal User", email: "user@example.com", password: "Podokit3e-Str0ng!pw" };
2
+
3
+ /**
4
+ * The password the suite signs in with, per account.
5
+ *
6
+ * A test must never change an existing account's credentials. `seed.setup.ts` signs up
7
+ * idempotently and then signs in with these values, which is right on a stack the suite
8
+ * created — and wrong on every stack where the account already exists with a password of
9
+ * its own. An install bootstrapped with `admin:bootstrap` is exactly that: `admin@example.com`
10
+ * is a real account somebody logs in as, and resetting it to match the constant takes their
11
+ * login with it while the run goes green.
12
+ *
13
+ * So the constant is a DEFAULT and the environment wins:
14
+ *
15
+ * ```bash
16
+ * E2E_BASE_URL=https://app.example.com E2E_ADMIN_PASSWORD='…' npx playwright test …
17
+ * ```
18
+ *
19
+ * Without the override the run fails with `INVALID_EMAIL_OR_PASSWORD`, which is the correct
20
+ * outcome — a refusal, not a silent overwrite. Do not commit a real value here.
21
+ */
22
+ function seedPassword(envVar: "E2E_ADMIN_PASSWORD" | "E2E_USER_PASSWORD"): string {
23
+ const value = process.env[envVar];
24
+ return value !== undefined && value.length > 0 ? value : "Podokit3e-Str0ng!pw";
25
+ }
26
+
27
+ export const ADMIN = { name: "Admin", email: "admin@example.com", password: seedPassword("E2E_ADMIN_PASSWORD") };
28
+ export const USER = { name: "Normal User", email: "user@example.com", password: seedPassword("E2E_USER_PASSWORD") };
4
29
  export const adminState = "playwright/.auth/admin.json";
5
30
  export const userState = "playwright/.auth/user.json";
6
31
  export const userBaselineState = "playwright/.auth/user-baseline.json";
@@ -161,12 +161,31 @@ test("account security and sessions sub-navigation", async ({ page }) => {
161
161
  await ready(page, "/admin/account");
162
162
  await page.getByRole("button", { name: "Security" }).click();
163
163
  await expect(page.getByLabel("Current password")).toBeVisible();
164
+ // `exact` because "Confirm new password" also contains "New password".
165
+ await expect(page.getByLabel("New password", { exact: true })).toBeVisible();
166
+ await expect(page.getByLabel("Confirm new password")).toBeVisible();
164
167
  await page.getByRole("button", { name: "Sessions" }).click();
165
168
  await expect(page.getByText("Active sessions")).toBeVisible();
166
169
  await expect(page.getByText("Current", { exact: true })).toBeVisible();
167
170
  await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeVisible();
168
171
  });
169
172
 
173
+ // Changing a password is the one form here whose mistake is silent: the change
174
+ // succeeds, and the person finds out at the next sign-in with a password they
175
+ // cannot reproduce.
176
+ test("changing a password refuses a mistyped confirmation", async ({ page }) => {
177
+ await ready(page, "/admin/account");
178
+ await page.getByRole("button", { name: "Security" }).click();
179
+ await page.getByLabel("Current password").fill(ADMIN.password);
180
+ await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
181
+ await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pwX");
182
+ // The control: the message must not already be on screen, or this test would pass
183
+ // against a form that shows it unconditionally.
184
+ await expect(page.getByText("Passwords do not match")).toBeHidden();
185
+ await page.getByRole("button", { name: "Update password" }).click();
186
+ await expect(page.getByText("Passwords do not match")).toBeVisible();
187
+ });
188
+
170
189
  test("account nav shows the core sections", async ({ page }) => {
171
190
  await ready(page, "/admin/account");
172
191
  await expect(page.getByRole("button", { name: "Profile", exact: true })).toBeVisible();
@@ -182,7 +201,7 @@ test("two-factor setup shows a scannable QR code when enabled", async ({ page })
182
201
  await expect(page.getByRole("button", { name: "Enable", exact: true })).toBeVisible();
183
202
  // Start setup (password + Enable) and confirm the QR renders. Stop before
184
203
  // verifying so 2FA stays inactive and the shared admin session keeps working.
185
- await page.locator("#tf-on-pw").fill("Podokit3e-Str0ng!pw");
204
+ await page.locator("#tf-on-pw").fill(ADMIN.password);
186
205
  await page.getByRole("button", { name: "Enable", exact: true }).click();
187
206
  await expect(page.getByRole("img", { name: "TOTP QR code" })).toBeVisible();
188
207
  });
@@ -47,6 +47,8 @@ test("authentication return targets reject external and recursive redirects", ()
47
47
  test("signup page renders", async ({ page }) => {
48
48
  await page.goto("/signup");
49
49
  await expect(page.getByLabel("Name")).toBeVisible();
50
+ await expect(page.getByLabel("Password", { exact: true })).toBeVisible();
51
+ await expect(page.getByLabel("Confirm password")).toBeVisible();
50
52
  await expect(page.getByRole("button", { name: "Create account" })).toBeVisible();
51
53
  });
52
54
 
@@ -120,8 +122,31 @@ test("signup creates an account and enters the app @smoke", async ({ page }) =>
120
122
  const email = `signup-${Date.now()}@example.com`;
121
123
  await page.getByLabel("Name").fill("New User");
122
124
  await page.getByLabel("Email").fill(email);
123
- await page.getByLabel("Password").fill("Podokit3e-Str0ng!pw");
125
+ // `exact` because "Confirm password" also contains "Password".
126
+ await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
127
+ await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
124
128
  await page.getByRole("button", { name: "Create account" }).click();
125
129
  // Verification off → straight into the app; on → the verify-email page.
126
130
  await expect(page).toHaveURL((url) => url.pathname === "/" || url.pathname === "/verify-email");
127
131
  });
132
+
133
+ // A typo here is not recoverable the way a mistyped login is: the address has not
134
+ // been verified, so the reset link that would rescue the account is sent to an
135
+ // inbox the person cannot open.
136
+ test("signup refuses a mistyped password confirmation", async ({ page }) => {
137
+ await ready(page, "/signup");
138
+ const email = `signup-mismatch-${Date.now()}@example.com`;
139
+ await page.getByLabel("Name").fill("New User");
140
+ await page.getByLabel("Email").fill(email);
141
+ await page.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
142
+ await page.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pwX");
143
+ await page.getByRole("button", { name: "Create account" }).click();
144
+ await expect(page.getByText("Passwords do not match")).toBeVisible();
145
+ // Still on the form, and no account was created for that address.
146
+ await expect(page).toHaveURL(/\/signup/);
147
+ const probe = await page.context().request.post("/api/auth/sign-in/email", {
148
+ data: { email, password: "Podokit3e-Str0ng!pw" },
149
+ headers: { origin: new URL(page.url()).origin },
150
+ });
151
+ expect(probe.ok()).toBeFalsy();
152
+ });
@@ -31,7 +31,16 @@ test("forgot password: request, open the emailed link, set a new password, sign
31
31
  await page.goto(await waitForLink(email));
32
32
  await page.waitForLoadState("load");
33
33
  await page.waitForTimeout(400);
34
- await page.getByLabel("New password").fill("Podokit3e-N3wStr0ng!pw");
34
+ // `exact` because "Confirm new password" also contains "New password".
35
+ await page.getByLabel("New password", { exact: true }).fill("Podokit3e-N3wStr0ng!pw");
36
+ // A mismatch is refused, and the single-use token survives it: the same page
37
+ // still accepts the corrected pair below.
38
+ await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pwX");
39
+ await page.getByRole("button", { name: "Update password" }).click();
40
+ await expect(page.getByText("Passwords do not match")).toBeVisible();
41
+ await expect(page).not.toHaveURL(/\/login/);
42
+
43
+ await page.getByLabel("Confirm new password").fill("Podokit3e-N3wStr0ng!pw");
35
44
  await page.getByRole("button", { name: "Update password" }).click();
36
45
  await expect(page).toHaveURL(/\/login/);
37
46
 
@@ -1,5 +1,5 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
- import { anonState } from "../helpers/accounts";
2
+ import { ADMIN, USER, anonState } from "../helpers/accounts";
3
3
  import { totpCode } from "../helpers/totp";
4
4
 
5
5
  test.use({ storageState: anonState });
@@ -59,7 +59,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
59
59
  const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
60
60
  const caps = await (await admin.get("/api/account/capabilities")).json();
61
61
  test.skip(!caps?.twoFactor, "two-factor not enabled");
62
- await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: "Podokit3e-Str0ng!pw" } });
62
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
63
63
 
64
64
  const email = `rq-ui-${Date.now()}@example.com`;
65
65
  const pw = "Podokit3e-Str0ng!pw";
@@ -97,7 +97,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
97
97
  await admin.put("/api/account/settings", { data: { require2fa: false } });
98
98
  await expect(async () => {
99
99
  const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
100
- await probe.post("/api/auth/sign-in/email", { data: { email: "user@example.com", password: "Podokit3e-Str0ng!pw" } });
100
+ await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
101
101
  const status = (await probe.get("/api/account/me")).status();
102
102
  await probe.dispose();
103
103
  expect(status).toBe(200);
@@ -1,3 +1,4 @@
1
+ # Example only. See infra/k3s/README.md; `podo deploy` renders its own.
1
2
  # BullMQ worker — runs the same API image with the worker entrypoint.
2
3
  # It has no HTTP server, so there is no Service or Ingress.
3
4
  apiVersion: apps/v1
@@ -15,7 +16,7 @@ spec:
15
16
  spec:
16
17
  containers:
17
18
  - name: worker
18
- image: ghcr.io/example/podokit-api:latest
19
+ image: ghcr.io/example/podokit-api:v0.0.0
19
20
  command: ["node", "dist/main-worker"]
20
21
  envFrom:
21
22
  - configMapRef: { name: app-config }
@@ -70,7 +70,8 @@ Useful commands:
70
70
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
71
71
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
72
72
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
73
- - `podo deploy` — plan, apply, verify, or roll back an immutable Kubernetes release. Use `.agents/skills/podokit-deploy`.
73
+ - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
74
+ - `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
74
75
 
75
76
  ## Commit conventions
76
77