@podosoft/podokit 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/deploy-checks.d.ts +20 -2
  2. package/dist/deploy-checks.js +83 -4
  3. package/dist/deploy-compose-render.js +15 -4
  4. package/dist/deploy-compose.js +211 -27
  5. package/dist/deploy.js +16 -0
  6. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
  7. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
  8. package/dist/templates/fullstack/tests/README.md +11 -0
  9. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
  11. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
  12. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
  13. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
  14. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
  15. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
  18. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  19. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
  20. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
  21. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
  22. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
  24. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
  25. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
  26. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
  27. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
  28. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ import { APIError } from "better-auth/api";
2
+ import { PUBLIC_SIGNUP_DISABLED } from "./feature-gate";
3
+
4
+ // Domains reserved for documentation and testing (RFC 2606, RFC 6761). They
5
+ // receive no mail, so an address on one, such as admin@example.com, is a
6
+ // placeholder that nobody can prove.
7
+ const RESERVED_DOMAINS = ["example.com", "example.net", "example.org"];
8
+ const RESERVED_TOP_LEVEL = new Set(["example", "test", "invalid", "localhost"]);
9
+
10
+ /** Why an ADMIN_EMAILS entry cannot name a real administrator, or null when it can. */
11
+ export function adminEmailProblem(entry: string): string | null {
12
+ const domain = entry.slice(entry.lastIndexOf("@") + 1);
13
+ if (
14
+ RESERVED_DOMAINS.some((name) => domain === name || domain.endsWith(`.${name}`)) ||
15
+ RESERVED_TOP_LEVEL.has(domain.slice(domain.lastIndexOf(".") + 1))
16
+ ) {
17
+ return "a placeholder on a reserved domain";
18
+ }
19
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry) ? null : "not an email address";
20
+ }
21
+
22
+ /**
23
+ * The administrator addresses in ADMIN_EMAILS, lowercased. In production an
24
+ * entry that is not an address, or a placeholder such as admin@example.com,
25
+ * stops the API: a placeholder means the administrators were never configured.
26
+ */
27
+ export function adminEmailsFrom(
28
+ value: string | undefined,
29
+ production = process.env.NODE_ENV === "production",
30
+ ): ReadonlySet<string> {
31
+ const entries = (value ?? "")
32
+ .split(",")
33
+ .map((email) => email.trim().toLowerCase())
34
+ .filter(Boolean);
35
+ if (production) {
36
+ const problems = entries.flatMap((entry) => {
37
+ const problem = adminEmailProblem(entry);
38
+ return problem ? [`"${entry}" is ${problem}`] : [];
39
+ });
40
+ if (problems.length > 0) {
41
+ throw new Error(
42
+ `ADMIN_EMAILS must list real administrator addresses in production, or stay empty: ${problems.join("; ")}.`,
43
+ );
44
+ }
45
+ }
46
+ return new Set(entries);
47
+ }
48
+
49
+ /**
50
+ * Whether a new account becomes an administrator through ADMIN_EMAILS. Only an
51
+ * account that has proven its address does: one created with a verified email
52
+ * (a social provider's verified address, a magic link or email code, or
53
+ * admin:bootstrap) or one an administrator creates. Any other account with a
54
+ * listed address is refused, so nobody can register an administrator's address
55
+ * before its owner and take the role, the approval, or the address itself.
56
+ */
57
+ export function becomesAdministrator(
58
+ user: { email: string; emailVerified?: boolean | null },
59
+ createdByAdmin: boolean,
60
+ administrators: ReadonlySet<string>,
61
+ ): boolean {
62
+ if (!administrators.has(user.email.toLowerCase())) return false;
63
+ if (user.emailVerified === true || createdByAdmin) return true;
64
+ throw APIError.from("FORBIDDEN", {
65
+ code: PUBLIC_SIGNUP_DISABLED,
66
+ message: "This email address cannot be registered by sign-up.",
67
+ });
68
+ }
@@ -17,18 +17,14 @@ import { authBaseUrl, authSecret } from "./auth-environment";
17
17
  import { isMaintenanceCommand } from "./maintenance";
18
18
  import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
19
19
  import { revokeEveryOtherSession } from "./revoke-other-sessions";
20
+ import { adminEmailsFrom, becomesAdministrator } from "./admin-emails";
20
21
  // podokit:begin:auth-imports
21
22
  // podokit:end:auth-imports
22
23
 
23
24
  // Web origin(s) where the browser runs (WebAuthn ceremonies must match these).
24
25
  const webOrigins = (process.env.CORS_ORIGIN ?? "http://localhost:5001").split(",").map((o) => o.trim());
25
26
 
26
- const adminEmails = new Set(
27
- (process.env.ADMIN_EMAILS ?? "")
28
- .split(",")
29
- .map((email) => email.trim().toLowerCase())
30
- .filter(Boolean),
31
- );
27
+ const adminEmails = adminEmailsFrom(process.env.ADMIN_EMAILS);
32
28
 
33
29
  // Enabled social providers, from the resolved config (DB-first, env fallback).
34
30
  // Admins add/edit/remove providers dynamically (Settings page), so this maps the
@@ -45,6 +41,56 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
45
41
  return providers as NonNullable<BetterAuthOptions["socialProviders"]>;
46
42
  }
47
43
 
44
+ // The account and session rules of buildAuth: who may be created, who starts as
45
+ // an administrator or approved, and who may hold a session.
46
+ export function databaseHooksFor(
47
+ config: Pick<AuthConfig, "requireSignupApproval">,
48
+ administrators: ReadonlySet<string>,
49
+ signupOpen: () => Promise<boolean>,
50
+ ): NonNullable<BetterAuthOptions["databaseHooks"]> {
51
+ return {
52
+ user: {
53
+ create: {
54
+ before: async (user, context) => {
55
+ const createdByAdmin = context?.path === "/admin/create-user";
56
+ assertUserCreationAllowed(await readSignupOpen(signupOpen), context?.path);
57
+ const adminEmail = becomesAdministrator(user, createdByAdmin, administrators);
58
+ return {
59
+ data: {
60
+ ...user,
61
+ // The admin plugin contributes `role` before this hook. Preserve an
62
+ // explicitly requested admin-created role, while ADMIN_EMAILS
63
+ // addresses always become administrators.
64
+ ...(Object.prototype.hasOwnProperty.call(user, "role")
65
+ ? { role: adminEmail ? "admin" : user.role }
66
+ : {}),
67
+ // Existing rows are treated as approved through the field default.
68
+ // Only self-registration while the policy is enabled starts pending.
69
+ signupApproved: adminEmail || createdByAdmin || !config.requireSignupApproval,
70
+ },
71
+ };
72
+ },
73
+ },
74
+ },
75
+ session: {
76
+ create: {
77
+ before: async (session, context) => {
78
+ if (!context) return;
79
+ const user = (await context.context.internalAdapter.findUserById(session.userId)) as {
80
+ signupApproved?: boolean | null;
81
+ } | null;
82
+ if (user?.signupApproved === false) {
83
+ throw APIError.from("FORBIDDEN", {
84
+ code: SIGNUP_APPROVAL_REQUIRED,
85
+ message: "Your account is waiting for administrator approval.",
86
+ });
87
+ }
88
+ },
89
+ },
90
+ },
91
+ };
92
+ }
93
+
48
94
  // Build a better-auth instance from resolved config. Called at boot and rebuilt by
49
95
  // auth-provider.ts when the DB config changes, so admin edits (OAuth credentials,
50
96
  // server-enforced toggles) apply without a restart. Everything below the injection
@@ -147,47 +193,7 @@ export function buildAuth(config: AuthConfig) {
147
193
  // podokit:begin:auth-plugins
148
194
  // podokit:end:auth-plugins
149
195
 
150
- const databaseHooks: NonNullable<BetterAuthOptions["databaseHooks"]> = {
151
- user: {
152
- create: {
153
- before: async (user, context) => {
154
- const adminEmail = adminEmails.has(user.email.toLowerCase());
155
- const createdByAdmin = context?.path === "/admin/create-user";
156
- assertUserCreationAllowed(await readSignupOpen(isSignupOpen), context?.path);
157
- return {
158
- data: {
159
- ...user,
160
- // The admin plugin contributes `role` before this hook. Preserve an
161
- // explicitly requested admin-created role, while bootstrap emails
162
- // always become administrators.
163
- ...(Object.prototype.hasOwnProperty.call(user, "role")
164
- ? { role: adminEmail ? "admin" : user.role }
165
- : {}),
166
- // Existing rows are treated as approved through the field default.
167
- // Only self-registration while the policy is enabled starts pending.
168
- signupApproved: adminEmail || createdByAdmin || !config.requireSignupApproval,
169
- },
170
- };
171
- },
172
- },
173
- },
174
- session: {
175
- create: {
176
- before: async (session, context) => {
177
- if (!context) return;
178
- const user = (await context.context.internalAdapter.findUserById(session.userId)) as {
179
- signupApproved?: boolean | null;
180
- } | null;
181
- if (user?.signupApproved === false) {
182
- throw APIError.from("FORBIDDEN", {
183
- code: SIGNUP_APPROVAL_REQUIRED,
184
- message: "Your account is waiting for administrator approval.",
185
- });
186
- }
187
- },
188
- },
189
- },
190
- };
196
+ const databaseHooks = databaseHooksFor(config, adminEmails, isSignupOpen);
191
197
 
192
198
  // Request-time hooks. The feature gate turns the admin Settings toggles into a
193
199
  // real server boundary (disabled features 404) for endpoint calls, including
@@ -98,7 +98,7 @@ export MAIL_FROM="Example <noreply@example.com>"
98
98
  2. Open `/login` and confirm the provider button appears.
99
99
  3. With sign-up approval enabled, register a disposable account and confirm it reaches `/pending-approval` without a session.
100
100
  4. Open `/admin/users`, filter by **Pending approval**, approve the account, and confirm sign-in succeeds.
101
- 5. Confirm administrator-created users and emails in `ADMIN_EMAILS` do not enter the approval queue.
101
+ 5. Confirm administrator-created users and administrators made from `ADMIN_EMAILS` do not enter the approval queue, and that an email and password sign-up with an `ADMIN_EMAILS` address is refused.
102
102
  6. Send a real verification or password-reset email and inspect SPF, DKIM, and DMARC results in the receiving mailbox.
103
103
 
104
104
  ## Preserve provider independence
@@ -9,8 +9,15 @@ Do not create the first administrator through the public sign-up page.
9
9
 
10
10
  Set `ADMIN_EMAILS` in the same deployment environment used by the API. Include
11
11
  the exact lowercase-normalized address selected for the initial administrator.
12
- Keep this value after bootstrap so future matching sign-ups retain the same role
13
- policy.
12
+ Use the administrators' real addresses: with `NODE_ENV=production` the API
13
+ refuses to start when an entry is not an address or is on a reserved
14
+ documentation name such as `example.com`, and `podo deploy doctor` fails on it.
15
+
16
+ Keep this value after bootstrap. A later account with a listed address becomes
17
+ an administrator only when its address is proven as the account is created: by
18
+ `admin:bootstrap`, an administrator, a social provider that reports the email
19
+ verified, a magic link, or an email code. An email and password sign-up with a
20
+ listed address is refused, so nobody can claim it before its owner.
14
21
 
15
22
  ## Inject credentials for one command
16
23
 
@@ -20,11 +20,41 @@ const origin = { origin: base };
20
20
  // RateLimit-Limit and RateLimit-Remaining headers. The spec reads the limits from
21
21
  // them, so it follows whatever limits the stack runs with. When the runner's
22
22
  // environment sets RATE_LIMIT_MAX, RATE_LIMIT_AUTH_MAX, or RATE_LIMIT_RUNTIME_MAX,
23
- // the spec also expects the stack to use that value. It sends one more request
24
- // than the general limit, so a run that should not spend that many on a raised
25
- // limit sets E2E_SKIP_RATE_LIMIT=true to skip the file.
23
+ // the spec also expects the stack to use that value. A test that sends a budget's
24
+ // worth of requests times the first ones and skips, with the reason, when the
25
+ // rest would not fit in its time, as with raised local limits. A run that should
26
+ // not send them at all sets E2E_SKIP_RATE_LIMIT=true to skip the file.
26
27
  test.skip(process.env.E2E_SKIP_RATE_LIMIT === "true", "E2E_SKIP_RATE_LIMIT is true");
27
28
 
29
+ /** Requests a test times before it judges whether the rest fit in its time. */
30
+ const PACE_SAMPLE = 50;
31
+
32
+ let testStarted = Date.now();
33
+ test.beforeEach(() => {
34
+ testStarted = Date.now();
35
+ });
36
+
37
+ function seconds(ms: number): string {
38
+ return `${(ms / 1000).toFixed(1)} s`;
39
+ }
40
+
41
+ /**
42
+ * Why `left` more requests, sent at the pace of the `sent` ones since `since`,
43
+ * would not end within half of the time this test has left, or undefined when
44
+ * they would. The other half leaves room for a slower stretch.
45
+ */
46
+ function outOfTime(sent: number, since: number, left: number): string | undefined {
47
+ const { timeout } = test.info();
48
+ if (timeout === 0) return undefined;
49
+ const now = Date.now();
50
+ const perRequest = (now - since) / sent;
51
+ const needed = perRequest * left;
52
+ const available = timeout - (now - testStarted);
53
+ if (needed <= available / 2) return undefined;
54
+ const pace = Math.round(1000 / perRequest);
55
+ return `${left} more requests at ${pace} per second take about ${seconds(needed)}, more than half of the ${seconds(available)} this test has left`;
56
+ }
57
+
28
58
  /** Requires a session, so a session's requests count under its user from the first one. */
29
59
  const PROBE = "/api/account/me";
30
60
  const HEALTH = ["/api/health", "/api/health/ready"];
@@ -64,11 +94,14 @@ async function inOneWindow(check: () => Promise<void>): Promise<void> {
64
94
  }
65
95
 
66
96
  /**
67
- * Requests the probe until the limiter answers 429, or null when the general
68
- * window keeps ending first. More remaining than on the request before means a
69
- * new window started, which starts the count over.
97
+ * Requests the probe until the limiter answers 429, or returns why it stopped
98
+ * first: the general window kept ending first, or the requests left to the
99
+ * limit would not fit in the test's time. More remaining than on the request
100
+ * before means a new window started, which starts the count over.
70
101
  */
71
- async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIResponse | null> {
102
+ async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIResponse | string> {
103
+ const started = Date.now();
104
+ let sent = 0;
72
105
  let windows = 1;
73
106
  let counted = 0;
74
107
  let previous = Number.POSITIVE_INFINITY;
@@ -77,15 +110,21 @@ async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIR
77
110
  if (response.status() === 429) return response;
78
111
  expect(response.status(), `${PROBE} request ${counted + 1} of window ${windows}`).toBe(200);
79
112
  const { remaining } = budget(response);
113
+ sent += 1;
80
114
  if (remaining >= previous) {
81
115
  windows += 1;
82
116
  counted = 0;
83
- if (windows > 3) return null;
117
+ if (windows > 3) return `the general window ends before this client sends ${limit} requests`;
84
118
  }
85
119
  previous = remaining;
86
120
  counted += 1;
87
121
  // A window holds at most `limit` served requests.
88
122
  expect(counted, `requests served in one general window`).toBeLessThanOrEqual(limit);
123
+ // Judged in each window, since a new one needs the whole limit again.
124
+ if (counted === PACE_SAMPLE) {
125
+ const late = outOfTime(sent, started, remaining + 1);
126
+ if (late) return `reaching the general limit (${limit}): ${late}`;
127
+ }
89
128
  }
90
129
  }
91
130
 
@@ -139,7 +178,6 @@ test("rate limit: health probes stay available while ordinary routes return 429
139
178
  expect(probe.status()).toBe(200);
140
179
  const { limit } = budget(probe);
141
180
  expectConfigured("RATE_LIMIT_MAX", limit);
142
- test.skip(limit > 10_000, `the general budget (${limit}) is too large to exhaust here`);
143
181
 
144
182
  // Health probes are not counted: they carry no budget and leave the general one as it was.
145
183
  await inOneWindow(async () => {
@@ -156,9 +194,12 @@ test("rate limit: health probes stay available while ordinary routes return 429
156
194
 
157
195
  // A window serves `limit` requests and rejects the next one.
158
196
  const limited = await untilLimited(ctx, limit);
159
- test.skip(limited === null, `the general window ends before this client sends ${limit} requests`);
160
- expect(limited?.headers()["retry-after"]).toBeTruthy();
161
- expect(await limited?.json()).toMatchObject({
197
+ if (typeof limited === "string") {
198
+ test.skip(true, limited);
199
+ return;
200
+ }
201
+ expect(limited.headers()["retry-after"]).toBeTruthy();
202
+ expect(await limited.json()).toMatchObject({
162
203
  success: false,
163
204
  error: { code: "RATE_LIMIT_EXCEEDED", statusCode: 429 },
164
205
  });
@@ -239,10 +280,14 @@ test("rate limit: session reads do not consume the strict authentication budget"
239
280
  reads + 1 > runtimeLimit,
240
281
  `the authentication budget (${authLimit}) is not far enough below the runtime budget (${runtimeLimit})`,
241
282
  );
242
- test.skip(authLimit > 1000, `the authentication budget (${authLimit}) is too large to exhaust here`);
243
- for (let i = 0; i < reads; i++) {
283
+ const started = Date.now();
284
+ for (let i = 1; i <= reads; i++) {
244
285
  const response = await ctx.get("/api/auth/get-session");
245
- expect(response.status(), `session request ${i + 1}`).toBe(200);
286
+ expect(response.status(), `session request ${i}`).toBe(200);
287
+ if (i === PACE_SAMPLE) {
288
+ const late = outOfTime(i, started, reads - i);
289
+ test.skip(late !== undefined, `reading past the authentication budget (${authLimit}): ${late}`);
290
+ }
246
291
  }
247
292
  await ctx.dispose();
248
293
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {