@cosmicdrift/kumiko-samples 0.353.0 → 0.354.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +18 -2
- package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
- package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
- package/packages/bundled-features/src/agent-tools/index.ts +6 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
- package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
- package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
- package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
- package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
- package/packages/bundled-features/src/files/changes.json +24 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
- package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
- package/packages/bundled-features/src/jobs/changes.json +30 -0
- package/packages/bundled-features/src/jobs/constants.ts +1 -0
- package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
- package/packages/bundled-features/src/jobs/feature.ts +7 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
- package/packages/bundled-features/src/jobs/index.ts +2 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
- package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
- package/packages/bundled-features/src/ledger/changes.json +23 -0
- package/packages/bundled-features/src/ledger/entity.ts +13 -5
- package/packages/bundled-features/src/ledger/feature.ts +3 -0
- package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
- package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/notes-history/changes.json +13 -0
- package/packages/bundled-features/src/notes-history/entity.ts +4 -0
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/sessions/feature.ts +21 -8
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
- package/packages/bundled-features/src/sessions/screens.ts +10 -8
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
- package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
- package/packages/bundled-features/src/template-resolver/api.ts +3 -3
- package/packages/bundled-features/src/template-resolver/changes.json +5 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
- package/packages/bundled-features/src/tenant/changes.json +12 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
- package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
- package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
- package/packages/bundled-features/src/tier-engine/changes.json +5 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
- package/packages/bundled-features/src/user/changes.json +15 -0
- package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
- package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
- package/packages/bundled-features/src/user/seeding.ts +34 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
- package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
- package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
- package/samples/apps/showcase/src/features/items/feature.ts +2 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
- package/samples/apps/ui-walkthrough/package.json +3 -0
- package/samples/apps/use-all-bundled/README.md +1 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/user-data-rights-demo/README.md +1 -3
- package/samples/recipes/legal-pages/README.md +3 -4
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/seed-migration/README.md +0 -2
|
@@ -22,7 +22,11 @@ import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
|
22
22
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
23
23
|
import { buildChannelContext } from "./channel-context.js";
|
|
24
24
|
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
type AddressOptOutKey,
|
|
27
|
+
addressOptOutKey,
|
|
28
|
+
selectOptedOutAddresses,
|
|
29
|
+
} from "./db/queries/address-opt-outs.js";
|
|
26
30
|
import { selectNotificationPreferences } from "./db/queries/preferences.js";
|
|
27
31
|
import {
|
|
28
32
|
type ChannelContext,
|
|
@@ -411,16 +415,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
411
415
|
|
|
412
416
|
// Shared by deliverToUser (resolved account address) and deliverDirect (a
|
|
413
417
|
// route address with no user account) — same suppression rule either way:
|
|
414
|
-
// no blind-index key configured means no hash, so nothing to look up.
|
|
415
|
-
|
|
418
|
+
// no blind-index key configured means no hash, so nothing to look up. One
|
|
419
|
+
// query per notify() call, not per recipient and channel.
|
|
420
|
+
async function loadSuppressedAddresses(
|
|
421
|
+
tenantId: TenantId,
|
|
422
|
+
notificationType: string,
|
|
423
|
+
priority: NotifyPriority,
|
|
424
|
+
addresses: Iterable<string>,
|
|
425
|
+
): Promise<ReadonlySet<AddressOptOutKey>> {
|
|
426
|
+
// Critical priority skips address opt-outs, same rule as user preferences.
|
|
427
|
+
if (priority === "critical") return new Set();
|
|
428
|
+
const addressHashes = new Set<string>();
|
|
429
|
+
for (const address of addresses) {
|
|
430
|
+
const addressHash = hashUnsubscribeAddress(address);
|
|
431
|
+
if (addressHash !== undefined) addressHashes.add(addressHash);
|
|
432
|
+
}
|
|
433
|
+
return selectOptedOutAddresses(db, tenantId, notificationType, [...addressHashes]);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isAddressSuppressed(
|
|
437
|
+
suppressed: ReadonlySet<AddressOptOutKey>,
|
|
416
438
|
address: string,
|
|
439
|
+
channelName: string,
|
|
440
|
+
): boolean {
|
|
441
|
+
const addressHash = hashUnsubscribeAddress(address);
|
|
442
|
+
return addressHash !== undefined && suppressed.has(addressOptOutKey(addressHash, channelName));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const recipientChannelKey = (userId: string, channelName: string) => `${userId}|${channelName}`;
|
|
446
|
+
|
|
447
|
+
async function isChannelDeliverable(
|
|
448
|
+
userId: string,
|
|
417
449
|
tenantId: TenantId,
|
|
418
450
|
notificationType: string,
|
|
419
451
|
channelName: string,
|
|
420
452
|
): Promise<boolean> {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
453
|
+
if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) return false;
|
|
454
|
+
return isChannelEnabled(userId, tenantId, notificationType, channelName);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Resolves every user's channel addresses up front so the opt-out lookup can
|
|
458
|
+
// cover them all at once. A resolve that throws stays out of the map: the
|
|
459
|
+
// delivery loop resolves it again and reports channel_error as before.
|
|
460
|
+
// Channels the kill switch or the user's preferences disable are never resolved
|
|
461
|
+
// here: resolve may have side effects and deliverToUser skips those channels.
|
|
462
|
+
async function prefetchUserAddresses(
|
|
463
|
+
userIds: readonly string[],
|
|
464
|
+
tenantId: TenantId,
|
|
465
|
+
notificationType: string,
|
|
466
|
+
): Promise<Map<string, string | null>> {
|
|
467
|
+
const addresses = new Map<string, string | null>();
|
|
468
|
+
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
469
|
+
for (const userId of userIds) {
|
|
470
|
+
for (const channel of channels) {
|
|
471
|
+
if (!channel.resolve) continue;
|
|
472
|
+
try {
|
|
473
|
+
if (!(await isChannelDeliverable(userId, tenantId, notificationType, channel.name))) {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
addresses.set(
|
|
477
|
+
recipientChannelKey(userId, channel.name),
|
|
478
|
+
await channel.resolve(userId, channelCtx),
|
|
479
|
+
);
|
|
480
|
+
} catch {
|
|
481
|
+
// skip: re-resolved (and logged) by deliverToUser
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return addresses;
|
|
424
486
|
}
|
|
425
487
|
|
|
426
488
|
// Check if user has disabled this notification+channel combo.
|
|
@@ -466,6 +528,8 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
466
528
|
tenantId: TenantId,
|
|
467
529
|
priority: NotifyPriority,
|
|
468
530
|
run: NotifyRun,
|
|
531
|
+
prefetchedAddresses: ReadonlyMap<string, string | null>,
|
|
532
|
+
suppressed: ReadonlySet<AddressOptOutKey>,
|
|
469
533
|
): Promise<void> {
|
|
470
534
|
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
471
535
|
|
|
@@ -530,7 +594,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
530
594
|
}
|
|
531
595
|
|
|
532
596
|
try {
|
|
533
|
-
const
|
|
597
|
+
const prefetchKey = recipientChannelKey(userId, channel.name);
|
|
598
|
+
const address = prefetchedAddresses.has(prefetchKey)
|
|
599
|
+
? prefetchedAddresses.get(prefetchKey)
|
|
600
|
+
: await channel.resolve(userId, channelCtx);
|
|
534
601
|
if (!address) {
|
|
535
602
|
await logDelivery(run, {
|
|
536
603
|
tenantId,
|
|
@@ -545,10 +612,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
545
612
|
continue;
|
|
546
613
|
}
|
|
547
614
|
|
|
548
|
-
if (
|
|
549
|
-
priority !== "critical" &&
|
|
550
|
-
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
551
|
-
) {
|
|
615
|
+
if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
|
|
552
616
|
await logDelivery(run, {
|
|
553
617
|
tenantId,
|
|
554
618
|
notificationType,
|
|
@@ -602,6 +666,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
602
666
|
run: NotifyRun,
|
|
603
667
|
): Promise<void> {
|
|
604
668
|
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
669
|
+
const suppressed = await loadSuppressedAddresses(
|
|
670
|
+
tenantId,
|
|
671
|
+
notificationType,
|
|
672
|
+
priority,
|
|
673
|
+
Object.values(route),
|
|
674
|
+
);
|
|
605
675
|
|
|
606
676
|
// Direct routing skips preferences (no user account) but NOT rate limit
|
|
607
677
|
// — direct sends can still be abused (webhook replays, test harnesses).
|
|
@@ -612,10 +682,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
612
682
|
|
|
613
683
|
// Address opt-out (critical priority skips it, same rule as user
|
|
614
684
|
// preferences).
|
|
615
|
-
if (
|
|
616
|
-
priority !== "critical" &&
|
|
617
|
-
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
618
|
-
) {
|
|
685
|
+
if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
|
|
619
686
|
await logDelivery(run, {
|
|
620
687
|
tenantId,
|
|
621
688
|
notificationType,
|
|
@@ -729,8 +796,28 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
729
796
|
userIds = to;
|
|
730
797
|
}
|
|
731
798
|
|
|
799
|
+
const prefetchedAddresses =
|
|
800
|
+
priority === "critical"
|
|
801
|
+
? new Map<string, string | null>()
|
|
802
|
+
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
803
|
+
const suppressed = await loadSuppressedAddresses(
|
|
804
|
+
tenantId,
|
|
805
|
+
notificationType,
|
|
806
|
+
priority,
|
|
807
|
+
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
808
|
+
);
|
|
809
|
+
|
|
732
810
|
for (const userId of userIds) {
|
|
733
|
-
await deliverToUser(
|
|
811
|
+
await deliverToUser(
|
|
812
|
+
userId,
|
|
813
|
+
notificationType,
|
|
814
|
+
data,
|
|
815
|
+
tenantId,
|
|
816
|
+
priority,
|
|
817
|
+
run,
|
|
818
|
+
prefetchedAddresses,
|
|
819
|
+
suppressed,
|
|
820
|
+
);
|
|
734
821
|
}
|
|
735
822
|
}
|
|
736
823
|
return { deliveries: run.deliveries };
|
|
@@ -71,6 +71,11 @@ export type UnsubscribeRouteOptions = {
|
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
const UNSUBSCRIBE_EXPIRY = "7d";
|
|
74
|
+
const RESUBSCRIBE_EXPIRY = "1h";
|
|
75
|
+
const UNSUBSCRIBE_ISSUER = "kumiko:unsubscribe";
|
|
76
|
+
const RESUBSCRIBE_AUDIENCE = "kumiko:resubscribe";
|
|
77
|
+
|
|
78
|
+
type TokenPurpose = "unsubscribe" | "resubscribe";
|
|
74
79
|
const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
|
|
75
80
|
|
|
76
81
|
function assertUnsubscribeSecret(secret: string, caller: string): void {
|
|
@@ -98,7 +103,7 @@ export async function signUnsubscribeToken(
|
|
|
98
103
|
})
|
|
99
104
|
.setProtectedHeader({ alg: "HS256" })
|
|
100
105
|
.setSubject(String(payload.userId))
|
|
101
|
-
.setIssuer(
|
|
106
|
+
.setIssuer(UNSUBSCRIBE_ISSUER)
|
|
102
107
|
.setIssuedAt()
|
|
103
108
|
.setExpirationTime(UNSUBSCRIBE_EXPIRY)
|
|
104
109
|
.sign(encodedSecret);
|
|
@@ -133,12 +138,10 @@ export async function signAddressUnsubscribeToken(
|
|
|
133
138
|
})
|
|
134
139
|
.setProtectedHeader({ alg: "HS256" })
|
|
135
140
|
.setSubject(addressHash)
|
|
136
|
-
.setIssuer(
|
|
141
|
+
.setIssuer(UNSUBSCRIBE_ISSUER)
|
|
137
142
|
.setIssuedAt()
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// today must keep working indefinitely — there's no signed-in flow for
|
|
141
|
-
// a no-account address to request a fresh link from.
|
|
143
|
+
// Only opt-out, so no expiry: a link mailed out today must keep working,
|
|
144
|
+
// there's no signed-in flow for a no-account address to request a fresh one.
|
|
142
145
|
.sign(encodedSecret)
|
|
143
146
|
);
|
|
144
147
|
}
|
|
@@ -168,6 +171,35 @@ const CONFIRMATION_PAGE_HEADERS = {
|
|
|
168
171
|
"Referrer-Policy": "no-referrer",
|
|
169
172
|
} as const;
|
|
170
173
|
|
|
174
|
+
// Undo token, handed out only in the response of a successful unsubscribe. A
|
|
175
|
+
// separate audience and a short expiry keep the long-lived mailed unsubscribe
|
|
176
|
+
// link from being able to undo an opt-out (and vice versa).
|
|
177
|
+
async function signResubscribeToken(
|
|
178
|
+
verified: VerifiedUnsubscribe,
|
|
179
|
+
encodedSecret: Uint8Array,
|
|
180
|
+
): Promise<string> {
|
|
181
|
+
const claims = {
|
|
182
|
+
tenantId: verified.tenantId,
|
|
183
|
+
notificationType: verified.notificationType,
|
|
184
|
+
channel: verified.channel,
|
|
185
|
+
};
|
|
186
|
+
const jwt =
|
|
187
|
+
verified.kind === "address"
|
|
188
|
+
? new jose.SignJWT({
|
|
189
|
+
...claims,
|
|
190
|
+
addressHash: verified.addressHash,
|
|
191
|
+
kind: "address",
|
|
192
|
+
}).setSubject(verified.addressHash)
|
|
193
|
+
: new jose.SignJWT(claims).setSubject(verified.userId);
|
|
194
|
+
return jwt
|
|
195
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
196
|
+
.setIssuer(UNSUBSCRIBE_ISSUER)
|
|
197
|
+
.setAudience(RESUBSCRIBE_AUDIENCE)
|
|
198
|
+
.setIssuedAt()
|
|
199
|
+
.setExpirationTime(RESUBSCRIBE_EXPIRY)
|
|
200
|
+
.sign(encodedSecret);
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
// Every throw here must become the same ExtraRouteRejection(400,
|
|
172
204
|
// unsubscribe_token_invalid) — anything else falls through to the
|
|
173
205
|
// framework's generic 401 extra_route_signature_invalid mapping, which
|
|
@@ -175,6 +207,7 @@ const CONFIRMATION_PAGE_HEADERS = {
|
|
|
175
207
|
async function verifyUnsubscribeToken(
|
|
176
208
|
token: string | undefined,
|
|
177
209
|
encodedSecret: Uint8Array,
|
|
210
|
+
purpose: TokenPurpose,
|
|
178
211
|
): Promise<VerifiedUnsubscribe> {
|
|
179
212
|
try {
|
|
180
213
|
if (!token) {
|
|
@@ -185,9 +218,22 @@ async function verifyUnsubscribeToken(
|
|
|
185
218
|
);
|
|
186
219
|
}
|
|
187
220
|
|
|
188
|
-
const { payload: verifiedPayload } =
|
|
189
|
-
|
|
190
|
-
|
|
221
|
+
const { payload: verifiedPayload } =
|
|
222
|
+
purpose === "resubscribe"
|
|
223
|
+
? await jose.jwtVerify(token, encodedSecret, {
|
|
224
|
+
issuer: UNSUBSCRIBE_ISSUER,
|
|
225
|
+
audience: RESUBSCRIBE_AUDIENCE,
|
|
226
|
+
requiredClaims: ["exp"],
|
|
227
|
+
})
|
|
228
|
+
: await jose.jwtVerify(token, encodedSecret, { issuer: UNSUBSCRIBE_ISSUER });
|
|
229
|
+
// jose only checks `aud` when asked to, so an unsubscribe route would accept an undo token.
|
|
230
|
+
if (purpose === "unsubscribe" && verifiedPayload.aud !== undefined) {
|
|
231
|
+
throw new ExtraRouteRejection(
|
|
232
|
+
400,
|
|
233
|
+
UNSUBSCRIBE_TOKEN_INVALID_BODY,
|
|
234
|
+
"resubscribe token used as unsubscribe token",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
191
237
|
|
|
192
238
|
const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
193
239
|
if (addressAttempt.success) {
|
|
@@ -329,6 +375,24 @@ function confirmationPage(token: string): string {
|
|
|
329
375
|
</html>`;
|
|
330
376
|
}
|
|
331
377
|
|
|
378
|
+
function unsubscribedPage(resubscribeToken: string): string {
|
|
379
|
+
return `<!doctype html>
|
|
380
|
+
<html>
|
|
381
|
+
<head>
|
|
382
|
+
<meta charset="utf-8">
|
|
383
|
+
<meta name="robots" content="noindex">
|
|
384
|
+
<title>Unsubscribed</title>
|
|
385
|
+
</head>
|
|
386
|
+
<body>
|
|
387
|
+
<p>You have been unsubscribed.</p>
|
|
388
|
+
<form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
|
|
389
|
+
<input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
|
|
390
|
+
<button type="submit">Undo</button>
|
|
391
|
+
</form>
|
|
392
|
+
</body>
|
|
393
|
+
</html>`;
|
|
394
|
+
}
|
|
395
|
+
|
|
332
396
|
// One-click clients put the token in the query string; only the
|
|
333
397
|
// confirmation-page form submits it in the body.
|
|
334
398
|
function tokenFromPostRequest(request: SignatureExtraRouteVerifyRequest): string | undefined {
|
|
@@ -377,7 +441,7 @@ export function createUnsubscribeRoutes(
|
|
|
377
441
|
"missing unsubscribe token",
|
|
378
442
|
);
|
|
379
443
|
}
|
|
380
|
-
await verifyUnsubscribeToken(token, encodedSecret);
|
|
444
|
+
await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
381
445
|
return { token };
|
|
382
446
|
},
|
|
383
447
|
handler: async (c, { token }) =>
|
|
@@ -388,7 +452,8 @@ export function createUnsubscribeRoutes(
|
|
|
388
452
|
method: "POST",
|
|
389
453
|
path: DELIVERY_UNSUBSCRIBE_PATH,
|
|
390
454
|
entry: "signature",
|
|
391
|
-
verify: async (request) =>
|
|
455
|
+
verify: async (request) =>
|
|
456
|
+
verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
|
|
392
457
|
handler: async (c, verified, deps) => {
|
|
393
458
|
// Token-verify passed — everything below is a legitimate write. Don't
|
|
394
459
|
// swallow write-errors as "invalid token", that would mask real bugs
|
|
@@ -397,7 +462,9 @@ export function createUnsubscribeRoutes(
|
|
|
397
462
|
if (!succeeded) {
|
|
398
463
|
return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
|
|
399
464
|
}
|
|
400
|
-
return c.html(
|
|
465
|
+
return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
|
|
466
|
+
"Cache-Control": "no-store",
|
|
467
|
+
});
|
|
401
468
|
},
|
|
402
469
|
});
|
|
403
470
|
|
|
@@ -408,7 +475,8 @@ export function createUnsubscribeRoutes(
|
|
|
408
475
|
method: "POST",
|
|
409
476
|
path: DELIVERY_RESUBSCRIBE_PATH,
|
|
410
477
|
entry: "signature",
|
|
411
|
-
verify: async (request) =>
|
|
478
|
+
verify: async (request) =>
|
|
479
|
+
verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
|
|
412
480
|
handler: async (c, verified, deps) => {
|
|
413
481
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
414
482
|
if (outcome === "limit_reached") {
|
|
@@ -108,9 +108,8 @@ export function createFeatureTogglesFeature(
|
|
|
108
108
|
// process's handler calls runtime.broadcastToggle, which publishes on
|
|
109
109
|
// GlobalFeatureToggleRuntime's syncSignal so every process (including
|
|
110
110
|
// the one that won the cursor) applies the flip to its own snapshot —
|
|
111
|
-
// see toggle-runtime.ts and toggle-sync-signal.ts.
|
|
112
|
-
//
|
|
113
|
-
// applies directly; there is nobody else to reach. Named "cache-sync"
|
|
111
|
+
// see toggle-runtime.ts and toggle-sync-signal.ts. The signal defaults to
|
|
112
|
+
// the cache-sync bus (Redis with REDIS_URL, process-local without). Named "cache-sync"
|
|
114
113
|
// (not "projection" or "audit") because it's side-effect-only
|
|
115
114
|
// infrastructure.
|
|
116
115
|
//
|
|
@@ -5,7 +5,15 @@ import {
|
|
|
5
5
|
type Registry,
|
|
6
6
|
type ToggleReader,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
9
|
+
import {
|
|
10
|
+
type ClosableCacheSyncBus,
|
|
11
|
+
createDefaultCacheSyncBus,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/redis";
|
|
8
13
|
import { globalFeatureStateTable } from "./global-feature-state-table.js";
|
|
14
|
+
import { createCacheSyncToggleSignal } from "./toggle-sync-signal.js";
|
|
15
|
+
|
|
16
|
+
const log = createFallbackLogger("kumiko:feature-toggles");
|
|
9
17
|
|
|
10
18
|
// Cross-replica transport for a toggle flip (fw#2625). Deliberately narrow
|
|
11
19
|
// (featureName + enabled, not a generic channel/payload pair) so a caller
|
|
@@ -15,6 +23,8 @@ import { globalFeatureStateTable } from "./global-feature-state-table.js";
|
|
|
15
23
|
export type ToggleSyncSignal = {
|
|
16
24
|
publish(featureName: string, enabled: boolean): void;
|
|
17
25
|
onMessage(listener: (featureName: string, enabled: boolean) => void): void;
|
|
26
|
+
// Fires after the transport lost and restored its subscription: flips sent meanwhile are gone.
|
|
27
|
+
onResync?(listener: () => void): void;
|
|
18
28
|
};
|
|
19
29
|
|
|
20
30
|
// Holds the current global-override snapshot in memory and exposes a
|
|
@@ -38,6 +48,7 @@ export class GlobalFeatureToggleRuntime {
|
|
|
38
48
|
private readonly db: DbConnection,
|
|
39
49
|
private readonly registry: Registry,
|
|
40
50
|
private readonly syncSignal?: ToggleSyncSignal,
|
|
51
|
+
private readonly ownedBus?: ClosableCacheSyncBus,
|
|
41
52
|
) {
|
|
42
53
|
// Every process, including the one that calls broadcastToggle, learns
|
|
43
54
|
// of a flip through this same subscription — mirrors the SSE broker's
|
|
@@ -46,6 +57,16 @@ export class GlobalFeatureToggleRuntime {
|
|
|
46
57
|
this.syncSignal?.onMessage((featureName, enabled) => {
|
|
47
58
|
this.apply(featureName, enabled);
|
|
48
59
|
});
|
|
60
|
+
this.syncSignal?.onResync?.(() => {
|
|
61
|
+
this.refresh().catch((err: unknown) => {
|
|
62
|
+
log.error("snapshot reload after sync reconnect failed", { err });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Only the runtime built by createFeatureToggleRuntime without an explicit signal owns one.
|
|
68
|
+
async close(): Promise<void> {
|
|
69
|
+
await this.ownedBus?.close();
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
async initialize(): Promise<void> {
|
|
@@ -121,15 +142,23 @@ export class GlobalFeatureToggleRuntime {
|
|
|
121
142
|
};
|
|
122
143
|
}
|
|
123
144
|
|
|
124
|
-
// Factory for app-boot wiring: instantiate, initialize, return
|
|
125
|
-
//
|
|
126
|
-
//
|
|
145
|
+
// Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
|
|
146
|
+
// set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
|
|
147
|
+
// syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
|
|
148
|
+
// REDIS_URL is set, process-local otherwise. close() releases a bus it created.
|
|
127
149
|
export async function createFeatureToggleRuntime(
|
|
128
150
|
db: DbConnection,
|
|
129
151
|
registry: Registry,
|
|
130
152
|
syncSignal?: ToggleSyncSignal,
|
|
131
153
|
): Promise<GlobalFeatureToggleRuntime> {
|
|
132
|
-
const
|
|
133
|
-
|
|
154
|
+
const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
|
|
155
|
+
const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
|
|
156
|
+
const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
|
|
157
|
+
try {
|
|
158
|
+
await runtime.initialize();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
await runtime.close();
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
134
163
|
return runtime;
|
|
135
164
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CACHE_SYNC_TOPICS,
|
|
3
|
+
type CacheSyncBus,
|
|
2
4
|
createRedisPubSubSignal,
|
|
3
5
|
type RedisClientOptions,
|
|
6
|
+
redisChannelPrefixFromEnv,
|
|
4
7
|
} from "@cosmicdrift/kumiko-framework/redis";
|
|
5
8
|
import type { ToggleSyncSignal } from "./toggle-runtime.js";
|
|
6
9
|
|
|
7
10
|
// Single fixed channel — unlike the SSE broker there's no per-tenant/
|
|
8
11
|
// per-user variance to multiplex, every toggle flip is global.
|
|
9
|
-
const
|
|
12
|
+
const TOGGLE_SYNC_CHANNEL_NAME = "kumiko:feature-toggles:cache-sync";
|
|
10
13
|
|
|
11
14
|
export type RedisToggleSyncSignal = ToggleSyncSignal & {
|
|
12
15
|
// Not on ToggleSyncSignal — the in-memory/no-signal path (GlobalFeature
|
|
@@ -27,31 +30,31 @@ function isTogglePayload(value: unknown): value is { featureName: string; enable
|
|
|
27
30
|
// fw#2625: gives toggle-cache-sync the cross-replica transport its shared
|
|
28
31
|
// dispatcher cursor needs — without this, only the one process that won
|
|
29
32
|
// the shared cursor's toggle-set event would ever learn about a flip.
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// this only when REDIS_URL is present and pass it into
|
|
33
|
-
// createFeatureToggleRuntime.
|
|
33
|
+
// Standalone Redis transport for callers that want their own connections;
|
|
34
|
+
// createFeatureToggleRuntime builds a cache-sync-bus signal by default.
|
|
34
35
|
export function createRedisToggleSyncSignal(
|
|
35
36
|
redisUrl: string,
|
|
36
37
|
clientOptions?: RedisClientOptions,
|
|
38
|
+
channelPrefix: string = redisChannelPrefixFromEnv(),
|
|
37
39
|
): RedisToggleSyncSignal {
|
|
40
|
+
const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
|
|
38
41
|
const signal = createRedisPubSubSignal({
|
|
39
42
|
redisUrl,
|
|
40
|
-
channelPattern:
|
|
43
|
+
channelPattern: toggleSyncChannel,
|
|
41
44
|
label: "feature-toggles",
|
|
42
45
|
...(clientOptions ? { clientOptions } : {}),
|
|
43
46
|
});
|
|
44
47
|
|
|
45
48
|
return {
|
|
46
49
|
publish(featureName, enabled) {
|
|
47
|
-
signal.publish(
|
|
50
|
+
signal.publish(toggleSyncChannel, { featureName, enabled });
|
|
48
51
|
},
|
|
49
52
|
onMessage(listener) {
|
|
50
53
|
signal.onMessage((_channel, payload) => {
|
|
51
54
|
if (!isTogglePayload(payload)) {
|
|
52
55
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.log in a raw PubSubSignal handler.
|
|
53
56
|
console.error(
|
|
54
|
-
`[kumiko:feature-toggles] dropping malformed cache-sync message on "${
|
|
57
|
+
`[kumiko:feature-toggles] dropping malformed cache-sync message on "${toggleSyncChannel}"`,
|
|
55
58
|
);
|
|
56
59
|
// skip: malformed message already logged above, nothing to apply
|
|
57
60
|
return;
|
|
@@ -62,3 +65,23 @@ export function createRedisToggleSyncSignal(
|
|
|
62
65
|
close: signal.close,
|
|
63
66
|
};
|
|
64
67
|
}
|
|
68
|
+
|
|
69
|
+
// Reuses the framework cache-sync bus (local without REDIS_URL, Redis-backed with it) so
|
|
70
|
+
// toggles need no Pub/Sub wiring of their own. The bus applies a publish locally first.
|
|
71
|
+
export function createCacheSyncToggleSignal(bus: CacheSyncBus): ToggleSyncSignal {
|
|
72
|
+
return {
|
|
73
|
+
publish(featureName, enabled) {
|
|
74
|
+
bus.publish(CACHE_SYNC_TOPICS.featureToggle, { featureName, enabled });
|
|
75
|
+
},
|
|
76
|
+
onMessage(listener) {
|
|
77
|
+
bus.subscribe(CACHE_SYNC_TOPICS.featureToggle, (payload) => {
|
|
78
|
+
// skip: foreign or malformed payload on the topic
|
|
79
|
+
if (!isTogglePayload(payload)) return;
|
|
80
|
+
listener(payload.featureName, payload.enabled);
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
onResync(listener) {
|
|
84
|
+
bus.onResync(listener);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -42,7 +42,10 @@ type FileRefRow = {
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
// Declared variants are implicitly public once the entityType check passes — `fieldName`/`variant` allow per-variant opt-out.
|
|
45
|
+
// entityType/entityId/fieldName of a FileRef are client-supplied at upload, so a predicate that
|
|
46
|
+
// serves "the public image of X" must also match `fileRefId` against the row it trusts.
|
|
45
47
|
export type DerivativePublicPredicateArgs = {
|
|
48
|
+
readonly fileRefId: string;
|
|
46
49
|
readonly entityId: string;
|
|
47
50
|
readonly tenantId: TenantId;
|
|
48
51
|
readonly fieldName: string;
|
|
@@ -119,7 +122,13 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
const isPublic = await usage.options.isPublic(
|
|
122
|
-
{
|
|
125
|
+
{
|
|
126
|
+
fileRefId: query.payload.fileRefId,
|
|
127
|
+
entityId,
|
|
128
|
+
tenantId: ctx.user.tenantId,
|
|
129
|
+
fieldName,
|
|
130
|
+
variant: query.payload.variant,
|
|
131
|
+
},
|
|
123
132
|
ctx,
|
|
124
133
|
);
|
|
125
134
|
if (!isPublic) return null;
|
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "FileHandle.derive throws for suffixes outside <name>-<16 hex>",
|
|
6
|
+
"detail": "Final review batch D.\n- `FileHandle.derive(suffix)` now throws unless the suffix is `<name>-<16 hex>` (build it with `variantSuffix(name, spec)`). Free-form suffixes such as `derive(\"thumb\")` wrote keys the forget/tenant-destroy sweep could not recognize, so those binaries survived erasure. Callers passing a bare name must switch to `variantSuffix`.\n- `resolveKmsWiring` / `requireKmsWiring` throw when a `*_CIPHERTEXT` slot is set without its plaintext, pointing at the async entry points, instead of a misleading \"trio required\" error or a silent plaintext-PII fallback.\n- Event-PII owner/`whenAbsent` resolution now also runs without a configured KMS, so a missing owner fails in dev like in prod.\n- PII event backfill queues rebuilds under a per-run migration id, so a peer replica's re-queue is no longer cleared by another run.\n- `seedAdminGuarded` re-writes the canonical admin's email so the blind index is repaired, and skips undecryptable foreign user rows instead of aborting the boot seed.\n- A tenant-bound `FileContext.list` refuses prefixes outside the tenant's key space.\n- An entityList whose create screen was dropped for the caller's roles now carries `createUnavailable`, so the renderer no longer falls back to a generic create form.\n- Boot validation rejects an object-form `redirect` with `idFrom` whose same-feature target screen carries no id.\n- `defineFeature` throws when `dedupeOptions` holds a nested object (it can never compare equal across two mounts).\n- `FileContext.ref` and `list` (tenant-bound) refuse keys outside `${tenantId}/`; list columns accept `virtual: true`; `httpRoute` session-only routes now pass the global IP rate limit; tenant timezone cache TTL is 30 s; text fields without a personal stance warn at boot; the orphan-derivative sweep keeps derivatives whose original still exists; `piiFields` owner fields that can never yield a string throw unless `whenAbsent` is set.",
|
|
7
|
+
"migration": "Build the suffix with variantSuffix(name, spec) instead of passing a free-form string."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.354.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "A tenant-bound FileContext.list throws for prefixes outside the tenant's key space",
|
|
13
|
+
"migration": "Pass a prefix that starts with `${tenantId}/`."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.354.0",
|
|
17
|
+
"type": "breaking",
|
|
18
|
+
"title": "A tenant-bound FileContext.ref throws for keys outside the tenant's key space",
|
|
19
|
+
"migration": "Use keys that start with `${tenantId}/`."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.354.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "The signed download URL carries a neutral file name instead of the personal fileName"
|
|
25
|
+
},
|
|
2
26
|
{
|
|
3
27
|
"version": "0.341.0",
|
|
4
28
|
"type": "improvement",
|
package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts
CHANGED
|
@@ -140,6 +140,10 @@ export async function sweepOrphanedDerivativesJob(
|
|
|
140
140
|
{ limit: 1 },
|
|
141
141
|
);
|
|
142
142
|
if (owners.length > 0) return "skipped";
|
|
143
|
+
// Binaries written through ctx.files.ref(key) (and their derive()d
|
|
144
|
+
// variants) never get a fileRef row; an original still sitting in storage
|
|
145
|
+
// means the derivative is not an orphan.
|
|
146
|
+
if (await candidate.provider.exists(candidate.originalKey)) return "skipped";
|
|
143
147
|
if (dryRun) {
|
|
144
148
|
dryRunWouldDelete++;
|
|
145
149
|
return "skipped";
|