@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
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
|
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,
|