@cosmicdrift/kumiko-samples 0.355.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/compliance-profiles/handlers/set-profile.write.ts +18 -24
- 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/custom-fields/handlers/define-system-field.write.ts +10 -17
- package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +5 -2
- package/packages/bundled-features/src/custom-fields/lib/define-or-resurrect.ts +12 -3
- 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/managed-pages/handlers/set.write.ts +8 -25
- 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/shared/index.ts +1 -0
- package/packages/bundled-features/src/shared/require-for-tenant.ts +18 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +6 -4
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +7 -7
- package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +7 -6
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +7 -4
- package/packages/bundled-features/src/tenant/cancel-pending-invitation.ts +3 -1
- 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/handlers/remove-member.write.ts +22 -8
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +19 -11
- 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/tier-engine/handlers/set-tenant-tier.write.ts +9 -23
- 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
|
@@ -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
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { createEventStoreExecutor
|
|
2
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
5
6
|
import { type PageRow, pageEntity, pagesTable } from "../table.js";
|
|
6
7
|
|
|
7
8
|
const slugSchema = z
|
|
@@ -18,9 +19,6 @@ const langSchema = z
|
|
|
18
19
|
|
|
19
20
|
const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
|
|
20
21
|
|
|
21
|
-
const SET_PAGE_TENANT_OVERRIDE_REASON =
|
|
22
|
-
"SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
|
|
23
|
-
|
|
24
22
|
// Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
|
|
25
23
|
// Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
|
|
26
24
|
// für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
|
|
@@ -43,29 +41,12 @@ export const setWrite = defineWriteHandler({
|
|
|
43
41
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
44
42
|
description:
|
|
45
43
|
"Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
|
|
46
|
-
escapeHatch: {
|
|
47
|
-
grants: ["unsafeRaw"],
|
|
48
|
-
reason: SET_PAGE_TENANT_OVERRIDE_REASON,
|
|
49
|
-
},
|
|
50
44
|
handler: async (event, ctx) => {
|
|
51
|
-
const db = ctx.db;
|
|
52
45
|
const override = event.payload.tenantIdOverride;
|
|
53
|
-
const tenantId = override ?? event.user.tenantId;
|
|
54
|
-
// override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
|
|
55
|
-
const executorUser =
|
|
56
|
-
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
|
|
57
|
-
|
|
46
|
+
const tenantId = (override ?? event.user.tenantId) as TenantId; // @cast-boundary engine-bridge
|
|
58
47
|
// ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
|
|
59
48
|
// on both the existing-check and the executor's stream reads; re-scope to the target.
|
|
60
|
-
|
|
61
|
-
const scopedDb =
|
|
62
|
-
override !== undefined
|
|
63
|
-
? createTenantDb(
|
|
64
|
-
db.unsafeRaw(),
|
|
65
|
-
override as TenantId, // @cast-boundary engine-bridge
|
|
66
|
-
"tenant",
|
|
67
|
-
)
|
|
68
|
-
: db;
|
|
49
|
+
const { db: scopedDb, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
69
50
|
const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
|
|
70
51
|
tenantId,
|
|
71
52
|
slug: event.payload.slug,
|
|
@@ -89,8 +70,9 @@ export const setWrite = defineWriteHandler({
|
|
|
89
70
|
event.payload.published !== undefined ? event.payload.published : existing.published,
|
|
90
71
|
},
|
|
91
72
|
},
|
|
92
|
-
|
|
73
|
+
event.user,
|
|
93
74
|
scopedDb,
|
|
75
|
+
{ streamTenantId },
|
|
94
76
|
);
|
|
95
77
|
if (!result.isSuccess) return result;
|
|
96
78
|
return {
|
|
@@ -110,8 +92,9 @@ export const setWrite = defineWriteHandler({
|
|
|
110
92
|
published: event.payload.published ?? false,
|
|
111
93
|
tenantId,
|
|
112
94
|
},
|
|
113
|
-
|
|
95
|
+
event.user,
|
|
114
96
|
scopedDb,
|
|
97
|
+
{ streamTenantId },
|
|
115
98
|
);
|
|
116
99
|
if (!result.isSuccess) return result;
|
|
117
100
|
return {
|
|
@@ -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
|
],
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HandlerContext,
|
|
3
|
+
TenantId,
|
|
4
|
+
TenantWriteTarget,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
|
|
8
|
+
// ctx.forTenant is absent for r.systemScope() handlers and without a db; a write handler that
|
|
9
|
+
// needs a cross-tenant target must fail loudly instead of falling back to ctx.db.
|
|
10
|
+
export function requireForTenant(
|
|
11
|
+
ctx: Pick<HandlerContext, "forTenant">,
|
|
12
|
+
targetTenantId: TenantId,
|
|
13
|
+
): TenantWriteTarget {
|
|
14
|
+
if (!ctx.forTenant) {
|
|
15
|
+
throw new InternalError({ message: "ctx.forTenant is not available in this handler context" });
|
|
16
|
+
}
|
|
17
|
+
return ctx.forTenant(targetTenantId);
|
|
18
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type TenantId,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
8
9
|
import {
|
|
9
10
|
type CollectionEntryRow,
|
|
10
11
|
collectionStore,
|
|
@@ -39,12 +40,11 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
|
|
|
39
40
|
? `Creates or overwrites the caller's own "${collection.id}" entry at a slug and locale, live on save with no draft stage; it always writes the acting user's entry, never another user's.`
|
|
40
41
|
: `Creates or overwrites the tenant-wide "${collection.id}" entry at a slug and locale, live on save with no draft stage; the change is visible to everyone who may reach this shared collection.`,
|
|
41
42
|
handler: async (event, ctx) => {
|
|
42
|
-
const db = ctx.db;
|
|
43
43
|
const override = event.payload.tenantIdOverride;
|
|
44
44
|
// @cast-boundary engine-payload — override is a zod-validated string, the
|
|
45
45
|
// user's own tenantId is already TenantId-branded.
|
|
46
46
|
const tenantId = (override ?? event.user.tenantId) as TenantId;
|
|
47
|
-
const
|
|
47
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
48
48
|
// Scoped to the acting user even under a tenant override: a SystemAdmin
|
|
49
49
|
// writing into another tenant still writes their own entry, never
|
|
50
50
|
// someone else's signature.
|
|
@@ -73,8 +73,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
|
|
|
73
73
|
folder: event.payload.folder ?? null,
|
|
74
74
|
},
|
|
75
75
|
},
|
|
76
|
-
|
|
76
|
+
event.user,
|
|
77
77
|
db,
|
|
78
|
+
{ streamTenantId },
|
|
78
79
|
);
|
|
79
80
|
if (!result.isSuccess) return result;
|
|
80
81
|
return {
|
|
@@ -96,8 +97,9 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
|
|
|
96
97
|
...store.createDefaults(tenantId),
|
|
97
98
|
...owner,
|
|
98
99
|
},
|
|
99
|
-
|
|
100
|
+
event.user,
|
|
100
101
|
db,
|
|
102
|
+
{ streamTenantId },
|
|
101
103
|
);
|
|
102
104
|
if (!result.isSuccess) return result;
|
|
103
105
|
return {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type TenantId,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
8
9
|
import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants.js";
|
|
9
10
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
10
11
|
import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.js";
|
|
@@ -48,15 +49,11 @@ export const setWrite = defineWriteHandler({
|
|
|
48
49
|
description:
|
|
49
50
|
"Creates or overwrites one content-tree resource addressed by slug, kind and locale, carrying a title and folder and going live on save with no draft stage; a SystemAdmin may target the system tenant, where shared legal and marketing copy lives.",
|
|
50
51
|
handler: async (event, ctx) => {
|
|
51
|
-
const db = ctx.db;
|
|
52
52
|
const override = event.payload.tenantIdOverride;
|
|
53
53
|
// @cast-boundary engine-payload — override is a zod-validated string, the
|
|
54
54
|
// user's own tenantId is already TenantId-branded.
|
|
55
55
|
const tenantId = (override ?? event.user.tenantId) as TenantId;
|
|
56
|
-
|
|
57
|
-
// otherwise the event-store stream lookup runs against event.user.tenantId
|
|
58
|
-
// and reports a version conflict although the projection row exists.
|
|
59
|
-
const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
|
|
56
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
60
57
|
|
|
61
58
|
const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
|
|
62
59
|
const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
@@ -98,8 +95,9 @@ export const setWrite = defineWriteHandler({
|
|
|
98
95
|
folder: fields.folder,
|
|
99
96
|
},
|
|
100
97
|
},
|
|
101
|
-
|
|
98
|
+
event.user,
|
|
102
99
|
db,
|
|
100
|
+
{ streamTenantId },
|
|
103
101
|
);
|
|
104
102
|
if (!result.isSuccess) return result;
|
|
105
103
|
return {
|
|
@@ -108,7 +106,9 @@ export const setWrite = defineWriteHandler({
|
|
|
108
106
|
};
|
|
109
107
|
}
|
|
110
108
|
|
|
111
|
-
const result = await executor.create({ ...fields, tenantId },
|
|
109
|
+
const result = await executor.create({ ...fields, tenantId }, event.user, db, {
|
|
110
|
+
streamTenantId,
|
|
111
|
+
});
|
|
112
112
|
if (!result.isSuccess) return result;
|
|
113
113
|
return {
|
|
114
114
|
isSuccess: true as const,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
SYSTEM_TENANT_ID,
|
|
5
5
|
type TenantId,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
7
8
|
import type { TemplateResourceRow } from "../table.js";
|
|
8
9
|
import { templateResourcesTable } from "../table.js";
|
|
9
10
|
import { executor, upsertPayloadSchema } from "./shared.js";
|
|
@@ -19,13 +20,10 @@ export const upsertSystemWrite = defineWriteHandler({
|
|
|
19
20
|
description:
|
|
20
21
|
"Creates or overwrites a system-default template under the system tenant with its full variable schema and linked resources, active straight away with no draft stage; SystemAdmin only, since tenants shadow these defaults through upsert-tenant instead.",
|
|
21
22
|
handler: async (event, ctx) => {
|
|
22
|
-
const db = ctx.db;
|
|
23
23
|
// @cast-boundary engine-payload — SYSTEM_TENANT_ID is a UUID literal,
|
|
24
24
|
// asserted to the branded TenantId (parseTenantId equivalent).
|
|
25
25
|
const tenantId = SYSTEM_TENANT_ID as TenantId;
|
|
26
|
-
|
|
27
|
-
// event store looks up the stream under user.tenantId → conflict.
|
|
28
|
-
const executorUser = { ...event.user, tenantId };
|
|
26
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
29
27
|
|
|
30
28
|
const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
31
29
|
tenantId,
|
|
@@ -53,8 +51,9 @@ export const upsertSystemWrite = defineWriteHandler({
|
|
|
53
51
|
if (existing) {
|
|
54
52
|
const result = await executor.update(
|
|
55
53
|
{ id: existing.id, version: existing.version, changes: fields },
|
|
56
|
-
|
|
54
|
+
event.user,
|
|
57
55
|
db,
|
|
56
|
+
{ streamTenantId },
|
|
58
57
|
);
|
|
59
58
|
if (!result.isSuccess) return result;
|
|
60
59
|
return {
|
|
@@ -63,7 +62,9 @@ export const upsertSystemWrite = defineWriteHandler({
|
|
|
63
62
|
};
|
|
64
63
|
}
|
|
65
64
|
|
|
66
|
-
const result = await executor.create({ ...fields, tenantId },
|
|
65
|
+
const result = await executor.create({ ...fields, tenantId }, event.user, db, {
|
|
66
|
+
streamTenantId,
|
|
67
|
+
});
|
|
67
68
|
if (!result.isSuccess) return result;
|
|
68
69
|
// @cast-boundary db-row — executor.create returnt Record-row aus
|
|
69
70
|
// INSERT RETURNING; shape { id } ist garantiert weil PK in der
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
+
import { requireForTenant } from "../../shared/index.js";
|
|
9
10
|
import type { TemplateResourceRow } from "../table.js";
|
|
10
11
|
import { templateResourcesTable } from "../table.js";
|
|
11
12
|
import { executor, upsertPayloadSchema } from "./shared.js";
|
|
@@ -25,7 +26,6 @@ export const upsertTenantWrite = defineWriteHandler({
|
|
|
25
26
|
description:
|
|
26
27
|
"Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live; it refuses a system-tenant target, which is what upsert-system is for.",
|
|
27
28
|
handler: async (event, ctx) => {
|
|
28
|
-
const db = ctx.db;
|
|
29
29
|
const override = event.payload.tenantIdOverride;
|
|
30
30
|
// upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
|
|
31
31
|
// scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
|
|
@@ -43,7 +43,7 @@ export const upsertTenantWrite = defineWriteHandler({
|
|
|
43
43
|
// event.user.tenantId schon TenantId-branded; union als TenantId casten
|
|
44
44
|
// ist legit (override ist UUID-Format-validiert in schema).
|
|
45
45
|
const tenantId = (override ?? event.user.tenantId) as TenantId;
|
|
46
|
-
const
|
|
46
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
47
47
|
|
|
48
48
|
const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
49
49
|
tenantId,
|
|
@@ -70,8 +70,9 @@ export const upsertTenantWrite = defineWriteHandler({
|
|
|
70
70
|
if (existing) {
|
|
71
71
|
const result = await executor.update(
|
|
72
72
|
{ id: existing.id, version: existing.version, changes: fields },
|
|
73
|
-
|
|
73
|
+
event.user,
|
|
74
74
|
db,
|
|
75
|
+
{ streamTenantId },
|
|
75
76
|
);
|
|
76
77
|
if (!result.isSuccess) return result;
|
|
77
78
|
return {
|
|
@@ -80,7 +81,9 @@ export const upsertTenantWrite = defineWriteHandler({
|
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
const result = await executor.create({ ...fields, tenantId },
|
|
84
|
+
const result = await executor.create({ ...fields, tenantId }, event.user, db, {
|
|
85
|
+
streamTenantId,
|
|
86
|
+
});
|
|
84
87
|
if (!result.isSuccess) return result;
|
|
85
88
|
// @cast-boundary db-row — executor.create returnt Record-row aus
|
|
86
89
|
// INSERT RETURNING; shape { id } ist garantiert weil PK in der
|