@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.335.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/admin-shell/overview-panels.js +2 -2
- package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
- package/dist/agent-tools/agent-doc-lint.js +33 -3
- package/dist/agent-tools/agent-manifest.js +47 -6
- package/dist/agent-tools/filter-ops.d.ts +2 -0
- package/dist/agent-tools/filter-ops.js +4 -0
- package/dist/agent-tools/tool-catalog.js +1 -4
- package/dist/agent-tools/tool-dispatch.js +1 -4
- package/dist/audit/constants.d.ts +1 -1
- package/dist/audit/constants.js +1 -1
- package/dist/auth-email-password/handlers/change-password.write.js +2 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
- package/dist/auth-email-password/handlers/login.write.js +2 -0
- package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
- package/dist/auth-email-password/i18n.js +2 -0
- package/dist/auth-email-password/web/auth-client.d.ts +1 -0
- package/dist/auth-email-password/web/auth-client.js +1 -1
- package/dist/auth-email-password/web/auth-gate.js +6 -8
- package/dist/auth-email-password/web/index.d.ts +1 -1
- package/dist/auth-email-password/web/index.js +1 -1
- package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
- package/dist/auth-email-password/web/login-screen.js +1 -1
- package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
- package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
- package/dist/auth-email-password/web/session.d.ts +4 -0
- package/dist/auth-email-password/web/session.js +15 -0
- package/dist/auth-mfa/feature.js +3 -2
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
- package/dist/auth-mfa/handlers/verify.write.js +2 -0
- package/dist/auth-mfa/hmac-token-codec.js +3 -0
- package/dist/auth-mfa/mfa-challenge-token.js +4 -0
- package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
- package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-client.js +4 -10
- package/dist/billing-foundation/billing-info-query.js +1 -1
- package/dist/billing-foundation/checkout-core.d.ts +5 -5
- package/dist/billing-foundation/checkout-core.js +10 -6
- package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
- package/dist/cap-counter/book-cap-usage.js +15 -5
- package/dist/cap-counter/enforce-cap.js +9 -3
- package/dist/cap-counter/feature.js +3 -2
- package/dist/cap-counter/i18n.d.ts +1 -0
- package/dist/cap-counter/i18n.js +6 -5
- package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
- package/dist/cap-overview/i18n.js +5 -0
- package/dist/cap-overview/web/cap-cards-panel.js +4 -1
- package/dist/cap-overview/web/cap-usage-bar.js +1 -1
- package/dist/channel-email/email-channel.js +12 -1
- package/dist/channel-in-app/tables.js +2 -6
- package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
- package/dist/config/db/queries/resolver.js +3 -1
- package/dist/config/handlers/set.write.js +8 -4
- package/dist/config/web/i18n.js +1 -0
- package/dist/config/write-helpers.d.ts +1 -1
- package/dist/config/write-helpers.js +11 -5
- package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
- package/dist/custom-fields/constants.js +1 -4
- package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
- package/dist/data-retention/run-retention-cleanup.js +100 -44
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/types.js +7 -0
- package/dist/delivery/unsubscribe.js +5 -11
- package/dist/derivatives-sharp/render.js +27 -9
- package/dist/document-ingest-foundation/feature.js +5 -5
- package/dist/document-ingest-foundation/providers.d.ts +1 -0
- package/dist/document-ingest-foundation/providers.js +6 -0
- package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
- package/dist/document-ingest-foundation/write-document-extract.js +10 -0
- package/dist/feature-toggles/global-feature-state-table.js +2 -7
- package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
- package/dist/feature-toggles/toggle-sync-signal.js +3 -2
- package/dist/files-tenant-data/index.js +3 -2
- package/dist/folders/handlers/clear-folder.write.js +4 -11
- package/dist/folders/handlers/set-folder.write.js +4 -10
- package/dist/form-draft/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +7 -8
- package/dist/form-draft/handlers/get.query.js +7 -1
- package/dist/foundation-shared/host-allowlist.d.ts +4 -0
- package/dist/foundation-shared/host-allowlist.js +14 -0
- package/dist/foundation-shared/index.d.ts +1 -0
- package/dist/foundation-shared/index.js +1 -0
- package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
- package/dist/foundation-shared/mail-host-policy.js +3 -13
- package/dist/inbound-mail-foundation/connect-routes.js +7 -5
- package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
- package/dist/jobs/handlers/list.query.d.ts +7 -2
- package/dist/jobs/handlers/list.query.js +9 -2
- package/dist/jobs/handlers/retry.write.js +3 -2
- package/dist/jobs/handlers/tenant-failures.query.js +3 -9
- package/dist/jobs/job-run-logger.js +18 -2
- package/dist/jobs/tenant-job-failure-table.js +2 -6
- package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
- package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
- package/dist/ledger/subject-reference.d.ts +4 -0
- package/dist/ledger/subject-reference.js +7 -0
- package/dist/legal-pages/feature.js +8 -1
- package/dist/managed-pages/handlers/branding.query.js +1 -1
- package/dist/managed-pages/handlers/by-slug.query.js +1 -1
- package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
- package/dist/metrics/compute.js +7 -1
- package/dist/metrics/window.js +5 -0
- package/dist/notes-history/handlers/add-note.write.js +4 -9
- package/dist/notes-history-user-data/hooks.js +8 -1
- package/dist/personal-access-tokens/constants.d.ts +1 -0
- package/dist/personal-access-tokens/constants.js +1 -0
- package/dist/personal-access-tokens/feature.js +2 -2
- package/dist/personal-access-tokens/handlers/create.write.js +2 -2
- package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
- package/dist/personal-access-tokens/i18n.js +12 -13
- package/dist/personal-access-tokens/schema/api-token.js +2 -1
- package/dist/personal-access-tokens/screens.js +5 -1
- package/dist/rate-limiting/feature.js +2 -0
- package/dist/rate-limiting/handlers/bucket-access.js +15 -7
- package/dist/rate-limiting/i18n.d.ts +10 -0
- package/dist/rate-limiting/i18n.js +10 -0
- package/dist/secrets/feature.js +3 -2
- package/dist/seo/handlers/seo-config.query.js +1 -1
- package/dist/sessions/constants.d.ts +1 -0
- package/dist/sessions/constants.js +1 -0
- package/dist/sessions/feature.js +5 -3
- package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
- package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
- package/dist/sessions/handlers/revoke.write.js +2 -2
- package/dist/sessions/schema/user-session.js +2 -1
- package/dist/sessions/screens.js +5 -1
- package/dist/sessions/session-callbacks.js +2 -2
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/lockout-counter.js +1 -1
- package/dist/shared/parent-visibility.d.ts +5 -0
- package/dist/shared/parent-visibility.js +17 -1
- package/dist/shared/row-bound-grant.js +20 -9
- package/dist/shared/single-use-token-store.js +5 -13
- package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
- package/dist/step-dispatcher/webhook-runner.js +18 -17
- package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
- package/dist/subscription-mollie/plugin-methods.js +7 -1
- package/dist/subscription-mollie/verify-webhook.js +6 -0
- package/dist/subscription-stripe/plugin-methods.js +25 -6
- package/dist/subscription-stripe/verify-webhook.js +13 -6
- package/dist/tags/handlers/assign-tag.write.js +4 -10
- package/dist/tags/handlers/remove-tag.write.js +4 -11
- package/dist/template-resolver/api.d.ts +2 -1
- package/dist/template-resolver/api.js +1 -1
- package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
- package/dist/template-resolver/handlers/by-slug.query.js +2 -2
- package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
- package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
- package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
- package/dist/template-resolver/handlers/collection-shared.js +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
- package/dist/template-resolver/handlers/list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/list.query.js +1 -1
- package/dist/template-resolver/seeding.js +41 -39
- package/dist/template-resolver/table.d.ts +8 -5
- package/dist/template-resolver/testing.js +2 -2
- package/dist/template-resolver/user-content-table.d.ts +2 -5
- package/dist/template-resolver/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver-user-data/hooks.js +1 -1
- package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
- package/dist/tenant/cancel-pending-invitation.js +22 -0
- package/dist/tenant/feature.js +4 -2
- package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +17 -4
- package/dist/tenant/handlers/remove-member.write.js +9 -14
- package/dist/tenant/handlers/team-list.query.js +2 -2
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +13 -3
- package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
- package/dist/tenant/handlers/update-member-roles.write.js +28 -12
- package/dist/tenant/i18n.js +4 -0
- package/dist/tenant/index.d.ts +1 -1
- package/dist/tenant/index.js +1 -1
- package/dist/tenant/last-tenant-admin.d.ts +1 -1
- package/dist/tenant/last-tenant-admin.js +5 -3
- package/dist/tenant/seeding.d.ts +4 -0
- package/dist/tenant/seeding.js +9 -2
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +27 -1
- package/dist/tenant-lifecycle/feature.js +2 -1
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/tier-engine/feature.js +3 -2
- package/dist/user/command-schemas.d.ts +2 -2
- package/dist/user/handlers/create.write.d.ts +1 -1
- package/dist/user/handlers/create.write.js +8 -2
- package/dist/user/handlers/update.write.d.ts +1 -1
- package/dist/user/handlers/update.write.js +8 -2
- package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
- package/dist/user-data-rights/feature.js +22 -9
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
- package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
- package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
- package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
- package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
- package/dist/user-data-rights/run-export-jobs.js +74 -41
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/user-data-rights/run-user-export.js +2 -2
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
- package/dist/user-profile/feature.js +6 -1
- package/dist/user-profile/handlers/change-email.write.js +2 -0
- package/dist/workflow-runner/db/queries/due-runs.js +6 -9
- package/dist/workflow-runner/event-subscriber.js +29 -9
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
- package/dist/workflow-runner/pending-projection.js +8 -1
- package/package.json +11 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/data-retention/changes.json +12 -0
- package/src/delivery/changes.json +7 -0
- package/src/derivatives-sharp/changes.json +11 -0
- package/src/document-ingest-foundation/changes.json +12 -0
- package/src/inbound-mail-foundation/changes.json +12 -0
- package/src/jobs/changes.json +13 -0
- package/src/legal-pages/changes.json +8 -1
- package/src/managed-pages/changes.json +5 -0
- package/src/metrics/changes.json +6 -0
- package/src/rate-limiting/changes.json +5 -0
- package/src/secrets/changes.json +7 -0
- package/src/sessions/changes.json +5 -0
- package/src/shared/changes.json +6 -0
- package/src/step-dispatcher/changes.json +5 -0
- package/src/subscription-mollie/changes.json +7 -0
- package/src/subscription-stripe/changes.json +11 -0
- package/src/template-resolver/changes.json +7 -0
- package/src/tenant/changes.json +29 -0
- package/src/user-data-rights/changes.json +22 -0
- package/src/workflow-runner/changes.json +6 -0
|
@@ -3,6 +3,9 @@ import { Temporal } from "temporal-polyfill";
|
|
|
3
3
|
function hmacSign(input, secret) {
|
|
4
4
|
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
5
5
|
}
|
|
6
|
+
// `domain` is part of the signed input: it is the only thing that stops a token
|
|
7
|
+
// minted for one MFA step from verifying as another (e.g. a preauth-setup token
|
|
8
|
+
// replayed as a challenge token), since both share the same secret and payload shape.
|
|
6
9
|
export function createHmacTokenCodec(domain, isPayload) {
|
|
7
10
|
function isEncodedBody(value) {
|
|
8
11
|
if (typeof value !== "object" || value === null)
|
|
@@ -6,6 +6,10 @@ function isPayload(value) {
|
|
|
6
6
|
const v = value;
|
|
7
7
|
return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
|
|
8
8
|
}
|
|
9
|
+
// Domain-separated from mfa-preauth-setup (see hmac-token-codec). Single-use is
|
|
10
|
+
// enforced by the consumer via burnToken (purpose "mfa-challenge"); this codec
|
|
11
|
+
// only signs and verifies. Brute-forcing the code behind a valid token is
|
|
12
|
+
// limited separately in mfa-verify-attempts.ts.
|
|
9
13
|
const codec = createHmacTokenCodec("mfa-challenge", isPayload);
|
|
10
14
|
export function signMfaChallengeToken(payload, ttlMinutes, secret, now = Temporal.Now.instant()) {
|
|
11
15
|
return codec.sign(payload, ttlMinutes, secret, now);
|
|
@@ -6,6 +6,9 @@ function isPayload(value) {
|
|
|
6
6
|
const v = value;
|
|
7
7
|
return typeof v["userId"] === "string" && typeof v["tenantId"] === "string";
|
|
8
8
|
}
|
|
9
|
+
// Domain "mfa-preauth-setup" must never equal the challenge domain: a setup
|
|
10
|
+
// token has to fail verification as a challenge token and vice versa. The
|
|
11
|
+
// challenge token secret is reused deliberately; the domain carries the separation.
|
|
9
12
|
const codec = createHmacTokenCodec("mfa-preauth-setup", isPayload);
|
|
10
13
|
export function signMfaPreauthSetupToken(payload, ttlMinutes, secret, now = Temporal.Now.instant()) {
|
|
11
14
|
return codec.sign(payload, ttlMinutes, secret, now);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type LoginFailure, type LoginResponse } from "../../auth-email-password/web/index.js";
|
|
2
2
|
export type MfaVerifyResult = {
|
|
3
3
|
readonly kind: "success";
|
|
4
4
|
readonly data: LoginResponse;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// byte-identical to /auth/login's ({isSuccess, token, user} | {isSuccess:
|
|
7
7
|
// false, error}) since auth-routes.ts mints via the same
|
|
8
8
|
// mintSessionAndRespond() for both routes.
|
|
9
|
+
// kumiko-lint-ignore cross-feature-import client-only types, the feature's server barrel has no web/ re-export
|
|
10
|
+
import { toLoginResponse, } from "../../auth-email-password/web/index.js";
|
|
9
11
|
export async function verifyMfaChallenge(challengeToken, code) {
|
|
10
12
|
const res = await fetch("/api/auth/mfa/verify", {
|
|
11
13
|
method: "POST",
|
|
@@ -21,11 +23,7 @@ export async function verifyMfaChallenge(challengeToken, code) {
|
|
|
21
23
|
if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
|
|
22
24
|
return {
|
|
23
25
|
kind: "success",
|
|
24
|
-
data:
|
|
25
|
-
token: body.token,
|
|
26
|
-
user: body.user,
|
|
27
|
-
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
28
|
-
},
|
|
26
|
+
data: toLoginResponse(body.token, body.user, body.landingPath),
|
|
29
27
|
};
|
|
30
28
|
}
|
|
31
29
|
const err = body.error;
|
|
@@ -109,11 +107,7 @@ export async function confirmMfaSetupPreauth(setupToken, code) {
|
|
|
109
107
|
if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
|
|
110
108
|
return {
|
|
111
109
|
kind: "success",
|
|
112
|
-
data:
|
|
113
|
-
token: body.token,
|
|
114
|
-
user: body.user,
|
|
115
|
-
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
116
|
-
},
|
|
110
|
+
data: toLoginResponse(body.token, body.user, body.landingPath),
|
|
117
111
|
};
|
|
118
112
|
}
|
|
119
113
|
const err = body.error;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generic billing-info query-handler-config factory. Extracted from the
|
|
2
2
|
// near-identical billing-info.query.ts app copies in show-pony and
|
|
3
|
-
// publicstatus
|
|
3
|
+
// publicstatus — the only per-app variables were the
|
|
4
4
|
// TTier union/resolver, the allowed roles, and how Stripe prices are read
|
|
5
5
|
// off the app's extraContext.
|
|
6
6
|
//
|
|
@@ -19,11 +19,11 @@ export declare function findProviderPlugin(ctx: HandlerContext, providerName: st
|
|
|
19
19
|
export declare function resolveProviderPlugin(ctx: HandlerContext, providerName: string): ResolvedProvider;
|
|
20
20
|
/** Picks the catalog's provider — an explicit `catalog.providerName`, or the
|
|
21
21
|
* single registered provider exposing `priceToTier` when there is exactly
|
|
22
|
-
* one. Returns null when
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* one. Returns null when `providerName` is unset and no registered plugin
|
|
23
|
+
* exposes `priceToTier` — a state `billing-plans` renders as
|
|
24
|
+
* `enabled: false` instead of erroring. An explicit `providerName` that is
|
|
25
|
+
* not registered, or more than one candidate, is a config bug and throws
|
|
26
|
+
* `UnconfiguredError`. */
|
|
27
27
|
export declare function findCatalogProvider(ctx: HandlerContext, catalog: BillingPlanCatalog): ResolvedProvider | null;
|
|
28
28
|
/** `findCatalogProvider` for callers that require a provider to proceed —
|
|
29
29
|
* start-plan-checkout and switch-plan. `FeatureDisabledError` (not
|
|
@@ -37,16 +37,20 @@ export function resolveProviderPlugin(ctx, providerName) {
|
|
|
37
37
|
}
|
|
38
38
|
/** Picks the catalog's provider — an explicit `catalog.providerName`, or the
|
|
39
39
|
* single registered provider exposing `priceToTier` when there is exactly
|
|
40
|
-
* one. Returns null when
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* one. Returns null when `providerName` is unset and no registered plugin
|
|
41
|
+
* exposes `priceToTier` — a state `billing-plans` renders as
|
|
42
|
+
* `enabled: false` instead of erroring. An explicit `providerName` that is
|
|
43
|
+
* not registered, or more than one candidate, is a config bug and throws
|
|
44
|
+
* `UnconfiguredError`. */
|
|
45
45
|
export function findCatalogProvider(ctx, catalog) {
|
|
46
46
|
if (catalog.providerName) {
|
|
47
47
|
const found = findProviderPlugin(ctx, catalog.providerName);
|
|
48
48
|
if (!found) {
|
|
49
|
-
|
|
49
|
+
throw new UnconfiguredError({
|
|
50
|
+
feature: "billing-foundation",
|
|
51
|
+
key: "catalog.providerName",
|
|
52
|
+
hint: `provider "${catalog.providerName}" not registered`,
|
|
53
|
+
});
|
|
50
54
|
}
|
|
51
55
|
return found;
|
|
52
56
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// gets a `catalog`.
|
|
5
5
|
import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import * as z from "zod";
|
|
7
|
-
import { assertBillingEnabled, assertRedirectOrigins, isNonEmptyStringArray, joinBaseUrl, resolveCatalogProvider,
|
|
8
|
-
import { isSwitchableSubscriptionStatus
|
|
7
|
+
import { assertBillingEnabled, assertRedirectOrigins, isNonEmptyStringArray, joinBaseUrl, resolveCatalogProvider, } from "../checkout-core.js";
|
|
8
|
+
import { isSwitchableSubscriptionStatus } from "../constants.js";
|
|
9
9
|
import { getSubscriptionForTenant } from "../get-subscription-for-tenant.js";
|
|
10
10
|
import { purchaseRolesOf, resolvePlanPrices } from "../plan-catalog.js";
|
|
11
11
|
export function createSwitchPlanHandler(options, catalog) {
|
|
@@ -25,9 +25,7 @@ export function createSwitchPlanHandler(options, catalog) {
|
|
|
25
25
|
const { name: catalogProviderName, plugin } = resolveCatalogProvider(ctx, catalog, "switch-plan");
|
|
26
26
|
await assertBillingEnabled(ctx, plugin, "switch-plan");
|
|
27
27
|
const sub = await getSubscriptionForTenant(ctx, event.user.tenantId);
|
|
28
|
-
if (!sub ||
|
|
29
|
-
isTerminalSubscriptionStatus(sub.status) ||
|
|
30
|
-
!isSwitchableSubscriptionStatus(sub.status)) {
|
|
28
|
+
if (!sub || !isSwitchableSubscriptionStatus(sub.status)) {
|
|
31
29
|
throw new ConflictError({
|
|
32
30
|
i18nKey: "billing-foundation.errors.noActiveSubscription",
|
|
33
31
|
message: "tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
|
|
@@ -47,14 +45,13 @@ export function createSwitchPlanHandler(options, catalog) {
|
|
|
47
45
|
}
|
|
48
46
|
// providerSubscriptionId only exists at the subscription's own provider; after a
|
|
49
47
|
// provider migration the catalog provider can differ, which is not a plan switch.
|
|
50
|
-
const { plugin: switchPlugin } = resolveProviderPlugin(ctx, sub.providerName);
|
|
51
48
|
if (sub.providerName !== catalogProviderName) {
|
|
52
49
|
throw new ConflictError({
|
|
53
50
|
i18nKey: "billing-foundation.errors.providerMismatch",
|
|
54
51
|
message: `tenant's active subscription is on provider "${sub.providerName}", the catalog resolves provider "${catalogProviderName}" — switch-plan cannot switch across providers`,
|
|
55
52
|
});
|
|
56
53
|
}
|
|
57
|
-
if (!
|
|
54
|
+
if (!plugin.createPlanSwitchSession) {
|
|
58
55
|
throw new UnprocessableError("plan_switch_not_supported", {
|
|
59
56
|
i18nKey: "billing-foundation.errors.planSwitchNotSupported",
|
|
60
57
|
message: `subscription-foundation: provider "${sub.providerName}" has no createPlanSwitchSession-method`,
|
|
@@ -74,7 +71,7 @@ export function createSwitchPlanHandler(options, catalog) {
|
|
|
74
71
|
const baseUrl = options.baseUrl ?? "";
|
|
75
72
|
const returnUrl = joinBaseUrl(baseUrl, catalog.returnPath ?? catalog.successPath);
|
|
76
73
|
assertRedirectOrigins([returnUrl], options.baseUrl);
|
|
77
|
-
const result = await
|
|
74
|
+
const result = await plugin.createPlanSwitchSession(ctx, {
|
|
78
75
|
providerSubscriptionId: sub.providerSubscriptionId,
|
|
79
76
|
targetPriceId: target.priceId,
|
|
80
77
|
allowedPriceIds,
|
|
@@ -8,19 +8,28 @@ import { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js"
|
|
|
8
8
|
import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "./constants.js";
|
|
9
9
|
import { capCounterEntity } from "./entity.js";
|
|
10
10
|
const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
11
|
-
|
|
11
|
+
// periodStartIso feeds the aggregate id and Temporal.Instant.from(); a non-instant string would
|
|
12
|
+
// silently fork a counter row on update and throw a RangeError (500) on create.
|
|
13
|
+
const capPeriodSchema = z.object({
|
|
12
14
|
capName: z.string().min(1).max(100),
|
|
13
|
-
periodStartIso: z.
|
|
15
|
+
periodStartIso: z.iso.datetime({ offset: true }),
|
|
16
|
+
});
|
|
17
|
+
const capBookingSchema = capPeriodSchema.extend({
|
|
14
18
|
amount: z.number().int().positive().default(1),
|
|
15
19
|
});
|
|
16
20
|
function isLostCounterRace(result) {
|
|
17
21
|
return !result.isSuccess && result.error.code === "version_conflict";
|
|
18
22
|
}
|
|
19
|
-
// Each attempt writes event + projection atomically, so a conflict means another booker just committed
|
|
23
|
+
// Each attempt writes event + projection atomically, so a conflict means another booker just committed. Contention is bounded by the bookers running at once across ALL replicas on the same DB (not per pool), so the jittered backoff spreads out losers instead of letting them collide again in lockstep; the ceiling only guards against non-convergence.
|
|
20
24
|
const MAX_COUNTER_WRITE_ATTEMPTS = 20;
|
|
25
|
+
const BACKOFF_JITTER_MS_PER_ATTEMPT = 5;
|
|
26
|
+
function sleepMs(ms) {
|
|
27
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
}
|
|
21
29
|
async function retryCounterWriteOnVersionConflict(writeAttempt) {
|
|
22
30
|
let result = await writeAttempt();
|
|
23
31
|
for (let attempt = 1; attempt < MAX_COUNTER_WRITE_ATTEMPTS && isLostCounterRace(result); attempt++) {
|
|
32
|
+
await sleepMs(Math.random() * attempt * BACKOFF_JITTER_MS_PER_ATTEMPT);
|
|
24
33
|
result = await writeAttempt();
|
|
25
34
|
}
|
|
26
35
|
return result;
|
|
@@ -64,11 +73,12 @@ export async function bookCapUsage(ctx, options) {
|
|
|
64
73
|
return retryCounterWriteOnVersionConflict(() => attemptWrite(ctx.db));
|
|
65
74
|
}
|
|
66
75
|
export async function markCapSoftWarned(ctx, options) {
|
|
67
|
-
const
|
|
76
|
+
const parsed = capPeriodSchema.parse(options);
|
|
77
|
+
const aggregateId = capCounterAggregateId(ctx.user.tenantId, parsed.capName, parsed.periodStartIso);
|
|
68
78
|
return retryCounterWriteOnVersionConflict(async () => {
|
|
69
79
|
const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
70
80
|
if (existing.length === 0) {
|
|
71
|
-
throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${
|
|
81
|
+
throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
|
|
72
82
|
}
|
|
73
83
|
const row = existing[0];
|
|
74
84
|
if (!row) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
2
|
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
|
|
5
5
|
import { capCounterEntity } from "./entity.js";
|
|
6
6
|
// Temporal globally provided by the framework's polyfill init
|
|
@@ -48,6 +48,10 @@ export async function enforceCap(ctx, options) {
|
|
|
48
48
|
if (!ctx.db) {
|
|
49
49
|
throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
|
|
50
50
|
}
|
|
51
|
+
const amount = options.amount ?? 1;
|
|
52
|
+
if (!Number.isInteger(amount) || amount < 1) {
|
|
53
|
+
throw new Error(`cap-counter.enforceCap: amount must be a positive integer, got ${options.amount}`);
|
|
54
|
+
}
|
|
51
55
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
52
56
|
const softThreshold = options.limit * tolerance.soft;
|
|
53
57
|
const hardThreshold = options.limit * tolerance.hard;
|
|
@@ -55,7 +59,7 @@ export async function enforceCap(ctx, options) {
|
|
|
55
59
|
const row = rows[0];
|
|
56
60
|
const storedValue = row ? row["value"] : 0; // @cast-boundary db-row
|
|
57
61
|
// The last of `amount` units sees this value before its own increment.
|
|
58
|
-
const value = storedValue +
|
|
62
|
+
const value = storedValue + amount - 1;
|
|
59
63
|
if (value >= hardThreshold) {
|
|
60
64
|
throw new CapExceededError(options.capName, options.limit, value, tolerance);
|
|
61
65
|
}
|
|
@@ -193,10 +197,12 @@ export async function enforceCapAndMaybeNotify(ctx, options) {
|
|
|
193
197
|
tenantId: ctx.user.tenantId,
|
|
194
198
|
});
|
|
195
199
|
// Flip the soft-warned flag so the same period doesn't re-notify.
|
|
196
|
-
await markCapSoftWarned(ctx, {
|
|
200
|
+
const marked = await markCapSoftWarned(ctx, {
|
|
197
201
|
capName: options.capName,
|
|
198
202
|
periodStartIso: options.periodStartIso,
|
|
199
203
|
});
|
|
204
|
+
if (!marked.isSuccess)
|
|
205
|
+
throw reraiseAsKumikoError(marked.error);
|
|
200
206
|
}
|
|
201
207
|
return result;
|
|
202
208
|
}
|
|
@@ -91,7 +91,9 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
91
91
|
entity: "cap-counter",
|
|
92
92
|
columns: ["tenantId", "capName", "value", "periodStart", "lastSoftWarnedAt"],
|
|
93
93
|
defaultSort: { field: "capName", dir: "asc" },
|
|
94
|
-
|
|
94
|
+
// Search resolves against the caller's own tenant stream, so on this
|
|
95
|
+
// cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
|
|
96
|
+
searchable: false,
|
|
95
97
|
access: { roles: ["SystemAdmin"] },
|
|
96
98
|
});
|
|
97
99
|
r.nav({
|
|
@@ -105,7 +107,6 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
105
107
|
keys: {
|
|
106
108
|
...CAP_COUNTER_I18N,
|
|
107
109
|
"cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
|
|
108
|
-
"cap-counter:entity:cap-counter:field:tenantId": { de: "Mandant", en: "Tenant" },
|
|
109
110
|
},
|
|
110
111
|
});
|
|
111
112
|
});
|
package/dist/cap-counter/i18n.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export const CAP_COUNTER_I18N = {
|
|
2
|
-
"screen:cap-list.title": { en: "Usage caps" },
|
|
3
|
-
"cap-counter:entity:cap-counter:field:tenantId": { en: "Tenant" },
|
|
4
|
-
"cap-counter:entity:cap-counter:field:capName": { en: "Cap" },
|
|
5
|
-
"cap-counter:entity:cap-counter:field:value": { en: "Value" },
|
|
6
|
-
"cap-counter:entity:cap-counter:field:periodStart": { en: "Period start" },
|
|
2
|
+
"screen:cap-list.title": { de: "Nutzungslimits", en: "Usage caps" },
|
|
3
|
+
"cap-counter:entity:cap-counter:field:tenantId": { de: "Mandant", en: "Tenant" },
|
|
4
|
+
"cap-counter:entity:cap-counter:field:capName": { de: "Limit", en: "Cap" },
|
|
5
|
+
"cap-counter:entity:cap-counter:field:value": { de: "Wert", en: "Value" },
|
|
6
|
+
"cap-counter:entity:cap-counter:field:periodStart": { de: "Periodenbeginn", en: "Period start" },
|
|
7
7
|
"cap-counter:entity:cap-counter:field:lastSoftWarnedAt": {
|
|
8
|
+
de: "Letzte Soft-Warnung",
|
|
8
9
|
en: "Last soft warning",
|
|
9
10
|
},
|
|
10
11
|
};
|
|
@@ -51,30 +51,23 @@ export function createCapsUsageQuery(caps) {
|
|
|
51
51
|
const rows = await Promise.all(caps.map(async (cap) => {
|
|
52
52
|
const used = await cap.usage(db, targetTenantId);
|
|
53
53
|
const limit = await cap.limit(tier, { config: ctx.config });
|
|
54
|
+
const base = {
|
|
55
|
+
id: cap.id,
|
|
56
|
+
label: cap.label,
|
|
57
|
+
limit,
|
|
58
|
+
...(cap.icon !== undefined && { icon: cap.icon }),
|
|
59
|
+
...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
|
|
60
|
+
};
|
|
54
61
|
if (used === null) {
|
|
55
|
-
return {
|
|
56
|
-
id: cap.id,
|
|
57
|
-
label: cap.label,
|
|
58
|
-
used: null,
|
|
59
|
-
limit,
|
|
60
|
-
fraction: 0,
|
|
61
|
-
tone: "default",
|
|
62
|
-
percent: null,
|
|
63
|
-
...(cap.icon !== undefined && { icon: cap.icon }),
|
|
64
|
-
...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
|
|
65
|
-
};
|
|
62
|
+
return { ...base, used: null, fraction: 0, tone: "default", percent: null };
|
|
66
63
|
}
|
|
67
64
|
const fraction = computeFraction(used, limit);
|
|
68
65
|
return {
|
|
69
|
-
|
|
70
|
-
label: cap.label,
|
|
66
|
+
...base,
|
|
71
67
|
used,
|
|
72
|
-
limit,
|
|
73
68
|
fraction,
|
|
74
69
|
tone: computeTone(fraction),
|
|
75
70
|
percent: limit === null ? null : Math.round(computeUnclampedFraction(used, limit) * 100),
|
|
76
|
-
...(cap.icon !== undefined && { icon: cap.icon }),
|
|
77
|
-
...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
|
|
78
71
|
};
|
|
79
72
|
}));
|
|
80
73
|
return { rows };
|
|
@@ -52,6 +52,26 @@ function assertSupportedTierFilterOp(op) {
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
function extractTierFilter(filters) {
|
|
56
|
+
const tierFilters = filters.filter((f) => f.field === "tier");
|
|
57
|
+
if (tierFilters.length !== filters.length || tierFilters.length > 1) {
|
|
58
|
+
const unsupportedField = filters.find((f) => f.field !== "tier")?.field ?? "tier";
|
|
59
|
+
throw new ValidationError({
|
|
60
|
+
fields: [
|
|
61
|
+
{
|
|
62
|
+
path: "filters",
|
|
63
|
+
code: "unsupported_field",
|
|
64
|
+
i18nKey: "cap-overview.errors.filterFieldUnsupported",
|
|
65
|
+
params: { field: unsupportedField },
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const tierFilter = tierFilters[0];
|
|
71
|
+
if (tierFilter !== undefined)
|
|
72
|
+
assertSupportedTierFilterOp(tierFilter.op);
|
|
73
|
+
return tierFilter;
|
|
74
|
+
}
|
|
55
75
|
function matchesTierFilter(tier, filter) {
|
|
56
76
|
if (filter === undefined)
|
|
57
77
|
return true;
|
|
@@ -104,10 +124,7 @@ export function createTenantCapsListQuery(caps, listCaps) {
|
|
|
104
124
|
// query builder (only `like`, SQL-LIKE case-sensitive) — filtered here
|
|
105
125
|
// in JS, same as tenant/team-list.query.ts's search.
|
|
106
126
|
const search = query.payload.search?.trim().toLowerCase();
|
|
107
|
-
const
|
|
108
|
-
const tierFilter = filters.find((f) => f.field === "tier");
|
|
109
|
-
if (tierFilter !== undefined)
|
|
110
|
-
assertSupportedTierFilterOp(tierFilter.op);
|
|
127
|
+
const tierFilter = extractTierFilter(query.payload.filters ?? []);
|
|
111
128
|
const merged = tenants
|
|
112
129
|
.filter((tenant) => search === undefined || search === "" || tenant.name.toLowerCase().includes(search))
|
|
113
130
|
.map((tenant) => {
|
|
@@ -144,24 +161,33 @@ export function createTenantCapsListQuery(caps, listCaps) {
|
|
|
144
161
|
// that the fix is a real combined read-projection, not a cursor
|
|
145
162
|
// bolted onto this merge.
|
|
146
163
|
const offset = query.payload.cursor ? Number(decodeCursor(query.payload.cursor)) : 0;
|
|
164
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
165
|
+
throw new ValidationError({
|
|
166
|
+
fields: [
|
|
167
|
+
{
|
|
168
|
+
path: "cursor",
|
|
169
|
+
code: "invalid_cursor",
|
|
170
|
+
i18nKey: "cap-overview.errors.invalidCursor",
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
}
|
|
147
175
|
const limit = query.payload.limit;
|
|
148
176
|
const page = sorted.slice(offset, offset + limit);
|
|
149
|
-
//
|
|
150
|
-
//
|
|
177
|
+
// Usage is computed only for this page's tenant ids, never for the
|
|
178
|
+
// full tenant set; caps without usageBatch still cost one query per
|
|
179
|
+
// tenant of the page, so those run in parallel.
|
|
151
180
|
const pageTenantIds = page.map((row) => row.tenantId);
|
|
152
181
|
const usageByCap = new Map();
|
|
153
|
-
|
|
182
|
+
await Promise.all(listedCaps.map(async (cap) => {
|
|
154
183
|
if (cap.usageBatch) {
|
|
155
184
|
usageByCap.set(cap.id, await cap.usageBatch(db, pageTenantIds));
|
|
185
|
+
// skip: batch result already stored, no per-tenant fallback needed
|
|
186
|
+
return;
|
|
156
187
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
perTenant.set(tenantId, await cap.usage(db, tenantId));
|
|
161
|
-
}
|
|
162
|
-
usageByCap.set(cap.id, perTenant);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
188
|
+
const perTenantEntries = await Promise.all(pageTenantIds.map(async (tenantId) => [tenantId, await cap.usage(db, tenantId)]));
|
|
189
|
+
usageByCap.set(cap.id, new Map(perTenantEntries));
|
|
190
|
+
}));
|
|
165
191
|
const limitByCapAndTier = await resolveLimitsByCapAndTier(listedCaps, new Set(page.map((row) => row.tier)), { config: ctx.config });
|
|
166
192
|
const rows = page.map((row) => {
|
|
167
193
|
const capFields = {};
|
|
@@ -11,9 +11,14 @@ export const CAP_OVERVIEW_I18N = {
|
|
|
11
11
|
"cap-overview.cards.empty": { en: "No caps configured for this tenant." },
|
|
12
12
|
"cap-overview.cards.loading": { en: "Loading usage…" },
|
|
13
13
|
"cap-overview.notMeasured": { en: "Not measured yet" },
|
|
14
|
+
"cap-overview.unlimited": { en: "{used} · unlimited" },
|
|
14
15
|
"cap-overview.errors.progressPrimitiveMissing": {
|
|
15
16
|
en: "Usage bar unavailable — Progress primitive is not registered.",
|
|
16
17
|
},
|
|
18
|
+
"cap-overview.errors.invalidCursor": { en: "The pagination cursor is invalid." },
|
|
19
|
+
"cap-overview.errors.filterFieldUnsupported": {
|
|
20
|
+
en: "Filtering by this field is not supported, or tier was filtered more than once.",
|
|
21
|
+
},
|
|
17
22
|
"cap-overview.errors.sortFieldUnsupported": { en: "Sorting by this field is not supported." },
|
|
18
23
|
"cap-overview.errors.tenantOverrideRequiresSystemAdmin": {
|
|
19
24
|
en: "Only SystemAdmin may query another tenant's data.",
|
|
@@ -52,10 +52,13 @@ export function CapCardsPanel({ filterParams }) {
|
|
|
52
52
|
// rule exists, since that file IS in the scanned surface.
|
|
53
53
|
className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", "data-testid": "cap-cards-panel", children: [query.loading && rows.length === 0 && (_jsx("div", { className: "text-sm text-muted-foreground", children: t("cap-overview.cards.loading") })), rows.map((cap) => {
|
|
54
54
|
const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
|
|
55
|
+
// An unlimited, measured cap has neither a badge nor a bar; StatCard still
|
|
56
|
+
// wraps any defined child in a spacing div, so pass undefined then.
|
|
57
|
+
const body = !isMeasured(cap) || cap.limit !== null ? (_jsxs("div", { className: "flex flex-col gap-2", children: [cap.percent !== null && (_jsx("div", { className: "flex items-center justify-between gap-2", children: _jsx(StatusBadge, { tone: TONE_TO_STATUS[cap.tone], children: `${cap.percent}%` }) })), _jsx(CapUsageBar, { usage: cap, showLabel: false })] })) : undefined;
|
|
55
58
|
return (_jsx(StatCard, { icon: Icon !== undefined ? _jsx(Icon, { className: "size-4" }) : undefined, label: t(cap.label), value: !isMeasured(cap)
|
|
56
59
|
? "—"
|
|
57
60
|
: cap.limit === null
|
|
58
61
|
? String(cap.used)
|
|
59
|
-
: `${cap.used} / ${cap.limit}`, ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }), testId: "cap-card", children:
|
|
62
|
+
: `${cap.used} / ${cap.limit}`, ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }), testId: "cap-card", children: body }, cap.id));
|
|
60
63
|
})] }));
|
|
61
64
|
}
|
|
@@ -10,7 +10,7 @@ export function CapUsageBar({ usage, showLabel = true, }) {
|
|
|
10
10
|
}
|
|
11
11
|
if (usage.limit === null) {
|
|
12
12
|
// A progress bar with no denominator says nothing — show the raw count instead.
|
|
13
|
-
return showLabel ? (_jsx(Text, { variant: "small", testId: "cap-usage-unlimited", children:
|
|
13
|
+
return showLabel ? (_jsx(Text, { variant: "small", testId: "cap-usage-unlimited", children: t("cap-overview.unlimited", { used: usage.used }) })) : null;
|
|
14
14
|
}
|
|
15
15
|
return (_jsxs("div", { className: "flex flex-col gap-1", children: [Progress ? (_jsx(Progress, { value: usage.fraction, tone: computeTone(usage.fraction), testId: "cap-usage-bar" })) : (_jsx(Banner, { variant: "error", testId: "cap-usage-bar-missing-primitive", children: t("cap-overview.errors.progressPrimitiveMissing") })), showLabel && _jsx(Text, { variant: "small", children: `${usage.used} / ${usage.limit}` })] }));
|
|
16
16
|
}
|
|
@@ -15,6 +15,10 @@ function stringHeaders(raw) {
|
|
|
15
15
|
}
|
|
16
16
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
17
17
|
}
|
|
18
|
+
const UNSUBSCRIBE_HEADER_NAMES = new Set([
|
|
19
|
+
"list-unsubscribe",
|
|
20
|
+
"list-unsubscribe-post",
|
|
21
|
+
]);
|
|
18
22
|
// Only the framework's own unsubscribe route can honor a one-click POST.
|
|
19
23
|
function listUnsubscribeHeaders(data) {
|
|
20
24
|
const rawUrl = data?.["unsubscribeUrl"];
|
|
@@ -43,7 +47,14 @@ function emailEnvelopeFrom(data) {
|
|
|
43
47
|
const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
|
|
44
48
|
const autoHeaders = listUnsubscribeHeaders(data);
|
|
45
49
|
const explicitHeaders = stringHeaders(data["headers"]);
|
|
46
|
-
|
|
50
|
+
// Header names are case-insensitive, and the auto pair only works together:
|
|
51
|
+
// an explicit override of either half must drop both, or a mail client would
|
|
52
|
+
// one-click POST to a URL that cannot handle it.
|
|
53
|
+
const overridesUnsubscribe = explicitHeaders !== undefined &&
|
|
54
|
+
Object.keys(explicitHeaders).some((name) => UNSUBSCRIBE_HEADER_NAMES.has(name.toLowerCase()));
|
|
55
|
+
const headers = (!overridesUnsubscribe && autoHeaders) || explicitHeaders
|
|
56
|
+
? { ...(overridesUnsubscribe ? undefined : autoHeaders), ...explicitHeaders }
|
|
57
|
+
: undefined;
|
|
47
58
|
return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
|
|
48
59
|
}
|
|
49
60
|
export function createEmailChannel(options) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { boolean, instant, table as pgTable, serial, sql, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
export const inAppMessagesTable = pgTable("in_app_messages", {
|
|
4
4
|
id: serial("id").primaryKey(),
|
|
@@ -14,8 +14,4 @@ export const inAppMessagesTable = pgTable("in_app_messages", {
|
|
|
14
14
|
});
|
|
15
15
|
// Derived from inAppMessagesTable (table() attaches the meta as a Symbol) so
|
|
16
16
|
// the two can't drift, instead of hand-duplicating the column list.
|
|
17
|
-
const
|
|
18
|
-
if (!derivedInAppMessagesTableMeta) {
|
|
19
|
-
throw new Error("inAppMessagesTable: table carries no EntityTableMeta — built via table()?");
|
|
20
|
-
}
|
|
21
|
-
export const inAppMessagesTableMeta = derivedInAppMessagesTableMeta;
|
|
17
|
+
export const inAppMessagesTableMeta = requireEntityTableMeta(inAppMessagesTable, "inAppMessagesTable");
|
|
@@ -19,7 +19,7 @@ export const subProcessorsQuery = defineQueryHandler({
|
|
|
19
19
|
name: "sub-processors",
|
|
20
20
|
schema: z.object({}),
|
|
21
21
|
access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
|
|
22
|
-
rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
|
|
22
|
+
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
|
|
23
23
|
description: "Returns the publicly readable Kumiko sub-processor list split into active and planned entries, for the GDPR Art. 28(2) disclosure a privacy policy or data-processing agreement links to.",
|
|
24
24
|
handler: async () => {
|
|
25
25
|
return {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { configValuesTable } from "../../table.js";
|
|
3
|
-
// Method-form so a tenant-mode TenantDb stays filtered.
|
|
3
|
+
// Method-form so a tenant-mode TenantDb stays filtered. Two parallel selects
|
|
4
|
+
// (disjoint via userId IS NULL) instead of one OR query: the buckets don't share
|
|
5
|
+
// a snapshot, so a concurrent config write can mix old scope and new user values.
|
|
4
6
|
export async function selectConfigRowsForScope(db, systemTenantId, tenantId, userId) {
|
|
5
7
|
const [scopeRows, userRows] = await Promise.all([
|
|
6
8
|
selectMany(db, configValuesTable, {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { configuredPiiSubjectKms, encryptPiiValueForSubject, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import { ConfigScopes, defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { ConfigScopes, defineWriteHandler, isEncryptedAtRest, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { InternalError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import * as z from "zod";
|
|
7
7
|
import { requireConfigEncryption, requireSystemDb } from "../feature.js";
|
|
8
|
+
import { MASKED } from "../read-redaction.js";
|
|
8
9
|
import { configValueEntity, configValuesTable } from "../table.js";
|
|
9
10
|
import { findConfigRow, prepareConfigWrite, resolvePiiSubject, validateBounds, validateExtensionPlugin, validatePattern, validateScope, validateType, } from "../write-helpers.js";
|
|
10
11
|
const scopeEnum = z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]);
|
|
@@ -53,7 +54,7 @@ export const setWrite = defineWriteHandler({
|
|
|
53
54
|
const typeError = validateType(event.payload.value, keyDef);
|
|
54
55
|
if (typeError)
|
|
55
56
|
return writeFailure(typeError);
|
|
56
|
-
const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value);
|
|
57
|
+
const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value, keyDef);
|
|
57
58
|
if (pluginError)
|
|
58
59
|
return writeFailure(pluginError);
|
|
59
60
|
// Bounds enforcement: hard-reject (not silent-clamp). A caller that
|
|
@@ -66,6 +67,9 @@ export const setWrite = defineWriteHandler({
|
|
|
66
67
|
const patternError = validatePattern(event.payload.value, keyDef);
|
|
67
68
|
if (patternError)
|
|
68
69
|
return writeFailure(patternError);
|
|
70
|
+
// The batch idempotency cache stores this result and only masks entity
|
|
71
|
+
// writeOnly fields, so an at-rest-encrypted value must not be echoed.
|
|
72
|
+
const echoedValue = isEncryptedAtRest(keyDef) ? MASKED : event.payload.value;
|
|
69
73
|
// backing="secrets": persist into the secrets store (system tenant, own
|
|
70
74
|
// envelope encryption + audit) instead of config_values. Same JSON
|
|
71
75
|
// serialization as a config row so the read path round-trips via
|
|
@@ -82,7 +86,7 @@ export const setWrite = defineWriteHandler({
|
|
|
82
86
|
});
|
|
83
87
|
return {
|
|
84
88
|
isSuccess: true,
|
|
85
|
-
data: { key: event.payload.key, value:
|
|
89
|
+
data: { key: event.payload.key, value: echoedValue, scope },
|
|
86
90
|
};
|
|
87
91
|
}
|
|
88
92
|
let serialized = JSON.stringify(event.payload.value);
|
|
@@ -139,7 +143,7 @@ export const setWrite = defineWriteHandler({
|
|
|
139
143
|
}
|
|
140
144
|
return {
|
|
141
145
|
isSuccess: true,
|
|
142
|
-
data: { key: event.payload.key, value:
|
|
146
|
+
data: { key: event.payload.key, value: echoedValue, scope },
|
|
143
147
|
};
|
|
144
148
|
},
|
|
145
149
|
});
|
package/dist/config/web/i18n.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
export const defaultTranslations = {
|
|
10
10
|
en: {
|
|
11
11
|
"config.secrets.delete": "Remove",
|
|
12
|
+
"config.secrets.deleteConfirm": "Remove this secret? Anything using it stops working, and it cannot be restored.",
|
|
12
13
|
"config.secrets.notSet": "Not set",
|
|
13
14
|
"config.secrets.placeholder": "Enter a value",
|
|
14
15
|
"config.secrets.replacePlaceholder": "Enter a new value to replace it",
|
|
@@ -38,7 +38,7 @@ export declare function resolveScopeIds(scope: ConfigScope, tenantId: TenantId,
|
|
|
38
38
|
userId: string | null;
|
|
39
39
|
};
|
|
40
40
|
export declare function resolvePiiSubject(scope: Exclude<ConfigScope, "system">, tenantId: TenantId, userId: string | null): SubjectId;
|
|
41
|
-
export declare function validateExtensionPlugin(registry: Registry, key: string, value: string | number | boolean): KumikoError | null;
|
|
41
|
+
export declare function validateExtensionPlugin(registry: Pick<Registry, "getAllExtensionSelectors" | "getExtensionUsages">, key: string, value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|
|
42
42
|
export declare function validateType(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|
|
43
43
|
export declare function validateBounds(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|
|
44
44
|
export declare function validatePattern(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|