@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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 +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +26 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
- package/src/folders/web/folder-manager.tsx +18 -13
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
- package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +43 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +69 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
"./secrets": "./src/secrets/index.ts",
|
|
78
78
|
"./sessions": "./src/sessions/index.ts",
|
|
79
79
|
"./sessions/testing": "./src/sessions/testing.ts",
|
|
80
|
+
"./personal-access-tokens": "./src/personal-access-tokens/index.ts",
|
|
81
|
+
"./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
|
|
80
82
|
"./feature-toggles": "./src/feature-toggles/index.ts",
|
|
81
83
|
"./text-content": "./src/text-content/index.ts",
|
|
82
84
|
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
@@ -93,11 +95,11 @@
|
|
|
93
95
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
94
96
|
},
|
|
95
97
|
"dependencies": {
|
|
96
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
98
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.109.0",
|
|
99
|
+
"@cosmicdrift/kumiko-framework": "0.109.0",
|
|
100
|
+
"@cosmicdrift/kumiko-headless": "0.109.0",
|
|
101
|
+
"@cosmicdrift/kumiko-renderer": "0.109.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer-web": "0.109.0",
|
|
101
103
|
"@mollie/api-client": "^4.5.0",
|
|
102
104
|
"@node-rs/argon2": "^2.0.2",
|
|
103
105
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { asRawClient
|
|
3
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
unsafeCreateEntityTable,
|
|
13
13
|
unsafePushTables,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
16
|
import { createConfigFeature } from "../../config";
|
|
16
17
|
import { createConfigResolver } from "../../config/resolver";
|
|
17
18
|
import { configValuesTable } from "../../config/table";
|
|
@@ -119,7 +120,7 @@ async function seedUser(email: string, password: string): Promise<string> {
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
async function addMembership(userId: string, tenantId: TenantId, roles: string[]): Promise<void> {
|
|
122
|
-
await
|
|
123
|
+
await seedRow(stack.db, tenantMembershipsTable, {
|
|
123
124
|
userId,
|
|
124
125
|
tenantId,
|
|
125
126
|
roles: JSON.stringify(roles),
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
asRawClient,
|
|
5
|
-
insertOne,
|
|
6
|
-
selectMany,
|
|
7
|
-
updateMany,
|
|
8
|
-
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
10
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
6
|
import {
|
|
@@ -15,6 +10,7 @@ import {
|
|
|
15
10
|
unsafeCreateEntityTable,
|
|
16
11
|
unsafePushTables,
|
|
17
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
14
|
import { Temporal } from "temporal-polyfill";
|
|
19
15
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
20
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -146,7 +142,7 @@ async function seedUser(opts: {
|
|
|
146
142
|
// it directly via SQL after create. Row.version is left at 1; no
|
|
147
143
|
// subsequent event-store writes happen on this row in these tests.
|
|
148
144
|
if (opts.emailVerified === true) {
|
|
149
|
-
await
|
|
145
|
+
await updateRows(stack.db, userTable, { emailVerified: true }, { id: created.id });
|
|
150
146
|
}
|
|
151
147
|
const tenantId = opts.tenantId ?? "00000000-0000-4000-8000-000000000001";
|
|
152
148
|
await seedTenantMembership(stack.db, {
|
|
@@ -252,7 +248,7 @@ describe("POST /auth/verify-email", () => {
|
|
|
252
248
|
const firstAttempt = await post("/api/auth/verify-email", { token });
|
|
253
249
|
expect(firstAttempt.status).toBe(422);
|
|
254
250
|
|
|
255
|
-
await
|
|
251
|
+
await seedRow(stack.db, userTable, userRow);
|
|
256
252
|
|
|
257
253
|
const secondAttempt = await post("/api/auth/verify-email", { token });
|
|
258
254
|
expect(secondAttempt.status).toBe(200);
|
|
@@ -296,7 +292,7 @@ describe("POST /auth/verify-email", () => {
|
|
|
296
292
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
297
293
|
|
|
298
294
|
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
299
|
-
await
|
|
295
|
+
await seedRow(stack.db, userTable, {
|
|
300
296
|
...donorRow,
|
|
301
297
|
id: orphanId,
|
|
302
298
|
email: "orphan@example.com",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// 5. DB-State + Membership + Cookies/JWT verifizieren
|
|
16
16
|
|
|
17
17
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
-
import { asRawClient,
|
|
18
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
19
|
import {
|
|
20
20
|
createSystemUser,
|
|
21
21
|
type SessionUser,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
unsafeCreateEntityTable,
|
|
29
29
|
unsafePushTables,
|
|
30
30
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
31
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
31
32
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
32
33
|
import { createConfigFeature } from "../../config";
|
|
33
34
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -421,7 +422,7 @@ describe("invite-accept defense-in-depth (assertAssignableMembershipRoles)", ()
|
|
|
421
422
|
// (which already validates at request time — see "privilege escalation"
|
|
422
423
|
// below). A DB migration or direct write is the only realistic vector.
|
|
423
424
|
const fakeInvitationId = crypto.randomUUID();
|
|
424
|
-
await
|
|
425
|
+
await seedRow(stack.db, tenantInvitationsTable, {
|
|
425
426
|
id: fakeInvitationId,
|
|
426
427
|
tenantId: TENANT_A_ID,
|
|
427
428
|
email: BOB_EMAIL,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (nur tenant-membership-roles in der Session).
|
|
10
10
|
|
|
11
11
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
-
import { asRawClient,
|
|
12
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import {
|
|
15
15
|
setupTestStack,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
unsafeCreateEntityTable,
|
|
20
20
|
unsafePushTables,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
22
23
|
import { createConfigFeature } from "../../config";
|
|
23
24
|
import { createConfigResolver } from "../../config/resolver";
|
|
24
25
|
import { configValuesTable } from "../../config/table";
|
|
@@ -102,7 +103,7 @@ async function addMembership(
|
|
|
102
103
|
tenantId: TenantId,
|
|
103
104
|
roles: readonly string[],
|
|
104
105
|
): Promise<void> {
|
|
105
|
-
await
|
|
106
|
+
await seedRow(stack.db, tenantMembershipsTable, {
|
|
106
107
|
userId,
|
|
107
108
|
tenantId,
|
|
108
109
|
roles: JSON.stringify(roles),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { asRawClient,
|
|
3
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
unsafeCreateEntityTable,
|
|
11
11
|
unsafePushTables,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
13
14
|
import { Temporal } from "temporal-polyfill";
|
|
14
15
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
15
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -302,7 +303,7 @@ describe("POST /auth/reset-password", () => {
|
|
|
302
303
|
|
|
303
304
|
// Re-insert the captured row verbatim. Same userId, same version, same
|
|
304
305
|
// token still valid.
|
|
305
|
-
await
|
|
306
|
+
await seedRow(stack.db, userTable, userRow);
|
|
306
307
|
|
|
307
308
|
const secondAttempt = await post("/api/auth/reset-password", {
|
|
308
309
|
token,
|
|
@@ -357,7 +358,7 @@ describe("POST /auth/reset-password", () => {
|
|
|
357
358
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
358
359
|
|
|
359
360
|
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
360
|
-
await
|
|
361
|
+
await seedRow(stack.db, userTable, {
|
|
361
362
|
...donorRow,
|
|
362
363
|
id: orphanId,
|
|
363
364
|
email: "orphan@example.com",
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
7
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
8
|
+
import { UserHandlers, UserQueries } from "../../user";
|
|
5
9
|
import { invalidCredentials } from "../errors";
|
|
6
10
|
import { hashPassword, verifyPassword } from "../password-hashing";
|
|
7
11
|
|
|
@@ -35,14 +39,10 @@ export const changePasswordWrite = defineWriteHandler({
|
|
|
35
39
|
|
|
36
40
|
const newHash = await hashPassword(event.payload.newPassword);
|
|
37
41
|
|
|
38
|
-
// The user aggregate is
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
// of version_conflict-ing against an empty one. Falls back to the session
|
|
43
|
-
// tenant when the lookup finds nothing (fresh/unknown stream).
|
|
44
|
-
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
|
|
45
|
-
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
42
|
+
// The user aggregate is systemStream (#497): its event stream lives on
|
|
43
|
+
// SYSTEM_TENANT_ID deterministically. Pre-#497 scattered streams need the
|
|
44
|
+
// one-time backfillUserStreamTenants migration (#762).
|
|
45
|
+
const writer = createSystemUser(SYSTEM_TENANT_ID);
|
|
46
46
|
|
|
47
47
|
// Apply via user feature's update handler — writeAs(system) satisfies
|
|
48
48
|
// the privileged-only write rule on passwordHash. Pass the current version
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
// 1. Redis check + burn (single-use enforcement)
|
|
6
6
|
// 2. Load user + deleted/missing/no-version guard
|
|
7
7
|
// 3. Optional idempotent short-circuit (verify-email when already done)
|
|
8
|
-
// 4.
|
|
9
|
-
// 5.
|
|
10
|
-
// 6. Release the burn on ANY non-success path so a legit retry isn't
|
|
8
|
+
// 4. Apply the handler-specific `changes` on the user's SYSTEM stream
|
|
9
|
+
// 5. Release the burn on ANY non-success path so a legit retry isn't
|
|
11
10
|
// locked out by a stale marker
|
|
12
11
|
//
|
|
13
12
|
// The top-level `runConfirmTokenFlow` orchestrates and owns the
|
|
@@ -15,26 +14,26 @@
|
|
|
15
14
|
// burn (success, already-done) flips `committed = true`; everything
|
|
16
15
|
// else — including future branches a maintainer adds — releases
|
|
17
16
|
// automatically.
|
|
17
|
+
//
|
|
18
|
+
// The user aggregate is systemStream (#497): its event stream lives on
|
|
19
|
+
// SYSTEM_TENANT_ID deterministically, so the write targets exactly one
|
|
20
|
+
// stream. The former membership-probing (`tryWriteAcrossTenants`) existed
|
|
21
|
+
// for pre-#497 scattered streams; those need the one-time
|
|
22
|
+
// backfillUserStreamTenants migration (#762) — probing them stopped working
|
|
23
|
+
// the moment the executor's stream choke-point landed anyway.
|
|
18
24
|
|
|
19
25
|
import {
|
|
20
26
|
createSystemUser,
|
|
21
27
|
type HandlerContext,
|
|
22
28
|
type SessionUser,
|
|
23
29
|
SYSTEM_TENANT_ID,
|
|
24
|
-
type TenantId,
|
|
25
30
|
type WriteResult,
|
|
26
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
-
import {
|
|
28
|
-
InternalError,
|
|
29
|
-
type WriteFailure,
|
|
30
|
-
writeFailure,
|
|
31
|
-
} from "@cosmicdrift/kumiko-framework/errors";
|
|
32
|
-
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
32
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
33
33
|
import type Redis from "ioredis";
|
|
34
|
-
import {
|
|
34
|
+
import { UserHandlers, UserQueries } from "../../user";
|
|
35
35
|
import type { AuthUserRow } from "../auth-user-row";
|
|
36
36
|
import { parseAuthUserRow } from "../auth-user-row";
|
|
37
|
-
import { orderTenantsByPreference } from "../stream-tenant";
|
|
38
37
|
import { burnToken, unburnToken } from "../token-burn-store";
|
|
39
38
|
|
|
40
39
|
export type ConfirmTokenFlowSpec<TSuccessData> = {
|
|
@@ -46,13 +45,11 @@ export type ConfirmTokenFlowSpec<TSuccessData> = {
|
|
|
46
45
|
// is misconfigured, not the caller's fault.
|
|
47
46
|
readonly redisRequiredMessage: string;
|
|
48
47
|
// Standard failure returned for every "token can't be consumed" path
|
|
49
|
-
// (bad state,
|
|
50
|
-
//
|
|
51
|
-
// branch fired.
|
|
48
|
+
// (bad state, version conflict on the stream). The route layer returns
|
|
49
|
+
// 422 with a uniform code so the caller can't tell which branch fired.
|
|
52
50
|
readonly invalidToken: () => ReturnType<typeof writeFailure>;
|
|
53
|
-
// Handler-specific payload for user:update. Runs once per token
|
|
54
|
-
//
|
|
55
|
-
// (password-reset hashes here).
|
|
51
|
+
// Handler-specific payload for user:update. Runs once per token. Can be
|
|
52
|
+
// async (password-reset hashes here).
|
|
56
53
|
readonly buildChanges: (me: AuthUserRow) => Promise<Record<string, unknown>>;
|
|
57
54
|
// Returned verbatim on a successful write.
|
|
58
55
|
readonly successData: TSuccessData;
|
|
@@ -96,20 +93,21 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
96
93
|
return { isSuccess: true, data: spec.alreadyDone.data };
|
|
97
94
|
}
|
|
98
95
|
|
|
99
|
-
const tenantOrder = await resolveStreamTenants(ctx, systemUser, me);
|
|
100
|
-
if (tenantOrder.length === 0) return spec.invalidToken();
|
|
101
|
-
|
|
102
96
|
const changes = await spec.buildChanges(me);
|
|
103
|
-
const
|
|
104
|
-
|
|
97
|
+
const writeRes = await ctx.writeAs(systemUser, UserHandlers.update, {
|
|
98
|
+
id: me.id,
|
|
99
|
+
version: me.version,
|
|
100
|
+
changes,
|
|
101
|
+
});
|
|
102
|
+
if (writeRes.isSuccess) {
|
|
105
103
|
committed = true;
|
|
106
104
|
return { isSuccess: true, data: spec.successData };
|
|
107
105
|
}
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// denied)
|
|
111
|
-
if (
|
|
112
|
-
return
|
|
106
|
+
// version_conflict = concurrent modification (or an un-migrated pre-#497
|
|
107
|
+
// stream, see header) → token-level failure. Anything else (DB down,
|
|
108
|
+
// access denied) bubbles unchanged so ops sees the real failure class.
|
|
109
|
+
if (writeRes.error.code === "version_conflict") return spec.invalidToken();
|
|
110
|
+
return writeRes;
|
|
113
111
|
} finally {
|
|
114
112
|
// committed===false covers EVERY failure path — including branches a
|
|
115
113
|
// future maintainer adds without reading this file. The original
|
|
@@ -121,14 +119,13 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
121
119
|
}
|
|
122
120
|
}
|
|
123
121
|
|
|
124
|
-
// --- Private helpers ------------------------------------------------------
|
|
125
|
-
|
|
126
122
|
// Fetches the user row via the privileged findForAuth query and validates
|
|
127
|
-
// it's usable for a write: not deleted, has a row.version (
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// Return type narrows `version` to `number` so the
|
|
131
|
-
// need a `?? 0` fallback — the guard lives here,
|
|
123
|
+
// it's usable for a write: not deleted, has a row.version >= 1 (a row
|
|
124
|
+
// without any event stream — e.g. inserted straight into read_users — must
|
|
125
|
+
// not be confirmable: the write would otherwise seed a fresh stream with a
|
|
126
|
+
// bare user.updated). Return type narrows `version` to `number` so the
|
|
127
|
+
// write-callsite doesn't need a `?? 0` fallback — the guard lives here,
|
|
128
|
+
// not at every callsite.
|
|
132
129
|
async function loadValidatedUser(
|
|
133
130
|
ctx: HandlerContext,
|
|
134
131
|
systemUser: SessionUser,
|
|
@@ -137,74 +134,6 @@ async function loadValidatedUser(
|
|
|
137
134
|
const me = parseAuthUserRow(
|
|
138
135
|
await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
|
|
139
136
|
);
|
|
140
|
-
if (!me || me.isDeleted || me.version === undefined) return null;
|
|
137
|
+
if (!me || me.isDeleted || me.version === undefined || me.version < 1) return null;
|
|
141
138
|
return { ...me, version: me.version };
|
|
142
139
|
}
|
|
143
|
-
|
|
144
|
-
// Loads the user's memberships and returns a prioritised tenant list, with the
|
|
145
|
-
// aggregate's real stream tenant recovered from the event log prepended.
|
|
146
|
-
//
|
|
147
|
-
// Empty only when the user has NO memberships AND no recoverable stream tenant
|
|
148
|
-
// — the caller treats that as invalid_token. A zero-membership systemScope user
|
|
149
|
-
// whose stream lives outside any membership (a platform operator seeded under a
|
|
150
|
-
// fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
|
|
151
|
-
// before the empty check rather than being short-circuited by it.
|
|
152
|
-
async function resolveStreamTenants(
|
|
153
|
-
ctx: HandlerContext,
|
|
154
|
-
systemUser: SessionUser,
|
|
155
|
-
me: AuthUserRow,
|
|
156
|
-
): Promise<readonly TenantId[]> {
|
|
157
|
-
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
158
|
-
userId: me.id,
|
|
159
|
-
})) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
|
|
160
|
-
const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
|
|
161
|
-
|
|
162
|
-
// The user aggregate is r.systemScope(): its event stream lives in whichever
|
|
163
|
-
// tenant the creating executor used, which need NOT be a membership tenant.
|
|
164
|
-
// A platform operator seeded under a fixture/platform tenant is the live case
|
|
165
|
-
// — its stream tenant is absent from `ordered`, so a membership-only search
|
|
166
|
-
// rejects every write and collapses to invalid_token. Recover the real stream
|
|
167
|
-
// tenant from the event log and try it first; memberships stay as fallback.
|
|
168
|
-
// Pulled BEFORE the empty-membership check so a zero-membership operator whose
|
|
169
|
-
// stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
|
|
170
|
-
// invalid_token — mirrors change-password.write.ts's unconditional recovery.
|
|
171
|
-
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
|
|
172
|
-
if (streamTenant && !ordered.includes(streamTenant)) {
|
|
173
|
-
return [streamTenant, ...ordered];
|
|
174
|
-
}
|
|
175
|
-
return ordered;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Discriminated result for the write-across-tenants loop.
|
|
179
|
-
// all_conflicts → every candidate rejected with version_conflict →
|
|
180
|
-
// token-level failure; caller returns invalidToken.
|
|
181
|
-
// hard_failure → a non-conflict error that should bubble unchanged
|
|
182
|
-
// (DB down, access denied, …); caller returns it as-is.
|
|
183
|
-
type TenantWriteResult =
|
|
184
|
-
| { isSuccess: true }
|
|
185
|
-
| { isSuccess: false; reason: "all_conflicts" }
|
|
186
|
-
| { isSuccess: false; reason: "hard_failure"; failure: WriteFailure };
|
|
187
|
-
|
|
188
|
-
// Attempts the update against each candidate stream. memberships-query
|
|
189
|
-
// has no deterministic ORDER BY, so the matching stream is discovered by
|
|
190
|
-
// attempt: version_conflict → try the next candidate, anything else →
|
|
191
|
-
// bubble immediately so ops sees the real failure class.
|
|
192
|
-
async function tryWriteAcrossTenants(
|
|
193
|
-
ctx: HandlerContext,
|
|
194
|
-
me: AuthUserRow & { version: number },
|
|
195
|
-
tenantOrder: readonly TenantId[],
|
|
196
|
-
changes: Record<string, unknown>,
|
|
197
|
-
): Promise<TenantWriteResult> {
|
|
198
|
-
for (const tenantId of tenantOrder) {
|
|
199
|
-
const writeRes = await ctx.writeAs(createSystemUser(tenantId), UserHandlers.update, {
|
|
200
|
-
id: me.id,
|
|
201
|
-
version: me.version,
|
|
202
|
-
changes,
|
|
203
|
-
});
|
|
204
|
-
if (writeRes.isSuccess) return { isSuccess: true };
|
|
205
|
-
if (writeRes.error.code !== "version_conflict") {
|
|
206
|
-
return { isSuccess: false, reason: "hard_failure", failure: writeRes };
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return { isSuccess: false, reason: "all_conflicts" };
|
|
210
|
-
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// InviteAcceptScreen is a public route — anon invite-links are the
|
|
2
|
+
// documented use case (no <SessionProvider> ancestor). Regression 632/1:
|
|
3
|
+
// useSession() throws without a provider, so any consumer mounting this
|
|
4
|
+
// screen on a public route outside SessionAuthGate crashed at runtime.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
createStaticLocaleResolver,
|
|
9
|
+
LocaleProvider,
|
|
10
|
+
PrimitivesProvider,
|
|
11
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
12
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
13
|
+
import { render, screen } from "@testing-library/react";
|
|
14
|
+
import { defaultTranslations } from "../../i18n";
|
|
15
|
+
import { InviteAcceptScreen } from "../invite-accept-screen";
|
|
16
|
+
|
|
17
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
18
|
+
|
|
19
|
+
function renderWithoutSessionProvider(token: string) {
|
|
20
|
+
return render(
|
|
21
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
22
|
+
<LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
|
|
23
|
+
<InviteAcceptScreen token={token} />
|
|
24
|
+
</LocaleProvider>
|
|
25
|
+
</PrimitivesProvider>,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
|
|
30
|
+
test("renders the anonymous accept-form instead of throwing", () => {
|
|
31
|
+
renderWithoutSessionProvider("tok-123");
|
|
32
|
+
expect(screen.getByText("Einladung annehmen")).toBeTruthy();
|
|
33
|
+
expect(screen.getByLabelText(/^Passwort/)).toBeTruthy();
|
|
34
|
+
// Anon default mode is "anon-existing" — email field is shown too.
|
|
35
|
+
expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("missing token still renders (no session access needed for that branch)", () => {
|
|
39
|
+
renderWithoutSessionProvider("");
|
|
40
|
+
expect(
|
|
41
|
+
screen.getByText("Der Einladungs-Link enthält keinen Token oder ist ungültig."),
|
|
42
|
+
).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { SidebarProvider } from "@cosmicdrift/kumiko-renderer-web";
|
|
3
|
-
import { screen } from "@testing-library/react";
|
|
3
|
+
import { screen, within } from "@testing-library/react";
|
|
4
4
|
import userEvent from "@testing-library/user-event";
|
|
5
5
|
import { UserMenu } from "../user-menu";
|
|
6
6
|
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
@@ -31,6 +31,19 @@ describe("UserMenu", () => {
|
|
|
31
31
|
// Trim "" → leerer displayName → fallback auf email → erste 2 Chars
|
|
32
32
|
expect(screen.getByText("BO")).toBeTruthy();
|
|
33
33
|
});
|
|
34
|
+
test("no displayName → dropdown label shows the email exactly once (not duplicated)", async () => {
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
const session = makeSessionApi({
|
|
37
|
+
user: { id: "u1", email: "bob@example.com", displayName: "", globalRoles: [] },
|
|
38
|
+
});
|
|
39
|
+
renderWithProviders(<UserMenu />, { session });
|
|
40
|
+
await user.click(screen.getByRole("button", { name: /bob@example.com/ }));
|
|
41
|
+
const menu = within(screen.getByRole("menu"));
|
|
42
|
+
// getByText throws if the email appears more than once inside the
|
|
43
|
+
// dropdown panel — displayName falls back to the email, so without the
|
|
44
|
+
// hasName guard the second (redundant) email line would duplicate it.
|
|
45
|
+
expect(menu.getByText("bob@example.com")).toBeTruthy();
|
|
46
|
+
});
|
|
34
47
|
test("opens dropdown on click and shows logout button", async () => {
|
|
35
48
|
const user = userEvent.setup();
|
|
36
49
|
const session = makeSessionApi();
|
|
@@ -32,10 +32,9 @@ export function makeAuthGate(
|
|
|
32
32
|
return AuthGate;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* kein provider mehr sein. */
|
|
35
|
+
// SessionProvider + AuthGate as one gate, so a public gate in front (e.g.
|
|
36
|
+
// /calculator) doesn't mount the session bootstrap — createKumikoApp stacks
|
|
37
|
+
// providers outside all gates, so SessionProvider can't be a provider anymore.
|
|
39
38
|
export function makeSessionAuthGate(
|
|
40
39
|
LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
|
|
41
40
|
loginProps?: LoginScreenProps,
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
// auch zu loggedInHref damit der invitee in seinem neuen Tenant landet.
|
|
18
18
|
|
|
19
19
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
20
|
-
import { type FormEvent, type ReactNode, useState } from "react";
|
|
20
|
+
import { type FormEvent, type ReactNode, useContext, useState } from "react";
|
|
21
21
|
import { csrfHeader } from "./auth-client";
|
|
22
22
|
import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
|
|
23
|
-
import {
|
|
23
|
+
import { SessionContext, UNAUTHENTICATED } from "./session";
|
|
24
24
|
|
|
25
25
|
export type InviteAcceptScreenProps = {
|
|
26
26
|
readonly title?: string;
|
|
@@ -42,7 +42,11 @@ export function InviteAcceptScreen({
|
|
|
42
42
|
}: InviteAcceptScreenProps): ReactNode {
|
|
43
43
|
const t = useTranslation();
|
|
44
44
|
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
45
|
-
|
|
45
|
+
// NOT useSession(): InviteAcceptScreen is a public route (anon invite-links
|
|
46
|
+
// are the documented use case) — useSession() throws without a
|
|
47
|
+
// <SessionProvider> ancestor. Read the context directly and fall back to
|
|
48
|
+
// the anonymous state when no provider wraps this screen (632/1).
|
|
49
|
+
const session = useContext(SessionContext) ?? UNAUTHENTICATED;
|
|
46
50
|
const [token] = useState(() => tokenProp ?? parseUrlToken());
|
|
47
51
|
const [mode, setMode] = useState<Mode>(() =>
|
|
48
52
|
session.status === "authenticated" ? "loggedin" : "anon-existing",
|
|
@@ -44,7 +44,11 @@ export type SessionApi = SessionState & {
|
|
|
44
44
|
readonly switchTenant: (tenantId: string) => Promise<void>;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// Exported so screens that render on BOTH authenticated and anonymous public
|
|
48
|
+
// routes (e.g. InviteAcceptScreen) can read `.status` without requiring a
|
|
49
|
+
// <SessionProvider> ancestor — see useContext(SessionContext) ?? UNAUTHENTICATED
|
|
50
|
+
// in invite-accept-screen.tsx (632/1).
|
|
51
|
+
export const UNAUTHENTICATED: SessionState = {
|
|
48
52
|
status: "unauthenticated",
|
|
49
53
|
user: null,
|
|
50
54
|
activeTenantId: null,
|
|
@@ -64,7 +64,7 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
|
|
|
64
64
|
<DropdownMenuContent align="end" aria-label={t("auth.user.menu.label")}>
|
|
65
65
|
<DropdownMenuLabel className="text-xs">
|
|
66
66
|
<div className="font-medium text-foreground truncate">{displayName}</div>
|
|
67
|
-
<div className="truncate">{user.email}</div>
|
|
67
|
+
{hasName && <div className="truncate">{user.email}</div>}
|
|
68
68
|
</DropdownMenuLabel>
|
|
69
69
|
<DropdownMenuSeparator />
|
|
70
70
|
{children}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { deserializeValue } from "../resolver";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// refactor can't quietly change them: a stored value whose JSON type disagrees
|
|
8
|
-
// with the declared type is NOT rejected — it is coerced (number via Number(),
|
|
9
|
-
// boolean only via literal true / the string "true", text via String()).
|
|
4
|
+
// Type mismatches are coerced, not rejected (number via Number(), boolean
|
|
5
|
+
// only via literal true/"true", text via String()) — pinned so a refactor
|
|
6
|
+
// can't quietly change that.
|
|
10
7
|
|
|
11
8
|
describe("deserializeValue", () => {
|
|
12
9
|
test("null raw short-circuits to undefined before any parse", () => {
|