@cosmicdrift/kumiko-bundled-features 0.336.0 → 0.337.1
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-manifest.js +34 -7
- package/dist/agent-tools/tool-catalog.js +21 -2
- package/dist/audit/handlers/list.query.js +7 -3
- package/dist/auth-email-password/handlers/login.write.js +1 -2
- package/dist/auth-email-password/seeding.js +6 -0
- package/dist/auth-email-password/web/invite-accept-screen.js +4 -1
- package/dist/auth-mfa/handlers/enable-start.write.js +13 -2
- package/dist/auth-mfa/hmac-token-codec.js +8 -9
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +2 -0
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -7
- package/dist/config/write-helpers.js +2 -2
- package/dist/custom-fields/feature.js +3 -2
- package/dist/custom-fields/lib/field-access.d.ts +3 -2
- package/dist/custom-fields/lib/field-access.js +1 -0
- package/dist/delivery/unsubscribe.js +1 -0
- package/dist/files-tenant-data/hooks.js +11 -3
- package/dist/folders/web/folder-section.js +8 -1
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/jobs/constants.d.ts +1 -0
- package/dist/jobs/constants.js +1 -0
- package/dist/jobs/handlers/trigger.write.js +7 -2
- package/dist/jobs/i18n.js +3 -0
- package/dist/metrics/compute.js +16 -5
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history/handlers/add-note.write.js +2 -1
- package/dist/notes-history/web/notes-section.js +6 -1
- package/dist/personal-access-tokens/schema/api-token.js +5 -5
- package/dist/rate-limiting/handlers/bucket-access.d.ts +2 -5
- package/dist/secrets/feature.js +2 -0
- package/dist/secrets/handlers/delete.write.js +4 -0
- package/dist/secrets/handlers/set.write.js +5 -2
- package/dist/secrets/i18n.d.ts +10 -0
- package/dist/secrets/i18n.js +10 -0
- package/dist/secrets/write-gate.d.ts +10 -0
- package/dist/secrets/write-gate.js +36 -0
- package/dist/shared/index.d.ts +0 -1
- package/dist/shared/index.js +0 -1
- package/dist/shared/session-locale-field.js +1 -2
- package/dist/shared/session-timezone-field.js +3 -2
- package/dist/step-dispatcher/feature.js +4 -3
- package/dist/step-dispatcher/mail-runner.d.ts +1 -0
- package/dist/step-dispatcher/mail-runner.js +5 -0
- package/dist/step-dispatcher/webhook-runner.d.ts +7 -0
- package/dist/step-dispatcher/webhook-runner.js +9 -0
- package/dist/subscription-mollie/plugin-methods.js +3 -5
- package/dist/subscription-stripe/plugin-methods.js +30 -5
- package/dist/tenant/handlers/member-directory.query.js +16 -3
- package/dist/tenant/handlers/tenant-directory.query.js +7 -1
- package/dist/tenant-handover/feature.js +1 -1
- package/dist/tenant-handover/move-entity-graph.js +11 -3
- package/dist/tenant-lifecycle/lifecycle-gate.d.ts +4 -0
- package/dist/tenant-lifecycle/lifecycle-gate.js +26 -4
- package/dist/user/schema/user.js +4 -3
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +3 -3
- package/dist/user-data-rights/handlers/deletion-grace-period.js +3 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -3
- package/dist/workflow-runner/db/queries/due-runs.d.ts +1 -0
- package/dist/workflow-runner/db/queries/due-runs.js +4 -1
- package/package.json +9 -9
- package/src/agent-tools/changes.json +6 -0
- package/src/secrets/changes.json +7 -0
- package/src/subscription-stripe/changes.json +12 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +8 -1
- package/src/workflow-runner/changes.json +6 -0
- package/dist/shared/session-field.d.ts +0 -3
- package/dist/shared/session-field.js +0 -6
|
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
|
|
4
4
|
import { requireSecretsContext } from "../feature.js";
|
|
5
|
+
import { checkSecretKeyWrite } from "../write-gate.js";
|
|
5
6
|
export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
|
|
6
7
|
return defineWriteHandler({
|
|
7
8
|
name: "set",
|
|
@@ -26,8 +27,10 @@ export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
|
|
|
26
27
|
// (via r.secret()) > generic default. A feature that declared a
|
|
27
28
|
// domain-aware redact (Stripe keys: "sk_test...2345") wins over the
|
|
28
29
|
// framework default unless the caller sent a specific preview.
|
|
29
|
-
const
|
|
30
|
-
|
|
30
|
+
const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, key);
|
|
31
|
+
if (!gate.ok)
|
|
32
|
+
return gate.failure;
|
|
33
|
+
const featureRedact = gate.keyDef?.redact;
|
|
31
34
|
const redactFn = redactedPreview
|
|
32
35
|
? () => redactedPreview
|
|
33
36
|
: (featureRedact ?? defaultRedact);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const SECRETS_I18N: {
|
|
2
|
+
readonly "secrets.errors.unknownKey": {
|
|
3
|
+
readonly en: "Unknown secret key.";
|
|
4
|
+
readonly de: "Unbekannter Geheimnis-Schlüssel.";
|
|
5
|
+
};
|
|
6
|
+
readonly "secrets.errors.writeDenied": {
|
|
7
|
+
readonly en: "You are not allowed to change this secret.";
|
|
8
|
+
readonly de: "Dieses Geheimnis darf nicht geändert werden.";
|
|
9
|
+
};
|
|
10
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const SECRETS_I18N = {
|
|
2
|
+
"secrets.errors.unknownKey": {
|
|
3
|
+
en: "Unknown secret key.",
|
|
4
|
+
de: "Unbekannter Geheimnis-Schlüssel.",
|
|
5
|
+
},
|
|
6
|
+
"secrets.errors.writeDenied": {
|
|
7
|
+
en: "You are not allowed to change this secret.",
|
|
8
|
+
de: "Dieses Geheimnis darf nicht geändert werden.",
|
|
9
|
+
},
|
|
10
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Registry, SecretKeyDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
export type SecretKeyWriteCheck = {
|
|
4
|
+
readonly ok: true;
|
|
5
|
+
readonly keyDef: SecretKeyDefinition | undefined;
|
|
6
|
+
} | {
|
|
7
|
+
readonly ok: false;
|
|
8
|
+
readonly failure: WriteFailure;
|
|
9
|
+
};
|
|
10
|
+
export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string): SecretKeyWriteCheck;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AccessDeniedError, NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
+
function unknownKeyFailure(key) {
|
|
3
|
+
return {
|
|
4
|
+
ok: false,
|
|
5
|
+
failure: writeFailure(new NotFoundError("secretKey", key, { i18nKey: "secrets.errors.unknownKey" })),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function namespaceAcceptsKey(namespace, key) {
|
|
9
|
+
const suffix = key.slice(namespace.qualifiedPrefix.length);
|
|
10
|
+
if (suffix.length === 0)
|
|
11
|
+
return false;
|
|
12
|
+
return namespace.nameSchema ? namespace.nameSchema.safeParse(suffix).success : true;
|
|
13
|
+
}
|
|
14
|
+
// Only keys declared via r.secret() or under a declared r.secretNamespace()
|
|
15
|
+
// are writable through the API, so a tenant admin cannot plant arbitrary rows
|
|
16
|
+
// next to the feature-owned ones. writeRoles narrows the handler access
|
|
17
|
+
// further for a key or a whole namespace.
|
|
18
|
+
export function checkSecretKeyWrite(registry, userRoles, key) {
|
|
19
|
+
const keyDef = registry.getSecretKey(key);
|
|
20
|
+
const namespace = keyDef ? undefined : registry.findSecretNamespace(key);
|
|
21
|
+
if (!keyDef && !(namespace && namespaceAcceptsKey(namespace, key))) {
|
|
22
|
+
return unknownKeyFailure(key);
|
|
23
|
+
}
|
|
24
|
+
const writeRoles = keyDef?.writeRoles ?? namespace?.writeRoles;
|
|
25
|
+
if (writeRoles !== undefined && !userRoles.some((role) => writeRoles.includes(role))) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
failure: writeFailure(new AccessDeniedError({
|
|
29
|
+
message: "secret write access denied",
|
|
30
|
+
i18nKey: "secrets.errors.writeDenied",
|
|
31
|
+
details: { requiredRoles: writeRoles },
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, keyDef };
|
|
36
|
+
}
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -14,7 +14,6 @@ export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisi
|
|
|
14
14
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
15
15
|
export { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
|
|
16
16
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
|
17
|
-
export { sessionField } from "./session-field.js";
|
|
18
17
|
export { sessionLocaleField } from "./session-locale-field.js";
|
|
19
18
|
export { sessionTimezoneField } from "./session-timezone-field.js";
|
|
20
19
|
export { peekTokenSubject, signToken, TokenPurpose, type VerifyResult, verifyToken, } from "./signed-token.js";
|
package/dist/shared/index.js
CHANGED
|
@@ -14,7 +14,6 @@ export { denyUnlessJoinRowParentVisible, joinRowParentIsVisible, parentRowIsVisi
|
|
|
14
14
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
15
15
|
export { redeemRowBoundGrant, signRowBoundGrant, } from "./row-bound-grant.js";
|
|
16
16
|
export { runInSubTransaction } from "./run-in-sub-transaction.js";
|
|
17
|
-
export { sessionField } from "./session-field.js";
|
|
18
17
|
export { sessionLocaleField } from "./session-locale-field.js";
|
|
19
18
|
export { sessionTimezoneField } from "./session-timezone-field.js";
|
|
20
19
|
export { peekTokenSubject, signToken, TokenPurpose, verifyToken, } from "./signed-token.js";
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
|
-
import { sessionField } from "./session-field.js";
|
|
3
2
|
export function sessionLocaleField(locale) {
|
|
4
3
|
if (locale === null || locale === undefined || !isValidLocaleTag(locale))
|
|
5
4
|
return {};
|
|
6
|
-
return
|
|
5
|
+
return { locale: canonicalizeLocaleTag(locale) };
|
|
7
6
|
}
|
|
@@ -12,8 +12,8 @@ import { defineFeature, STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE,
|
|
|
12
12
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
13
13
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
14
14
|
import * as z from "zod";
|
|
15
|
-
import { mailSpecSchema, performMailDispatch } from "./mail-runner.js";
|
|
16
|
-
import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, webhookSpecSchema, } from "./webhook-runner.js";
|
|
15
|
+
import { mailSpecSchema, performMailDispatch, redactEmailAddresses, } from "./mail-runner.js";
|
|
16
|
+
import { performWebhookDispatch, WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR, WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS, webhookSpecSchema, } from "./webhook-runner.js";
|
|
17
17
|
const log = createFallbackLogger("step-dispatcher");
|
|
18
18
|
export const stepDispatcherEnvSchema = z.object({
|
|
19
19
|
[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
@@ -144,6 +144,7 @@ function buildDispatchSpec(payload, fields) {
|
|
|
144
144
|
export function createStepDispatcherFeature() {
|
|
145
145
|
return defineFeature("step-dispatcher", (r) => {
|
|
146
146
|
r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.");
|
|
147
|
+
r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
|
|
147
148
|
r.uiHints({
|
|
148
149
|
displayLabel: "Step Dispatcher · Deferred Side-Effects",
|
|
149
150
|
category: "infrastructure",
|
|
@@ -216,7 +217,7 @@ export function createStepDispatcherFeature() {
|
|
|
216
217
|
if (payload.stepKind === "mail.send") {
|
|
217
218
|
log.warn("mail dispatch failed", {
|
|
218
219
|
aggregateId: event.aggregateId,
|
|
219
|
-
reason: result.error,
|
|
220
|
+
reason: redactEmailAddresses(result.error),
|
|
220
221
|
});
|
|
221
222
|
await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
|
|
222
223
|
}
|
|
@@ -15,3 +15,4 @@ export type MailDispatchResult = {
|
|
|
15
15
|
};
|
|
16
16
|
export declare function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult>): void;
|
|
17
17
|
export declare function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult>;
|
|
18
|
+
export declare function redactEmailAddresses(text: string): string;
|
|
@@ -19,3 +19,8 @@ export function setMailRunner(fn) {
|
|
|
19
19
|
export async function performMailDispatch(spec) {
|
|
20
20
|
return mailRunner(spec);
|
|
21
21
|
}
|
|
22
|
+
// Adapter errors usually quote the rejected recipient ("550 <ops@example.com> rejected"); the
|
|
23
|
+
// address must not outlive the crypto-shredded dispatch payload in logs.
|
|
24
|
+
export function redactEmailAddresses(text) {
|
|
25
|
+
return text.replace(/[^\s<>,;"']+@[^\s<>,;"']+/g, "[redacted-address]");
|
|
26
|
+
}
|
|
@@ -7,6 +7,13 @@ export declare function readAllowedPrivateWebhookHostsFromEnv(env?: Readonly<Rec
|
|
|
7
7
|
export declare function setWebhookHostLookup(fn: typeof lookup | undefined): void;
|
|
8
8
|
export declare const WEBHOOK_AUTH_SECRET_KEY_PREFIX = "step-dispatcher:webhook-auth.";
|
|
9
9
|
export declare function webhookAuthSecretKey(name: string): string;
|
|
10
|
+
export declare const WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS: {
|
|
11
|
+
readonly label: {
|
|
12
|
+
readonly en: "Webhook auth secrets";
|
|
13
|
+
};
|
|
14
|
+
readonly scope: "tenant";
|
|
15
|
+
readonly nameSchema: z.ZodString;
|
|
16
|
+
};
|
|
10
17
|
export declare const webhookSpecSchema: z.ZodObject<{
|
|
11
18
|
url: z.ZodString;
|
|
12
19
|
method: z.ZodEnum<{
|
|
@@ -50,6 +50,15 @@ const webhookAuthSecretNameSchema = z
|
|
|
50
50
|
.min(1)
|
|
51
51
|
.max(WEBHOOK_AUTH_SECRET_NAME_MAX_LENGTH)
|
|
52
52
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
|
53
|
+
// The same schema validates `auth.secret` in the step spec and the suffix the
|
|
54
|
+
// secrets handlers accept under the namespace, so the two cannot drift. The
|
|
55
|
+
// prefix itself is derived from the feature name by r.secretNamespace; the
|
|
56
|
+
// boot test pins it to WEBHOOK_AUTH_SECRET_KEY_PREFIX.
|
|
57
|
+
export const WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS = {
|
|
58
|
+
label: { en: "Webhook auth secrets" },
|
|
59
|
+
scope: "tenant",
|
|
60
|
+
nameSchema: webhookAuthSecretNameSchema,
|
|
61
|
+
};
|
|
53
62
|
export const webhookSpecSchema = z.object({
|
|
54
63
|
url: z.string(),
|
|
55
64
|
method: z.enum(["POST", "PUT", "PATCH"]),
|
|
@@ -32,11 +32,9 @@ export function createMollieCheckoutSession(client, priceToConfig, appWebhookUrl
|
|
|
32
32
|
customerId = customer.id;
|
|
33
33
|
}
|
|
34
34
|
// mode "payment" (one-off top-up) → sequenceType.oneoff, no mandate
|
|
35
|
-
// setup.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// Webhook resolution for oneoff payments is separate follow-up
|
|
39
|
-
// work (offlot#109), not part of this contract.
|
|
35
|
+
// setup. Oneoff payments carry no subscriptionId and are not
|
|
36
|
+
// sequenceType "first", so verifyAndParseMollieWebhook drops them before
|
|
37
|
+
// any mandate/subscription handling.
|
|
40
38
|
const sequenceType = options.mode === "payment" ? SequenceType.oneoff : SequenceType.first;
|
|
41
39
|
// payments.create ist overloaded (Promise OR void mit callback);
|
|
42
40
|
// explicit cast auf Promise<Payment>-overload.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Stripe-Plugin-Methoden für die POST-tenant-resolution-Phase:
|
|
2
|
-
// createCheckoutSession, createPortalSession, cancelSubscription
|
|
2
|
+
// createCheckoutSession, createPortalSession, cancelSubscription,
|
|
3
|
+
// retrieveSubscription, retrievePrices, planSwitchSession.
|
|
3
4
|
//
|
|
4
5
|
// Werden vom Plugin-build (feature.ts) als methods auf dem
|
|
5
6
|
// SubscriptionProviderPlugin registriert. Anders als
|
|
@@ -8,7 +9,7 @@
|
|
|
8
9
|
// runtime auf (api-key aus system-secrets, audited), statt aus einem
|
|
9
10
|
// mount-time-Closure. Key-Rotation wirkt damit ohne Redeploy.
|
|
10
11
|
//
|
|
11
|
-
// **Type-Ableitung:** die options-shapes der
|
|
12
|
+
// **Type-Ableitung:** die options-shapes der methods werden
|
|
12
13
|
// **direkt vom Plugin-Contract** abgeleitet (`Parameters<NonNullable
|
|
13
14
|
// <SubscriptionProviderPlugin["...method"]>>[1]`). Wenn Foundation den
|
|
14
15
|
// Contract erweitert (z.B. neuer optionaler Field), bemerkt der
|
|
@@ -18,6 +19,11 @@ import { KNOWN_RECURRING_INTERVALS, } from "@cosmicdrift/kumiko-bundled-features
|
|
|
18
19
|
import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
19
20
|
import Stripe from "stripe";
|
|
20
21
|
import { isResourceMissingStripeError, mapStripeSubscriptionState } from "./verify-webhook.js";
|
|
22
|
+
function isUnknownCustomerStripeError(error) {
|
|
23
|
+
return (isResourceMissingStripeError(error) &&
|
|
24
|
+
error instanceof Stripe.errors.StripeInvalidRequestError &&
|
|
25
|
+
error.param === "customer");
|
|
26
|
+
}
|
|
21
27
|
export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation = true } = {}) {
|
|
22
28
|
return async (ctx, options) => {
|
|
23
29
|
// #104-Invariante: ohne billing-live darf keine Stripe-Session
|
|
@@ -28,7 +34,7 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
|
|
|
28
34
|
await runtime.assertBillingLive(ctx);
|
|
29
35
|
const stripe = await runtime.clientForCtx(ctx);
|
|
30
36
|
const mode = options.mode ?? "subscription";
|
|
31
|
-
const
|
|
37
|
+
const sessionParams = (providerCustomerId) => ({
|
|
32
38
|
mode,
|
|
33
39
|
line_items: [{ price: options.priceId, quantity: 1 }],
|
|
34
40
|
success_url: options.successUrl,
|
|
@@ -48,10 +54,22 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
|
|
|
48
54
|
// Without a customer, Stripe's default "if_required" makes a guest
|
|
49
55
|
// checkout when invoice_creation is off; the webhook then has no
|
|
50
56
|
// customer id and drops the paid payment as ignored.
|
|
51
|
-
...(!
|
|
57
|
+
...(!providerCustomerId && { customer_creation: "always" }),
|
|
52
58
|
}),
|
|
53
|
-
...(
|
|
59
|
+
...(providerCustomerId && { customer: providerCustomerId }),
|
|
54
60
|
});
|
|
61
|
+
let session;
|
|
62
|
+
try {
|
|
63
|
+
session = await stripe.checkout.sessions.create(sessionParams(options.providerCustomerId));
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (!options.providerCustomerId || !isUnknownCustomerStripeError(error))
|
|
67
|
+
throw error;
|
|
68
|
+
// Stripe account switch (test -> live) or a customer deleted in the dashboard leaves a
|
|
69
|
+
// stored customer id Stripe no longer knows; checkout creates a fresh customer instead.
|
|
70
|
+
ctx.log?.warn("subscription-stripe: stored customer unknown to Stripe, retrying without it");
|
|
71
|
+
session = await stripe.checkout.sessions.create(sessionParams(undefined));
|
|
72
|
+
}
|
|
55
73
|
if (!session.url) {
|
|
56
74
|
// Defensive: Stripe returns a hosted url for both modes today —
|
|
57
75
|
// guards against future API drift.
|
|
@@ -252,6 +270,13 @@ async function resolvePortalConfiguration(stripe, runtime, cache, portalConfigCa
|
|
|
252
270
|
const price = cache.get(priceId);
|
|
253
271
|
return price ? [price] : [];
|
|
254
272
|
});
|
|
273
|
+
// A transient prices.retrieve failure would otherwise yield a different
|
|
274
|
+
// priceSetHash and persist an incomplete portal configuration at Stripe.
|
|
275
|
+
if (prices.length !== allowedPriceIds.length) {
|
|
276
|
+
throw new UnprocessableError("price_unavailable", {
|
|
277
|
+
i18nKey: "billing-foundation.errors.priceUnavailable",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
255
280
|
const hash = priceSetHash(prices);
|
|
256
281
|
const cached = portalConfigCache.get(hash);
|
|
257
282
|
if (cached)
|
|
@@ -13,9 +13,14 @@ const KMS_POOL_CONCURRENCY = 4;
|
|
|
13
13
|
// are decrypted and filtered in memory. The cap keeps a SystemAdmin's global
|
|
14
14
|
// search from decrypting every user.
|
|
15
15
|
const SEARCH_SCAN_CAP = 1000;
|
|
16
|
+
// Display names are encrypted, so id is the only stable SQL-side key; uuid text
|
|
17
|
+
// order equals the uuid column's byte order.
|
|
18
|
+
function compareIds(a, b) {
|
|
19
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
20
|
+
}
|
|
16
21
|
async function loadMemberUserIds(db, tenantId) {
|
|
17
22
|
const memberships = await selectMany(db, tenantMembershipsTable, { tenantId });
|
|
18
|
-
return [...new Set(memberships.map((row) => String(row["userId"])))];
|
|
23
|
+
return [...new Set(memberships.map((row) => String(row["userId"])))].sort(compareIds);
|
|
19
24
|
}
|
|
20
25
|
// Label source behind every `user:user` reference column (fw#3107). The
|
|
21
26
|
// entity-convention lookup, user:query:user:list, is a SystemAdmin
|
|
@@ -52,12 +57,20 @@ export const memberDirectoryQuery = definePagedQueryHandler({
|
|
|
52
57
|
let users;
|
|
53
58
|
if (isSystemAdmin(query.user)) {
|
|
54
59
|
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as user:query:user:list did");
|
|
55
|
-
users = await selectMany(db, userTable, undefined, {
|
|
60
|
+
users = await selectMany(db, userTable, undefined, {
|
|
61
|
+
limit: scanLimit,
|
|
62
|
+
orderBy: { col: "id", direction: "asc" },
|
|
63
|
+
});
|
|
56
64
|
}
|
|
57
65
|
else {
|
|
58
66
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
59
67
|
const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, scanLimit);
|
|
60
|
-
users =
|
|
68
|
+
users =
|
|
69
|
+
userIds.length > 0
|
|
70
|
+
? await selectMany(db, userTable, { id: [...userIds] }, {
|
|
71
|
+
orderBy: { col: "id", direction: "asc" },
|
|
72
|
+
})
|
|
73
|
+
: [];
|
|
61
74
|
}
|
|
62
75
|
const resolved = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
|
|
63
76
|
const id = String(user.id);
|
|
@@ -35,7 +35,13 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
35
35
|
let tenants;
|
|
36
36
|
if (isSystemAdmin(query.user)) {
|
|
37
37
|
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did");
|
|
38
|
-
tenants = await selectMany(db, tenantTable, undefined, {
|
|
38
|
+
tenants = await selectMany(db, tenantTable, undefined, {
|
|
39
|
+
limit: scanLimit,
|
|
40
|
+
orderBy: [
|
|
41
|
+
{ col: "name", direction: "asc" },
|
|
42
|
+
{ col: "id", direction: "asc" },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
39
45
|
}
|
|
40
46
|
else {
|
|
41
47
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
@@ -13,7 +13,7 @@ export function createTenantHandoverFeature(opts = {}) {
|
|
|
13
13
|
r.describe("Try-before-signup ownership handover: claims the rows of a declared-transferable " +
|
|
14
14
|
"entity graph (root plus its parentRef-linked children) from an anonymous/public " +
|
|
15
15
|
"tenant into the caller's own tenant, legitimized by a row-bound grant the anonymous " +
|
|
16
|
-
"flow minted. Idempotent, audited via a
|
|
16
|
+
"flow minted. Idempotent, audited via a `tenant-handover:event:claimed` event on its own `tenant-handover-claim` aggregate.");
|
|
17
17
|
r.uiHints({
|
|
18
18
|
displayLabel: "Tenant Handover",
|
|
19
19
|
category: "identity",
|
|
@@ -175,7 +175,9 @@ export async function moveTransferGraph(args) {
|
|
|
175
175
|
{ entityName: rootEntityName, rowIds: [rootRowId] },
|
|
176
176
|
];
|
|
177
177
|
for (let round = 0; round < MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
|
|
178
|
-
|
|
178
|
+
// Merged per type so an edge leaving a type runs once per round, not once
|
|
179
|
+
// per edge that reached it.
|
|
180
|
+
const discovered = new Map();
|
|
179
181
|
for (const { entityName, rowIds } of pending) {
|
|
180
182
|
for (const edge of adjacency.get(entityName) ?? []) {
|
|
181
183
|
const childIds = await moveEdgeRows({
|
|
@@ -204,7 +206,13 @@ export async function moveTransferGraph(args) {
|
|
|
204
206
|
// (`note.vehicleId` and `note.campaignId`), and assigning would drop the
|
|
205
207
|
// earlier count.
|
|
206
208
|
movedCounts[edge.entityName] = (movedCounts[edge.entityName] ?? 0) + childIds.length;
|
|
207
|
-
discovered.
|
|
209
|
+
const discoveredIds = discovered.get(edge.entityName);
|
|
210
|
+
if (discoveredIds === undefined) {
|
|
211
|
+
discovered.set(edge.entityName, [...childIds]);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
discoveredIds.push(...childIds);
|
|
215
|
+
}
|
|
208
216
|
await transferAggregateStreams(db, {
|
|
209
217
|
aggregateType: edge.entityName,
|
|
210
218
|
aggregateIds: childIds,
|
|
@@ -223,7 +231,7 @@ export async function moveTransferGraph(args) {
|
|
|
223
231
|
}));
|
|
224
232
|
}
|
|
225
233
|
}
|
|
226
|
-
pending = discovered;
|
|
234
|
+
pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
|
|
227
235
|
}
|
|
228
236
|
// The depth limit is a safety net, not a licence to move part of a graph: if
|
|
229
237
|
// rounds ran out while rows remain whose type still leads somewhere, the
|
|
@@ -4,6 +4,8 @@ export type TenantLifecycleGate = {
|
|
|
4
4
|
readonly status: string;
|
|
5
5
|
readonly gracePeriodEnd: string | null;
|
|
6
6
|
};
|
|
7
|
+
/** @internal exported for the bounded-cache test */
|
|
8
|
+
export declare const GATE_CACHE_MAX_ENTRIES = 5000;
|
|
7
9
|
export declare function invalidateTenantLifecycleGate(tenantId: TenantId): void;
|
|
8
10
|
/** @internal test-only — resetTestTables/resetTestTable wipe rows directly
|
|
9
11
|
* via SQL, bypassing the write handlers that invalidate normally. Tests
|
|
@@ -11,4 +13,6 @@ export declare function invalidateTenantLifecycleGate(tenantId: TenantId): void;
|
|
|
11
13
|
* call this in beforeEach or a later test can read an earlier test's
|
|
12
14
|
* cached status for the same tenantId. */
|
|
13
15
|
export declare function resetTenantLifecycleGateCacheForTests(): void;
|
|
16
|
+
/** @internal test-only */
|
|
17
|
+
export declare function tenantLifecycleGateCacheSizeForTests(): number;
|
|
14
18
|
export declare function resolveTenantLifecycleGate(db: DbRunner, tenantId: TenantId): Promise<TenantLifecycleGate | null>;
|
|
@@ -11,9 +11,12 @@ import { tenantTable } from "../tenant/index.js";
|
|
|
11
11
|
// never arrives).
|
|
12
12
|
// Split into its own module (not run-tenant-destroy.ts) so stages.ts can
|
|
13
13
|
// invalidate too without a stages.ts <-> run-tenant-destroy.ts import cycle.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
14
|
+
// The anonymous path feeds client-supplied X-Tenant ids in here, so unknown ids
|
|
15
|
+
// (cached as null) must not grow the Map without bound: past the cap, expired
|
|
16
|
+
// entries are swept and, if still full, the oldest-inserted entries are evicted.
|
|
16
17
|
const GATE_TTL_MS = 3000;
|
|
18
|
+
/** @internal exported for the bounded-cache test */
|
|
19
|
+
export const GATE_CACHE_MAX_ENTRIES = 5000;
|
|
17
20
|
const gateCache = new Map();
|
|
18
21
|
export function invalidateTenantLifecycleGate(tenantId) {
|
|
19
22
|
gateCache.delete(tenantId);
|
|
@@ -26,6 +29,25 @@ export function invalidateTenantLifecycleGate(tenantId) {
|
|
|
26
29
|
export function resetTenantLifecycleGateCacheForTests() {
|
|
27
30
|
gateCache.clear();
|
|
28
31
|
}
|
|
32
|
+
/** @internal test-only */
|
|
33
|
+
export function tenantLifecycleGateCacheSizeForTests() {
|
|
34
|
+
return gateCache.size;
|
|
35
|
+
}
|
|
36
|
+
function cacheGate(tenantId, value) {
|
|
37
|
+
if (gateCache.size >= GATE_CACHE_MAX_ENTRIES && !gateCache.has(tenantId)) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const [id, entry] of gateCache) {
|
|
40
|
+
if (entry.expiresAt <= now)
|
|
41
|
+
gateCache.delete(id);
|
|
42
|
+
}
|
|
43
|
+
for (const id of gateCache.keys()) {
|
|
44
|
+
if (gateCache.size < GATE_CACHE_MAX_ENTRIES)
|
|
45
|
+
break;
|
|
46
|
+
gateCache.delete(id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
gateCache.set(tenantId, { value, expiresAt: Date.now() + GATE_TTL_MS });
|
|
50
|
+
}
|
|
29
51
|
export async function resolveTenantLifecycleGate(db, tenantId) {
|
|
30
52
|
const cached = gateCache.get(tenantId);
|
|
31
53
|
if (cached && cached.expiresAt > Date.now())
|
|
@@ -33,13 +55,13 @@ export async function resolveTenantLifecycleGate(db, tenantId) {
|
|
|
33
55
|
const rows = await selectMany(db, tenantTable, { id: tenantId });
|
|
34
56
|
const row = rows[0];
|
|
35
57
|
if (!row) {
|
|
36
|
-
|
|
58
|
+
cacheGate(tenantId, null);
|
|
37
59
|
return null;
|
|
38
60
|
}
|
|
39
61
|
const gate = {
|
|
40
62
|
status: row.status,
|
|
41
63
|
gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
|
|
42
64
|
};
|
|
43
|
-
|
|
65
|
+
cacheGate(tenantId, gate);
|
|
44
66
|
return gate;
|
|
45
67
|
}
|
package/dist/user/schema/user.js
CHANGED
|
@@ -72,12 +72,13 @@ export const userEntity = createEntity({
|
|
|
72
72
|
// login-critical read stays raw, so verifyPassword would compare a
|
|
73
73
|
// plaintext password against ciphertext and every login would fail once
|
|
74
74
|
// a KMS is active. Already an irreversible one-way hash, access-gated to
|
|
75
|
-
// privileged read/write
|
|
75
|
+
// privileged read/write. Needs a blind-index lookup before this can be
|
|
76
|
+
// subject-encrypted.
|
|
76
77
|
passwordHash: createTextField({
|
|
77
78
|
maxLength: 255,
|
|
78
79
|
access: { read: access.privileged, write: access.privileged },
|
|
79
80
|
personal: false,
|
|
80
|
-
reason: "
|
|
81
|
+
reason: "credential_hash_read_without_decrypt",
|
|
81
82
|
}),
|
|
82
83
|
// Profile — user-editable. Real name in most apps → PII. Searchable via
|
|
83
84
|
// the derived Meili index; events/projection stay ciphertext (#1610).
|
|
@@ -164,7 +165,7 @@ export const userEntity = createEntity({
|
|
|
164
165
|
reason: "technical_reference",
|
|
165
166
|
}),
|
|
166
167
|
},
|
|
167
|
-
//
|
|
168
|
+
// email is `personal: "self", find: "fuzzy"` (lookupable), so the column holds
|
|
168
169
|
// per-row ciphertext: a plain unique index on it can't catch duplicates.
|
|
169
170
|
// `unique: true` over a lookupable column makes buildEntityTable /
|
|
170
171
|
// deriveEntityTableMeta emit a second, partial unique index over the
|
|
@@ -49,10 +49,10 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
|
|
|
49
49
|
schema: z.object({ token: z.string().min(1) }),
|
|
50
50
|
access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
|
|
51
51
|
escapeHatch: {
|
|
52
|
-
reason: "
|
|
52
|
+
reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
|
|
53
53
|
"...) to compute the grace period end — the anonymous token holder has no read access " +
|
|
54
|
-
"to that tenant-config projection.
|
|
55
|
-
"SYSTEM_TENANT_ID user stream.",
|
|
54
|
+
"to that tenant-config projection. startDeletionGracePeriod also appends the user " +
|
|
55
|
+
"lifecycle event on the SYSTEM_TENANT_ID user stream.",
|
|
56
56
|
},
|
|
57
57
|
agent: { expose: false },
|
|
58
58
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
@@ -73,10 +73,12 @@ export async function startDeletionGracePeriod(ctx, userId, resolveGracePeriodSp
|
|
|
73
73
|
const gracePeriodEnd = addDurationSpec(T.Now.instant(), await resolveGracePeriodSpec());
|
|
74
74
|
const { applied } = await updateUserLifecycle(lifecycleRunner, userId, { status: USER_STATUS.DeletionRequested, gracePeriodEnd, pendingDeletionRequestId: null }, { expect: { status: USER_STATUS.Active, ...additionalExpect } });
|
|
75
75
|
if (!applied) {
|
|
76
|
+
// userRow predates the write, so its status is stale for the concurrent loser.
|
|
77
|
+
const currentRow = await ctx.db.global(userTable).fetchOne({ id: userId });
|
|
76
78
|
return {
|
|
77
79
|
ok: false,
|
|
78
80
|
error: new UnprocessableError("user_not_in_active_state", {
|
|
79
|
-
details: { currentStatus: userRow["status"] },
|
|
81
|
+
details: { currentStatus: currentRow?.["status"] ?? userRow["status"] },
|
|
80
82
|
}),
|
|
81
83
|
};
|
|
82
84
|
}
|
|
@@ -21,10 +21,10 @@ export function createRequestDeletionHandler(opts = {}) {
|
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
23
|
escapeHatch: {
|
|
24
|
-
reason: "
|
|
24
|
+
reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
|
|
25
25
|
"...) to compute the grace period end — the calling user's own identity has no read " +
|
|
26
|
-
"access to that tenant-config projection.
|
|
27
|
-
"the SYSTEM_TENANT_ID user stream.",
|
|
26
|
+
"access to that tenant-config projection. startDeletionGracePeriod also appends the " +
|
|
27
|
+
"user lifecycle event on the SYSTEM_TENANT_ID user stream.",
|
|
28
28
|
},
|
|
29
29
|
description: "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
|
|
30
30
|
agent: { risk: "high" },
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
// Oldest first and capped, so a backlog after an outage drains over several
|
|
3
|
+
// ticks instead of holding one tick's `concurrency: "skip"` slot for minutes.
|
|
4
|
+
export const DUE_RUNS_BATCH_SIZE = 100;
|
|
2
5
|
// Dedup happens solely via resume-run's VersionConflictError-checked claim
|
|
3
6
|
// (ctx.tryAppendEvent on WORKFLOW_RESUMED) — a second dispatch for the same
|
|
4
7
|
// row loses that race and no-ops. No row locks: in an autocommit statement
|
|
5
8
|
// they would be released right after the SELECT and guard nothing.
|
|
6
9
|
export async function selectDueWorkflowRunPending(db, tenantId) {
|
|
7
10
|
// kumiko-lint-ignore raw-sql tenant-scoped due-row pickup
|
|
8
|
-
return (await asRawClient(db).unsafe(`SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now()`, [tenantId]));
|
|
11
|
+
return (await asRawClient(db).unsafe(`SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now() ORDER BY wake_at ASC LIMIT ${DUE_RUNS_BATCH_SIZE}`, [tenantId]));
|
|
9
12
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.337.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -483,12 +483,12 @@
|
|
|
483
483
|
}
|
|
484
484
|
},
|
|
485
485
|
"dependencies": {
|
|
486
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
487
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
488
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
489
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
490
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
491
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
486
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.337.1",
|
|
487
|
+
"@cosmicdrift/kumiko-framework": "0.337.1",
|
|
488
|
+
"@cosmicdrift/kumiko-headless": "0.337.1",
|
|
489
|
+
"@cosmicdrift/kumiko-renderer": "0.337.1",
|
|
490
|
+
"@cosmicdrift/kumiko-renderer-web": "0.337.1",
|
|
491
|
+
"@cosmicdrift/kumiko-types": "0.337.1",
|
|
492
492
|
"@mollie/api-client": "^4.5.0",
|
|
493
493
|
"@node-rs/argon2": "^2.0.2",
|
|
494
494
|
"@types/mailparser": "^3.4.6",
|
|
@@ -985,8 +985,8 @@
|
|
|
985
985
|
],
|
|
986
986
|
"devDependencies": {
|
|
987
987
|
"@testing-library/user-event": "^14.6.1",
|
|
988
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
989
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
988
|
+
"@cosmicdrift/kumiko-locale-de": "0.337.1",
|
|
989
|
+
"@cosmicdrift/kumiko-locale-es": "0.337.1",
|
|
990
990
|
"jsqr": "^1.4.0"
|
|
991
991
|
}
|
|
992
992
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.337.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "agent tool name collisions are disambiguated, audit search is substring, encrypted config bounds errors do not echo the value",
|
|
6
|
+
"detail": "The agent manifest reports a detail screen's configured `idParam` and builds its label index once per manifest. Handler tools whose names collide after sanitizing or truncation are no longer dropped; the later one gets a QN-hash suffix. The audit log search matches event types by substring instead of exact equality. Config bounds errors no longer echo an encrypted value. The MFA account label never falls back to the internal user id. The tenant caps list resolves limits in parallel."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "breaking",
|