@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.
Files changed (116) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/wire-for-entity.ts +104 -77
  22. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  23. package/src/data-retention/resolve-for-tenant.ts +16 -4
  24. package/src/data-retention/run-retention-cleanup.ts +72 -10
  25. package/src/delivery/attempt-log.ts +22 -10
  26. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  27. package/src/folders/__tests__/folders.integration.test.ts +26 -0
  28. package/src/folders/feature.ts +4 -2
  29. package/src/folders/handlers/delete-folder.write.ts +40 -0
  30. package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
  31. package/src/folders/web/folder-manager.tsx +18 -13
  32. package/src/folders/web/i18n.ts +0 -2
  33. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  34. package/src/folders-user-data/hooks.ts +31 -8
  35. package/src/folders-user-data/index.ts +4 -4
  36. package/src/jobs/feature.ts +23 -21
  37. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  38. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  39. package/src/ledger/__tests__/reports.test.ts +20 -0
  40. package/src/ledger/entity.ts +1 -1
  41. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  42. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  43. package/src/ledger/recurring.ts +18 -9
  44. package/src/ledger/schemas.ts +4 -1
  45. package/src/ledger/web/index.ts +3 -2
  46. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  47. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
  48. package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
  49. package/src/personal-access-tokens/constants.ts +33 -0
  50. package/src/personal-access-tokens/feature.ts +78 -0
  51. package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
  52. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  53. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  54. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  55. package/src/personal-access-tokens/hash.ts +17 -0
  56. package/src/personal-access-tokens/index.ts +45 -0
  57. package/src/personal-access-tokens/resolver.ts +58 -0
  58. package/src/personal-access-tokens/roles.ts +29 -0
  59. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  60. package/src/personal-access-tokens/scopes.ts +43 -0
  61. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  62. package/src/personal-access-tokens/web/i18n.ts +69 -0
  63. package/src/personal-access-tokens/web/index.ts +7 -0
  64. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
  65. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  66. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  67. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  68. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  69. package/src/sessions/schema/user-session.ts +8 -2
  70. package/src/sessions/session-callbacks.ts +9 -3
  71. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  72. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  73. package/src/tags/handlers/assign-tag.write.ts +13 -3
  74. package/src/tags/schemas.ts +10 -4
  75. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  76. package/src/template-resolver/testing.ts +4 -3
  77. package/src/tier-engine/feature.ts +22 -10
  78. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  79. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  80. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  81. package/src/user/index.ts +4 -0
  82. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  85. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  86. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  87. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  88. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  89. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  90. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  91. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  92. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  93. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  95. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  96. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  97. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  98. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  99. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  100. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  101. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  102. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  103. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  104. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  105. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  106. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  107. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  108. package/src/user-data-rights/web/i18n.ts +0 -10
  109. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  110. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  111. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  112. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  113. package/src/user-profile/handlers/change-email.write.ts +11 -8
  114. package/src/user-profile/web/profile-screen.tsx +10 -8
  115. package/src/auth-email-password/stream-tenant.ts +0 -31
  116. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -0,0 +1,251 @@
1
+ // @runtime client
2
+ // PatTokensScreen — logged-in self-service for Personal Access Tokens. Two-axis
3
+ // scopes (like GitHub fine-grained PATs): per API domain pick a permission LEVEL
4
+ // (no access / read / read & write). Mint → copy the plaintext ONCE → it's never
5
+ // re-displayed. Layout follows the framework's polished-screen convention (Form
6
+ // primitive + Tailwind), cards match the mh style. The feature registers this
7
+ // dormant (r.screen); the app places it via r.nav.
8
+
9
+ import {
10
+ useDispatcher,
11
+ usePrimitives,
12
+ useQuery,
13
+ useTranslation,
14
+ } from "@cosmicdrift/kumiko-renderer";
15
+ import { type ReactNode, useState } from "react";
16
+ import { PatHandlers, PatQueries } from "../constants";
17
+ import { parseGrant } from "../scopes";
18
+
19
+ type ScopeDomain = { readonly name: string; readonly label: string; readonly canWrite: boolean };
20
+ type TokenRow = {
21
+ readonly id: string;
22
+ readonly name: string;
23
+ readonly scopes: readonly string[];
24
+ readonly createdAt: string | null;
25
+ readonly expiresAt: string | null;
26
+ readonly revokedAt: string | null;
27
+ };
28
+
29
+ type Level = "none" | "read" | "write";
30
+ type ExpiryKey = "30d" | "90d" | "1y" | "never";
31
+ const EXPIRY_DAYS: Record<ExpiryKey, number | undefined> = {
32
+ "30d": 30,
33
+ "90d": 90,
34
+ "1y": 365,
35
+ never: undefined,
36
+ };
37
+
38
+ // Timestamps arrive as ISO strings over the JSON API — slice the date part
39
+ // without touching the banned Date API.
40
+ function isoDate(value: string | null): string | null {
41
+ return typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : null;
42
+ }
43
+
44
+ export function PatTokensScreen(): ReactNode {
45
+ const t = useTranslation();
46
+ const { Form, Field, Input, Button, Banner, Card, Heading } = usePrimitives();
47
+ const dispatcher = useDispatcher();
48
+
49
+ const scopesQuery = useQuery<readonly ScopeDomain[]>(PatQueries.availableScopes, {});
50
+ const listQuery = useQuery<readonly TokenRow[]>(PatQueries.mine, {});
51
+
52
+ const [name, setName] = useState("");
53
+ const [levels, setLevels] = useState<Readonly<Record<string, Level>>>({});
54
+ const [expiry, setExpiry] = useState<ExpiryKey>("90d");
55
+ const [minted, setMinted] = useState<{ token: string } | null>(null);
56
+ const [copied, setCopied] = useState(false);
57
+ const [error, setError] = useState<string | null>(null);
58
+ const [busy, setBusy] = useState(false);
59
+
60
+ const domains = scopesQuery.data ?? [];
61
+ const tokens = listQuery.data ?? [];
62
+ const labelOf = (domain: string): string =>
63
+ domains.find((d) => d.name === domain)?.label ?? domain;
64
+
65
+ // Granted scopes = "<domain>:<level>" for every domain not set to "none".
66
+ const grants = (): string[] =>
67
+ Object.entries(levels)
68
+ .filter(([, l]) => l !== "none")
69
+ .map(([domain, l]) => `${domain}:${l}`);
70
+
71
+ const setLevel = (domain: string, level: Level): void =>
72
+ setLevels((cur) => ({ ...cur, [domain]: level }));
73
+
74
+ const create = async (): Promise<void> => {
75
+ if (name.trim() === "") return setError(t("pat.create.needName"));
76
+ const scopes = grants();
77
+ if (scopes.length === 0) return setError(t("pat.create.needScope"));
78
+ setBusy(true);
79
+ setError(null);
80
+ const days = EXPIRY_DAYS[expiry];
81
+ const res = await dispatcher.write(PatHandlers.create, {
82
+ name: name.trim(),
83
+ scopes,
84
+ ...(days !== undefined ? { expiresInDays: days } : {}),
85
+ });
86
+ setBusy(false);
87
+ if (!res.isSuccess) return setError(t("pat.error.generic"));
88
+ setMinted({ token: (res.data as { token: string }).token });
89
+ setCopied(false);
90
+ setName("");
91
+ setLevels({});
92
+ setExpiry("90d");
93
+ void listQuery.refetch?.();
94
+ };
95
+
96
+ const copy = async (): Promise<void> => {
97
+ if (!minted) return;
98
+ try {
99
+ await navigator.clipboard.writeText(minted.token);
100
+ setCopied(true);
101
+ } catch {
102
+ // clipboard blocked (non-secure context) — the token stays selectable in place
103
+ }
104
+ };
105
+
106
+ const revoke = async (id: string): Promise<void> => {
107
+ const res = await dispatcher.write(PatHandlers.revoke, { id });
108
+ if (!res.isSuccess) return setError(t("pat.error.generic"));
109
+ void listQuery.refetch?.();
110
+ };
111
+
112
+ // "Pages (read & write) · Tags (read) · Valid until … · Created …"
113
+ const meta = (row: TokenRow): string => {
114
+ const scopeText = row.scopes
115
+ .map((g) => {
116
+ const p = parseGrant(g);
117
+ if (!p) return g;
118
+ const lvl = p.level === "write" ? t("pat.level.write") : t("pat.level.read");
119
+ return `${labelOf(p.domain)} (${lvl})`;
120
+ })
121
+ .join(" · ");
122
+ const d = isoDate(row.expiresAt);
123
+ const parts = [
124
+ scopeText,
125
+ d ? t("pat.list.validUntil", { date: d }) : t("pat.list.neverExpires"),
126
+ ];
127
+ const created = isoDate(row.createdAt);
128
+ if (created) parts.push(t("pat.list.created", { date: created }));
129
+ return parts.join(" · ");
130
+ };
131
+
132
+ return (
133
+ <div className="flex max-w-3xl flex-col gap-6 p-6">
134
+ <Heading>{t("pat.title")}</Heading>
135
+
136
+ {minted && (
137
+ <Banner
138
+ variant="info"
139
+ actions={
140
+ <>
141
+ <Button variant="primary" onClick={copy}>
142
+ {copied ? t("pat.created.copied") : t("pat.created.copy")}
143
+ </Button>
144
+ <Button variant="secondary" onClick={() => setMinted(null)}>
145
+ {t("pat.created.dismiss")}
146
+ </Button>
147
+ </>
148
+ }
149
+ >
150
+ <div className="flex flex-col gap-2">
151
+ <span className="text-sm">{t("pat.created.hint")}</span>
152
+ <code className="block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
153
+ {minted.token}
154
+ </code>
155
+ </div>
156
+ </Banner>
157
+ )}
158
+
159
+ {error && <Banner variant="error">{error}</Banner>}
160
+
161
+ <Form
162
+ title={t("pat.create.title")}
163
+ subtitle={t("pat.create.subtitle")}
164
+ onSubmit={create}
165
+ actions={
166
+ <Button type="submit" variant="primary" onClick={create} loading={busy} disabled={busy}>
167
+ {t("pat.create.submit")}
168
+ </Button>
169
+ }
170
+ >
171
+ <Field id="pat-name" label={t("pat.create.name")} required>
172
+ <Input
173
+ kind="text"
174
+ id="pat-name"
175
+ name="name"
176
+ value={name}
177
+ onChange={setName}
178
+ placeholder={t("pat.create.namePlaceholder")}
179
+ disabled={busy}
180
+ />
181
+ </Field>
182
+
183
+ {domains.map((d) => (
184
+ <Field key={d.name} id={`pat-scope-${d.name}`} label={d.label}>
185
+ <Input
186
+ kind="select"
187
+ id={`pat-scope-${d.name}`}
188
+ name={d.name}
189
+ value={levels[d.name] ?? "none"}
190
+ onChange={(v) => setLevel(d.name, v as Level)}
191
+ options={[
192
+ { value: "none", label: t("pat.level.none") },
193
+ { value: "read", label: t("pat.level.read") },
194
+ ...(d.canWrite ? [{ value: "write", label: t("pat.level.write") }] : []),
195
+ ]}
196
+ disabled={busy}
197
+ />
198
+ </Field>
199
+ ))}
200
+
201
+ <Field id="pat-expiry" label={t("pat.create.expiry")}>
202
+ <Input
203
+ kind="select"
204
+ id="pat-expiry"
205
+ name="expiry"
206
+ value={expiry}
207
+ onChange={(v) => setExpiry(v as ExpiryKey)}
208
+ options={[
209
+ { value: "30d", label: t("pat.expiry.30d") },
210
+ { value: "90d", label: t("pat.expiry.90d") },
211
+ { value: "1y", label: t("pat.expiry.1y") },
212
+ { value: "never", label: t("pat.expiry.never") },
213
+ ]}
214
+ disabled={busy}
215
+ />
216
+ </Field>
217
+ </Form>
218
+
219
+ <div className="flex flex-col gap-3">
220
+ <Heading>{t("pat.list.title")}</Heading>
221
+ {tokens.length === 0 && (
222
+ <span className="text-sm text-muted-foreground">{t("pat.list.empty")}</span>
223
+ )}
224
+ {tokens.map((row) => {
225
+ const revoked = row.revokedAt !== null;
226
+ return (
227
+ <Card
228
+ key={row.id}
229
+ options={{ padded: false }}
230
+ className={`p-4 ${revoked ? "opacity-60" : ""}`}
231
+ >
232
+ <div className="flex items-center justify-between gap-3">
233
+ <div className="flex min-w-0 flex-col gap-0.5">
234
+ <span className="truncate text-sm font-semibold">{row.name}</span>
235
+ <span className="truncate text-xs text-muted-foreground">
236
+ {revoked ? t("pat.list.revoked") : meta(row)}
237
+ </span>
238
+ </div>
239
+ {!revoked && (
240
+ <Button variant="danger" onClick={() => revoke(row.id)}>
241
+ {t("pat.list.revoke")}
242
+ </Button>
243
+ )}
244
+ </div>
245
+ </Card>
246
+ );
247
+ })}
248
+ </div>
249
+ </div>
250
+ );
251
+ }
@@ -1,5 +1,4 @@
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 type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
4
  import {
@@ -7,6 +6,7 @@ import {
7
6
  type TestStack,
8
7
  unsafeCreateEntityTable,
9
8
  } from "@cosmicdrift/kumiko-framework/stack";
9
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
10
10
  import type { RendererContext } from "../../renderer-foundation";
11
11
  import { SYSTEM_TENANT_ID } from "../../template-resolver/constants";
12
12
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
@@ -35,7 +35,7 @@ function rendererCtx(): RendererContext {
35
35
  }
36
36
 
37
37
  async function seedPlainNotificationTemplate(content: string): Promise<void> {
38
- await insertOne(db, templateResourcesTable, {
38
+ await seedRow(db, templateResourcesTable, {
39
39
  tenantId: SYSTEM_TENANT_ID,
40
40
  slug: "welcome-mail",
41
41
  kind: "notification",
@@ -5,7 +5,7 @@
5
5
  // expired/revoked rows go, live rows stay, batching + signal work.
6
6
 
7
7
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
- import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
9
  import { sql } from "@cosmicdrift/kumiko-framework/db";
10
10
  import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import {
@@ -14,7 +14,7 @@ import {
14
14
  testTenantId,
15
15
  unsafeCreateEntityTable,
16
16
  } from "@cosmicdrift/kumiko-framework/stack";
17
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
17
+ import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
18
18
  import { createUserFeature } from "../../user/feature";
19
19
  import { userEntity } from "../../user/schema/user";
20
20
  import { createSessionsFeature } from "../feature";
@@ -79,7 +79,7 @@ async function seedSession(opts: {
79
79
  const past = sql`now() - ${sql.raw(`interval '${opts.ageDays} days'`)}`;
80
80
  const future = sql`now() + ${sql.raw(`interval '30 days'`)}`;
81
81
 
82
- await insertOne(stack.db, userSessionTable, {
82
+ await seedRow(stack.db, userSessionTable, {
83
83
  id: opts.id,
84
84
  tenantId: TENANT,
85
85
  userId: opts.userId,
@@ -1,10 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import {
3
- asRawClient,
4
- insertOne,
5
- selectMany,
6
- updateMany,
7
- } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
3
  import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
4
  import { createRegistry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
10
5
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -18,6 +13,7 @@ import {
18
13
  testTenantId,
19
14
  unsafeCreateEntityTable,
20
15
  } from "@cosmicdrift/kumiko-framework/stack";
16
+ import { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
21
17
  import { Temporal } from "temporal-polyfill";
22
18
  import { createUserFeature } from "../../user/feature";
23
19
  import { createSessionsFeature } from "../feature";
@@ -62,7 +58,7 @@ const SID = "00000000-0000-0000-0000-000000000001";
62
58
 
63
59
  async function insertRevokedSession(db: DbConnection): Promise<void> {
64
60
  const now = Temporal.Now.instant();
65
- await insertOne(db, userSessionTable, {
61
+ await seedRow(db, userSessionTable, {
66
62
  id: SID,
67
63
  tenantId: TENANT,
68
64
  userId: "user-1",
@@ -71,7 +67,7 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
71
67
  ip: "1.2.3.4",
72
68
  userAgent: "test-agent",
73
69
  });
74
- await updateMany(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
70
+ await updateRows(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
75
71
  }
76
72
 
77
73
  describe("sessions / read_user_sessions survives projection rebuild", () => {
@@ -93,6 +89,6 @@ describe("sessions / read_user_sessions survives projection rebuild", () => {
93
89
 
94
90
  const rows = await selectMany(testDb.db, userSessionTable, {});
95
91
  expect(rows.length).toBe(1);
96
- expect((rows[0] as { revokedAt: unknown }).revokedAt).not.toBeNull();
92
+ expect(rows[0]?.revokedAt).not.toBeNull();
97
93
  });
98
94
  });
@@ -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 { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
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 { createLateBoundHolder, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
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 deleteMany(stack.db, userSessionTable, { id: sid });
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 updateMany(
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 updateMany(
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 updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
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 updateMany(stack.db, userTable, { status: USER_STATUS.Deleted }, { id: userId });
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 deleteMany(stack.db, userTable, { id: userId });
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 updateMany(
527
+ await updateRows(
523
528
  stack.db,
524
529
  userTable,
525
530
  { status: USER_STATUS.DeletionRequested },
@@ -1,4 +1,4 @@
1
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
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
- export const userSessionTable = buildEntityTable("user_session", userSessionEntity);
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<string> = new Set([
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
- const user = await fetchOne<{ status: string }>(db, userTable, { id: expectedUserId });
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-parseStoredSecret
48
- * denselben Pfad nimmt wie gegen den echten Store. Ignoriert auditCtx. */
49
- function stubSecrets(values: Record<string, string>): SecretsContext {
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
- return value === undefined ? undefined : createSecret(JSON.stringify(value));
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: rawSecretsStub({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }),
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 still version_conflicts (409); acceptable, since
18
- // assigning is a low-frequency UI action.
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
- return tagAssignmentExecutor.create(
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
  }
@@ -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.string().max(32).optional(),
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.string().max(32).optional(),
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 insertOne(db, templateResourcesTable, {
59
+ await seedRow(db, templateResourcesTable, {
60
60
  tenantId: args.tenantId,
61
61
  slug: args.slug,
62
62
  kind: args.kind,