@cosmicdrift/kumiko-bundled-features 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/dist/agent-tools/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/tool-dispatch.d.ts +8 -3
- package/dist/agent-tools/tool-dispatch.js +36 -25
- package/dist/audit/feature.js +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
- package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
- package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
- package/dist/auth-email-password/magic-link-mail.js +7 -3
- package/dist/auth-email-password/signup-activation.d.ts +1 -0
- package/dist/auth-email-password/signup-activation.js +1 -0
- package/dist/auth-email-password/web/auth-client.d.ts +2 -2
- package/dist/auth-email-password/web/auth-client.js +8 -4
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
- package/dist/auth-email-password/web/auth-redirect.js +20 -25
- package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
- package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
- package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
- package/dist/auth-email-password/web/index.d.ts +1 -0
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/login-screen.js +11 -6
- package/dist/auth-email-password/web/reset-password-screen.js +7 -1
- package/dist/auth-email-password/web/return-path.d.ts +4 -0
- package/dist/auth-email-password/web/return-path.js +34 -0
- package/dist/auth-email-password/web/session.js +6 -1
- package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
- package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
- package/dist/auth-email-password/web/signup-screen.js +8 -3
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +3 -7
- package/dist/auth-mfa/mfa-status-checker.js +5 -3
- package/dist/cap-counter/cap-reservation.js +15 -6
- package/dist/cap-counter/feature.js +1 -0
- package/dist/cap-overview/feature.js +1 -0
- package/dist/config/handlers/set.write.js +12 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.js +1 -1
- package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
- package/dist/delivery/feature.js +1 -0
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/system-channel-context.d.ts +15 -0
- package/dist/delivery/system-channel-context.js +5 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
- package/dist/document-ingest-foundation/events.d.ts +11 -0
- package/dist/document-ingest-foundation/events.js +11 -0
- package/dist/document-ingest-foundation/index.d.ts +2 -1
- package/dist/document-ingest-foundation/index.js +2 -1
- package/dist/feature-toggles/feature.js +1 -0
- package/dist/host-routing/validate-options.js +3 -0
- package/dist/inbound-mail-foundation/feature.js +1 -0
- package/dist/jobs/feature.js +1 -0
- package/dist/ledger/web/index.js +1 -1
- package/dist/legal-pages/seed.d.ts +3 -1
- package/dist/legal-pages/seed.js +2 -2
- package/dist/mail-transport-smtp/config-keys.js +1 -1
- package/dist/mail-transport-smtp/feature.js +6 -5
- package/dist/managed-pages/constants.d.ts +1 -0
- package/dist/managed-pages/constants.js +1 -0
- package/dist/managed-pages/feature.js +3 -1
- package/dist/managed-pages/index.d.ts +1 -0
- package/dist/managed-pages/index.js +1 -0
- package/dist/notes-history/handlers/add-note.write.js +1 -1
- package/dist/personal-access-tokens/feature.js +1 -0
- package/dist/secrets/write-gate.js +8 -2
- package/dist/sessions/feature.js +1 -0
- package/dist/sessions/index.d.ts +1 -1
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/session-callbacks.d.ts +0 -2
- package/dist/sessions/session-callbacks.js +1 -3
- package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
- package/dist/shared/purge-files-reaching-subject.js +19 -2
- package/dist/shared/run-in-sub-transaction.js +5 -5
- package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
- package/dist/step-dispatcher/mail-runner.d.ts +1 -0
- package/dist/tags/feature.js +1 -0
- package/dist/template-resolver/document-ops.d.ts +5 -0
- package/dist/template-resolver/document-ops.js +18 -2
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
- package/dist/template-resolver/handlers/layout-source.query.js +26 -6
- package/dist/template-resolver/handlers/overview.query.js +15 -7
- package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
- package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
- package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
- package/dist/template-resolver/seeding.d.ts +1 -0
- package/dist/template-resolver/seeding.js +5 -2
- package/dist/template-resolver/store-template-resource.js +46 -6
- package/dist/template-resolver/web/i18n.js +3 -0
- package/dist/template-resolver/web/qualified-names.d.ts +1 -0
- package/dist/template-resolver/web/qualified-names.js +1 -0
- package/dist/tenant/feature.js +1 -0
- package/dist/tenant/handlers/team-list.query.js +3 -3
- package/dist/tenant-lifecycle/stages.js +22 -1
- package/dist/user/feature.js +1 -0
- package/dist/user-data-rights/feature.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -1
- package/dist/user-data-rights/index.js +1 -1
- package/dist/user-data-rights/web/constants.d.ts +6 -0
- package/dist/user-data-rights/web/constants.js +6 -0
- package/dist/user-data-rights/web/index.d.ts +1 -0
- package/dist/user-data-rights/web/index.js +1 -0
- package/dist/waitlist/feature.js +1 -0
- package/dist/waitlist/handlers/submit.write.js +13 -9
- package/dist/waitlist/import.d.ts +2 -1
- package/dist/waitlist/import.js +22 -2
- package/dist/waitlist/legacy-migrate.js +2 -2
- package/dist/waitlist/options.d.ts +2 -1
- package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
- package/package.json +11 -11
- package/src/agent-tools/changes.json +6 -0
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/cap-counter/changes.json +12 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +12 -0
- package/src/delivery/changes.json +12 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/host-routing/changes.json +6 -0
- package/src/mail-transport-smtp/changes.json +6 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- package/src/sessions/changes.json +7 -0
- package/src/step-dispatcher/changes.json +6 -0
- package/src/template-resolver/changes.json +36 -0
- package/src/tenant-lifecycle/changes.json +6 -0
- package/src/user-data-rights/changes.json +12 -0
- package/src/waitlist/changes.json +18 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { type TemplateEditPolicy } from "../template-edit-policy.ts";
|
|
2
3
|
export type SendTestStatus = {
|
|
3
4
|
readonly canSend: true;
|
|
4
5
|
readonly reason: null;
|
|
@@ -6,4 +7,4 @@ export type SendTestStatus = {
|
|
|
6
7
|
readonly canSend: false;
|
|
7
8
|
readonly reason: "no_channel" | "no_address";
|
|
8
9
|
};
|
|
9
|
-
export declare
|
|
10
|
+
export declare function makeSendTestStatusQuery(policy: TemplateEditPolicy): import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"send-test-status", z.ZodObject<{}, z.core.$strip>, SendTestStatus, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import { templateEditAccess } from "../template-edit-policy.js";
|
|
3
4
|
import { emailChannelOf, resolveTestMailAddress, testMailChannelContext, } from "./send-test-shared.js";
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
5
|
+
export function makeSendTestStatusQuery(policy) {
|
|
6
|
+
return defineQueryHandler({
|
|
7
|
+
name: "send-test-status",
|
|
8
|
+
schema: z.object({}),
|
|
9
|
+
access: templateEditAccess(policy.tenantEditable),
|
|
10
|
+
description: "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.",
|
|
11
|
+
handler: async (query, ctx) => {
|
|
12
|
+
const channel = emailChannelOf(ctx.registry);
|
|
13
|
+
if (channel === undefined)
|
|
14
|
+
return { canSend: false, reason: "no_channel" };
|
|
15
|
+
const address = await resolveTestMailAddress(channel, testMailChannelContext(ctx, query.user), query.user);
|
|
16
|
+
return address === null
|
|
17
|
+
? { canSend: false, reason: "no_address" }
|
|
18
|
+
: { canSend: true, reason: null };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -295,11 +295,13 @@ export async function seedTextBlock(db, opts) {
|
|
|
295
295
|
status: "active",
|
|
296
296
|
};
|
|
297
297
|
const applyUpdate = async (row, current) => {
|
|
298
|
+
// No folder option keeps the row's folder, so a re-seed cannot drop it from the content tree.
|
|
299
|
+
const folder = opts.folder === undefined ? (current?.folder ?? null) : fields.folder;
|
|
298
300
|
// Skip no-op updates so a legal re-seed on every pod boot does not bump
|
|
299
301
|
// version/modifiedAt (ETag, "last changed", event-store growth).
|
|
300
302
|
if (current?.title === fields.title &&
|
|
301
303
|
current?.content === fields.content &&
|
|
302
|
-
current?.folder ===
|
|
304
|
+
current?.folder === folder) {
|
|
303
305
|
return { id: String(row.id) };
|
|
304
306
|
}
|
|
305
307
|
const result = await executor.update({
|
|
@@ -308,7 +310,7 @@ export async function seedTextBlock(db, opts) {
|
|
|
308
310
|
changes: {
|
|
309
311
|
title: fields.title,
|
|
310
312
|
content: fields.content,
|
|
311
|
-
folder
|
|
313
|
+
folder,
|
|
312
314
|
// A re-seed must not revive an archived block; only a served one gets a new served version.
|
|
313
315
|
...(current?.status === "active" && servedStampChanges(row)),
|
|
314
316
|
},
|
|
@@ -369,6 +371,7 @@ export async function seedLegalContentFromJson(db, blocks, opts = {}) {
|
|
|
369
371
|
locale: block.locale,
|
|
370
372
|
title: block.title,
|
|
371
373
|
content: block.content,
|
|
374
|
+
...(opts.folder !== undefined && { folder: opts.folder }),
|
|
372
375
|
ifExists: "update",
|
|
373
376
|
by,
|
|
374
377
|
});
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { NotFoundError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "@cosmicdrift/kumiko-framework/files";
|
|
4
|
-
import { fileContextForTenant } from "../file-foundation/index.js";
|
|
4
|
+
import { fileContextForTenant, readFileRefContent } from "../file-foundation/index.js";
|
|
5
5
|
import { extensionForResourceContentType, requireForTenant } from "../shared/index.js";
|
|
6
6
|
import { DEFAULT_RESOURCE_MAX_BYTES, MAX_FILE_ATTACHMENTS_PER_TEMPLATE, RESOURCE_MAX_BYTES_CONFIG_KEY, } from "./handlers/resource-shared.js";
|
|
7
7
|
import { executor } from "./handlers/shared.js";
|
|
8
|
-
import { attachmentEntriesOf, parseLinkedResourceEntries, templatePdfAttachmentLinkSchema, } from "./linked-resources.js";
|
|
8
|
+
import { attachmentEntriesOf, isFileAttachmentEntry, parseLinkedResourceEntries, templatePdfAttachmentLinkSchema, } from "./linked-resources.js";
|
|
9
9
|
import { normalizeResourceImage } from "./resource-image.js";
|
|
10
10
|
import { templateResourcesTable } from "./table.js";
|
|
11
|
-
import { readTemplateResource } from "./template-resources.js";
|
|
12
11
|
import { ACTIVE_TEMPLATE_STATUS, SYSTEM_TENANT_ID } from "./web/constants.js";
|
|
13
12
|
import { TemplateResolverErrors } from "./web/qualified-names.js";
|
|
14
13
|
export async function readTemplateResourceMaxBytes(config, registry) {
|
|
@@ -143,6 +142,38 @@ export async function releaseAfterFailure(ctx, releases) {
|
|
|
143
142
|
});
|
|
144
143
|
}
|
|
145
144
|
}
|
|
145
|
+
// The source row's own fileName is personal data and may be ciphertext, so the copy is named from
|
|
146
|
+
// the link entry or the key.
|
|
147
|
+
function copiedFileName(key, entry, contentType) {
|
|
148
|
+
if (typeof entry !== "string" && isFileAttachmentEntry(entry))
|
|
149
|
+
return entry.fileName;
|
|
150
|
+
return `${key}.${extensionForResourceContentType(contentType) ?? "bin"}`;
|
|
151
|
+
}
|
|
152
|
+
function sourceUnreadable() {
|
|
153
|
+
return writeFailure(new UnprocessableError(TemplateResolverErrors.resourceSourceUnreadable, {
|
|
154
|
+
i18nKey: "templateResolver.errors.resource.sourceUnreadable",
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
// Undefined: the fileRef row exists but its bytes cannot be read, which must not look like a
|
|
158
|
+
// deleted file. The provider is chosen by the tenant of the fileRef, not by the caller's.
|
|
159
|
+
async function readCopySource(ctx, fileRefId) {
|
|
160
|
+
try {
|
|
161
|
+
const found = await readFileRefContent({
|
|
162
|
+
db: ctx.db,
|
|
163
|
+
filesForTenant: (tid) => fileContextForTenant(ctx, ctx.user.tenantId, tid),
|
|
164
|
+
fileRefId,
|
|
165
|
+
allowedTenantIds: [SYSTEM_TENANT_ID, ctx.user.tenantId],
|
|
166
|
+
});
|
|
167
|
+
return found === null ? null : { contentType: found.contentType, bytes: found.content };
|
|
168
|
+
}
|
|
169
|
+
catch (cause) {
|
|
170
|
+
ctx.log?.warn("template copy: source file could not be read", {
|
|
171
|
+
fileRefId,
|
|
172
|
+
errorClass: cause instanceof Error ? cause.name : typeof cause,
|
|
173
|
+
});
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
146
177
|
// beforeTransaction half: copies the file behind every entry into the target tenant's storage, so a
|
|
147
178
|
// template copied from the system default keeps working once the tenant exports it or measures its
|
|
148
179
|
// attachments. An entry whose source file is gone is left out: it could not be resolved at render
|
|
@@ -157,10 +188,19 @@ export async function prepareLinkedResourceCopiesBeforeTransaction(args) {
|
|
|
157
188
|
copies.push({ key, entry });
|
|
158
189
|
continue;
|
|
159
190
|
}
|
|
160
|
-
const
|
|
161
|
-
if (
|
|
191
|
+
const source = await readCopySource(ctx, typeof entry === "string" ? entry : entry.fileRefId);
|
|
192
|
+
if (source === undefined) {
|
|
193
|
+
await releaseAfterFailure(ctx, releases);
|
|
194
|
+
return sourceUnreadable();
|
|
195
|
+
}
|
|
196
|
+
// skip: the source fileRef is deleted or unknown, so there is nothing to copy
|
|
197
|
+
if (source === null)
|
|
162
198
|
continue;
|
|
163
|
-
const uploaded = await uploadToTenant({ ctx, user, tenantId }, {
|
|
199
|
+
const uploaded = await uploadToTenant({ ctx, user, tenantId }, {
|
|
200
|
+
fileName: copiedFileName(key, entry, source.contentType),
|
|
201
|
+
mimeType: source.contentType,
|
|
202
|
+
data: source.bytes,
|
|
203
|
+
});
|
|
164
204
|
if (!uploaded.isSuccess) {
|
|
165
205
|
await releaseAfterFailure(ctx, releases);
|
|
166
206
|
return uploaded;
|
|
@@ -72,6 +72,8 @@ export const defaultTranslations = {
|
|
|
72
72
|
"templateResolver.errors.patch.blockRemoved": "The block {blockId} was already removed by an earlier step.",
|
|
73
73
|
"templateResolver.errors.patch.textNotFound": "The text {find} was not found in the block.",
|
|
74
74
|
"templateResolver.errors.patch.textAmbiguous": "The text {find} occurs {count} times in the block; use a longer text.",
|
|
75
|
+
"templateResolver.errors.patch.textSpansFormatting": "The text {find} crosses a formatting change in the block; replace each formatted part on its own.",
|
|
76
|
+
"templateResolver.errors.patch.placeholderNotReplaceable": "The placeholder {find} cannot be replaced as text; set the text around it with another operation or remove the block.",
|
|
75
77
|
"templateResolver.errors.patch.blockNotAllowed": "The block type {type} is not available in {kind} templates.",
|
|
76
78
|
"templateResolver.errors.patch.blockTypeMismatch": "This step needs a {expected} block, but the block is a {actual}.",
|
|
77
79
|
"templateResolver.errors.patch.invalidResourceKey": "The image key {resource} is not valid.",
|
|
@@ -86,6 +88,7 @@ export const defaultTranslations = {
|
|
|
86
88
|
"templateResolver.errors.testMail.noAddress": "Your account has no email address to send the test mail to.",
|
|
87
89
|
"templateResolver.errors.testMail.failed": "The test mail could not be sent.",
|
|
88
90
|
"templateResolver.errors.resource.tooLarge": "The image is too large.",
|
|
91
|
+
"templateResolver.errors.resource.sourceUnreadable": "A file of the source template could not be read, so nothing was copied.",
|
|
89
92
|
"templateResolver.errors.resource.unsupportedImage": "This file is not a supported image. Use PNG, JPEG, WebP or GIF.",
|
|
90
93
|
"templateResolver.errors.template.systemResourcesNeedSystemTenant": "Resource files of system templates can only be imported or exported by a user of the system tenant.",
|
|
91
94
|
"templateResolver.errors.template.bundleLocalesDiffer": "The locales of this template differ in content format or variables, so they cannot be exported as one bundle.",
|
|
@@ -53,6 +53,7 @@ export declare const TemplateResolverErrors: {
|
|
|
53
53
|
readonly bundleLocalesDiffer: "bundle_locales_differ";
|
|
54
54
|
readonly resourceTooLarge: "template_resource_too_large";
|
|
55
55
|
readonly resourceUnsupportedImage: "template_resource_unsupported_image";
|
|
56
|
+
readonly resourceSourceUnreadable: "template_resource_source_unreadable";
|
|
56
57
|
readonly attachmentUnsupportedType: "template_attachment_unsupported_type";
|
|
57
58
|
readonly attachmentLimit: "template_attachment_limit";
|
|
58
59
|
readonly attachmentInvalid: "template_attachment_invalid";
|
|
@@ -63,6 +63,7 @@ export const TemplateResolverErrors = {
|
|
|
63
63
|
bundleLocalesDiffer: "bundle_locales_differ",
|
|
64
64
|
resourceTooLarge: "template_resource_too_large",
|
|
65
65
|
resourceUnsupportedImage: "template_resource_unsupported_image",
|
|
66
|
+
resourceSourceUnreadable: "template_resource_source_unreadable",
|
|
66
67
|
attachmentUnsupportedType: "template_attachment_unsupported_type",
|
|
67
68
|
attachmentLimit: "template_attachment_limit",
|
|
68
69
|
attachmentInvalid: "template_attachment_invalid",
|
package/dist/tenant/feature.js
CHANGED
|
@@ -31,6 +31,7 @@ export function collectAssignableAppRoles(features) {
|
|
|
31
31
|
// --- Feature ---
|
|
32
32
|
export function createTenantFeature(options) {
|
|
33
33
|
return defineFeature("tenant", (r) => {
|
|
34
|
+
r.adoptsBundledLists();
|
|
34
35
|
r.describe("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.");
|
|
35
36
|
r.uiHints({
|
|
36
37
|
displayLabel: "Multi-Tenant Core",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { isUndefinedTable, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
|
-
import {
|
|
6
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
import { userSessionTable } from "../../sessions/index.js";
|
|
9
9
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
|
|
@@ -110,7 +110,7 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
110
110
|
// ponytail: lean sample apps (admin-console) mount auth without
|
|
111
111
|
// sessions — store_user_sessions never gets pushed. Integration tests
|
|
112
112
|
// may seed the table without mounting the feature; prod mounts both.
|
|
113
|
-
if (
|
|
113
|
+
if (!isUndefinedTable(err))
|
|
114
114
|
throw err;
|
|
115
115
|
}
|
|
116
116
|
}
|
|
@@ -73,6 +73,27 @@ async function runTenantDataHooks(ctx) {
|
|
|
73
73
|
}
|
|
74
74
|
return total.outcome();
|
|
75
75
|
}
|
|
76
|
+
// A user key outlives the tenant while the user still belongs to another live tenant:
|
|
77
|
+
// erasing it would tombstone the key and break every later PII write for that user.
|
|
78
|
+
async function userIdsWithoutOtherLiveTenant(ctx, userIds) {
|
|
79
|
+
// skip: no members, nothing to look up.
|
|
80
|
+
if (userIds.length === 0)
|
|
81
|
+
return [];
|
|
82
|
+
const otherMemberships = (await selectMany(ctx.db, tenantMembershipsTable, {
|
|
83
|
+
userId: { in: [...userIds] },
|
|
84
|
+
})).filter((membership) => membership.tenantId !== ctx.tenantId);
|
|
85
|
+
// skip: every member belongs to this tenant only.
|
|
86
|
+
if (otherMemberships.length === 0)
|
|
87
|
+
return userIds;
|
|
88
|
+
const otherTenants = await selectMany(ctx.db, tenantTable, {
|
|
89
|
+
id: { in: [...new Set(otherMemberships.map((membership) => membership.tenantId))] },
|
|
90
|
+
});
|
|
91
|
+
const liveTenantIds = new Set(otherTenants.filter((tenant) => tenant.status !== "destroyed").map((tenant) => tenant.id));
|
|
92
|
+
const retainedUserIds = new Set(otherMemberships
|
|
93
|
+
.filter((membership) => liveTenantIds.has(membership.tenantId))
|
|
94
|
+
.map((membership) => membership.userId));
|
|
95
|
+
return userIds.filter((userId) => !retainedUserIds.has(userId));
|
|
96
|
+
}
|
|
76
97
|
async function eraseSubjectKeys(ctx) {
|
|
77
98
|
const kms = configuredPiiSubjectKms();
|
|
78
99
|
if (!kms) {
|
|
@@ -87,7 +108,7 @@ async function eraseSubjectKeys(ctx) {
|
|
|
87
108
|
const subjects = [
|
|
88
109
|
{ kind: "tenant", tenantId: ctx.tenantId },
|
|
89
110
|
tenantRecordSubject,
|
|
90
|
-
...memberships.map((m) => ({ kind: "user", userId
|
|
111
|
+
...(await userIdsWithoutOtherLiveTenant(ctx, memberships.map((m) => m.userId))).map((userId) => ({ kind: "user", userId })),
|
|
91
112
|
];
|
|
92
113
|
for (const subject of subjects) {
|
|
93
114
|
await kms.eraseKey(subject, {
|
package/dist/user/feature.js
CHANGED
|
@@ -14,6 +14,7 @@ import { userEditScreen, userListScreen } from "./screens.js";
|
|
|
14
14
|
// Membership + tenant-specific roles live in the tenant feature.
|
|
15
15
|
export function createUserFeature() {
|
|
16
16
|
return defineFeature("user", (r) => {
|
|
17
|
+
r.adoptsBundledLists();
|
|
17
18
|
r.describe("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.");
|
|
18
19
|
r.uiHints({
|
|
19
20
|
displayLabel: "User Identity",
|
|
@@ -51,6 +51,7 @@ export function createUserDataRightsFeature(opts = {}) {
|
|
|
51
51
|
let warnedMissingMailDelivery = false;
|
|
52
52
|
const showDeletion = opts.privacyCenterShowDeletion ?? true;
|
|
53
53
|
return defineFeature("user-data-rights", (r) => {
|
|
54
|
+
r.adoptsBundledLists();
|
|
54
55
|
r.describe('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`.');
|
|
55
56
|
r.uiHints({
|
|
56
57
|
displayLabel: "User Data Rights \u00b7 GDPR",
|
|
@@ -11,4 +11,4 @@ export type { UserExportBundle } from "./run-user-export.ts";
|
|
|
11
11
|
export { runUserExport } from "./run-user-export.ts";
|
|
12
12
|
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.ts";
|
|
13
13
|
export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, type ExportJobStatus, exportJobEntity, exportJobsTable, } from "./schema/export-job.ts";
|
|
14
|
-
export { TENANT_MODEL_CONFIG_KEY } from "./web/constants.ts";
|
|
14
|
+
export { TENANT_MODEL_CONFIG_KEY, USER_DATA_RIGHTS_FEATURE, UserDataRightsHandlers, UserDataRightsQueries, } from "./web/constants.ts";
|
|
@@ -15,4 +15,4 @@ export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
|
|
|
15
15
|
export { runUserExport } from "./run-user-export.js";
|
|
16
16
|
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
|
|
17
17
|
export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable, } from "./schema/export-job.js";
|
|
18
|
-
export { TENANT_MODEL_CONFIG_KEY } from "./web/constants.js";
|
|
18
|
+
export { TENANT_MODEL_CONFIG_KEY, USER_DATA_RIGHTS_FEATURE, UserDataRightsHandlers, UserDataRightsQueries, } from "./web/constants.js";
|
|
@@ -6,13 +6,19 @@ export declare const UserDataRightsQueries: {
|
|
|
6
6
|
readonly exportStatus: "user-data-rights:query:export-status";
|
|
7
7
|
readonly myAuditLog: "user-data-rights:query:my-audit-log";
|
|
8
8
|
readonly downloadByJob: "user-data-rights:query:download-by-job";
|
|
9
|
+
readonly downloadByToken: "user-data-rights:query:download-by-token";
|
|
10
|
+
readonly listDownloadAttempts: "user-data-rights:query:list-download-attempts";
|
|
9
11
|
};
|
|
10
12
|
export declare const TENANT_MODEL_CONFIG_KEY: "user-data-rights:config:tenant-model";
|
|
11
13
|
export declare const UserDataRightsHandlers: {
|
|
12
14
|
readonly requestExport: "user-data-rights:write:request-export";
|
|
13
15
|
readonly requestDeletion: "user-data-rights:write:request-deletion";
|
|
16
|
+
readonly requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email";
|
|
17
|
+
readonly confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token";
|
|
14
18
|
readonly cancelDeletion: "user-data-rights:write:cancel-deletion";
|
|
15
19
|
readonly restrictAccount: "user-data-rights:write:restrict-account";
|
|
20
|
+
readonly liftRestriction: "user-data-rights:write:lift-restriction";
|
|
21
|
+
readonly runForgetCleanup: "user-data-rights:write:run-forget-cleanup";
|
|
16
22
|
};
|
|
17
23
|
export declare const USER_ME_QUERY: "user:query:user:me";
|
|
18
24
|
export declare const EXPORT_JOB_STATUS: {
|
|
@@ -21,6 +21,8 @@ export const UserDataRightsQueries = {
|
|
|
21
21
|
exportStatus: "user-data-rights:query:export-status",
|
|
22
22
|
myAuditLog: "user-data-rights:query:my-audit-log",
|
|
23
23
|
downloadByJob: "user-data-rights:query:download-by-job",
|
|
24
|
+
downloadByToken: "user-data-rights:query:download-by-token",
|
|
25
|
+
listDownloadAttempts: "user-data-rights:query:list-download-attempts",
|
|
24
26
|
};
|
|
25
27
|
// App-level config: tenant-occupancy model. "single-user" tells the forget
|
|
26
28
|
// pipeline that each tenant has exactly one user, so tenant-scoped contributors
|
|
@@ -31,8 +33,12 @@ export const TENANT_MODEL_CONFIG_KEY = "user-data-rights:config:tenant-model";
|
|
|
31
33
|
export const UserDataRightsHandlers = {
|
|
32
34
|
requestExport: "user-data-rights:write:request-export",
|
|
33
35
|
requestDeletion: "user-data-rights:write:request-deletion",
|
|
36
|
+
requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email",
|
|
37
|
+
confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token",
|
|
34
38
|
cancelDeletion: "user-data-rights:write:cancel-deletion",
|
|
35
39
|
restrictAccount: "user-data-rights:write:restrict-account",
|
|
40
|
+
liftRestriction: "user-data-rights:write:lift-restriction",
|
|
41
|
+
runForgetCleanup: "user-data-rights:write:run-forget-cleanup",
|
|
36
42
|
};
|
|
37
43
|
// Foreign QN: the lifecycle status (active / deletionRequested / restricted)
|
|
38
44
|
// comes from the user feature. Pinned locally instead of importing the user
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { type UserDataRightsClientOptions, userDataRightsClient } from "./client-plugin.tsx";
|
|
2
2
|
export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen.tsx";
|
|
3
3
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.tsx";
|
|
4
|
+
export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.ts";
|
|
4
5
|
export { defaultTranslations } from "./default-translations.ts";
|
|
5
6
|
export { formatDate } from "./privacy-center-screen.tsx";
|
|
6
7
|
export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate.tsx";
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// no React/DOM dependencies.
|
|
6
6
|
export { userDataRightsClient } from "./client-plugin.js";
|
|
7
7
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.js";
|
|
8
|
+
export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.js";
|
|
8
9
|
export { defaultTranslations } from "./default-translations.js";
|
|
9
10
|
export { formatDate } from "./privacy-center-screen.js";
|
|
10
11
|
export { makePublicDeletionGate } from "./public-deletion-gate.js";
|
package/dist/waitlist/feature.js
CHANGED
|
@@ -15,6 +15,7 @@ import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-ho
|
|
|
15
15
|
import { WAITLIST_EXPORTED_EVENT_SHORT, WAITLIST_FEATURE, WAITLIST_PURGED_EVENT_SHORT, } from "./web/constants.js";
|
|
16
16
|
export function createWaitlistFeature(opts = {}) {
|
|
17
17
|
return defineFeature(WAITLIST_FEATURE, (r) => {
|
|
18
|
+
r.adoptsBundledLists();
|
|
18
19
|
r.describe("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.");
|
|
19
20
|
r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
|
|
20
21
|
r.systemScope();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { acquireNamespacedAdvisoryLock, createSystemDbView, createTenantDb, runInSavepointIfSupported, } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -10,7 +10,7 @@ import { WAITLIST_NOTIFICATION_TYPES, WAITLIST_STATUS } from "../web/constants.j
|
|
|
10
10
|
// pg_advisory_xact_lock namespace (int4): 'wlst' as ASCII, disjoint from the
|
|
11
11
|
// framework's other fixed advisory-lock keys.
|
|
12
12
|
const SUBMIT_LOCK_NAMESPACE = 0x776c7374;
|
|
13
|
-
const SUBMIT_LOCK_REASON = "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check; TenantDb has
|
|
13
|
+
const SUBMIT_LOCK_REASON = "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check and wraps the admin-recipient resolver in a savepoint so a failing statement cannot abort the submit transaction; TenantDb has neither API";
|
|
14
14
|
// Logged-in callers carry no anonymous role, so every signed-in role must be listed.
|
|
15
15
|
const SUBMIT_ROLES = access.roles(...access.anonymous, ...access.authenticated, "Member", "TenantAdmin");
|
|
16
16
|
const OPEN_STATUSES = [WAITLIST_STATUS.Pending, WAITLIST_STATUS.Invited];
|
|
@@ -40,13 +40,17 @@ async function hasOpenEntryForEmail(email, ctx) {
|
|
|
40
40
|
}, platformActor(), waitlistDb(ctx));
|
|
41
41
|
return found.rows.length > 0;
|
|
42
42
|
}
|
|
43
|
-
// The resolver usually reads config. It runs before the per-email advisory lock so a
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
43
|
+
// The resolver usually reads config. It runs before the per-email advisory lock so a slow
|
|
44
|
+
// resolver does not extend the lock. Postgres aborts the whole transaction after a failed
|
|
45
|
+
// statement, even one whose JS error is caught here, so the resolver gets a ctx whose
|
|
46
|
+
// systemDb is bound to its own savepoint: a failing statement rolls back only that.
|
|
47
|
+
// Statements through any other ctx handle (ctx.query, ctx.write) stay in the handler transaction.
|
|
48
|
+
async function resolveAdminRecipient(opts, ctx, systemDb, tenantId) {
|
|
48
49
|
try {
|
|
49
|
-
return
|
|
50
|
+
return await runInSavepointIfSupported(systemDb.unsafeRaw(SUBMIT_LOCK_REASON), async (sp) => {
|
|
51
|
+
const savepointSystemDb = createSystemDbView(createTenantDb({ db: sp, tenantId, mode: "system" }));
|
|
52
|
+
return (await opts.notifyRecipient?.({ ...ctx, systemDb: savepointSystemDb })) || null;
|
|
53
|
+
});
|
|
50
54
|
}
|
|
51
55
|
catch (err) {
|
|
52
56
|
ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
|
|
@@ -89,7 +93,7 @@ export function createSubmitHandler(opts) {
|
|
|
89
93
|
message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
|
|
90
94
|
});
|
|
91
95
|
}
|
|
92
|
-
const adminRecipient = await resolveAdminRecipient(opts, ctx);
|
|
96
|
+
const adminRecipient = await resolveAdminRecipient(opts, ctx, ctx.systemDb, event.user.tenantId);
|
|
93
97
|
await acquireNamespacedAdvisoryLock(ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON), SUBMIT_LOCK_NAMESPACE, email);
|
|
94
98
|
if (await hasOpenEntryForEmail(email, ctx))
|
|
95
99
|
return SUBMITTED;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
-
import type
|
|
3
|
+
import { type WaitlistStatus } from "./web/constants.ts";
|
|
4
4
|
export type WaitlistImportEntry = {
|
|
5
5
|
/** Stable identity of the source row (e.g. `offlot:<uuid>`); the new entry id derives from it. */
|
|
6
6
|
readonly sourceKey: string;
|
|
@@ -20,6 +20,7 @@ export type WaitlistImportEntry = {
|
|
|
20
20
|
export type WaitlistImportOutcome = "created" | "skipped";
|
|
21
21
|
export declare function waitlistImportId(sourceKey: string): string;
|
|
22
22
|
export declare function isWaitlistImportId(id: string): boolean;
|
|
23
|
+
export declare function waitlistImportEntryWithinLimits(entry: WaitlistImportEntry): boolean;
|
|
23
24
|
export declare function importWaitlistEntry(db: TenantDb, entry: WaitlistImportEntry, options?: {
|
|
24
25
|
readonly dryRun?: boolean;
|
|
25
26
|
}): Promise<WaitlistImportOutcome>;
|
package/dist/waitlist/import.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { normalizeEmail, platformActor, waitlistExecutor } from "./lib.js";
|
|
4
|
+
import { WAITLIST_FIELD_LIMITS } from "./web/constants.js";
|
|
4
5
|
// RFC 4122 name-based UUID (v5 layout, fixed namespace) so a re-run finds the entry
|
|
5
6
|
// it created before without matching on encrypted personal data.
|
|
6
7
|
const IMPORT_NAMESPACE = "kumiko-waitlist-import";
|
|
@@ -17,13 +18,32 @@ export function waitlistImportId(sourceKey) {
|
|
|
17
18
|
export function isWaitlistImportId(id) {
|
|
18
19
|
return id[14] === "5";
|
|
19
20
|
}
|
|
21
|
+
// The live submit schema caps these fields; an imported value above the cap would be
|
|
22
|
+
// stored but could never pass a later update through the same payload schema.
|
|
23
|
+
export function waitlistImportEntryWithinLimits(entry) {
|
|
24
|
+
const limited = [
|
|
25
|
+
[entry.name, WAITLIST_FIELD_LIMITS.name],
|
|
26
|
+
[entry.email, WAITLIST_FIELD_LIMITS.email],
|
|
27
|
+
[entry.locale, WAITLIST_FIELD_LIMITS.locale],
|
|
28
|
+
[entry.company, WAITLIST_FIELD_LIMITS.company],
|
|
29
|
+
[entry.portfolio, WAITLIST_FIELD_LIMITS.portfolio],
|
|
30
|
+
[entry.message, WAITLIST_FIELD_LIMITS.message],
|
|
31
|
+
];
|
|
32
|
+
return limited.every(([value, max]) => value === undefined || value.length <= max);
|
|
33
|
+
}
|
|
20
34
|
function presentOnly(fields) {
|
|
21
35
|
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
22
36
|
}
|
|
23
|
-
// Writes through the entity executor, so events
|
|
24
|
-
//
|
|
37
|
+
// Writes through the entity executor, so events and projection match a live submit; subject
|
|
38
|
+
// encryption applies only when the caller configured a PII KMS (the CLI wires it from env
|
|
39
|
+
// and aborts without one). No mail, no rate limit, no duplicate-by-email
|
|
25
40
|
// check: the source row is the truth and the id makes the call idempotent.
|
|
26
41
|
export async function importWaitlistEntry(db, entry, options = {}) {
|
|
42
|
+
if (!waitlistImportEntryWithinLimits(entry)) {
|
|
43
|
+
throw new InternalError({
|
|
44
|
+
message: `waitlist import entry ${entry.sourceKey} exceeds a field length limit`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
27
47
|
const id = waitlistImportId(entry.sourceKey);
|
|
28
48
|
const existing = await waitlistExecutor.detail({ id }, platformActor(), db);
|
|
29
49
|
if (existing)
|
|
@@ -3,7 +3,7 @@ import { countWhere, createTenantDb, selectMany, table, tableExists, text as tex
|
|
|
3
3
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { archiveStream, createArchivedStreamsTable, eventsTable, isStreamArchived, } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
|
-
import { importWaitlistEntry, isWaitlistImportId, waitlistImportId, } from "./import.js";
|
|
6
|
+
import { importWaitlistEntry, isWaitlistImportId, waitlistImportEntryWithinLimits, waitlistImportId, } from "./import.js";
|
|
7
7
|
import { WAITLIST_STATUS } from "./web/constants.js";
|
|
8
8
|
const ARCHIVED_STREAMS_TABLE = "public.kumiko_archived_streams";
|
|
9
9
|
export const LEGACY_WAITLIST_SOURCES = ["offlot", "publicstatus"];
|
|
@@ -199,7 +199,7 @@ export async function migrateLegacyWaitlist(db, options) {
|
|
|
199
199
|
const entry = options.from === "offlot"
|
|
200
200
|
? await mapOfflotRow(row)
|
|
201
201
|
: mapPublicstatusRow(row, options.defaultLocale);
|
|
202
|
-
if (!entry) {
|
|
202
|
+
if (!entry || !waitlistImportEntryWithinLimits(entry)) {
|
|
203
203
|
unreadable += 1;
|
|
204
204
|
continue;
|
|
205
205
|
}
|
|
@@ -14,7 +14,8 @@ export type WaitlistInviteOptions = {
|
|
|
14
14
|
export type WaitlistOptions = {
|
|
15
15
|
/** Address for an admin notification mail on each new entry. Null, an empty
|
|
16
16
|
* string or an absent option sends none. Best effort, never fails the submit,
|
|
17
|
-
* also when the resolver throws or rejects.
|
|
17
|
+
* also when the resolver throws or rejects. `ctx.systemDb` is bound to a savepoint, so a
|
|
18
|
+
* failing statement through it is rolled back without aborting the submit transaction. */
|
|
18
19
|
readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
|
|
19
20
|
/** Language of the admin notice mail (en, de or es; anything else falls back to en).
|
|
20
21
|
* Not taken from the request: a submit comes from the entrant, not the operator. */
|
|
@@ -143,7 +143,7 @@ function readTriggerSource(startedPayload) {
|
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
145
|
async function loadReferencedEvent(ctx, ref) {
|
|
146
|
-
const events = await ctx.loadAggregate(ref.aggregateId);
|
|
146
|
+
const events = await ctx.loadAggregate(ref.aggregateId, { atVersion: ref.version });
|
|
147
147
|
return events.find((e) => e.id === ref.eventId);
|
|
148
148
|
}
|
|
149
149
|
async function recoverTriggerEvent(ctx, runEvents, runId, user) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.389.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -548,9 +548,9 @@
|
|
|
548
548
|
}
|
|
549
549
|
},
|
|
550
550
|
"dependencies": {
|
|
551
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
552
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
553
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
551
|
+
"@cosmicdrift/kumiko-framework": "0.389.0",
|
|
552
|
+
"@cosmicdrift/kumiko-renderer": "0.389.0",
|
|
553
|
+
"@cosmicdrift/kumiko-types": "0.389.0",
|
|
554
554
|
"@mollie/api-client": "^4.5.0",
|
|
555
555
|
"@node-rs/argon2": "^2.0.2",
|
|
556
556
|
"@tiptap/core": "3.31.3",
|
|
@@ -1124,17 +1124,17 @@
|
|
|
1124
1124
|
],
|
|
1125
1125
|
"devDependencies": {
|
|
1126
1126
|
"@testing-library/user-event": "^14.6.1",
|
|
1127
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1128
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1129
|
-
"@cosmicdrift/kumiko-locale-fr": "0.
|
|
1130
|
-
"@cosmicdrift/kumiko-locale-it": "0.
|
|
1131
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
1127
|
+
"@cosmicdrift/kumiko-locale-de": "0.389.0",
|
|
1128
|
+
"@cosmicdrift/kumiko-locale-es": "0.389.0",
|
|
1129
|
+
"@cosmicdrift/kumiko-locale-fr": "0.389.0",
|
|
1130
|
+
"@cosmicdrift/kumiko-locale-it": "0.389.0",
|
|
1131
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.389.0",
|
|
1132
1132
|
"jsqr": "^1.4.0",
|
|
1133
1133
|
"unpdf": "^1.8.1",
|
|
1134
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
1134
|
+
"@cosmicdrift/kumiko-testing": "0.389.0"
|
|
1135
1135
|
},
|
|
1136
1136
|
"peerDependencies": {
|
|
1137
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
1137
|
+
"@cosmicdrift/kumiko-testing": "0.389.0"
|
|
1138
1138
|
},
|
|
1139
1139
|
"peerDependenciesMeta": {
|
|
1140
1140
|
"@cosmicdrift/kumiko-testing": {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Local agent tool errors carry a code",
|
|
6
|
+
"detail": "dispatchToolCall returns code invalid_tool_call for validation errors and unknown_tool for an unknown tool name, instead of a result without code. TOOL_CALL_ERROR_CODES names both."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Optional returnTo for password reset and signup",
|
|
6
|
+
"detail": "Password reset and signup accept an optional returnTo path. A safe value (same-origin path, no dot segments) is appended to the mailed link as a query parameter, the reset and signup-complete screens follow it, and the login screen forwards its next to the forgot-password and signup links. Invalid values are dropped silently and never block the mail. isSafeReturnPath and readReturnToFromSearch are exported from the web barrel. isSafeNextPath now uses the same check, so a next with a dot segment such as \"/a/../b\" is no longer followed."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "CurrentUser carries tenantId, logout clears remembered list state",
|
|
12
|
+
"detail": "The session CurrentUser has an optional tenantId used to scope remembered list state; logout removes all kumiko.list.* storage entries."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.385.0",
|
|
4
16
|
"type": "improvement",
|