@cosmicdrift/kumiko-samples 0.404.0 → 0.405.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 +25 -25
- package/packages/bundled-features/src/account-security/README.md +7 -5
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
- package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
- package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
- package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
- package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
- package/packages/bundled-features/src/documents/changes.json +8 -0
- package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
- package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
- package/packages/bundled-features/src/folders/feature.ts +2 -8
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
- package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
- package/packages/bundled-features/src/host-routing/README.md +10 -10
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
- package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
- package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
- package/packages/bundled-features/src/ledger/feature.ts +2 -7
- package/packages/bundled-features/src/legal-pages/README.md +21 -13
- package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
- package/packages/bundled-features/src/notes-history/feature.ts +7 -8
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
- package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
- package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
- package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
- package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
- package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
- package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
- package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
- package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
- package/packages/bundled-features/src/shared/signed-token.ts +4 -14
- package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
- package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
- package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
- package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
- package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
- package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
- package/packages/bundled-features/src/tags/feature.ts +2 -8
- package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
- package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
- package/packages/bundled-features/src/template-resolver/README.md +29 -29
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
- package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
- package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
- package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
- package/packages/bundled-features/src/user-data-rights/README.md +33 -33
- package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
- package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
- package/samples/apps/admin-console/README.md +5 -5
- package/samples/apps/admin-console/package.json +3 -3
- package/samples/apps/cap-billing-demo/README.md +15 -15
- package/samples/apps/config-demo/README.md +7 -7
- package/samples/apps/config-demo/package.json +2 -2
- package/samples/apps/marketing-demo/README.md +2 -2
- package/samples/apps/marketing-demo/package.json +2 -2
- package/samples/apps/showcase/package.json +3 -3
- package/samples/apps/styleguide/package.json +2 -2
- package/samples/apps/ui-walkthrough/README.md +8 -9
- package/samples/apps/ui-walkthrough/package.json +2 -2
- package/samples/apps/use-all-bundled/README.md +2 -2
- package/samples/apps/use-all-bundled/package.json +2 -2
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
- package/samples/apps/user-data-rights-demo/README.md +12 -12
- package/samples/apps/workspaces/package.json +2 -2
- package/samples/recipes/apex-landing/README.md +3 -3
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-surface-auth/README.md +13 -9
- package/samples/recipes/auth-mfa/README.md +2 -4
- package/samples/recipes/auth-mfa/package.json +2 -2
- package/samples/recipes/boot-check/README.md +2 -2
- package/samples/recipes/compliance-profiles-demo/README.md +5 -5
- package/samples/recipes/compose-stacks/README.md +8 -8
- package/samples/recipes/cross-feature-events/README.md +1 -0
- package/samples/recipes/crypto-shredding-hr/README.md +3 -3
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/documents/README.md +25 -0
- package/samples/recipes/embedded-entity-form/package.json +2 -2
- package/samples/recipes/encrypted-tenant-config/README.md +8 -8
- package/samples/recipes/env-composition/README.md +10 -10
- package/samples/recipes/error-contract/README.md +9 -9
- package/samples/recipes/event-sourcing/README.md +9 -9
- package/samples/recipes/ledger-invoicing/README.md +6 -6
- package/samples/recipes/legal-pages/README.md +16 -16
- package/samples/recipes/managed-config/README.md +15 -11
- package/samples/recipes/managed-pages/README.md +7 -7
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pipeline-basics/README.md +13 -13
- package/samples/recipes/public-share-token/README.md +4 -4
- package/samples/recipes/record-detail-layout/package.json +3 -3
- package/samples/recipes/security-baseline/README.md +4 -1
- package/samples/recipes/session-revocation/README.md +1 -1
- package/samples/recipes/tenant-settings/README.md +4 -4
- package/samples/recipes/tier-admin/README.md +2 -2
- package/samples/recipes/user-data-rights/README.md +21 -19
- package/samples/recipes/wizard-form/package.json +3 -3
- package/samples/recipes/writeform-section/package.json +3 -3
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createHmacTokenCodec,
|
|
5
|
+
type HmacTokenVerifyResult,
|
|
6
|
+
isUserTenantPayload,
|
|
7
|
+
} from "./hmac-token-codec.ts";
|
|
5
8
|
|
|
6
9
|
export type MfaChallengePayload = {
|
|
7
10
|
readonly userId: string;
|
|
8
11
|
readonly tenantId: TenantId;
|
|
9
12
|
};
|
|
10
13
|
|
|
11
|
-
function isPayload(value: unknown): value is MfaChallengePayload {
|
|
12
|
-
if (!isPlainObject(value)) return false;
|
|
13
|
-
return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
|
|
14
|
-
}
|
|
15
|
-
|
|
16
14
|
// Domain-separated from mfa-preauth-setup (see hmac-token-codec). Single-use is
|
|
17
15
|
// enforced by the consumer via burnToken (purpose "mfa-challenge"); this codec
|
|
18
16
|
// only signs and verifies. Brute-forcing the code behind a valid token is
|
|
19
17
|
// limited separately in mfa-verify-attempts.ts.
|
|
20
|
-
const codec = createHmacTokenCodec("mfa-challenge",
|
|
18
|
+
const codec = createHmacTokenCodec("mfa-challenge", isUserTenantPayload);
|
|
21
19
|
|
|
22
20
|
export function signMfaChallengeToken(
|
|
23
21
|
payload: MfaChallengePayload,
|
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createHmacTokenCodec,
|
|
5
|
+
type HmacTokenVerifyResult,
|
|
6
|
+
isUserTenantPayload,
|
|
7
|
+
} from "./hmac-token-codec.ts";
|
|
5
8
|
|
|
6
9
|
export type MfaPreauthSetupPayload = {
|
|
7
10
|
readonly userId: string;
|
|
8
11
|
readonly tenantId: TenantId;
|
|
9
12
|
};
|
|
10
13
|
|
|
11
|
-
function isPayload(value: unknown): value is MfaPreauthSetupPayload {
|
|
12
|
-
if (!isPlainObject(value)) return false;
|
|
13
|
-
return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
|
|
14
|
-
}
|
|
15
|
-
|
|
16
14
|
// Domain "mfa-preauth-setup" must never equal the challenge domain: a setup
|
|
17
15
|
// token has to fail verification as a challenge token and vice versa. The
|
|
18
16
|
// challenge token secret is reused deliberately; the domain carries the separation.
|
|
19
|
-
const codec = createHmacTokenCodec("mfa-preauth-setup",
|
|
17
|
+
const codec = createHmacTokenCodec("mfa-preauth-setup", isUserTenantPayload);
|
|
20
18
|
|
|
21
19
|
export function signMfaPreauthSetupToken(
|
|
22
20
|
payload: MfaPreauthSetupPayload,
|
|
@@ -6,12 +6,10 @@
|
|
|
6
6
|
// the same response — the token exists to avoid a second round-trip to
|
|
7
7
|
// regenerate it, not to hide it.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
// signed-token.ts (userId-only payload) because setup needs to carry
|
|
11
|
-
// generated secret material, not just re-derive it from a lookup.
|
|
12
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
9
|
+
// Wire format and signing live in hmac-token-codec.ts.
|
|
13
10
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
14
11
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
12
|
+
import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec.ts";
|
|
15
13
|
|
|
16
14
|
export type MfaSetupPayload = {
|
|
17
15
|
readonly userId: string;
|
|
@@ -24,73 +22,34 @@ export type MfaSetupPayload = {
|
|
|
24
22
|
readonly tenantId?: string;
|
|
25
23
|
};
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
function isMfaSetupPayload(value: unknown): value is MfaSetupPayload {
|
|
26
|
+
if (!isPlainObject(value)) return false;
|
|
27
|
+
return (
|
|
28
|
+
typeof value["userId"] === "string" &&
|
|
29
|
+
typeof value["totpSecretBase32"] === "string" &&
|
|
30
|
+
Array.isArray(value["recoveryCodeHashes"]) &&
|
|
31
|
+
value["recoveryCodeHashes"].every((h) => typeof h === "string") &&
|
|
32
|
+
(value["tenantId"] === undefined || typeof value["tenantId"] === "string")
|
|
33
|
+
);
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
const codec = createHmacTokenCodec("mfa-setup", isMfaSetupPayload);
|
|
37
|
+
|
|
33
38
|
export function signMfaSetupToken(
|
|
34
39
|
payload: MfaSetupPayload,
|
|
35
40
|
ttlMinutes: number,
|
|
36
41
|
secret: string,
|
|
37
42
|
now: Temporal.Instant = Temporal.Now.instant(),
|
|
38
43
|
): { token: string; expiresAt: Temporal.Instant } {
|
|
39
|
-
|
|
40
|
-
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
41
|
-
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
42
|
-
const signature = sign(`mfa-setup:${bodyB64}`, secret);
|
|
43
|
-
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
44
|
+
return codec.sign(payload, ttlMinutes, secret, now);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export type VerifyMfaSetupResult =
|
|
47
|
-
| { readonly ok: true; readonly payload: MfaSetupPayload; readonly expiresAtMs: number }
|
|
48
|
-
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
49
|
-
|
|
50
|
-
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
51
|
-
if (!isPlainObject(value)) return false;
|
|
52
|
-
return (
|
|
53
|
-
typeof value["userId"] === "string" &&
|
|
54
|
-
typeof value["totpSecretBase32"] === "string" &&
|
|
55
|
-
Array.isArray(value["recoveryCodeHashes"]) &&
|
|
56
|
-
value["recoveryCodeHashes"].every((h) => typeof h === "string") &&
|
|
57
|
-
typeof value["expiresAtMs"] === "number" &&
|
|
58
|
-
(value["tenantId"] === undefined || typeof value["tenantId"] === "string")
|
|
59
|
-
);
|
|
60
|
-
}
|
|
47
|
+
export type VerifyMfaSetupResult = HmacTokenVerifyResult<MfaSetupPayload>;
|
|
61
48
|
|
|
62
49
|
export function verifyMfaSetupToken(
|
|
63
50
|
token: string,
|
|
64
51
|
secret: string,
|
|
65
52
|
now: Temporal.Instant = Temporal.Now.instant(),
|
|
66
53
|
): VerifyMfaSetupResult {
|
|
67
|
-
|
|
68
|
-
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
69
|
-
const [bodyB64, providedSig] = parts;
|
|
70
|
-
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
71
|
-
|
|
72
|
-
const expected = sign(`mfa-setup:${bodyB64}`, secret);
|
|
73
|
-
const expectedBuf = Buffer.from(expected, "base64url");
|
|
74
|
-
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
75
|
-
// Length check before timingSafeEqual — a length mismatch throws inside
|
|
76
|
-
// timingSafeEqual, and that throw is itself a timing signal.
|
|
77
|
-
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
78
|
-
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
79
|
-
|
|
80
|
-
let parsed: unknown;
|
|
81
|
-
try {
|
|
82
|
-
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
83
|
-
} catch {
|
|
84
|
-
return { ok: false, reason: "malformed" };
|
|
85
|
-
}
|
|
86
|
-
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
90
|
-
) {
|
|
91
|
-
return { ok: false, reason: "expired" };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const { expiresAtMs, ...payload } = parsed;
|
|
95
|
-
return { ok: true, payload, expiresAtMs };
|
|
54
|
+
return codec.verify(token, secret, now);
|
|
96
55
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.405.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Provider helpers shared through billing-foundation",
|
|
6
|
+
"detail": "billing-foundation exports validateOneOffPriceIds, parseStoredSecret, createTtlPriceCache and hasSeenProviderEvent. subscription-stripe and subscription-paddle use them instead of their own copies, with the same error texts as before. The subscription projection reuses one apply body for created/updated and one for canceled/invoice-paid; the exported apply names stay."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.399.0",
|
|
4
10
|
"type": "improvement",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// The anonymous handler only routes to the declaration's tenant; the system
|
|
4
4
|
// handler checks the token and does the work.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { hashOpaqueTokenSync, opaqueTokenHashMatches } from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
7
|
import {
|
|
8
8
|
access,
|
|
9
9
|
createSystemUser,
|
|
@@ -45,17 +45,7 @@ export function createRevocationToken(): { readonly token: string; readonly toke
|
|
|
45
45
|
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))).toString(
|
|
46
46
|
"base64url",
|
|
47
47
|
);
|
|
48
|
-
return { token, tokenHash:
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function hashRevocationToken(token: string): string {
|
|
52
|
-
return createHash("sha256").update(token).digest("hex");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function tokenMatchesHash(token: string, expectedHash: string): boolean {
|
|
56
|
-
const actual = Buffer.from(hashRevocationToken(token));
|
|
57
|
-
const expected = Buffer.from(expectedHash);
|
|
58
|
-
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
48
|
+
return { token, tokenHash: hashOpaqueTokenSync(token) };
|
|
59
49
|
}
|
|
60
50
|
|
|
61
51
|
export function revocationUrl(args: {
|
|
@@ -154,7 +144,7 @@ export function createRecordContractTerminationRevocationHandler(
|
|
|
154
144
|
.find((candidate) => candidate?.requestId === payload.requestId);
|
|
155
145
|
if (
|
|
156
146
|
declared?.revocationTokenHash === undefined ||
|
|
157
|
-
!
|
|
147
|
+
!opaqueTokenHashMatches(payload.token, declared.revocationTokenHash)
|
|
158
148
|
) {
|
|
159
149
|
return outcome("invalid");
|
|
160
150
|
}
|
|
@@ -200,11 +200,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
|
|
|
200
200
|
});
|
|
201
201
|
|
|
202
202
|
// Plugin extension point. Provider plugins register here.
|
|
203
|
-
r.extendsRegistrar(SUBSCRIPTION_PROVIDER_EXTENSION, {
|
|
204
|
-
onRegister: () => {
|
|
205
|
-
// No side-effects at register-time.
|
|
206
|
-
},
|
|
207
|
-
});
|
|
203
|
+
r.extendsRegistrar(SUBSCRIPTION_PROVIDER_EXTENSION, {});
|
|
208
204
|
|
|
209
205
|
// Custom write handlers:
|
|
210
206
|
// - process-event: programmatic entry point called by the webhook
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type SubscriptionEventPayload,
|
|
34
34
|
} from "../events.ts";
|
|
35
35
|
import { getSubscriptionForTenant, type SubscriptionView } from "../get-subscription-for-tenant.ts";
|
|
36
|
+
import { hasSeenProviderEvent } from "../provider-event-seen.ts";
|
|
36
37
|
import {
|
|
37
38
|
BILLING_SUBSCRIPTION_LOCK_NAMESPACE,
|
|
38
39
|
isSubscriptionBlockingCheckout,
|
|
@@ -176,14 +177,7 @@ export async function appendSubscriptionEvent(
|
|
|
176
177
|
// idempotency anchor (like cap-counter).
|
|
177
178
|
// ---------------------------------------------------------------
|
|
178
179
|
const existingEvents = await ctx.events.load(aggId);
|
|
179
|
-
|
|
180
|
-
const headers = e.metadata.headers ?? {};
|
|
181
|
-
return (
|
|
182
|
-
headers["providerEventId"] === payload.providerEventId &&
|
|
183
|
-
headers["providerName"] === payload.providerName
|
|
184
|
-
);
|
|
185
|
-
});
|
|
186
|
-
if (alreadySeen) {
|
|
180
|
+
if (hasSeenProviderEvent(existingEvents, payload)) {
|
|
187
181
|
return { duplicate: true, subscriptionAggregateId: aggId };
|
|
188
182
|
}
|
|
189
183
|
|
package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type PaymentEventHeaders,
|
|
25
25
|
type PaymentEventPayload,
|
|
26
26
|
} from "../events.ts";
|
|
27
|
+
import { hasSeenProviderEvent } from "../provider-event-seen.ts";
|
|
27
28
|
|
|
28
29
|
export const processPaymentEventSchema = z.object({
|
|
29
30
|
providerEventId: z.string().min(1).max(200),
|
|
@@ -57,14 +58,7 @@ export const processPaymentEventHandler: WriteHandlerDef = {
|
|
|
57
58
|
// optimize via a per-tenant providerEventId-index if that bites).
|
|
58
59
|
// ---------------------------------------------------------------
|
|
59
60
|
const existingEvents = await ctx.events.load(aggId);
|
|
60
|
-
|
|
61
|
-
const headers = e.metadata.headers ?? {};
|
|
62
|
-
return (
|
|
63
|
-
headers["providerEventId"] === payload.providerEventId &&
|
|
64
|
-
headers["providerName"] === payload.providerName
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
if (alreadySeen) {
|
|
61
|
+
if (hasSeenProviderEvent(existingEvents, payload)) {
|
|
68
62
|
return {
|
|
69
63
|
isSuccess: true as const,
|
|
70
64
|
data: { duplicate: true as const, paymentAggregateId: aggId },
|
|
@@ -46,6 +46,14 @@ export {
|
|
|
46
46
|
export { createBillingFoundationFeature } from "./feature.ts";
|
|
47
47
|
export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant.ts";
|
|
48
48
|
export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection.ts";
|
|
49
|
+
export { hasSeenProviderEvent } from "./provider-event-seen.ts";
|
|
50
|
+
export {
|
|
51
|
+
createTtlPriceCache,
|
|
52
|
+
parseStoredSecret,
|
|
53
|
+
type TtlPriceCache,
|
|
54
|
+
type TtlPriceCacheOptions,
|
|
55
|
+
validateOneOffPriceIds,
|
|
56
|
+
} from "./provider-shared.ts";
|
|
49
57
|
export { billingPlansPanel, createBillingPlansScreen } from "./screens.ts";
|
|
50
58
|
export {
|
|
51
59
|
createSubscriptionTierSync,
|
|
@@ -130,43 +130,12 @@ async function upsert(
|
|
|
130
130
|
// materialize the row correctly.
|
|
131
131
|
// =============================================================================
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
* subscriptionAggregateId(tenantId), with one row per tenant. UPSERT allows
|
|
135
|
-
* the Disney+-change pattern (a second provider sends create for the same
|
|
136
|
-
* tenant) to overwrite the existing row instead of causing a PK conflict. */
|
|
137
|
-
export const applySubscriptionCreated = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
138
|
-
const full = fullSetFromPayload(event.payload);
|
|
139
|
-
await upsert(tx, event, { ...full, ...cancelAtSetFromPayload(event.payload) }, event.payload);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
/** subscription-updated → UPSERT with all fields. */
|
|
143
|
-
export const applySubscriptionUpdated = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
133
|
+
const applyFullSubscriptionState = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
144
134
|
const full = fullSetFromPayload(event.payload);
|
|
145
135
|
await upsert(tx, event, { ...full, ...cancelAtSetFromPayload(event.payload) }, event.payload);
|
|
146
136
|
});
|
|
147
137
|
|
|
148
|
-
|
|
149
|
-
export const applySubscriptionCanceled = defineApply<SubscriptionEventPayload>(
|
|
150
|
-
async (event, tx) => {
|
|
151
|
-
const p = event.payload;
|
|
152
|
-
await upsert(
|
|
153
|
-
tx,
|
|
154
|
-
event,
|
|
155
|
-
{
|
|
156
|
-
status: p.status,
|
|
157
|
-
tier: p.tier,
|
|
158
|
-
currentPeriodEnd: p.currentPeriodEndIso,
|
|
159
|
-
...cancelAtSetFromPayload(p),
|
|
160
|
-
},
|
|
161
|
-
p,
|
|
162
|
-
);
|
|
163
|
-
},
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
/** invoice-paid → state update (status, tier, currentPeriodEnd).
|
|
167
|
-
* The invoice history itself lives in the event store, so it remains
|
|
168
|
-
* replayable. */
|
|
169
|
-
export const applyInvoicePaid = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
138
|
+
const applyStatusTierPeriodPatch = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
170
139
|
const p = event.payload;
|
|
171
140
|
await upsert(
|
|
172
141
|
tx,
|
|
@@ -181,6 +150,23 @@ export const applyInvoicePaid = defineApply<SubscriptionEventPayload>(async (eve
|
|
|
181
150
|
);
|
|
182
151
|
});
|
|
183
152
|
|
|
153
|
+
/** subscription-created → UPSERT with all fields. PK = aggregateId =
|
|
154
|
+
* subscriptionAggregateId(tenantId), with one row per tenant. UPSERT allows
|
|
155
|
+
* the Disney+-change pattern (a second provider sends create for the same
|
|
156
|
+
* tenant) to overwrite the existing row instead of causing a PK conflict. */
|
|
157
|
+
export const applySubscriptionCreated = applyFullSubscriptionState;
|
|
158
|
+
|
|
159
|
+
/** subscription-updated → UPSERT with all fields. */
|
|
160
|
+
export const applySubscriptionUpdated = applyFullSubscriptionState;
|
|
161
|
+
|
|
162
|
+
/** subscription-canceled → status/tier/currentPeriodEnd patchen. */
|
|
163
|
+
export const applySubscriptionCanceled = applyStatusTierPeriodPatch;
|
|
164
|
+
|
|
165
|
+
/** invoice-paid → state update (status, tier, currentPeriodEnd).
|
|
166
|
+
* The invoice history itself lives in the event store, so it remains
|
|
167
|
+
* replayable. */
|
|
168
|
+
export const applyInvoicePaid = applyStatusTierPeriodPatch;
|
|
169
|
+
|
|
184
170
|
/** invoice-payment-failed → status (typically past_due) + tier. The tier
|
|
185
171
|
* engine reads the row and decides whether to downgrade. currentPeriodEnd is
|
|
186
172
|
* intentionally unchanged: the period has not "ended"; payment simply
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { StoredEvent } from "@cosmicdrift/kumiko-framework/event-store";
|
|
2
|
+
|
|
3
|
+
export function hasSeenProviderEvent(
|
|
4
|
+
existingEvents: readonly Pick<StoredEvent, "metadata">[],
|
|
5
|
+
providerEvent: { readonly providerEventId: string; readonly providerName: string },
|
|
6
|
+
): boolean {
|
|
7
|
+
return existingEvents.some((e) => {
|
|
8
|
+
const headers = e.metadata.headers ?? {};
|
|
9
|
+
return (
|
|
10
|
+
headers["providerEventId"] === providerEvent.providerEventId &&
|
|
11
|
+
headers["providerName"] === providerEvent.providerName
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
2
|
+
|
|
3
|
+
export function validateOneOffPriceIds(providerLabel: string, priceIds: readonly string[]): void {
|
|
4
|
+
const seen = new Set<string>();
|
|
5
|
+
for (const priceId of priceIds) {
|
|
6
|
+
if (priceId.length === 0) {
|
|
7
|
+
throw new Error(`${providerLabel}: oneOffPriceIds must not contain an empty string`);
|
|
8
|
+
}
|
|
9
|
+
if (seen.has(priceId)) {
|
|
10
|
+
throw new Error(`${providerLabel}: duplicate oneOffPriceIds entry "${priceId}"`);
|
|
11
|
+
}
|
|
12
|
+
seen.add(priceId);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// backing:"secrets" values are stored JSON-serialized, so the store read has to
|
|
17
|
+
// be parsed back; the env fallback is a plain string and stays raw. The error
|
|
18
|
+
// must not carry the input: the JSON parser's own message would repeat the
|
|
19
|
+
// credential into logs and, for webhooks, into the 401 body.
|
|
20
|
+
export function parseStoredSecret(
|
|
21
|
+
providerLabel: string,
|
|
22
|
+
raw: string | undefined,
|
|
23
|
+
): string | undefined {
|
|
24
|
+
if (raw === undefined || raw.length === 0) return undefined;
|
|
25
|
+
const parsed = parseJsonSafe<unknown>(raw, undefined);
|
|
26
|
+
if (parsed === undefined) {
|
|
27
|
+
throw new Error(`${providerLabel} credential (backing:secrets) is not valid JSON`);
|
|
28
|
+
}
|
|
29
|
+
return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TtlPriceCache<TPrice> = {
|
|
33
|
+
readonly get: (priceId: string) => TPrice | undefined;
|
|
34
|
+
readonly set: (priceId: string, price: TPrice) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type TtlPriceCacheOptions = {
|
|
38
|
+
readonly ttlMs?: number;
|
|
39
|
+
readonly now: () => number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function createTtlPriceCache<TPrice>({
|
|
43
|
+
ttlMs = 600_000,
|
|
44
|
+
now,
|
|
45
|
+
}: TtlPriceCacheOptions): TtlPriceCache<TPrice> {
|
|
46
|
+
const store = new Map<string, { readonly price: TPrice; readonly expiresAt: number }>();
|
|
47
|
+
return {
|
|
48
|
+
get: (priceId) => {
|
|
49
|
+
const entry = store.get(priceId);
|
|
50
|
+
if (!entry) return undefined;
|
|
51
|
+
if (entry.expiresAt <= now()) {
|
|
52
|
+
store.delete(priceId);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return entry.price;
|
|
56
|
+
},
|
|
57
|
+
set: (priceId, price) => {
|
|
58
|
+
store.set(priceId, { price, expiresAt: now() + ttlMs });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
3
|
import { resolveProfileForTenant } from "../compliance-profiles/index.ts";
|
|
4
|
+
import {
|
|
5
|
+
TENANT_DESTROY_ARCHIVE_REASON,
|
|
6
|
+
TENANT_DESTROY_ARCHIVED_BY,
|
|
7
|
+
} from "../tenant-lifecycle/index.ts";
|
|
4
8
|
import { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.ts";
|
|
5
9
|
import { PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE } from "./events.ts";
|
|
6
10
|
import { paymentsProjectionMeta, subscriptionsProjectionMeta } from "./projection.ts";
|
|
@@ -42,8 +46,8 @@ export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Pro
|
|
|
42
46
|
tenantId: ctx.tenantId,
|
|
43
47
|
aggregateId,
|
|
44
48
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
45
|
-
archivedBy:
|
|
46
|
-
reason:
|
|
49
|
+
archivedBy: TENANT_DESTROY_ARCHIVED_BY,
|
|
50
|
+
reason: TENANT_DESTROY_ARCHIVE_REASON,
|
|
47
51
|
});
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -74,7 +78,7 @@ export async function paymentTenantDestroyHook(ctx: TenantDataHookCtx): Promise<
|
|
|
74
78
|
tenantId: ctx.tenantId,
|
|
75
79
|
aggregateId,
|
|
76
80
|
aggregateType: PAYMENT_AGGREGATE_TYPE,
|
|
77
|
-
archivedBy:
|
|
78
|
-
reason:
|
|
81
|
+
archivedBy: TENANT_DESTROY_ARCHIVED_BY,
|
|
82
|
+
reason: TENANT_DESTROY_ARCHIVE_REASON,
|
|
79
83
|
});
|
|
80
84
|
}
|
|
@@ -25,7 +25,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions) {
|
|
|
25
25
|
r.envSchema(smtpEnvSchema);
|
|
26
26
|
|
|
27
27
|
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
28
|
-
r.extendsRegistrar(EMAIL_APP_TRANSPORT_EXTENSION, {
|
|
28
|
+
r.extendsRegistrar(EMAIL_APP_TRANSPORT_EXTENSION, {});
|
|
29
29
|
r.useExtension(EMAIL_APP_TRANSPORT_EXTENSION, EMAIL_APP_TRANSPORT_USAGE, {
|
|
30
30
|
transport: appTransport,
|
|
31
31
|
});
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
createSystemDbView,
|
|
29
29
|
createTenantDb,
|
|
30
30
|
type DbConnection,
|
|
31
|
-
type TenantDb,
|
|
32
31
|
type UncheckedSystemDb,
|
|
33
32
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
34
33
|
import {
|
|
@@ -43,6 +42,7 @@ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
43
42
|
import {
|
|
44
43
|
type ChunkedMigrationStopReason,
|
|
45
44
|
classifyStoredEnvelope,
|
|
45
|
+
createTenantDbCache,
|
|
46
46
|
runChunkedMigration,
|
|
47
47
|
} from "../../shared/index.ts";
|
|
48
48
|
import { configValueEntity, configValuesTable } from "../table.ts";
|
|
@@ -121,20 +121,14 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
reason: "KEK rotation job re-encrypts config values of every tenant",
|
|
133
|
-
});
|
|
134
|
-
tdbCache.set(tenantId, existing);
|
|
135
|
-
}
|
|
136
|
-
return existing;
|
|
137
|
-
}
|
|
124
|
+
const tdbFor = createTenantDbCache((tenantId) =>
|
|
125
|
+
createTenantDb({
|
|
126
|
+
db,
|
|
127
|
+
tenantId,
|
|
128
|
+
mode: "system",
|
|
129
|
+
reason: "KEK rotation job re-encrypts config values of every tenant",
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
138
132
|
|
|
139
133
|
const sdbCache = new Map<TenantId, UncheckedSystemDb>();
|
|
140
134
|
function sdbFor(tenantId: TenantId): UncheckedSystemDb {
|
|
@@ -33,7 +33,7 @@ import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framewo
|
|
|
33
33
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
34
34
|
import type { Redis } from "ioredis";
|
|
35
35
|
import { fileContextFromResolver } from "../file-foundation/index.ts";
|
|
36
|
-
import { redactErrorText } from "../shared/index.ts";
|
|
36
|
+
import { collectExtensionOptions, redactErrorText } from "../shared/index.ts";
|
|
37
37
|
import { hashUnsubscribeAddress } from "./address-opt-out.ts";
|
|
38
38
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.ts";
|
|
39
39
|
import { buildChannelContext } from "./channel-context.ts";
|
|
@@ -120,15 +120,7 @@ export type DeliveryServiceOptions = {
|
|
|
120
120
|
|
|
121
121
|
// Build channel list from registry extension usages
|
|
122
122
|
export function collectChannels(registry: Registry): DeliveryChannel[] {
|
|
123
|
-
|
|
124
|
-
return usages.map((usage) => {
|
|
125
|
-
if (!isDeliveryChannelPlugin(usage.options)) {
|
|
126
|
-
throw new Error(
|
|
127
|
-
`${DELIVERY_CHANNEL_EXTENSION} registration for "${usage.entityName}" has invalid options`,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
return { name: usage.entityName, ...usage.options };
|
|
131
|
-
});
|
|
123
|
+
return collectExtensionOptions(registry, DELIVERY_CHANNEL_EXTENSION, isDeliveryChannelPlugin);
|
|
132
124
|
}
|
|
133
125
|
|
|
134
126
|
export function createDeliveryService(options: DeliveryServiceOptions): DeliveryService {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.404.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Documents recipe has a README and gen-sample-index rejects a primarySample without one",
|
|
6
|
+
"detail": "feature-reference linked to /en/samples/recipes-documents/, a page the docs site never generated because the recipe had no README.md. The README is added, and gen-sample-index aborts with the feature, the sample and the fix when a primarySample from sample-index.overrides.json has no README.md."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -120,27 +120,15 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
120
120
|
// time variant() runs.
|
|
121
121
|
r.requires({ features: ["files"] });
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// No side-effects at register-time — resolution happens at
|
|
133
|
-
// request-time in publicVariantQuery, keyed by the FileRef's
|
|
134
|
-
// entityType.
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
r.extendsRegistrar(EXT_DERIVATIVE_OVERLAY_RESOLVER, {
|
|
138
|
-
onRegister: () => {
|
|
139
|
-
// No side-effects at register-time — resolution happens in
|
|
140
|
-
// variant(), keyed by the FileRef's entityType, before the spec
|
|
141
|
-
// hash is computed.
|
|
142
|
-
},
|
|
143
|
-
});
|
|
123
|
+
// Resolution (exact MIME match, then `<type>/*` wildcard) happens at request-time in
|
|
124
|
+
// resolveRenderer, mirrors file-foundation's fileProvider point.
|
|
125
|
+
r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {});
|
|
126
|
+
// Resolution happens at request-time in publicVariantQuery, keyed by the FileRef's
|
|
127
|
+
// entityType.
|
|
128
|
+
r.extendsRegistrar(EXT_DERIVATIVE_PUBLIC_PREDICATE, {});
|
|
129
|
+
// Resolution happens in variant(), keyed by the FileRef's entityType, before the spec
|
|
130
|
+
// hash is computed.
|
|
131
|
+
r.extendsRegistrar(EXT_DERIVATIVE_OVERLAY_RESOLVER, {});
|
|
144
132
|
|
|
145
133
|
// Registered ONLY when resolveApexTenant is supplied — r.queryHandler
|
|
146
134
|
// registers publicVariantQuery into the feature's dispatch table as a
|
|
@@ -54,12 +54,8 @@ export function createFileFoundationFeature() {
|
|
|
54
54
|
});
|
|
55
55
|
r.requires({ features: ["config"] });
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// No side-effects at register-time — registry stores the usage,
|
|
60
|
-
// factory looks it up at request-time.
|
|
61
|
-
},
|
|
62
|
-
});
|
|
57
|
+
// Registry stores the usage, factory looks it up at request-time.
|
|
58
|
+
r.extendsRegistrar(EXT_FILE_PROVIDER, {});
|
|
63
59
|
|
|
64
60
|
const providerConfigKey = r.config(
|
|
65
61
|
"provider",
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
storageKeyStemPrefix,
|
|
63
63
|
tenantStoragePrefixes,
|
|
64
64
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
65
|
+
import { chunk } from "@cosmicdrift/kumiko-framework/utils";
|
|
65
66
|
import { type ChunkedMigrationResult, runChunkedMigration } from "../shared/index.ts";
|
|
66
67
|
|
|
67
68
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
@@ -73,12 +74,6 @@ const SURVIVOR_CHECK_BATCH_SIZE = 500;
|
|
|
73
74
|
|
|
74
75
|
const DESTROY_CHUNK_SIZE = 500;
|
|
75
76
|
|
|
76
|
-
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
77
|
-
const chunks: T[][] = [];
|
|
78
|
-
for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
|
|
79
|
-
return chunks;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
77
|
function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): boolean {
|
|
83
78
|
return prefixes.some((prefix) => storageKey.startsWith(prefix));
|
|
84
79
|
}
|