@cosmicdrift/kumiko-dev-server 0.155.0 → 0.156.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 +4 -4
- package/src/__tests__/compose-stacks.test.ts +64 -0
- package/src/__tests__/few-shot-corpus.test.ts +1 -1
- package/src/__tests__/saas-identity-wire.integration.test.ts +423 -0
- package/src/build-server-bundle.ts +9 -3
- package/src/codegen/__tests__/run-codegen.test.ts +2 -2
- package/src/codegen/__tests__/watch.test.ts +1 -2
- package/src/codegen/scan-events.ts +7 -5
- package/src/codegen/watch.ts +7 -4
- package/src/compose-stacks.ts +24 -4
- package/src/create-kumiko-server.ts +2 -0
- package/src/few-shot-corpus.ts +4 -3
- package/src/index.ts +2 -0
- package/src/scaffold-app.ts +1 -1
- package/src/scaffold-demo-tasks.ts +22 -0
- package/src/schema-apply.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.0",
|
|
4
4
|
"description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules — see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
58
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
59
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
57
|
+
"@cosmicdrift/kumiko-bundled-features": "0.156.0",
|
|
58
|
+
"@cosmicdrift/kumiko-framework": "0.156.0",
|
|
59
|
+
"@cosmicdrift/kumiko-server-runtime": "0.156.0",
|
|
60
60
|
"ts-morph": "^28.0.0"
|
|
61
61
|
},
|
|
62
62
|
"publishConfig": {
|
|
@@ -4,6 +4,7 @@ import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-feat
|
|
|
4
4
|
import {
|
|
5
5
|
composeFileStack,
|
|
6
6
|
composeGdprStack,
|
|
7
|
+
composeIdentityStack,
|
|
7
8
|
composeMailStack,
|
|
8
9
|
composeOpsStack,
|
|
9
10
|
composePagesStack,
|
|
@@ -12,6 +13,12 @@ import {
|
|
|
12
13
|
stackFeatureNames,
|
|
13
14
|
} from "../compose-stacks";
|
|
14
15
|
|
|
16
|
+
const TEST_MFA = {
|
|
17
|
+
setupTokenSecret: "compose-stacks-mfa-setup-secret-at-least-32b!!",
|
|
18
|
+
challengeTokenSecret: "compose-stacks-mfa-challenge-secret-32b!!",
|
|
19
|
+
issuer: "Kumiko ComposeStacks",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
15
22
|
describe("composeStacks", () => {
|
|
16
23
|
test("composeRendererStack → template-resolver, renderer-foundation, renderer-simple", () => {
|
|
17
24
|
expect(stackFeatureNames(composeRendererStack())).toEqual([
|
|
@@ -77,6 +84,38 @@ describe("composeStacks", () => {
|
|
|
77
84
|
"rate-limiting",
|
|
78
85
|
]);
|
|
79
86
|
});
|
|
87
|
+
|
|
88
|
+
test("composeIdentityStack defaults sessions only", () => {
|
|
89
|
+
expect(stackFeatureNames(composeIdentityStack())).toEqual(["sessions"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("composeIdentityStack mounts auth-mfa when mfa options given", () => {
|
|
93
|
+
expect(stackFeatureNames(composeIdentityStack({ mfa: TEST_MFA }))).toEqual([
|
|
94
|
+
"sessions",
|
|
95
|
+
"auth-mfa",
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("composeIdentityStack sessions:false + mfa → auth-mfa only", () => {
|
|
100
|
+
expect(stackFeatureNames(composeIdentityStack({ sessions: false, mfa: TEST_MFA }))).toEqual([
|
|
101
|
+
"auth-mfa",
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("composeIdentityStack + composeGdprStack(sessions) → duplicate feature rejected at boot", () => {
|
|
106
|
+
const names = stackFeatureNames([
|
|
107
|
+
...composeIdentityStack(),
|
|
108
|
+
...composeGdprStack({ sessions: true }),
|
|
109
|
+
]);
|
|
110
|
+
expect(names.filter((n) => n === "sessions")).toHaveLength(2);
|
|
111
|
+
expect(() =>
|
|
112
|
+
validateBoot(
|
|
113
|
+
composeFeatures([...composeIdentityStack(), ...composeGdprStack({ sessions: true })], {
|
|
114
|
+
includeBundled: true,
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
).toThrow(/duplicate feature/i);
|
|
118
|
+
});
|
|
80
119
|
});
|
|
81
120
|
|
|
82
121
|
/** Snapshot, not a live parity check: intended block combinations, mirrored by hand from studio/money-horse/publicstatus run-configs. A drifted run-config stays green here. */
|
|
@@ -95,6 +134,25 @@ describe("composeStacks boots for real", () => {
|
|
|
95
134
|
);
|
|
96
135
|
expect(() => validateBoot(features)).not.toThrow();
|
|
97
136
|
});
|
|
137
|
+
|
|
138
|
+
// auth-mfa's encrypted fields need a runtime KEK (env or configureEntityFieldEncryption
|
|
139
|
+
// + secrets). Name-list + saas-identity-wire.integration.test.ts cover identity boot.
|
|
140
|
+
test("identity stack names compose with ops + renderer under includeBundled", () => {
|
|
141
|
+
const features = composeFeatures(
|
|
142
|
+
[
|
|
143
|
+
...composeIdentityStack({ mfa: TEST_MFA }),
|
|
144
|
+
...composeOpsStack(),
|
|
145
|
+
...composeRendererStack(),
|
|
146
|
+
...composeMailStack({ transports: ["inmemory"] }),
|
|
147
|
+
],
|
|
148
|
+
{ includeBundled: true },
|
|
149
|
+
);
|
|
150
|
+
const names = stackFeatureNames(features);
|
|
151
|
+
expect(names).toContain("auth-email-password");
|
|
152
|
+
expect(names).toContain("sessions");
|
|
153
|
+
expect(names).toContain("auth-mfa");
|
|
154
|
+
expect(names).toContain("delivery");
|
|
155
|
+
});
|
|
98
156
|
});
|
|
99
157
|
|
|
100
158
|
describe("composeStacks intended block names (snapshot, not live parity)", () => {
|
|
@@ -172,4 +230,10 @@ describe("composeStacks intended block names (snapshot, not live parity)", () =>
|
|
|
172
230
|
expect(names).toContain(expected);
|
|
173
231
|
}
|
|
174
232
|
});
|
|
233
|
+
|
|
234
|
+
test("identity stack covers sessions + auth-mfa", () => {
|
|
235
|
+
const names = stackFeatureNames(composeIdentityStack({ mfa: TEST_MFA }));
|
|
236
|
+
expect(names).toContain("sessions");
|
|
237
|
+
expect(names).toContain("auth-mfa");
|
|
238
|
+
});
|
|
175
239
|
});
|
|
@@ -169,7 +169,7 @@ describe("buildFewShotCorpus — warnings", () => {
|
|
|
169
169
|
|
|
170
170
|
expect(corpus.entries).toHaveLength(1);
|
|
171
171
|
expect(corpus.warnings).toHaveLength(1);
|
|
172
|
-
expect(corpus.warnings[0]?.
|
|
172
|
+
expect(corpus.warnings[0]?.message).toMatch(/^duplicate-id:/);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
test("parser-throw surfaces as a warning instead of silent skip", () => {
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Cross-feature SaaS identity wire suite — one mount that mirrors a real app
|
|
2
|
+
// stack (composeIdentityStack + delivery + renderer + channel-email) and drives
|
|
3
|
+
// the happy paths apps always need. Proves auth ↔ delivery mail ↔ sessions
|
|
4
|
+
// (login jti + mine) ↔ MFA challenge/verify. Renderer stack is mounted for
|
|
5
|
+
// delivery deps; channel-email uses an injected in-memory transport (not a
|
|
6
|
+
// template-resolver round-trip). Edge cases stay in per-feature suites.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { AuthErrors, AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
11
|
+
import {
|
|
12
|
+
seedAdmin,
|
|
13
|
+
seedUserWithPassword,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
15
|
+
import {
|
|
16
|
+
AuthMfaHandlers,
|
|
17
|
+
base32Decode,
|
|
18
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
19
|
+
userMfaEntity,
|
|
20
|
+
userMfaTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
22
|
+
import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
|
|
23
|
+
import {
|
|
24
|
+
createChannelEmailFeature,
|
|
25
|
+
createInMemoryTransport,
|
|
26
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
27
|
+
import {
|
|
28
|
+
configValuesTable,
|
|
29
|
+
createConfigResolver,
|
|
30
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
31
|
+
import { createDeliveryTestContext } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
32
|
+
import { notificationPreferencesTable } from "@cosmicdrift/kumiko-bundled-features/delivery/tables";
|
|
33
|
+
import { simpleRenderer } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
|
|
34
|
+
import {
|
|
35
|
+
createSessionCallbacks,
|
|
36
|
+
type SessionCallbacks,
|
|
37
|
+
SessionQueries,
|
|
38
|
+
userSessionEntity,
|
|
39
|
+
userSessionTable,
|
|
40
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
41
|
+
import { sessionCallbacksFromLateBound } from "@cosmicdrift/kumiko-bundled-features/sessions/testing";
|
|
42
|
+
import {
|
|
43
|
+
tenantEntity,
|
|
44
|
+
tenantInvitationEntity,
|
|
45
|
+
tenantInvitationsTable,
|
|
46
|
+
tenantMembershipsTable,
|
|
47
|
+
tenantTable,
|
|
48
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
49
|
+
import {
|
|
50
|
+
seedTenant,
|
|
51
|
+
seedTenantMembership,
|
|
52
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant/seeding";
|
|
53
|
+
import { userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
54
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
55
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
56
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
57
|
+
import {
|
|
58
|
+
setupTestStack,
|
|
59
|
+
type TestStack,
|
|
60
|
+
unsafeCreateEntityTable,
|
|
61
|
+
unsafePushTables,
|
|
62
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
63
|
+
import {
|
|
64
|
+
createLateBoundHolder,
|
|
65
|
+
createTestEnvelopeCipher,
|
|
66
|
+
deleteRows,
|
|
67
|
+
updateRows,
|
|
68
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
69
|
+
import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
70
|
+
import * as jose from "jose";
|
|
71
|
+
import { composeIdentityStack, composeOpsStack, composeRendererStack } from "../compose-stacks";
|
|
72
|
+
|
|
73
|
+
const SETUP_TOKEN_SECRET = "wire-mfa-setup-secret-at-least-32-bytes-long!!";
|
|
74
|
+
const CHALLENGE_TOKEN_SECRET = "wire-mfa-challenge-secret-at-least-32-bytes!!";
|
|
75
|
+
const RESET_SECRET = randomBytes(32).toString("base64");
|
|
76
|
+
const VERIFY_SECRET = randomBytes(32).toString("base64");
|
|
77
|
+
const APP_SIGNUP_URL = "https://app.example.com/signup/complete";
|
|
78
|
+
const APP_RESET_URL = "https://app.example.com/reset";
|
|
79
|
+
const APP_VERIFY_URL = "https://app.example.com/verify";
|
|
80
|
+
const APP_INVITE_URL = "https://app.example.com/invite/accept";
|
|
81
|
+
|
|
82
|
+
const emailTransport = createInMemoryTransport();
|
|
83
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
84
|
+
|
|
85
|
+
let stack: TestStack;
|
|
86
|
+
|
|
87
|
+
function extractTokenFromMail(html: string): string {
|
|
88
|
+
const match = html.match(/[?&]token=([^&"'<\s]+)/);
|
|
89
|
+
if (!match?.[1]) throw new Error(`No token in mail html: ${html.slice(0, 200)}`);
|
|
90
|
+
return decodeURIComponent(match[1]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function markVerified(userId: string): Promise<void> {
|
|
94
|
+
await updateRows(stack.db, userTable, { emailVerified: true }, { id: userId });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
beforeAll(async () => {
|
|
98
|
+
configureEntityFieldEncryption(createTestEnvelopeCipher());
|
|
99
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
100
|
+
|
|
101
|
+
const identity = composeIdentityStack({
|
|
102
|
+
mfa: {
|
|
103
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
104
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
105
|
+
issuer: "Kumiko Wire",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const mfaFeature = identity.find((f) => f.name === "auth-mfa");
|
|
109
|
+
if (!mfaFeature) throw new Error("composeIdentityStack did not mount auth-mfa");
|
|
110
|
+
|
|
111
|
+
const features = composeFeatures(
|
|
112
|
+
[
|
|
113
|
+
...identity,
|
|
114
|
+
...composeOpsStack({ delivery: true, audit: false, jobs: false }),
|
|
115
|
+
...composeRendererStack(),
|
|
116
|
+
createChannelEmailFeature({
|
|
117
|
+
transport: emailTransport,
|
|
118
|
+
renderer: simpleRenderer,
|
|
119
|
+
resolveEmail: async () => "unused@test.local",
|
|
120
|
+
}),
|
|
121
|
+
],
|
|
122
|
+
{
|
|
123
|
+
includeBundled: true,
|
|
124
|
+
authOptions: {
|
|
125
|
+
signup: { tokenTtlMinutes: 60, appUrl: APP_SIGNUP_URL },
|
|
126
|
+
invite: { tokenTtlMinutes: 60, appUrl: APP_INVITE_URL },
|
|
127
|
+
passwordReset: {
|
|
128
|
+
hmacSecret: RESET_SECRET,
|
|
129
|
+
tokenTtlMinutes: 15,
|
|
130
|
+
appUrl: APP_RESET_URL,
|
|
131
|
+
},
|
|
132
|
+
emailVerification: {
|
|
133
|
+
hmacSecret: VERIFY_SECRET,
|
|
134
|
+
tokenTtlMinutes: 60,
|
|
135
|
+
mode: "strict",
|
|
136
|
+
appUrl: APP_VERIFY_URL,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
stack = await setupTestStack({
|
|
143
|
+
features,
|
|
144
|
+
extraContext: (deps) => ({
|
|
145
|
+
...createDeliveryTestContext(deps),
|
|
146
|
+
configResolver: createConfigResolver(),
|
|
147
|
+
}),
|
|
148
|
+
authConfig: {
|
|
149
|
+
...bound.asAuthConfig(),
|
|
150
|
+
// No sessionStrictMode: seed/writeOk uses TestUsers JWTs without sid.
|
|
151
|
+
// Login still gets jti via sessionCreator (asserted below).
|
|
152
|
+
membershipQuery: "tenant:query:memberships",
|
|
153
|
+
loginHandler: AuthHandlers.login,
|
|
154
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
155
|
+
loginErrorStatusMap: {
|
|
156
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
157
|
+
[AuthErrors.noMembership]: 403,
|
|
158
|
+
[AuthErrors.emailNotVerified]: 403,
|
|
159
|
+
},
|
|
160
|
+
signup: {
|
|
161
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
162
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
163
|
+
},
|
|
164
|
+
passwordReset: {
|
|
165
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
166
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
167
|
+
},
|
|
168
|
+
emailVerification: {
|
|
169
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
170
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
171
|
+
},
|
|
172
|
+
invite: {
|
|
173
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
174
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
175
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const sessionCallbacks = createSessionCallbacks({ db: stack.db });
|
|
181
|
+
callbacks.set(sessionCallbacks);
|
|
182
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
|
|
183
|
+
|
|
184
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
185
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
186
|
+
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
187
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
188
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
189
|
+
await unsafePushTables(stack.db, {
|
|
190
|
+
configValuesTable,
|
|
191
|
+
tenantMembershipsTable,
|
|
192
|
+
notificationPreferencesTable,
|
|
193
|
+
userSessionTable,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterAll(async () => {
|
|
198
|
+
await stack?.cleanup();
|
|
199
|
+
configureEntityFieldEncryption(undefined);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
beforeEach(async () => {
|
|
203
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
204
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
205
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
|
|
206
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
207
|
+
await deleteRows(stack.db, userSessionTable, {});
|
|
208
|
+
await deleteRows(stack.db, userMfaTable, {});
|
|
209
|
+
emailTransport.sent.length = 0;
|
|
210
|
+
for (const pattern of ["signup:*", "invite:*"] as const) {
|
|
211
|
+
const keys = await stack.redis.redis.keys(pattern);
|
|
212
|
+
if (keys.length > 0) await stack.redis.redis.del(...keys);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("saas-identity-wire", () => {
|
|
217
|
+
test("signup → delivery mail → confirm → login JWT with sid", async () => {
|
|
218
|
+
const email = "signup-wire@example.com";
|
|
219
|
+
const password = "fresh-secure-pw-1234";
|
|
220
|
+
|
|
221
|
+
const req = await stack.http.raw("POST", "/api/auth/signup-request", { email });
|
|
222
|
+
expect(req.status).toBe(200);
|
|
223
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
224
|
+
const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
|
|
225
|
+
|
|
226
|
+
const confirm = await stack.http.raw("POST", "/api/auth/signup-confirm", { token, password });
|
|
227
|
+
expect(confirm.status).toBe(200);
|
|
228
|
+
|
|
229
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
230
|
+
expect(login.status).toBe(200);
|
|
231
|
+
const body = (await login.json()) as { token?: string };
|
|
232
|
+
expect(body.token).toBeTypeOf("string");
|
|
233
|
+
expect(typeof jose.decodeJwt(body.token!).jti).toBe("string");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("password reset → delivery mail → reset → login with new password", async () => {
|
|
237
|
+
const email = "reset-wire@example.com";
|
|
238
|
+
const oldPassword = "old-password-1234";
|
|
239
|
+
const newPassword = "new-password-5678";
|
|
240
|
+
const tenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
241
|
+
const { id } = await seedAdmin(stack.db, {
|
|
242
|
+
email,
|
|
243
|
+
password: oldPassword,
|
|
244
|
+
displayName: "Reset",
|
|
245
|
+
memberships: [
|
|
246
|
+
{
|
|
247
|
+
tenantId,
|
|
248
|
+
tenantKey: "reset-wire",
|
|
249
|
+
tenantName: "Reset Wire",
|
|
250
|
+
roles: ["User"],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
await markVerified(id);
|
|
255
|
+
|
|
256
|
+
const req = await stack.http.raw("POST", "/api/auth/request-password-reset", { email });
|
|
257
|
+
expect(req.status).toBe(200);
|
|
258
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
259
|
+
const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
|
|
260
|
+
|
|
261
|
+
const reset = await stack.http.raw("POST", "/api/auth/reset-password", {
|
|
262
|
+
token,
|
|
263
|
+
newPassword,
|
|
264
|
+
});
|
|
265
|
+
expect(reset.status).toBe(200);
|
|
266
|
+
|
|
267
|
+
const oldLogin = await stack.http.raw("POST", "/api/auth/login", {
|
|
268
|
+
email,
|
|
269
|
+
password: oldPassword,
|
|
270
|
+
});
|
|
271
|
+
expect(oldLogin.status).toBe(401);
|
|
272
|
+
|
|
273
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password: newPassword });
|
|
274
|
+
expect(login.status).toBe(200);
|
|
275
|
+
const body = (await login.json()) as { token?: string };
|
|
276
|
+
expect(body.token).toBeTypeOf("string");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("invite Branch3 → signup-complete → login", async () => {
|
|
280
|
+
const tenantId = crypto.randomUUID() as TenantId;
|
|
281
|
+
const { id: adminId } = await seedAdmin(stack.db, {
|
|
282
|
+
email: "admin-wire@example.com",
|
|
283
|
+
password: "admin-password-1234",
|
|
284
|
+
displayName: "Admin",
|
|
285
|
+
memberships: [
|
|
286
|
+
{
|
|
287
|
+
tenantId,
|
|
288
|
+
tenantKey: `invite-${tenantId.slice(0, 8)}`,
|
|
289
|
+
tenantName: "Invite Wire",
|
|
290
|
+
roles: ["Admin"],
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
await markVerified(adminId);
|
|
295
|
+
|
|
296
|
+
const admin: SessionUser = { id: adminId, tenantId, roles: ["Admin"] };
|
|
297
|
+
const invitee = "carol-wire@example.com";
|
|
298
|
+
await stack.http.writeOk(AuthHandlers.inviteCreate, { email: invitee, role: "User" }, admin);
|
|
299
|
+
expect(emailTransport.sent.length).toBeGreaterThanOrEqual(1);
|
|
300
|
+
const token = extractTokenFromMail(emailTransport.sent.at(-1)?.html ?? "");
|
|
301
|
+
|
|
302
|
+
const complete = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
303
|
+
token,
|
|
304
|
+
password: "carol-new-pw-1234",
|
|
305
|
+
});
|
|
306
|
+
expect(complete.status).toBe(200);
|
|
307
|
+
|
|
308
|
+
const login = await stack.http.raw("POST", "/api/auth/login", {
|
|
309
|
+
email: invitee,
|
|
310
|
+
password: "carol-new-pw-1234",
|
|
311
|
+
});
|
|
312
|
+
expect(login.status).toBe(200);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("email verify gate: unverified blocked → verify → login ok", async () => {
|
|
316
|
+
const email = "verify-wire@example.com";
|
|
317
|
+
const password = "verify-password-1234";
|
|
318
|
+
const tenantId = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
319
|
+
await seedTenant(stack.db, {
|
|
320
|
+
id: tenantId,
|
|
321
|
+
key: "verify-wire",
|
|
322
|
+
name: "Verify Wire",
|
|
323
|
+
});
|
|
324
|
+
const { id } = await seedUserWithPassword(stack.db, {
|
|
325
|
+
email,
|
|
326
|
+
password,
|
|
327
|
+
displayName: "Verify",
|
|
328
|
+
emailVerified: false,
|
|
329
|
+
});
|
|
330
|
+
await seedTenantMembership(stack.db, {
|
|
331
|
+
userId: id,
|
|
332
|
+
tenantId,
|
|
333
|
+
roles: ["User"],
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const blocked = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
337
|
+
expect(blocked.status).toBe(403);
|
|
338
|
+
const blockedBody = (await blocked.json()) as { error?: { details?: { reason?: string } } };
|
|
339
|
+
expect(blockedBody.error?.details?.reason).toBe(AuthErrors.emailNotVerified);
|
|
340
|
+
|
|
341
|
+
const req = await stack.http.raw("POST", "/api/auth/request-email-verification", { email });
|
|
342
|
+
expect(req.status).toBe(200);
|
|
343
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
344
|
+
const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
|
|
345
|
+
|
|
346
|
+
const verify = await stack.http.raw("POST", "/api/auth/verify-email", { token });
|
|
347
|
+
expect(verify.status).toBe(200);
|
|
348
|
+
|
|
349
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
350
|
+
expect(login.status).toBe(200);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("MFA enable → login challenges → verify JWT; sessions:mine lists sid", async () => {
|
|
354
|
+
const email = "mfa-wire@example.com";
|
|
355
|
+
const password = "mfa-password-1234";
|
|
356
|
+
const tenantId = "00000000-0000-4000-8000-000000000003" as TenantId;
|
|
357
|
+
const { id: userId } = await seedAdmin(stack.db, {
|
|
358
|
+
email,
|
|
359
|
+
password,
|
|
360
|
+
displayName: "Mfa",
|
|
361
|
+
memberships: [
|
|
362
|
+
{
|
|
363
|
+
tenantId,
|
|
364
|
+
tenantKey: "mfa-wire",
|
|
365
|
+
tenantName: "MFA Wire",
|
|
366
|
+
roles: ["User"],
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
});
|
|
370
|
+
await markVerified(userId);
|
|
371
|
+
const user: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
372
|
+
|
|
373
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
374
|
+
AuthMfaHandlers.enableStart,
|
|
375
|
+
{ accountLabel: email },
|
|
376
|
+
user,
|
|
377
|
+
);
|
|
378
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
379
|
+
const secret = base32Decode(secretParam);
|
|
380
|
+
await stack.http.writeOk(
|
|
381
|
+
AuthMfaHandlers.enableConfirm,
|
|
382
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
383
|
+
user,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
387
|
+
expect(login.status).toBe(200);
|
|
388
|
+
const challenge = (await login.json()) as {
|
|
389
|
+
mfaRequired?: boolean;
|
|
390
|
+
challengeToken?: string;
|
|
391
|
+
token?: string;
|
|
392
|
+
};
|
|
393
|
+
expect(challenge.mfaRequired).toBe(true);
|
|
394
|
+
expect(challenge.token).toBeUndefined();
|
|
395
|
+
expect(challenge.challengeToken).toBeTypeOf("string");
|
|
396
|
+
|
|
397
|
+
const verify = await stack.http.raw("POST", "/api/auth/mfa/verify", {
|
|
398
|
+
challengeToken: challenge.challengeToken,
|
|
399
|
+
code: currentTotpCode(secret),
|
|
400
|
+
});
|
|
401
|
+
expect(verify.status).toBe(200);
|
|
402
|
+
const { token } = (await verify.json()) as { token: string };
|
|
403
|
+
expect(token).toBeTypeOf("string");
|
|
404
|
+
const sid = jose.decodeJwt(token).jti;
|
|
405
|
+
expect(typeof sid).toBe("string");
|
|
406
|
+
|
|
407
|
+
const mineRes = await stack.http.raw(
|
|
408
|
+
"POST",
|
|
409
|
+
"/api/query",
|
|
410
|
+
{ type: SessionQueries.mine, payload: {} },
|
|
411
|
+
{ Authorization: `Bearer ${token}` },
|
|
412
|
+
);
|
|
413
|
+
expect(mineRes.status).toBe(200);
|
|
414
|
+
const mineBody = (await mineRes.json()) as {
|
|
415
|
+
data?: Array<{ id: string }> | { items?: Array<{ id: string }> };
|
|
416
|
+
};
|
|
417
|
+
const items = Array.isArray(mineBody.data) ? mineBody.data : (mineBody.data?.items ?? []);
|
|
418
|
+
expect(items.some((s) => s.id === sid)).toBe(true);
|
|
419
|
+
|
|
420
|
+
const rows = await selectMany(stack.db, userSessionTable, { userId });
|
|
421
|
+
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
39
39
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
40
40
|
import { dirname, join, resolve } from "node:path";
|
|
41
|
+
import { parseJsonOrThrow, parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
41
42
|
|
|
42
43
|
const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
|
43
44
|
|
|
@@ -239,7 +240,7 @@ async function resolveRuntimeDepsVersions(
|
|
|
239
240
|
for (const path of pinSources) {
|
|
240
241
|
if (!existsSync(path)) continue;
|
|
241
242
|
const raw = await readFile(path, "utf-8");
|
|
242
|
-
const parsed =
|
|
243
|
+
const parsed = parseJsonOrThrow<{ dependencies?: Record<string, string> }>(raw, path);
|
|
243
244
|
Object.assign(allDeps, parsed.dependencies ?? {});
|
|
244
245
|
}
|
|
245
246
|
for (const pkg of packages) {
|
|
@@ -251,12 +252,17 @@ async function resolveRuntimeDepsVersions(
|
|
|
251
252
|
function derivePkgName(cwd: string): string {
|
|
252
253
|
const pkgJson = join(cwd, "package.json");
|
|
253
254
|
if (!existsSync(pkgJson)) return "kumiko-app-runtime";
|
|
255
|
+
// existsSync/readFileSync race (deleted between the two calls) or a
|
|
256
|
+
// permission error must fall back like a missing file, not crash the
|
|
257
|
+
// build — read here too, not just the JSON.parse below.
|
|
258
|
+
let raw: string;
|
|
254
259
|
try {
|
|
255
|
-
|
|
256
|
-
return parsed.name ? `${parsed.name}-runtime` : "kumiko-app-runtime";
|
|
260
|
+
raw = readFileSync(pkgJson, "utf-8");
|
|
257
261
|
} catch {
|
|
258
262
|
return "kumiko-app-runtime";
|
|
259
263
|
}
|
|
264
|
+
const parsed = parseJsonSafe<{ name?: string }>(raw, {});
|
|
265
|
+
return parsed.name ? `${parsed.name}-runtime` : "kumiko-app-runtime";
|
|
260
266
|
}
|
|
261
267
|
|
|
262
268
|
export function formatServerBuildResult(
|
|
@@ -283,7 +283,7 @@ export default defineFeature("dup", (r) => {
|
|
|
283
283
|
const result = runCodegen({ appRoot });
|
|
284
284
|
expect(result.eventCount).toBe(1);
|
|
285
285
|
expect(result.warnings.length).toBeGreaterThanOrEqual(1);
|
|
286
|
-
const w = result.warnings.find((w) => w.
|
|
286
|
+
const w = result.warnings.find((w) => w.message.includes("duplicate"));
|
|
287
287
|
expect(w).toBeDefined();
|
|
288
288
|
});
|
|
289
289
|
|
|
@@ -368,7 +368,7 @@ export default defineFeature("inline", (r) => {
|
|
|
368
368
|
const result = runCodegen({ appRoot });
|
|
369
369
|
expect(result.eventCount).toBe(0);
|
|
370
370
|
expect(result.warnings.length).toBe(1);
|
|
371
|
-
expect(result.warnings[0]?.
|
|
371
|
+
expect(result.warnings[0]?.message).toMatch(/not a named import nor an inline z\.\* call/);
|
|
372
372
|
});
|
|
373
373
|
|
|
374
374
|
test("skips test files, node_modules, .kumiko, dist", () => {
|
|
@@ -138,8 +138,7 @@ export default defineFeature("orders", (r) => {
|
|
|
138
138
|
writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("nope", "evt"));
|
|
139
139
|
const handle = watchAndRegenerate({ appRoot, onResult: () => {} });
|
|
140
140
|
handle.close();
|
|
141
|
-
handle.close();
|
|
142
|
-
expect(true).toBe(true);
|
|
141
|
+
expect(() => handle.close()).not.toThrow();
|
|
143
142
|
});
|
|
144
143
|
|
|
145
144
|
test("non-ts file changes do not trigger codegen", async () => {
|
|
@@ -93,7 +93,7 @@ export type SchemaSource =
|
|
|
93
93
|
export type ScanWarning = {
|
|
94
94
|
readonly file: string;
|
|
95
95
|
readonly line: number;
|
|
96
|
-
readonly
|
|
96
|
+
readonly message: string;
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
export type ScanResult = {
|
|
@@ -152,7 +152,7 @@ function collectTsFiles(dir: string, out: string[]): void {
|
|
|
152
152
|
try {
|
|
153
153
|
entries = readdirSync(dir);
|
|
154
154
|
} catch {
|
|
155
|
-
//
|
|
155
|
+
// skip: directory unreadable, nothing to scan there
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
158
|
for (const entry of entries) {
|
|
@@ -224,8 +224,9 @@ function collectFromDefineEvent(
|
|
|
224
224
|
warnings.push({
|
|
225
225
|
file: filePath,
|
|
226
226
|
line: call.getStartLineNumber(),
|
|
227
|
-
|
|
227
|
+
message: "r.defineEvent: cannot read event-name + schema statically",
|
|
228
228
|
});
|
|
229
|
+
// skip: defineEvent call shape not statically parseable, already recorded as warning
|
|
229
230
|
return;
|
|
230
231
|
}
|
|
231
232
|
|
|
@@ -241,8 +242,9 @@ function collectFromDefineEvent(
|
|
|
241
242
|
warnings.push({
|
|
242
243
|
file: filePath,
|
|
243
244
|
line: call.getStartLineNumber(),
|
|
244
|
-
|
|
245
|
+
message: `r.defineEvent("${parsed.eventName}"): schema "${parsed.schemaNode.getText()}" — not a named import nor an inline z.* call, skipped`,
|
|
245
246
|
});
|
|
247
|
+
// skip: schema source not resolvable, already recorded as warning
|
|
246
248
|
return;
|
|
247
249
|
}
|
|
248
250
|
|
|
@@ -544,7 +546,7 @@ function dedupe(events: ScannedEvent[], warnings: ScanWarning[]): ScannedEvent[]
|
|
|
544
546
|
warnings.push({
|
|
545
547
|
file: ev.source.file,
|
|
546
548
|
line: ev.source.line,
|
|
547
|
-
|
|
549
|
+
message: `duplicate r.defineEvent("${ev.qualifiedName}") — first declared at ${existing.source.file}:${existing.source.line}, ignored here`,
|
|
548
550
|
});
|
|
549
551
|
continue;
|
|
550
552
|
}
|
package/src/codegen/watch.ts
CHANGED
|
@@ -74,7 +74,7 @@ export function watchAndRegenerate(opts: WatchOptions): WatchHandle {
|
|
|
74
74
|
if (result.warnings.length > 0) {
|
|
75
75
|
for (const w of result.warnings) {
|
|
76
76
|
// biome-ignore lint/suspicious/noConsole: codegen-watcher logs to terminal
|
|
77
|
-
console.warn(`[codegen] ${w.file}:${w.line} — ${w.
|
|
77
|
+
console.warn(`[codegen] ${w.file}:${w.line} — ${w.message}`);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -95,17 +95,19 @@ export function watchAndRegenerate(opts: WatchOptions): WatchHandle {
|
|
|
95
95
|
|
|
96
96
|
try {
|
|
97
97
|
watcher = watch(srcDir, { recursive: true }, (_eventType, filename) => {
|
|
98
|
+
// skip: watcher closed or no filename reported, nothing to act on
|
|
98
99
|
if (closed || !filename) return;
|
|
99
100
|
// node liefert filename relativ zu srcDir, kann aber posix oder
|
|
100
101
|
// windows-style separators haben. Wir prüfen substring-tolerant.
|
|
101
102
|
const normalised = `/${filename.toString().replace(/\\/g, "/")}`;
|
|
103
|
+
// skip: path matches an excluded segment (node_modules/dist/etc.)
|
|
102
104
|
if (SKIP_SUBSTRINGS.some((seg) => normalised.includes(seg))) return;
|
|
103
|
-
// Nur .ts/.tsx interessieren — alles andere (CSS, MD, JSON) hat
|
|
105
|
+
// skip: Nur .ts/.tsx interessieren — alles andere (CSS, MD, JSON) hat
|
|
104
106
|
// keinen Einfluss auf r.defineEvent-Calls.
|
|
105
107
|
if (!normalised.endsWith(".ts") && !normalised.endsWith(".tsx")) return;
|
|
106
|
-
// .d.ts ausgenommen — die kommen meistens vom codegen selbst.
|
|
108
|
+
// skip: .d.ts ausgenommen — die kommen meistens vom codegen selbst.
|
|
107
109
|
if (normalised.endsWith(".d.ts")) return;
|
|
108
|
-
// .test.ts/.test.tsx ausgenommen — Tests definieren keine
|
|
110
|
+
// skip: .test.ts/.test.tsx ausgenommen — Tests definieren keine
|
|
109
111
|
// Production-Features.
|
|
110
112
|
if (normalised.endsWith(".test.ts") || normalised.endsWith(".test.tsx")) return;
|
|
111
113
|
|
|
@@ -127,6 +129,7 @@ export function watchAndRegenerate(opts: WatchOptions): WatchHandle {
|
|
|
127
129
|
|
|
128
130
|
return {
|
|
129
131
|
close: () => {
|
|
132
|
+
// skip: already closed, avoid double-close
|
|
130
133
|
if (closed) return;
|
|
131
134
|
closed = true;
|
|
132
135
|
if (timer) clearTimeout(timer);
|
package/src/compose-stacks.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// or tier maps (those stay in bin/server.ts / app run-config).
|
|
4
4
|
|
|
5
5
|
import { createAuditFeature } from "@cosmicdrift/kumiko-bundled-features/audit";
|
|
6
|
+
import {
|
|
7
|
+
type AuthMfaFeatureOptions,
|
|
8
|
+
createAuthMfaFeature,
|
|
9
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
6
10
|
import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
|
|
7
11
|
import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features/data-retention";
|
|
8
12
|
import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
@@ -61,10 +65,9 @@ export type UserDataRightsStackOptions = {
|
|
|
61
65
|
readonly includeDefaults?: boolean;
|
|
62
66
|
};
|
|
63
67
|
|
|
64
|
-
// `sessions`
|
|
65
|
-
//
|
|
66
|
-
// "sessions"
|
|
67
|
-
// with "duplicate feature" (a boot-time crash, not a warning).
|
|
68
|
+
// `sessions` may live on composeIdentityStack OR composeGdprStack — never both
|
|
69
|
+
// with sessions:true. composeOpsStack used to accept it too; combining presets
|
|
70
|
+
// that each push "sessions" crashes createRegistry with "duplicate feature".
|
|
68
71
|
export type OpsStackOptions = {
|
|
69
72
|
readonly delivery?: boolean;
|
|
70
73
|
readonly audit?: boolean;
|
|
@@ -72,6 +75,13 @@ export type OpsStackOptions = {
|
|
|
72
75
|
readonly rateLimiting?: boolean;
|
|
73
76
|
};
|
|
74
77
|
|
|
78
|
+
/** sessions (+ optional auth-mfa). config/user/tenant/auth-email-password stay
|
|
79
|
+
* on composeFeatures(includeBundled). Pass `mfa` options to mount TOTP. */
|
|
80
|
+
export type IdentityStackOptions = {
|
|
81
|
+
readonly sessions?: boolean;
|
|
82
|
+
readonly mfa?: AuthMfaFeatureOptions;
|
|
83
|
+
};
|
|
84
|
+
|
|
75
85
|
export function stackFeatureNames(features: readonly FeatureDefinition[]): string[] {
|
|
76
86
|
return features.map((f) => f.name);
|
|
77
87
|
}
|
|
@@ -146,3 +156,13 @@ export function composeOpsStack(options: OpsStackOptions = {}): FeatureDefinitio
|
|
|
146
156
|
if (rateLimiting) out.push(createRateLimitingFeature());
|
|
147
157
|
return out;
|
|
148
158
|
}
|
|
159
|
+
|
|
160
|
+
/** Identity opt-ins: sessions by default; auth-mfa when `mfa` options given.
|
|
161
|
+
* Do not also pass `sessions: true` to composeGdprStack — duplicate feature. */
|
|
162
|
+
export function composeIdentityStack(options: IdentityStackOptions = {}): FeatureDefinition[] {
|
|
163
|
+
const sessions = options.sessions ?? true;
|
|
164
|
+
const out: FeatureDefinition[] = [];
|
|
165
|
+
if (sessions) out.push(createSessionsFeature());
|
|
166
|
+
if (options.mfa !== undefined) out.push(createAuthMfaFeature(options.mfa));
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
@@ -342,6 +342,7 @@ async function watchDir(
|
|
|
342
342
|
} catch (err) {
|
|
343
343
|
// signal.abort() wirft AbortError aus dem async-iterator; das ist
|
|
344
344
|
// gewollt und kein Fehler. Andere Errors weiterreichen.
|
|
345
|
+
// skip: AbortSignal fired the abort, this is expected teardown not a real error
|
|
345
346
|
if ((err as { name?: string }).name === "AbortError") return;
|
|
346
347
|
throw err;
|
|
347
348
|
}
|
|
@@ -954,6 +955,7 @@ export async function createKumikoServer(
|
|
|
954
955
|
dir,
|
|
955
956
|
async (filename) => {
|
|
956
957
|
const action = classifyChange(filename);
|
|
958
|
+
// skip: file change classified as ignore (test/css/json), nothing to rebuild
|
|
957
959
|
if (action === "ignore") return;
|
|
958
960
|
if (action === "restart") {
|
|
959
961
|
logInfo(
|
package/src/few-shot-corpus.ts
CHANGED
|
@@ -52,7 +52,7 @@ export type CorpusWarning = {
|
|
|
52
52
|
/** Human-readable explanation. Currently only "parser-throw" but
|
|
53
53
|
* kept open-ended so future builders can add more (e.g.
|
|
54
54
|
* "duplicate-id", "no-feature-name"). */
|
|
55
|
-
readonly
|
|
55
|
+
readonly message: string;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
export type FewShotEntry = {
|
|
@@ -161,7 +161,7 @@ export function buildFewShotCorpus(options: BuildFewShotCorpusOptions): FewShotC
|
|
|
161
161
|
// warning, drop the second occurrence.
|
|
162
162
|
warnings.push({
|
|
163
163
|
sourcePath: entry.sourcePath,
|
|
164
|
-
|
|
164
|
+
message: `duplicate-id: collides with ${previousPath}`,
|
|
165
165
|
});
|
|
166
166
|
continue;
|
|
167
167
|
}
|
|
@@ -198,7 +198,7 @@ function buildEntry(filePath: string, repoRoot: string): BuildEntryResult {
|
|
|
198
198
|
// hide newly broken feature-files until L2 hit them.
|
|
199
199
|
const detail = err instanceof Error ? err.message : String(err);
|
|
200
200
|
return {
|
|
201
|
-
warning: { sourcePath,
|
|
201
|
+
warning: { sourcePath, message: `parser-throw: ${detail}` },
|
|
202
202
|
};
|
|
203
203
|
}
|
|
204
204
|
|
|
@@ -295,6 +295,7 @@ function walkDir(dir: string, acc: string[]): void {
|
|
|
295
295
|
try {
|
|
296
296
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
297
297
|
} catch {
|
|
298
|
+
// skip: directory missing/unreadable, nothing to walk
|
|
298
299
|
return;
|
|
299
300
|
}
|
|
300
301
|
for (const entry of entries) {
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export {
|
|
|
32
32
|
export {
|
|
33
33
|
composeFileStack,
|
|
34
34
|
composeGdprStack,
|
|
35
|
+
composeIdentityStack,
|
|
35
36
|
composeMailStack,
|
|
36
37
|
composeOpsStack,
|
|
37
38
|
composePagesStack,
|
|
@@ -41,6 +42,7 @@ export {
|
|
|
41
42
|
type FileStackOptions,
|
|
42
43
|
type GdprStackOptions,
|
|
43
44
|
type GdprStackOrder,
|
|
45
|
+
type IdentityStackOptions,
|
|
44
46
|
type MailStackOptions,
|
|
45
47
|
type MailTransportKind,
|
|
46
48
|
type OpsStackOptions,
|
package/src/scaffold-app.ts
CHANGED
|
@@ -500,7 +500,7 @@ function renderMain(appName: string): string {
|
|
|
500
500
|
[
|
|
501
501
|
"// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
|
|
502
502
|
"// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
|
|
503
|
-
"// (see @cosmicdrift/kumiko-
|
|
503
|
+
"// (see @cosmicdrift/kumiko-server-runtime runProdApp). The real dev boot",
|
|
504
504
|
"// runs via `bunx kumiko dev` (in-repo dev-tool) with a Docker stack — DX-1.0",
|
|
505
505
|
"// only covers the boot-mode path; `kumiko dev` lands in a later DX phase.",
|
|
506
506
|
"",
|
|
@@ -35,6 +35,7 @@ const listScreen: EntityListScreenDefinition = {
|
|
|
35
35
|
entity: "task",
|
|
36
36
|
columns: ["title", "status", "isUrgent", "priority"],
|
|
37
37
|
defaultSort: { field: "title", dir: "asc" },
|
|
38
|
+
rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
const editScreen: EntityEditScreenDefinition = {
|
|
@@ -48,9 +49,19 @@ const editScreen: EntityEditScreenDefinition = {
|
|
|
48
49
|
|
|
49
50
|
const open = { access: { openToAll: true } } as const;
|
|
50
51
|
|
|
52
|
+
const TASKS_I18N = {
|
|
53
|
+
"screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
|
|
54
|
+
"screen:task-edit.title": { de: "Aufgabe", en: "Task" },
|
|
55
|
+
"tasks:entity:task:field:title": { de: "Titel", en: "Title" },
|
|
56
|
+
"tasks:entity:task:field:status": { de: "Status", en: "Status" },
|
|
57
|
+
"tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
|
|
58
|
+
"tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
51
61
|
/** Canonical demo feature — keep in sync with `renderDemoTasksFeatureFile()`. */
|
|
52
62
|
export function createDemoTasksFeature(): FeatureDefinition {
|
|
53
63
|
return defineFeature("tasks", (r) => {
|
|
64
|
+
r.translations({ keys: TASKS_I18N });
|
|
54
65
|
r.entity("task", taskEntity);
|
|
55
66
|
r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
|
|
56
67
|
r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
|
|
@@ -106,6 +117,7 @@ const listScreen: EntityListScreenDefinition = {
|
|
|
106
117
|
entity: "task",
|
|
107
118
|
columns: ["title", "status", "isUrgent", "priority"],
|
|
108
119
|
defaultSort: { field: "title", dir: "asc" },
|
|
120
|
+
rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
|
|
109
121
|
};
|
|
110
122
|
|
|
111
123
|
const editScreen: EntityEditScreenDefinition = {
|
|
@@ -119,7 +131,17 @@ const editScreen: EntityEditScreenDefinition = {
|
|
|
119
131
|
|
|
120
132
|
const open = { access: { openToAll: true } } as const;
|
|
121
133
|
|
|
134
|
+
const TASKS_I18N = {
|
|
135
|
+
"screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
|
|
136
|
+
"screen:task-edit.title": { de: "Aufgabe", en: "Task" },
|
|
137
|
+
"tasks:entity:task:field:title": { de: "Titel", en: "Title" },
|
|
138
|
+
"tasks:entity:task:field:status": { de: "Status", en: "Status" },
|
|
139
|
+
"tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
|
|
140
|
+
"tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
|
|
141
|
+
} as const;
|
|
142
|
+
|
|
122
143
|
export const tasksFeature = defineFeature("tasks", (r) => {
|
|
144
|
+
r.translations({ keys: TASKS_I18N });
|
|
123
145
|
r.entity("task", taskEntity);
|
|
124
146
|
r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
|
|
125
147
|
r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
|
package/src/schema-apply.ts
CHANGED