@cosmicdrift/kumiko-dev-server 0.105.1 → 0.108.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/package.json +3 -3
- package/src/__tests__/compose-features-wiring.integration.test.ts +9 -9
- package/src/__tests__/compose-features.test.ts +6 -1
- package/src/__tests__/config-resolver-default.integration.test.ts +35 -1
- package/src/__tests__/require-env.test.ts +29 -0
- package/src/__tests__/run-dev-app.integration.test.ts +75 -1
- package/src/__tests__/scaffold-app-feature.test.ts +2 -2
- package/src/__tests__/scaffold-app.test.ts +137 -81
- package/src/__tests__/session-wiring.test.ts +14 -0
- package/src/__tests__/walkthrough.integration.test.ts +13 -19
- package/src/run-dev-app.ts +34 -4
- package/src/run-prod-app.ts +33 -14
- package/src/scaffold-app.ts +297 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.108.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "0.108.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.108.0",
|
|
55
55
|
"ts-morph": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
@@ -50,7 +50,6 @@ import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-featu
|
|
|
50
50
|
import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
51
51
|
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
|
|
52
52
|
import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
53
|
-
import { deleteMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
54
53
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
55
54
|
import {
|
|
56
55
|
setupTestStack,
|
|
@@ -59,6 +58,7 @@ import {
|
|
|
59
58
|
unsafeCreateEntityTable,
|
|
60
59
|
unsafePushTables,
|
|
61
60
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
61
|
+
import { deleteRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
62
62
|
import { composeFeatures } from "../compose-features";
|
|
63
63
|
|
|
64
64
|
const RESET_HMAC = randomBytes(32).toString("base64");
|
|
@@ -184,8 +184,8 @@ describe("composeFeatures wiring — passwordReset", () => {
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
beforeEach(async () => {
|
|
187
|
-
await
|
|
188
|
-
await
|
|
187
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
188
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
189
189
|
suite.emailTransport.sent.length = 0;
|
|
190
190
|
});
|
|
191
191
|
|
|
@@ -257,8 +257,8 @@ describe("composeFeatures wiring — emailVerification", () => {
|
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
beforeEach(async () => {
|
|
260
|
-
await
|
|
261
|
-
await
|
|
260
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
261
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
262
262
|
suite.emailTransport.sent.length = 0;
|
|
263
263
|
});
|
|
264
264
|
|
|
@@ -302,8 +302,8 @@ describe("composeFeatures wiring — asymmetric activation", () => {
|
|
|
302
302
|
});
|
|
303
303
|
|
|
304
304
|
beforeEach(async () => {
|
|
305
|
-
await
|
|
306
|
-
await
|
|
305
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
306
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
307
307
|
suite.emailTransport.sent.length = 0;
|
|
308
308
|
});
|
|
309
309
|
|
|
@@ -355,8 +355,8 @@ describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
|
|
|
355
355
|
});
|
|
356
356
|
|
|
357
357
|
afterEach(async () => {
|
|
358
|
-
await
|
|
359
|
-
await
|
|
358
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
359
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
360
360
|
suite.emailTransport.sent.length = 0;
|
|
361
361
|
});
|
|
362
362
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// aber die Handler fehlen → POST /api/auth/request-password-reset
|
|
8
8
|
// dispatched ins Leere → 500.
|
|
9
9
|
|
|
10
|
-
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
11
11
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { composeFeatures } from "../compose-features";
|
|
13
13
|
|
|
@@ -104,9 +104,14 @@ describe("composeFeatures", () => {
|
|
|
104
104
|
// copy via includeBundled:true, and createRegistry throws "Duplicate
|
|
105
105
|
// feature: auth-email-password". The dedupe path keeps the bundled
|
|
106
106
|
// instance (it carries authOptions wiring) and drops the app stub.
|
|
107
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
107
108
|
const features = composeFeatures([pickerAuthDupe, noopFeature], {
|
|
108
109
|
includeBundled: true,
|
|
109
110
|
});
|
|
111
|
+
// The warn is part of the fix contract (changeset: "warn so the user can
|
|
112
|
+
// remove the line") — without this assertion the warn is removable with no RED.
|
|
113
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("auth-email-password"));
|
|
114
|
+
warnSpy.mockRestore();
|
|
110
115
|
expect(features.map((f) => f.name)).toEqual([
|
|
111
116
|
"config",
|
|
112
117
|
"user",
|
|
@@ -5,13 +5,19 @@ import {
|
|
|
5
5
|
createConfigFeature,
|
|
6
6
|
createConfigResolver,
|
|
7
7
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
access,
|
|
10
|
+
createTenantConfig,
|
|
11
|
+
defineFeature,
|
|
12
|
+
defineQueryHandler,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
14
|
import {
|
|
10
15
|
setupTestStack,
|
|
11
16
|
type TestStack,
|
|
12
17
|
TestUsers,
|
|
13
18
|
unsafePushTables,
|
|
14
19
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { z } from "zod";
|
|
15
21
|
import { mergeConfigResolverDefault } from "../run-dev-app";
|
|
16
22
|
|
|
17
23
|
// Pins runDevApp's ENV→config-app-override wiring: mergeConfigResolverDefault
|
|
@@ -22,6 +28,18 @@ import { mergeConfigResolverDefault } from "../run-dev-app";
|
|
|
22
28
|
|
|
23
29
|
const PAGE_SIZE = "devcfg:config:page-size";
|
|
24
30
|
|
|
31
|
+
// Reads ctx.config directly — the exact consumption seam mergeConfigResolverDefault
|
|
32
|
+
// wires the dispatcher through. Every other test in this file inspects the returned
|
|
33
|
+
// {configResolver, _configAccessorFactory} object without ever dispatching a handler
|
|
34
|
+
// that actually reads ctx.config; a "Boot writes field A, dispatcher expects field B"
|
|
35
|
+
// drift (the original #359-adjacent bug class) would be invisible to all of them.
|
|
36
|
+
const readPageSizeQuery = defineQueryHandler({
|
|
37
|
+
name: "read-page-size",
|
|
38
|
+
schema: z.object({}),
|
|
39
|
+
access: { openToAll: true },
|
|
40
|
+
handler: async (_query, ctx) => ({ pageSize: await ctx.config?.(PAGE_SIZE) }),
|
|
41
|
+
});
|
|
42
|
+
|
|
25
43
|
const devcfgFeature = defineFeature("devcfg", (r) => {
|
|
26
44
|
r.requires("config");
|
|
27
45
|
r.config({
|
|
@@ -34,6 +52,7 @@ const devcfgFeature = defineFeature("devcfg", (r) => {
|
|
|
34
52
|
}),
|
|
35
53
|
},
|
|
36
54
|
});
|
|
55
|
+
r.queryHandler(readPageSizeQuery);
|
|
37
56
|
});
|
|
38
57
|
|
|
39
58
|
// ctx=undefined → object form, configResolver is the only key (test boundary).
|
|
@@ -128,4 +147,19 @@ describe("runDevApp configResolver-default — ENV→app-override bridge", () =>
|
|
|
128
147
|
// was built from the caller's resolver, exactly like money-horse's "s3-env".
|
|
129
148
|
expect(await accessor(PAGE_SIZE)).toBe("99");
|
|
130
149
|
});
|
|
150
|
+
|
|
151
|
+
test("a real dispatch through stack.http resolves ctx.config end-to-end (not the factory called directly)", async () => {
|
|
152
|
+
// Every test above calls mergeConfigResolverDefault()/_configAccessorFactory
|
|
153
|
+
// directly — none go through an actual dispatched request. The original bug
|
|
154
|
+
// this feature guards against ("boot writes configResolver, dispatcher reads
|
|
155
|
+
// a different field") only manifests on a REAL dispatch, where the
|
|
156
|
+
// dispatcher's own wiring (not test code) builds ctx.config from
|
|
157
|
+
// _configAccessorFactory. This is that missing end-to-end proof.
|
|
158
|
+
const res = await stack.http.queryOk<{ pageSize: number | undefined }>(
|
|
159
|
+
"devcfg:query:read-page-size",
|
|
160
|
+
{},
|
|
161
|
+
TestUsers.systemAdmin,
|
|
162
|
+
);
|
|
163
|
+
expect(res.pageSize).toBe(10); // keyDef.default — no env override wired on this stack
|
|
164
|
+
});
|
|
131
165
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// requireEnv — the missing-var error message must match the actual caller.
|
|
2
|
+
// Regression (728/2): the advice text was hardcoded to prod/container
|
|
3
|
+
// wording ("Coolify secrets") regardless of the `context` param, so a
|
|
4
|
+
// runDevApp caller got misleading production-deploy instructions for a
|
|
5
|
+
// missing local .env var.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { requireEnv } from "../run-prod-app";
|
|
9
|
+
|
|
10
|
+
describe("requireEnv", () => {
|
|
11
|
+
test("default context (runProdApp) gives container/production advice", () => {
|
|
12
|
+
expect(() => requireEnv("MISSING_VAR", {})).toThrow(/container env.*Coolify secrets/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("runDevApp context gives local .env advice, not production advice", () => {
|
|
16
|
+
expect(() => requireEnv("MISSING_VAR", {}, "runDevApp")).toThrow(
|
|
17
|
+
/\.env \/ shell before running the dev server/,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("runDevApp context error does NOT mention Coolify", () => {
|
|
22
|
+
try {
|
|
23
|
+
requireEnv("MISSING_VAR", {}, "runDevApp");
|
|
24
|
+
throw new Error("expected requireEnv to throw");
|
|
25
|
+
} catch (err) {
|
|
26
|
+
expect(String(err)).not.toContain("Coolify");
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -6,7 +6,18 @@
|
|
|
6
6
|
// path would silently drop the fail-closed guard and dev/prod would diverge.
|
|
7
7
|
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
requireTextContent,
|
|
11
|
+
type TextContentApi,
|
|
12
|
+
} from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
13
|
+
import {
|
|
14
|
+
createEntity,
|
|
15
|
+
createTextField,
|
|
16
|
+
defineFeature,
|
|
17
|
+
defineQueryHandler,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { z } from "zod";
|
|
10
21
|
import type { KumikoServerHandle } from "../create-kumiko-server";
|
|
11
22
|
import { runDevApp } from "../run-dev-app";
|
|
12
23
|
|
|
@@ -37,6 +48,24 @@ afterEach(async () => {
|
|
|
37
48
|
else process.env["JWT_SECRET"] = savedJwtSecret;
|
|
38
49
|
});
|
|
39
50
|
|
|
51
|
+
describe("runDevApp — auth.mail fail-fast on missing JWT_SECRET", () => {
|
|
52
|
+
test("options.auth set but JWT_SECRET missing → throws, no dev-fallback", async () => {
|
|
53
|
+
// beforeEach sets JWT_SECRET unconditionally for every OTHER test in this
|
|
54
|
+
// file — none of them ever exercise the missing-secret path, so it's
|
|
55
|
+
// unclear whether the fail-closed guard (requireEnv, not the old dev-
|
|
56
|
+
// fallback) actually fires or was silently dropped. Pass a custom
|
|
57
|
+
// envSource without JWT_SECRET instead of touching process.env.
|
|
58
|
+
const { JWT_SECRET: _drop, ...envWithoutJwtSecret } = process.env;
|
|
59
|
+
await expect(
|
|
60
|
+
runDevApp({
|
|
61
|
+
features: [validFeature()],
|
|
62
|
+
auth: { admin: ADMIN, cookieDomain: "example.eu", allowedOrigins: ["https://example.eu"] },
|
|
63
|
+
envSource: envWithoutJwtSecret,
|
|
64
|
+
}),
|
|
65
|
+
).rejects.toThrow(/JWT_SECRET/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
40
69
|
describe("runDevApp — auth allowedOrigins forwarding (#399/1)", () => {
|
|
41
70
|
test("cookieDomain without allowedOrigins fails closed — guard is wired through runDevApp", async () => {
|
|
42
71
|
await expect(
|
|
@@ -80,3 +109,48 @@ describe("runDevApp — auth allowedOrigins forwarding (#399/1)", () => {
|
|
|
80
109
|
expect(handle).toBeDefined();
|
|
81
110
|
});
|
|
82
111
|
});
|
|
112
|
+
|
|
113
|
+
describe("runDevApp — extraContext merge order: app values win over boot defaults (707/1)", () => {
|
|
114
|
+
test("a caller-supplied extraContext.textContent wins over the auto-wired boot default", async () => {
|
|
115
|
+
// runDevApp's extraContext factory does `{ ...boot, ...base }` — boot's
|
|
116
|
+
// auto-wired textContent must lose to a caller-supplied override, exactly
|
|
117
|
+
// the "app values win" parity claim this PR made against runProdApp. Every
|
|
118
|
+
// OTHER test in this file only checks that runDevApp boots without
|
|
119
|
+
// throwing; none dispatch a request that actually reads the merged value.
|
|
120
|
+
const sentinel: TextContentApi = {
|
|
121
|
+
getBlock: async () => ({
|
|
122
|
+
slug: "sentinel",
|
|
123
|
+
lang: "en",
|
|
124
|
+
title: "from caller extraContext",
|
|
125
|
+
body: null,
|
|
126
|
+
updatedAt: new Date(0),
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
const readBlockQuery = defineQueryHandler({
|
|
130
|
+
name: "read-block",
|
|
131
|
+
schema: z.object({}),
|
|
132
|
+
access: { openToAll: true },
|
|
133
|
+
handler: async (_query, ctx) => {
|
|
134
|
+
const api = requireTextContent(ctx, "textcheck:query:read-block");
|
|
135
|
+
return api.getBlock({ tenantId: TestUsers.systemAdmin.tenantId, slug: "x", lang: "en" });
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const textcheckFeature = defineFeature("textcheck", (r) => {
|
|
139
|
+
r.queryHandler(readBlockQuery);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
handle = await runDevApp({
|
|
143
|
+
features: [validFeature(), textcheckFeature],
|
|
144
|
+
port: 0,
|
|
145
|
+
extraContext: { textContent: sentinel },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const res = await handle.stack.http.queryOk<{ title: string } | null>(
|
|
149
|
+
"textcheck:query:read-block",
|
|
150
|
+
{},
|
|
151
|
+
TestUsers.systemAdmin,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(res?.title).toBe("from caller extraContext");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -10,10 +10,10 @@ import { scaffoldAppFeature } from "../scaffold-app-feature";
|
|
|
10
10
|
describe("scaffoldAppFeature", () => {
|
|
11
11
|
let tmp: string;
|
|
12
12
|
let appRoot: string;
|
|
13
|
-
beforeEach(() => {
|
|
13
|
+
beforeEach(async () => {
|
|
14
14
|
tmp = mkdtempSync(join(tmpdir(), "scaffold-app-feature-"));
|
|
15
15
|
appRoot = join(tmp, "my-shop");
|
|
16
|
-
scaffoldApp({ name: "my-shop", destination: appRoot });
|
|
16
|
+
await scaffoldApp({ name: "my-shop", destination: appRoot });
|
|
17
17
|
});
|
|
18
18
|
afterEach(() => {
|
|
19
19
|
rmSync(tmp, { recursive: true, force: true });
|