@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -7
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +7 -6
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
|
@@ -2,15 +2,24 @@ import { fetchOne, insertMany, insertOne, updateMany } from "@cosmicdrift/kumiko
|
|
|
2
2
|
import {
|
|
3
3
|
configuredPiiSubjectKms,
|
|
4
4
|
encryptPiiValueForSubject,
|
|
5
|
+
KeyErasedError,
|
|
6
|
+
type LocalKeyKmsAdapter,
|
|
7
|
+
PII_ERASED_SENTINEL,
|
|
5
8
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
6
9
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
10
|
import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
11
|
import type { JobLogEntry, JobMeta, JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
9
12
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
13
|
+
import { mapWithConcurrency } from "../shared";
|
|
10
14
|
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events";
|
|
11
15
|
import { parseJobInstant } from "./job-instant";
|
|
12
16
|
import { jobRunLogsTable, jobRunsTable } from "./job-run-table";
|
|
13
17
|
|
|
18
|
+
// Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
|
|
19
|
+
// bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
|
|
20
|
+
// every connection in the pool.
|
|
21
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
22
|
+
|
|
14
23
|
// Direct-write job-run log (#2243): onJobStart/-Complete/-Failed write
|
|
15
24
|
// straight into jobRunsTable / jobRunLogsTable instead of appending to the
|
|
16
25
|
// event store and replaying through inline projections. Pre-#2243 every run
|
|
@@ -44,27 +53,32 @@ const DEFAULT_CACHE_MAX_ENTRIES = 10_000;
|
|
|
44
53
|
// to DB-lookup if actually needed.
|
|
45
54
|
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
46
55
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
56
|
+
// Same per-subject encryption as encryptFailureError/encryptStartedPayload,
|
|
57
|
+
// applied to a single value — shared so payload/message/error all go
|
|
58
|
+
// through the identical erased-key fallback. A key erased right before (or
|
|
59
|
+
// between) any of these callbacks must not blow up the write (status is
|
|
60
|
+
// already committed by the time logs/error land): fall back to the
|
|
61
|
+
// sentinel like decryptPiiValueForSubject does on the read side, instead of
|
|
62
|
+
// throwing and dropping the rest of the batch — or, for onJobStart, the
|
|
63
|
+
// whole run row.
|
|
64
|
+
async function encryptOrSentinel(
|
|
65
|
+
kms: LocalKeyKmsAdapter,
|
|
66
|
+
subjectUserId: string,
|
|
67
|
+
value: string,
|
|
68
|
+
field: string,
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
try {
|
|
71
|
+
return await encryptPiiValueForSubject(
|
|
72
|
+
kms,
|
|
73
|
+
{ kind: "user", userId: subjectUserId },
|
|
74
|
+
value,
|
|
75
|
+
{ requestId: "jobs:job-run-logger" },
|
|
76
|
+
field,
|
|
77
|
+
);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (!(e instanceof KeyErasedError)) throw e;
|
|
80
|
+
return PII_ERASED_SENTINEL;
|
|
81
|
+
}
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
// Same per-subject encryption as encryptStartedPayload, applied to each
|
|
@@ -74,7 +88,8 @@ async function encryptStartedPayload(
|
|
|
74
88
|
// field name and must match the field passed to decryptStoredPii on read
|
|
75
89
|
// (detail.query.ts) or decrypt fails loud. Same skip rules as the payload:
|
|
76
90
|
// null subject (system/cron runs) and absent KMS (rollout mode) both stay
|
|
77
|
-
// plaintext.
|
|
91
|
+
// plaintext. Bounded concurrency (#2307) — unbounded Promise.all would fire
|
|
92
|
+
// one getOrCreateDek per log line at once against the KMS adapter's pool.
|
|
78
93
|
async function encryptLogMessages<T extends { readonly message: string }>(
|
|
79
94
|
logs: readonly T[],
|
|
80
95
|
triggeredById: string | null,
|
|
@@ -82,18 +97,40 @@ async function encryptLogMessages<T extends { readonly message: string }>(
|
|
|
82
97
|
if (triggeredById === null) return [...logs];
|
|
83
98
|
const kms = configuredPiiSubjectKms();
|
|
84
99
|
if (!kms) return [...logs];
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
);
|
|
100
|
+
return mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => ({
|
|
101
|
+
...log,
|
|
102
|
+
message: await encryptOrSentinel(kms, triggeredById, log.message, "message"),
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Mirrors encryptFailureError for the failure-path `error` column (#2307):
|
|
107
|
+
// stored alongside logs[].message under the same triggering user's DEK, so
|
|
108
|
+
// a log/error pair from the same failed run decrypt together or erase
|
|
109
|
+
// together.
|
|
110
|
+
async function encryptFailureError(error: string, triggeredById: string | null): Promise<string> {
|
|
111
|
+
if (triggeredById === null) return error;
|
|
112
|
+
const kms = configuredPiiSubjectKms();
|
|
113
|
+
if (!kms) return error;
|
|
114
|
+
return encryptOrSentinel(kms, triggeredById, error, "error");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The run-started payload can carry arbitrary user data; triggeredById
|
|
118
|
+
// names its owning user. No event-PII catalog involved (#2243 removed the
|
|
119
|
+
// jobRun r.defineEvent registrations, so there is nothing to catalog) —
|
|
120
|
+
// the subject is known statically, so we encrypt directly. A null subject
|
|
121
|
+
// (system cron runs, recipient-less triggers) stays plaintext: there is no
|
|
122
|
+
// user key to shred, mirroring the previous event-pii catalog's own skip
|
|
123
|
+
// rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
|
|
124
|
+
// A key already erased by the time the trigger lands (#2307) must not fail
|
|
125
|
+
// the whole run-start write — sentinel like the log/error paths.
|
|
126
|
+
async function encryptStartedPayload(
|
|
127
|
+
payload: string | null,
|
|
128
|
+
triggeredById: string | null,
|
|
129
|
+
): Promise<string | null> {
|
|
130
|
+
if (payload === null || triggeredById === null) return payload;
|
|
131
|
+
const kms = configuredPiiSubjectKms();
|
|
132
|
+
if (!kms) return payload;
|
|
133
|
+
return encryptOrSentinel(kms, triggeredById, payload, "payload");
|
|
97
134
|
}
|
|
98
135
|
|
|
99
136
|
export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks {
|
|
@@ -225,7 +262,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
225
262
|
jobRunsTable,
|
|
226
263
|
{
|
|
227
264
|
status: "completed",
|
|
228
|
-
|
|
265
|
+
// Clear stale-sweep error if the run finished after being marked failed.
|
|
266
|
+
error: null,
|
|
267
|
+
// integer column — match onJobFailed's Math.round so fractional
|
|
268
|
+
// BullMQ timings don't trip Postgres integer syntax.
|
|
269
|
+
duration: Math.round(payload.duration),
|
|
229
270
|
finishedAt: parseJobInstant(payload.finishedAt),
|
|
230
271
|
modifiedAt: Temporal.Now.instant(),
|
|
231
272
|
modifiedById: "system",
|
|
@@ -279,12 +320,13 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
279
320
|
timestamp: l.timestamp.toString(),
|
|
280
321
|
})),
|
|
281
322
|
});
|
|
323
|
+
const encryptedError = await encryptFailureError(payload.error, triggeredById);
|
|
282
324
|
await updateMany(
|
|
283
325
|
db,
|
|
284
326
|
jobRunsTable,
|
|
285
327
|
{
|
|
286
328
|
status: "failed",
|
|
287
|
-
error:
|
|
329
|
+
error: encryptedError,
|
|
288
330
|
duration: payload.duration,
|
|
289
331
|
finishedAt: parseJobInstant(payload.finishedAt),
|
|
290
332
|
modifiedAt: now,
|
|
@@ -83,7 +83,7 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
83
83
|
|
|
84
84
|
if (state.kind === "loading") {
|
|
85
85
|
return (
|
|
86
|
-
<FormScreenShell testId="job-run-detail-screen">
|
|
86
|
+
<FormScreenShell testId="job-run-detail-screen" maxWidth="3xl">
|
|
87
87
|
<Text variant="small">{t("jobs.detail.loading")}</Text>
|
|
88
88
|
</FormScreenShell>
|
|
89
89
|
);
|
|
@@ -91,7 +91,7 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
91
91
|
|
|
92
92
|
if (state.kind === "missing") {
|
|
93
93
|
return (
|
|
94
|
-
<FormScreenShell testId="job-run-detail-screen">
|
|
94
|
+
<FormScreenShell testId="job-run-detail-screen" maxWidth="3xl">
|
|
95
95
|
<Banner variant="error">{t("jobs.detail.missing")}</Banner>
|
|
96
96
|
</FormScreenShell>
|
|
97
97
|
);
|
|
@@ -99,7 +99,7 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
99
99
|
|
|
100
100
|
if (state.kind === "error") {
|
|
101
101
|
return (
|
|
102
|
-
<FormScreenShell testId="job-run-detail-screen">
|
|
102
|
+
<FormScreenShell testId="job-run-detail-screen" maxWidth="3xl">
|
|
103
103
|
<Banner variant="error">{state.message}</Banner>
|
|
104
104
|
</FormScreenShell>
|
|
105
105
|
);
|
|
@@ -108,7 +108,7 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
108
108
|
const { run } = state;
|
|
109
109
|
|
|
110
110
|
return (
|
|
111
|
-
<FormScreenShell testId="job-run-detail-screen" className="flex flex-col gap-6">
|
|
111
|
+
<FormScreenShell testId="job-run-detail-screen" maxWidth="3xl" className="flex flex-col gap-6">
|
|
112
112
|
<Card slots={{ title: run.jobName }}>
|
|
113
113
|
<dl className="grid gap-3 text-sm">
|
|
114
114
|
<div>
|
|
@@ -62,9 +62,8 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
62
62
|
|
|
63
63
|
// Provider-secret. Sensitive: redact-helper for admin-UI display.
|
|
64
64
|
const password = r.secret("smtp.password", {
|
|
65
|
-
label: {
|
|
65
|
+
label: { en: "SMTP password" },
|
|
66
66
|
hint: {
|
|
67
|
-
de: "Login-Passwort am SMTP-Server. Bei Brevo/Postmark/SES heißt es 'API key' bzw. 'SMTP credentials'.",
|
|
68
67
|
en: "Login password at the SMTP server. Brevo/Postmark/SES call it 'API key' or 'SMTP credentials'.",
|
|
69
68
|
},
|
|
70
69
|
redact: (plaintext) => {
|
|
@@ -109,7 +109,8 @@ describe("notes-history integration — add + list", () => {
|
|
|
109
109
|
// Same guard as authorId above: addNotePayloadSchema doesn't accept an
|
|
110
110
|
// authorName field, so a smuggled value never survives to the write —
|
|
111
111
|
// the name (once stamped) can only come from the caller's own session.
|
|
112
|
-
expect(rows
|
|
112
|
+
expect(rows).toHaveLength(1);
|
|
113
|
+
expect(rows[0]?.["authorName"]).toBeNull();
|
|
113
114
|
});
|
|
114
115
|
|
|
115
116
|
test("multiple notes on the same entity accumulate — nothing overwrites", async () => {
|
|
@@ -58,6 +58,8 @@ const AUTHOR_NAME = "Jamie Vance";
|
|
|
58
58
|
const INSERTED_AT = "2026-08-21T12:28:41.580Z";
|
|
59
59
|
const NOTE_BODY = "Vertragsunterlagen per Post verschickt.";
|
|
60
60
|
|
|
61
|
+
const PII_ERASED_SENTINEL = "[[erased]]";
|
|
62
|
+
|
|
61
63
|
const NOTE_ROWS: readonly NoteRowFixture[] = [
|
|
62
64
|
{
|
|
63
65
|
id: "note-1",
|
|
@@ -77,6 +79,15 @@ const NOTE_ROWS: readonly NoteRowFixture[] = [
|
|
|
77
79
|
body: "Legacy entry predating authorName.",
|
|
78
80
|
insertedAt: "2026-07-01T09:00:00.000Z",
|
|
79
81
|
},
|
|
82
|
+
{
|
|
83
|
+
id: "note-3",
|
|
84
|
+
entityType: "contact",
|
|
85
|
+
entityId: "contact-1",
|
|
86
|
+
authorId: "03c2466d-9314-98a6-a761-a5827a611301",
|
|
87
|
+
authorName: PII_ERASED_SENTINEL,
|
|
88
|
+
body: "Note whose author was crypto-shredded.",
|
|
89
|
+
insertedAt: "2026-06-15T08:00:00.000Z",
|
|
90
|
+
},
|
|
80
91
|
];
|
|
81
92
|
|
|
82
93
|
function makeDispatcher(): Dispatcher {
|
|
@@ -146,7 +157,7 @@ describe("NotesSection — entry display", () => {
|
|
|
146
157
|
expect(metaEl.textContent).toContain(AUTHOR_NAME);
|
|
147
158
|
});
|
|
148
159
|
|
|
149
|
-
test("a null authorName (legacy row
|
|
160
|
+
test("a null authorName (legacy row) renders the placeholder", async () => {
|
|
150
161
|
const view = renderSection();
|
|
151
162
|
await waitFor(() => {
|
|
152
163
|
if (view.queryByTestId("notes-section-row-note-2-meta") === null) {
|
|
@@ -157,6 +168,18 @@ describe("NotesSection — entry display", () => {
|
|
|
157
168
|
expect(metaEl.textContent).toContain("Unknown author");
|
|
158
169
|
});
|
|
159
170
|
|
|
171
|
+
test("a shredded authorName sentinel renders the placeholder, never [[erased]]", async () => {
|
|
172
|
+
const view = renderSection();
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
if (view.queryByTestId("notes-section-row-note-3-meta") === null) {
|
|
175
|
+
throw new Error("row not rendered yet");
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const metaEl = view.getByTestId("notes-section-row-note-3-meta");
|
|
179
|
+
expect(metaEl.textContent).toContain("Unknown author");
|
|
180
|
+
expect(metaEl.textContent).not.toContain(PII_ERASED_SENTINEL);
|
|
181
|
+
});
|
|
182
|
+
|
|
160
183
|
test("date renders formatted, never the raw ISO timestamp", async () => {
|
|
161
184
|
const view = renderSection();
|
|
162
185
|
await waitFor(() => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchOne,
|
|
1
|
+
import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { decryptStoredPii } from "../../shared";
|
|
4
4
|
import { userTable } from "../../user";
|
|
@@ -26,14 +26,18 @@ export function createAddNoteHandler(
|
|
|
26
26
|
// read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
|
|
27
27
|
// Bun.SQL poisons the whole tx after any error inside it, even one that's
|
|
28
28
|
// caught — a bare try/catch here would take the note write down with it.
|
|
29
|
-
authorName = await
|
|
29
|
+
authorName = await runInSavepointIfSupported(ctx.db.raw, async (sp) => {
|
|
30
30
|
const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
|
|
31
31
|
id: event.user.id,
|
|
32
32
|
});
|
|
33
33
|
if (!userRow?.displayName) return null;
|
|
34
34
|
return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
|
|
35
35
|
});
|
|
36
|
-
} catch {
|
|
36
|
+
} catch (e) {
|
|
37
|
+
ctx.log?.warn("notes-history: authorName lookup failed", {
|
|
38
|
+
error: e,
|
|
39
|
+
userId: event.user.id,
|
|
40
|
+
});
|
|
37
41
|
authorName = null;
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -26,11 +26,19 @@ type NoteRow = {
|
|
|
26
26
|
readonly entityType: string;
|
|
27
27
|
readonly entityId: string;
|
|
28
28
|
readonly authorId: string;
|
|
29
|
-
// Null for pre-authorName history
|
|
29
|
+
// Null for pre-authorName history; shredded authors arrive as PII_ERASED_SENTINEL.
|
|
30
30
|
readonly authorName: string | null;
|
|
31
31
|
readonly body: string;
|
|
32
32
|
readonly insertedAt: string;
|
|
33
33
|
};
|
|
34
|
+
|
|
35
|
+
// Matches @cosmicdrift/kumiko-framework/crypto PII_ERASED_SENTINEL — cannot
|
|
36
|
+
// import that module from @runtime client (runtime-isolation).
|
|
37
|
+
const PII_ERASED_SENTINEL = "[[erased]]";
|
|
38
|
+
|
|
39
|
+
function displayAuthorName(authorName: string | null, unknownLabel: string): string {
|
|
40
|
+
return authorName && authorName !== PII_ERASED_SENTINEL ? authorName : unknownLabel;
|
|
41
|
+
}
|
|
34
42
|
type NoteListResponse = { readonly rows: readonly NoteRow[] };
|
|
35
43
|
|
|
36
44
|
export function NotesSection({
|
|
@@ -144,7 +152,7 @@ export function NotesSection({
|
|
|
144
152
|
<Text testId={`notes-section-row-${n.id}-body`}>{n.body}</Text>
|
|
145
153
|
<Text variant="small" testId={`notes-section-row-${n.id}-meta`}>
|
|
146
154
|
{t("notesHistory.section.meta", {
|
|
147
|
-
author: n.authorName
|
|
155
|
+
author: displayAuthorName(n.authorName, t("notesHistory.section.authorUnknown")),
|
|
148
156
|
date: formatWhen(n.insertedAt),
|
|
149
157
|
})}
|
|
150
158
|
</Text>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSecretsFeature } from "../feature";
|
|
3
|
+
|
|
4
|
+
describe("createSecretsFeature access/roles precedence", () => {
|
|
5
|
+
test("roles-only adopts the host role vocabulary", () => {
|
|
6
|
+
expect(() => createSecretsFeature({ roles: ["Admin"] })).not.toThrow();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("access-only is accepted", () => {
|
|
10
|
+
expect(() => createSecretsFeature({ access: { openToAll: true } })).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("access + roles together fail fast", () => {
|
|
14
|
+
expect(() => createSecretsFeature({ access: { openToAll: true }, roles: ["Admin"] })).toThrow(
|
|
15
|
+
/either `access` or `roles`/,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -77,12 +77,22 @@ describe("secrets — roles option (#2296)", () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
test("the framework-default TenantAdmin role is denied once `roles` overrides it", async () => {
|
|
80
|
-
const
|
|
80
|
+
const deniedSet = await stack.http.writeErr(
|
|
81
81
|
"secrets:write:set",
|
|
82
82
|
{ key: "api.key.denied", value: "x" },
|
|
83
83
|
admin,
|
|
84
84
|
);
|
|
85
|
-
expect(
|
|
85
|
+
expect(deniedSet.code).toBe("access_denied");
|
|
86
|
+
|
|
87
|
+
const deniedList = await stack.http.queryErr("secrets:query:list", {}, admin);
|
|
88
|
+
expect(deniedList.code).toBe("access_denied");
|
|
89
|
+
|
|
90
|
+
const deniedDelete = await stack.http.writeErr(
|
|
91
|
+
"secrets:write:delete",
|
|
92
|
+
{ key: "api.key.denied" },
|
|
93
|
+
admin,
|
|
94
|
+
);
|
|
95
|
+
expect(deniedDelete.code).toBe("access_denied");
|
|
86
96
|
});
|
|
87
97
|
});
|
|
88
98
|
|
package/src/secrets/feature.ts
CHANGED
|
@@ -96,14 +96,21 @@ export type SecretsFeatureOptions = {
|
|
|
96
96
|
* user manage secrets — note this is a much larger blast radius than the
|
|
97
97
|
* default: tenant credentials (API keys, tokens) become writable/deletable,
|
|
98
98
|
* and their previews/hints listable, by every tenant member, not just
|
|
99
|
-
* admins.
|
|
99
|
+
* admins. Mutually exclusive with `roles`. The `rotate` job is unaffected —
|
|
100
100
|
* it's a manual, cross-tenant ops path, not per-tenant RBAC. */
|
|
101
101
|
readonly access?: AccessRule;
|
|
102
|
-
/** Shorthand for { access: { roles } }.
|
|
102
|
+
/** Shorthand for { access: { roles } }. Mutually exclusive with `access`. */
|
|
103
103
|
readonly roles?: readonly string[];
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
function resolveSecretsAccess(opts: SecretsFeatureOptions): AccessRule {
|
|
107
|
+
if (opts.access !== undefined && opts.roles !== undefined) {
|
|
108
|
+
throw new InternalError({
|
|
109
|
+
message:
|
|
110
|
+
"createSecretsFeature: pass either `access` or `roles`, not both — " +
|
|
111
|
+
"`access` previously silently discarded `roles`, which looks like a refinement.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
107
114
|
if (opts.access !== undefined) return opts.access;
|
|
108
115
|
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
109
116
|
return DEFAULT_SECRETS_ACCESS;
|
|
@@ -21,7 +21,3 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
|
|
|
21
21
|
},
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
// Backwards-compat: existing imports of `deleteWrite` keep working —
|
|
26
|
-
// identical to createDeleteHandler() with the default access.
|
|
27
|
-
export const deleteWrite = createDeleteHandler();
|
|
@@ -38,7 +38,3 @@ export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
|
38
38
|
},
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
// Backwards-compat: existing imports of `listQuery` keep working — identical
|
|
43
|
-
// to createListHandler() with the default access.
|
|
44
|
-
export const listQuery = createListHandler();
|
|
@@ -44,10 +44,6 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// Backwards-compat: existing imports of `setWrite` keep working — identical
|
|
48
|
-
// to createSetHandler() with the default access.
|
|
49
|
-
export const setWrite = createSetHandler();
|
|
50
|
-
|
|
51
47
|
// Fallback redaction: show at most the first 3 chars + trailing bullets.
|
|
52
48
|
// Deliberately conservative — a too-generous preview defeats the point.
|
|
53
49
|
function defaultRedact(value: string): string {
|
package/src/secrets/index.ts
CHANGED
|
@@ -13,11 +13,11 @@ export {
|
|
|
13
13
|
TENANT_SECRET_READ_EVENT,
|
|
14
14
|
tenantSecretsTable,
|
|
15
15
|
} from "./feature";
|
|
16
|
-
export { createDeleteHandler
|
|
17
|
-
export { createListHandler
|
|
16
|
+
export { createDeleteHandler } from "./handlers/delete.write";
|
|
17
|
+
export { createListHandler } from "./handlers/list.query";
|
|
18
18
|
export {
|
|
19
19
|
type RotateJobPayload,
|
|
20
20
|
type RotateJobResult,
|
|
21
21
|
rotateJob,
|
|
22
22
|
} from "./handlers/rotate.job";
|
|
23
|
-
export { createSetHandler
|
|
23
|
+
export { createSetHandler } from "./handlers/set.write";
|
package/src/shared/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { isWithinGracePeriod } from "./grace-period";
|
|
|
16
16
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
17
17
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
18
18
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
19
|
+
export { sessionField } from "./session-field";
|
|
19
20
|
export { sessionLocaleField } from "./session-locale-field";
|
|
20
21
|
export { sessionTimezoneField } from "./session-timezone-field";
|
|
21
22
|
export type { SystemQueryFn } from "./system-query";
|
|
@@ -44,7 +44,7 @@ describe("mapWithConcurrency", () => {
|
|
|
44
44
|
// `mapWithConcurrency` call already settled on the first one.
|
|
45
45
|
test("a second rejection after the first doesn't surface as an unhandled rejection", async () => {
|
|
46
46
|
const unhandled: unknown[] = [];
|
|
47
|
-
const onUnhandledRejection = (reason: unknown): void => {
|
|
47
|
+
const onUnhandledRejection = (reason: unknown, _promise: Promise<unknown>): void => {
|
|
48
48
|
unhandled.push(reason);
|
|
49
49
|
};
|
|
50
50
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
@@ -68,7 +68,7 @@ describe("mapWithConcurrency", () => {
|
|
|
68
68
|
// for a real unhandled rejection (if any) to fire.
|
|
69
69
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
70
70
|
} finally {
|
|
71
|
-
process.off("unhandledRejection"
|
|
71
|
+
process.off("unhandledRejection", onUnhandledRejection);
|
|
72
72
|
}
|
|
73
73
|
expect(unhandled).toEqual([]);
|
|
74
74
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
|
|
3
|
+
/** Omit null/undefined session keys so callers can spread without wiping defaults. */
|
|
4
|
+
export function sessionField<K extends "locale" | "timezone">(
|
|
5
|
+
key: K,
|
|
6
|
+
value: string | null | undefined,
|
|
7
|
+
): Partial<Pick<SessionUser, K>> {
|
|
8
|
+
if (value === null || value === undefined) return {};
|
|
9
|
+
return { [key]: value } as Pick<SessionUser, K>;
|
|
10
|
+
}
|
|
@@ -6,6 +6,15 @@ describe("sessionLocaleField", () => {
|
|
|
6
6
|
expect(sessionLocaleField("de-DE")).toEqual({ locale: "de-DE" });
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
test("canonicalizes primary subtag", () => {
|
|
10
|
+
expect(sessionLocaleField("DE")).toEqual({ locale: "de" });
|
|
11
|
+
expect(sessionLocaleField("DE-at")).toEqual({ locale: "de-at" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("rejects malformed locale tags", () => {
|
|
15
|
+
expect(sessionLocaleField('"><img sr')).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
|
|
9
18
|
test("null locale → empty object", () => {
|
|
10
19
|
expect(sessionLocaleField(null)).toEqual({});
|
|
11
20
|
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
|
+
import { sessionField } from "./session-field";
|
|
2
4
|
|
|
3
5
|
export function sessionLocaleField(
|
|
4
6
|
locale: string | null | undefined,
|
|
5
7
|
): Pick<SessionUser, "locale"> | Record<string, never> {
|
|
6
|
-
|
|
8
|
+
if (locale === null || locale === undefined || !isValidLocaleTag(locale)) return {};
|
|
9
|
+
return sessionField("locale", canonicalizeLocaleTag(locale));
|
|
7
10
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { sessionField } from "./session-field";
|
|
2
3
|
|
|
3
4
|
export function sessionTimezoneField(
|
|
4
5
|
timezone: string | null | undefined,
|
|
5
6
|
): Pick<SessionUser, "timezone"> | Record<string, never> {
|
|
6
|
-
return timezone
|
|
7
|
+
return sessionField("timezone", timezone);
|
|
7
8
|
}
|
|
@@ -163,17 +163,11 @@ export function createSubscriptionStripeFeature(
|
|
|
163
163
|
|
|
164
164
|
r.translations({
|
|
165
165
|
keys: {
|
|
166
|
-
"subscription-stripe.api-key": {
|
|
167
|
-
"subscription-stripe.webhook-secret": {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
},
|
|
171
|
-
"subscription-stripe.billing-live": {
|
|
172
|
-
de: "Stripe Billing live",
|
|
173
|
-
en: "Stripe Billing Live",
|
|
174
|
-
},
|
|
175
|
-
"subscription-stripe.settings": { de: "Stripe", en: "Stripe" },
|
|
176
|
-
"screen:subscription-stripe-system.title": { de: "Stripe", en: "Stripe" },
|
|
166
|
+
"subscription-stripe.api-key": { en: "Stripe API Key" },
|
|
167
|
+
"subscription-stripe.webhook-secret": { en: "Stripe Webhook Secret" },
|
|
168
|
+
"subscription-stripe.billing-live": { en: "Stripe Billing Live" },
|
|
169
|
+
"subscription-stripe.settings": { en: "Stripe" },
|
|
170
|
+
"screen:subscription-stripe-system.title": { en: "Stripe" },
|
|
177
171
|
},
|
|
178
172
|
});
|
|
179
173
|
|
|
@@ -48,7 +48,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
describe("createTagsFeature shape", () => {
|
|
51
|
-
test("registers tag + tag-assignment entities,
|
|
51
|
+
test("registers tag + tag-assignment entities, legacy + convention CRUD handlers", () => {
|
|
52
52
|
const feature = createTagsFeature();
|
|
53
53
|
|
|
54
54
|
expect(Object.keys(feature.entities ?? {})).toEqual(
|
|
@@ -62,17 +62,21 @@ describe("createTagsFeature shape", () => {
|
|
|
62
62
|
expect.stringMatching(/delete-tag/),
|
|
63
63
|
expect.stringMatching(/assign-tag/),
|
|
64
64
|
expect.stringMatching(/remove-tag/),
|
|
65
|
+
"tag:create",
|
|
66
|
+
"tag:update",
|
|
67
|
+
"tag:delete",
|
|
65
68
|
]),
|
|
66
69
|
);
|
|
67
|
-
expect(Object.keys(feature.writeHandlers)).toHaveLength(
|
|
70
|
+
expect(Object.keys(feature.writeHandlers)).toHaveLength(8);
|
|
68
71
|
|
|
69
72
|
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
70
73
|
expect.arrayContaining([
|
|
71
74
|
expect.stringMatching(/tag:list/),
|
|
72
75
|
expect.stringMatching(/tag-assignment:list/),
|
|
76
|
+
"tag:detail",
|
|
73
77
|
]),
|
|
74
78
|
);
|
|
75
|
-
expect(Object.keys(feature.queryHandlers)).toHaveLength(
|
|
79
|
+
expect(Object.keys(feature.queryHandlers)).toHaveLength(3);
|
|
76
80
|
});
|
|
77
81
|
});
|
|
78
82
|
|
|
@@ -102,10 +106,19 @@ describe("createTagsFeature access-options", () => {
|
|
|
102
106
|
|
|
103
107
|
test("access:{openToAll} applies to every write- and query-path", () => {
|
|
104
108
|
const feature = createTagsFeature({ access: { openToAll: true } });
|
|
105
|
-
for (const path of [
|
|
109
|
+
for (const path of [
|
|
110
|
+
"create-tag",
|
|
111
|
+
"update-tag",
|
|
112
|
+
"delete-tag",
|
|
113
|
+
"tag:create",
|
|
114
|
+
"tag:update",
|
|
115
|
+
"tag:delete",
|
|
116
|
+
"assign-tag",
|
|
117
|
+
"remove-tag",
|
|
118
|
+
]) {
|
|
106
119
|
expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
|
|
107
120
|
}
|
|
108
|
-
for (const query of ["tag:list", "tag-assignment:list"]) {
|
|
121
|
+
for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
|
|
109
122
|
expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
|
|
110
123
|
}
|
|
111
124
|
});
|