@cosmicdrift/kumiko-bundled-features 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/dist/agent-tools/agent-doc-lint.d.ts +2 -0
- package/dist/agent-tools/agent-doc-lint.js +61 -1
- package/dist/agent-tools/agent-manifest.d.ts +1 -0
- package/dist/agent-tools/agent-manifest.js +17 -5
- package/dist/agent-tools/feature.js +7 -2
- package/dist/agent-tools/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
- package/dist/agent-tools/manifest-deny-qns.js +17 -0
- package/dist/agent-tools/tool-catalog.d.ts +2 -1
- package/dist/agent-tools/tool-catalog.js +4 -1
- package/dist/agent-tools/tool-dispatch.js +1 -1
- package/dist/audit/handlers/list.query.js +13 -8
- package/dist/auth-email-password/handlers/change-password.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
- package/dist/auth-email-password/handlers/login.write.js +1 -0
- package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
- package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +2 -0
- package/dist/auth-email-password/seeding.d.ts +2 -0
- package/dist/auth-email-password/seeding.js +30 -8
- package/dist/auth-email-password/signup-activation.d.ts +24 -0
- package/dist/auth-email-password/signup-activation.js +41 -0
- package/dist/auth-email-password/signup-token-store.d.ts +5 -0
- package/dist/auth-email-password/signup-token-store.js +8 -3
- package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
- package/dist/auth-email-password/web/auth-gate.js +5 -5
- package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
- package/dist/auth-email-password/web/auth-redirect.js +14 -0
- package/dist/auth-email-password/web/login-screen.js +2 -2
- package/dist/auth-email-password/web/user-menu.js +1 -1
- package/dist/auth-mfa/feature.js +5 -0
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
- package/dist/auth-mfa/handlers/verify.write.js +1 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/screens.js +2 -2
- package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
- package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
- package/dist/billing-foundation/billing-info-query.d.ts +5 -2
- package/dist/billing-foundation/checkout-core.d.ts +7 -0
- package/dist/billing-foundation/checkout-core.js +7 -2
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-overview/access.js +2 -9
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
- package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
- package/dist/channel-in-app/in-app-channel.js +1 -1
- package/dist/channel-in-app/tables.js +4 -2
- package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
- package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
- package/dist/config/handlers/values.query.d.ts +3 -1
- package/dist/config/handlers/values.query.js +7 -2
- package/dist/config/table.d.ts +6 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
- package/dist/delivery/db/queries/address-opt-outs.js +12 -7
- package/dist/delivery/delivery-service.js +60 -12
- package/dist/delivery/public-names.js +1 -1
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/unsubscribe.js +66 -14
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/feature-toggles/feature.js +2 -3
- package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
- package/dist/feature-toggles/toggle-runtime.js +30 -6
- package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
- package/dist/feature-toggles/toggle-sync-signal.js +29 -10
- package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
- package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
- package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
- package/dist/files-tenant-data/hooks.js +61 -24
- package/dist/folders/entity.d.ts +8 -0
- package/dist/folders/feature.js +2 -10
- package/dist/folders/handlers/delete-folder.write.js +31 -4
- package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
- package/dist/form-draft/db/queries/cleanup.js +4 -3
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
- package/dist/form-draft/handlers/cleanup.job.js +11 -5
- package/dist/foundation-shared/index.d.ts +1 -1
- package/dist/foundation-shared/index.js +1 -1
- package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
- package/dist/foundation-shared/mail-host-policy.js +13 -4
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/inbound-provider-imap/feature.js +2 -0
- package/dist/inbound-provider-imap/imap-client.js +3 -4
- package/dist/jobs/constants.d.ts +1 -0
- package/dist/jobs/constants.js +1 -0
- package/dist/jobs/db/queries/retention.d.ts +1 -0
- package/dist/jobs/db/queries/retention.js +5 -0
- package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
- package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
- package/dist/jobs/feature.js +7 -1
- package/dist/jobs/handlers/list.query.d.ts +2 -1
- package/dist/jobs/handlers/list.query.js +40 -15
- package/dist/jobs/handlers/tenant-failures.query.js +1 -1
- package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
- package/dist/jobs/handlers/tenant-runs.query.js +72 -0
- package/dist/jobs/i18n.js +1 -0
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/job-run-logger.d.ts +2 -2
- package/dist/jobs/job-run-logger.js +98 -9
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/jobs/tenant-job-failure-table.js +15 -2
- package/dist/jobs/tenant-job-run-table.d.ts +4 -0
- package/dist/jobs/tenant-job-run-table.js +34 -0
- package/dist/ledger/entity.d.ts +24 -8
- package/dist/ledger/entity.js +17 -5
- package/dist/ledger/feature.js +3 -0
- package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
- package/dist/ledger/handlers/create-transaction.write.js +1 -0
- package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
- package/dist/ledger/schedule-subject-pair.d.ts +2 -0
- package/dist/ledger/schedule-subject-pair.js +24 -0
- package/dist/ledger/schemas.js +8 -2
- package/dist/mail-transport-smtp/feature.d.ts +3 -4
- package/dist/mail-transport-smtp/feature.js +4 -13
- package/dist/managed-pages/feature.js +28 -10
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/entity.js +4 -0
- package/dist/notes-history/feature.js +2 -10
- package/dist/personal-access-tokens/handlers/create.write.js +1 -0
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/screens.js +14 -3
- package/dist/secrets/table.d.ts +1 -0
- package/dist/seo/feature.js +28 -6
- package/dist/sessions/feature.d.ts +1 -0
- package/dist/sessions/feature.js +9 -8
- package/dist/sessions/handlers/detail.query.d.ts +1 -1
- package/dist/sessions/handlers/detail.query.js +2 -2
- package/dist/sessions/handlers/list.query.d.ts +1 -1
- package/dist/sessions/handlers/list.query.js +5 -3
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/sessions/screens.d.ts +2 -2
- package/dist/sessions/screens.js +6 -7
- package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
- package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
- package/dist/shared/encrypt-for-direct-write.js +10 -2
- package/dist/shared/index.d.ts +4 -3
- package/dist/shared/index.js +4 -3
- package/dist/shared/parent-visibility.d.ts +2 -1
- package/dist/shared/parent-visibility.js +21 -0
- package/dist/shared/rate-limited-text-response.d.ts +2 -0
- package/dist/shared/rate-limited-text-response.js +9 -0
- package/dist/shared/row-bound-grant.js +16 -2
- package/dist/shared/session-locale-field.d.ts +1 -0
- package/dist/shared/session-locale-field.js +6 -0
- package/dist/tags/entity.d.ts +9 -0
- package/dist/tags/feature.js +2 -10
- package/dist/template-resolver/api.d.ts +2 -2
- package/dist/template-resolver/handlers/list.query.d.ts +2 -0
- package/dist/template-resolver/handlers/list.query.js +2 -2
- package/dist/template-resolver/table.d.ts +48 -30
- package/dist/template-resolver/user-content-table.d.ts +34 -20
- package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
- package/dist/tenant/decrypt-tenant-names.js +10 -0
- package/dist/tenant/feature.js +7 -1
- package/dist/tenant/handlers/me.query.js +5 -1
- package/dist/tenant/handlers/memberships.query.js +3 -2
- package/dist/tenant/handlers/tenant-directory.query.js +9 -3
- package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
- package/dist/tenant/handlers/update-member-roles.write.js +1 -0
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +54 -8
- package/dist/tenant/invitation-table.js +6 -1
- package/dist/tenant/invited-membership.d.ts +8 -6
- package/dist/tenant/invited-membership.js +28 -7
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +18 -10
- package/dist/tenant/schema/tenant.js +4 -6
- package/dist/tenant/screens.js +1 -1
- package/dist/tenant-handover/move-entity-graph.js +11 -18
- package/dist/tenant-handover/transfer-graph.d.ts +2 -21
- package/dist/tenant-handover/transfer-graph.js +8 -75
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +13 -0
- package/dist/tenant-lifecycle/constants.d.ts +3 -0
- package/dist/tenant-lifecycle/constants.js +5 -0
- package/dist/tenant-lifecycle/events.d.ts +5 -0
- package/dist/tenant-lifecycle/events.js +5 -0
- package/dist/tenant-lifecycle/feature.js +6 -3
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
- package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
- package/dist/tenant-lifecycle/stages.d.ts +8 -1
- package/dist/tenant-lifecycle/stages.js +33 -4
- package/dist/tenant-lifecycle/testing.d.ts +8 -0
- package/dist/tenant-lifecycle/testing.js +42 -0
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/handlers/detail.query.js +2 -0
- package/dist/user/handlers/list.query.js +6 -2
- package/dist/user/handlers/update.write.js +1 -0
- package/dist/user/schema/user.d.ts +22 -8
- package/dist/user/seeding.d.ts +1 -0
- package/dist/user/seeding.js +20 -0
- package/dist/user-data-rights/boot-checks.js +5 -0
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
- package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -0
- package/dist/user-data-rights/index.js +1 -0
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
- package/dist/user-data-rights/schema/download-attempt.js +17 -9
- package/dist/user-data-rights/schema/download-token.d.ts +8 -0
- package/dist/user-data-rights/schema/export-job.d.ts +16 -8
- package/dist/user-profile/handlers/change-email.write.js +8 -2
- package/dist/waitlist/entity.d.ts +22 -6
- package/dist/workflow-runner/event-trigger.d.ts +1 -0
- package/dist/workflow-runner/event-trigger.js +63 -15
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
- package/dist/workflow-runner/runner.d.ts +2 -1
- package/dist/workflow-runner/runner.js +5 -1
- package/package.json +35 -10
- package/src/agent-tools/changes.json +12 -0
- package/src/audit/changes.json +6 -0
- package/src/auth-email-password/changes.json +24 -0
- package/src/auth-mfa/changes.json +7 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/channel-in-app/changes.json +8 -1
- package/src/crypto-shredding/changes.json +12 -0
- package/src/delivery/changes.json +6 -0
- package/src/files/changes.json +24 -0
- package/src/files-tenant-data/changes.json +5 -0
- package/src/folders/changes.json +6 -0
- package/src/form-draft/changes.json +7 -1
- package/src/inbound-provider-imap/changes.json +6 -0
- package/src/jobs/changes.json +30 -0
- package/src/ledger/changes.json +23 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/notes-history/changes.json +13 -0
- package/src/seo/changes.json +5 -0
- package/src/template-resolver/changes.json +5 -0
- package/src/tenant/changes.json +19 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-lifecycle/changes.json +22 -0
- package/src/tier-engine/changes.json +5 -0
- package/src/user/changes.json +15 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/workflow-runner/changes.json +5 -0
- package/dist/shared/has-where-rule.d.ts +0 -2
- package/dist/shared/has-where-rule.js +0 -10
|
@@ -32,6 +32,9 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
|
|
|
32
32
|
addressHash: z.string().min(1),
|
|
33
33
|
});
|
|
34
34
|
const UNSUBSCRIBE_EXPIRY = "7d";
|
|
35
|
+
const RESUBSCRIBE_EXPIRY = "1h";
|
|
36
|
+
const UNSUBSCRIBE_ISSUER = "kumiko:unsubscribe";
|
|
37
|
+
const RESUBSCRIBE_AUDIENCE = "kumiko:resubscribe";
|
|
35
38
|
const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
|
|
36
39
|
function assertUnsubscribeSecret(secret, caller) {
|
|
37
40
|
if (secret.length < MIN_UNSUBSCRIBE_SECRET_LENGTH) {
|
|
@@ -51,7 +54,7 @@ export async function signUnsubscribeToken(payload, secret) {
|
|
|
51
54
|
})
|
|
52
55
|
.setProtectedHeader({ alg: "HS256" })
|
|
53
56
|
.setSubject(String(payload.userId))
|
|
54
|
-
.setIssuer(
|
|
57
|
+
.setIssuer(UNSUBSCRIBE_ISSUER)
|
|
55
58
|
.setIssuedAt()
|
|
56
59
|
.setExpirationTime(UNSUBSCRIBE_EXPIRY)
|
|
57
60
|
.sign(encodedSecret);
|
|
@@ -79,12 +82,10 @@ export async function signAddressUnsubscribeToken(payload, secret) {
|
|
|
79
82
|
})
|
|
80
83
|
.setProtectedHeader({ alg: "HS256" })
|
|
81
84
|
.setSubject(addressHash)
|
|
82
|
-
.setIssuer(
|
|
85
|
+
.setIssuer(UNSUBSCRIBE_ISSUER)
|
|
83
86
|
.setIssuedAt()
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// today must keep working indefinitely — there's no signed-in flow for
|
|
87
|
-
// a no-account address to request a fresh link from.
|
|
87
|
+
// Only opt-out, so no expiry: a link mailed out today must keep working,
|
|
88
|
+
// there's no signed-in flow for a no-account address to request a fresh one.
|
|
88
89
|
.sign(encodedSecret));
|
|
89
90
|
}
|
|
90
91
|
// RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
|
|
@@ -94,18 +95,50 @@ const CONFIRMATION_PAGE_HEADERS = {
|
|
|
94
95
|
"Cache-Control": "no-store",
|
|
95
96
|
"Referrer-Policy": "no-referrer",
|
|
96
97
|
};
|
|
98
|
+
// Undo token, handed out only in the response of a successful unsubscribe. A
|
|
99
|
+
// separate audience and a short expiry keep the long-lived mailed unsubscribe
|
|
100
|
+
// link from being able to undo an opt-out (and vice versa).
|
|
101
|
+
async function signResubscribeToken(verified, encodedSecret) {
|
|
102
|
+
const claims = {
|
|
103
|
+
tenantId: verified.tenantId,
|
|
104
|
+
notificationType: verified.notificationType,
|
|
105
|
+
channel: verified.channel,
|
|
106
|
+
};
|
|
107
|
+
const jwt = verified.kind === "address"
|
|
108
|
+
? new jose.SignJWT({
|
|
109
|
+
...claims,
|
|
110
|
+
addressHash: verified.addressHash,
|
|
111
|
+
kind: "address",
|
|
112
|
+
}).setSubject(verified.addressHash)
|
|
113
|
+
: new jose.SignJWT(claims).setSubject(verified.userId);
|
|
114
|
+
return jwt
|
|
115
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
116
|
+
.setIssuer(UNSUBSCRIBE_ISSUER)
|
|
117
|
+
.setAudience(RESUBSCRIBE_AUDIENCE)
|
|
118
|
+
.setIssuedAt()
|
|
119
|
+
.setExpirationTime(RESUBSCRIBE_EXPIRY)
|
|
120
|
+
.sign(encodedSecret);
|
|
121
|
+
}
|
|
97
122
|
// Every throw here must become the same ExtraRouteRejection(400,
|
|
98
123
|
// unsubscribe_token_invalid) — anything else falls through to the
|
|
99
124
|
// framework's generic 401 extra_route_signature_invalid mapping, which
|
|
100
125
|
// would leak jose's internal error text into the response body.
|
|
101
|
-
async function verifyUnsubscribeToken(token, encodedSecret) {
|
|
126
|
+
async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
|
|
102
127
|
try {
|
|
103
128
|
if (!token) {
|
|
104
129
|
throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
|
|
105
130
|
}
|
|
106
|
-
const { payload: verifiedPayload } =
|
|
107
|
-
|
|
108
|
-
|
|
131
|
+
const { payload: verifiedPayload } = purpose === "resubscribe"
|
|
132
|
+
? await jose.jwtVerify(token, encodedSecret, {
|
|
133
|
+
issuer: UNSUBSCRIBE_ISSUER,
|
|
134
|
+
audience: RESUBSCRIBE_AUDIENCE,
|
|
135
|
+
requiredClaims: ["exp"],
|
|
136
|
+
})
|
|
137
|
+
: await jose.jwtVerify(token, encodedSecret, { issuer: UNSUBSCRIBE_ISSUER });
|
|
138
|
+
// jose only checks `aud` when asked to, so an unsubscribe route would accept an undo token.
|
|
139
|
+
if (purpose === "unsubscribe" && verifiedPayload.aud !== undefined) {
|
|
140
|
+
throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "resubscribe token used as unsubscribe token");
|
|
141
|
+
}
|
|
109
142
|
const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
110
143
|
if (addressAttempt.success) {
|
|
111
144
|
const parsed = addressAttempt.data;
|
|
@@ -211,6 +244,23 @@ function confirmationPage(token) {
|
|
|
211
244
|
</body>
|
|
212
245
|
</html>`;
|
|
213
246
|
}
|
|
247
|
+
function unsubscribedPage(resubscribeToken) {
|
|
248
|
+
return `<!doctype html>
|
|
249
|
+
<html>
|
|
250
|
+
<head>
|
|
251
|
+
<meta charset="utf-8">
|
|
252
|
+
<meta name="robots" content="noindex">
|
|
253
|
+
<title>Unsubscribed</title>
|
|
254
|
+
</head>
|
|
255
|
+
<body>
|
|
256
|
+
<p>You have been unsubscribed.</p>
|
|
257
|
+
<form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
|
|
258
|
+
<input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
|
|
259
|
+
<button type="submit">Undo</button>
|
|
260
|
+
</form>
|
|
261
|
+
</body>
|
|
262
|
+
</html>`;
|
|
263
|
+
}
|
|
214
264
|
// One-click clients put the token in the query string; only the
|
|
215
265
|
// confirmation-page form submits it in the body.
|
|
216
266
|
function tokenFromPostRequest(request) {
|
|
@@ -251,7 +301,7 @@ export function createUnsubscribeRoutes(options) {
|
|
|
251
301
|
if (!token) {
|
|
252
302
|
throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
|
|
253
303
|
}
|
|
254
|
-
await verifyUnsubscribeToken(token, encodedSecret);
|
|
304
|
+
await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
255
305
|
return { token };
|
|
256
306
|
},
|
|
257
307
|
handler: async (c, { token }) => c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
|
|
@@ -260,7 +310,7 @@ export function createUnsubscribeRoutes(options) {
|
|
|
260
310
|
method: "POST",
|
|
261
311
|
path: DELIVERY_UNSUBSCRIBE_PATH,
|
|
262
312
|
entry: "signature",
|
|
263
|
-
verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret),
|
|
313
|
+
verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
|
|
264
314
|
handler: async (c, verified, deps) => {
|
|
265
315
|
// Token-verify passed — everything below is a legitimate write. Don't
|
|
266
316
|
// swallow write-errors as "invalid token", that would mask real bugs
|
|
@@ -269,7 +319,9 @@ export function createUnsubscribeRoutes(options) {
|
|
|
269
319
|
if (!succeeded) {
|
|
270
320
|
return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
|
|
271
321
|
}
|
|
272
|
-
return c.html(
|
|
322
|
+
return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
|
|
323
|
+
"Cache-Control": "no-store",
|
|
324
|
+
});
|
|
273
325
|
},
|
|
274
326
|
});
|
|
275
327
|
// Undo direction: token only from the JSON/form body, never the query
|
|
@@ -279,7 +331,7 @@ export function createUnsubscribeRoutes(options) {
|
|
|
279
331
|
method: "POST",
|
|
280
332
|
path: DELIVERY_RESUBSCRIBE_PATH,
|
|
281
333
|
entry: "signature",
|
|
282
|
-
verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
|
|
334
|
+
verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
|
|
283
335
|
handler: async (c, verified, deps) => {
|
|
284
336
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
285
337
|
if (outcome === "limit_reached") {
|
|
@@ -14,6 +14,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
14
14
|
readonly type: "text";
|
|
15
15
|
readonly description?: string;
|
|
16
16
|
readonly maxLength?: number;
|
|
17
|
+
readonly minLength?: number;
|
|
17
18
|
readonly required?: boolean;
|
|
18
19
|
readonly searchable?: boolean;
|
|
19
20
|
readonly sortable?: boolean;
|
|
@@ -35,6 +36,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
|
|
|
35
36
|
readonly type: "text";
|
|
36
37
|
readonly description?: string;
|
|
37
38
|
readonly maxLength?: number;
|
|
39
|
+
readonly minLength?: number;
|
|
38
40
|
readonly required?: boolean;
|
|
39
41
|
readonly searchable?: boolean;
|
|
40
42
|
readonly sortable?: boolean;
|
|
@@ -75,6 +77,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
|
|
|
75
77
|
readonly type: "text";
|
|
76
78
|
readonly description?: string;
|
|
77
79
|
readonly maxLength?: number;
|
|
80
|
+
readonly minLength?: number;
|
|
78
81
|
readonly required?: boolean;
|
|
79
82
|
readonly searchable?: boolean;
|
|
80
83
|
readonly sortable?: boolean;
|
|
@@ -96,6 +99,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
|
|
|
96
99
|
readonly type: "text";
|
|
97
100
|
readonly description?: string;
|
|
98
101
|
readonly maxLength?: number;
|
|
102
|
+
readonly minLength?: number;
|
|
99
103
|
readonly required?: boolean;
|
|
100
104
|
readonly searchable?: boolean;
|
|
101
105
|
readonly sortable?: boolean;
|
|
@@ -64,9 +64,8 @@ export function createFeatureTogglesFeature(options = {}) {
|
|
|
64
64
|
// process's handler calls runtime.broadcastToggle, which publishes on
|
|
65
65
|
// GlobalFeatureToggleRuntime's syncSignal so every process (including
|
|
66
66
|
// the one that won the cursor) applies the flip to its own snapshot —
|
|
67
|
-
// see toggle-runtime.ts and toggle-sync-signal.ts.
|
|
68
|
-
//
|
|
69
|
-
// applies directly; there is nobody else to reach. Named "cache-sync"
|
|
67
|
+
// see toggle-runtime.ts and toggle-sync-signal.ts. The signal defaults to
|
|
68
|
+
// the cache-sync bus (Redis with REDIS_URL, process-local without). Named "cache-sync"
|
|
70
69
|
// (not "projection" or "audit") because it's side-effect-only
|
|
71
70
|
// infrastructure.
|
|
72
71
|
//
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { type ClosableCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
|
|
3
4
|
export type ToggleSyncSignal = {
|
|
4
5
|
publish(featureName: string, enabled: boolean): void;
|
|
5
6
|
onMessage(listener: (featureName: string, enabled: boolean) => void): void;
|
|
7
|
+
onResync?(listener: () => void): void;
|
|
6
8
|
};
|
|
7
9
|
export declare class GlobalFeatureToggleRuntime {
|
|
8
10
|
private readonly db;
|
|
9
11
|
private readonly registry;
|
|
10
12
|
private readonly syncSignal?;
|
|
13
|
+
private readonly ownedBus?;
|
|
11
14
|
private snapshot;
|
|
12
15
|
private appliedDuringLoad;
|
|
13
|
-
constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined);
|
|
16
|
+
constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined, ownedBus?: ClosableCacheSyncBus | undefined);
|
|
17
|
+
close(): Promise<void>;
|
|
14
18
|
initialize(): Promise<void>;
|
|
15
19
|
refresh(): Promise<void>;
|
|
16
20
|
apply(featureName: string, enabled: boolean): void;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { computeEffectiveFeatures, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
|
+
import { createDefaultCacheSyncBus, } from "@cosmicdrift/kumiko-framework/redis";
|
|
3
5
|
import { globalFeatureStateTable } from "./global-feature-state-table.js";
|
|
6
|
+
import { createCacheSyncToggleSignal } from "./toggle-sync-signal.js";
|
|
7
|
+
const log = createFallbackLogger("kumiko:feature-toggles");
|
|
4
8
|
// Holds the current global-override snapshot in memory and exposes a
|
|
5
9
|
// synchronous reader — the dispatcher's feature-gate calls it on every
|
|
6
10
|
// handler invocation, so this must not do I/O on the hot path. The
|
|
@@ -16,14 +20,16 @@ export class GlobalFeatureToggleRuntime {
|
|
|
16
20
|
db;
|
|
17
21
|
registry;
|
|
18
22
|
syncSignal;
|
|
23
|
+
ownedBus;
|
|
19
24
|
snapshot = new Map();
|
|
20
25
|
// Flips applied while a snapshot load is in flight. The load's SELECT may have
|
|
21
26
|
// started before the flip committed, so its rows can be older than these.
|
|
22
27
|
appliedDuringLoad;
|
|
23
|
-
constructor(db, registry, syncSignal) {
|
|
28
|
+
constructor(db, registry, syncSignal, ownedBus) {
|
|
24
29
|
this.db = db;
|
|
25
30
|
this.registry = registry;
|
|
26
31
|
this.syncSignal = syncSignal;
|
|
32
|
+
this.ownedBus = ownedBus;
|
|
27
33
|
// Every process, including the one that calls broadcastToggle, learns
|
|
28
34
|
// of a flip through this same subscription — mirrors the SSE broker's
|
|
29
35
|
// publish-then-echo-to-self pattern instead of a separate local-apply
|
|
@@ -31,6 +37,15 @@ export class GlobalFeatureToggleRuntime {
|
|
|
31
37
|
this.syncSignal?.onMessage((featureName, enabled) => {
|
|
32
38
|
this.apply(featureName, enabled);
|
|
33
39
|
});
|
|
40
|
+
this.syncSignal?.onResync?.(() => {
|
|
41
|
+
this.refresh().catch((err) => {
|
|
42
|
+
log.error("snapshot reload after sync reconnect failed", { err });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Only the runtime built by createFeatureToggleRuntime without an explicit signal owns one.
|
|
47
|
+
async close() {
|
|
48
|
+
await this.ownedBus?.close();
|
|
34
49
|
}
|
|
35
50
|
async initialize() {
|
|
36
51
|
const pendingFlips = new Map();
|
|
@@ -99,11 +114,20 @@ export class GlobalFeatureToggleRuntime {
|
|
|
99
114
|
return computeEffectiveFeatures(this.registry, reader);
|
|
100
115
|
};
|
|
101
116
|
}
|
|
102
|
-
// Factory for app-boot wiring: instantiate, initialize, return
|
|
103
|
-
//
|
|
104
|
-
//
|
|
117
|
+
// Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
|
|
118
|
+
// set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
|
|
119
|
+
// syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
|
|
120
|
+
// REDIS_URL is set, process-local otherwise. close() releases a bus it created.
|
|
105
121
|
export async function createFeatureToggleRuntime(db, registry, syncSignal) {
|
|
106
|
-
const
|
|
107
|
-
|
|
122
|
+
const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
|
|
123
|
+
const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
|
|
124
|
+
const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
|
|
125
|
+
try {
|
|
126
|
+
await runtime.initialize();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
await runtime.close();
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
108
132
|
return runtime;
|
|
109
133
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
|
|
1
|
+
import { type CacheSyncBus, type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
|
|
2
2
|
import type { ToggleSyncSignal } from "./toggle-runtime.js";
|
|
3
3
|
export type RedisToggleSyncSignal = ToggleSyncSignal & {
|
|
4
4
|
close(): Promise<void>;
|
|
5
5
|
};
|
|
6
|
-
export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions): RedisToggleSyncSignal;
|
|
6
|
+
export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions, channelPrefix?: string): RedisToggleSyncSignal;
|
|
7
|
+
export declare function createCacheSyncToggleSignal(bus: CacheSyncBus): ToggleSyncSignal;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createRedisPubSubSignal, } from "@cosmicdrift/kumiko-framework/redis";
|
|
1
|
+
import { CACHE_SYNC_TOPICS, createRedisPubSubSignal, redisChannelPrefixFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
|
|
2
2
|
// Single fixed channel — unlike the SSE broker there's no per-tenant/
|
|
3
3
|
// per-user variance to multiplex, every toggle flip is global.
|
|
4
|
-
const
|
|
4
|
+
const TOGGLE_SYNC_CHANNEL_NAME = "kumiko:feature-toggles:cache-sync";
|
|
5
5
|
function isTogglePayload(value) {
|
|
6
6
|
return (typeof value === "object" &&
|
|
7
7
|
value !== null &&
|
|
@@ -11,26 +11,25 @@ function isTogglePayload(value) {
|
|
|
11
11
|
// fw#2625: gives toggle-cache-sync the cross-replica transport its shared
|
|
12
12
|
// dispatcher cursor needs — without this, only the one process that won
|
|
13
13
|
// the shared cursor's toggle-set event would ever learn about a flip.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
|
|
14
|
+
// Standalone Redis transport for callers that want their own connections;
|
|
15
|
+
// createFeatureToggleRuntime builds a cache-sync-bus signal by default.
|
|
16
|
+
export function createRedisToggleSyncSignal(redisUrl, clientOptions, channelPrefix = redisChannelPrefixFromEnv()) {
|
|
17
|
+
const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
|
|
19
18
|
const signal = createRedisPubSubSignal({
|
|
20
19
|
redisUrl,
|
|
21
|
-
channelPattern:
|
|
20
|
+
channelPattern: toggleSyncChannel,
|
|
22
21
|
label: "feature-toggles",
|
|
23
22
|
...(clientOptions ? { clientOptions } : {}),
|
|
24
23
|
});
|
|
25
24
|
return {
|
|
26
25
|
publish(featureName, enabled) {
|
|
27
|
-
signal.publish(
|
|
26
|
+
signal.publish(toggleSyncChannel, { featureName, enabled });
|
|
28
27
|
},
|
|
29
28
|
onMessage(listener) {
|
|
30
29
|
signal.onMessage((_channel, payload) => {
|
|
31
30
|
if (!isTogglePayload(payload)) {
|
|
32
31
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.log in a raw PubSubSignal handler.
|
|
33
|
-
console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${
|
|
32
|
+
console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${toggleSyncChannel}"`);
|
|
34
33
|
// skip: malformed message already logged above, nothing to apply
|
|
35
34
|
return;
|
|
36
35
|
}
|
|
@@ -40,3 +39,23 @@ export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
|
|
|
40
39
|
close: signal.close,
|
|
41
40
|
};
|
|
42
41
|
}
|
|
42
|
+
// Reuses the framework cache-sync bus (local without REDIS_URL, Redis-backed with it) so
|
|
43
|
+
// toggles need no Pub/Sub wiring of their own. The bus applies a publish locally first.
|
|
44
|
+
export function createCacheSyncToggleSignal(bus) {
|
|
45
|
+
return {
|
|
46
|
+
publish(featureName, enabled) {
|
|
47
|
+
bus.publish(CACHE_SYNC_TOPICS.featureToggle, { featureName, enabled });
|
|
48
|
+
},
|
|
49
|
+
onMessage(listener) {
|
|
50
|
+
bus.subscribe(CACHE_SYNC_TOPICS.featureToggle, (payload) => {
|
|
51
|
+
// skip: foreign or malformed payload on the topic
|
|
52
|
+
if (!isTogglePayload(payload))
|
|
53
|
+
return;
|
|
54
|
+
listener(payload.featureName, payload.enabled);
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
onResync(listener) {
|
|
58
|
+
bus.onResync(listener);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EXT_DERIVATIVE_PUBLIC_PREDICATE, type HandlerContext, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
export type DerivativePublicPredicateArgs = {
|
|
4
|
+
readonly fileRefId: string;
|
|
4
5
|
readonly entityId: string;
|
|
5
6
|
readonly tenantId: TenantId;
|
|
6
7
|
readonly fieldName: string;
|
|
@@ -78,7 +78,13 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
78
78
|
if (!isDerivativePublicPredicatePlugin(usage.options)) {
|
|
79
79
|
throw new Error(`file-derivatives: "${usage.entityName}" registered ${EXT_DERIVATIVE_PUBLIC_PREDICATE} without an isPublic(args, ctx) — extension options must be a DerivativePublicPredicatePlugin.`);
|
|
80
80
|
}
|
|
81
|
-
const isPublic = await usage.options.isPublic({
|
|
81
|
+
const isPublic = await usage.options.isPublic({
|
|
82
|
+
fileRefId: query.payload.fileRefId,
|
|
83
|
+
entityId,
|
|
84
|
+
tenantId: ctx.user.tenantId,
|
|
85
|
+
fieldName,
|
|
86
|
+
variant: query.payload.variant,
|
|
87
|
+
}, ctx);
|
|
82
88
|
if (!isPublic)
|
|
83
89
|
return null;
|
|
84
90
|
// ctx.files/ctx.derivatives are optional on HandlerContext (only wired
|
|
@@ -99,6 +99,11 @@ export async function sweepOrphanedDerivativesJob(rawPayload, ctx, db) {
|
|
|
99
99
|
const owners = await selectMany(db, fileRefsTable, { tenantId: candidate.tenantId, storageKey: candidate.originalKey }, { limit: 1 });
|
|
100
100
|
if (owners.length > 0)
|
|
101
101
|
return "skipped";
|
|
102
|
+
// Binaries written through ctx.files.ref(key) (and their derive()d
|
|
103
|
+
// variants) never get a fileRef row; an original still sitting in storage
|
|
104
|
+
// means the derivative is not an orphan.
|
|
105
|
+
if (await candidate.provider.exists(candidate.originalKey))
|
|
106
|
+
return "skipped";
|
|
102
107
|
if (dryRun) {
|
|
103
108
|
dryRunWouldDelete++;
|
|
104
109
|
return "skipped";
|
|
@@ -49,11 +49,13 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
49
49
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
50
50
|
import { createSystemUser, parseTenantId, } from "@cosmicdrift/kumiko-framework/engine";
|
|
51
51
|
import { assertSafeStorageKey, fileRefEntity, fileRefsTable, storageKeyStemPrefix, tenantStoragePrefixes, } from "@cosmicdrift/kumiko-framework/files";
|
|
52
|
+
import { runChunkedMigration } from "../shared/index.js";
|
|
52
53
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
53
54
|
// Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
|
|
54
55
|
// — bounds each query instead of one `storage_key = ANY(...)` per listed key,
|
|
55
56
|
// or a single unbounded array for a tenant with a huge prefix.
|
|
56
57
|
const SURVIVOR_CHECK_BATCH_SIZE = 500;
|
|
58
|
+
const DESTROY_CHUNK_SIZE = 500;
|
|
57
59
|
function chunk(items, size) {
|
|
58
60
|
const chunks = [];
|
|
59
61
|
for (let i = 0; i < items.length; i += size)
|
|
@@ -63,25 +65,43 @@ function chunk(items, size) {
|
|
|
63
65
|
function isUnderOwnPrefix(storageKey, prefixes) {
|
|
64
66
|
return prefixes.some((prefix) => storageKey.startsWith(prefix));
|
|
65
67
|
}
|
|
68
|
+
function toDestroyHookResult(result, failureMessage) {
|
|
69
|
+
// A failure on the last item ends the run as "done" (nothing left to batch),
|
|
70
|
+
// so the failed count, not the stop reason, decides fail-closed.
|
|
71
|
+
if (result.failed > 0) {
|
|
72
|
+
throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
|
|
73
|
+
}
|
|
74
|
+
return { done: result.stoppedReason === "done", processed: result.migrated };
|
|
75
|
+
}
|
|
66
76
|
export const fileRefTenantDestroyHook = async (ctx) => {
|
|
67
|
-
const rows = await ctx.db.selectMany(fileRefsTable, {
|
|
68
|
-
tenantId: ctx.tenantId,
|
|
69
|
-
});
|
|
70
77
|
const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
|
|
71
78
|
const user = createSystemUser(ctx.tenantId);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
let firstFailure;
|
|
80
|
+
// Converges without a cursor: forget() hard-deletes, so each batch query
|
|
81
|
+
// returns the next rows.
|
|
82
|
+
const result = await runChunkedMigration({
|
|
83
|
+
nextBatch: () => ctx.db.selectMany(fileRefsTable, { tenantId: ctx.tenantId }, { limit: DESTROY_CHUNK_SIZE }),
|
|
84
|
+
migrateRow: async (row) => {
|
|
85
|
+
if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
|
|
86
|
+
await deleteHandedOverBinary(ctx, row.storageKey);
|
|
87
|
+
}
|
|
88
|
+
const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
|
|
89
|
+
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
90
|
+
// discarded result would report this destroy stage "succeeded" while the
|
|
91
|
+
// row (and its PII fileName) survives. Throw so the pipeline's
|
|
92
|
+
// retry/abandon handling sees it.
|
|
93
|
+
if (!forgotten.isSuccess) {
|
|
94
|
+
throw new Error(`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`);
|
|
95
|
+
}
|
|
96
|
+
return "migrated";
|
|
97
|
+
},
|
|
98
|
+
maxFailures: 1,
|
|
99
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
100
|
+
onRowError: (_row, err) => {
|
|
101
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return toDestroyHookResult(result, () => firstFailure);
|
|
85
105
|
};
|
|
86
106
|
// The bytes live under the tenant that uploaded them (`<tenantId>/…` or
|
|
87
107
|
// `exports/<tenantId>/…`), so with per-tenant storage config that tenant's
|
|
@@ -137,18 +157,35 @@ export const fileRefStorageDestroyHook = async (tenantId, ctx) => {
|
|
|
137
157
|
}
|
|
138
158
|
// Provider resolved — a list()/delete() failure from here IS fail-closed:
|
|
139
159
|
// the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
|
|
140
|
-
// it
|
|
141
|
-
//
|
|
160
|
+
// it and retries up to the stage's attempt cap before abandoning the destroy
|
|
161
|
+
// (list+delete are idempotent, so a retry converges rather than
|
|
162
|
+
// double-deleting or erroring on a missing key).
|
|
163
|
+
let processed = 0;
|
|
142
164
|
for (const prefix of tenantStoragePrefixes(tenantId)) {
|
|
143
165
|
const keys = await provider.list(prefix);
|
|
144
166
|
const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
167
|
+
const deletableKeys = keys.filter((key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)));
|
|
168
|
+
const pending = [...deletableKeys];
|
|
169
|
+
let firstFailure;
|
|
170
|
+
const result = await runChunkedMigration({
|
|
171
|
+
nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
|
|
172
|
+
migrateRow: async (key) => {
|
|
173
|
+
assertSafeStorageKey(key);
|
|
174
|
+
await provider.delete(key);
|
|
175
|
+
return "migrated";
|
|
176
|
+
},
|
|
177
|
+
maxFailures: 1,
|
|
178
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
179
|
+
onRowError: (_key, err) => {
|
|
180
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const hookResult = toDestroyHookResult(result, () => firstFailure);
|
|
184
|
+
processed += result.migrated;
|
|
185
|
+
if (!hookResult.done)
|
|
186
|
+
return { done: false, processed };
|
|
151
187
|
}
|
|
188
|
+
return { done: true, processed };
|
|
152
189
|
};
|
|
153
190
|
// Any listed key that is itself a surviving row's exact storageKey means a
|
|
154
191
|
// foreign tenant still owns it (see header comment) — its stem prefix then
|
package/dist/folders/entity.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const folderEntity: EntityDefinition<{
|
|
|
4
4
|
readonly type: "text";
|
|
5
5
|
readonly description?: string;
|
|
6
6
|
readonly maxLength?: number;
|
|
7
|
+
readonly minLength?: number;
|
|
7
8
|
readonly required?: boolean;
|
|
8
9
|
readonly searchable?: boolean;
|
|
9
10
|
readonly sortable?: boolean;
|
|
@@ -25,6 +26,7 @@ export declare const folderEntity: EntityDefinition<{
|
|
|
25
26
|
readonly type: "text";
|
|
26
27
|
readonly description?: string;
|
|
27
28
|
readonly maxLength?: number;
|
|
29
|
+
readonly minLength?: number;
|
|
28
30
|
readonly required?: boolean;
|
|
29
31
|
readonly searchable?: boolean;
|
|
30
32
|
readonly sortable?: boolean;
|
|
@@ -48,6 +50,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
|
|
|
48
50
|
readonly type: "text";
|
|
49
51
|
readonly description?: string;
|
|
50
52
|
readonly maxLength?: number;
|
|
53
|
+
readonly minLength?: number;
|
|
51
54
|
readonly required?: boolean;
|
|
52
55
|
readonly searchable?: boolean;
|
|
53
56
|
readonly sortable?: boolean;
|
|
@@ -69,6 +72,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
|
|
|
69
72
|
readonly type: "text";
|
|
70
73
|
readonly description?: string;
|
|
71
74
|
readonly maxLength?: number;
|
|
75
|
+
readonly minLength?: number;
|
|
72
76
|
readonly required?: boolean;
|
|
73
77
|
readonly searchable?: boolean;
|
|
74
78
|
readonly sortable?: boolean;
|
|
@@ -90,6 +94,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
|
|
|
90
94
|
readonly type: "text";
|
|
91
95
|
readonly description?: string;
|
|
92
96
|
readonly maxLength?: number;
|
|
97
|
+
readonly minLength?: number;
|
|
93
98
|
readonly required?: boolean;
|
|
94
99
|
readonly searchable?: boolean;
|
|
95
100
|
readonly sortable?: boolean;
|
|
@@ -113,6 +118,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
|
|
|
113
118
|
readonly type: "text";
|
|
114
119
|
readonly description?: string;
|
|
115
120
|
readonly maxLength?: number;
|
|
121
|
+
readonly minLength?: number;
|
|
116
122
|
readonly required?: boolean;
|
|
117
123
|
readonly searchable?: boolean;
|
|
118
124
|
readonly sortable?: boolean;
|
|
@@ -134,6 +140,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
|
|
|
134
140
|
readonly type: "text";
|
|
135
141
|
readonly description?: string;
|
|
136
142
|
readonly maxLength?: number;
|
|
143
|
+
readonly minLength?: number;
|
|
137
144
|
readonly required?: boolean;
|
|
138
145
|
readonly searchable?: boolean;
|
|
139
146
|
readonly sortable?: boolean;
|
|
@@ -155,6 +162,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
|
|
|
155
162
|
readonly type: "text";
|
|
156
163
|
readonly description?: string;
|
|
157
164
|
readonly maxLength?: number;
|
|
165
|
+
readonly minLength?: number;
|
|
158
166
|
readonly required?: boolean;
|
|
159
167
|
readonly searchable?: boolean;
|
|
160
168
|
readonly sortable?: boolean;
|
package/dist/folders/feature.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Deferred: reparenting with cycle-check (folder:update CAN change parentId, but
|
|
16
16
|
// no UI exposes it in v1 — folders-view guards cycles defensively).
|
|
17
17
|
import { defineEntityCreateHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
-
import {
|
|
18
|
+
import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
|
|
19
19
|
import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants.js";
|
|
20
20
|
import { createFolderAssignmentEntity, folderEntity } from "./entity.js";
|
|
21
21
|
import { createClearFolderHandler } from "./handlers/clear-folder.write.js";
|
|
@@ -80,15 +80,7 @@ export function createFoldersFeature(opts = {}) {
|
|
|
80
80
|
opts.parents === undefined) {
|
|
81
81
|
return foldersFeature;
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
throw new Error("createFoldersFeature({ ownership }): ownership.write must not contain a " +
|
|
85
|
-
'`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
|
|
86
|
-
"layer (the read path, via buildOwnershipClause). Write paths that " +
|
|
87
|
-
"consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
|
|
88
|
-
"evaluate them, so such a rule can only ever deny — boot validation " +
|
|
89
|
-
"rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
|
|
90
|
-
"leave it unset.");
|
|
91
|
-
}
|
|
83
|
+
assertNoWhereRuleInOwnershipWrite("createFoldersFeature", opts.ownership?.write);
|
|
92
84
|
if (opts.parents !== undefined && opts.parents.length === 0) {
|
|
93
85
|
throw new Error("createFoldersFeature({ parents }): parents must not be an empty array — " +
|
|
94
86
|
"an empty allowlist rejects every set-folder call and hides every " +
|