@cosmicdrift/kumiko-bundled-features 0.337.1 → 0.339.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/tool-catalog.js +37 -17
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +3 -2
- package/dist/auth-email-password/handlers/invite-accept.write.js +3 -4
- package/dist/auth-email-password/handlers/invite-info.query.js +4 -1
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +3 -4
- package/dist/auth-email-password/handlers/signup-confirm.write.js +2 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +2 -2
- package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
- package/dist/auth-email-password/web/auth-gate.js +25 -1
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
- package/dist/auth-email-password/web/auth-redirect.js +58 -0
- package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
- package/dist/auth-email-password/web/client-plugin.js +2 -0
- package/dist/auth-email-password/web/index.d.ts +2 -1
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/session.d.ts +3 -1
- package/dist/auth-email-password/web/session.js +8 -2
- package/dist/auth-mfa/feature.js +1 -1
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
- package/dist/auth-mfa/handlers/enable-start-preauth.write.js +2 -1
- package/dist/auth-mfa/handlers/verify.write.js +1 -1
- package/dist/auth-mfa/mfa-code-verifier.js +1 -1
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/billing-foundation/aggregate-id.d.ts +2 -0
- package/dist/billing-foundation/aggregate-id.js +8 -0
- package/dist/billing-foundation/checkout-core.d.ts +3 -0
- package/dist/billing-foundation/checkout-core.js +3 -0
- package/dist/billing-foundation/constants.d.ts +38 -0
- package/dist/billing-foundation/constants.js +43 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +96 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
- package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
- package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
- package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
- package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-termination.js +40 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +45 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.js +268 -0
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +73 -0
- package/dist/billing-foundation/consumer-protection/termination-record.js +230 -0
- package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
- package/dist/billing-foundation/consumer-protection/termination-request.js +231 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
- package/dist/billing-foundation/events.d.ts +105 -2
- package/dist/billing-foundation/events.js +88 -2
- package/dist/billing-foundation/feature.js +6 -2
- package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
- package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
- package/dist/billing-foundation/handlers/process-event.write.js +43 -15
- package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
- package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +17 -1
- package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
- package/dist/billing-foundation/i18n.js +61 -0
- package/dist/billing-foundation/index.d.ts +3 -2
- package/dist/billing-foundation/index.js +2 -1
- package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
- package/dist/billing-foundation/types.d.ts +54 -9
- package/dist/billing-foundation/validate-options.js +51 -10
- package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
- package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
- package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
- package/dist/billing-foundation/webhook-handler.js +5 -2
- package/dist/cap-counter/with-cap-enforcement.js +7 -3
- package/dist/cap-overview/access.d.ts +2 -1
- package/dist/cap-overview/access.js +26 -5
- package/dist/cap-overview/feature.d.ts +4 -0
- package/dist/cap-overview/feature.js +8 -6
- package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
- package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +12 -7
- package/dist/cap-overview/index.d.ts +2 -2
- package/dist/cap-overview/index.js +2 -2
- package/dist/cap-overview/screens.d.ts +1 -1
- package/dist/cap-overview/screens.js +17 -16
- package/dist/channel-discord/discord-channel.d.ts +6 -0
- package/dist/channel-discord/discord-channel.js +19 -0
- package/dist/channel-discord/feature.d.ts +3 -0
- package/dist/channel-discord/feature.js +30 -0
- package/dist/channel-discord/index.d.ts +2 -0
- package/dist/channel-discord/index.js +2 -0
- package/dist/channel-slack/feature.d.ts +3 -0
- package/dist/channel-slack/feature.js +30 -0
- package/dist/channel-slack/index.d.ts +2 -0
- package/dist/channel-slack/index.js +2 -0
- package/dist/channel-slack/slack-channel.d.ts +5 -0
- package/dist/channel-slack/slack-channel.js +16 -0
- package/dist/channel-teams/feature.d.ts +3 -0
- package/dist/channel-teams/feature.js +34 -0
- package/dist/channel-teams/index.d.ts +2 -0
- package/dist/channel-teams/index.js +2 -0
- package/dist/channel-teams/teams-channel.d.ts +5 -0
- package/dist/channel-teams/teams-channel.js +38 -0
- package/dist/channel-telegram/feature.d.ts +3 -0
- package/dist/channel-telegram/feature.js +28 -0
- package/dist/channel-telegram/index.d.ts +2 -0
- package/dist/channel-telegram/index.js +2 -0
- package/dist/channel-telegram/telegram-channel.d.ts +8 -0
- package/dist/channel-telegram/telegram-channel.js +54 -0
- package/dist/config/handlers/set.write.js +18 -2
- package/dist/crypto-shredding/feature.js +3 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
- package/dist/crypto-shredding/handlers/forget-subject.write.js +30 -19
- package/dist/custom-fields/feature.js +9 -6
- package/dist/custom-fields/handlers/clear-custom-field.write.js +2 -1
- package/dist/custom-fields/handlers/define-tenant-field.write.js +2 -1
- package/dist/custom-fields/handlers/set-custom-field.write.js +2 -1
- package/dist/custom-fields/wire-user-data-rights.js +1 -1
- package/dist/data-retention/feature.js +1 -1
- package/dist/delivery/channel-context.d.ts +2 -1
- package/dist/delivery/channel-context.js +2 -2
- package/dist/delivery/chat-webhook-channel.d.ts +19 -0
- package/dist/delivery/chat-webhook-channel.js +48 -0
- package/dist/delivery/chat-webhook-sender.d.ts +21 -0
- package/dist/delivery/chat-webhook-sender.js +102 -0
- package/dist/delivery/delivery-service.js +12 -7
- package/dist/delivery/index.d.ts +2 -0
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/jobs.js +1 -1
- package/dist/delivery/testing.d.ts +2 -0
- package/dist/delivery/types.d.ts +3 -1
- package/dist/delivery/types.js +1 -2
- package/dist/document-ingest-foundation/feature.js +3 -2
- package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
- package/dist/files-tenant-data/index.js +1 -1
- package/dist/form-draft/feature.js +1 -1
- package/dist/form-draft/handlers/discard.write.js +2 -1
- package/dist/form-draft/handlers/save.write.js +2 -1
- package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
- package/dist/inbound-mail-foundation/connect-routes.js +13 -8
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +2 -1
- package/dist/inbound-mail-foundation/index.d.ts +1 -0
- package/dist/inbound-mail-foundation/index.js +1 -0
- package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
- package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
- package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
- package/dist/inbound-mail-foundation/types.d.ts +18 -1
- package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
- package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
- package/dist/managed-pages/handlers/set.write.js +2 -1
- package/dist/notes-history/executor.d.ts +2 -0
- package/dist/notes-history/executor.js +9 -1
- package/dist/notes-history/feature.d.ts +2 -3
- package/dist/notes-history/feature.js +2 -1
- package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
- package/dist/notes-history/handlers/add-note.write.js +21 -4
- package/dist/notes-history-user-data/hooks.js +26 -8
- package/dist/renderer-simple/feature.d.ts +8 -2
- package/dist/renderer-simple/feature.js +8 -5
- package/dist/renderer-simple/index.d.ts +2 -2
- package/dist/renderer-simple/index.js +1 -1
- package/dist/renderer-simple/simple-renderer.d.ts +15 -0
- package/dist/renderer-simple/simple-renderer.js +74 -27
- package/dist/secrets/feature.js +1 -1
- package/dist/sessions/feature.js +1 -1
- package/dist/sessions/handlers/revoke-all-for-user.write.js +2 -1
- package/dist/subscription-mollie/plugin-methods.js +1 -0
- package/dist/subscription-mollie/verify-webhook.js +5 -3
- package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
- package/dist/subscription-stripe/plugin-methods.js +78 -13
- package/dist/subscription-stripe/verify-webhook.js +7 -3
- package/dist/tags/executor.d.ts +2 -0
- package/dist/tags/executor.js +9 -1
- package/dist/tags/feature.d.ts +4 -3
- package/dist/tags/feature.js +4 -2
- package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
- package/dist/tags/handlers/assign-tag.write.js +4 -4
- package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
- package/dist/tags/handlers/remove-tag.write.js +3 -3
- package/dist/tenant-handover/handlers/claim.write.js +2 -2
- package/dist/tenant-lifecycle/feature.js +1 -1
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +4 -1
- package/dist/tenant-lifecycle/handlers/request-destruction.write.js +2 -1
- package/dist/tier-engine/feature.js +1 -1
- package/dist/tier-engine/handlers/get-tenant-tier.query.js +2 -1
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +2 -1
- package/dist/user/handlers/detail.query.js +1 -0
- package/dist/user/handlers/list.query.js +1 -0
- package/dist/user-data-rights/feature.js +2 -2
- package/dist/user-data-rights/handlers/cancel-deletion.write.js +2 -1
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
- package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
- package/dist/user-data-rights/handlers/download-by-job.query.js +2 -1
- package/dist/user-data-rights/handlers/download-by-token.query.js +2 -1
- package/dist/user-data-rights/handlers/export-status.query.js +2 -1
- package/dist/user-data-rights/handlers/lift-restriction.write.js +3 -4
- package/dist/user-data-rights/handlers/my-audit-log.query.js +2 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +2 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
- package/dist/user-data-rights/handlers/request-export.write.js +2 -1
- package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
- package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +2 -1
- package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
- package/dist/user-data-rights/run-forget-cleanup.js +30 -15
- package/dist/workflow-runner/event-subscriber.js +6 -2
- package/dist/workflow-runner/event-trigger.js +5 -0
- package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
- package/dist/workflow-runner/pending-projection.js +1 -0
- package/dist/workflow-runner/runner.d.ts +7 -1
- package/dist/workflow-runner/runner.js +1 -1
- package/dist/workflow-runner/tables.js +8 -1
- package/package.json +41 -9
- package/src/agent-tools/changes.json +14 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/billing-foundation/changes.json +83 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +14 -0
- package/src/config/changes.json +7 -0
- package/src/crypto-shredding/changes.json +13 -0
- package/src/custom-fields/changes.json +7 -0
- package/src/delivery/changes.json +14 -0
- package/src/inbound-mail-foundation/changes.json +7 -0
- package/src/notes-history/changes.json +7 -0
- package/src/renderer-simple/changes.json +9 -1
- package/src/subscription-mollie/changes.json +13 -0
- package/src/subscription-stripe/changes.json +13 -0
- package/src/tags/changes.json +7 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/workflow-runner/changes.json +7 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
3
2
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
4
|
-
import { configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
5
|
-
import { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { computeBlindIndex, configuredBlindIndexKey, configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
4
|
+
import { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
|
|
6
5
|
import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
6
|
import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
7
|
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -26,7 +25,7 @@ export const subjectForgottenSchema = z.object({
|
|
|
26
25
|
forgottenBy: z.string().min(1),
|
|
27
26
|
});
|
|
28
27
|
export const subjectForgetDeniedSchema = z.object({
|
|
29
|
-
subjectKeyDigest: z.string().min(1),
|
|
28
|
+
subjectKeyDigest: z.string().min(1).optional(),
|
|
30
29
|
subjectKind: z.enum(["user", "tenant", "record"]),
|
|
31
30
|
reason: z.string().min(10),
|
|
32
31
|
forgottenBy: z.string().min(1),
|
|
@@ -78,13 +77,20 @@ async function resolveTenantScopeDenial(db, features, user, raw) {
|
|
|
78
77
|
if (!features.has("tenant"))
|
|
79
78
|
return undefined;
|
|
80
79
|
const entity = findRegisteredEntity(features, raw.entity);
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const rowInTenant = entity !== undefined &&
|
|
81
|
+
(await recordRowExistsInTenant(db, features, raw.entity, raw.id, user.tenantId));
|
|
82
|
+
if (rowInTenant)
|
|
83
|
+
return undefined;
|
|
84
|
+
// A deleted row or a custom aggregate without an entity has no projection
|
|
85
|
+
// row to check; its event stream still proves which tenant owns it.
|
|
86
|
+
if (await recordEventsOwnedExclusivelyByTenant(db, raw.entity, raw.id, user.tenantId)) {
|
|
87
|
+
return undefined;
|
|
83
88
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
return writeFailure(new AccessDeniedError({
|
|
90
|
+
details: {
|
|
91
|
+
reason: entity ? TARGET_RECORD_NOT_ADMIN_TENANT : RECORD_ENTITY_NOT_REGISTERED,
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
88
94
|
}
|
|
89
95
|
// Without the tenant feature there's no membership table to check against —
|
|
90
96
|
// no scoping concept exists to enforce (single/no-tenant apps only;
|
|
@@ -160,15 +166,19 @@ async function appendDenialAuditEvent(ctx, event, subjectKey, subjectKind, denia
|
|
|
160
166
|
message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
|
|
161
167
|
}));
|
|
162
168
|
}
|
|
169
|
+
const blindIndexKey = configuredBlindIndexKey();
|
|
163
170
|
const payload = subjectForgetDeniedSchema.parse({
|
|
164
171
|
// The denial lands in the REQUESTING actor's own tenant-scoped stream —
|
|
165
172
|
// it must never materialise the foreign subject's identifiers there. A
|
|
166
173
|
// plaintext subjectKey/aggregateId would survive as a permanent record
|
|
167
174
|
// for the prober and would still be present when the owning tenant
|
|
168
175
|
// later runs its own (legitimate) forget-subject for that subject. The
|
|
169
|
-
// digest
|
|
170
|
-
//
|
|
171
|
-
|
|
176
|
+
// digest is keyed (a bare hash of a guessable id could be recomputed from
|
|
177
|
+
// a candidate id) and lets an operator correlate repeated probes of the
|
|
178
|
+
// same subject; without a blind-index key no digest is stored at all.
|
|
179
|
+
...(blindIndexKey !== undefined && {
|
|
180
|
+
subjectKeyDigest: computeBlindIndex(blindIndexKey, subjectKey),
|
|
181
|
+
}),
|
|
172
182
|
subjectKind,
|
|
173
183
|
reason: event.payload.reason,
|
|
174
184
|
forgottenBy: event.user.id,
|
|
@@ -216,6 +226,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
216
226
|
// not be able to reach it at all.
|
|
217
227
|
agent: { expose: false, risk: "high" },
|
|
218
228
|
escapeHatch: {
|
|
229
|
+
grants: ["unsafeRaw"],
|
|
219
230
|
reason: "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
|
|
220
231
|
"the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
|
|
221
232
|
"the blind-index sweep and search purge address the subject across tenants; the user " +
|
|
@@ -237,17 +248,17 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
237
248
|
? { kind: "tenant", tenantId: raw.tenantId }
|
|
238
249
|
: { kind: "record", entity: raw.entity, id: raw.id };
|
|
239
250
|
const subjectKey = subjectIdToKey(subject);
|
|
240
|
-
const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw(
|
|
251
|
+
const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw(), ctx.registry.features, event.user, raw);
|
|
241
252
|
if (tenantScopeDenial) {
|
|
242
|
-
const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw(
|
|
253
|
+
const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw());
|
|
243
254
|
return auditFailure ?? tenantScopeDenial;
|
|
244
255
|
}
|
|
245
256
|
// Runs after the tenant gate but before any key is touched: a retention
|
|
246
257
|
// check ahead of the tenant gate would leak a foreign entity's retention
|
|
247
258
|
// posture to a cross-tenant prober.
|
|
248
|
-
const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw(
|
|
259
|
+
const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw());
|
|
249
260
|
if (retentionDenial) {
|
|
250
|
-
const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw(
|
|
261
|
+
const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw());
|
|
251
262
|
return auditFailure ?? retentionDenial;
|
|
252
263
|
}
|
|
253
264
|
// Erase BEFORE the audit append: if the append throws, the key is gone
|
|
@@ -261,7 +272,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
261
272
|
});
|
|
262
273
|
// Blind-index sweep (#818): nulls bidx columns now so the deterministic
|
|
263
274
|
// HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
|
|
264
|
-
const crossTenantSubjectRunner = ctx.db.unsafeRaw(
|
|
275
|
+
const crossTenantSubjectRunner = ctx.db.unsafeRaw();
|
|
265
276
|
await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
|
|
266
277
|
// Derived search index still holds plaintext (#1610) — purge next to the
|
|
267
278
|
// blind-index sweep. No adapter → no-op (apps without search).
|
|
@@ -289,7 +300,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
289
300
|
// update is best-effort for real users.
|
|
290
301
|
if (raw.kind === "user" && ctx.registry.features.has("user")) {
|
|
291
302
|
try {
|
|
292
|
-
const userLifecycleRunner = ctx.db.unsafeRaw(
|
|
303
|
+
const userLifecycleRunner = ctx.db.unsafeRaw();
|
|
293
304
|
await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
|
|
294
305
|
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
295
306
|
await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
|
|
@@ -136,12 +136,15 @@ export function resolveFieldDefinitionListRoles(opts) {
|
|
|
136
136
|
if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
|
|
137
137
|
return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
|
|
138
138
|
}
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
139
|
+
// The default stays in the union: with valueWriteRoles unset, TenantAdmin still
|
|
140
|
+
// saves values and must be able to load the FormSection.
|
|
141
|
+
return [
|
|
142
|
+
...new Set([
|
|
143
|
+
...(opts.valueWriteRoles ?? []),
|
|
144
|
+
...(opts.fieldDefinitionWriteRoles ?? []),
|
|
145
|
+
...DEFAULT_FIELD_DEFINITION_LIST_ROLES,
|
|
146
|
+
]),
|
|
147
|
+
];
|
|
145
148
|
}
|
|
146
149
|
// Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
|
|
147
150
|
// host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
|
|
@@ -26,11 +26,12 @@ export const clearCustomFieldHandler = {
|
|
|
26
26
|
access: { roles: DEFAULT_VALUE_WRITE_ROLES },
|
|
27
27
|
description: "Removes the stored value of one custom field from a single host entity row after re-checking that field's per-field write roles; use it to blank a field the user emptied instead of writing a null value.",
|
|
28
28
|
escapeHatch: {
|
|
29
|
+
grants: ["unsafeRaw"],
|
|
29
30
|
reason: CLEAR_CUSTOM_FIELD_REASON,
|
|
30
31
|
},
|
|
31
32
|
handler: async (event, ctx) => {
|
|
32
33
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
33
|
-
const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(
|
|
34
|
+
const accessCheck = await checkFieldAccessForWrite(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey, event.user.roles);
|
|
34
35
|
if (!accessCheck.ok) {
|
|
35
36
|
if (accessCheck.reason === "field_definition_not_found") {
|
|
36
37
|
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
@@ -15,6 +15,7 @@ export function createDefineTenantFieldHandler(opts = {}) {
|
|
|
15
15
|
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
16
16
|
description: "Creates a custom-field definition owned by the caller's own tenant on the named entity, rejecting the write once the tenant's definition quota is reached; use it when one tenant needs an extra field the other tenants must not see.",
|
|
17
17
|
escapeHatch: {
|
|
18
|
+
grants: ["unsafeRaw"],
|
|
18
19
|
reason: DEFINE_TENANT_FIELD_REASON,
|
|
19
20
|
},
|
|
20
21
|
handler: async (event, ctx) => {
|
|
@@ -25,7 +26,7 @@ export function createDefineTenantFieldHandler(opts = {}) {
|
|
|
25
26
|
throw new Error("define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions");
|
|
26
27
|
}
|
|
27
28
|
if (limit !== undefined) {
|
|
28
|
-
const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(
|
|
29
|
+
const current = await countTenantFieldDefinitions(ctx.db.unsafeRaw(), tenantId);
|
|
29
30
|
if (current >= limit) {
|
|
30
31
|
return failUnprocessable("cap_exceeded", {
|
|
31
32
|
capName: "customFields.fieldDefinition.count",
|
|
@@ -49,11 +49,12 @@ export const setCustomFieldHandler = {
|
|
|
49
49
|
access: { roles: DEFAULT_VALUE_WRITE_ROLES },
|
|
50
50
|
description: "Stores one custom-field value on a single host entity row after validating it against the field definition's declared type and per-field write roles; use it to save what a user entered into a custom field.",
|
|
51
51
|
escapeHatch: {
|
|
52
|
+
grants: ["unsafeRaw"],
|
|
52
53
|
reason: SET_CUSTOM_FIELD_REASON,
|
|
53
54
|
},
|
|
54
55
|
handler: async (event, ctx) => {
|
|
55
56
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
56
|
-
const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(
|
|
57
|
+
const loaded = await loadFieldDefinition(ctx.db.unsafeRaw(), event.user.tenantId, payload.entityName, payload.fieldKey);
|
|
57
58
|
if (!loaded.found) {
|
|
58
59
|
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
59
60
|
}
|
|
@@ -46,7 +46,7 @@ export function wireCustomFieldsUserDataRightsFor(r, opts) {
|
|
|
46
46
|
const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
|
|
47
47
|
// biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
|
|
48
48
|
r.useExtension(EXT_USER_DATA, opts.entityName, {
|
|
49
|
-
export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(
|
|
49
|
+
export: async (ctx) => exportCustomFields(ctx.db.unsafeRaw(), ctx, tableName, opts),
|
|
50
50
|
escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
|
|
51
51
|
});
|
|
52
52
|
}
|
|
@@ -64,7 +64,7 @@ export function createDataRetentionFeature() {
|
|
|
64
64
|
return;
|
|
65
65
|
}
|
|
66
66
|
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
67
|
-
const cleanupDb = ctx.db.unsafeRaw(
|
|
67
|
+
const cleanupDb = ctx.db.unsafeRaw(); // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
68
68
|
const tenantPreset = await resolveTenantRetentionPreset({
|
|
69
69
|
db: cleanupDb,
|
|
70
70
|
registry: ctx.registry,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
4
5
|
import type { ChannelContext } from "./types.js";
|
|
5
|
-
export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId): ChannelContext;
|
|
6
|
+
export declare function buildChannelContext(db: DbConnection, registry: Registry, sseBroker: SseBroker | undefined, tenantId: TenantId, secrets?: SecretsContext): ChannelContext;
|
|
@@ -3,6 +3,6 @@ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
|
3
3
|
// Shared by the synchronous delivery-service path and the async job handlers
|
|
4
4
|
// (which pass sseBroker=undefined — only the inline inApp channel uses SSE,
|
|
5
5
|
// and it never runs through a job).
|
|
6
|
-
export function buildChannelContext(db, registry, sseBroker, tenantId) {
|
|
7
|
-
return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
|
|
6
|
+
export function buildChannelContext(db, registry, sseBroker, tenantId, secrets) {
|
|
7
|
+
return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId, secrets };
|
|
8
8
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ChatSendResult } from "./chat-webhook-sender.js";
|
|
2
|
+
import type { ChannelMessage, ChannelResult, DeliveryChannel } from "./types.js";
|
|
3
|
+
export type ChatWebhookChannelOptions = {
|
|
4
|
+
readonly allowedHosts?: readonly string[];
|
|
5
|
+
readonly requireHttps?: boolean;
|
|
6
|
+
readonly timeoutMs?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function toChannelResult(address: string, result: ChatSendResult): ChannelResult;
|
|
9
|
+
export declare function chatMessageText(message: ChannelMessage): string;
|
|
10
|
+
export type ChatWebhookChannelSpec = {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly featureName: string;
|
|
13
|
+
readonly defaultAllowedHosts: readonly string[];
|
|
14
|
+
readonly requiredPathPrefix?: string;
|
|
15
|
+
readonly keyFor: (connection: string) => string;
|
|
16
|
+
readonly buildBody: (message: ChannelMessage) => unknown;
|
|
17
|
+
readonly options: ChatWebhookChannelOptions;
|
|
18
|
+
};
|
|
19
|
+
export declare function createChatWebhookChannel(spec: ChatWebhookChannelSpec): DeliveryChannel;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
|
+
import { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, } from "./chat-webhook-sender.js";
|
|
3
|
+
export function toChannelResult(address, result) {
|
|
4
|
+
return result.ok
|
|
5
|
+
? { status: "sent", address }
|
|
6
|
+
: { status: "failed", error: result.code, address };
|
|
7
|
+
}
|
|
8
|
+
export function chatMessageText(message) {
|
|
9
|
+
return message.body ? `${message.title}\n${message.body}` : message.title;
|
|
10
|
+
}
|
|
11
|
+
// Slack/Discord/Teams: the route address is a tenant-chosen connection name, the
|
|
12
|
+
// webhook URL lives in the secrets store under that name. Route-only (no resolve).
|
|
13
|
+
export function createChatWebhookChannel(spec) {
|
|
14
|
+
const { options } = spec;
|
|
15
|
+
return {
|
|
16
|
+
name: spec.name,
|
|
17
|
+
mode: "queued",
|
|
18
|
+
async send(address, message, ctx) {
|
|
19
|
+
if (!chatConnectionNameSchema.safeParse(address).success) {
|
|
20
|
+
return toChannelResult(address, { ok: false, code: "invalid_address" });
|
|
21
|
+
}
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
const revealed = await ctx.secrets?.get(ctx.tenantId, spec.keyFor(address), {
|
|
25
|
+
userId: SYSTEM_USER_ID,
|
|
26
|
+
handlerName: `${spec.featureName}:send`,
|
|
27
|
+
});
|
|
28
|
+
url = revealed?.reveal();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
url = undefined;
|
|
32
|
+
}
|
|
33
|
+
if (!url)
|
|
34
|
+
return toChannelResult(address, { ok: false, code: "missing_credentials" });
|
|
35
|
+
const result = await postChatWebhook({
|
|
36
|
+
url,
|
|
37
|
+
allowedHosts: options.allowedHosts ?? spec.defaultAllowedHosts,
|
|
38
|
+
requireHttps: options.requireHttps ?? true,
|
|
39
|
+
...(spec.requiredPathPrefix !== undefined && {
|
|
40
|
+
requiredPathPrefix: spec.requiredPathPrefix,
|
|
41
|
+
}),
|
|
42
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
43
|
+
body: spec.buildBody(message),
|
|
44
|
+
});
|
|
45
|
+
return toChannelResult(address, result);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export type ChatSendFailureCode = `http_${number}` | "timeout" | "network_error" | "redirect_blocked" | "host_not_allowed" | "missing_credentials" | "invalid_address";
|
|
3
|
+
export type ChatSendResult = {
|
|
4
|
+
readonly ok: true;
|
|
5
|
+
} | {
|
|
6
|
+
readonly ok: false;
|
|
7
|
+
readonly code: ChatSendFailureCode;
|
|
8
|
+
};
|
|
9
|
+
export declare const DEFAULT_CHAT_TIMEOUT_MS = 10000;
|
|
10
|
+
export declare const chatConnectionNameSchema: z.ZodString;
|
|
11
|
+
export type ChatWebhookRequest = {
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly allowedHosts: readonly string[];
|
|
14
|
+
readonly requireHttps: boolean;
|
|
15
|
+
readonly requiredPathPrefix?: string;
|
|
16
|
+
readonly timeoutMs: number;
|
|
17
|
+
readonly body: unknown;
|
|
18
|
+
readonly send?: (url: string, init: RequestInit) => Promise<Response>;
|
|
19
|
+
};
|
|
20
|
+
export declare function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult>;
|
|
21
|
+
export declare function truncateChars(text: string, maxChars: number): string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BlockedHostError, egress } from "@cosmicdrift/kumiko-framework/http";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
|
|
4
|
+
// Tenant-chosen connection name (the route address for webhook channels). Also
|
|
5
|
+
// the suffix the secrets write gate accepts under the channel's namespace, so
|
|
6
|
+
// the two cannot drift. Validated before it is used to build a secret key.
|
|
7
|
+
export const chatConnectionNameSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(64)
|
|
11
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/);
|
|
12
|
+
// https targets are provider endpoints on public hosts: egress "external" adds
|
|
13
|
+
// the private/reserved-range denial and DNS pinning on top of our allowlist. The
|
|
14
|
+
// cleartext opt-out (requireHttps: false) exists for operator-trusted local
|
|
15
|
+
// endpoints, which "external" would refuse; the allowlist already pins the host.
|
|
16
|
+
function policyFor(request, target) {
|
|
17
|
+
return request.requireHttps
|
|
18
|
+
? { kind: "external" }
|
|
19
|
+
: { kind: "internal", allowHosts: [target.hostname] };
|
|
20
|
+
}
|
|
21
|
+
function hostMatches(hostname, allowedHost) {
|
|
22
|
+
const allowed = allowedHost.toLowerCase();
|
|
23
|
+
if (allowed.startsWith(".")) {
|
|
24
|
+
return hostname.length > allowed.length && hostname.endsWith(allowed);
|
|
25
|
+
}
|
|
26
|
+
return hostname === allowed;
|
|
27
|
+
}
|
|
28
|
+
// Allowlist runs on the parsed URL, never on the raw string: userinfo
|
|
29
|
+
// (`https://hooks.slack.com@evil.com`) and look-alike suffixes must not pass.
|
|
30
|
+
function checkTarget(request) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(request.url);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const protocolAllowed = parsed.protocol === "https:" || (!request.requireHttps && parsed.protocol === "http:");
|
|
39
|
+
if (!protocolAllowed)
|
|
40
|
+
return undefined;
|
|
41
|
+
// egress() has no port rule; a provider webhook over https never needs a custom port.
|
|
42
|
+
if (request.requireHttps && parsed.port !== "")
|
|
43
|
+
return undefined;
|
|
44
|
+
if (parsed.username !== "" || parsed.password !== "")
|
|
45
|
+
return undefined;
|
|
46
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
47
|
+
if (!request.allowedHosts.some((allowed) => hostMatches(hostname, allowed)))
|
|
48
|
+
return undefined;
|
|
49
|
+
if (request.requiredPathPrefix !== undefined &&
|
|
50
|
+
!parsed.pathname.startsWith(request.requiredPathPrefix)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
function isTimeout(err) {
|
|
56
|
+
return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
57
|
+
}
|
|
58
|
+
// egress(internal) follows same-host redirects itself and throws on cross-host
|
|
59
|
+
// ones; both are the "redirect" outcome here. Matched on the stable message
|
|
60
|
+
// prefix only — the message is discarded, it can carry the Location header.
|
|
61
|
+
function isEgressRedirectRefusal(err) {
|
|
62
|
+
return err instanceof Error && err.message.startsWith("egress(internal): redirect");
|
|
63
|
+
}
|
|
64
|
+
function classifyFailure(err) {
|
|
65
|
+
if (err instanceof BlockedHostError)
|
|
66
|
+
return "host_not_allowed";
|
|
67
|
+
if (isEgressRedirectRefusal(err))
|
|
68
|
+
return "redirect_blocked";
|
|
69
|
+
return isTimeout(err) ? "timeout" : "network_error";
|
|
70
|
+
}
|
|
71
|
+
// Never throws and never surfaces the URL: egress/Bun errors can carry it, so
|
|
72
|
+
// every failure is reduced to a code here.
|
|
73
|
+
export async function postChatWebhook(request) {
|
|
74
|
+
const target = checkTarget(request);
|
|
75
|
+
if (!target)
|
|
76
|
+
return { ok: false, code: "host_not_allowed" };
|
|
77
|
+
let response;
|
|
78
|
+
try {
|
|
79
|
+
const send = request.send ?? egress(policyFor(request, target));
|
|
80
|
+
response = await send(target.toString(), {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify(request.body),
|
|
84
|
+
signal: AbortSignal.timeout(request.timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return { ok: false, code: classifyFailure(err) };
|
|
89
|
+
}
|
|
90
|
+
// Free the connection; the body is never read or stored.
|
|
91
|
+
await response.body?.cancel().catch(() => undefined);
|
|
92
|
+
const { status } = response;
|
|
93
|
+
if (status >= 300 && status < 400)
|
|
94
|
+
return { ok: false, code: "redirect_blocked" };
|
|
95
|
+
if (status >= 200 && status < 300)
|
|
96
|
+
return { ok: true };
|
|
97
|
+
return { ok: false, code: `http_${status}` };
|
|
98
|
+
}
|
|
99
|
+
export function truncateChars(text, maxChars) {
|
|
100
|
+
const chars = Array.from(text);
|
|
101
|
+
return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
|
|
102
|
+
}
|
|
@@ -88,16 +88,18 @@ export function createDeliveryService(options) {
|
|
|
88
88
|
sink: escapeHatchAuditSink,
|
|
89
89
|
log,
|
|
90
90
|
});
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
91
|
+
// ctx.db/dbOutsideTransaction are tenant-filtered even for a systemScope handler:
|
|
92
|
+
// the dispatcher closes them off entirely there, so "system" mode would hand this
|
|
93
|
+
// path a cross-tenant read the dispatcher never allows. Cross-tenant reach only
|
|
94
|
+
// exists through the grant-carrying systemDb view below.
|
|
95
95
|
const isSystem = registry.isHandlerSystemScoped(tenantUserIdsQuery);
|
|
96
|
-
const tenantDb = createTenantDb(db, tenantId,
|
|
96
|
+
const tenantDb = createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, {
|
|
97
|
+
report,
|
|
98
|
+
});
|
|
97
99
|
// The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw() ungated —
|
|
98
100
|
// the grant comes from the handler's own r.systemScope() registration, not from this
|
|
99
|
-
// caller.
|
|
100
|
-
//
|
|
101
|
+
// caller. Only the systemDb view's own source TenantDb carries the grant, so it
|
|
102
|
+
// can't leak onto ctx.db.
|
|
101
103
|
const systemDbSourceTenantDb = isSystem
|
|
102
104
|
? createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
|
|
103
105
|
report,
|
|
@@ -227,6 +229,9 @@ export function createDeliveryService(options) {
|
|
|
227
229
|
async function deliverToUser(userId, notificationType, data, tenantId, priority) {
|
|
228
230
|
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
|
|
229
231
|
for (const channel of channels) {
|
|
232
|
+
// Route-only channel (no per-user address): not a user-notification target.
|
|
233
|
+
if (!channel.resolve)
|
|
234
|
+
continue;
|
|
230
235
|
const message = buildMessage(notificationType, data, channel.name);
|
|
231
236
|
// Kill switch: tenant admin disabled this channel entirely
|
|
232
237
|
if (isChannelKilled) {
|
package/dist/delivery/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
2
|
+
export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
|
|
3
|
+
export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
|
|
2
4
|
export type { DeliveryStatusValue } from "./constants.js";
|
|
3
5
|
export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
|
|
4
6
|
export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
|
package/dist/delivery/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
2
|
+
export { chatMessageText, createChatWebhookChannel, toChannelResult, } from "./chat-webhook-channel.js";
|
|
3
|
+
export { chatConnectionNameSchema, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
|
|
2
4
|
export { DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, } from "./constants.js";
|
|
3
5
|
export { collectChannels, createDeliveryService, } from "./delivery-service.js";
|
|
4
6
|
export { createDeliveryFeature } from "./feature.js";
|
package/dist/delivery/jobs.js
CHANGED
|
@@ -103,7 +103,7 @@ export const deliverySendJob = async (payload, ctx) => {
|
|
|
103
103
|
const tenantId = p.tenantId; // @cast-boundary engine-payload — stream tenant
|
|
104
104
|
const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
|
|
105
105
|
const channel = resolveChannel(registry, p.channelName);
|
|
106
|
-
const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
|
|
106
|
+
const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
|
|
107
107
|
try {
|
|
108
108
|
const result = await channel.send(p.address, toMessage(p), channelCtx, p.rendered);
|
|
109
109
|
await appendAttemptEvent(db, registry, p.deliveryAttemptId, entryFor(p, result.status, result.error ?? null, result.address ?? p.address));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { EscapeHatchAuditSink, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
4
5
|
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
5
6
|
import type { Redis } from "ioredis";
|
|
6
7
|
import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
|
|
@@ -11,6 +12,7 @@ export type CreateDeliveryTestContextOptions = {
|
|
|
11
12
|
readonly isChannelKilled?: KillSwitchResolver;
|
|
12
13
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
13
14
|
readonly log?: Logger;
|
|
15
|
+
readonly jobRunner?: JobRunner;
|
|
14
16
|
};
|
|
15
17
|
/**
|
|
16
18
|
* Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
4
5
|
import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
|
|
5
6
|
export type ChannelContext = {
|
|
6
7
|
readonly db: TenantDb;
|
|
7
8
|
readonly registry: Registry;
|
|
8
9
|
readonly sseBroker: SseBroker | undefined;
|
|
9
10
|
readonly tenantId: TenantId;
|
|
11
|
+
readonly secrets?: SecretsContext | undefined;
|
|
10
12
|
};
|
|
11
13
|
export type ChannelMessage = {
|
|
12
14
|
readonly notificationType: string;
|
|
@@ -28,7 +30,7 @@ export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
|
|
|
28
30
|
export type DeliveryChannel = {
|
|
29
31
|
readonly name: string;
|
|
30
32
|
readonly mode: DeliveryChannelMode;
|
|
31
|
-
resolve(userId: string, ctx: ChannelContext): Promise<string | null>;
|
|
33
|
+
resolve?(userId: string, ctx: ChannelContext): Promise<string | null>;
|
|
32
34
|
render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
|
|
33
35
|
send(address: string, message: ChannelMessage, ctx: ChannelContext, rendered?: RenderedMessage): Promise<ChannelResult>;
|
|
34
36
|
};
|
package/dist/delivery/types.js
CHANGED
|
@@ -10,8 +10,7 @@ export function isDeliveryChannelPlugin(o) {
|
|
|
10
10
|
"mode" in o &&
|
|
11
11
|
DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
|
|
12
12
|
(!("render" in o) || o.render === undefined || typeof o.render === "function") &&
|
|
13
|
-
"resolve" in o &&
|
|
14
|
-
typeof o.resolve === "function" &&
|
|
13
|
+
(!("resolve" in o) || o.resolve === undefined || typeof o.resolve === "function") &&
|
|
15
14
|
"send" in o &&
|
|
16
15
|
typeof o.send === "function");
|
|
17
16
|
}
|
|
@@ -114,8 +114,9 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
|
|
|
114
114
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
115
115
|
read: access.roles("TenantAdmin", "SystemAdmin", "User"),
|
|
116
116
|
}));
|
|
117
|
-
// "fileName" can carry a real person's name
|
|
118
|
-
//
|
|
117
|
+
// "fileName" can carry a real person's name, but it reaches both events already as the
|
|
118
|
+
// fileRef's own self-subject ciphertext (copied from fileRef.created/restored), which
|
|
119
|
+
// append() passes through; shredding the fileRef's key shreds it here too.
|
|
119
120
|
r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema, {
|
|
120
121
|
piiFields: "none",
|
|
121
122
|
version: 2,
|
|
@@ -16,7 +16,7 @@ export const publicVariantByFileRefQuery = defineQueryHandler({
|
|
|
16
16
|
reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
|
|
17
17
|
},
|
|
18
18
|
handler: async (query, ctx) => {
|
|
19
|
-
const db = ctx.db.unsafeRaw(
|
|
19
|
+
const db = ctx.db.unsafeRaw();
|
|
20
20
|
const row = await fetchOne(db, fileRefsTable, {
|
|
21
21
|
id: query.payload.fileRefId,
|
|
22
22
|
isDeleted: false,
|
|
@@ -34,7 +34,7 @@ export function createFilesTenantDataFeature() {
|
|
|
34
34
|
escapeHatch: {
|
|
35
35
|
reason: SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON,
|
|
36
36
|
},
|
|
37
|
-
handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw(
|
|
37
|
+
handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw()),
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
}
|
|
@@ -38,7 +38,7 @@ function registerFormDraft(r) {
|
|
|
38
38
|
trigger: { cron: "0 3 * * *" },
|
|
39
39
|
concurrency: "skip",
|
|
40
40
|
escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
|
|
41
|
-
handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw(
|
|
41
|
+
handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw()),
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
|
|
@@ -29,6 +29,7 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
29
29
|
description: "Deletes the calling user's draft for one draftKey and, when releaseFiles is set, hard-deletes the file references that draft alone uploaded; use it after a successful submit or when the user abandons the form.",
|
|
30
30
|
agent: { risk: "high" },
|
|
31
31
|
escapeHatch: {
|
|
32
|
+
grants: ["unsafeRaw"],
|
|
32
33
|
reason: DISCARD_DRAFT_FILE_REFS_REASON,
|
|
33
34
|
},
|
|
34
35
|
handler: async (event, ctx) => {
|
|
@@ -58,7 +59,7 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
58
59
|
// lazily on the first step-change — a file uploaded before that exists
|
|
59
60
|
// predates the draft row, so the insertedAt filter must not apply.
|
|
60
61
|
const isCreateMode = event.payload.draftKey.includes(":new:");
|
|
61
|
-
const ownedRefs = await filterOwnedFileRefs(ctx.db.unsafeRaw(
|
|
62
|
+
const ownedRefs = await filterOwnedFileRefs(ctx.db.unsafeRaw(), event.user.tenantId, ownerId, candidateKeys, existing.insertedAt, isCreateMode);
|
|
62
63
|
// Fail-closed erase of file_refs rows FIRST (outside the best-effort
|
|
63
64
|
// storage-hygiene loop). releaseDraftFileRefs swallows per-key errors
|
|
64
65
|
// by design — routing forget through it would report discarded:true
|
|
@@ -27,6 +27,7 @@ export const saveDraftWrite = defineWriteHandler({
|
|
|
27
27
|
access: FORM_DRAFT_ACCESS,
|
|
28
28
|
description: "Upserts the calling user's draft for one draftKey with the given form values and step index, stamping savedAt server-side and refusing a brand-new draft once the per-owner draft cap is reached; use it to persist an in-progress form before the real entity exists.",
|
|
29
29
|
escapeHatch: {
|
|
30
|
+
grants: ["unsafeRaw"],
|
|
30
31
|
reason: SAVE_DRAFT_COUNT_REASON,
|
|
31
32
|
},
|
|
32
33
|
handler: async (event, ctx) => {
|
|
@@ -41,7 +42,7 @@ export const saveDraftWrite = defineWriteHandler({
|
|
|
41
42
|
// Cap check only on the create path — an update never grows the number
|
|
42
43
|
// of drafts this owner has, so a user already at the limit can still
|
|
43
44
|
// keep saving their existing drafts.
|
|
44
|
-
const draftCount = await countDraftsByOwner(ctx.db.unsafeRaw(
|
|
45
|
+
const draftCount = await countDraftsByOwner(ctx.db.unsafeRaw(), event.user.tenantId, ownerId);
|
|
45
46
|
if (draftCount >= FORM_DRAFT_MAX_PER_OWNER) {
|
|
46
47
|
return failUnprocessable("draft_limit_reached", { limit: FORM_DRAFT_MAX_PER_OWNER });
|
|
47
48
|
}
|