@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.
- package/README.md +7 -3
- package/dist/add.d.ts +16 -0
- package/dist/add.js +63 -20
- package/dist/assemble.js +9 -9
- package/dist/dev.d.ts +26 -0
- package/dist/dev.js +297 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +82 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +7 -1
- package/dist/locale.d.ts +22 -0
- package/dist/locale.js +184 -0
- package/dist/lockfile.d.ts +5 -3
- package/dist/lockfile.js +21 -5
- package/dist/remove.js +26 -23
- package/dist/templates/fullstack-nest-svelte/README.md +42 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/scripts/.keep +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +8 -11
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
- package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
- package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +14 -3
- package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +15 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +2 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +51 -0
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
- package/dist/templates/modules/auth/module.manifest.json +3 -0
- package/dist/templates/todo/README.md +42 -0
- package/dist/templates/todo/apps/api/Dockerfile +1 -0
- package/dist/templates/todo/apps/api/scripts/.keep +1 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
- package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
- package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
- package/dist/templates/todo/apps/web/vite.config.ts +3 -9
- package/dist/templates/todo/compose.dev.yaml +8 -11
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
- package/dist/templates/todo/dot-env.docker +2 -2
- package/dist/templates/todo/dot-gitignore +3 -0
- package/dist/templates/todo/dot-podokit/dev.json +4 -0
- package/dist/templates/todo/tests/README.md +10 -4
- package/dist/update.js +48 -7
- package/package.json +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
- package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
- 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
|
-
|
|
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.
|
|
10
|
-
await page.getByRole("
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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).
|
|
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).
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
[provider]
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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"]> = {
|
|
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(
|
|
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
|
-
//
|
|
71
|
-
//
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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: "
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Localization
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
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 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|