@podosoft/podokit 0.17.2 → 0.17.4

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/dist/dev.js CHANGED
@@ -156,10 +156,15 @@ function writeRuntimeFiles(runtime) {
156
156
  (0, node_fs_1.writeFileSync)(runtime.runtimeComposePath, renderRuntimeCompose(runtime));
157
157
  }
158
158
  function registerRoute(runtime) {
159
- const collision = activeEntries().find((entry) => entry.hostname === runtime.config.hostname && entry.projectRoot !== runtime.projectRoot);
159
+ const entries = activeEntries();
160
+ const collision = entries.find((entry) => entry.hostname === runtime.config.hostname && entry.projectRoot !== runtime.projectRoot);
160
161
  if (collision) {
161
162
  throw new Error(`Development hostname ${runtime.config.hostname} is already registered by ${collision.projectRoot}. Change .podokit/dev.json or run podo dev down there.`);
162
163
  }
164
+ for (const stale of entries.filter((entry) => entry.projectRoot === runtime.projectRoot && entry.routeId !== runtime.routeId)) {
165
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(registryDirectory(), `${stale.routeId}.json`), { force: true });
166
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(routesDirectory(), `${stale.routeId}.yml`), { force: true });
167
+ }
163
168
  (0, node_fs_1.mkdirSync)(registryDirectory(), { recursive: true });
164
169
  (0, node_fs_1.mkdirSync)(routesDirectory(), { recursive: true });
165
170
  (0, node_fs_1.writeFileSync)(routePath(runtime), renderRoute(runtime));
@@ -307,6 +312,23 @@ function runDevCommand(projectRoot, action, args, runner = defaultRunner) {
307
312
  }
308
313
  return;
309
314
  }
315
+ if (action === "up") {
316
+ ensureNetwork(runner);
317
+ registerRoute(runtime);
318
+ try {
319
+ ensureGateway(runner);
320
+ process.stdout.write(`Development URL: http://${runtime.config.hostname}\n`);
321
+ if (runtime.config.publicUrl)
322
+ process.stdout.write(`Public OAuth URL: ${runtime.config.publicUrl}\n`);
323
+ runCompose(runtime, composeCommand("up", args, profiles), runner);
324
+ }
325
+ catch (error) {
326
+ unregisterRoute(runtime);
327
+ stopGatewayWhenUnused(runner);
328
+ throw error;
329
+ }
330
+ return;
331
+ }
310
332
  if (action === "down") {
311
333
  runCompose(runtime, composeCommand("down", args, profiles), runner);
312
334
  unregisterRoute(runtime);
@@ -317,5 +339,5 @@ function runDevCommand(projectRoot, action, args, runner = defaultRunner) {
317
339
  runCompose(runtime, composeCommand(action, args, profiles), runner);
318
340
  return;
319
341
  }
320
- throw new Error("Usage: podo dev <watch|exec|logs|ps|down|url> [docker compose options]");
342
+ throw new Error("Usage: podo dev <watch|up|exec|logs|ps|down|url> [docker compose options]");
321
343
  }
@@ -27,6 +27,7 @@ Run lifecycle and container commands from a second terminal:
27
27
 
28
28
  ```bash
29
29
  npx @podosoft/podokit dev url
30
+ npx @podosoft/podokit dev up -d # detached stack without source watching
30
31
  npx @podosoft/podokit dev ps
31
32
  npx @podosoft/podokit dev logs
32
33
  npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
@@ -35,6 +36,8 @@ npx @podosoft/podokit dev down
35
36
 
36
37
  `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
37
38
  `storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
39
+ `dev up` uses the same shared gateway and module profiles without keeping Compose
40
+ Watch attached.
38
41
  You can still activate an additional Compose profile explicitly:
39
42
 
40
43
  ```bash
@@ -1,4 +1,8 @@
1
1
  import { loadAccountData } from "$lib/account-data.server";
2
+ import { loadProtectedLayout } from "$lib/server/protected-layout";
2
3
  import type { PageServerLoad } from "./$types";
3
4
 
4
- export const load: PageServerLoad = ({ locals, fetch }) => loadAccountData(locals, fetch);
5
+ export const load: PageServerLoad = async ({ locals, fetch }) => {
6
+ await loadProtectedLayout({ locals, fetch });
7
+ return loadAccountData(locals, fetch);
8
+ };
@@ -8,6 +8,6 @@ export const load: PageServerLoad = ({ locals }) => {
8
8
  requireBackendAvailable(locals);
9
9
  if (!locals.user) redirect(302, "/login");
10
10
  const user = locals.user as App.Locals["user"] & { twoFactorEnabled?: boolean };
11
- if (user.twoFactorEnabled) redirect(302, "/admin");
11
+ if (user.twoFactorEnabled) redirect(302, "/account");
12
12
  return { user: locals.user };
13
13
  };
@@ -51,7 +51,7 @@
51
51
  error = i18n.t.auth.twoFactorInvalidCode;
52
52
  return;
53
53
  }
54
- await goto("/admin", { invalidateAll: true });
54
+ await goto("/account", { invalidateAll: true });
55
55
  }
56
56
 
57
57
  function downloadCodes(): void {
@@ -15,11 +15,13 @@ async function loginFresh(page: import("@playwright/test").Page, playwright: imp
15
15
  const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
16
16
  await api.post("/api/auth/sign-up/email", { data: { email, password: PW, name: "Acc" } });
17
17
  await api.dispose();
18
- await ready(page, `/login?redirect=${encodeURIComponent("/admin/account")}`);
18
+ await ready(page, `/login?redirect=${encodeURIComponent("/account")}`);
19
19
  await page.locator("#email").fill(email);
20
20
  await page.locator("#password").fill(PW);
21
21
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
22
- await expect(page).toHaveURL(/\/admin\/account/);
22
+ await expect(page).toHaveURL((url) => url.pathname === "/account");
23
+ await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
24
+ await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
23
25
  }
24
26
 
25
27
  test("account: register and verify a phone number", async ({ page, playwright }) => {
@@ -15,6 +15,12 @@ async function openHydratedLogin(page: Page, redirect?: string): Promise<void> {
15
15
  await page.waitForLoadState("networkidle");
16
16
  }
17
17
 
18
+ async function expectAccountPage(page: Page): Promise<void> {
19
+ await expect(page).toHaveURL((url) => url.pathname === "/account");
20
+ await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
21
+ await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
22
+ }
23
+
18
24
  // A disposable account (never the shared admin/user) so enabling 2FA here can't
19
25
  // invalidate the seeded sessions the other auth specs rely on.
20
26
  test("two-factor: sign in with a backup code from the login page", async ({ page, playwright }) => {
@@ -31,7 +37,7 @@ test("two-factor: sign in with a backup code from the login page", async ({ page
31
37
  await api.dispose();
32
38
 
33
39
  // Password sign-in hands off to the second-factor step (no session yet).
34
- await openHydratedLogin(page, "/admin/account");
40
+ await openHydratedLogin(page, "/account");
35
41
  await page.getByLabel("Email").fill(email);
36
42
  await page.getByLabel("Password").fill(pw);
37
43
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
@@ -52,7 +58,30 @@ test("two-factor: sign in with a backup code from the login page", async ({ page
52
58
  // The real code completes the login.
53
59
  await page.getByLabel("Backup code").fill(backupCode);
54
60
  await page.getByRole("button", { name: "Verify", exact: true }).click();
55
- await expect(page).toHaveURL(/\/admin\/account/);
61
+ await expectAccountPage(page);
62
+ });
63
+
64
+ test("two-factor: sign in with an authenticator code from the login page", async ({ page, playwright }) => {
65
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
66
+ const caps = await (await api.get("/api/account/capabilities")).json();
67
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
68
+
69
+ const email = `tf-totp-ui-${Date.now()}@example.com`;
70
+ const pw = "Podokit3e-Str0ng!pw";
71
+ await api.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "UI" } });
72
+ const enable = await (await api.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
73
+ await api.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
74
+ await api.dispose();
75
+
76
+ await openHydratedLogin(page, "/account");
77
+ await page.getByLabel("Email").fill(email);
78
+ await page.getByLabel("Password").fill(pw);
79
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
80
+ await expect(page.getByTestId("two-factor-step")).toBeVisible();
81
+ await page.getByLabel("Authentication code").fill(totpCode(enable.totpURI));
82
+ await page.getByRole("button", { name: "Verify", exact: true }).click();
83
+
84
+ await expectAccountPage(page);
56
85
  });
57
86
 
58
87
  test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
@@ -74,7 +103,7 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
74
103
  await probe.dispose();
75
104
 
76
105
  // Signing in toward a protected route lands on the mandatory enrolment page.
77
- await openHydratedLogin(page, "/admin");
106
+ await openHydratedLogin(page, "/account");
78
107
  await page.getByLabel("Email").fill(email);
79
108
  await page.getByLabel("Password").fill(pw);
80
109
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
@@ -91,8 +120,12 @@ test("require-2fa: a new sign-up is forced through the enrolment page", async ({
91
120
  await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
92
121
  await page.getByRole("button", { name: "Activate and continue" }).click();
93
122
 
94
- // Enrolled → the gate lets them into the app (no longer bounced to /setup-2fa).
95
- await expect(page).toHaveURL(/\/admin/);
123
+ // Enrolled → the gate lets the regular user into their account page.
124
+ await expectAccountPage(page);
125
+
126
+ // An enrolled user who revisits setup is returned to the same non-admin page.
127
+ await page.goto("/setup-2fa");
128
+ await expectAccountPage(page);
96
129
  } finally {
97
130
  await admin.put("/api/account/settings", { data: { require2fa: false } });
98
131
  await expect(async () => {
@@ -119,14 +152,14 @@ test("account: regenerate backup codes shows a fresh set", async ({ page, playwr
119
152
  await api.dispose();
120
153
 
121
154
  // Sign in (backup-code path) straight to the account page.
122
- await openHydratedLogin(page, "/admin/account");
155
+ await openHydratedLogin(page, "/account");
123
156
  await page.getByLabel("Email").fill(email);
124
157
  await page.getByLabel("Password").fill(pw);
125
158
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
126
159
  await page.getByRole("button", { name: "Use a backup code instead" }).click();
127
160
  await page.getByLabel("Backup code").fill(backupCode);
128
161
  await page.getByRole("button", { name: "Verify", exact: true }).click();
129
- await expect(page).toHaveURL(/\/admin\/account/);
162
+ await expectAccountPage(page);
130
163
 
131
164
  // Security → regenerate backup codes → a fresh set is shown.
132
165
  await page.getByRole("button", { name: "Security" }).click();
@@ -28,6 +28,7 @@ In a second terminal, apply the included Todo migration and use the lifecycle he
28
28
  ```bash
29
29
  npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
30
30
  npx @podosoft/podokit dev url
31
+ npx @podosoft/podokit dev up -d # detached stack without source watching
31
32
  npx @podosoft/podokit dev ps
32
33
  npx @podosoft/podokit dev logs
33
34
  npx @podosoft/podokit dev down
@@ -35,6 +36,8 @@ npx @podosoft/podokit dev down
35
36
 
36
37
  `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
37
38
  `storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
39
+ `dev up` uses the same shared gateway and module profiles without keeping Compose
40
+ Watch attached.
38
41
  You can still activate an additional Compose profile explicitly:
39
42
 
40
43
  ```bash
package/dist/update.js CHANGED
@@ -151,6 +151,14 @@ function planUpdate(projectRoot, templatesDir) {
151
151
  const classified = (0, lockfile_1.classifyTier)(path, newText ?? disk ?? "", ownedGlobs, managedOverrides);
152
152
  const tier = controlledByTarget ? classified : locked?.tier ?? classified;
153
153
  if (tier === "owned") {
154
+ // A file introduced by a newer template or module has no previous lock
155
+ // entry. Seed it once when the application has not already claimed that
156
+ // path; subsequent edits or deletions stay protected by the recorded
157
+ // owned entry.
158
+ if (!locked && disk === null && newText !== null) {
159
+ changes.push({ path, tier, action: "add", note: "new owned seed in this version" });
160
+ continue;
161
+ }
154
162
  const explicitlyOwned = ownedGlobs.some((glob) => !glob.includes("*") && (0, lockfile_1.matchGlob)(path, glob));
155
163
  changes.push({
156
164
  path,
@@ -283,6 +291,18 @@ function updatedFilesLock(projectRoot, newTree, previous, ownedGlobs, managedOve
283
291
  delete next.files[path];
284
292
  }
285
293
  }
294
+ // Keep a tombstone for an owned seed that the application deleted or moved.
295
+ // Without the previous entry, the next update would mistake the missing path
296
+ // for a newly introduced seed and recreate it.
297
+ for (const [path, entry] of Object.entries(previous.files)) {
298
+ if (entry.tier !== "owned" || next.files[path])
299
+ continue;
300
+ const newText = treeText(newTree, path);
301
+ if (newText !== null &&
302
+ (0, lockfile_1.classifyTier)(path, newText, ownedGlobs, managedOverrides) === "owned") {
303
+ next.files[path] = entry;
304
+ }
305
+ }
286
306
  return next;
287
307
  }
288
308
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {