@cosmicdrift/kumiko-samples 0.352.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 +26 -7
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- 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/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- 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 +7 -3
- 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/screens.ts +2 -2
- 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 +7 -8
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- 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/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- 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/public-names.ts +1 -1
- 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 +75 -25
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- 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 +45 -12
- 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/i18n.ts +1 -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 +17 -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/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- 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/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- 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/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +4 -2
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- 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/tags/feature.ts +2 -12
- 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 +19 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- 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/index.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/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- 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 +14 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
- 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 +8 -2
- 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/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- 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/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -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/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- 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/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +0 -2
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -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";
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
type StorageProviderHookCtx,
|
|
56
56
|
type TenantDataDestroyHook,
|
|
57
57
|
type TenantDataHookCtx,
|
|
58
|
+
type TenantDestroyHookResult,
|
|
58
59
|
type TenantId,
|
|
59
60
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
60
61
|
import {
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
storageKeyStemPrefix,
|
|
65
66
|
tenantStoragePrefixes,
|
|
66
67
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
68
|
+
import { type ChunkedMigrationResult, runChunkedMigration } from "../shared/index.js";
|
|
67
69
|
|
|
68
70
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
69
71
|
|
|
@@ -72,6 +74,8 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
|
|
|
72
74
|
// or a single unbounded array for a tenant with a huge prefix.
|
|
73
75
|
const SURVIVOR_CHECK_BATCH_SIZE = 500;
|
|
74
76
|
|
|
77
|
+
const DESTROY_CHUNK_SIZE = 500;
|
|
78
|
+
|
|
75
79
|
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
76
80
|
const chunks: T[][] = [];
|
|
77
81
|
for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
|
|
@@ -82,27 +86,54 @@ function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): bool
|
|
|
82
86
|
return prefixes.some((prefix) => storageKey.startsWith(prefix));
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
function toDestroyHookResult(
|
|
90
|
+
result: ChunkedMigrationResult,
|
|
91
|
+
failureMessage: () => string | undefined,
|
|
92
|
+
): TenantDestroyHookResult {
|
|
93
|
+
// A failure on the last item ends the run as "done" (nothing left to batch),
|
|
94
|
+
// so the failed count, not the stop reason, decides fail-closed.
|
|
95
|
+
if (result.failed > 0) {
|
|
96
|
+
throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
|
|
97
|
+
}
|
|
98
|
+
return { done: result.stoppedReason === "done", processed: result.migrated };
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
86
|
-
const rows = await ctx.db.selectMany<{ id: string; storageKey: string }>(fileRefsTable, {
|
|
87
|
-
tenantId: ctx.tenantId,
|
|
88
|
-
});
|
|
89
102
|
const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
|
|
90
103
|
const user = createSystemUser(ctx.tenantId);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
let firstFailure: string | undefined;
|
|
105
|
+
// Converges without a cursor: forget() hard-deletes, so each batch query
|
|
106
|
+
// returns the next rows.
|
|
107
|
+
const result = await runChunkedMigration<{ id: string; storageKey: string }>({
|
|
108
|
+
nextBatch: () =>
|
|
109
|
+
ctx.db.selectMany<{ id: string; storageKey: string }>(
|
|
110
|
+
fileRefsTable,
|
|
111
|
+
{ tenantId: ctx.tenantId },
|
|
112
|
+
{ limit: DESTROY_CHUNK_SIZE },
|
|
113
|
+
),
|
|
114
|
+
migrateRow: async (row) => {
|
|
115
|
+
if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
|
|
116
|
+
await deleteHandedOverBinary(ctx, row.storageKey);
|
|
117
|
+
}
|
|
118
|
+
const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
|
|
119
|
+
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
120
|
+
// discarded result would report this destroy stage "succeeded" while the
|
|
121
|
+
// row (and its PII fileName) survives. Throw so the pipeline's
|
|
122
|
+
// retry/abandon handling sees it.
|
|
123
|
+
if (!forgotten.isSuccess) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return "migrated";
|
|
129
|
+
},
|
|
130
|
+
maxFailures: 1,
|
|
131
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
132
|
+
onRowError: (_row, err) => {
|
|
133
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
return toDestroyHookResult(result, () => firstFailure);
|
|
106
137
|
};
|
|
107
138
|
|
|
108
139
|
// The bytes live under the tenant that uploaded them (`<tenantId>/…` or
|
|
@@ -170,17 +201,36 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
|
|
|
170
201
|
}
|
|
171
202
|
// Provider resolved — a list()/delete() failure from here IS fail-closed:
|
|
172
203
|
// the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
|
|
173
|
-
// it
|
|
174
|
-
//
|
|
204
|
+
// it and retries up to the stage's attempt cap before abandoning the destroy
|
|
205
|
+
// (list+delete are idempotent, so a retry converges rather than
|
|
206
|
+
// double-deleting or erroring on a missing key).
|
|
207
|
+
let processed = 0;
|
|
175
208
|
for (const prefix of tenantStoragePrefixes(tenantId)) {
|
|
176
209
|
const keys = await provider.list(prefix);
|
|
177
210
|
const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
const deletableKeys = keys.filter(
|
|
212
|
+
(key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)),
|
|
213
|
+
);
|
|
214
|
+
const pending = [...deletableKeys];
|
|
215
|
+
let firstFailure: string | undefined;
|
|
216
|
+
const result = await runChunkedMigration<string>({
|
|
217
|
+
nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
|
|
218
|
+
migrateRow: async (key) => {
|
|
219
|
+
assertSafeStorageKey(key);
|
|
220
|
+
await provider.delete(key);
|
|
221
|
+
return "migrated";
|
|
222
|
+
},
|
|
223
|
+
maxFailures: 1,
|
|
224
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
225
|
+
onRowError: (_key, err) => {
|
|
226
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const hookResult = toDestroyHookResult(result, () => firstFailure);
|
|
230
|
+
processed += result.migrated;
|
|
231
|
+
if (!hookResult.done) return { done: false, processed };
|
|
183
232
|
}
|
|
233
|
+
return { done: true, processed };
|
|
184
234
|
};
|
|
185
235
|
|
|
186
236
|
// Any listed key that is itself a surviving row's exact storageKey means a
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "folder:delete removes assignments whose host entity is gone instead of refusing forever",
|
|
6
|
+
"detail": "An assignment pointing at a hard-deleted, soft-deleted or unregistered host blocked folder:delete permanently with folder_has_assignments, because clear-folder needs a visible host and could not remove it. folder:delete now removes such assignments through the event-store executor (normal delete events) before the check, without the caller's read gate. Assignments of live hosts, including ones the caller cannot see, still block with folder_has_assignments. A refused delete removes nothing. The cleanup runs lazily at folder:delete, not in a host-delete hook, so restoring a soft-deleted host before then keeps its folder."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.246.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type EntityDefinition,
|
|
26
26
|
type FeatureRegistrar,
|
|
27
27
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
-
import {
|
|
28
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
29
29
|
import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants.js";
|
|
30
30
|
import { createFolderAssignmentEntity, folderEntity } from "./entity.js";
|
|
31
31
|
import { createClearFolderHandler } from "./handlers/clear-folder.write.js";
|
|
@@ -166,17 +166,7 @@ export function createFoldersFeature(opts: FoldersFeatureOptions = {}): typeof f
|
|
|
166
166
|
) {
|
|
167
167
|
return foldersFeature;
|
|
168
168
|
}
|
|
169
|
-
|
|
170
|
-
throw new Error(
|
|
171
|
-
"createFoldersFeature({ ownership }): ownership.write must not contain a " +
|
|
172
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
173
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
174
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
175
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
176
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
177
|
-
"leave it unset.",
|
|
178
|
-
);
|
|
179
|
-
}
|
|
169
|
+
assertNoWhereRuleInOwnershipWrite("createFoldersFeature", opts.ownership?.write);
|
|
180
170
|
if (opts.parents !== undefined && opts.parents.length === 0) {
|
|
181
171
|
throw new Error(
|
|
182
172
|
"createFoldersFeature({ parents }): parents must not be an empty array — " +
|