@cosmicdrift/kumiko-dev-server 0.155.1 → 0.156.1
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/__tests__/scaffold-app.test.ts +1 -0
- package/src/build-server-bundle.ts +9 -3
- package/src/codegen/__tests__/watch.test.ts +1 -2
- package/src/codegen/watch.ts +1 -1
- package/src/compose-stacks.ts +24 -4
- package/src/few-shot-corpus.ts +3 -3
- package/src/index.ts +2 -0
- package/src/scaffold-app.ts +13 -3
- package/src/scaffold-demo-tasks.ts +60 -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.1",
|
|
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.1",
|
|
58
|
+
"@cosmicdrift/kumiko-framework": "0.156.1",
|
|
59
|
+
"@cosmicdrift/kumiko-server-runtime": "0.156.1",
|
|
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
|
+
});
|
|
@@ -121,6 +121,7 @@ describe("scaffoldApp", () => {
|
|
|
121
121
|
expect(client).toContain(">my-shop</span>");
|
|
122
122
|
expect(client).toContain('from "@cosmicdrift/kumiko-renderer-web"');
|
|
123
123
|
expect(client).toContain('from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web"');
|
|
124
|
+
expect(client).toContain("tasksClient");
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
test(".env.example carries KUMIKO_DEV_DB_NAME default so reboots are persistent", async () => {
|
|
@@ -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(
|
|
@@ -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 () => {
|
package/src/codegen/watch.ts
CHANGED
|
@@ -97,7 +97,7 @@ export function watchAndRegenerate(opts: WatchOptions): WatchHandle {
|
|
|
97
97
|
watcher = watch(srcDir, { recursive: true }, (_eventType, filename) => {
|
|
98
98
|
// skip: watcher closed or no filename reported, nothing to act on
|
|
99
99
|
if (closed || !filename) return;
|
|
100
|
-
//
|
|
100
|
+
// node liefert filename relativ zu srcDir, kann aber posix oder
|
|
101
101
|
// windows-style separators haben. Wir prüfen substring-tolerant.
|
|
102
102
|
const normalised = `/${filename.toString().replace(/\\/g, "/")}`;
|
|
103
103
|
// skip: path matches an excluded segment (node_modules/dist/etc.)
|
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
|
+
}
|
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
|
|
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
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
createDemoTasksFeature,
|
|
27
27
|
renderDemoSeedFile,
|
|
28
28
|
renderDemoTasksFeatureFile,
|
|
29
|
+
renderDemoTasksI18n,
|
|
29
30
|
renderDemoTasksIndex,
|
|
31
|
+
renderDemoTasksWebIndex,
|
|
30
32
|
} from "./scaffold-demo-tasks";
|
|
31
33
|
import { scaffoldDeploy } from "./scaffold-deploy";
|
|
32
34
|
|
|
@@ -101,11 +103,18 @@ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<Scaffold
|
|
|
101
103
|
write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
|
|
102
104
|
files.push("src/run-config.ts");
|
|
103
105
|
|
|
104
|
-
mkdirSync(join(destination, "src", "features", "tasks"), { recursive: true });
|
|
106
|
+
mkdirSync(join(destination, "src", "features", "tasks", "web"), { recursive: true });
|
|
105
107
|
write(join(destination, "src", "features", "tasks", "feature.ts"), renderDemoTasksFeatureFile());
|
|
106
108
|
files.push("src/features/tasks/feature.ts");
|
|
109
|
+
write(join(destination, "src", "features", "tasks", "i18n.ts"), renderDemoTasksI18n());
|
|
110
|
+
files.push("src/features/tasks/i18n.ts");
|
|
107
111
|
write(join(destination, "src", "features", "tasks", "index.ts"), renderDemoTasksIndex());
|
|
108
112
|
files.push("src/features/tasks/index.ts");
|
|
113
|
+
write(
|
|
114
|
+
join(destination, "src", "features", "tasks", "web", "index.ts"),
|
|
115
|
+
renderDemoTasksWebIndex(),
|
|
116
|
+
);
|
|
117
|
+
files.push("src/features/tasks/web/index.ts");
|
|
109
118
|
write(join(destination, "src", "seed.ts"), renderDemoSeedFile());
|
|
110
119
|
files.push("src/seed.ts");
|
|
111
120
|
|
|
@@ -500,7 +509,7 @@ function renderMain(appName: string): string {
|
|
|
500
509
|
[
|
|
501
510
|
"// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
|
|
502
511
|
"// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
|
|
503
|
-
"// (see @cosmicdrift/kumiko-
|
|
512
|
+
"// (see @cosmicdrift/kumiko-server-runtime runProdApp). The real dev boot",
|
|
504
513
|
"// runs via `bunx kumiko dev` (in-repo dev-tool) with a Docker stack — DX-1.0",
|
|
505
514
|
"// only covers the boot-mode path; `kumiko dev` lands in a later DX phase.",
|
|
506
515
|
"",
|
|
@@ -608,6 +617,7 @@ function renderClient(appName: string): string {
|
|
|
608
617
|
"// here — symmetric to APP_FEATURES on the server side.",
|
|
609
618
|
"",
|
|
610
619
|
'import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";',
|
|
620
|
+
'import { tasksClient } from "./features/tasks/web";',
|
|
611
621
|
'import { type AppSchema, createKumikoApp, DefaultAppShell } from "@cosmicdrift/kumiko-renderer-web";',
|
|
612
622
|
'import type { ReactNode } from "react";',
|
|
613
623
|
"",
|
|
@@ -622,7 +632,7 @@ function renderClient(appName: string): string {
|
|
|
622
632
|
"",
|
|
623
633
|
"createKumikoApp({",
|
|
624
634
|
" shell: AppShell,",
|
|
625
|
-
" clientFeatures: [emailPasswordClient()],",
|
|
635
|
+
" clientFeatures: [emailPasswordClient(), tasksClient],",
|
|
626
636
|
"});",
|
|
627
637
|
"",
|
|
628
638
|
].join("\n");
|
|
@@ -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));
|
|
@@ -90,6 +101,7 @@ import type {
|
|
|
90
101
|
EntityEditScreenDefinition,
|
|
91
102
|
EntityListScreenDefinition,
|
|
92
103
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
104
|
+
import { tasksTranslationKeys } from "./i18n";
|
|
93
105
|
|
|
94
106
|
const taskEntity = createEntity({
|
|
95
107
|
fields: {
|
|
@@ -106,6 +118,7 @@ const listScreen: EntityListScreenDefinition = {
|
|
|
106
118
|
entity: "task",
|
|
107
119
|
columns: ["title", "status", "isUrgent", "priority"],
|
|
108
120
|
defaultSort: { field: "title", dir: "asc" },
|
|
121
|
+
rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
|
|
109
122
|
};
|
|
110
123
|
|
|
111
124
|
const editScreen: EntityEditScreenDefinition = {
|
|
@@ -120,6 +133,7 @@ const editScreen: EntityEditScreenDefinition = {
|
|
|
120
133
|
const open = { access: { openToAll: true } } as const;
|
|
121
134
|
|
|
122
135
|
export const tasksFeature = defineFeature("tasks", (r) => {
|
|
136
|
+
r.translations({ keys: tasksTranslationKeys });
|
|
123
137
|
r.entity("task", taskEntity);
|
|
124
138
|
r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
|
|
125
139
|
r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
|
|
@@ -140,6 +154,52 @@ export const tasksFeature = defineFeature("tasks", (r) => {
|
|
|
140
154
|
`;
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
export function renderDemoTasksI18n(): string {
|
|
158
|
+
return `// Server-side translation keys for the demo tasks feature.
|
|
159
|
+
|
|
160
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
161
|
+
|
|
162
|
+
export const tasksTranslationKeys = {
|
|
163
|
+
"screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
|
|
164
|
+
"screen:task-edit.title": { de: "Aufgabe", en: "Task" },
|
|
165
|
+
"tasks:entity:task:field:title": { de: "Titel", en: "Title" },
|
|
166
|
+
"tasks:entity:task:field:status": { de: "Status", en: "Status" },
|
|
167
|
+
"tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
|
|
168
|
+
"tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
|
|
169
|
+
} as const;
|
|
170
|
+
|
|
171
|
+
export const tasksTranslations: TranslationsByLocale = {
|
|
172
|
+
de: {
|
|
173
|
+
"screen:task-list.title": "Aufgaben",
|
|
174
|
+
"screen:task-edit.title": "Aufgabe",
|
|
175
|
+
"tasks:entity:task:field:title": "Titel",
|
|
176
|
+
"tasks:entity:task:field:status": "Status",
|
|
177
|
+
"tasks:entity:task:field:priority": "Priorität",
|
|
178
|
+
"tasks:entity:task:field:isUrgent": "Dringend",
|
|
179
|
+
},
|
|
180
|
+
en: {
|
|
181
|
+
"screen:task-list.title": "Tasks",
|
|
182
|
+
"screen:task-edit.title": "Task",
|
|
183
|
+
"tasks:entity:task:field:title": "Title",
|
|
184
|
+
"tasks:entity:task:field:status": "Status",
|
|
185
|
+
"tasks:entity:task:field:priority": "Priority",
|
|
186
|
+
"tasks:entity:task:field:isUrgent": "Urgent",
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function renderDemoTasksWebIndex(): string {
|
|
193
|
+
return `import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
194
|
+
import { tasksTranslations } from "../i18n";
|
|
195
|
+
|
|
196
|
+
export const tasksClient: ClientFeatureDefinition = {
|
|
197
|
+
name: "tasks",
|
|
198
|
+
translations: tasksTranslations,
|
|
199
|
+
};
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
|
|
143
203
|
export function renderDemoTasksIndex(): string {
|
|
144
204
|
return `export { tasksFeature } from "./feature";
|
|
145
205
|
`;
|