@cosmicdrift/kumiko-bundled-features 0.60.3 → 0.63.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 +2 -1
- package/src/tags/__tests__/feature.test.ts +26 -0
- package/src/tags/constants.ts +6 -0
- package/src/tags/feature.ts +27 -6
- package/src/tags/web/__tests__/tag-section.test.tsx +126 -0
- package/src/tags/web/client-plugin.tsx +21 -0
- package/src/tags/web/i18n.ts +25 -0
- package/src/tags/web/index.ts +4 -0
- package/src/tags/web/tag-section.tsx +204 -0
- package/src/user/schema/user.ts +11 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +94 -4
- package/src/user-data-rights/deletion-token.ts +32 -11
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +4 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -12
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.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>",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./custom-fields": "./src/custom-fields/index.ts",
|
|
32
32
|
"./custom-fields/web": "./src/custom-fields/web/index.ts",
|
|
33
33
|
"./tags": "./src/tags/index.ts",
|
|
34
|
+
"./tags/web": "./src/tags/web/index.ts",
|
|
34
35
|
"./billing-foundation": "./src/billing-foundation/index.ts",
|
|
35
36
|
"./subscription-stripe": "./src/subscription-stripe/index.ts",
|
|
36
37
|
"./subscription-mollie": "./src/subscription-mollie/index.ts",
|
|
@@ -111,6 +111,32 @@ describe("createTagsFeature access-options", () => {
|
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
describe("createTagsFeature toggleable-option (tier-gating)", () => {
|
|
115
|
+
test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
|
|
116
|
+
expect(createTagsFeature().toggleableDefault).toBeUndefined();
|
|
117
|
+
expect(createTagsFeature({ access: { openToAll: true } }).toggleableDefault).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
|
|
121
|
+
const feature = createTagsFeature({
|
|
122
|
+
access: { openToAll: true },
|
|
123
|
+
toggleable: { default: false },
|
|
124
|
+
});
|
|
125
|
+
expect(feature.toggleableDefault).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("toggleable:{default:true} declares toggleable, enabled-by-default", () => {
|
|
129
|
+
expect(createTagsFeature({ toggleable: { default: true } }).toggleableDefault).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("toggleable alone (no access/roles) builds a fresh, non-singleton feature", () => {
|
|
133
|
+
const feature = createTagsFeature({ toggleable: { default: false } });
|
|
134
|
+
expect(feature).not.toBe(createTagsFeature());
|
|
135
|
+
// access still defaults when only toggleable is set
|
|
136
|
+
expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
114
140
|
describe("createTagPayloadSchema", () => {
|
|
115
141
|
test("accepts name only", () => {
|
|
116
142
|
expect(createTagPayloadSchema.safeParse({ name: "Kunde Müller" }).success).toBe(true);
|
package/src/tags/constants.ts
CHANGED
|
@@ -8,6 +8,12 @@ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
8
8
|
|
|
9
9
|
export const TAGS_FEATURE_NAME = "tags";
|
|
10
10
|
|
|
11
|
+
// Registry name for the drop-in <TagSection> component. Apps reference it in a
|
|
12
|
+
// screen schema via `component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } }`
|
|
13
|
+
// after mounting tagsClient(); the component is also importable directly for
|
|
14
|
+
// standalone use from `@cosmicdrift/kumiko-bundled-features/tags/web`.
|
|
15
|
+
export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
|
|
16
|
+
|
|
11
17
|
// Qualified handler names (QN format: scope:type:name). Clients reference the
|
|
12
18
|
// object instead of magic strings (mirror custom-fields' Handlers/Queries).
|
|
13
19
|
export const TagsHandlers = {
|
package/src/tags/feature.ts
CHANGED
|
@@ -28,11 +28,27 @@ import { createAssignTagHandler } from "./handlers/assign-tag.write";
|
|
|
28
28
|
import { createCreateTagHandler } from "./handlers/create-tag.write";
|
|
29
29
|
import { createRemoveTagHandler } from "./handlers/remove-tag.write";
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
|
|
32
|
+
// dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
|
|
33
|
+
// (handlers, queries, hooks) on/off per tenant — no host-side hook. `default`
|
|
34
|
+
// is the enablement when no toggle row / tier override exists. For a tier-gated
|
|
35
|
+
// feature use { default: false } (fail-closed) and list the feature name in the
|
|
36
|
+
// entitling tiers' TierMap; tenants below it get every tag path disabled.
|
|
37
|
+
type TagsToggleable = { readonly default: boolean };
|
|
38
|
+
|
|
39
|
+
function registerTags(
|
|
40
|
+
r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>,
|
|
41
|
+
access: AccessRule,
|
|
42
|
+
toggleable: TagsToggleable | undefined,
|
|
43
|
+
): void {
|
|
32
44
|
r.describe(
|
|
33
|
-
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }).",
|
|
45
|
+
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
34
46
|
);
|
|
35
47
|
|
|
48
|
+
// Tier-gating is a framework concern, not a per-app hook: declaring the
|
|
49
|
+
// feature toggleable lets tier-engine/feature-toggles cut it per tenant.
|
|
50
|
+
if (toggleable !== undefined) r.toggleable(toggleable);
|
|
51
|
+
|
|
36
52
|
r.entity("tag", tagEntity);
|
|
37
53
|
r.entity("tag-assignment", tagAssignmentEntity);
|
|
38
54
|
|
|
@@ -45,7 +61,7 @@ function registerTags(r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>, access: Acc
|
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
48
|
-
registerTags(r, DEFAULT_TAG_ACCESS),
|
|
64
|
+
registerTags(r, DEFAULT_TAG_ACCESS, undefined),
|
|
49
65
|
);
|
|
50
66
|
|
|
51
67
|
export type TagsFeatureOptions = {
|
|
@@ -56,6 +72,11 @@ export type TagsFeatureOptions = {
|
|
|
56
72
|
readonly access?: AccessRule;
|
|
57
73
|
/** Shorthand for { access: { roles } }. Ignored when `access` is set. */
|
|
58
74
|
readonly roles?: readonly string[];
|
|
75
|
+
/** Make the whole feature tier-gatable: declares r.toggleable so the
|
|
76
|
+
* tier-engine/feature-toggles can enable/disable every tag path per tenant.
|
|
77
|
+
* `default` applies when no toggle/tier override exists — use { default: false }
|
|
78
|
+
* for fail-closed tier-gating. Omit to keep tags always-on (default). */
|
|
79
|
+
readonly toggleable?: TagsToggleable;
|
|
59
80
|
};
|
|
60
81
|
|
|
61
82
|
function resolveAccess(opts: TagsFeatureOptions): AccessRule {
|
|
@@ -65,11 +86,11 @@ function resolveAccess(opts: TagsFeatureOptions): AccessRule {
|
|
|
65
86
|
}
|
|
66
87
|
|
|
67
88
|
// Backwards-compat / options wrapper. Without options returns the module-level
|
|
68
|
-
// singleton (no rebuild). access/roles build a fresh feature-definition.
|
|
89
|
+
// singleton (no rebuild). access/roles/toggleable build a fresh feature-definition.
|
|
69
90
|
export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFeature {
|
|
70
|
-
if (opts.access === undefined && opts.roles === undefined) {
|
|
91
|
+
if (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
|
|
71
92
|
return tagsFeature;
|
|
72
93
|
}
|
|
73
94
|
const access = resolveAccess(opts);
|
|
74
|
-
return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r, access));
|
|
95
|
+
return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r, access, opts.toggleable));
|
|
75
96
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { TagsHandlers, TagsQueries } from "../../constants";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
12
|
+
import { TagSection } from "../tag-section";
|
|
13
|
+
|
|
14
|
+
type TagRow = { id: string; name: string };
|
|
15
|
+
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
16
|
+
|
|
17
|
+
let catalogRows: readonly TagRow[] = [];
|
|
18
|
+
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
|
+
|
|
20
|
+
// createTag returns the new id; assign/remove return data-less success.
|
|
21
|
+
const dispatchSpy = mock(async (type: string) =>
|
|
22
|
+
type === TagsHandlers.createTag
|
|
23
|
+
? { isSuccess: true, data: { id: "tag-new" } }
|
|
24
|
+
: { isSuccess: true, data: undefined },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// useQuery is called twice (catalog + assignments) — branch on the QN.
|
|
28
|
+
const useQuerySpy = mock((type: string) => ({
|
|
29
|
+
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
30
|
+
loading: false,
|
|
31
|
+
error: null,
|
|
32
|
+
refetch: mock(async () => {}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
36
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
37
|
+
...actual_renderer,
|
|
38
|
+
useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
|
|
39
|
+
useQuery: useQuerySpy,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
43
|
+
return (
|
|
44
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
45
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
46
|
+
</LocaleProvider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("TagSection", () => {
|
|
51
|
+
test("shows assigned + available tags and dispatches assign/remove with the right QN + payload", async () => {
|
|
52
|
+
catalogRows = [
|
|
53
|
+
{ id: "t1", name: "important" },
|
|
54
|
+
{ id: "t2", name: "project-x" },
|
|
55
|
+
];
|
|
56
|
+
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
|
|
57
|
+
dispatchSpy.mockClear();
|
|
58
|
+
|
|
59
|
+
render(
|
|
60
|
+
<Wrapper>
|
|
61
|
+
<TagSection entityName="note" entityId="note-1" />
|
|
62
|
+
</Wrapper>,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Assigned tag → remove button; unassigned catalog tag → assign button.
|
|
66
|
+
expect(screen.getByTestId("tags-section-remove-t1")).toBeTruthy();
|
|
67
|
+
expect(screen.getByTestId("tags-section-assign-t2")).toBeTruthy();
|
|
68
|
+
expect(screen.queryByTestId("tags-section-assign-t1")).toBeNull();
|
|
69
|
+
|
|
70
|
+
fireEvent.click(screen.getByTestId("tags-section-assign-t2"));
|
|
71
|
+
await waitFor(() =>
|
|
72
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
|
|
73
|
+
tagId: "t2",
|
|
74
|
+
entityType: "note",
|
|
75
|
+
entityId: "note-1",
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
fireEvent.click(screen.getByTestId("tags-section-remove-t1"));
|
|
80
|
+
await waitFor(() =>
|
|
81
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
|
|
82
|
+
tagId: "t1",
|
|
83
|
+
entityType: "note",
|
|
84
|
+
entityId: "note-1",
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("create-and-attach dispatches create-tag, then assign-tag with the new id", async () => {
|
|
90
|
+
catalogRows = [];
|
|
91
|
+
assignmentRows = [];
|
|
92
|
+
dispatchSpy.mockClear();
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<Wrapper>
|
|
96
|
+
<TagSection entityName="note" entityId="note-9" />
|
|
97
|
+
</Wrapper>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
fireEvent.change(document.getElementById("tags-section-new") as HTMLInputElement, {
|
|
101
|
+
target: { value: "urgent" },
|
|
102
|
+
});
|
|
103
|
+
fireEvent.click(screen.getByTestId("tags-section-create"));
|
|
104
|
+
|
|
105
|
+
await waitFor(() =>
|
|
106
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.createTag, { name: "urgent" }),
|
|
107
|
+
);
|
|
108
|
+
await waitFor(() =>
|
|
109
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
|
|
110
|
+
tagId: "tag-new",
|
|
111
|
+
entityType: "note",
|
|
112
|
+
entityId: "note-9",
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("create-mode (no entityId yet) shows the save-first hint instead of the manager", () => {
|
|
118
|
+
render(
|
|
119
|
+
<Wrapper>
|
|
120
|
+
<TagSection entityName="note" entityId={null} />
|
|
121
|
+
</Wrapper>,
|
|
122
|
+
);
|
|
123
|
+
expect(screen.getByTestId("tags-section-create-mode")).toBeTruthy();
|
|
124
|
+
expect(screen.queryByTestId("tags-section")).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-feature factory for tags. Mounted via
|
|
3
|
+
// createKumikoApp({ clientFeatures: [tagsClient()] }) — registers TagSection
|
|
4
|
+
// under TAGS_SECTION_EXTENSION_NAME and contributes the default translations.
|
|
5
|
+
// Required even for standalone <TagSection> use, otherwise its i18n keys render
|
|
6
|
+
// raw.
|
|
7
|
+
|
|
8
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
|
+
import { TAGS_FEATURE_NAME, TAGS_SECTION_EXTENSION_NAME } from "../constants";
|
|
10
|
+
import { defaultTranslations } from "./i18n";
|
|
11
|
+
import { TagSection } from "./tag-section";
|
|
12
|
+
|
|
13
|
+
export function tagsClient(): ClientFeatureDefinition {
|
|
14
|
+
return {
|
|
15
|
+
name: TAGS_FEATURE_NAME,
|
|
16
|
+
extensionSectionComponents: {
|
|
17
|
+
[TAGS_SECTION_EXTENSION_NAME]: TagSection,
|
|
18
|
+
},
|
|
19
|
+
translations: defaultTranslations,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default translation bundle for the tags UI. tagsClient() hangs it into the
|
|
3
|
+
// LocaleProvider as a fallback bundle — apps override individual keys via
|
|
4
|
+
// tagsClient({ translations: { de: { ... } } }). Keys follow `tags.<area>.<slug>`.
|
|
5
|
+
|
|
6
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
|
|
8
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
9
|
+
de: {
|
|
10
|
+
"tags.section.createMode": "Speichere zuerst den Eintrag, um Tags zu setzen.",
|
|
11
|
+
"tags.section.loading": "Lädt…",
|
|
12
|
+
"tags.section.none": "Keine Tags.",
|
|
13
|
+
"tags.section.newLabel": "Neuer Tag",
|
|
14
|
+
"tags.section.create": "Tag anlegen & zuweisen",
|
|
15
|
+
"tags.section.working": "Speichert…",
|
|
16
|
+
},
|
|
17
|
+
en: {
|
|
18
|
+
"tags.section.createMode": "Save the entity first to add tags.",
|
|
19
|
+
"tags.section.loading": "Loading…",
|
|
20
|
+
"tags.section.none": "No tags.",
|
|
21
|
+
"tags.section.newLabel": "New tag",
|
|
22
|
+
"tags.section.create": "Create & attach tag",
|
|
23
|
+
"tags.section.working": "Saving…",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TagSection — drop-in tag manager for ANY entity. Given an entityName +
|
|
3
|
+
// entityId it shows the entity's current tags and lets the user attach an
|
|
4
|
+
// existing tag, create-and-attach a new one, or detach a tag. Tag writes are
|
|
5
|
+
// immediate (assign/remove are idempotent), so the section owns its own state
|
|
6
|
+
// and refetches after each action — it is NOT part of a host form's save.
|
|
7
|
+
//
|
|
8
|
+
// Two ways to mount (both need tagsClient() registered once, for i18n):
|
|
9
|
+
// - standalone: <TagSection entityName="note" entityId={noteId} />
|
|
10
|
+
// - extension: a screen-schema section with
|
|
11
|
+
// component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } }
|
|
12
|
+
// (RenderEdit passes { entityName, entityId }).
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
useDispatcher,
|
|
16
|
+
usePrimitives,
|
|
17
|
+
useQuery,
|
|
18
|
+
useTranslation,
|
|
19
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
20
|
+
import { type ReactNode, useState } from "react";
|
|
21
|
+
import { TagsHandlers, TagsQueries } from "../constants";
|
|
22
|
+
|
|
23
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
24
|
+
type AssignmentRow = {
|
|
25
|
+
readonly tagId: string;
|
|
26
|
+
readonly entityType: string;
|
|
27
|
+
readonly entityId: string;
|
|
28
|
+
};
|
|
29
|
+
type TagListResponse = { readonly rows: readonly TagRow[] };
|
|
30
|
+
type AssignmentListResponse = { readonly rows: readonly AssignmentRow[] };
|
|
31
|
+
|
|
32
|
+
// Structural shape of a dispatcher write result for the generic action wrapper.
|
|
33
|
+
// The real WriteResult (a discriminated union) is assignable to this; narrowing
|
|
34
|
+
// on `isSuccess` reaches `error.i18nKey` without importing server-side types.
|
|
35
|
+
type ActionResult =
|
|
36
|
+
| { readonly isSuccess: true }
|
|
37
|
+
| { readonly isSuccess: false; readonly error: { readonly i18nKey: string } };
|
|
38
|
+
|
|
39
|
+
export function TagSection({
|
|
40
|
+
entityName,
|
|
41
|
+
entityId,
|
|
42
|
+
}: {
|
|
43
|
+
readonly entityName: string;
|
|
44
|
+
readonly entityId: string | null;
|
|
45
|
+
}): ReactNode {
|
|
46
|
+
const { Banner, Button, Field, Input, Text } = usePrimitives();
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
const dispatcher = useDispatcher();
|
|
49
|
+
const enabled = entityId !== null;
|
|
50
|
+
const catalog = useQuery<TagListResponse>(TagsQueries.tagList, {}, { enabled });
|
|
51
|
+
const assignments = useQuery<AssignmentListResponse>(
|
|
52
|
+
TagsQueries.assignmentList,
|
|
53
|
+
{ filter: { field: "entityId", op: "eq", value: entityId } },
|
|
54
|
+
{ enabled },
|
|
55
|
+
);
|
|
56
|
+
const [newName, setNewName] = useState("");
|
|
57
|
+
const [busy, setBusy] = useState(false);
|
|
58
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
59
|
+
|
|
60
|
+
if (entityId === null) {
|
|
61
|
+
return (
|
|
62
|
+
<Banner variant="info" testId="tags-section-create-mode">
|
|
63
|
+
<Text>{t("tags.section.createMode")}</Text>
|
|
64
|
+
</Banner>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
(catalog.loading && catalog.data === null) ||
|
|
69
|
+
(assignments.loading && assignments.data === null)
|
|
70
|
+
) {
|
|
71
|
+
return (
|
|
72
|
+
<Banner variant="loading" testId="tags-section-loading">
|
|
73
|
+
<Text>{t("tags.section.loading")}</Text>
|
|
74
|
+
</Banner>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const queryError = catalog.error ?? assignments.error;
|
|
78
|
+
if (queryError) {
|
|
79
|
+
return (
|
|
80
|
+
<Banner variant="error" testId="tags-section-error">
|
|
81
|
+
<Text>{t(queryError.i18nKey, queryError.i18nParams)}</Text>
|
|
82
|
+
</Banner>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const catalogTags = catalog.data?.rows ?? [];
|
|
87
|
+
const byId = new Map(catalogTags.map((tg) => [tg.id, tg]));
|
|
88
|
+
const assignedRows = (assignments.data?.rows ?? []).filter((r) => r.entityType === entityName);
|
|
89
|
+
const assignedIds = new Set(assignedRows.map((r) => r.tagId));
|
|
90
|
+
const assignedTags = assignedRows.map((r) => byId.get(r.tagId) ?? { id: r.tagId, name: r.tagId });
|
|
91
|
+
const available = catalogTags.filter((tg) => !assignedIds.has(tg.id));
|
|
92
|
+
|
|
93
|
+
const refetch = async (): Promise<void> => {
|
|
94
|
+
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const run = async (action: () => Promise<ActionResult>): Promise<void> => {
|
|
98
|
+
setBusy(true);
|
|
99
|
+
setErrorKey(null);
|
|
100
|
+
try {
|
|
101
|
+
const result = await action();
|
|
102
|
+
if (!result.isSuccess) {
|
|
103
|
+
setErrorKey(result.error.i18nKey);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await refetch();
|
|
107
|
+
} finally {
|
|
108
|
+
setBusy(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const assign = (tagId: string): Promise<void> =>
|
|
113
|
+
run(() =>
|
|
114
|
+
dispatcher.write(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId }),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const detach = (tagId: string): Promise<void> =>
|
|
118
|
+
run(() =>
|
|
119
|
+
dispatcher.write(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId }),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const createAndAssign = async (): Promise<void> => {
|
|
123
|
+
const name = newName.trim();
|
|
124
|
+
if (name === "") return;
|
|
125
|
+
setBusy(true);
|
|
126
|
+
setErrorKey(null);
|
|
127
|
+
try {
|
|
128
|
+
const created = await dispatcher.write<{ id: string }>(TagsHandlers.createTag, { name });
|
|
129
|
+
if (!created.isSuccess) {
|
|
130
|
+
setErrorKey(created.error.i18nKey);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const assigned = await dispatcher.write(TagsHandlers.assignTag, {
|
|
134
|
+
tagId: created.data.id,
|
|
135
|
+
entityType: entityName,
|
|
136
|
+
entityId,
|
|
137
|
+
});
|
|
138
|
+
if (!assigned.isSuccess) {
|
|
139
|
+
setErrorKey(assigned.error.i18nKey);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
setNewName("");
|
|
143
|
+
await refetch();
|
|
144
|
+
} finally {
|
|
145
|
+
setBusy(false);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div data-testid="tags-section">
|
|
151
|
+
{assignedTags.length === 0 ? (
|
|
152
|
+
<Text>{t("tags.section.none")}</Text>
|
|
153
|
+
) : (
|
|
154
|
+
assignedTags.map((tg) => (
|
|
155
|
+
<Button
|
|
156
|
+
key={tg.id}
|
|
157
|
+
variant="secondary"
|
|
158
|
+
disabled={busy}
|
|
159
|
+
onClick={() => void detach(tg.id)}
|
|
160
|
+
testId={`tags-section-remove-${tg.id}`}
|
|
161
|
+
>
|
|
162
|
+
{`${tg.name} ✕`}
|
|
163
|
+
</Button>
|
|
164
|
+
))
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{available.map((tg) => (
|
|
168
|
+
<Button
|
|
169
|
+
key={tg.id}
|
|
170
|
+
variant="secondary"
|
|
171
|
+
disabled={busy}
|
|
172
|
+
onClick={() => void assign(tg.id)}
|
|
173
|
+
testId={`tags-section-assign-${tg.id}`}
|
|
174
|
+
>
|
|
175
|
+
{`+ ${tg.name}`}
|
|
176
|
+
</Button>
|
|
177
|
+
))}
|
|
178
|
+
|
|
179
|
+
<Field id="tags-section-new" label={t("tags.section.newLabel")}>
|
|
180
|
+
<Input
|
|
181
|
+
kind="text"
|
|
182
|
+
id="tags-section-new"
|
|
183
|
+
name="newTag"
|
|
184
|
+
value={newName}
|
|
185
|
+
onChange={setNewName}
|
|
186
|
+
/>
|
|
187
|
+
</Field>
|
|
188
|
+
<Button
|
|
189
|
+
variant="primary"
|
|
190
|
+
disabled={busy || newName.trim() === ""}
|
|
191
|
+
onClick={() => void createAndAssign()}
|
|
192
|
+
testId="tags-section-create"
|
|
193
|
+
>
|
|
194
|
+
{busy ? t("tags.section.working") : t("tags.section.create")}
|
|
195
|
+
</Button>
|
|
196
|
+
|
|
197
|
+
{errorKey !== null && (
|
|
198
|
+
<Banner variant="error" testId="tags-section-action-error">
|
|
199
|
+
<Text>{t(errorKey)}</Text>
|
|
200
|
+
</Banner>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
package/src/user/schema/user.ts
CHANGED
|
@@ -141,6 +141,17 @@ export const userEntity = createEntity({
|
|
|
141
141
|
gracePeriodEnd: createTimestampField({
|
|
142
142
|
access: { write: access.privileged },
|
|
143
143
|
}),
|
|
144
|
+
|
|
145
|
+
// Replay-Schutz für den anonymen email-Token-Deletion-Flow (#354/1).
|
|
146
|
+
// Gesetzt von request-deletion-by-email (eine UUID pro Mail-Antrag),
|
|
147
|
+
// genullt von cancel-deletion. confirm-deletion-by-token faltet diese ID
|
|
148
|
+
// in die HMAC-Purpose des Tokens — ein nach einem Cancel nachgespieltes
|
|
149
|
+
// (noch TTL-gültiges) Token verifiziert gegen die genullte/erneuerte ID
|
|
150
|
+
// nicht mehr. NULL solange kein email-Antrag offen ist.
|
|
151
|
+
pendingDeletionRequestId: createTextField({
|
|
152
|
+
maxLength: 36,
|
|
153
|
+
access: { write: access.privileged },
|
|
154
|
+
}),
|
|
144
155
|
},
|
|
145
156
|
});
|
|
146
157
|
|
|
@@ -31,6 +31,7 @@ import type { SendDeletionVerificationEmailFn } from "../handlers/request-deleti
|
|
|
31
31
|
|
|
32
32
|
const REQUEST_BY_EMAIL = "user-data-rights:write:request-deletion-by-email";
|
|
33
33
|
const CONFIRM_BY_TOKEN = "user-data-rights:write:confirm-deletion-by-token";
|
|
34
|
+
const CANCEL_DELETION = "user-data-rights:write:cancel-deletion";
|
|
34
35
|
const DELETION_SECRET = "test-deletion-secret-0123456789abcdef";
|
|
35
36
|
const VERIFY_URL = "https://app.example.test/delete-account/confirm";
|
|
36
37
|
|
|
@@ -166,8 +167,9 @@ describe("anonymous deletion flow", () => {
|
|
|
166
167
|
expect(first.status).toBe(200);
|
|
167
168
|
expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
|
|
168
169
|
|
|
169
|
-
//
|
|
170
|
-
//
|
|
170
|
+
// Pre-cancel-Replay: zweites Confirm trifft den noch-pending User
|
|
171
|
+
// (DeletionRequested) → der Active-State-Guard schlägt zu → 422. (Den
|
|
172
|
+
// post-cancel-Replay deckt der requestId-Test darunter ab.)
|
|
171
173
|
const second = await stack.http.raw("POST", "/api/write", {
|
|
172
174
|
type: CONFIRM_BY_TOKEN,
|
|
173
175
|
payload: { token },
|
|
@@ -187,6 +189,82 @@ describe("anonymous deletion flow", () => {
|
|
|
187
189
|
expect(serialized).not.toContain(USER_STATUS.DeletionRequested);
|
|
188
190
|
});
|
|
189
191
|
|
|
192
|
+
test("replay-after-cancel (#354/1): Token nach cancel-deletion re-armt NICHT → 422, bleibt Active", async () => {
|
|
193
|
+
await seedAlice();
|
|
194
|
+
await stack.http.raw("POST", "/api/write", {
|
|
195
|
+
type: REQUEST_BY_EMAIL,
|
|
196
|
+
payload: { email: ALICE_EMAIL },
|
|
197
|
+
});
|
|
198
|
+
const token = tokenFromLastVerifyCall();
|
|
199
|
+
|
|
200
|
+
// 1. Confirm armt die Grace-Period.
|
|
201
|
+
expect(
|
|
202
|
+
(await stack.http.raw("POST", "/api/write", { type: CONFIRM_BY_TOKEN, payload: { token } }))
|
|
203
|
+
.status,
|
|
204
|
+
).toBe(200);
|
|
205
|
+
expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
|
|
206
|
+
|
|
207
|
+
// 2. User loggt sich (innerhalb der Grace) ein und bricht ab → Active,
|
|
208
|
+
// pendingDeletionRequestId genullt.
|
|
209
|
+
await stack.http.writeOk(CANCEL_DELETION, {}, aliceUser);
|
|
210
|
+
expect(await statusOf()).toBe(USER_STATUS.Active);
|
|
211
|
+
|
|
212
|
+
// 3. Dasselbe, noch TTL-gültige Token nachspielen → die genullte requestId
|
|
213
|
+
// lässt die HMAC-Purpose nicht mehr aufgehen → 422, kein re-arm.
|
|
214
|
+
const replay = await stack.http.raw("POST", "/api/write", {
|
|
215
|
+
type: CONFIRM_BY_TOKEN,
|
|
216
|
+
payload: { token },
|
|
217
|
+
});
|
|
218
|
+
expect(replay.status).toBe(422);
|
|
219
|
+
expect(await statusOf()).toBe(USER_STATUS.Active);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("supersede (#354/1): altes Token re-armt NICHT, nachdem ein zweiter Antrag eine neue requestId setzt", async () => {
|
|
223
|
+
// Der diskriminierende Fall gegen einen presence-only-Check: nach cancel
|
|
224
|
+
// macht ein FRISCHER Antrag den marker wieder non-null (neue requestId).
|
|
225
|
+
// Ein presence-only-Guard würde das alte Token jetzt fälschlich akzeptieren;
|
|
226
|
+
// der requestId-Match lehnt es ab (token1 trägt R1, Row hält R2).
|
|
227
|
+
await seedAlice();
|
|
228
|
+
|
|
229
|
+
await stack.http.raw("POST", "/api/write", {
|
|
230
|
+
type: REQUEST_BY_EMAIL,
|
|
231
|
+
payload: { email: ALICE_EMAIL },
|
|
232
|
+
});
|
|
233
|
+
const token1 = tokenFromLastVerifyCall();
|
|
234
|
+
|
|
235
|
+
await stack.http.raw("POST", "/api/write", {
|
|
236
|
+
type: CONFIRM_BY_TOKEN,
|
|
237
|
+
payload: { token: token1 },
|
|
238
|
+
});
|
|
239
|
+
await stack.http.writeOk(CANCEL_DELETION, {}, aliceUser);
|
|
240
|
+
expect(await statusOf()).toBe(USER_STATUS.Active);
|
|
241
|
+
|
|
242
|
+
// Zweiter Antrag → neue requestId R2 auf der Row + token2.
|
|
243
|
+
verifyCalls.length = 0;
|
|
244
|
+
await stack.http.raw("POST", "/api/write", {
|
|
245
|
+
type: REQUEST_BY_EMAIL,
|
|
246
|
+
payload: { email: ALICE_EMAIL },
|
|
247
|
+
});
|
|
248
|
+
const token2 = tokenFromLastVerifyCall();
|
|
249
|
+
expect(token2).not.toBe(token1);
|
|
250
|
+
|
|
251
|
+
// Altes token1 (R1) gegen Row mit R2 → bad_signature → 422, kein re-arm.
|
|
252
|
+
const replayOld = await stack.http.raw("POST", "/api/write", {
|
|
253
|
+
type: CONFIRM_BY_TOKEN,
|
|
254
|
+
payload: { token: token1 },
|
|
255
|
+
});
|
|
256
|
+
expect(replayOld.status).toBe(422);
|
|
257
|
+
expect(await statusOf()).toBe(USER_STATUS.Active);
|
|
258
|
+
|
|
259
|
+
// Gegenprobe: das aktuelle token2 (R2) armt regulär.
|
|
260
|
+
const confirmNew = await stack.http.raw("POST", "/api/write", {
|
|
261
|
+
type: CONFIRM_BY_TOKEN,
|
|
262
|
+
payload: { token: token2 },
|
|
263
|
+
});
|
|
264
|
+
expect(confirmNew.status).toBe(200);
|
|
265
|
+
expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
|
|
266
|
+
});
|
|
267
|
+
|
|
190
268
|
test("request-by-email für nicht-existente Email → success, KEINE Mail (enumeration-safe)", async () => {
|
|
191
269
|
await seedAlice();
|
|
192
270
|
const res = await stack.http.raw("POST", "/api/write", {
|
|
@@ -221,7 +299,19 @@ describe("anonymous deletion flow", () => {
|
|
|
221
299
|
|
|
222
300
|
test("confirm mit falsch-signiertem Token → 422", async () => {
|
|
223
301
|
await seedAlice();
|
|
224
|
-
|
|
302
|
+
// Erst einen echten Antrag stellen, damit eine requestId auf der Row liegt
|
|
303
|
+
// — sonst greift schon der no-outstanding-request-Guard und der Bad-
|
|
304
|
+
// Signature-Pfad würde nie erreicht.
|
|
305
|
+
await stack.http.raw("POST", "/api/write", {
|
|
306
|
+
type: REQUEST_BY_EMAIL,
|
|
307
|
+
payload: { email: ALICE_EMAIL },
|
|
308
|
+
});
|
|
309
|
+
const { token } = signDeletionToken(
|
|
310
|
+
aliceUser.id,
|
|
311
|
+
"forged-request-id",
|
|
312
|
+
60,
|
|
313
|
+
"the-wrong-secret-totally-different",
|
|
314
|
+
);
|
|
225
315
|
const res = await stack.http.raw("POST", "/api/write", {
|
|
226
316
|
type: CONFIRM_BY_TOKEN,
|
|
227
317
|
payload: { token },
|
|
@@ -282,7 +372,7 @@ describe("anonymous deletion flow — not configured (kein Secret)", () => {
|
|
|
282
372
|
});
|
|
283
373
|
|
|
284
374
|
test("confirm ohne Secret → 422", async () => {
|
|
285
|
-
const { token } = signDeletionToken(aliceUser.id, 60, DELETION_SECRET);
|
|
375
|
+
const { token } = signDeletionToken(aliceUser.id, "req-id", 60, DELETION_SECRET);
|
|
286
376
|
const res = await bareStack.http.raw("POST", "/api/write", {
|
|
287
377
|
type: CONFIRM_BY_TOKEN,
|
|
288
378
|
payload: { token },
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
// flow, so it reuses the same self-contained token mechanism (no DB row, no
|
|
5
5
|
// Redis: the userId + expiry are baked into the signed token).
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
7
|
+
// Replay-after-cancel (#354/1): the per-request `requestId` is folded INTO the
|
|
8
|
+
// HMAC purpose (`deletion-request:<requestId>`), not carried in the token body.
|
|
9
|
+
// The same id is stored on the user row when the request is minted and nulled
|
|
10
|
+
// on cancel. confirm recomputes the HMAC with the row's CURRENT id, so a token
|
|
11
|
+
// from a cancelled cycle (row id nulled) or a superseded one (row holds a newer
|
|
12
|
+
// id) fails verification — the bounded-TTL replay window is closed without
|
|
13
|
+
// touching the shared signToken/verifyToken primitive.
|
|
14
14
|
|
|
15
15
|
import type { Temporal } from "temporal-polyfill";
|
|
16
16
|
import { signToken, verifyToken } from "../auth-email-password";
|
|
@@ -21,21 +21,42 @@ export type VerifyResult =
|
|
|
21
21
|
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
22
22
|
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
function deletionPurpose(requestId: string): string {
|
|
25
|
+
return `${DELETION_REQUEST_PURPOSE}:${requestId}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
25
28
|
export function signDeletionToken(
|
|
26
29
|
userId: string,
|
|
30
|
+
requestId: string,
|
|
27
31
|
ttlMinutes: number,
|
|
28
32
|
secret: string,
|
|
29
33
|
now?: Temporal.Instant,
|
|
30
34
|
): { token: string; expiresAt: Temporal.Instant } {
|
|
31
|
-
return signToken(userId,
|
|
35
|
+
return signToken(userId, deletionPurpose(requestId), ttlMinutes, secret, now);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
// @wrapper-known semantic-alias
|
|
35
38
|
export function verifyDeletionToken(
|
|
36
39
|
token: string,
|
|
40
|
+
requestId: string,
|
|
37
41
|
secret: string,
|
|
38
42
|
now?: Temporal.Instant,
|
|
39
43
|
): VerifyResult {
|
|
40
|
-
return verifyToken(token,
|
|
44
|
+
return verifyToken(token, deletionPurpose(requestId), secret, now);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reads the userId from the token body WITHOUT verifying the HMAC — used only
|
|
48
|
+
// to look up the row's current requestId, which is itself an input to the
|
|
49
|
+
// verification below. The signature is still the gate; this peek never grants
|
|
50
|
+
// trust. Token format is `<userId>.<expiresAtMs>.<sig>`.
|
|
51
|
+
//
|
|
52
|
+
// Mirrors verifyToken's structural malformed-checks so an obviously-bogus token
|
|
53
|
+
// returns null here (→ generic reject) instead of reaching the DB lookup.
|
|
54
|
+
export function peekDeletionTokenUserId(token: string): string | null {
|
|
55
|
+
const parts = token.split(".");
|
|
56
|
+
if (parts.length !== 3) return null;
|
|
57
|
+
const [userId, expiresAtRaw, sig] = parts;
|
|
58
|
+
if (!userId || !expiresAtRaw || !sig) return null;
|
|
59
|
+
const expiresAtMs = Number(expiresAtRaw);
|
|
60
|
+
if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) return null;
|
|
61
|
+
return userId;
|
|
41
62
|
}
|
|
@@ -67,6 +67,10 @@ export const cancelDeletionWrite = defineWriteHandler({
|
|
|
67
67
|
{
|
|
68
68
|
status: USER_STATUS.Active,
|
|
69
69
|
gracePeriodEnd: null,
|
|
70
|
+
// #354/1: schließt das replay-after-cancel-Fenster — ein noch
|
|
71
|
+
// TTL-gültiges email-Token verifiziert gegen die genullte requestId
|
|
72
|
+
// nicht mehr und kann keine zweite Grace-Period armen.
|
|
73
|
+
pendingDeletionRequestId: null,
|
|
70
74
|
},
|
|
71
75
|
{ id: event.user.id },
|
|
72
76
|
);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { defineWriteHandler, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
|
-
import { USER_STATUS } from "../../user";
|
|
5
|
-
import { verifyDeletionToken } from "../deletion-token";
|
|
5
|
+
import { USER_STATUS, userTable } from "../../user";
|
|
6
|
+
import { peekDeletionTokenUserId, verifyDeletionToken } from "../deletion-token";
|
|
6
7
|
import { startDeletionGracePeriod } from "./deletion-grace-period";
|
|
7
8
|
|
|
8
9
|
export type ConfirmDeletionByTokenOptions = {
|
|
@@ -17,18 +18,35 @@ function invalidToken(): UnprocessableError {
|
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// userId stammt aus dem noch-unverifizierten Token (Angreifer-Eingabe). Ein
|
|
22
|
+
// fehlgeschlagener Lookup — z.B. eine typfremde id auf einer int/uuid-Spalte —
|
|
23
|
+
// darf nicht als 500 durchschlagen; null behandelt der Caller wie "kein offener
|
|
24
|
+
// Antrag" (generischer 422). Die HMAC-Prüfung bleibt der eigentliche Gate.
|
|
25
|
+
async function readPendingDeletionRequestId(
|
|
26
|
+
ctx: HandlerContext,
|
|
27
|
+
userId: string,
|
|
28
|
+
): Promise<string | null> {
|
|
29
|
+
try {
|
|
30
|
+
const row = await fetchOne<{ pendingDeletionRequestId: string | null }>(ctx.db.raw, userTable, {
|
|
31
|
+
id: userId,
|
|
32
|
+
});
|
|
33
|
+
return row?.["pendingDeletionRequestId"] ?? null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
// Anonymer Apex-Flow Schritt 2: Verify-Link-Target. Verifiziert das
|
|
21
40
|
// HMAC-Token, extrahiert die userId und startet die Grace-Period über die
|
|
22
41
|
// geteilte Logik.
|
|
23
42
|
//
|
|
24
|
-
//
|
|
25
|
-
// (
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// deferred — er braucht eine Migration der geteilten user-Entity.
|
|
43
|
+
// Replay-Schutz (#354/1): die requestId der Row ist Teil des Verify-Keys. Wir
|
|
44
|
+
// lesen sie über die (unverifizierte, nur-Lookup) userId aus dem Token, lehnen
|
|
45
|
+
// einen fehlenden Eintrag ab und verifizieren das Token gegen die CURRENT
|
|
46
|
+
// requestId. Ein zweites Confirm auf einen noch-pending User trifft zudem
|
|
47
|
+
// non-active → cannot_process_deletion. Nach einem cancel-deletion (status →
|
|
48
|
+
// Active, pendingDeletionRequestId → null) schlägt ein nachgespieltes Token an
|
|
49
|
+
// der genullten/erneuerten requestId fehl — kein re-arm mehr.
|
|
32
50
|
export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByTokenOptions = {}) {
|
|
33
51
|
return defineWriteHandler({
|
|
34
52
|
name: "confirm-deletion-by-token",
|
|
@@ -38,7 +56,22 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
38
56
|
handler: async (event, ctx) => {
|
|
39
57
|
if (!opts.deletionTokenSecret) return writeFailure(invalidToken());
|
|
40
58
|
|
|
41
|
-
const
|
|
59
|
+
const peekedUserId = peekDeletionTokenUserId(event.payload.token);
|
|
60
|
+
if (!peekedUserId) return writeFailure(invalidToken());
|
|
61
|
+
|
|
62
|
+
// Die requestId der Row ist Teil des Verify-Keys (HMAC-Purpose). Kein
|
|
63
|
+
// offener Antrag (null) → das Token gehört zu einem abgebrochenen Zyklus
|
|
64
|
+
// → Reject ohne weitere Signal-Preisgabe (gleicher generischer 422). Der
|
|
65
|
+
// peekedUserId ist unverifizierte Angreifer-Eingabe — ein Lookup-Fehler
|
|
66
|
+
// (z.B. typfremde id) wird zu demselben generischen 422, nie zu einem 500.
|
|
67
|
+
const requestId = await readPendingDeletionRequestId(ctx, peekedUserId);
|
|
68
|
+
if (!requestId) return writeFailure(invalidToken());
|
|
69
|
+
|
|
70
|
+
const verified = verifyDeletionToken(
|
|
71
|
+
event.payload.token,
|
|
72
|
+
requestId,
|
|
73
|
+
opts.deletionTokenSecret,
|
|
74
|
+
);
|
|
42
75
|
if (!verified.ok) return writeFailure(invalidToken());
|
|
43
76
|
|
|
44
77
|
const res = await startDeletionGracePeriod(ctx, verified.userId, event.user.tenantId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { USER_STATUS, userTable } from "../../user";
|
|
@@ -75,8 +75,20 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
|
|
|
75
75
|
return success;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Replay-Schutz (#354/1): pro Antrag eine frische requestId, die auf der
|
|
79
|
+
// user-Row landet und in die Token-HMAC-Purpose gefaltet wird. cancel
|
|
80
|
+
// nullt sie → ein nach Cancel nachgespieltes Token verifiziert nicht mehr.
|
|
81
|
+
const requestId = crypto.randomUUID();
|
|
82
|
+
await updateMany(
|
|
83
|
+
ctx.db.raw,
|
|
84
|
+
userTable,
|
|
85
|
+
{ pendingDeletionRequestId: requestId },
|
|
86
|
+
{ id: userRow["id"] },
|
|
87
|
+
);
|
|
88
|
+
|
|
78
89
|
const { token, expiresAt } = signDeletionToken(
|
|
79
90
|
userRow["id"],
|
|
91
|
+
requestId,
|
|
80
92
|
DELETION_VERIFY_TTL_MINUTES,
|
|
81
93
|
opts.deletionTokenSecret,
|
|
82
94
|
);
|