@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +204 -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 +15 -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 +22 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +45 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -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
|
@@ -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 {
|
|
3
|
+
import { selectMany } 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 {
|
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
unsafeCreateEntityTable,
|
|
11
11
|
unsafePushTables,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createLateBoundHolder,
|
|
15
|
+
deleteRows,
|
|
16
|
+
resetTestTables,
|
|
17
|
+
updateRows,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
14
19
|
import { Temporal } from "temporal-polyfill";
|
|
15
20
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
16
21
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
@@ -367,7 +372,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
367
372
|
|
|
368
373
|
// Hard-delete the session row so it's gone from the store (as opposed to
|
|
369
374
|
// soft-revoked). The JWT stays syntactically valid.
|
|
370
|
-
await
|
|
375
|
+
await deleteRows(stack.db, userSessionTable, { id: sid });
|
|
371
376
|
|
|
372
377
|
const res = await h.authedPost("/api/query", token, {
|
|
373
378
|
type: "user:query:user:me",
|
|
@@ -384,7 +389,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
384
389
|
|
|
385
390
|
// Back-date expiresAt so the row is still present + not revoked, just
|
|
386
391
|
// past its window. Simulates what a long-lived JWT would hit.
|
|
387
|
-
await
|
|
392
|
+
await updateRows(
|
|
388
393
|
stack.db,
|
|
389
394
|
userSessionTable,
|
|
390
395
|
{ expiresAt: Temporal.Instant.from("2020-01-01T00:00:00Z") },
|
|
@@ -422,7 +427,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
422
427
|
// so she gets a fresh JWT with the new role in its claims. This is the
|
|
423
428
|
// actual production path — roles are tenant-membership data, not JWT
|
|
424
429
|
// metadata we can fiddle with directly.
|
|
425
|
-
await
|
|
430
|
+
await updateRows(
|
|
426
431
|
stack.db,
|
|
427
432
|
tenantMembershipsTable,
|
|
428
433
|
{ roles: JSON.stringify(["Admin"]) },
|
|
@@ -475,7 +480,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
|
|
|
475
480
|
test("restricted after login → 401 reason=blocked", async () => {
|
|
476
481
|
const { userId } = await h.seedUser("restrict@example.com", "pw-long-enough");
|
|
477
482
|
const { token } = await h.login("restrict@example.com", "pw-long-enough");
|
|
478
|
-
await
|
|
483
|
+
await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
|
|
479
484
|
|
|
480
485
|
const res = await h.authedPost("/api/query", token, {
|
|
481
486
|
type: "user:query:user:me",
|
|
@@ -489,7 +494,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
|
|
|
489
494
|
test("deleted after login → 401 reason=blocked", async () => {
|
|
490
495
|
const { userId } = await h.seedUser("gone@example.com", "pw-long-enough");
|
|
491
496
|
const { token } = await h.login("gone@example.com", "pw-long-enough");
|
|
492
|
-
await
|
|
497
|
+
await updateRows(stack.db, userTable, { status: USER_STATUS.Deleted }, { id: userId });
|
|
493
498
|
|
|
494
499
|
const res = await h.authedPost("/api/query", token, {
|
|
495
500
|
type: "user:query:user:me",
|
|
@@ -507,7 +512,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
|
|
|
507
512
|
// into a global lockout for every affected session instead of a no-op.
|
|
508
513
|
const { userId } = await h.seedUser("hardgone@example.com", "pw-long-enough");
|
|
509
514
|
const { token } = await h.login("hardgone@example.com", "pw-long-enough");
|
|
510
|
-
await
|
|
515
|
+
await deleteRows(stack.db, userTable, { id: userId });
|
|
511
516
|
|
|
512
517
|
const res = await h.authedPost("/api/query", token, {
|
|
513
518
|
type: "user:query:user:me",
|
|
@@ -519,7 +524,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
|
|
|
519
524
|
test("deletionRequested keeps its session live — reversible grace period", async () => {
|
|
520
525
|
const { userId } = await h.seedUser("leaving@example.com", "pw-long-enough");
|
|
521
526
|
const { token } = await h.login("leaving@example.com", "pw-long-enough");
|
|
522
|
-
await
|
|
527
|
+
await updateRows(
|
|
523
528
|
stack.db,
|
|
524
529
|
userTable,
|
|
525
530
|
{ status: USER_STATUS.DeletionRequested },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
4
|
createEntity,
|
|
@@ -64,4 +64,10 @@ export const userSessionEntity = createEntity({
|
|
|
64
64
|
},
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// Plain EntityTableMeta, NOT a branded EntityTable: user-session is an
|
|
68
|
+
// unmanaged direct-write store (r.unmanagedTable in feature.ts, no event
|
|
69
|
+
// stream — revocation is a column write, not an aggregate). The feature's
|
|
70
|
+
// handlers write it directly via ctx.db; the meta carries no executor-only
|
|
71
|
+
// brand so those writes stay legal. See feature.ts for the rebuild-exclusion
|
|
72
|
+
// rationale (#494/#498).
|
|
73
|
+
export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity);
|
|
@@ -10,14 +10,14 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
|
10
10
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
12
12
|
import { Temporal } from "temporal-polyfill";
|
|
13
|
-
import { USER_STATUS, userTable } from "../user";
|
|
13
|
+
import { USER_STATUS, type UserStatus, userTable } from "../user";
|
|
14
14
|
import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
|
|
15
15
|
import { userSessionTable } from "./schema/user-session";
|
|
16
16
|
|
|
17
17
|
// Locked accounts whose live sessions must be refused. deletionRequested is
|
|
18
18
|
// intentionally absent — it's a reversible grace period and the user needs
|
|
19
19
|
// their session to reach cancel-deletion.
|
|
20
|
-
const BLOCKED_STATUSES: ReadonlySet<
|
|
20
|
+
const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
|
|
21
21
|
USER_STATUS.Restricted,
|
|
22
22
|
USER_STATUS.Deleted,
|
|
23
23
|
]);
|
|
@@ -104,7 +104,13 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
104
104
|
// a stale sid. Fail-OPEN on a lookup miss — this is the second layer,
|
|
105
105
|
// revocation is primary; never turn a user-row miss into a global
|
|
106
106
|
// lockout. (+1 PK read on read_users per authenticated request.)
|
|
107
|
-
|
|
107
|
+
//
|
|
108
|
+
// Fail-open covers a THROW too, not just a null-miss: this read sits on
|
|
109
|
+
// the hot path of every authenticated request, so a DB timeout / lock
|
|
110
|
+
// contention / pool exhaustion here must not turn into a global lockout.
|
|
111
|
+
const user = await fetchOne<{ status: UserStatus }>(db, userTable, {
|
|
112
|
+
id: expectedUserId,
|
|
113
|
+
}).catch(() => null);
|
|
108
114
|
if (user && BLOCKED_STATUSES.has(user.status)) return "blocked";
|
|
109
115
|
return "live";
|
|
110
116
|
},
|
|
@@ -44,34 +44,24 @@ const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
|
|
|
44
44
|
|
|
45
45
|
/** Stub-SecretsContext: liest aus einer in-memory-map, matcht per
|
|
46
46
|
* qualified-name. backing:"secrets" persistiert config-Werte JSON-
|
|
47
|
-
* serialisiert — der Stub spiegelt das, damit der Runtime-
|
|
48
|
-
* denselben Pfad nimmt wie gegen den echten Store.
|
|
49
|
-
|
|
47
|
+
* serialisiert — der Stub spiegelt das per Default, damit der Runtime-
|
|
48
|
+
* parseStoredSecret denselben Pfad nimmt wie gegen den echten Store.
|
|
49
|
+
* `raw: true` speichert UN-serialisiert (482/1) — um parseStoredSecret's
|
|
50
|
+
* Fehlerpfad zu treffen: ein Credential, das der Store un-JSON-kodiert
|
|
51
|
+
* zurückgibt (Korruption oder außerhalb des backing:"secrets"-Roundtrips
|
|
52
|
+
* geschrieben) muss laut failen, nicht still Müll liefern. Ignoriert
|
|
53
|
+
* auditCtx. */
|
|
54
|
+
function stubSecrets(
|
|
55
|
+
values: Record<string, string>,
|
|
56
|
+
opts?: { readonly raw?: boolean },
|
|
57
|
+
): SecretsContext {
|
|
50
58
|
const nameOf = (k: string | { readonly name: string }): string =>
|
|
51
59
|
typeof k === "string" ? k : k.name;
|
|
52
60
|
return {
|
|
53
61
|
get: async (_tenantId, key) => {
|
|
54
62
|
const value = values[nameOf(key)];
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
|
|
58
|
-
set: async () => undefined,
|
|
59
|
-
delete: async () => false,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Wie stubSecrets, aber speichert den Wert ROH (kein JSON.stringify) — um
|
|
64
|
-
* parseStoredSecret's Fehlerpfad zu treffen: ein Credential, das der Store
|
|
65
|
-
* un-JSON-kodiert zurückgibt (Korruption oder ein außerhalb des
|
|
66
|
-
* backing:"secrets"-Roundtrips geschriebener Wert) muss laut failen, nicht
|
|
67
|
-
* still Müll liefern. */
|
|
68
|
-
function rawSecretsStub(values: Record<string, string>): SecretsContext {
|
|
69
|
-
const nameOf = (k: string | { readonly name: string }): string =>
|
|
70
|
-
typeof k === "string" ? k : k.name;
|
|
71
|
-
return {
|
|
72
|
-
get: async (_tenantId, key) => {
|
|
73
|
-
const value = values[nameOf(key)];
|
|
74
|
-
return value === undefined ? undefined : createSecret(value); // RAW, not JSON
|
|
63
|
+
if (value === undefined) return undefined;
|
|
64
|
+
return createSecret(opts?.raw ? value : JSON.stringify(value));
|
|
75
65
|
},
|
|
76
66
|
has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
|
|
77
67
|
set: async () => undefined,
|
|
@@ -164,7 +154,7 @@ describe("StripeCtxRuntime.clientForCtx", () => {
|
|
|
164
154
|
// throw, not silently fall through to undefined/fallback.
|
|
165
155
|
const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
|
|
166
156
|
const ctx = stubCtx({
|
|
167
|
-
secrets:
|
|
157
|
+
secrets: stubSecrets({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }, { raw: true }),
|
|
168
158
|
});
|
|
169
159
|
await expect(rt.ctx.clientForCtx(ctx)).rejects.toThrow(
|
|
170
160
|
/Invalid JSON in subscription-stripe credential/,
|
|
@@ -306,6 +306,21 @@ describe("tags integration — idempotency", () => {
|
|
|
306
306
|
expect(rows).toHaveLength(1);
|
|
307
307
|
expect(rows[0]?.["tagId"]).toBe(tagId);
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
test("two concurrent first-time assigns of the same (tag, entity) both succeed — one row", async () => {
|
|
311
|
+
// Both requests read `existing === null` before either write lands, so
|
|
312
|
+
// both fall through to create(); the loser's create() version_conflicts.
|
|
313
|
+
// The handler must converge that into success instead of surfacing a 409
|
|
314
|
+
// for what is, from the caller's perspective, an idempotent operation.
|
|
315
|
+
const tagId = await createTag("racy");
|
|
316
|
+
const [a, b] = await Promise.all([
|
|
317
|
+
assign(tagId, "credit", "credit-race"),
|
|
318
|
+
assign(tagId, "credit", "credit-race"),
|
|
319
|
+
]);
|
|
320
|
+
expect(a).toBeDefined();
|
|
321
|
+
expect(b).toBeDefined();
|
|
322
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
323
|
+
});
|
|
309
324
|
});
|
|
310
325
|
|
|
311
326
|
describe("tags integration — referential integrity", () => {
|
|
@@ -14,8 +14,9 @@ import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
|
|
|
14
14
|
// append at version 0 onto the created+deleted stream and version_conflict;
|
|
15
15
|
// the deterministic id means that stream is permanent.
|
|
16
16
|
// - never assigned → create() (restore reports not_found).
|
|
17
|
-
// A concurrent first-time race
|
|
18
|
-
//
|
|
17
|
+
// A concurrent first-time race converges: both callers fall through to
|
|
18
|
+
// create(), the loser's create() version_conflicts, and the handler treats
|
|
19
|
+
// that as success since the winner already wrote the desired end state.
|
|
19
20
|
//
|
|
20
21
|
// Referential integrity: there is no FK (event-sourced, no JOIN), so before a
|
|
21
22
|
// first-time create we verify the tag exists in the catalog — a malformed call
|
|
@@ -46,7 +47,7 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
46
47
|
const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
|
|
47
48
|
if (!tag) return writeFailure(new NotFoundError("tag", payload.tagId));
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
const created = await tagAssignmentExecutor.create(
|
|
50
51
|
{
|
|
51
52
|
id,
|
|
52
53
|
tagId: payload.tagId,
|
|
@@ -56,6 +57,15 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
56
57
|
event.user,
|
|
57
58
|
ctx.db,
|
|
58
59
|
);
|
|
60
|
+
if (created.isSuccess) return created;
|
|
61
|
+
// A concurrent first-time assign of the same (tag, entity) races here —
|
|
62
|
+
// both callers' `existing` read above saw null, so both fall through to
|
|
63
|
+
// create(). The loser's create() version_conflicts (409), but the
|
|
64
|
+
// desired end state (the assignment exists) is already true — the
|
|
65
|
+
// winner just wrote it. Converge instead of surfacing a spurious 409
|
|
66
|
+
// for an idempotent operation.
|
|
67
|
+
if (created.error.code !== "version_conflict") return created;
|
|
68
|
+
return { isSuccess: true as const, data: { id } };
|
|
59
69
|
},
|
|
60
70
|
};
|
|
61
71
|
}
|
package/src/tags/schemas.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
export const createTagPayloadSchema = z.object({
|
|
4
|
-
name: z.string().min(1).max(64),
|
|
5
|
-
color: z
|
|
4
|
+
name: z.string().trim().min(1).max(64),
|
|
5
|
+
color: z
|
|
6
|
+
.string()
|
|
7
|
+
.regex(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$/, "color must be a hex code (#rgb or #rrggbb)")
|
|
8
|
+
.optional(),
|
|
6
9
|
scope: z.string().max(64).optional(),
|
|
7
10
|
});
|
|
8
11
|
export type CreateTagPayload = z.infer<typeof createTagPayloadSchema>;
|
|
@@ -16,8 +19,11 @@ export const updateTagPayloadSchema = z
|
|
|
16
19
|
.object({
|
|
17
20
|
id: z.string().min(1),
|
|
18
21
|
version: z.number().int().nonnegative(),
|
|
19
|
-
name: z.string().min(1).max(64).optional(),
|
|
20
|
-
color: z
|
|
22
|
+
name: z.string().trim().min(1).max(64).optional(),
|
|
23
|
+
color: z
|
|
24
|
+
.string()
|
|
25
|
+
.regex(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$/, "color must be a hex code (#rgb or #rrggbb)")
|
|
26
|
+
.optional(),
|
|
21
27
|
scope: z.string().max(64).optional(),
|
|
22
28
|
})
|
|
23
29
|
.refine((p) => p.name !== undefined || p.color !== undefined || p.scope !== undefined, {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
3
|
import {
|
|
5
4
|
setupTestStack,
|
|
6
5
|
type TestStack,
|
|
7
6
|
unsafeCreateEntityTable,
|
|
8
7
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
8
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
9
9
|
import { createTemplateResolverApi, TemplateNotFoundError, type TemplateResolverApi } from "../api";
|
|
10
10
|
import {
|
|
11
11
|
type ContentFormat,
|
|
@@ -56,7 +56,7 @@ async function seedTemplate(args: {
|
|
|
56
56
|
linkedResources?: Record<string, string>;
|
|
57
57
|
parentTemplateId?: string;
|
|
58
58
|
}) {
|
|
59
|
-
await
|
|
59
|
+
await seedRow(db, templateResourcesTable, {
|
|
60
60
|
tenantId: args.tenantId,
|
|
61
61
|
slug: args.slug,
|
|
62
62
|
kind: args.kind,
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// `@cosmicdrift/kumiko-bundled-features/template-resolver/testing`.
|
|
3
3
|
// No bun:test here — callers register cases with their own test runner.
|
|
4
4
|
|
|
5
|
-
import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
5
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
7
7
|
import type { ResolveRequest, TemplateResource } from "./api";
|
|
8
8
|
import { TemplateNotFoundError } from "./api";
|
|
9
9
|
import {
|
|
@@ -50,7 +50,7 @@ type SeedTemplateArgs = {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
async function seedTemplate(db: DbConnection, args: SeedTemplateArgs): Promise<void> {
|
|
53
|
-
await
|
|
53
|
+
await seedRow(db, templateResourcesTable, {
|
|
54
54
|
tenantId: args.tenantId,
|
|
55
55
|
slug: args.slug,
|
|
56
56
|
kind: args.kind,
|
|
@@ -74,10 +74,11 @@ export async function assertConsumerHandlesNotFound(
|
|
|
74
74
|
const { tenantId } = opts;
|
|
75
75
|
let rejected = false;
|
|
76
76
|
let err: unknown;
|
|
77
|
+
const slug = `conformance-not-found-${crypto.randomUUID()}`;
|
|
77
78
|
try {
|
|
78
79
|
await consumer.resolve({
|
|
79
80
|
tenantId,
|
|
80
|
-
slug
|
|
81
|
+
slug,
|
|
81
82
|
kind: "mail-html",
|
|
82
83
|
locale: "de",
|
|
83
84
|
});
|
|
@@ -217,6 +217,7 @@ export function createTierEngineFeature<
|
|
|
217
217
|
r.writeHandler(
|
|
218
218
|
createSetTenantTierWrite({
|
|
219
219
|
onAssigned: (tenantId, tier) => onTierAssigned.fn(tenantId, tier),
|
|
220
|
+
validTiers: opts.tierMap ? new Set(Object.keys(opts.tierMap)) : undefined,
|
|
220
221
|
}),
|
|
221
222
|
);
|
|
222
223
|
r.queryHandler(getTenantTierQuery);
|
|
@@ -452,19 +453,30 @@ export function createTierEngineFeature<
|
|
|
452
453
|
// tier-assignment-Row, die der seed-Pfad nicht anlegt) und prüft das
|
|
453
454
|
// Fenster gegen die aktuelle Zeit. Nur auf dem disabled-Gate-Pfad
|
|
454
455
|
// konsultiert. inserted_at ist immutable → pro Tenant einmal lesen.
|
|
455
|
-
|
|
456
|
+
// Promise-memo (not a plain value cache): N concurrent requests for the
|
|
457
|
+
// same fresh tenant must await the SAME in-flight fetchOne, not each
|
|
458
|
+
// fire their own — a plain `Map<TenantId, number>` set AFTER the await
|
|
459
|
+
// lets every concurrent caller miss the cache and issue a redundant
|
|
460
|
+
// read (benign but wasteful on a signup burst).
|
|
461
|
+
const startedMemo = new Map<TenantId, Promise<number | undefined>>();
|
|
456
462
|
const trialGate: TrialGate = async (tenantId, featureName) => {
|
|
457
463
|
if (!trialFeatures.has(featureName)) return false;
|
|
458
|
-
let
|
|
459
|
-
if (
|
|
460
|
-
|
|
464
|
+
let startedMsPromise = startedMemo.get(tenantId);
|
|
465
|
+
if (startedMsPromise === undefined) {
|
|
466
|
+
startedMsPromise = fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
|
|
461
467
|
id: tenantId,
|
|
462
|
-
});
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
+
}).then((row) => row?.insertedAt?.epochMilliseconds);
|
|
469
|
+
// Evict on rejection too — a transient DB error must not poison
|
|
470
|
+
// every future call for this tenant with the same cached rejection.
|
|
471
|
+
startedMsPromise.catch(() => startedMemo.delete(tenantId));
|
|
472
|
+
startedMemo.set(tenantId, startedMsPromise);
|
|
473
|
+
}
|
|
474
|
+
const startedMs = await startedMsPromise;
|
|
475
|
+
// Tenant-Row noch nicht projiziert (Replay-Race) → keinen Miss
|
|
476
|
+
// memoizen, beim nächsten Request neu lesen.
|
|
477
|
+
if (startedMs === undefined) {
|
|
478
|
+
startedMemo.delete(tenantId);
|
|
479
|
+
return false;
|
|
468
480
|
}
|
|
469
481
|
return isTrialActive(startedMs, nowMs(), trial.durationHours);
|
|
470
482
|
};
|
|
@@ -44,6 +44,12 @@ export type SetTenantTierOptions = {
|
|
|
44
44
|
* Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
|
|
45
45
|
* nicht). Ohne tierMap kein Resolver → no-op. */
|
|
46
46
|
readonly onAssigned?: (tenantId: TenantId, tier: string) => void;
|
|
47
|
+
/** Tier-Namen aus der tierMap-Closure — ohne sie (storage-only-Mode) bleibt
|
|
48
|
+
* `tier` unvalidiert außer Length/Non-Empty. Mit ihr rejected der Handler
|
|
49
|
+
* einen unbekannten Tier-Namen statt ihn stillschweigend durchzuschreiben
|
|
50
|
+
* (featuresForTier gibt für unbekannte Namen defensiv ein leeres Set
|
|
51
|
+
* zurück — der Tenant verliert sonst lautlos alle Tier-Gates). */
|
|
52
|
+
readonly validTiers?: ReadonlySet<string>;
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
@@ -51,7 +57,11 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
51
57
|
name: "set-tenant-tier",
|
|
52
58
|
schema: z.object({
|
|
53
59
|
tenantId: z.string().min(1),
|
|
54
|
-
tier: z
|
|
60
|
+
tier: z
|
|
61
|
+
.string()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(50)
|
|
64
|
+
.refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
|
|
55
65
|
}),
|
|
56
66
|
access: { roles: ["SystemAdmin"] },
|
|
57
67
|
handler: async (event, ctx) => {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// #762 — backfillUserStreamTenants: pre-#497 user streams live on the
|
|
2
|
+
// creating tenant; post-#497 the executor addresses SYSTEM_TENANT_ID, so
|
|
3
|
+
// legacy users version-conflict on every write (password-reset collapses to
|
|
4
|
+
// invalid_token). The backfill retenants + renumbers per aggregate in global
|
|
5
|
+
// id order — including the split-stream case the raw #497-changeset SQL
|
|
6
|
+
// crashes on (legacy tenant v1..n AND post-#497 SYSTEM events for the same
|
|
7
|
+
// aggregate → events_aggregate_version_uq violation).
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import {
|
|
13
|
+
createSystemUser,
|
|
14
|
+
SYSTEM_TENANT_ID,
|
|
15
|
+
type TenantId,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { append, createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
|
+
import {
|
|
19
|
+
createTestDb,
|
|
20
|
+
type TestDb,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { backfillUserStreamTenants } from "../db/queries/stream-tenant-backfill";
|
|
24
|
+
import { userEntity, userTable } from "../schema/user";
|
|
25
|
+
|
|
26
|
+
const T1 = "00000000-0000-4000-8000-000000000011" as TenantId;
|
|
27
|
+
const LEGACY_USER = "aaaaaaaa-0000-4000-8000-000000000001";
|
|
28
|
+
const SPLIT_USER = "aaaaaaaa-0000-4000-8000-000000000002";
|
|
29
|
+
const MODERN_USER = "aaaaaaaa-0000-4000-8000-000000000003";
|
|
30
|
+
|
|
31
|
+
let testDb: TestDb;
|
|
32
|
+
|
|
33
|
+
const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
testDb = await createTestDb();
|
|
37
|
+
await unsafeCreateEntityTable(testDb.db, userEntity, "user");
|
|
38
|
+
await createEventsTable(testDb.db);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await testDb.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
await asRawClient(testDb.db).unsafe(
|
|
47
|
+
`TRUNCATE kumiko_events, kumiko_snapshots, kumiko_archived_streams, read_users RESTART IDENTITY CASCADE`,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function appendUserEvent(
|
|
52
|
+
aggregateId: string,
|
|
53
|
+
tenantId: TenantId,
|
|
54
|
+
expectedVersion: number,
|
|
55
|
+
type: string,
|
|
56
|
+
payload: Record<string, unknown>,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
await append(testDb.db, {
|
|
59
|
+
aggregateId,
|
|
60
|
+
aggregateType: "user",
|
|
61
|
+
tenantId,
|
|
62
|
+
expectedVersion,
|
|
63
|
+
type,
|
|
64
|
+
payload,
|
|
65
|
+
metadata: { userId: "test-migrator" },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function streamRows(
|
|
70
|
+
aggregateId: string,
|
|
71
|
+
): Promise<ReadonlyArray<{ tenant_id: string; version: number; type: string }>> {
|
|
72
|
+
return (await asRawClient(testDb.db).unsafe(
|
|
73
|
+
`SELECT "tenant_id", "version", "type" FROM "kumiko_events"
|
|
74
|
+
WHERE "aggregate_id" = $1::uuid ORDER BY "id" ASC`,
|
|
75
|
+
[aggregateId],
|
|
76
|
+
)) as ReadonlyArray<{ tenant_id: string; version: number; type: string }>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function seedUserRow(id: string, tenantId: TenantId, version: number) {
|
|
80
|
+
return asRawClient(testDb.db).unsafe(
|
|
81
|
+
`INSERT INTO "read_users" ("id", "tenant_id", "email", "display_name", "locale", "password_hash", "status", "version")
|
|
82
|
+
VALUES ($1::uuid, $2::uuid, $3, 'Legacy', 'de', 'x', 'active', $4)`,
|
|
83
|
+
[id, tenantId, `${id.slice(0, 8)}@example.com`, version],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("backfillUserStreamTenants (#762)", () => {
|
|
88
|
+
test("legacy stream: retenanted to SYSTEM, versions contiguous, executor write works again", async () => {
|
|
89
|
+
await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
|
|
90
|
+
await appendUserEvent(LEGACY_USER, T1, 1, "user.updated", { changes: { displayName: "L" } });
|
|
91
|
+
await seedUserRow(LEGACY_USER, T1, 2);
|
|
92
|
+
|
|
93
|
+
const result = await backfillUserStreamTenants(testDb.db);
|
|
94
|
+
expect(result.aggregatesMigrated).toBe(1);
|
|
95
|
+
expect(result.eventsMigrated).toBe(2);
|
|
96
|
+
expect(result.failed).toHaveLength(0);
|
|
97
|
+
|
|
98
|
+
const rows = await streamRows(LEGACY_USER);
|
|
99
|
+
expect(rows.map((r) => r.tenant_id)).toEqual([SYSTEM_TENANT_ID, SYSTEM_TENANT_ID]);
|
|
100
|
+
expect(rows.map((r) => r.version)).toEqual([1, 2]);
|
|
101
|
+
|
|
102
|
+
// The actual #762 symptom: an optimistic-locked executor update with the
|
|
103
|
+
// row version now targets a stream that HAS that version — no conflict.
|
|
104
|
+
const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
|
|
105
|
+
const writeRes = await executor.update(
|
|
106
|
+
{ id: LEGACY_USER, version: 2, changes: { displayName: "After" } },
|
|
107
|
+
createSystemUser(SYSTEM_TENANT_ID),
|
|
108
|
+
tdb,
|
|
109
|
+
);
|
|
110
|
+
expect(writeRes.isSuccess).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("split stream (legacy + post-#497 SYSTEM events) merges in global id order", async () => {
|
|
114
|
+
// Legacy: created + updated on T1 (v1, v2). Then a post-#497 lifecycle
|
|
115
|
+
// write appended v1 on SYSTEM — the split the raw changeset-SQL trips on.
|
|
116
|
+
await appendUserEvent(SPLIT_USER, T1, 0, "user.created", { email: "s@example.com" });
|
|
117
|
+
await appendUserEvent(SPLIT_USER, T1, 1, "user.updated", { changes: { a: 1 } });
|
|
118
|
+
await appendUserEvent(SPLIT_USER, SYSTEM_TENANT_ID, 0, "user.updated", {
|
|
119
|
+
changes: { status: "restricted" },
|
|
120
|
+
});
|
|
121
|
+
await seedUserRow(SPLIT_USER, T1, 2);
|
|
122
|
+
|
|
123
|
+
const result = await backfillUserStreamTenants(testDb.db);
|
|
124
|
+
expect(result.aggregatesMigrated).toBe(1);
|
|
125
|
+
expect(result.eventsMigrated).toBe(3);
|
|
126
|
+
expect(result.failed).toHaveLength(0);
|
|
127
|
+
|
|
128
|
+
const rows = await streamRows(SPLIT_USER);
|
|
129
|
+
expect(rows.map((r) => r.tenant_id)).toEqual([
|
|
130
|
+
SYSTEM_TENANT_ID,
|
|
131
|
+
SYSTEM_TENANT_ID,
|
|
132
|
+
SYSTEM_TENANT_ID,
|
|
133
|
+
]);
|
|
134
|
+
// Renumbered by global event id: created, updated(T1), updated(SYSTEM).
|
|
135
|
+
expect(rows.map((r) => r.version)).toEqual([1, 2, 3]);
|
|
136
|
+
expect(rows[0]?.type).toBe("user.created");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("idempotent: second run finds nothing; modern SYSTEM streams untouched", async () => {
|
|
140
|
+
await appendUserEvent(MODERN_USER, SYSTEM_TENANT_ID, 0, "user.created", {
|
|
141
|
+
email: "m@example.com",
|
|
142
|
+
});
|
|
143
|
+
await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
|
|
144
|
+
|
|
145
|
+
const first = await backfillUserStreamTenants(testDb.db);
|
|
146
|
+
expect(first.aggregatesMigrated).toBe(1);
|
|
147
|
+
|
|
148
|
+
const second = await backfillUserStreamTenants(testDb.db);
|
|
149
|
+
expect(second.aggregatesMigrated).toBe(0);
|
|
150
|
+
expect(second.eventsMigrated).toBe(0);
|
|
151
|
+
|
|
152
|
+
const modern = await streamRows(MODERN_USER);
|
|
153
|
+
expect(modern).toHaveLength(1);
|
|
154
|
+
expect(modern[0]?.version).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("stale snapshots dropped, archived-stream marker moves to SYSTEM", async () => {
|
|
158
|
+
await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
|
|
159
|
+
await asRawClient(testDb.db).unsafe(
|
|
160
|
+
`INSERT INTO "kumiko_snapshots" ("aggregate_id", "aggregate_type", "tenant_id", "version", "state")
|
|
161
|
+
VALUES ($1::uuid, 'user', $2::uuid, 1, '{}'::jsonb)`,
|
|
162
|
+
[LEGACY_USER, T1],
|
|
163
|
+
);
|
|
164
|
+
await asRawClient(testDb.db).unsafe(
|
|
165
|
+
`INSERT INTO "kumiko_archived_streams" ("tenant_id", "aggregate_id", "aggregate_type", "archived_by")
|
|
166
|
+
VALUES ($1::uuid, $2::uuid, 'user', 'test')`,
|
|
167
|
+
[T1, LEGACY_USER],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const result = await backfillUserStreamTenants(testDb.db);
|
|
171
|
+
expect(result.failed).toHaveLength(0);
|
|
172
|
+
|
|
173
|
+
const snapshots = (await asRawClient(testDb.db).unsafe(
|
|
174
|
+
`SELECT count(*)::int AS n FROM "kumiko_snapshots" WHERE "aggregate_id" = $1::uuid`,
|
|
175
|
+
[LEGACY_USER],
|
|
176
|
+
)) as ReadonlyArray<{ n: number }>;
|
|
177
|
+
expect(snapshots[0]?.n).toBe(0);
|
|
178
|
+
|
|
179
|
+
const archived = (await asRawClient(testDb.db).unsafe(
|
|
180
|
+
`SELECT "tenant_id" FROM "kumiko_archived_streams" WHERE "aggregate_id" = $1::uuid`,
|
|
181
|
+
[LEGACY_USER],
|
|
182
|
+
)) as ReadonlyArray<{ tenant_id: string }>;
|
|
183
|
+
expect(archived).toHaveLength(1);
|
|
184
|
+
expect(archived[0]?.tenant_id).toBe(SYSTEM_TENANT_ID);
|
|
185
|
+
});
|
|
186
|
+
});
|