@podosoft/podokit 0.17.1 → 0.17.3

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
@@ -384,6 +384,8 @@
384
384
  }
385
385
 
386
386
  // Sessions — this user's own devices
387
+ /** better-auth's code for "this session is older than `session.freshAge`". */
388
+ const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
387
389
  type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
388
390
  const PAGE_SIZE = DEFAULT_PAGE_SIZE;
389
391
  let sessions = $state<Session[]>([]);
@@ -397,8 +399,30 @@
397
399
  { key: "status", label: i18n.t.sessions.status },
398
400
  { key: "actions", label: "", class: "w-10" },
399
401
  ];
402
+ /**
403
+ * True when the session is too old to list sessions.
404
+ *
405
+ * better-auth guards `/list-sessions` (and `/unlink-account`) with its freshness
406
+ * middleware, and `session.freshAge` defaults to 24 hours. So for anyone who signed
407
+ * in yesterday — which is most people, most of the time — this call returns 403
408
+ * `SESSION_NOT_FRESH`. It is not a failure to report as one: the tab has to say what
409
+ * happened and what to do, or it is a blank table under an untranslated toast.
410
+ */
411
+ let sessionsStale = $state(false);
412
+ async function signOutToRefresh(): Promise<void> {
413
+ busy = true;
414
+ await api.auth.signOut();
415
+ await goto("/login", { invalidateAll: true });
416
+ }
400
417
  async function loadSessions(): Promise<void> {
401
418
  const { data: res, error } = await api.auth.listSessions();
419
+ if (error?.code === SESSION_NOT_FRESH) {
420
+ // Explained in the panel below, not thrown at the corner of the screen.
421
+ sessionsStale = true;
422
+ sessions = [];
423
+ return;
424
+ }
425
+ sessionsStale = false;
402
426
  if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
403
427
  // Current session first (users expect it on top; also keeps it on page 1),
404
428
  // then newest to oldest.
@@ -791,9 +815,21 @@
791
815
  <Card.Root>
792
816
  <Card.Header class="flex flex-row items-center justify-between gap-2">
793
817
  <Card.Title>{i18n.t.sessions.title}</Card.Title>
794
- <Button variant="outline" size="sm" disabled={busy || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
818
+ <Button variant="outline" size="sm" disabled={busy || sessionsStale || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
795
819
  </Card.Header>
796
820
  <Card.Content>
821
+ {#if sessionsStale}
822
+ <!-- The list is withheld by the server, so the card explains the rule and
823
+ offers the only thing that satisfies it. A toast would have scrolled
824
+ away and left an empty table behind it. -->
825
+ <div class="flex flex-col items-start gap-3" data-testid="sessions-stale">
826
+ <p class="text-muted-foreground text-sm">{i18n.t.sessions.staleTitle}</p>
827
+ <p class="text-muted-foreground text-xs">{i18n.t.sessions.staleHint}</p>
828
+ <Button variant="outline" size="sm" disabled={busy} onclick={signOutToRefresh} data-testid="sessions-stale-signin">
829
+ {i18n.t.sessions.staleAction}
830
+ </Button>
831
+ </div>
832
+ {:else}
797
833
  <DataTable
798
834
  columns={sessionsColumns}
799
835
  rows={sessions}
@@ -815,6 +851,7 @@
815
851
  </Table.Cell>
816
852
  {/snippet}
817
853
  </DataTable>
854
+ {/if}
818
855
  </Card.Content>
819
856
  </Card.Root>
820
857
  {:else if section === "apiKeys"}
@@ -483,7 +483,10 @@
483
483
  "switchAccount": "Switch",
484
484
  "switchFailed": "Failed to switch account",
485
485
  "othersRevoked": "Signed out other sessions",
486
- "total": "{count} sessions"
486
+ "total": "{count} sessions",
487
+ "staleTitle": "Sign in again to see where you are signed in.",
488
+ "staleHint": "For your security this list is only shown to a session that signed in recently.",
489
+ "staleAction": "Sign in again"
487
490
  },
488
491
  "adminSessions": {
489
492
  "title": "User sessions",
@@ -483,7 +483,10 @@
483
483
  "switchAccount": "전환",
484
484
  "switchFailed": "계정 전환에 실패했습니다",
485
485
  "othersRevoked": "다른 세션을 모두 종료했습니다",
486
- "total": "세션 {count}개"
486
+ "total": "세션 {count}개",
487
+ "staleTitle": "어디에서 로그인되어 있는지 보려면 다시 로그인하세요.",
488
+ "staleHint": "보안을 위해 이 목록은 최근에 로그인한 세션에만 보여줍니다.",
489
+ "staleAction": "다시 로그인"
487
490
  },
488
491
  "adminSessions": {
489
492
  "title": "사용자 세션",
@@ -170,6 +170,39 @@ test("account security and sessions sub-navigation", async ({ page }) => {
170
170
  await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeVisible();
171
171
  });
172
172
 
173
+ /**
174
+ * ⚠ THE SUITE ALWAYS SIGNS IN FIRST, WHICH IS WHY THIS NEEDED A MOCK.
175
+ *
176
+ * better-auth guards `/list-sessions` with its freshness middleware and
177
+ * `session.freshAge` defaults to 24 hours, so the endpoint returns 403
178
+ * `SESSION_NOT_FRESH` to anyone who signed in yesterday. The seeded session is
179
+ * minutes old, so the test above passes for every run while the screen is broken for
180
+ * real users — the tab showed an empty table under an untranslated "Session is not
181
+ * fresh" toast. Forcing the response is the only way to reach the state the suite's
182
+ * own setup prevents.
183
+ */
184
+ test("sessions tab explains a session that is too old to list them", async ({ page }) => {
185
+ await page.route("**/api/auth/list-sessions", (route) =>
186
+ route.fulfill({
187
+ status: 403,
188
+ contentType: "application/json",
189
+ body: JSON.stringify({ message: "Session is not fresh", code: "SESSION_NOT_FRESH" }),
190
+ }),
191
+ );
192
+
193
+ await ready(page, "/admin/account");
194
+ await page.getByRole("button", { name: "Sessions" }).click();
195
+
196
+ const panel = page.getByTestId("sessions-stale");
197
+ await expect(panel).toBeVisible();
198
+ // The raw upstream string must not be what a person reads.
199
+ await expect(page.getByText("Session is not fresh")).toHaveCount(0);
200
+ // And the one action that satisfies the rule is offered rather than described.
201
+ await expect(page.getByTestId("sessions-stale-signin")).toBeVisible();
202
+ // Revoking is meaningless while the list is withheld, so it is not offered either.
203
+ await expect(page.getByRole("button", { name: "Sign out other sessions" })).toBeDisabled();
204
+ });
205
+
173
206
  // Changing a password is the one form here whose mistake is silent: the change
174
207
  // succeeds, and the person finds out at the next sign-in with a password they
175
208
  // cannot reproduce.
@@ -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.1",
3
+ "version": "0.17.3",
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": {