@podosoft/podokit 0.11.1 → 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 (94) hide show
  1. package/README.md +7 -3
  2. package/dist/add.d.ts +16 -0
  3. package/dist/add.js +63 -20
  4. package/dist/assemble.js +9 -9
  5. package/dist/dev.d.ts +26 -0
  6. package/dist/dev.js +297 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +82 -2
  9. package/dist/lib.d.ts +2 -0
  10. package/dist/lib.js +7 -1
  11. package/dist/locale.d.ts +22 -0
  12. package/dist/locale.js +184 -0
  13. package/dist/lockfile.d.ts +5 -3
  14. package/dist/lockfile.js +21 -5
  15. package/dist/remove.js +26 -23
  16. package/dist/templates/fullstack-nest-svelte/README.md +42 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +1 -0
  18. package/dist/templates/fullstack-nest-svelte/apps/api/scripts/.keep +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  21. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  22. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
  25. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +8 -11
  26. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  27. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  28. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  29. package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
  30. package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
  31. package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +187 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +14 -3
  64. package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
  65. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
  67. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +15 -2
  68. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +2 -2
  69. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +51 -0
  70. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
  71. package/dist/templates/modules/auth/module.manifest.json +3 -0
  72. package/dist/templates/todo/README.md +42 -0
  73. package/dist/templates/todo/apps/api/Dockerfile +1 -0
  74. package/dist/templates/todo/apps/api/scripts/.keep +1 -0
  75. package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
  76. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  77. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  78. package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
  79. package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
  80. package/dist/templates/todo/apps/web/vite.config.ts +3 -9
  81. package/dist/templates/todo/compose.dev.yaml +8 -11
  82. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  83. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  84. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  85. package/dist/templates/todo/dot-env.docker +2 -2
  86. package/dist/templates/todo/dot-gitignore +3 -0
  87. package/dist/templates/todo/dot-podokit/dev.json +4 -0
  88. package/dist/templates/todo/tests/README.md +10 -4
  89. package/dist/update.js +48 -7
  90. package/package.json +1 -1
  91. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
  92. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
  93. package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
  94. 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,17 @@
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
+ ],
20
+ "packageOverlays": {
21
+ "api": {
22
+ "scripts": {
23
+ "admin:bootstrap": "node scripts/bootstrap-admin.mjs"
24
+ }
25
+ }
26
+ },
16
27
  "env": [
17
28
  "# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
18
29
  "ADMIN_EMAILS=admin@example.com"
@@ -42,7 +53,7 @@
42
53
  "file": "AGENTS.md",
43
54
  "marker": "<!-- podokit:end:agents-modules -->",
44
55
  "optional": true,
45
- "text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Emails in `ADMIN_EMAILS` become admins on sign-up. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
56
+ "text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `npm run admin:bootstrap -w <app>-api`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
46
57
  },
47
58
  {
48
59
  "file": "apps/api/src/app.module.ts",
@@ -56,9 +67,9 @@
56
67
  }
57
68
  ],
58
69
  "instructions": [
59
- "Set ADMIN_EMAILS in .env (comma-separated). Those emails become admins on sign-up.",
70
+ "Set ADMIN_EMAILS in .env (comma-separated). After building and migrating, inject ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD for one command only, then run npm run admin:bootstrap -w <app>-api. The command creates a verified, approved admin or safely verifies an existing one.",
60
71
  "Re-run the auth migration to add the admin and sign-up approval columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
61
- "Start the app (npm run dev), open /signup, register an ADMIN_EMAILS address, then sign in.",
72
+ "Remove ADMIN_BOOTSTRAP_PASSWORD from the environment after bootstrap. Keep ADMIN_EMAILS so matching future sign-ups are promoted consistently.",
62
73
  "Admin pages: /admin/users (list, approve pending registrations, ban, set role, revoke sessions). Signed-in users manage their profile and security at /account; /admin/account remains available in the admin shell. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
63
74
  "Password reset emails go through the mailer module (installed automatically with auth); in dev without SMTP they are logged to the API console. Configure SMTP in admin settings or via SMTP_* env for production."
64
75
  ]
@@ -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) =>
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: podokit-configure-auth
3
- description: Configure and verify PodoKit social OAuth providers, SMTP delivery, and the administrator sign-up approval policy. Use when enabling Google, Apple, or another Better Auth provider; calculating callback URLs; applying credentials through Admin Settings; configuring outbound mail; or diagnosing why a configured sign-in method is unavailable.
3
+ description: Bootstrap and verify the initial PodoKit administrator, then configure social OAuth providers, SMTP delivery, and sign-up approval. Use when setting the first admin email/password; enabling Google, Apple, or another Better Auth provider; calculating callback URLs; applying credentials through Admin Settings; configuring outbound mail; or diagnosing unavailable sign-in methods.
4
4
  ---
5
5
 
6
6
  # Configure PodoKit authentication
@@ -12,6 +12,7 @@ Use the generated `auth:configure` command so credentials enter the encrypted `a
12
12
  - Read [references/google.md](references/google.md) for Google sign-in, including External audience requirements.
13
13
  - Read [references/apple.md](references/apple.md) for Apple sign-in and client-secret rotation.
14
14
  - Read [references/smtp.md](references/smtp.md) for Google Workspace SMTP relay and DNS authentication.
15
+ - Read [references/bootstrap-admin.md](references/bootstrap-admin.md) before creating or repairing access to the first administrator.
15
16
  - Run the same generated command for other provider IDs. Confirm the provider appears in Admin Settings before applying it.
16
17
 
17
18
  ## Prepare the app
@@ -24,7 +25,8 @@ Use the generated `auth:configure` command so credentials enter the encrypted `a
24
25
  npm run migrate:all -w <app>-api
25
26
  ```
26
27
 
27
- 4. Derive the default callback as `<BETTER_AUTH_URL>/api/auth/callback/<provider>`. Only set `OAUTH_REDIRECT_URI` when the provider requires an explicit override.
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. If credentials are already stored, use `--provider <id> --redirect-only` to update the callback without reading or replacing the client secret.
28
30
 
29
31
  ## Handle secrets safely
30
32
 
@@ -44,6 +46,17 @@ export OAUTH_CLIENT_SECRET
44
46
  export OAUTH_CLIENT_ID="provider-client-id"
45
47
  ```
46
48
 
49
+ The bootstrap command accepts the same `AUTH_SETUP_ADMIN_EMAIL` and
50
+ `AUTH_SETUP_ADMIN_PASSWORD` variables as fallbacks. Keep `ADMIN_EMAILS` in the
51
+ deployment environment, run the bootstrap once, and reuse the ephemeral
52
+ credentials for `auth:configure`:
53
+
54
+ ```bash
55
+ export ADMIN_EMAILS="admin@example.com"
56
+ npm run admin:bootstrap -w <app>-api -- --dry-run
57
+ npm run admin:bootstrap -w <app>-api
58
+ ```
59
+
47
60
  Unset sensitive variables when finished:
48
61
 
49
62
  ```bash
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "PodoKit Auth Setup"
3
- short_description: "Configure social login, SMTP, and signup approval"
4
- default_prompt: "Use $podokit-configure-auth to configure Google login, SMTP, and sign-up approval safely."
3
+ short_description: "Bootstrap admin and configure authentication safely"
4
+ default_prompt: "Use $podokit-configure-auth to bootstrap the first administrator, then configure Google login, SMTP, and sign-up approval safely."
@@ -0,0 +1,51 @@
1
+ # Bootstrap the initial administrator
2
+
3
+ Use the generated database-backed command after the API build and migrations.
4
+ Do not create the first administrator through the public sign-up page.
5
+
6
+ ## Set the persistent allowlist
7
+
8
+ Set `ADMIN_EMAILS` in the same deployment environment used by the API. Include
9
+ the exact lowercase-normalized address selected for the initial administrator.
10
+ Keep this value after bootstrap so future matching sign-ups retain the same role
11
+ policy.
12
+
13
+ ## Inject credentials for one command
14
+
15
+ Prefer the deployment secret manager or a hidden shell prompt. Do not put the
16
+ password in a command argument, repository file, image layer, CI log, or
17
+ long-lived Kubernetes Secret.
18
+
19
+ ```bash
20
+ export ADMIN_BOOTSTRAP_EMAIL="admin@example.com"
21
+ printf "Initial admin password: " >&2
22
+ IFS= read -r -s ADMIN_BOOTSTRAP_PASSWORD
23
+ printf "\n" >&2
24
+ export ADMIN_BOOTSTRAP_PASSWORD
25
+ ```
26
+
27
+ `AUTH_SETUP_ADMIN_EMAIL` and `AUTH_SETUP_ADMIN_PASSWORD` are accepted as
28
+ fallbacks so the same ephemeral values can immediately configure OAuth and SMTP.
29
+
30
+ ## Preview, create, and verify
31
+
32
+ ```bash
33
+ npm run admin:bootstrap -w <app>-api -- --dry-run
34
+ npm run admin:bootstrap -w <app>-api
35
+ npm run admin:bootstrap -w <app>-api -- --check-only
36
+ ```
37
+
38
+ The create path writes `role=admin`, `emailVerified=true`, and
39
+ `signupApproved=true`. A rerun never updates an existing row or resets a
40
+ password. It succeeds only when the account still has the admin role, verified
41
+ and approved flags, and the supplied password matches.
42
+
43
+ Unset the password immediately after `auth:configure` or the verification step:
44
+
45
+ ```bash
46
+ unset ADMIN_BOOTSTRAP_PASSWORD AUTH_SETUP_ADMIN_PASSWORD
47
+ ```
48
+
49
+ If the command reports an existing mismatched account, stop. Recover it through
50
+ an authenticated administrator or an audited operator procedure; do not delete,
51
+ promote, verify, or reset the row automatically.
@@ -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
@@ -3,6 +3,9 @@
3
3
  "description": "Full authentication (better-auth): email/password, sessions, and \u2014 via config \u2014 OAuth and 2FA. Installs a global guard so the API is secure by default; health and /api/auth stay public.",
4
4
  "requires": ["mailer"],
5
5
  "targetApp": "api",
6
+ "managedOverrides": [
7
+ ".claude/skills/podokit-configure-auth/**"
8
+ ],
6
9
  "dependencies": {
7
10
  "@podosoft/podokit-auth": "^0.2.0",
8
11
  "@podosoft/podokit-contracts": "^0.2.0",
@@ -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.
@@ -20,6 +20,7 @@ RUN addgroup -S app && adduser -S app -G app
20
20
  COPY --from=build /app/node_modules ./node_modules
21
21
  COPY --from=deps /app/apps/api ./apps/api
22
22
  COPY --from=build /app/apps/api/dist ./apps/api/dist
23
+ COPY --from=build /app/apps/api/scripts ./apps/api/scripts
23
24
  WORKDIR /app/apps/api
24
25
  USER app
25
26
  EXPOSE 3000
@@ -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.