@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.
- package/dist/deploy-checks.d.ts +20 -2
- package/dist/deploy-checks.js +83 -4
- package/dist/deploy-compose-render.js +15 -4
- package/dist/deploy-compose.js +211 -27
- package/dist/deploy.js +16 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
- 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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
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.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
|
68
|
-
* window
|
|
69
|
-
*
|
|
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 |
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
243
|
-
for (let 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
|
|
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.
|
|
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": {
|