@podosoft/podokit 0.11.2 → 0.12.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 (81) hide show
  1. package/README.md +7 -3
  2. package/dist/dev.d.ts +26 -0
  3. package/dist/dev.js +297 -0
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.js +82 -2
  6. package/dist/lib.d.ts +2 -0
  7. package/dist/lib.js +7 -1
  8. package/dist/locale.d.ts +22 -0
  9. package/dist/locale.js +184 -0
  10. package/dist/lockfile.js +4 -0
  11. package/dist/templates/fullstack-nest-svelte/README.md +42 -0
  12. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
  16. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
  18. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -11
  19. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  20. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  21. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  22. package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
  23. package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
  24. package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
  25. package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
  55. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -0
  56. package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
  58. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
  59. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
  60. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
  61. package/dist/templates/todo/README.md +42 -0
  62. package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
  63. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  64. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  65. package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
  66. package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
  67. package/dist/templates/todo/apps/web/vite.config.ts +3 -9
  68. package/dist/templates/todo/compose.dev.yaml +6 -11
  69. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  70. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  71. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  72. package/dist/templates/todo/dot-env.docker +2 -2
  73. package/dist/templates/todo/dot-gitignore +3 -0
  74. package/dist/templates/todo/dot-podokit/dev.json +4 -0
  75. package/dist/templates/todo/tests/README.md +10 -4
  76. package/dist/update.js +23 -5
  77. package/package.json +1 -1
  78. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
  79. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
  80. package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
  81. package/dist/templates/todo/apps/web/src/lib/i18n/ko.ts +0 -11
@@ -1,13 +1,66 @@
1
1
  import { expect, test } from "@playwright/test";
2
- import { anonState } from "../helpers/accounts";
2
+ import { ADMIN, adminState, anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
4
 
5
5
  test.use({ storageState: anonState });
6
6
 
7
- test("language switch toggles Korean and sets <html lang>", async ({ page }) => {
7
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
+ const origin = { origin: base };
9
+
10
+ test("language switch updates messages and <html lang>", async ({ page }) => {
8
11
  await ready(page, "/login");
9
- await page.getByRole("button", { name: "Language" }).click();
10
- await page.getByRole("menuitem", { name: /한국어/ }).click();
11
- await expect(page.locator("html")).toHaveAttribute("lang", "ko");
12
- await expect(page.getByRole("button", { name: "로그인", exact: true })).toBeVisible();
12
+ const initial = await page.locator("html").getAttribute("lang");
13
+ await page.getByRole("button", { name: /^(Language|언어)$/ }).click();
14
+ if (initial === "ko") {
15
+ await page.getByRole("menuitem", { name: /English/ }).click();
16
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
17
+ await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
18
+ } else {
19
+ await page.getByRole("menuitem", { name: /한국어/ }).click();
20
+ await expect(page.locator("html")).toHaveAttribute("lang", "ko");
21
+ await expect(page.getByRole("button", { name: "로그인", exact: true })).toBeVisible();
22
+ }
23
+ });
24
+
25
+ test("explicit English overrides a Korean site fallback", async ({ page, playwright }) => {
26
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
27
+ await admin.post("/api/auth/sign-in/email", {
28
+ data: { email: ADMIN.email, password: ADMIN.password },
29
+ });
30
+ const previous = await (await admin.get("/api/site/settings")).json() as { locale?: string };
31
+
32
+ try {
33
+ expect((await admin.put("/api/site/settings", { data: { locale: "ko" } })).ok()).toBeTruthy();
34
+ await page.context().addCookies([{ name: "locale", value: "en", url: base }]);
35
+ await ready(page, "/login");
36
+
37
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
38
+ await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
39
+ } finally {
40
+ await admin.put("/api/site/settings", { data: { locale: previous.locale ?? "en" } });
41
+ await admin.dispose();
42
+ }
43
+ });
44
+
45
+ test("managed admin labels react to locale changes", async ({ browser }) => {
46
+ const context = await browser.newContext({ storageState: adminState });
47
+ const page = await context.newPage();
48
+
49
+ try {
50
+ await ready(page, "/admin/users");
51
+ const initial = await page.locator("html").getAttribute("lang");
52
+ if (initial === "ko") {
53
+ await expect(page.getByRole("columnheader", { name: "이메일" })).toBeVisible();
54
+ await page.getByRole("button", { name: "언어" }).click();
55
+ await page.getByRole("menuitem", { name: /English/ }).click();
56
+ await expect(page.getByRole("columnheader", { name: "Email" })).toBeVisible();
57
+ } else {
58
+ await expect(page.getByRole("columnheader", { name: "Email" })).toBeVisible();
59
+ await page.getByRole("button", { name: "Language" }).click();
60
+ await page.getByRole("menuitem", { name: /한국어/ }).click();
61
+ await expect(page.getByRole("columnheader", { name: "이메일" })).toBeVisible();
62
+ }
63
+ } finally {
64
+ await context.close();
65
+ }
13
66
  });
@@ -37,5 +37,5 @@ test("login: an emailed one-time code signs a user in @smoke", async ({ page, pl
37
37
  const otp = await waitForOtp(email);
38
38
  await page.getByLabel("One-time code").fill(otp);
39
39
  await page.getByRole("button", { name: "Verify code" }).click();
40
- await expect(page).toHaveURL(/\/admin/);
40
+ await expect(page).toHaveURL(new URL("/", base).toString());
41
41
  });
@@ -32,5 +32,5 @@ test("register a passkey and sign in with it @smoke", async ({ page, baseURL })
32
32
  await page.context().request.post("/api/auth/sign-out", { headers: origin });
33
33
  await ready(page, "/login");
34
34
  await page.getByRole("button", { name: "Use a passkey" }).click();
35
- await expect(page).toHaveURL(/\/admin(\/|$)/);
35
+ await expect(page).toHaveURL(new URL("/", baseURL).toString());
36
36
  });
@@ -7,6 +7,7 @@ test("settings: add and remove a social login provider @smoke", async ({ page })
7
7
  const dialog = page.getByRole("dialog");
8
8
  const addBtn = dialog.getByRole("button", { name: "Add provider" });
9
9
  const openConfigure = async (): Promise<void> => {
10
+ await page.waitForLoadState("networkidle");
10
11
  await page.getByRole("tab", { name: "Authentication" }).click();
11
12
  await page.getByRole("button", { name: "Configure" }).click();
12
13
  };
@@ -27,12 +28,16 @@ test("settings: add and remove a social login provider @smoke", async ({ page })
27
28
  const callback = await dialog.locator("code").textContent();
28
29
  const providerId = callback?.trim().split("/").pop() ?? ""; // first addable provider (not hard-coded)
29
30
  expect(providerId).not.toBe("");
31
+ const expectedCallback = new URL(`/api/auth/callback/${providerId}`, page.url()).toString();
32
+ expect(callback?.trim()).toBe(expectedCallback);
30
33
  try {
31
34
  await dialog.locator("#social-id").fill("test-client-id");
32
35
  await dialog.locator("#social-secret").fill("test-secret");
33
36
  await dialog.getByRole("button", { name: "Save", exact: true }).click();
34
37
  // Save returns to the list view, where the new provider row has an enable switch.
35
38
  await expect(dialog.getByRole("switch").first()).toBeVisible();
39
+ const saved = await (await page.request.get("/api/account/auth-config")).json();
40
+ expect(saved.social[providerId].redirectURI).toBe(expectedCallback);
36
41
  } finally {
37
42
  if (providerId) {
38
43
  await page.request
@@ -73,8 +73,8 @@ test("require-2fa: a new sign-up is forced through the enrolment page @smoke", a
73
73
  }).toPass({ timeout: 8000 });
74
74
  await probe.dispose();
75
75
 
76
- // Signing in through the UI lands on the mandatory enrolment page.
77
- await openHydratedLogin(page);
76
+ // Signing in toward a protected route lands on the mandatory enrolment page.
77
+ await openHydratedLogin(page, "/admin");
78
78
  await page.getByLabel("Email").fill(email);
79
79
  await page.getByLabel("Password").fill(pw);
80
80
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
@@ -13,6 +13,10 @@
13
13
  "@types/qrcode": "^1.5.5",
14
14
  "@types/multer": "^1.4.12"
15
15
  },
16
+ "ownedGlobs": [
17
+ "apps/web/src/lib/i18n/locales/**",
18
+ "apps/web/src/lib/i18n/catalogs/app/**"
19
+ ],
16
20
  "packageOverlays": {
17
21
  "api": {
18
22
  "scripts": {
@@ -16,6 +16,7 @@ function usage() {
16
16
 
17
17
  Usage:
18
18
  npm run auth:configure -- --provider google --require-signup-approval
19
+ npm run auth:configure -- --provider google --redirect-only
19
20
  npm run auth:configure -- --smtp
20
21
  npm run auth:configure -- --check-only --provider google
21
22
 
@@ -34,6 +35,7 @@ SMTP environment (with --smtp):
34
35
 
35
36
  Options:
36
37
  --provider <id> Configure a social provider
38
+ --redirect-only Update only the provider callback; keep credentials and enabled state
37
39
  --smtp Configure SMTP from environment variables
38
40
  --require-signup-approval Require admin approval for future registrations
39
41
  --no-require-signup-approval Auto-approve future registrations
@@ -114,6 +116,7 @@ async function main() {
114
116
  const rawOrigin = process.env.AUTH_SETUP_ORIGIN ?? process.env.BETTER_AUTH_URL ?? "http://localhost:5002";
115
117
  const origin = new URL(rawOrigin).origin;
116
118
  const provider = value("--provider");
119
+ const redirectOnly = has("--redirect-only");
117
120
  const configureSmtp = has("--smtp");
118
121
  const approval = has("--require-signup-approval")
119
122
  ? true
@@ -122,8 +125,12 @@ async function main() {
122
125
  : undefined;
123
126
 
124
127
  if (provider && !/^[a-z0-9-]+$/.test(provider)) throw new Error("Provider id must use lowercase letters, numbers, or hyphens.");
128
+ if (redirectOnly && !provider) throw new Error("--redirect-only requires --provider <id>.");
125
129
 
126
- if (provider) console.log(`OAuth callback URL: ${origin}/api/auth/callback/${provider}`);
130
+ const redirectURI = provider
131
+ ? process.env.OAUTH_REDIRECT_URI ?? `${origin}/api/auth/callback/${provider}`
132
+ : undefined;
133
+ if (redirectURI) console.log(`OAuth callback URL: ${redirectURI}`);
127
134
  if (has("--check-only")) {
128
135
  const capabilities = await publicCapabilities(origin);
129
136
  console.log(JSON.stringify(capabilities, null, 2));
@@ -135,16 +142,20 @@ async function main() {
135
142
 
136
143
  const body = {};
137
144
  if (provider) {
138
- const clientId = process.env.OAUTH_CLIENT_ID ?? process.env[providerEnvName(provider, "CLIENT_ID")];
139
- const clientSecret = process.env.OAUTH_CLIENT_SECRET ?? process.env[providerEnvName(provider, "CLIENT_SECRET")];
140
- body.social = {
141
- [provider]: {
142
- enabled: true,
143
- clientId: required("OAUTH_CLIENT_ID", clientId),
144
- clientSecret: required("OAUTH_CLIENT_SECRET", clientSecret),
145
- ...(process.env.OAUTH_REDIRECT_URI ? { redirectURI: process.env.OAUTH_REDIRECT_URI } : {}),
146
- },
147
- };
145
+ if (redirectOnly) {
146
+ body.social = { [provider]: { redirectURI } };
147
+ } else {
148
+ const clientId = process.env.OAUTH_CLIENT_ID ?? process.env[providerEnvName(provider, "CLIENT_ID")];
149
+ const clientSecret = process.env.OAUTH_CLIENT_SECRET ?? process.env[providerEnvName(provider, "CLIENT_SECRET")];
150
+ body.social = {
151
+ [provider]: {
152
+ enabled: true,
153
+ clientId: required("OAUTH_CLIENT_ID", clientId),
154
+ clientSecret: required("OAUTH_CLIENT_SECRET", clientSecret),
155
+ redirectURI,
156
+ },
157
+ };
158
+ }
148
159
  }
149
160
  if (configureSmtp) {
150
161
  body.smtp = {
@@ -3,7 +3,7 @@ import { APIError } from "better-auth/api";
3
3
  import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer } from "better-auth/plugins";
4
4
  import { actionEmail, sendMail } from "../mail/mailer";
5
5
  import { sendSms } from "../sms/sms";
6
- import { createFeatureGate } from "./feature-gate";
6
+ import { assertUserCreationAllowed, createFeatureGate, createSignupOpenCheck } from "./feature-gate";
7
7
  import { orgAc, orgRoles } from "./org-permissions";
8
8
  import { pool } from "./db";
9
9
  import { type AuthConfig, envAuthConfig, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
@@ -44,6 +44,7 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
44
44
  // server-enforced toggles) apply without a restart. Everything below the injection
45
45
  // markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
46
46
  export function buildAuth(config: AuthConfig) {
47
+ const isSignupOpen = createSignupOpenCheck(pool);
47
48
  const plugins: BetterAuthPlugin[] = [
48
49
  twoFactor(),
49
50
  magicLink({
@@ -130,6 +131,7 @@ export function buildAuth(config: AuthConfig) {
130
131
  before: async (user, context) => {
131
132
  const adminEmail = adminEmails.has(user.email.toLowerCase());
132
133
  const createdByAdmin = context?.path === "/admin/create-user";
134
+ assertUserCreationAllowed(await isSignupOpen(), context?.path);
133
135
  return {
134
136
  data: {
135
137
  ...user,
@@ -168,7 +170,9 @@ export function buildAuth(config: AuthConfig) {
168
170
  // Request-time hooks. The feature gate turns the admin Settings toggles into a
169
171
  // real server boundary (disabled features 404). Other PodoKit modules add their
170
172
  // own hooks below the marker.
171
- const hooks: NonNullable<BetterAuthOptions["hooks"]> = { before: createFeatureGate(pool) };
173
+ const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
174
+ before: createFeatureGate(pool, isSignupOpen),
175
+ };
172
176
  // podokit:begin:auth-hooks
173
177
  // podokit:end:auth-hooks
174
178
 
@@ -2,6 +2,8 @@ import { APIError, createAuthMiddleware } from "better-auth/api";
2
2
  import type { Pool } from "pg";
3
3
  import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
4
4
 
5
+ export const PUBLIC_SIGNUP_DISABLED = "PUBLIC_SIGNUP_DISABLED" as const;
6
+
5
7
  /** Auth-relative path prefixes owned by each admin-toggleable feature. A path is
6
8
  * gated when it equals a prefix or starts with a prefix ending in "/". */
7
9
  const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
@@ -19,12 +21,55 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
19
21
 
20
22
  const CACHE_TTL_MS = 3_000;
21
23
 
24
+ export type SignupOpenCheck = () => Promise<boolean>;
25
+
26
+ /** Public registration is the default until an administrator explicitly closes
27
+ * it. The short cache keeps every auth flow on the same live site-setting
28
+ * policy without rebuilding the Better Auth instance. */
29
+ export function createSignupOpenCheck(pool: Pool): SignupOpenCheck {
30
+ let signupOpen = true;
31
+ let fetchedAt = 0;
32
+
33
+ return async (): Promise<boolean> => {
34
+ if (Date.now() - fetchedAt < CACHE_TTL_MS) return signupOpen;
35
+ try {
36
+ const res = await pool.query<{ value: string }>(
37
+ 'SELECT "value" FROM "app_setting" WHERE "key" = $1',
38
+ ["site.allowSignup"],
39
+ );
40
+ signupOpen = res.rows[0]?.value !== "false";
41
+ } catch {
42
+ signupOpen = true;
43
+ }
44
+ fetchedAt = Date.now();
45
+ return signupOpen;
46
+ };
47
+ }
48
+
49
+ /** Administrator-created users are intentional and remain available when the
50
+ * public site is invite-only. Every other new-user path, including OAuth
51
+ * callbacks, must follow the public registration setting. */
52
+ export function isUserCreationAllowed(signupOpen: boolean, requestPath?: string): boolean {
53
+ return signupOpen || requestPath === "/admin/create-user";
54
+ }
55
+
56
+ export function assertUserCreationAllowed(signupOpen: boolean, requestPath?: string): void {
57
+ if (isUserCreationAllowed(signupOpen, requestPath)) return;
58
+ throw APIError.from("FORBIDDEN", {
59
+ code: PUBLIC_SIGNUP_DISABLED,
60
+ message: "Public sign-up is disabled.",
61
+ });
62
+ }
63
+
22
64
  /** Server-side enforcement for the admin Settings toggles: requests to a disabled
23
65
  * feature's endpoints are rejected with 404, so "off" is a real boundary rather
24
66
  * than hidden UI. Flags are read from app_setting through a small TTL cache
25
67
  * (a toggle takes effect within ~CACHE_TTL_MS); before the migration has run,
26
68
  * the shipped defaults apply. */
27
- export function createFeatureGate(pool: Pool) {
69
+ export function createFeatureGate(
70
+ pool: Pool,
71
+ isSignupOpen: SignupOpenCheck = createSignupOpenCheck(pool),
72
+ ) {
28
73
  let cache: Record<FeatureFlag, boolean> = { ...FLAG_DEFAULTS };
29
74
  let fetchedAt = 0;
30
75
 
@@ -45,32 +90,12 @@ export function createFeatureGate(pool: Pool) {
45
90
  return cache;
46
91
  }
47
92
 
48
- // Registration can be closed from the admin Settings ("Allow sign-up"). It is a
49
- // site setting (`site.allowSignup`), so the row is absent until an admin turns
50
- // it off — a missing/any-non-"false" value means open, matching the shipped
51
- // default. Cached the same way as the feature flags.
52
- let signupOpen = true;
53
- let signupFetchedAt = 0;
54
- async function isSignupOpen(): Promise<boolean> {
55
- if (Date.now() - signupFetchedAt < CACHE_TTL_MS) return signupOpen;
56
- try {
57
- const res = await pool.query<{ value: string }>(
58
- 'SELECT "value" FROM "app_setting" WHERE "key" = $1',
59
- ["site.allowSignup"],
60
- );
61
- signupOpen = res.rows[0]?.value !== "false";
62
- } catch {
63
- signupOpen = true;
64
- }
65
- signupFetchedAt = Date.now();
66
- return signupOpen;
67
- }
68
-
69
93
  return createAuthMiddleware(async (ctx) => {
70
- // Block email/password registration when sign-up is closed. Social/invite
71
- // flows are unaffected; admins create users from the dashboard.
94
+ // Reject explicit sign-up endpoints early. OAuth starts remain available so
95
+ // existing social users can sign in; their new-user callback is enforced by
96
+ // the database hook that uses this same policy.
72
97
  if (ctx.path === "/sign-up/email" || ctx.path.startsWith("/sign-up/")) {
73
- if (!(await isSignupOpen())) throw new APIError("FORBIDDEN", { message: "Sign-up is disabled." });
98
+ assertUserCreationAllowed(await isSignupOpen(), ctx.path);
74
99
  return;
75
100
  }
76
101
  const feature = FEATURE_PATHS.find((f) =>
@@ -26,7 +26,7 @@ Use the generated `auth:configure` command so credentials enter the encrypted `a
26
26
  ```
27
27
 
28
28
  4. Create or verify the initial administrator with `admin:bootstrap` before enabling mandatory email verification or other runtime authentication settings.
29
- 5. Derive the default callback as `<BETTER_AUTH_URL>/api/auth/callback/<provider>`. Only set `OAUTH_REDIRECT_URI` when the provider requires an explicit override.
29
+ 5. Derive the default callback as `<BETTER_AUTH_URL>/api/auth/callback/<provider>`. Only set `OAUTH_REDIRECT_URI` when the provider requires an explicit override. If credentials are already stored, use `--provider <id> --redirect-only` to update the callback without reading or replacing the client secret.
30
30
 
31
31
  ## Handle secrets safely
32
32
 
@@ -12,8 +12,52 @@ Use a Google Cloud project owned by the organization, not a personal project.
12
12
 
13
13
  Google may require verification for an External production app, especially when sensitive scopes or branding are involved. Keep the homepage, privacy policy, terms, and verified domain consistent with the OAuth consent screen.
14
14
 
15
+ ## Development
16
+
17
+ Keep normal development on the portless `*.localhost` origin provided by `podo dev`.
18
+ For an actual OAuth round trip, expose the web container through a stable HTTPS
19
+ development hostname using a Cloudflare Named Tunnel, a reserved ngrok domain, or a
20
+ preview deployment. Use a separate Google Cloud project and Web client for development.
21
+
22
+ 1. Add `https://app-dev.example.com` as an authorized JavaScript origin.
23
+ 2. Add `https://app-dev.example.com/api/auth/callback/google` as the exact redirect URI.
24
+ 3. Include both `http://app.localhost` and `https://app-dev.example.com` in
25
+ `CORS_ORIGIN`. Use the HTTPS origin as `BETTER_AUTH_URL` for the tunnel profile.
26
+ 4. Open Admin Settings through the HTTPS origin before saving Google. The displayed
27
+ callback is persisted. With the CLI, `AUTH_SETUP_ORIGIN` derives and persists the
28
+ same callback; set `OAUTH_REDIRECT_URI` only for an explicit override. Configure
29
+ without putting the secret on the command line:
30
+
31
+ ```bash
32
+ export AUTH_SETUP_ORIGIN="https://app-dev.example.com"
33
+ export AUTH_SETUP_ADMIN_EMAIL="admin@example.com"
34
+ export AUTH_SETUP_ADMIN_PASSWORD="<from-secret-manager>"
35
+ export OAUTH_CLIENT_ID="<google-client-id>"
36
+ export OAUTH_CLIENT_SECRET="<from-secret-manager>"
37
+ npm run auth:configure -w <app>-api -- --provider google --dry-run
38
+ npm run auth:configure -w <app>-api -- --provider google
39
+ ```
40
+
41
+ If only an existing callback is stale, keep the stored client ID, client
42
+ secret, and enabled state:
43
+
44
+ ```bash
45
+ AUTH_SETUP_ORIGIN=https://app-dev.example.com \
46
+ npm run auth:configure -w <app>-api -- --provider google --redirect-only
47
+ ```
48
+
49
+ 5. Start and finish Google sign-in through the HTTPS origin. Do not mix it with
50
+ `app.localhost`; OAuth state cookies are scoped to the origin that created them.
51
+
52
+ Protect development content with the tunnel provider's access control, but bypass
53
+ the exact `/api/auth/callback/*` path so Google can reach it. Quick tunnels with a
54
+ changing hostname are unsuitable for a registered callback. See the PodoKit
55
+ `docs/oauth-development.md` guide for the provider-neutral boundary.
56
+
15
57
  Official references:
16
58
 
17
59
  - https://developers.google.com/identity/protocols/oauth2/web-server
18
60
  - https://developers.google.com/identity/protocols/oauth2/policies
61
+ - https://support.google.com/cloud/answer/15549257
19
62
  - https://better-auth.com/docs/authentication/google
63
+ - https://better-auth.com/docs/plugins/oauth-proxy
@@ -8,10 +8,49 @@ Full-stack TypeScript app generated with [PodoKit](https://github.com/podosoft-d
8
8
 
9
9
  ## Getting started
10
10
 
11
+ ### Recommended: containerized development
12
+
11
13
  ```bash
12
14
  {{packageManager}} install
13
15
  cp .env.example .env
16
+ npx @podosoft/podokit dev watch
17
+ ```
18
+
19
+ Open the URL printed by the command; a new project defaults to
20
+ **http://{{projectName}}.localhost**. The committed `.podokit/dev.json` is the
21
+ source of truth if you change the hostname. The first running PodoKit project starts
22
+ one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
23
+ route by that hostname. Even a single app uses
24
+ the same topology, with one route and no project-specific host port.
25
+
26
+ In a second terminal, apply the included Todo migration and use the lifecycle helpers:
27
+
28
+ ```bash
29
+ npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
30
+ npx @podosoft/podokit dev url
31
+ npx @podosoft/podokit dev ps
32
+ npx @podosoft/podokit dev logs
33
+ npx @podosoft/podokit dev down
34
+ ```
35
+
36
+ If installed modules require extra services, use their profiles on the initial
37
+ watch command instead:
14
38
 
39
+ ```bash
40
+ npx @podosoft/podokit dev watch \
41
+ --profile cache --profile storage --profile queue
42
+ ```
43
+
44
+ Pass the same profile flags to other lifecycle commands when applicable. `dev down`
45
+ removes this project's stack and route. If it is the final registered
46
+ project, it also removes the shared gateway and network. Source changes are synced
47
+ through Compose Watch, including Vite HMR on the same portless browser origin.
48
+
49
+ ### Alternative: host processes
50
+
51
+ Use this loop when you want only the web and API processes on the host:
52
+
53
+ ```bash
15
54
  # start local PostgreSQL + Redis
16
55
  docker compose -f infra/docker/docker-compose.yml up -d
17
56
 
@@ -25,6 +64,9 @@ docker compose -f infra/docker/docker-compose.yml up -d
25
64
  - API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
26
65
  - Web: http://localhost:5001
27
66
 
67
+ For multi-project routing, container profiles, and OAuth over a stable HTTPS
68
+ tunnel, see the PodoKit [development guide](https://github.com/podosoft-dev/podokit/blob/main/docs/development.md).
69
+
28
70
  ## Database & migrations
29
71
 
30
72
  The API uses TypeORM with PostgreSQL. A sample `Todo` entity and an initial migration are included — replace them with your own domain model.
@@ -1,7 +1,9 @@
1
- # i18n
1
+ # Localization
2
2
 
3
- Locales use [typesafe-i18n](https://github.com/ivanhofer/typesafe-i18n).
3
+ Locale definitions live in `locales/<code>.json`. Product copy and intentional
4
+ module overrides live in the app-owned `catalogs/app/<code>.json` files.
4
5
 
5
- - `en.ts` is the base locale; `ko.ts` mirrors its shape.
6
- - Run `npx typesafe-i18n` to generate the typed runtime and Svelte store,
7
- then use `$LL.appTitle()` in components. Add keys to every locale.
6
+ The admin-dashboard module installs the composed JSON message runtime and its
7
+ complete English and Korean catalogs. Use `podo locale add`, `validate`, and
8
+ `activate` to extend it. See the generated `$podokit-add-locale` skill for the
9
+ complete workflow.
@@ -0,0 +1,6 @@
1
+ {
2
+ "code": "en",
3
+ "name": "English",
4
+ "direction": "ltr",
5
+ "enabled": true
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "code": "ko",
3
+ "name": "한국어",
4
+ "direction": "ltr",
5
+ "enabled": true
6
+ }
@@ -2,15 +2,10 @@ import { sveltekit } from "@sveltejs/kit/vite";
2
2
  import tailwindcss from "@tailwindcss/vite";
3
3
  import { defineConfig } from "vite";
4
4
 
5
- // Inside the dev container (compose.dev.yaml sets VITE_DOCKER=1): bind to all
6
- // interfaces, allow the Traefik hostname, and route HMR's WebSocket back through
7
- // Traefik. The browser reaches the app at Traefik's published host port, so the
8
- // HMR client must target that same port — otherwise the ws connects to :80,
9
- // fails, and Vite silently falls back to a full page reload on every change
10
- // (which wipes in-progress form input). compose.dev.yaml injects
11
- // VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
5
+ // Inside the dev container, bind to all interfaces and accept local gateway or
6
+ // HTTPS tunnel hostnames. Vite derives HMR's protocol, host, and port from the
7
+ // browser origin so the same configuration works for both entry points.
12
8
  const inDocker = process.env.VITE_DOCKER === "1";
13
- const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
14
9
 
15
10
  export default defineConfig({
16
11
  plugins: [tailwindcss(), sveltekit()],
@@ -19,7 +14,6 @@ export default defineConfig({
19
14
  host: true,
20
15
  port: 5001,
21
16
  allowedHosts: true,
22
- hmr: { clientPort: hmrClientPort },
23
17
  }
24
18
  : undefined,
25
19
  });
@@ -1,13 +1,11 @@
1
1
  # Containerized development environment.
2
2
  #
3
- # docker compose -f compose.dev.yaml watch # core stack
4
- # docker compose -f compose.dev.yaml --profile cache \
5
- # --profile storage --profile queue watch # with modules
3
+ # podo dev watch # core stack
4
+ # podo dev watch --profile cache --profile storage \
5
+ # --profile queue # with modules
6
6
  #
7
- # Only Traefik binds a host port (default 80). The app is at http://app.localhost.
8
- # Set TRAEFIK_PORT to publish on another port (e.g. TRAEFIK_PORT=8001) so several
9
- # containerized stacks can run at once without colliding — the web container reads
10
- # the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
7
+ # A user-level PodoKit gateway owns loopback :80 and routes each *.localhost host
8
+ # to this stack. The legacy per-project Traefik service is opt-in only.
11
9
  # api/postgres/redis/minio have NO published ports — they talk by service name
12
10
  # on the internal network, so this stack never collides with other projects.
13
11
  #
@@ -24,6 +22,7 @@ name: {{projectName}}-dev
24
22
 
25
23
  services:
26
24
  traefik:
25
+ profiles: [podokit-legacy-proxy]
27
26
  image: traefik:v3.5
28
27
  command:
29
28
  - --providers.file.filename=/etc/traefik/dynamic.yml
@@ -52,10 +51,6 @@ services:
52
51
  # getClientAddress() tied to the visitor instead of the proxy container.
53
52
  - ADDRESS_HEADER=x-forwarded-for
54
53
  - XFF_DEPTH=1
55
- # Match Traefik's published port so HMR's WebSocket targets the right port
56
- # (default 80). Without this, HMR connects to :80 and a non-80 stack falls
57
- # back to a full page reload on every edit.
58
- - VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
59
54
  depends_on: [api]
60
55
  networks: [podokit]
61
56
  develop:
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: podokit-add-locale
3
+ description: Add, translate, validate, activate, or troubleshoot a JSON locale in a PodoKit-generated SvelteKit app. Use when a user asks to add a language, split or edit locale JSON, check translation coverage or placeholders, configure fallback behavior, or verify locale changes through Vite HMR.
4
+ ---
5
+
6
+ # PodoKit Add Locale
7
+
8
+ Add languages through the PodoKit locale commands and the app-owned JSON catalog. Keep module catalogs managed so `podo update` can continue to update them.
9
+
10
+ ## Workflow
11
+
12
+ 1. Inspect the existing locale state.
13
+
14
+ ```sh
15
+ podo locale list
16
+ ```
17
+
18
+ 2. Add a canonical BCP 47 language tag. Set `--direction rtl` only for right-to-left languages.
19
+
20
+ ```sh
21
+ podo locale add pt-BR --name "Português (Brasil)"
22
+ ```
23
+
24
+ 3. Read [references/catalog-format.md](references/catalog-format.md), then translate the new app-owned file at `apps/web/src/lib/i18n/catalogs/app/<locale>.json`.
25
+
26
+ 4. Validate key coverage, JSON value types, and interpolation placeholders.
27
+
28
+ ```sh
29
+ podo locale validate pt-BR
30
+ ```
31
+
32
+ 5. Activate the locale. Partial catalogs are permitted because runtime fallback fills missing keys, but report the coverage percentage to the user.
33
+
34
+ ```sh
35
+ podo locale activate pt-BR
36
+ ```
37
+
38
+ 6. Run the app with its documented container-based development command. Verify the language switch, one complete key, one fallback key, and locale persistence. Editing an existing JSON file must update through Vite HMR without restarting the container. Adding a new locale file may trigger an automatic full-page reload.
39
+
40
+ ## Rules
41
+
42
+ - Treat `en` as the stable final fallback and never deactivate it.
43
+ - Preserve placeholders exactly. For example, translate `Hello {name}` without renaming or removing `{name}`.
44
+ - Put product-specific copy only in `catalogs/app/`. Do not edit `catalogs/admin-dashboard/` or other module catalogs directly.
45
+ - Keep locale definition filenames identical to their canonical codes, including case such as `pt-BR.json`.
46
+ - Do not activate a locale definition before its JSON parses and `podo locale validate` succeeds.
47
+ - Do not use the UI message catalogs for user-authored records. Dynamic content needs locale-aware records and a separate publication/fallback policy.
48
+ - When deactivating a locale, preserve its files so it can be reactivated without losing translations.
49
+
50
+ ## Fallback And Site Default
51
+
52
+ The message runtime resolves missing keys in this order: selected locale, configured site default, then English. The site default is an administrator choice, not a build-time constant. Changing it must not delete or rewrite catalogs.
53
+
54
+ If a requested language is unsupported or inactive, resolve it to the configured site default. Keep localized URL and SEO behavior in the consuming app; the catalogs provide translated strings but do not define routing policy.
55
+
56
+ ## Completion Checklist
57
+
58
+ - `podo locale validate` succeeds for every active locale.
59
+ - The new locale definition is active and has the intended display name and direction.
60
+ - App-owned JSON contains the new product copy; managed module catalogs remain untouched.
61
+ - Development edits update without a container restart.
62
+ - Automated tests cover selection, fallback, placeholders, and at least one incomplete catalog.
63
+ - User-facing locale setup or routing documentation is updated when behavior changes.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "PodoKit Locale Setup"
3
+ short_description: "Add and validate JSON locale catalogs safely"
4
+ default_prompt: "Use $podokit-add-locale to add, validate, activate, and test a new JSON locale in this PodoKit app."