@cosmicdrift/kumiko-samples 0.356.0 → 0.357.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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
- package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +16 -1
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
- package/packages/bundled-features/src/delivery/constants.ts +5 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
- package/packages/bundled-features/src/delivery/feature.ts +7 -1
- package/packages/bundled-features/src/delivery/jobs.ts +27 -1
- package/packages/bundled-features/src/delivery/testing.ts +1 -0
- package/packages/bundled-features/src/notes-history/entity.ts +7 -6
- package/packages/bundled-features/src/notes-history-user-data/hooks.ts +109 -62
- package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
- package/packages/bundled-features/src/sessions/screens.ts +2 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +6 -3
- package/packages/bundled-features/src/tenant/feature.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
- package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
- package/packages/bundled-features/src/tenant/screens.ts +9 -6
- package/packages/bundled-features/src/user/screens.ts +8 -1
- package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
- package/packages/bundled-features/src/waitlist/screens.ts +8 -1
- package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
- package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.357.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.357.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "MFA enable asks to confirm saved recovery codes, code field autocompletes one-time codes",
|
|
6
|
+
"detail": "secretMint `reveal.acknowledge` (i18n key) renders a checkbox on the reveal card that must be ticked before the confirm step or the acknowledge button can be used; the tick never reaches the payload. Text fields accept an `autoComplete` token that reaches the input. The MFA enable screen uses both: a \"saved my recovery codes\" gate and `one-time-code` on the code field"
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -28,6 +28,7 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
28
28
|
"mfa.enable.reveal.qr": { en: "Scan this QR code" },
|
|
29
29
|
"mfa.enable.reveal.secret": { en: "Or enter this code manually" },
|
|
30
30
|
"mfa.enable.reveal.recoveryCodes": { en: "Recovery codes" },
|
|
31
|
+
"mfa.enable.reveal.savedCodes": { en: "I have saved my recovery codes." },
|
|
31
32
|
|
|
32
33
|
"mfa.enable.confirm.submit": { en: "Enable" },
|
|
33
34
|
"mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
|
|
@@ -34,6 +34,7 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
34
34
|
reveal: {
|
|
35
35
|
title: i18nKey("mfa.enable.reveal.title"),
|
|
36
36
|
warning: i18nKey("mfa.enable.reveal.warning"),
|
|
37
|
+
acknowledge: i18nKey("mfa.enable.reveal.savedCodes"),
|
|
37
38
|
fields: [
|
|
38
39
|
{
|
|
39
40
|
field: "otpauthUri",
|
|
@@ -58,7 +59,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
|
|
|
58
59
|
confirm: {
|
|
59
60
|
handler: AuthMfaHandlers.enableConfirm,
|
|
60
61
|
fields: {
|
|
61
|
-
code: {
|
|
62
|
+
code: {
|
|
63
|
+
type: "text",
|
|
64
|
+
required: true,
|
|
65
|
+
minLength: 6,
|
|
66
|
+
maxLength: 6,
|
|
67
|
+
autoComplete: "one-time-code",
|
|
68
|
+
},
|
|
62
69
|
},
|
|
63
70
|
layout: { sections: [{ fields: ["code"] }] },
|
|
64
71
|
carry: ["setupToken"],
|
|
@@ -119,7 +119,13 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
119
119
|
id: CAP_COUNTER_LIST_SCREEN_ID,
|
|
120
120
|
type: "entityList",
|
|
121
121
|
entity: "cap-counter",
|
|
122
|
-
columns: [
|
|
122
|
+
columns: [
|
|
123
|
+
"tenantId",
|
|
124
|
+
"capName",
|
|
125
|
+
"value",
|
|
126
|
+
"periodStart",
|
|
127
|
+
{ field: "lastSoftWarnedAt", hideOnNarrow: true },
|
|
128
|
+
],
|
|
123
129
|
defaultSort: { field: "capName", dir: "asc" },
|
|
124
130
|
// Search resolves against the caller's own tenant stream, so on this
|
|
125
131
|
// cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
|
|
@@ -31,10 +31,12 @@ export function createTenantCapListScreen(
|
|
|
31
31
|
{ field: "name", label: "cap-overview.list.col.name" },
|
|
32
32
|
{ field: "tier", label: "cap-overview.list.col.tier" },
|
|
33
33
|
{ field: "billing", label: "cap-overview.list.col.billing" },
|
|
34
|
-
|
|
34
|
+
// Name is the title; tier, billing and the first cap fill the card's three meta rows.
|
|
35
|
+
...listedCaps.map((cap, index) => ({
|
|
35
36
|
field: capFieldName(cap.id),
|
|
36
37
|
label: cap.label,
|
|
37
38
|
renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
|
|
39
|
+
...(index > 0 && { hideOnNarrow: true }),
|
|
38
40
|
})),
|
|
39
41
|
],
|
|
40
42
|
searchable: true,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "forget-subject shreds note bodies attached to the subject and notes that mention it",
|
|
6
|
+
"detail": "`forget-subject` on a user or record subject now also erases the per-note keys of every `note-entry` that hangs on the subject as host entity (entityId, plus entityType for record subjects) or @-mentions it, unless the host entity's retention strategy is anonymize/blockDelete. The automated Art. 17 delete hook reaches notes on the user as host too, and the export lists them. Before, `forget-subject(contact)` left the note bodies readable. Consumer tests that expect readable note bodies after `forget-subject` on a contact (e.g. solon contact-notes-forget) now see the erased sentinel. Forgetting only the author still leaves the bodies readable."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -40,6 +40,7 @@ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/se
|
|
|
40
40
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
41
41
|
import * as z from "zod";
|
|
42
42
|
import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
|
|
43
|
+
import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
|
|
43
44
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
|
|
44
45
|
import { USER_STATUS } from "../../user/index.js";
|
|
45
46
|
import {
|
|
@@ -300,6 +301,7 @@ async function sweepDerivedSubjectData(
|
|
|
300
301
|
subject: SubjectId,
|
|
301
302
|
subjectKey: string,
|
|
302
303
|
crossTenantRunner: DbRunner,
|
|
304
|
+
audit: { readonly requestId: string; readonly erasedBy: string },
|
|
303
305
|
): Promise<string | undefined> {
|
|
304
306
|
try {
|
|
305
307
|
await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
|
|
@@ -318,6 +320,17 @@ async function sweepDerivedSubjectData(
|
|
|
318
320
|
subject,
|
|
319
321
|
);
|
|
320
322
|
}
|
|
323
|
+
|
|
324
|
+
// Note bodies are keyed per note, so the subject's own key never reaches them.
|
|
325
|
+
if (ctx.registry.features.has("notes-history")) {
|
|
326
|
+
await eraseNoteBodiesReachingSubject({
|
|
327
|
+
db: crossTenantSubjectRunner,
|
|
328
|
+
registry: ctx.registry,
|
|
329
|
+
subject,
|
|
330
|
+
erasedBy: audit.erasedBy,
|
|
331
|
+
requestId: audit.requestId,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
321
334
|
});
|
|
322
335
|
return undefined;
|
|
323
336
|
} catch (err) {
|
|
@@ -471,8 +484,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
471
484
|
// but no event exists — a retry is a no-op erase plus the event. The
|
|
472
485
|
// reverse order could leave an audit trail claiming a shred that never
|
|
473
486
|
// happened.
|
|
487
|
+
const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
|
|
474
488
|
await kms.eraseKey(subject, {
|
|
475
|
-
requestId
|
|
489
|
+
requestId,
|
|
476
490
|
userId: event.user.id,
|
|
477
491
|
eraseReason: event.payload.reason,
|
|
478
492
|
});
|
|
@@ -482,6 +496,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
482
496
|
subject,
|
|
483
497
|
subjectKey,
|
|
484
498
|
ctx.db.unsafeRaw(),
|
|
499
|
+
{ requestId, erasedBy: event.user.id },
|
|
485
500
|
);
|
|
486
501
|
|
|
487
502
|
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Delivery idempotencyKey is released when nothing was delivered",
|
|
6
|
+
"detail": "A retry of a failed notify() with the same idempotencyKey is no longer skipped as a duplicate. The key is deleted when no recipient got a `sent` or `queued` delivery, also when notify() throws; one successful recipient keeps it, even if notify() throws afterwards (a retry would re-send to them). For queued channels the send/render job deletes the key after its final failed attempt, not per retry. That reference is only attached when a `route` call has exactly one channel address; `to` calls (user lists) and multi-channel routes keep the key after queueing. The job payload gains an optional `idempotencyKey`, backward compatible. `createDeliveryTestContext` accepts `idempotencyRedis`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -57,7 +57,7 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
|
|
|
57
57
|
function policyFor(request: ChatWebhookRequest, target: URL): EgressPolicy {
|
|
58
58
|
return request.requireHttps
|
|
59
59
|
? { kind: "external" }
|
|
60
|
-
: { kind: "internal",
|
|
60
|
+
: { kind: "internal", allowOrigins: [target.origin] };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function hostMatches(hostname: string, allowedHost: string): boolean {
|
|
@@ -26,6 +26,11 @@ export {
|
|
|
26
26
|
type DeliveryStatusValue,
|
|
27
27
|
} from "./public-names.js";
|
|
28
28
|
|
|
29
|
+
// Single source for claim (delivery-service) and terminal-failure release (jobs).
|
|
30
|
+
export function deliveryIdempotencyRedisKey(tenantId: string, key: string): string {
|
|
31
|
+
return `delivery:idem:${tenantId}:${key}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
// notify() priority → BullMQ job priority. Lower number = processed first; all
|
|
30
35
|
// > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
|
|
31
36
|
// FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EscapeHatchAuditSink,
|
|
7
7
|
NotifyDelivery,
|
|
8
8
|
NotifyJobDispatcher,
|
|
9
|
+
NotifyOptions,
|
|
9
10
|
NotifyPriority,
|
|
10
11
|
Registry,
|
|
11
12
|
TenantId,
|
|
@@ -21,7 +22,12 @@ import { redactErrorText } from "../shared/index.js";
|
|
|
21
22
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
22
23
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
23
24
|
import { buildChannelContext } from "./channel-context.js";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
27
|
+
DeliveryJobs,
|
|
28
|
+
deliveryIdempotencyRedisKey,
|
|
29
|
+
deliveryPriorityRank,
|
|
30
|
+
} from "./constants.js";
|
|
25
31
|
import {
|
|
26
32
|
type AddressOptOutKey,
|
|
27
33
|
addressOptOutKey,
|
|
@@ -171,11 +177,43 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
171
177
|
"Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured",
|
|
172
178
|
);
|
|
173
179
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
180
|
+
const res = await idemRedis.set(
|
|
181
|
+
deliveryIdempotencyRedisKey(tenantId, key),
|
|
182
|
+
"1",
|
|
183
|
+
"EX",
|
|
184
|
+
ttlSec,
|
|
185
|
+
"NX",
|
|
186
|
+
);
|
|
176
187
|
return res === "OK";
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
function isDeliveredOrQueued(delivery: NotifyDelivery): boolean {
|
|
191
|
+
return delivery.status === "sent" || delivery.status === "queued";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
|
|
195
|
+
// Partial success keeps it: a retry would re-send to those who got it.
|
|
196
|
+
async function releaseIdempotencyIfNothingDelivered(
|
|
197
|
+
tenantId: TenantId,
|
|
198
|
+
key: string | undefined,
|
|
199
|
+
run: NotifyRun,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
// skip: no key claimed, or a recipient already got the notification
|
|
202
|
+
if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
|
|
203
|
+
await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Only a single-channel route lets the queued job free the key on terminal failure
|
|
207
|
+
// without risking a duplicate send on another channel.
|
|
208
|
+
function jobIdempotencyKeyFor(
|
|
209
|
+
idempotencyKey: string | undefined,
|
|
210
|
+
route: NotifyOptions["route"],
|
|
211
|
+
): string | undefined {
|
|
212
|
+
if (!idempotencyKey || !route) return undefined;
|
|
213
|
+
const routedChannelCount = channels.filter((channel) => route[channel.name]).length;
|
|
214
|
+
return routedChannelCount === 1 ? idempotencyKey : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
179
217
|
async function resolveUserIdsForTenant(tenantId: TenantId): Promise<readonly string[]> {
|
|
180
218
|
if (!tenantUserIdsQuery) {
|
|
181
219
|
throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
|
|
@@ -233,6 +271,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
233
271
|
readonly deliveries: NotifyDelivery[];
|
|
234
272
|
readonly jobDispatcher: NotifyJobDispatcher | undefined;
|
|
235
273
|
readonly locale: string | undefined;
|
|
274
|
+
// Set only when exactly one channel carries the call: the queued job may then free
|
|
275
|
+
// the key on terminal failure without risking a duplicate send on another channel.
|
|
276
|
+
readonly jobIdempotencyKey: string | undefined;
|
|
236
277
|
};
|
|
237
278
|
|
|
238
279
|
function recordDelivery(
|
|
@@ -343,6 +384,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
343
384
|
deliveryAttemptId,
|
|
344
385
|
priority,
|
|
345
386
|
message,
|
|
387
|
+
...(run.jobIdempotencyKey !== undefined && { idempotencyKey: run.jobIdempotencyKey }),
|
|
346
388
|
},
|
|
347
389
|
{ priority: deliveryPriorityRank[priority] },
|
|
348
390
|
);
|
|
@@ -745,14 +787,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
745
787
|
}
|
|
746
788
|
}
|
|
747
789
|
|
|
790
|
+
async function dispatchNotification(
|
|
791
|
+
notificationType: string,
|
|
792
|
+
options: NotifyOptions,
|
|
793
|
+
tenantId: TenantId,
|
|
794
|
+
priority: NotifyPriority,
|
|
795
|
+
run: NotifyRun,
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
const { to, route, data } = options;
|
|
798
|
+
if (route) {
|
|
799
|
+
await deliverDirect(
|
|
800
|
+
route,
|
|
801
|
+
notificationType,
|
|
802
|
+
data,
|
|
803
|
+
tenantId,
|
|
804
|
+
priority,
|
|
805
|
+
options.recipientId ?? null,
|
|
806
|
+
run,
|
|
807
|
+
);
|
|
808
|
+
// skip: explicit route is delivered directly, no recipient resolution needed
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (to !== undefined) {
|
|
813
|
+
let userIds: readonly string[];
|
|
814
|
+
|
|
815
|
+
if (typeof to === "string") {
|
|
816
|
+
userIds = [to];
|
|
817
|
+
} else if ("tenant" in to) {
|
|
818
|
+
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
819
|
+
} else {
|
|
820
|
+
userIds = to;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const prefetchedAddresses =
|
|
824
|
+
priority === "critical"
|
|
825
|
+
? new Map<string, string | null>()
|
|
826
|
+
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
827
|
+
const suppressed = await loadSuppressedAddresses(
|
|
828
|
+
tenantId,
|
|
829
|
+
notificationType,
|
|
830
|
+
priority,
|
|
831
|
+
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
for (const userId of userIds) {
|
|
835
|
+
await deliverToUser(
|
|
836
|
+
userId,
|
|
837
|
+
notificationType,
|
|
838
|
+
data,
|
|
839
|
+
tenantId,
|
|
840
|
+
priority,
|
|
841
|
+
run,
|
|
842
|
+
prefetchedAddresses,
|
|
843
|
+
suppressed,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
748
849
|
return {
|
|
749
850
|
async notify(notificationType, options, _user, tenantId, jobDispatcher) {
|
|
750
|
-
const {
|
|
851
|
+
const { route, idempotencyKey } = options;
|
|
751
852
|
const priority: NotifyPriority = options.priority ?? "normal";
|
|
752
853
|
const run: NotifyRun = {
|
|
753
854
|
deliveries: [],
|
|
754
855
|
jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
|
|
755
856
|
locale: options.locale,
|
|
857
|
+
jobIdempotencyKey: jobIdempotencyKeyFor(idempotencyKey, route),
|
|
756
858
|
};
|
|
757
859
|
|
|
758
860
|
if (idempotencyKey) {
|
|
@@ -772,54 +874,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
772
874
|
}
|
|
773
875
|
}
|
|
774
876
|
|
|
775
|
-
|
|
776
|
-
await
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
return { deliveries: run.deliveries };
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
if (to !== undefined) {
|
|
789
|
-
let userIds: readonly string[];
|
|
790
|
-
|
|
791
|
-
if (typeof to === "string") {
|
|
792
|
-
userIds = [to];
|
|
793
|
-
} else if ("tenant" in to) {
|
|
794
|
-
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
795
|
-
} else {
|
|
796
|
-
userIds = to;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const prefetchedAddresses =
|
|
800
|
-
priority === "critical"
|
|
801
|
-
? new Map<string, string | null>()
|
|
802
|
-
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
803
|
-
const suppressed = await loadSuppressedAddresses(
|
|
804
|
-
tenantId,
|
|
805
|
-
notificationType,
|
|
806
|
-
priority,
|
|
807
|
-
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
877
|
+
try {
|
|
878
|
+
await dispatchNotification(notificationType, options, tenantId, priority, run);
|
|
879
|
+
} catch (err) {
|
|
880
|
+
// A failing release must not replace the dispatch error the caller retries on.
|
|
881
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
|
|
882
|
+
(releaseErr: unknown) => {
|
|
883
|
+
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
884
|
+
notificationType,
|
|
885
|
+
});
|
|
886
|
+
},
|
|
808
887
|
);
|
|
809
|
-
|
|
810
|
-
for (const userId of userIds) {
|
|
811
|
-
await deliverToUser(
|
|
812
|
-
userId,
|
|
813
|
-
notificationType,
|
|
814
|
-
data,
|
|
815
|
-
tenantId,
|
|
816
|
-
priority,
|
|
817
|
-
run,
|
|
818
|
-
prefetchedAddresses,
|
|
819
|
-
suppressed,
|
|
820
|
-
);
|
|
821
|
-
}
|
|
888
|
+
throw err;
|
|
822
889
|
}
|
|
890
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
|
|
823
891
|
return { deliveries: run.deliveries };
|
|
824
892
|
},
|
|
825
893
|
};
|
|
@@ -247,15 +247,21 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
247
247
|
label: i18nKey("delivery.log.col.channel"),
|
|
248
248
|
renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
|
|
249
249
|
},
|
|
250
|
-
{
|
|
250
|
+
{
|
|
251
|
+
field: "recipientLabel",
|
|
252
|
+
label: i18nKey("delivery.log.col.recipient"),
|
|
253
|
+
hideOnNarrow: true,
|
|
254
|
+
},
|
|
251
255
|
{
|
|
252
256
|
field: "status",
|
|
253
257
|
label: i18nKey("delivery.log.col.status"),
|
|
258
|
+
hideOnNarrow: true,
|
|
254
259
|
renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
|
|
255
260
|
},
|
|
256
261
|
{
|
|
257
262
|
field: "error",
|
|
258
263
|
label: i18nKey("delivery.log.col.error"),
|
|
264
|
+
hideOnNarrow: true,
|
|
259
265
|
renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
|
|
260
266
|
},
|
|
261
267
|
],
|
|
@@ -18,7 +18,7 @@ import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
|
18
18
|
import * as z from "zod";
|
|
19
19
|
import { appendAttemptEvent } from "./attempt-log.js";
|
|
20
20
|
import { buildChannelContext } from "./channel-context.js";
|
|
21
|
-
import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
|
|
21
|
+
import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.js";
|
|
22
22
|
import { collectChannels, redactedMessageOf } from "./delivery-service.js";
|
|
23
23
|
import type {
|
|
24
24
|
ChannelMessage,
|
|
@@ -44,6 +44,7 @@ const renderJobPayloadSchema = z.object({
|
|
|
44
44
|
deliveryAttemptId: z.string(),
|
|
45
45
|
priority: z.enum(["critical", "normal", "low"]),
|
|
46
46
|
message: channelMessageSchema,
|
|
47
|
+
idempotencyKey: z.string().optional(),
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
@@ -110,6 +111,30 @@ function entryFor(
|
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
// Only the final retry frees the key: earlier failures are retried by the runner and
|
|
115
|
+
// must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
|
|
116
|
+
// wires the same instance into both.
|
|
117
|
+
async function releaseIdempotencyOnTerminalFailure(
|
|
118
|
+
ctx: JobContext,
|
|
119
|
+
p: RenderJobPayload,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
// skip: nothing to release before the last attempt or without an idempotency key
|
|
122
|
+
if (!ctx.finalAttempt || !p.idempotencyKey) return;
|
|
123
|
+
if (!ctx.redis) {
|
|
124
|
+
ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
|
|
125
|
+
notificationType: p.notificationType,
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await ctx.redis.del(deliveryIdempotencyRedisKey(p.tenantId, p.idempotencyKey));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
ctx.log.warn(`delivery: idempotency key release failed: ${redactedMessageOf(err)}`, {
|
|
133
|
+
notificationType: p.notificationType,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
// The job runner stores err.message in the run row, and BullMQ keeps failedReason and
|
|
114
139
|
// the stack trace in Redis in plain text, so the rethrown error carries only the code.
|
|
115
140
|
// The redacted message goes to the log.
|
|
@@ -132,6 +157,7 @@ async function failAttempt(
|
|
|
132
157
|
p.deliveryAttemptId,
|
|
133
158
|
entryFor(p, "failed", code, p.address),
|
|
134
159
|
);
|
|
160
|
+
await releaseIdempotencyOnTerminalFailure(ctx, p);
|
|
135
161
|
throw new Error(code);
|
|
136
162
|
}
|
|
137
163
|
|
|
@@ -26,17 +26,18 @@ import {
|
|
|
26
26
|
//
|
|
27
27
|
// `body` is instead `personal: { of: "id" }` (Row-Subject, fw#2596/#2786):
|
|
28
28
|
// the note's OWN row is its content's subject, so `body` is encrypted under a
|
|
29
|
-
// per-note key that nothing else shares. That key gets erased only when
|
|
30
|
-
// `note-mention` (below) names
|
|
31
|
-
//
|
|
32
|
-
//
|
|
29
|
+
// per-note key that nothing else shares. That key gets erased only when the
|
|
30
|
+
// note is reached by a forgotten subject: `note-mention` (below) names the
|
|
31
|
+
// subject, or the note hangs on the subject as host (entityId, plus entityType
|
|
32
|
+
// for record subjects). See `notes-history-user-data/hooks.ts`; any other note
|
|
33
|
+
// is untouched. GDPR erasure of the AUTHOR still separately
|
|
33
34
|
// shreds `authorName` (`personal: { of: "authorId" }`, the name as stamped at
|
|
34
35
|
// write time); the two erasures are independent.
|
|
35
36
|
//
|
|
36
37
|
// Known gap (fw#2596, not fixed by this entity): prose that names a third
|
|
37
38
|
// party without a structured @-mention is not tracked — regex/NLP over
|
|
38
|
-
// free text is unreliable.
|
|
39
|
-
//
|
|
39
|
+
// free text is unreliable. An operator must erase such a note's own record
|
|
40
|
+
// subject (`forget-subject` kind "record", entity "note-entry") to cover it.
|
|
40
41
|
export function createNoteEntryEntity(
|
|
41
42
|
access?: EntityDefinition["access"],
|
|
42
43
|
parents?: readonly string[],
|
|
@@ -4,9 +4,17 @@
|
|
|
4
4
|
// dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
|
|
5
5
|
// export-only, erasure via crypto-shredding.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
7
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
|
|
9
|
+
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type {
|
|
11
|
+
Registry,
|
|
12
|
+
TenantId,
|
|
13
|
+
UserDataDeleteHook,
|
|
14
|
+
UserDataExportHook,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
16
|
import {
|
|
17
|
+
type RetentionPresetKey,
|
|
10
18
|
resolveRetentionPolicyForTenant,
|
|
11
19
|
resolveTenantRetentionPreset,
|
|
12
20
|
} from "../data-retention/index.js";
|
|
@@ -32,95 +40,134 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
|
32
40
|
};
|
|
33
41
|
};
|
|
34
42
|
|
|
43
|
+
type NoteRow = {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly tenantId: TenantId;
|
|
46
|
+
readonly entityType: string;
|
|
47
|
+
readonly entityId: string;
|
|
48
|
+
readonly body: unknown;
|
|
49
|
+
readonly insertedAt: unknown;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// What erasing a subject's key reaches: notes that @-mention the subject plus notes
|
|
53
|
+
// attached to it as host. Export and erasure both go through this, so Art. 15
|
|
54
|
+
// lists exactly what Art. 17 shreds.
|
|
55
|
+
async function selectNotesReachingSubject(
|
|
56
|
+
db: DbRunner | TenantDb,
|
|
57
|
+
subject: SubjectId,
|
|
58
|
+
): Promise<readonly NoteRow[]> {
|
|
59
|
+
if (subject.kind === "tenant") return [];
|
|
60
|
+
const subjectId = subject.kind === "user" ? subject.userId : subject.id;
|
|
61
|
+
const attachedWhere =
|
|
62
|
+
subject.kind === "record"
|
|
63
|
+
? { entityType: subject.entity, entityId: subjectId }
|
|
64
|
+
: { entityId: subjectId };
|
|
65
|
+
|
|
66
|
+
const mentions = await selectMany<{ noteId: string }>(db, noteMentionTable, { subjectId });
|
|
67
|
+
const mentioned =
|
|
68
|
+
mentions.length === 0
|
|
69
|
+
? []
|
|
70
|
+
: await selectMany<NoteRow>(db, noteEntryTable, {
|
|
71
|
+
id: { in: [...new Set(mentions.map((m) => m.noteId))] },
|
|
72
|
+
});
|
|
73
|
+
const attached = await selectMany<NoteRow>(db, noteEntryTable, attachedWhere);
|
|
74
|
+
|
|
75
|
+
const byId = new Map<string, NoteRow>();
|
|
76
|
+
for (const note of [...mentioned, ...attached]) byId.set(note.id, note);
|
|
77
|
+
return [...byId.values()];
|
|
78
|
+
}
|
|
79
|
+
|
|
35
80
|
// `authorName` is separately annotated `personal: { of: "authorId" }`
|
|
36
81
|
// (entity.ts), so forgetting the AUTHOR already crypto-shreds that field on
|
|
37
|
-
// its own
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// that's the documented gap in entity.ts, not a bug here.
|
|
82
|
+
// its own. `body` is Row-Subject (`personal: { of: "id" }`): its key is per
|
|
83
|
+
// note, so forgetting a subject only reaches a body when the note structurally
|
|
84
|
+
// @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
|
|
85
|
+
// Free-text mentions are NOT detected — the documented gap in entity.ts.
|
|
42
86
|
//
|
|
43
|
-
// Per reached note
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// for note-entry, not the host, so it is deliberately unused here.
|
|
87
|
+
// Per reached note the HOST entity's retention strategy (entityType, resolved
|
|
88
|
+
// for the note's own tenant — the manual forget-subject path runs cross-tenant)
|
|
89
|
+
// is consulted first: anonymize/blockDelete win over erasure, same rule the
|
|
90
|
+
// automated forget-cleanup applies to every other entity.
|
|
48
91
|
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
92
|
+
// Without a mounted KMS this erases nothing: record-owned fields fall back to
|
|
93
|
+
// plaintext framework-wide (see pii-field-encryption.ts), so apps that need
|
|
94
|
+
// Art. 17 coverage must mount one.
|
|
95
|
+
export async function eraseNoteBodiesReachingSubject(args: {
|
|
96
|
+
readonly db: DbRunner | TenantDb;
|
|
97
|
+
readonly registry: Registry;
|
|
98
|
+
readonly subject: SubjectId;
|
|
99
|
+
readonly erasedBy: string;
|
|
100
|
+
readonly requestId: string;
|
|
101
|
+
}): Promise<void> {
|
|
56
102
|
const kms = configuredPiiSubjectKms();
|
|
57
|
-
// skip: no KMS adapter mounted —
|
|
103
|
+
// skip: no KMS adapter mounted — forget is a true no-op
|
|
58
104
|
if (!kms) return;
|
|
59
105
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (mentions.length === 0) return;
|
|
65
|
-
|
|
66
|
-
const noteIds = new Set(mentions.map((m) => m.noteId));
|
|
67
|
-
// Once per hook run: the preset depends on the tenant only, not the note.
|
|
68
|
-
const tenantPreset = await resolveTenantRetentionPreset({
|
|
69
|
-
db: ctx.db,
|
|
70
|
-
registry: ctx.registry,
|
|
71
|
-
tenantId: ctx.tenantId,
|
|
72
|
-
});
|
|
73
|
-
// Append-only rows are never hard-deleted, so every mentioned note should
|
|
74
|
-
// resolve; a missing one has no host and thus no retention policy to consult.
|
|
75
|
-
const notes = await ctx.db.selectMany<{ id: string; entityType: string }>(noteEntryTable, {
|
|
76
|
-
id: { in: [...noteIds] },
|
|
77
|
-
});
|
|
78
|
-
const strategyByEntityType = new Map<string, ReturnType<typeof policyToStrategy>>();
|
|
106
|
+
const notes = await selectNotesReachingSubject(args.db, args.subject);
|
|
107
|
+
const dataRetentionMounted = args.registry.features.has("data-retention");
|
|
108
|
+
const presetByTenant = new Map<string, RetentionPresetKey | null>();
|
|
109
|
+
const strategyByTenantAndEntityType = new Map<string, ReturnType<typeof policyToStrategy>>();
|
|
79
110
|
for (const note of notes) {
|
|
80
|
-
|
|
111
|
+
const strategyKey = `${note.tenantId}:${note.entityType}`;
|
|
112
|
+
let strategy = strategyByTenantAndEntityType.get(strategyKey);
|
|
81
113
|
if (strategy === undefined) {
|
|
114
|
+
let tenantPreset = presetByTenant.get(note.tenantId);
|
|
115
|
+
if (tenantPreset === undefined) {
|
|
116
|
+
tenantPreset = await resolveTenantRetentionPreset({
|
|
117
|
+
db: args.db,
|
|
118
|
+
registry: args.registry,
|
|
119
|
+
tenantId: note.tenantId,
|
|
120
|
+
});
|
|
121
|
+
presetByTenant.set(note.tenantId, tenantPreset);
|
|
122
|
+
}
|
|
82
123
|
const hostPolicy = await resolveRetentionPolicyForTenant({
|
|
83
|
-
db:
|
|
84
|
-
registry:
|
|
85
|
-
tenantId:
|
|
124
|
+
db: args.db,
|
|
125
|
+
registry: args.registry,
|
|
126
|
+
tenantId: note.tenantId,
|
|
86
127
|
entityName: note.entityType,
|
|
87
128
|
preloadedTenantPreset: tenantPreset,
|
|
129
|
+
// Without data-retention there is no override table to read.
|
|
130
|
+
...(!dataRetentionMounted && { preloadedOverride: null }),
|
|
88
131
|
});
|
|
89
132
|
strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
|
|
90
|
-
|
|
133
|
+
strategyByTenantAndEntityType.set(strategyKey, strategy);
|
|
91
134
|
}
|
|
92
135
|
if (strategy === "anonymize") continue;
|
|
93
|
-
const noteId = note.id;
|
|
94
136
|
|
|
95
137
|
await kms.eraseKey(
|
|
96
|
-
{ kind: "record", entity: "note-entry", id:
|
|
138
|
+
{ kind: "record", entity: "note-entry", id: note.id },
|
|
97
139
|
{
|
|
98
|
-
requestId:
|
|
99
|
-
userId:
|
|
100
|
-
eraseReason: "notes-history:
|
|
140
|
+
requestId: args.requestId,
|
|
141
|
+
userId: args.erasedBy,
|
|
142
|
+
eraseReason: "notes-history:subject-forget",
|
|
101
143
|
},
|
|
102
144
|
);
|
|
103
145
|
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
149
|
+
await eraseNoteBodiesReachingSubject({
|
|
150
|
+
db: ctx.db,
|
|
151
|
+
registry: ctx.registry,
|
|
152
|
+
subject: { kind: "user", userId: ctx.userId },
|
|
153
|
+
erasedBy: ctx.userId,
|
|
154
|
+
requestId: "notes-history-user-data:forget-reached-notes",
|
|
155
|
+
});
|
|
104
156
|
};
|
|
105
157
|
|
|
106
|
-
// Erasure reaches every note that
|
|
107
|
-
//
|
|
158
|
+
// Erasure reaches every note that mentions the user or hangs on the user as host
|
|
159
|
+
// (see eraseNoteBodiesReachingSubject), so the Art. 15/20 export lists those too.
|
|
108
160
|
export const noteMentionExportHook: UserDataExportHook = async (ctx) => {
|
|
109
|
-
const
|
|
110
|
-
subjectId: ctx.userId,
|
|
111
|
-
});
|
|
112
|
-
if (mentions.length === 0) return null;
|
|
113
|
-
const noteIds = [...new Set(mentions.map((m) => m.noteId))];
|
|
114
|
-
const notes = await ctx.db.selectMany(noteEntryTable, { id: { in: noteIds } });
|
|
161
|
+
const notes = await selectNotesReachingSubject(ctx.db, { kind: "user", userId: ctx.userId });
|
|
115
162
|
if (notes.length === 0) return null;
|
|
116
163
|
return {
|
|
117
164
|
entity: "note-mention",
|
|
118
165
|
rows: notes.map((note) => ({
|
|
119
|
-
noteId: note
|
|
120
|
-
entityType: note
|
|
121
|
-
entityId: note
|
|
122
|
-
body: note
|
|
123
|
-
insertedAt: note
|
|
166
|
+
noteId: note.id,
|
|
167
|
+
entityType: note.entityType,
|
|
168
|
+
entityId: note.entityId,
|
|
169
|
+
body: note.body,
|
|
170
|
+
insertedAt: note.insertedAt,
|
|
124
171
|
})),
|
|
125
172
|
};
|
|
126
173
|
};
|
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
noteMentionExportHook,
|
|
14
14
|
} from "./hooks.js";
|
|
15
15
|
|
|
16
|
+
export { eraseNoteBodiesReachingSubject } from "./hooks.js";
|
|
17
|
+
|
|
16
18
|
export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
|
|
17
19
|
r.describe(
|
|
18
|
-
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook
|
|
20
|
+
"GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
|
|
19
21
|
);
|
|
20
22
|
// user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
|
|
21
23
|
// is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
|
|
@@ -60,11 +60,13 @@ export const patListScreen: ProjectionListScreenDefinition = {
|
|
|
60
60
|
{
|
|
61
61
|
field: "createdAt",
|
|
62
62
|
label: i18nKey("pat.list.col.created"),
|
|
63
|
+
hideOnNarrow: true,
|
|
63
64
|
renderer: { format: "timestamp" },
|
|
64
65
|
},
|
|
65
66
|
{
|
|
66
67
|
field: "expiresAt",
|
|
67
68
|
label: i18nKey("pat.list.col.expires"),
|
|
69
|
+
hideOnNarrow: true,
|
|
68
70
|
renderer: { format: "timestamp" },
|
|
69
71
|
},
|
|
70
72
|
],
|
|
@@ -48,6 +48,7 @@ export const createSessionListScreen = (
|
|
|
48
48
|
{
|
|
49
49
|
field: "revokedAt",
|
|
50
50
|
label: i18nKey("sessions.list.col.revokedAt"),
|
|
51
|
+
hideOnNarrow: true,
|
|
51
52
|
renderer: { format: "timestamp" },
|
|
52
53
|
},
|
|
53
54
|
],
|
|
@@ -123,6 +124,7 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
|
123
124
|
{
|
|
124
125
|
field: "current",
|
|
125
126
|
label: i18nKey("sessions.mine.col.current"),
|
|
127
|
+
hideOnNarrow: true,
|
|
126
128
|
renderer: { format: "boolean" },
|
|
127
129
|
},
|
|
128
130
|
],
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The member roles form only offers roles the signed-in admin may assign",
|
|
6
|
+
"detail": "New query tenant:query:member-role-availability; TenantAdmin still sees every role."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -26,9 +26,10 @@ export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
|
|
|
26
26
|
// Admin (rank 2) shares the invite-create screen with TenantAdmin — TenantAdmin
|
|
27
27
|
// must not appear here or Admin sees an unassignable option (fw#2414).
|
|
28
28
|
export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"] as const;
|
|
29
|
-
// Full
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// Full rank list for memberRolesEditScreen: it is prefilled with a member's
|
|
30
|
+
// *current* roles, so it must represent every rank a membership can hold,
|
|
31
|
+
// including TenantAdmin (fw#2452). The screen hides what the caller may not
|
|
32
|
+
// assign via TenantQueries.memberRoleAvailability.
|
|
32
33
|
export const OWNER_INVITE_ROLE_OPTIONS = [
|
|
33
34
|
"User",
|
|
34
35
|
"Member",
|
|
@@ -67,6 +68,8 @@ export const TenantQueries = {
|
|
|
67
68
|
invitations: "tenant:query:invitations",
|
|
68
69
|
// Combined active-members + pending-invitations list backing /members.
|
|
69
70
|
teamList: "tenant:query:team:list",
|
|
71
|
+
// Per-role assignability for the caller, mirrors the rank rule of updateMemberRoles.
|
|
72
|
+
memberRoleAvailability: "tenant:query:member-role-availability",
|
|
70
73
|
} as const;
|
|
71
74
|
|
|
72
75
|
// Error codes
|
|
@@ -20,6 +20,7 @@ import { invitationsQuery } from "./handlers/invitations.query.js";
|
|
|
20
20
|
import { listQuery } from "./handlers/list.query.js";
|
|
21
21
|
import { meQuery } from "./handlers/me.query.js";
|
|
22
22
|
import { memberDirectoryQuery } from "./handlers/member-directory.query.js";
|
|
23
|
+
import { memberRoleAvailabilityQuery } from "./handlers/member-role-availability.query.js";
|
|
23
24
|
import { membersQuery } from "./handlers/members.query.js";
|
|
24
25
|
import { membershipsQuery } from "./handlers/memberships.query.js";
|
|
25
26
|
import { removeMemberWrite } from "./handlers/remove-member.write.js";
|
|
@@ -166,6 +167,7 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
|
|
|
166
167
|
resolveUserIds: r.queryHandler(resolveUserIdsQuery),
|
|
167
168
|
invitations: r.queryHandler(invitationsQuery),
|
|
168
169
|
teamList: r.queryHandler(teamListQuery),
|
|
170
|
+
memberRoleAvailability: r.queryHandler(memberRoleAvailabilityQuery),
|
|
169
171
|
};
|
|
170
172
|
|
|
171
173
|
// Entity-convention handlers for the SystemAdmin entityList/entityEdit
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
assignableAppRolesOf,
|
|
4
|
+
canActorAssignRole,
|
|
5
|
+
defineQueryHandler,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { memberRoleOptions } from "../member-role-options.js";
|
|
9
|
+
|
|
10
|
+
export const memberRoleAvailabilityQuery = defineQueryHandler({
|
|
11
|
+
name: "member-role-availability",
|
|
12
|
+
schema: z.object({}),
|
|
13
|
+
access: { roles: access.admin },
|
|
14
|
+
description:
|
|
15
|
+
"Lists every role the member roles form offers and whether the caller's own rank may assign it; the form hides the roles marked disabled.",
|
|
16
|
+
handler: async (query, ctx) => {
|
|
17
|
+
const assignableAppRoles = assignableAppRolesOf(ctx.registry);
|
|
18
|
+
return {
|
|
19
|
+
rows: memberRoleOptions(assignableAppRoles).map((value) => ({
|
|
20
|
+
value,
|
|
21
|
+
disabled: !canActorAssignRole(query.user.roles, value, assignableAppRoles),
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AssignableAppRoles } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { OWNER_INVITE_ROLE_OPTIONS } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
export function memberRoleOptions(assignableAppRoles: AssignableAppRoles): readonly string[] {
|
|
5
|
+
return [...OWNER_INVITE_ROLE_OPTIONS, ...assignableAppRoles.keys()];
|
|
6
|
+
}
|
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
MEMBER_ROLES_EDIT_SCREEN_ID,
|
|
16
16
|
MEMBER_STATUS_CELL_COMPONENT,
|
|
17
17
|
MEMBERS_SCREEN_ID,
|
|
18
|
-
OWNER_INVITE_ROLE_OPTIONS,
|
|
19
18
|
TenantHandlers,
|
|
20
19
|
TenantQueries,
|
|
21
20
|
} from "./constants.js";
|
|
21
|
+
import { memberRoleOptions } from "./member-role-options.js";
|
|
22
22
|
|
|
23
23
|
// Cross-tenant SystemAdmin platform view of the tenants themselves. The tenant
|
|
24
24
|
// feature runs with `r.systemScope()`, so the entityList returns every tenant.
|
|
@@ -107,6 +107,7 @@ export function createMembersScreen(options?: {
|
|
|
107
107
|
field: "lastSeenAt",
|
|
108
108
|
label: i18nKey("tenant.members.col.lastActivity"),
|
|
109
109
|
renderer: { format: "timestamp" },
|
|
110
|
+
hideOnNarrow: true,
|
|
110
111
|
},
|
|
111
112
|
],
|
|
112
113
|
defaultSort: { field: "createdAt", dir: "desc" },
|
|
@@ -199,13 +200,15 @@ export function createMemberRolesEditScreen(assignableAppRoles: AssignableAppRol
|
|
|
199
200
|
cancelTarget: MEMBERS_SCREEN_ID,
|
|
200
201
|
fields: {
|
|
201
202
|
userId: { type: "text", required: true },
|
|
202
|
-
// Prefilled with the member's current roles, so the
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
203
|
+
// Prefilled with the member's current roles, so the static options must
|
|
204
|
+
// represent every rank (DEFAULT_INVITE_ROLE_OPTIONS omits TenantAdmin and
|
|
205
|
+
// would silently strip it on submit); the availability query hides what the
|
|
206
|
+
// caller may not assign. Enforcement stays server-side via
|
|
207
|
+
// findForbiddenRoleAssignment (update-member-roles.write.ts).
|
|
206
208
|
roles: {
|
|
207
209
|
type: "multiSelect",
|
|
208
|
-
options:
|
|
210
|
+
options: memberRoleOptions(assignableAppRoles),
|
|
211
|
+
optionsAvailabilityQuery: TenantQueries.memberRoleAvailability,
|
|
209
212
|
required: true,
|
|
210
213
|
},
|
|
211
214
|
},
|
|
@@ -17,7 +17,14 @@ export const userListScreen: EntityListScreenDefinition = {
|
|
|
17
17
|
id: "user-list",
|
|
18
18
|
type: "entityList",
|
|
19
19
|
entity: "user",
|
|
20
|
-
columns: [
|
|
20
|
+
columns: [
|
|
21
|
+
"email",
|
|
22
|
+
"displayName",
|
|
23
|
+
"roles",
|
|
24
|
+
"tenants",
|
|
25
|
+
"status",
|
|
26
|
+
{ field: "emailVerified", hideOnNarrow: true },
|
|
27
|
+
],
|
|
21
28
|
rowActions: [
|
|
22
29
|
{
|
|
23
30
|
kind: "navigate",
|
|
@@ -68,7 +68,14 @@ export const downloadAttemptListScreen: EntityListScreenDefinition = {
|
|
|
68
68
|
id: "download-attempt-list",
|
|
69
69
|
type: "entityList",
|
|
70
70
|
entity: "download-attempt",
|
|
71
|
-
columns: [
|
|
71
|
+
columns: [
|
|
72
|
+
"attemptedAt",
|
|
73
|
+
"result",
|
|
74
|
+
"via",
|
|
75
|
+
"ip",
|
|
76
|
+
{ field: "attemptedByUserId", hideOnNarrow: true },
|
|
77
|
+
{ field: "jobId", hideOnNarrow: true },
|
|
78
|
+
],
|
|
72
79
|
searchable: false,
|
|
73
80
|
access: { roles: access.systemAdmin },
|
|
74
81
|
// Inert until an app navs it (see file header) — no nav area to resolve
|
|
@@ -6,7 +6,14 @@ export const waitlistListScreen: EntityListScreenDefinition = {
|
|
|
6
6
|
id: "waitlist-list",
|
|
7
7
|
type: "entityList",
|
|
8
8
|
entity: "waitlistEntry",
|
|
9
|
-
columns: [
|
|
9
|
+
columns: [
|
|
10
|
+
"name",
|
|
11
|
+
"email",
|
|
12
|
+
"company",
|
|
13
|
+
"status",
|
|
14
|
+
"locale",
|
|
15
|
+
{ field: "submittedAt", hideOnNarrow: true },
|
|
16
|
+
],
|
|
10
17
|
defaultSort: { field: "submittedAt", dir: "desc" },
|
|
11
18
|
access: adminAccess,
|
|
12
19
|
searchable: true,
|
|
@@ -116,10 +116,10 @@ export const assetListScreen: EntityListScreenDefinition = {
|
|
|
116
116
|
"status",
|
|
117
117
|
"department",
|
|
118
118
|
"owner",
|
|
119
|
-
"location",
|
|
120
|
-
"vendor",
|
|
121
|
-
"purchaseDate",
|
|
122
|
-
"warrantyUntil",
|
|
119
|
+
{ field: "location", hideOnNarrow: true },
|
|
120
|
+
{ field: "vendor", hideOnNarrow: true },
|
|
121
|
+
{ field: "purchaseDate", hideOnNarrow: true },
|
|
122
|
+
{ field: "warrantyUntil", hideOnNarrow: true },
|
|
123
123
|
],
|
|
124
124
|
pageSize: 25,
|
|
125
125
|
defaultSort: { field: "name", dir: "asc" },
|
|
@@ -123,10 +123,10 @@ export const ticketListScreen: EntityListScreenDefinition = {
|
|
|
123
123
|
"severity",
|
|
124
124
|
"status",
|
|
125
125
|
"department",
|
|
126
|
-
"assignee",
|
|
127
|
-
"reporter",
|
|
128
|
-
"dueDate",
|
|
129
|
-
"spentMinutes",
|
|
126
|
+
{ field: "assignee", hideOnNarrow: true },
|
|
127
|
+
{ field: "reporter", hideOnNarrow: true },
|
|
128
|
+
{ field: "dueDate", hideOnNarrow: true },
|
|
129
|
+
{ field: "spentMinutes", hideOnNarrow: true },
|
|
130
130
|
],
|
|
131
131
|
pageSize: 25,
|
|
132
132
|
defaultSort: { field: "severity", dir: "desc" },
|
|
@@ -102,7 +102,7 @@ export const itemListScreen: EntityListScreenDefinition = {
|
|
|
102
102
|
"dueDate",
|
|
103
103
|
// Tier 2.7e-4 Demo — Reference-Spalte. Renderer macht Bulk-Lookup
|
|
104
104
|
// auf item:list (limit:200), zeigt parentId als parent.title.
|
|
105
|
-
"parentId",
|
|
105
|
+
{ field: "parentId", hideOnNarrow: true },
|
|
106
106
|
],
|
|
107
107
|
// Server-side Pagination Demo — Showcase seedet ~200 items, der
|
|
108
108
|
// Pager hat 4 Seiten zum Durchklicken bei pageSize: 50.
|
|
@@ -228,9 +228,9 @@ export const demoFeature = defineFeature("styleguide", (r) => {
|
|
|
228
228
|
"status",
|
|
229
229
|
"isActive",
|
|
230
230
|
"quantity",
|
|
231
|
-
"rating",
|
|
232
|
-
"publishedAt",
|
|
233
|
-
"price",
|
|
231
|
+
{ field: "rating", hideOnNarrow: true },
|
|
232
|
+
{ field: "publishedAt", hideOnNarrow: true },
|
|
233
|
+
{ field: "price", hideOnNarrow: true },
|
|
234
234
|
],
|
|
235
235
|
pagination: "pages",
|
|
236
236
|
pageSize: 25,
|
|
@@ -331,7 +331,7 @@ export function Gallery(): ReactNode {
|
|
|
331
331
|
{/* Standalone <Section> (outside Form) = the calculator-result-card
|
|
332
332
|
standard. title-only = existing consumer (publicstatus/tier-admin),
|
|
333
333
|
must NOT regress from the border-b removal; subtitle +
|
|
334
|
-
actions
|
|
334
|
+
actions (title row, top right) = new. No divider under the title (shadcn pattern). */}
|
|
335
335
|
<div className="flex max-w-md flex-col gap-6">
|
|
336
336
|
<Section title="Affordable rate / month">
|
|
337
337
|
<div className="text-2xl font-semibold tabular-nums">€2,500.00</div>
|
|
@@ -27,7 +27,7 @@ const OPEN_FILL_COLUMNS = [
|
|
|
27
27
|
"einheit",
|
|
28
28
|
"liegenschaft",
|
|
29
29
|
"beginn",
|
|
30
|
-
"ende",
|
|
30
|
+
{ field: "ende", hideOnNarrow: true },
|
|
31
31
|
"status",
|
|
32
32
|
] as const;
|
|
33
33
|
|
|
@@ -145,7 +145,11 @@ const POSITION_COLUMNS = [
|
|
|
145
145
|
label: "rental:entity:leasePosition:field:gueltigVon",
|
|
146
146
|
sortable: true,
|
|
147
147
|
},
|
|
148
|
-
{
|
|
148
|
+
{
|
|
149
|
+
field: "gueltigBis",
|
|
150
|
+
label: "rental:entity:leasePosition:field:gueltigBis",
|
|
151
|
+
hideOnNarrow: true,
|
|
152
|
+
},
|
|
149
153
|
] as const;
|
|
150
154
|
|
|
151
155
|
export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
|
|
@@ -31,7 +31,14 @@ export const vehicleListScreen: EntityListScreenDefinition = {
|
|
|
31
31
|
id: "vehicle-list",
|
|
32
32
|
type: "entityList",
|
|
33
33
|
entity: "vehicle",
|
|
34
|
-
columns: [
|
|
34
|
+
columns: [
|
|
35
|
+
"marke",
|
|
36
|
+
"modell",
|
|
37
|
+
"baujahr",
|
|
38
|
+
"preis",
|
|
39
|
+
"zustand",
|
|
40
|
+
{ field: "scheckheftGepflegt", hideOnNarrow: true },
|
|
41
|
+
],
|
|
35
42
|
searchPlaceholder: "vehicles.search.placeholder",
|
|
36
43
|
defaultSort: { field: "marke", dir: "asc" },
|
|
37
44
|
rowActions: [
|
|
@@ -129,7 +136,7 @@ export const campaignListScreen: EntityListScreenDefinition = {
|
|
|
129
136
|
"aktionStartet",
|
|
130
137
|
"aktionEndet",
|
|
131
138
|
"meta",
|
|
132
|
-
"gepostet",
|
|
139
|
+
{ field: "gepostet", hideOnNarrow: true },
|
|
133
140
|
{ field: "gestartetAm", hideOnNarrow: true },
|
|
134
141
|
],
|
|
135
142
|
searchPlaceholder: "vehicles.search.placeholder",
|
|
@@ -1954,7 +1954,7 @@
|
|
|
1954
1954
|
},
|
|
1955
1955
|
{
|
|
1956
1956
|
"name": "notes-history-user-data",
|
|
1957
|
-
"description": "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook
|
|
1957
|
+
"description": "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
|
|
1958
1958
|
"toggleableDefault": null,
|
|
1959
1959
|
"requires": [
|
|
1960
1960
|
"user-data-rights"
|