@cosmicdrift/kumiko-samples 0.388.0 → 0.389.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/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/index.ts +1 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +42 -26
- package/packages/bundled-features/src/audit/feature.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
- package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
- package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
- package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
- package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
- package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +20 -7
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
- package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/index.ts +5 -0
- package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
- package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
- package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
- package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
- package/packages/bundled-features/src/jobs/feature.ts +1 -0
- package/packages/bundled-features/src/ledger/web/index.ts +1 -1
- package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
- package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
- package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
- package/packages/bundled-features/src/managed-pages/index.ts +1 -0
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +1 -0
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
- package/packages/bundled-features/src/sessions/changes.json +7 -0
- package/packages/bundled-features/src/sessions/feature.ts +1 -0
- package/packages/bundled-features/src/sessions/index.ts +1 -1
- package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
- package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
- package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
- package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
- package/packages/bundled-features/src/tags/feature.ts +1 -0
- package/packages/bundled-features/src/template-resolver/changes.json +36 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +30 -5
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
- package/packages/bundled-features/src/template-resolver/seeding.ts +10 -3
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +52 -8
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
- package/packages/bundled-features/src/tenant/feature.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
- package/packages/bundled-features/src/user/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
- package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
- package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +27 -8
- package/packages/bundled-features/src/waitlist/import.ts +23 -3
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +2 -1
- package/packages/bundled-features/src/waitlist/options.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
- package/samples/apps/styleguide/src/app/client.tsx +2 -0
- package/samples/apps/styleguide/src/app/seed.ts +6 -4
- package/samples/apps/styleguide/src/app/server.ts +4 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
- package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
- package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
- package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
- package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
- package/samples/apps/use-all-bundled/bin/main.ts +2 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +16 -0
- package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
- package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
- package/samples/apps/use-all-bundled/package.json +2 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
- package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
- package/samples/recipes/golden-path/src/feature.ts +2 -1
- package/samples/recipes/lifecycle-hooks/README.md +4 -0
- package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
- package/samples/recipes/list-table-options/README.md +50 -0
- package/samples/recipes/list-table-options/package.json +11 -0
- package/samples/recipes/list-table-options/src/feature.ts +221 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
- package/samples/recipes/state-machine/src/events.ts +3 -2
- package/samples/recipes/tenant-isolation/README.md +1 -0
- package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import { filterDeliveredTo } from "../api.ts";
|
|
5
6
|
import { type LayoutRegions, readLayoutRegions } from "../layout-regions.ts";
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
} from "../template-draft.ts";
|
|
15
16
|
import { type MailLayoutMeta, readMailLayoutMeta } from "../template-meta.ts";
|
|
16
17
|
import { FALLBACK_LOCALE } from "../web/constants.ts";
|
|
18
|
+
import { TemplateResolverErrors } from "../web/qualified-names.ts";
|
|
17
19
|
import { localeSchema, templateSlugSchema } from "./shared.ts";
|
|
18
20
|
import { parseStoredJson } from "./template-gates.ts";
|
|
19
21
|
|
|
@@ -66,20 +68,29 @@ function layoutFormat(row: WorkingRow): "html" | "mjml" | null {
|
|
|
66
68
|
return row.contentFormat === "html" || row.contentFormat === "mjml" ? row.contentFormat : null;
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
type EditScope = "tenant" | "system";
|
|
72
|
+
|
|
69
73
|
function pickEditedRows(
|
|
70
74
|
rows: readonly TemplateResourceRow[],
|
|
71
|
-
|
|
75
|
+
caller: { readonly tenantId: string; readonly roles: readonly string[] },
|
|
76
|
+
scope: EditScope,
|
|
72
77
|
locale: string | undefined,
|
|
73
78
|
): {
|
|
74
79
|
readonly tenantRow: WorkingRow | null;
|
|
75
80
|
readonly systemRow: WorkingRow | null;
|
|
76
81
|
} {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
// A SystemAdmin editing the tenant layout sees the system version like a tenant does: served only, no draft.
|
|
83
|
+
const user =
|
|
84
|
+
scope === "system"
|
|
85
|
+
? caller
|
|
86
|
+
: { ...caller, roles: caller.roles.filter((role) => role !== "SystemAdmin") };
|
|
87
|
+
const tenantRow =
|
|
88
|
+
scope === "system"
|
|
89
|
+
? null
|
|
90
|
+
: pickByLocale(
|
|
91
|
+
rows.filter((row) => row.scope === "tenant"),
|
|
92
|
+
locale,
|
|
93
|
+
);
|
|
83
94
|
const systemRow = pickByLocale(
|
|
84
95
|
rows.filter((row) => row.scope === "system" && !isHiddenSystemDraft(user, row)),
|
|
85
96
|
locale,
|
|
@@ -136,13 +147,21 @@ export const layoutSourceQuery = defineQueryHandler({
|
|
|
136
147
|
kind: z.enum(["mail-layout", "pdf-layout"]),
|
|
137
148
|
slug: templateSlugSchema.default("default"),
|
|
138
149
|
locale: localeSchema.optional(),
|
|
150
|
+
scope: z.enum(["tenant", "system"]).optional(),
|
|
139
151
|
}),
|
|
140
152
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
141
153
|
description:
|
|
142
|
-
"Reads the layout the caller edits: the tenant's own version and the system version of one layout slug (each the open draft if there is one, else the stored version, with `active` naming the served version), the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version and also receives its mjml source. Use it to load the layout editor.",
|
|
154
|
+
"Reads the layout the caller edits: the tenant's own version and the system version of one layout slug (each the open draft if there is one, else the stored version, with `active` naming the served version), the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version by default and also receives its mjml source; scope tenant loads the SystemAdmin's own tenant layout instead, and scope system is refused for everyone else. Use it to load the layout editor.",
|
|
143
155
|
handler: async (query, ctx): Promise<LayoutSourceResponse> => {
|
|
144
156
|
const { kind, slug, locale } = query.payload;
|
|
145
|
-
const
|
|
157
|
+
const callerIsSystemAdmin = query.user.roles.includes("SystemAdmin");
|
|
158
|
+
const scope = query.payload.scope ?? (callerIsSystemAdmin ? "system" : "tenant");
|
|
159
|
+
if (scope === "system" && !callerIsSystemAdmin) {
|
|
160
|
+
throw new AccessDeniedError({
|
|
161
|
+
i18nKey: "errors.access.denied",
|
|
162
|
+
details: { reason: TemplateResolverErrors.systemAdminRequired },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
146
165
|
const policy = deliveryPolicyOf(ctx.registry);
|
|
147
166
|
const rows = await filterDeliveredTo(
|
|
148
167
|
ctx.db,
|
|
@@ -150,7 +169,13 @@ export const layoutSourceQuery = defineQueryHandler({
|
|
|
150
169
|
query.user,
|
|
151
170
|
await selectMany<TemplateResourceRow>(ctx.db, templateResourcesTable, { kind, slug }),
|
|
152
171
|
);
|
|
153
|
-
const { tenantRow, systemRow } = pickEditedRows(rows, query.user, locale);
|
|
154
|
-
return buildLayoutSource({
|
|
172
|
+
const { tenantRow, systemRow } = pickEditedRows(rows, query.user, scope, locale);
|
|
173
|
+
return buildLayoutSource({
|
|
174
|
+
kind,
|
|
175
|
+
locale,
|
|
176
|
+
isSystemAdmin: scope === "system",
|
|
177
|
+
tenantRow,
|
|
178
|
+
systemRow,
|
|
179
|
+
});
|
|
155
180
|
},
|
|
156
181
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
|
|
5
6
|
import {
|
|
@@ -16,7 +17,11 @@ import {
|
|
|
16
17
|
parseTemplateDraft,
|
|
17
18
|
servedVersionOf,
|
|
18
19
|
} from "../template-draft.ts";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
mayEditTemplate,
|
|
22
|
+
type TemplateEditPolicy,
|
|
23
|
+
templateEditAccess,
|
|
24
|
+
} from "../template-edit-policy.ts";
|
|
20
25
|
import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
|
|
21
26
|
import { collectTemplateUsage, type EffectiveMailLayoutSlugs } from "../template-usage.ts";
|
|
22
27
|
import {
|
|
@@ -204,7 +209,15 @@ function splitDocumentSlug(slug: string): { feature: string; notification: strin
|
|
|
204
209
|
: { feature: slug.slice(0, separator), notification: slug.slice(separator + 1) };
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
|
|
212
|
+
type ShownTitle = (row: OverviewRow) => string;
|
|
213
|
+
|
|
214
|
+
function compareBy(
|
|
215
|
+
field: SortField,
|
|
216
|
+
a: OverviewRow,
|
|
217
|
+
b: OverviewRow,
|
|
218
|
+
shownTitle: ShownTitle,
|
|
219
|
+
): number {
|
|
220
|
+
if (field === "title") return shownTitle(a).localeCompare(shownTitle(b));
|
|
208
221
|
return String(a[field]).localeCompare(String(b[field]));
|
|
209
222
|
}
|
|
210
223
|
|
|
@@ -351,11 +364,17 @@ function filterRows(
|
|
|
351
364
|
search: string | undefined,
|
|
352
365
|
filters: readonly OverviewFilter[],
|
|
353
366
|
layoutSlug: string | undefined,
|
|
367
|
+
shownTitle: ShownTitle,
|
|
354
368
|
): OverviewRow[] {
|
|
355
369
|
const needle = search?.trim().toLowerCase() ?? "";
|
|
356
370
|
return rows.filter((row) => {
|
|
357
371
|
if (layoutSlug !== undefined && row.layoutSlug !== layoutSlug) return false;
|
|
358
|
-
if (
|
|
372
|
+
if (
|
|
373
|
+
needle !== "" &&
|
|
374
|
+
!`${shownTitle(row)} ${row.title} ${row.slug}`.toLowerCase().includes(needle)
|
|
375
|
+
) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
359
378
|
return filters.every((filter) => filter.value.includes(String(row[filter.field])));
|
|
360
379
|
});
|
|
361
380
|
}
|
|
@@ -379,7 +398,7 @@ export function makeOverviewQuery(policy: TemplateEditPolicy) {
|
|
|
379
398
|
offset: z.number().int().min(0).optional(),
|
|
380
399
|
totalCount: z.boolean().optional(),
|
|
381
400
|
}),
|
|
382
|
-
access:
|
|
401
|
+
access: templateEditAccess(policy.tenantEditable),
|
|
383
402
|
description:
|
|
384
403
|
"Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; templates of notifications registered with `systemOnly` are listed for SystemAdmins only, and a TenantAdmin sees only the templates the app lists in tenantEditable; a tenant override of a template that is not listed is not delivered, so the row shows the system version with `lockedOverride` true (SystemAdmin only); use it to browse and filter all templates at a glance.",
|
|
385
404
|
handler: async (query, ctx) => {
|
|
@@ -419,17 +438,23 @@ export function makeOverviewQuery(policy: TemplateEditPolicy) {
|
|
|
419
438
|
),
|
|
420
439
|
)
|
|
421
440
|
: rowsWithUsage;
|
|
441
|
+
// A seeded title can be an i18n key; search and sort must see what the admin sees.
|
|
442
|
+
const i18n = createI18n(ctx.registry, { defaultLocale: "en" });
|
|
443
|
+
const shownTitle: ShownTitle = (row) => i18n.t(row.title, ctx.locale);
|
|
422
444
|
const filtered = filterRows(
|
|
423
445
|
overviewRows,
|
|
424
446
|
payload.search,
|
|
425
447
|
payload.filters ?? [],
|
|
426
448
|
payload.layoutSlug,
|
|
449
|
+
shownTitle,
|
|
427
450
|
);
|
|
428
451
|
|
|
429
452
|
const sortField: SortField =
|
|
430
453
|
payload.sort !== undefined && isSortField(payload.sort) ? payload.sort : "modifiedAt";
|
|
431
454
|
const direction = (payload.sortDirection ?? "desc") === "desc" ? -1 : 1;
|
|
432
|
-
filtered.sort(
|
|
455
|
+
filtered.sort(
|
|
456
|
+
(a, b) => direction * compareBy(sortField, a, b, shownTitle) || a.id.localeCompare(b.id),
|
|
457
|
+
);
|
|
433
458
|
|
|
434
459
|
const offset = payload.offset ?? 0;
|
|
435
460
|
return {
|
|
@@ -40,7 +40,7 @@ export function makePatchDraftWrite(policy: TemplateEditPolicy) {
|
|
|
40
40
|
}),
|
|
41
41
|
access: templateEditAccess(policy.tenantEditable),
|
|
42
42
|
description:
|
|
43
|
-
"Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. A TenantAdmin may patch only templates the app lists in tenantEditable, otherwise SystemAdmin only. Returns the written row.",
|
|
43
|
+
"Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once within one formatting run and cannot contain a {{placeholder}}: a find across a formatting change fails with text_spans_formatting, a find with a placeholder with placeholder_not_replaceable, and the formatting of the replaced text is kept), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. A TenantAdmin may patch only templates the app lists in tenantEditable, otherwise SystemAdmin only. Returns the written row.",
|
|
44
44
|
handler: async (event, ctx) => {
|
|
45
45
|
const { slug, kind, locale, base, ops } = event.payload;
|
|
46
46
|
const refused = refuseTemplateEdit(policy, event.user, { slug, kind });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import { type TemplateEditPolicy, templateEditAccess } from "../template-edit-policy.ts";
|
|
3
4
|
import {
|
|
4
5
|
emailChannelOf,
|
|
5
6
|
resolveTestMailAddress,
|
|
@@ -10,22 +11,24 @@ export type SendTestStatus =
|
|
|
10
11
|
| { readonly canSend: true; readonly reason: null }
|
|
11
12
|
| { readonly canSend: false; readonly reason: "no_channel" | "no_address" };
|
|
12
13
|
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
14
|
+
export function makeSendTestStatusQuery(policy: TemplateEditPolicy) {
|
|
15
|
+
return defineQueryHandler({
|
|
16
|
+
name: "send-test-status",
|
|
17
|
+
schema: z.object({}),
|
|
18
|
+
access: templateEditAccess(policy.tenantEditable),
|
|
19
|
+
description:
|
|
20
|
+
"Tells whether send-test can reach the calling user: canSend is true when an email channel is mounted and resolves an address for the caller, otherwise reason is no_channel or no_address; the address itself is never returned.",
|
|
21
|
+
handler: async (query, ctx): Promise<SendTestStatus> => {
|
|
22
|
+
const channel = emailChannelOf(ctx.registry);
|
|
23
|
+
if (channel === undefined) return { canSend: false, reason: "no_channel" };
|
|
24
|
+
const address = await resolveTestMailAddress(
|
|
25
|
+
channel,
|
|
26
|
+
testMailChannelContext(ctx, query.user),
|
|
27
|
+
query.user,
|
|
28
|
+
);
|
|
29
|
+
return address === null
|
|
30
|
+
? { canSend: false, reason: "no_address" }
|
|
31
|
+
: { canSend: true, reason: null };
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -442,12 +442,14 @@ export async function seedTextBlock(
|
|
|
442
442
|
row: { id: string; version: number },
|
|
443
443
|
current: TemplateResourceRow | null | undefined,
|
|
444
444
|
): Promise<{ id: string }> => {
|
|
445
|
+
// No folder option keeps the row's folder, so a re-seed cannot drop it from the content tree.
|
|
446
|
+
const folder = opts.folder === undefined ? (current?.folder ?? null) : fields.folder;
|
|
445
447
|
// Skip no-op updates so a legal re-seed on every pod boot does not bump
|
|
446
448
|
// version/modifiedAt (ETag, "last changed", event-store growth).
|
|
447
449
|
if (
|
|
448
450
|
current?.title === fields.title &&
|
|
449
451
|
current?.content === fields.content &&
|
|
450
|
-
current?.folder ===
|
|
452
|
+
current?.folder === folder
|
|
451
453
|
) {
|
|
452
454
|
return { id: String(row.id) };
|
|
453
455
|
}
|
|
@@ -458,7 +460,7 @@ export async function seedTextBlock(
|
|
|
458
460
|
changes: {
|
|
459
461
|
title: fields.title,
|
|
460
462
|
content: fields.content,
|
|
461
|
-
folder
|
|
463
|
+
folder,
|
|
462
464
|
// A re-seed must not revive an archived block; only a served one gets a new served version.
|
|
463
465
|
...(current?.status === "active" && servedStampChanges(row)),
|
|
464
466
|
},
|
|
@@ -533,7 +535,11 @@ export type LegalContentBlock = {
|
|
|
533
535
|
export async function seedLegalContentFromJson(
|
|
534
536
|
db: DbConnection,
|
|
535
537
|
blocks: readonly LegalContentBlock[],
|
|
536
|
-
opts: {
|
|
538
|
+
opts: {
|
|
539
|
+
readonly tenantId?: TenantId;
|
|
540
|
+
readonly by?: SessionUser;
|
|
541
|
+
readonly folder?: string;
|
|
542
|
+
} = {},
|
|
537
543
|
): Promise<void> {
|
|
538
544
|
const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
|
|
539
545
|
const by = opts.by ?? createSystemUser(tenantId);
|
|
@@ -544,6 +550,7 @@ export async function seedLegalContentFromJson(
|
|
|
544
550
|
locale: block.locale,
|
|
545
551
|
title: block.title,
|
|
546
552
|
content: block.content,
|
|
553
|
+
...(opts.folder !== undefined && { folder: opts.folder }),
|
|
547
554
|
ifExists: "update",
|
|
548
555
|
by,
|
|
549
556
|
});
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type UploadedFileBytes,
|
|
20
20
|
uploadFileBytesBeforeTransaction,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
22
|
-
import { fileContextForTenant } from "../file-foundation/index.ts";
|
|
22
|
+
import { fileContextForTenant, readFileRefContent } from "../file-foundation/index.ts";
|
|
23
23
|
import { extensionForResourceContentType, requireForTenant } from "../shared/index.ts";
|
|
24
24
|
import {
|
|
25
25
|
DEFAULT_RESOURCE_MAX_BYTES,
|
|
@@ -29,13 +29,13 @@ import {
|
|
|
29
29
|
import { executor } from "./handlers/shared.ts";
|
|
30
30
|
import {
|
|
31
31
|
attachmentEntriesOf,
|
|
32
|
+
isFileAttachmentEntry,
|
|
32
33
|
type LinkedResourceEntry,
|
|
33
34
|
parseLinkedResourceEntries,
|
|
34
35
|
templatePdfAttachmentLinkSchema,
|
|
35
36
|
} from "./linked-resources.ts";
|
|
36
37
|
import { normalizeResourceImage } from "./resource-image.ts";
|
|
37
38
|
import { type TemplateResourceRow, templateResourcesTable } from "./table.ts";
|
|
38
|
-
import { readTemplateResource } from "./template-resources.ts";
|
|
39
39
|
import { ACTIVE_TEMPLATE_STATUS, SYSTEM_TENANT_ID } from "./web/constants.ts";
|
|
40
40
|
import { TemplateResolverErrors } from "./web/qualified-names.ts";
|
|
41
41
|
|
|
@@ -290,6 +290,44 @@ export type PreparedLinkedResourceCopy = {
|
|
|
290
290
|
readonly upload?: UploadedFileBytes;
|
|
291
291
|
};
|
|
292
292
|
|
|
293
|
+
// The source row's own fileName is personal data and may be ciphertext, so the copy is named from
|
|
294
|
+
// the link entry or the key.
|
|
295
|
+
function copiedFileName(key: string, entry: LinkedResourceEntry, contentType: string): string {
|
|
296
|
+
if (typeof entry !== "string" && isFileAttachmentEntry(entry)) return entry.fileName;
|
|
297
|
+
return `${key}.${extensionForResourceContentType(contentType) ?? "bin"}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function sourceUnreadable(): WriteResult<never> {
|
|
301
|
+
return writeFailure(
|
|
302
|
+
new UnprocessableError(TemplateResolverErrors.resourceSourceUnreadable, {
|
|
303
|
+
i18nKey: "templateResolver.errors.resource.sourceUnreadable",
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Undefined: the fileRef row exists but its bytes cannot be read, which must not look like a
|
|
309
|
+
// deleted file. The provider is chosen by the tenant of the fileRef, not by the caller's.
|
|
310
|
+
async function readCopySource(
|
|
311
|
+
ctx: HandlerContext,
|
|
312
|
+
fileRefId: string,
|
|
313
|
+
): Promise<{ contentType: string; bytes: Uint8Array } | null | undefined> {
|
|
314
|
+
try {
|
|
315
|
+
const found = await readFileRefContent({
|
|
316
|
+
db: ctx.db,
|
|
317
|
+
filesForTenant: (tid) => fileContextForTenant(ctx, ctx.user.tenantId, tid),
|
|
318
|
+
fileRefId,
|
|
319
|
+
allowedTenantIds: [SYSTEM_TENANT_ID, ctx.user.tenantId],
|
|
320
|
+
});
|
|
321
|
+
return found === null ? null : { contentType: found.contentType, bytes: found.content };
|
|
322
|
+
} catch (cause) {
|
|
323
|
+
ctx.log?.warn("template copy: source file could not be read", {
|
|
324
|
+
fileRefId,
|
|
325
|
+
errorClass: cause instanceof Error ? cause.name : typeof cause,
|
|
326
|
+
});
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
293
331
|
// beforeTransaction half: copies the file behind every entry into the target tenant's storage, so a
|
|
294
332
|
// template copied from the system default keeps working once the tenant exports it or measures its
|
|
295
333
|
// attachments. An entry whose source file is gone is left out: it could not be resolved at render
|
|
@@ -309,14 +347,20 @@ export async function prepareLinkedResourceCopiesBeforeTransaction(args: {
|
|
|
309
347
|
copies.push({ key, entry });
|
|
310
348
|
continue;
|
|
311
349
|
}
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
350
|
+
const source = await readCopySource(ctx, typeof entry === "string" ? entry : entry.fileRefId);
|
|
351
|
+
if (source === undefined) {
|
|
352
|
+
await releaseAfterFailure(ctx, releases);
|
|
353
|
+
return sourceUnreadable();
|
|
354
|
+
}
|
|
355
|
+
// skip: the source fileRef is deleted or unknown, so there is nothing to copy
|
|
356
|
+
if (source === null) continue;
|
|
317
357
|
const uploaded = await uploadToTenant(
|
|
318
358
|
{ ctx, user, tenantId },
|
|
319
|
-
{
|
|
359
|
+
{
|
|
360
|
+
fileName: copiedFileName(key, entry, source.contentType),
|
|
361
|
+
mimeType: source.contentType,
|
|
362
|
+
data: source.bytes,
|
|
363
|
+
},
|
|
320
364
|
);
|
|
321
365
|
if (!uploaded.isSuccess) {
|
|
322
366
|
await releaseAfterFailure(ctx, releases);
|
|
@@ -90,6 +90,10 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
90
90
|
"templateResolver.errors.patch.textNotFound": "The text {find} was not found in the block.",
|
|
91
91
|
"templateResolver.errors.patch.textAmbiguous":
|
|
92
92
|
"The text {find} occurs {count} times in the block; use a longer text.",
|
|
93
|
+
"templateResolver.errors.patch.textSpansFormatting":
|
|
94
|
+
"The text {find} crosses a formatting change in the block; replace each formatted part on its own.",
|
|
95
|
+
"templateResolver.errors.patch.placeholderNotReplaceable":
|
|
96
|
+
"The placeholder {find} cannot be replaced as text; set the text around it with another operation or remove the block.",
|
|
93
97
|
"templateResolver.errors.patch.blockNotAllowed":
|
|
94
98
|
"The block type {type} is not available in {kind} templates.",
|
|
95
99
|
"templateResolver.errors.patch.blockTypeMismatch":
|
|
@@ -113,6 +117,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
113
117
|
"Your account has no email address to send the test mail to.",
|
|
114
118
|
"templateResolver.errors.testMail.failed": "The test mail could not be sent.",
|
|
115
119
|
"templateResolver.errors.resource.tooLarge": "The image is too large.",
|
|
120
|
+
"templateResolver.errors.resource.sourceUnreadable":
|
|
121
|
+
"A file of the source template could not be read, so nothing was copied.",
|
|
116
122
|
"templateResolver.errors.resource.unsupportedImage":
|
|
117
123
|
"This file is not a supported image. Use PNG, JPEG, WebP or GIF.",
|
|
118
124
|
"templateResolver.errors.template.systemResourcesNeedSystemTenant":
|
|
@@ -68,6 +68,7 @@ export const TemplateResolverErrors = {
|
|
|
68
68
|
bundleLocalesDiffer: "bundle_locales_differ",
|
|
69
69
|
resourceTooLarge: "template_resource_too_large",
|
|
70
70
|
resourceUnsupportedImage: "template_resource_unsupported_image",
|
|
71
|
+
resourceSourceUnreadable: "template_resource_source_unreadable",
|
|
71
72
|
attachmentUnsupportedType: "template_attachment_unsupported_type",
|
|
72
73
|
attachmentLimit: "template_attachment_limit",
|
|
73
74
|
attachmentInvalid: "template_attachment_invalid",
|
|
@@ -71,6 +71,7 @@ export function collectAssignableAppRoles(
|
|
|
71
71
|
|
|
72
72
|
export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefinition {
|
|
73
73
|
return defineFeature("tenant", (r) => {
|
|
74
|
+
r.adoptsBundledLists();
|
|
74
75
|
r.describe(
|
|
75
76
|
"Registers the three core multi-tenancy entities \u2014 `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) \u2014 along with write handlers for create/update/disable/enable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
|
|
76
77
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { isUndefinedTable, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
4
|
definePagedQueryHandler,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
9
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
10
|
-
import {
|
|
10
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
11
11
|
import * as z from "zod";
|
|
12
12
|
import { userSessionTable } from "../../sessions/index.ts";
|
|
13
13
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.ts";
|
|
@@ -142,7 +142,7 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
142
142
|
// ponytail: lean sample apps (admin-console) mount auth without
|
|
143
143
|
// sessions — store_user_sessions never gets pushed. Integration tests
|
|
144
144
|
// may seed the table without mounting the feature; prod mounts both.
|
|
145
|
-
if (
|
|
145
|
+
if (!isUndefinedTable(err)) throw err;
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenant destroy keeps user keys of members in other live tenants",
|
|
6
|
+
"detail": "The subject-keys stage erases user keys only for members without a membership in another tenant whose status is not destroyed. The tenant and tenant-record keys are erased as before."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -163,6 +163,35 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<StageRunOut
|
|
|
163
163
|
return total.outcome();
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// A user key outlives the tenant while the user still belongs to another live tenant:
|
|
167
|
+
// erasing it would tombstone the key and break every later PII write for that user.
|
|
168
|
+
async function userIdsWithoutOtherLiveTenant(
|
|
169
|
+
ctx: DestructionStageCtx,
|
|
170
|
+
userIds: readonly string[],
|
|
171
|
+
): Promise<readonly string[]> {
|
|
172
|
+
// skip: no members, nothing to look up.
|
|
173
|
+
if (userIds.length === 0) return [];
|
|
174
|
+
const otherMemberships = (
|
|
175
|
+
await selectMany<{ userId: string; tenantId: string }>(ctx.db, tenantMembershipsTable, {
|
|
176
|
+
userId: { in: [...userIds] },
|
|
177
|
+
})
|
|
178
|
+
).filter((membership) => membership.tenantId !== ctx.tenantId);
|
|
179
|
+
// skip: every member belongs to this tenant only.
|
|
180
|
+
if (otherMemberships.length === 0) return userIds;
|
|
181
|
+
const otherTenants = await selectMany<{ id: string; status: string }>(ctx.db, tenantTable, {
|
|
182
|
+
id: { in: [...new Set(otherMemberships.map((membership) => membership.tenantId))] },
|
|
183
|
+
});
|
|
184
|
+
const liveTenantIds = new Set(
|
|
185
|
+
otherTenants.filter((tenant) => tenant.status !== "destroyed").map((tenant) => tenant.id),
|
|
186
|
+
);
|
|
187
|
+
const retainedUserIds = new Set(
|
|
188
|
+
otherMemberships
|
|
189
|
+
.filter((membership) => liveTenantIds.has(membership.tenantId))
|
|
190
|
+
.map((membership) => membership.userId),
|
|
191
|
+
);
|
|
192
|
+
return userIds.filter((userId) => !retainedUserIds.has(userId));
|
|
193
|
+
}
|
|
194
|
+
|
|
166
195
|
async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
167
196
|
const kms = configuredPiiSubjectKms();
|
|
168
197
|
if (!kms) {
|
|
@@ -177,7 +206,12 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutco
|
|
|
177
206
|
const subjects: SubjectId[] = [
|
|
178
207
|
{ kind: "tenant", tenantId: ctx.tenantId },
|
|
179
208
|
tenantRecordSubject,
|
|
180
|
-
...
|
|
209
|
+
...(
|
|
210
|
+
await userIdsWithoutOtherLiveTenant(
|
|
211
|
+
ctx,
|
|
212
|
+
memberships.map((m) => m.userId),
|
|
213
|
+
)
|
|
214
|
+
).map((userId) => ({ kind: "user" as const, userId })),
|
|
181
215
|
];
|
|
182
216
|
for (const subject of subjects) {
|
|
183
217
|
await kms.eraseKey(subject, {
|
|
@@ -19,6 +19,7 @@ import { userEditScreen, userListScreen } from "./screens.ts";
|
|
|
19
19
|
// Membership + tenant-specific roles live in the tenant feature.
|
|
20
20
|
export function createUserFeature(): FeatureDefinition {
|
|
21
21
|
return defineFeature("user", (r) => {
|
|
22
|
+
r.adoptsBundledLists();
|
|
22
23
|
r.describe(
|
|
23
24
|
"Manages the cross-tenant user identity: the `read_users` table holds each user's email, `displayName`, global `roles`, `emailVerified` flag, and lifecycle `status` (active / restricted / deletionRequested / deleted). Because users exist above any individual tenant, the feature runs with `r.systemScope()` \u2014 membership and tenant-specific roles live in the `tenant` feature instead. Add this feature whenever your app needs a persistent, tenant-agnostic user record that auth and GDPR pipelines can reference.",
|
|
24
25
|
);
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "USER_DATA_RIGHTS_FEATURE is exported from the barrel",
|
|
6
|
+
"detail": "The feature name constant that lived in web/constants.ts is now part of the public barrel."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Complete, exported handler name constants",
|
|
12
|
+
"detail": "UserDataRightsHandlers now covers all eight write handlers and UserDataRightsQueries all five explicit query handlers, and both are exported from user-data-rights and user-data-rights/web. A test compares them with the feature's registered handlers."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.384.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -188,6 +188,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
188
188
|
let warnedMissingMailDelivery = false;
|
|
189
189
|
const showDeletion = opts.privacyCenterShowDeletion ?? true;
|
|
190
190
|
return defineFeature("user-data-rights", (r) => {
|
|
191
|
+
r.adoptsBundledLists();
|
|
191
192
|
r.describe(
|
|
192
193
|
'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. When `mail-foundation` and a `mail-transport-*` are mounted, it also sends the four GDPR notifications (export ready/failed, deletion requested/executed) itself with no app callback code, rendered in each recipient’s locale. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
|
|
193
194
|
);
|
|
@@ -31,4 +31,9 @@ export {
|
|
|
31
31
|
exportJobEntity,
|
|
32
32
|
exportJobsTable,
|
|
33
33
|
} from "./schema/export-job.ts";
|
|
34
|
-
export {
|
|
34
|
+
export {
|
|
35
|
+
TENANT_MODEL_CONFIG_KEY,
|
|
36
|
+
USER_DATA_RIGHTS_FEATURE,
|
|
37
|
+
UserDataRightsHandlers,
|
|
38
|
+
UserDataRightsQueries,
|
|
39
|
+
} from "./web/constants.ts";
|
|
@@ -27,6 +27,8 @@ export const UserDataRightsQueries = {
|
|
|
27
27
|
exportStatus: "user-data-rights:query:export-status",
|
|
28
28
|
myAuditLog: "user-data-rights:query:my-audit-log",
|
|
29
29
|
downloadByJob: "user-data-rights:query:download-by-job",
|
|
30
|
+
downloadByToken: "user-data-rights:query:download-by-token",
|
|
31
|
+
listDownloadAttempts: "user-data-rights:query:list-download-attempts",
|
|
30
32
|
} as const;
|
|
31
33
|
|
|
32
34
|
// App-level config: tenant-occupancy model. "single-user" tells the forget
|
|
@@ -39,8 +41,12 @@ export const TENANT_MODEL_CONFIG_KEY = "user-data-rights:config:tenant-model" as
|
|
|
39
41
|
export const UserDataRightsHandlers = {
|
|
40
42
|
requestExport: "user-data-rights:write:request-export",
|
|
41
43
|
requestDeletion: "user-data-rights:write:request-deletion",
|
|
44
|
+
requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email",
|
|
45
|
+
confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token",
|
|
42
46
|
cancelDeletion: "user-data-rights:write:cancel-deletion",
|
|
43
47
|
restrictAccount: "user-data-rights:write:restrict-account",
|
|
48
|
+
liftRestriction: "user-data-rights:write:lift-restriction",
|
|
49
|
+
runForgetCleanup: "user-data-rights:write:run-forget-cleanup",
|
|
44
50
|
} as const;
|
|
45
51
|
|
|
46
52
|
// Foreign QN: the lifecycle status (active / deletionRequested / restricted)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export { type UserDataRightsClientOptions, userDataRightsClient } from "./client-plugin.tsx";
|
|
8
8
|
export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen.tsx";
|
|
9
9
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.tsx";
|
|
10
|
+
export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.ts";
|
|
10
11
|
export { defaultTranslations } from "./default-translations.ts";
|
|
11
12
|
export { formatDate } from "./privacy-center-screen.tsx";
|
|
12
13
|
export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate.tsx";
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A failing notifyRecipient SQL statement no longer aborts the submit",
|
|
6
|
+
"detail": "The resolver receives a ctx whose systemDb runs in a savepoint, so a failed statement is rolled back and the submit answers 200 with the entry stored. Only ctx.systemDb is savepoint-scoped; other ctx handles stay in the handler transaction."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "waitlist purge retry after a failed shred is covered",
|
|
12
|
+
"detail": "An integration test fails the second key shred of a purge, checks that the entries stay and no audit event is written, and that the retried purge erases every entry and records its audit event. No runtime change."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.389.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Waitlist import enforces the live field limits",
|
|
18
|
+
"detail": "migrateLegacyWaitlist counts a source row with a value above the live field limit as unreadable instead of importing it; importWaitlistEntry throws for it."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.388.0",
|
|
4
22
|
"type": "fix",
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
|
|
33
33
|
export function createWaitlistFeature(opts: WaitlistOptions = {}): FeatureDefinition {
|
|
34
34
|
return defineFeature(WAITLIST_FEATURE, (r) => {
|
|
35
|
+
r.adoptsBundledLists();
|
|
35
36
|
r.describe(
|
|
36
37
|
"Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
|
|
37
38
|
);
|