@cosmicdrift/kumiko-samples 0.342.0 → 0.344.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 +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -1
- package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +35 -7
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +36 -22
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +12 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-locale.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +34 -1
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +14 -5
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +61 -36
- package/packages/bundled-features/src/billing-foundation/index.ts +11 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +25 -7
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +36 -3
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +1 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +16 -6
- package/packages/bundled-features/src/billing-foundation/web/index.ts +12 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -0
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
- package/packages/bundled-features/src/channel-push/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/changes.json +8 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/changes.json +32 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
- package/packages/bundled-features/src/delivery/constants.ts +4 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
- package/packages/bundled-features/src/delivery/events.ts +15 -1
- package/packages/bundled-features/src/delivery/feature.ts +32 -4
- package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
- package/packages/bundled-features/src/delivery/i18n.ts +55 -0
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +43 -20
- package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
- package/packages/bundled-features/src/delivery/public-names.ts +4 -0
- package/packages/bundled-features/src/delivery/tables.ts +3 -0
- package/packages/bundled-features/src/delivery/testing.ts +5 -9
- package/packages/bundled-features/src/delivery/types.ts +6 -2
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
- package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
- package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
- package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
- package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/constants.ts +2 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +8 -3
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +10 -0
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/constants.ts +10 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -0
- package/packages/bundled-features/src/secrets/i18n.ts +5 -3
- package/packages/bundled-features/src/secrets/index.ts +8 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/redact.ts +27 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
- package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
- package/packages/bundled-features/src/user/index.ts +1 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +10 -11
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
import type { NotificationRenderer } from "../delivery/index.js";
|
|
3
|
+
import { renderSafeMarkdown } from "../page-render/index.js";
|
|
3
4
|
|
|
4
5
|
export type MailBranding = {
|
|
5
6
|
/** Shown as text when there is no logo, and as the logo alt text. */
|
|
@@ -141,6 +142,8 @@ function renderBrandingFooter(
|
|
|
141
142
|
|
|
142
143
|
type Section =
|
|
143
144
|
| { readonly text: string }
|
|
145
|
+
| { readonly heading: string }
|
|
146
|
+
| { readonly markdown: string }
|
|
144
147
|
| { readonly button: { readonly label: string; readonly url: string } };
|
|
145
148
|
|
|
146
149
|
type EmailTemplateData = {
|
|
@@ -157,6 +160,13 @@ function renderSection(section: Section, primaryColor: string): string {
|
|
|
157
160
|
if ("text" in section) {
|
|
158
161
|
return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
|
|
159
162
|
}
|
|
163
|
+
if ("heading" in section) {
|
|
164
|
+
return `<h2 style="margin:24px 0 12px;color:#111;font-size:16px;font-weight:600">${escapeHtml(section.heading)}</h2>`;
|
|
165
|
+
}
|
|
166
|
+
if ("markdown" in section) {
|
|
167
|
+
const markdownHtml = renderSafeMarkdown(section.markdown);
|
|
168
|
+
return `<div style="color:#333;font-size:14px;line-height:1.5">${markdownHtml}</div>`;
|
|
169
|
+
}
|
|
160
170
|
if ("button" in section) {
|
|
161
171
|
return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:${escapeHtmlAttr(primaryColor)};color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
|
|
162
172
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.343.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Recognise the invalid-secret-value error",
|
|
6
|
+
"detail": "`secrets` exports `isInvalidSecretValueError`, which recognises the validation error that `secrets:write:set` and `ctx.secrets.set` raise when a value fails the key's `valueSchema`. It also exports `SECRETS_ERROR_KEYS` (the i18n keys of the secrets write errors) and `INVALID_SECRET_VALUE_CODE` (the field error code), so callers can map the failure to their own message instead of matching strings."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.341.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -10,3 +10,13 @@ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
10
10
|
// design, not a per-tenant RBAC surface.
|
|
11
11
|
export const DEFAULT_SECRETS_ROLES = ["TenantAdmin"] as const;
|
|
12
12
|
export const DEFAULT_SECRETS_ACCESS: AccessRule = { roles: DEFAULT_SECRETS_ROLES };
|
|
13
|
+
|
|
14
|
+
// Error i18n keys of the secrets write path. Shared by the write gate, the
|
|
15
|
+
// bundled translations and callers that need to recognise a specific failure.
|
|
16
|
+
export const SECRETS_ERROR_KEYS = {
|
|
17
|
+
unknownKey: "secrets.errors.unknownKey",
|
|
18
|
+
writeDenied: "secrets.errors.writeDenied",
|
|
19
|
+
invalidValue: "secrets.errors.invalidValue",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export const INVALID_SECRET_VALUE_CODE = "invalid_secret_value" as const;
|
|
@@ -32,6 +32,10 @@ const ROTATE_ESCAPE_HATCH_REASON = "re-encrypts every tenant's secrets with the
|
|
|
32
32
|
*/
|
|
33
33
|
export const SECRETS_FEATURE_NAME = "secrets";
|
|
34
34
|
|
|
35
|
+
/** Env keys that carry the KEK keyring. Optional when the app supplies its own
|
|
36
|
+
* `masterKey` provider. */
|
|
37
|
+
export const SECRETS_MASTER_KEK_ENV_KEYS = ["KUMIKO_SECRETS_MASTER_KEY_V1"] as const;
|
|
38
|
+
|
|
35
39
|
export const secretsEnvSchema = z.object({
|
|
36
40
|
KUMIKO_SECRETS_MASTER_KEY_V1: z
|
|
37
41
|
.string()
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { SECRETS_ERROR_KEYS } from "./constants.js";
|
|
2
|
+
|
|
1
3
|
export const SECRETS_I18N = {
|
|
2
|
-
|
|
4
|
+
[SECRETS_ERROR_KEYS.unknownKey]: {
|
|
3
5
|
en: "Unknown secret key.",
|
|
4
6
|
de: "Unbekannter Geheimnis-Schlüssel.",
|
|
5
7
|
},
|
|
6
|
-
|
|
8
|
+
[SECRETS_ERROR_KEYS.writeDenied]: {
|
|
7
9
|
en: "You are not allowed to change this secret.",
|
|
8
10
|
de: "Dieses Geheimnis darf nicht geändert werden.",
|
|
9
11
|
},
|
|
10
|
-
|
|
12
|
+
[SECRETS_ERROR_KEYS.invalidValue]: {
|
|
11
13
|
en: "This value is not valid for this secret.",
|
|
12
14
|
de: "Dieser Wert ist für dieses Geheimnis nicht gültig.",
|
|
13
15
|
},
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_SECRETS_ACCESS,
|
|
3
|
+
DEFAULT_SECRETS_ROLES,
|
|
4
|
+
INVALID_SECRET_VALUE_CODE,
|
|
5
|
+
SECRETS_ERROR_KEYS,
|
|
6
|
+
} from "./constants.js";
|
|
2
7
|
export {
|
|
3
8
|
createSecretsContext,
|
|
4
9
|
createSecretsFeature,
|
|
5
10
|
requireSecretsContext,
|
|
6
11
|
SECRETS_FEATURE_NAME,
|
|
12
|
+
SECRETS_MASTER_KEK_ENV_KEYS,
|
|
7
13
|
type SecretsContext,
|
|
8
14
|
type SecretsContextOptions,
|
|
9
15
|
type SecretsFeatureOptions,
|
|
@@ -21,3 +27,4 @@ export {
|
|
|
21
27
|
rotateJob,
|
|
22
28
|
} from "./handlers/rotate.job.js";
|
|
23
29
|
export { createSetHandler } from "./handlers/set.write.js";
|
|
30
|
+
export { isInvalidSecretValueError } from "./write-gate.js";
|
|
@@ -5,11 +5,13 @@ import type {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import {
|
|
7
7
|
AccessDeniedError,
|
|
8
|
+
isKumikoError,
|
|
8
9
|
NotFoundError,
|
|
9
10
|
ValidationError,
|
|
10
11
|
type WriteFailure,
|
|
11
12
|
writeFailure,
|
|
12
13
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
14
|
+
import { INVALID_SECRET_VALUE_CODE, SECRETS_ERROR_KEYS } from "./constants.js";
|
|
13
15
|
|
|
14
16
|
// Fixed keys carry their definition (redact fn, writeRoles); namespace keys
|
|
15
17
|
// only the namespace, which has no redact fn.
|
|
@@ -21,7 +23,7 @@ function unknownKeyFailure(key: string): SecretKeyWriteCheck {
|
|
|
21
23
|
return {
|
|
22
24
|
ok: false,
|
|
23
25
|
failure: writeFailure(
|
|
24
|
-
new NotFoundError("secretKey", key, { i18nKey:
|
|
26
|
+
new NotFoundError("secretKey", key, { i18nKey: SECRETS_ERROR_KEYS.unknownKey }),
|
|
25
27
|
),
|
|
26
28
|
};
|
|
27
29
|
}
|
|
@@ -53,7 +55,7 @@ export function checkSecretKeyWrite(
|
|
|
53
55
|
failure: writeFailure(
|
|
54
56
|
new AccessDeniedError({
|
|
55
57
|
message: "secret write access denied",
|
|
56
|
-
i18nKey:
|
|
58
|
+
i18nKey: SECRETS_ERROR_KEYS.writeDenied,
|
|
57
59
|
details: { requiredRoles: writeRoles },
|
|
58
60
|
}),
|
|
59
61
|
),
|
|
@@ -72,12 +74,22 @@ export function invalidSecretValueError(): ValidationError {
|
|
|
72
74
|
fields: [
|
|
73
75
|
{
|
|
74
76
|
path: "value",
|
|
75
|
-
code:
|
|
76
|
-
i18nKey:
|
|
77
|
+
code: INVALID_SECRET_VALUE_CODE,
|
|
78
|
+
i18nKey: SECRETS_ERROR_KEYS.invalidValue,
|
|
77
79
|
},
|
|
78
80
|
],
|
|
79
81
|
},
|
|
80
|
-
{ i18nKey:
|
|
82
|
+
{ i18nKey: SECRETS_ERROR_KEYS.invalidValue },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Matches on the i18n key and error code rather than instanceof, which is fragile
|
|
87
|
+
// when the error crosses a package boundary with a duplicated framework copy.
|
|
88
|
+
export function isInvalidSecretValueError(err: unknown): err is ValidationError {
|
|
89
|
+
return (
|
|
90
|
+
isKumikoError(err) &&
|
|
91
|
+
err.code === "validation_error" &&
|
|
92
|
+
err.i18nKey === SECRETS_ERROR_KEYS.invalidValue
|
|
81
93
|
);
|
|
82
94
|
}
|
|
83
95
|
|
|
@@ -25,6 +25,12 @@ export {
|
|
|
25
25
|
parentRowIsVisible,
|
|
26
26
|
} from "./parent-visibility.js";
|
|
27
27
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
|
|
28
|
+
export {
|
|
29
|
+
redactBotTokens,
|
|
30
|
+
redactEmailAddresses,
|
|
31
|
+
redactErrorText,
|
|
32
|
+
redactUrls,
|
|
33
|
+
} from "./redact.js";
|
|
28
34
|
export {
|
|
29
35
|
type RowBoundGrantResult,
|
|
30
36
|
redeemRowBoundGrant,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Adapter errors usually quote the rejected recipient ("550 <ops@example.com> rejected"); the
|
|
2
|
+
// address must not outlive the crypto-shredded dispatch payload in logs.
|
|
3
|
+
export function redactEmailAddresses(text: string): string {
|
|
4
|
+
return text.replace(/[^\s<>,;"']+@[^\s<>,;"']+/g, "[redacted-address]");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Provider URLs carry secrets in the path (Slack hook, Telegram bot token), the query or
|
|
8
|
+
// the userinfo. The origin stays so the log still says which host failed. Any scheme
|
|
9
|
+
// counts: smtp://user:pass@host carries credentials just like https.
|
|
10
|
+
export function redactUrls(text: string): string {
|
|
11
|
+
return text.replace(
|
|
12
|
+
/([a-z][a-z0-9+.-]*:\/\/)([^\s/?#<>"']*)([^\s<>"']*)/gi,
|
|
13
|
+
(_match, scheme: string, authority: string, rest: string) => {
|
|
14
|
+
const host = authority.slice(authority.lastIndexOf("@") + 1);
|
|
15
|
+
return rest.length > 0 && rest !== "/" ? `${scheme}${host}/[redacted]` : `${scheme}${host}`;
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Telegram-style bot tokens outside a URL ("<digits>:<35 chars>"). Times like 12:30 are too short to match.
|
|
21
|
+
export function redactBotTokens(text: string): string {
|
|
22
|
+
return text.replace(/\b\d{6,}:[A-Za-z0-9_-]{30,}\b/g, "[redacted-token]");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function redactErrorText(text: string): string {
|
|
26
|
+
return redactBotTokens(redactEmailAddresses(redactUrls(text)));
|
|
27
|
+
}
|
|
@@ -25,12 +25,8 @@ import {
|
|
|
25
25
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
26
26
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
27
27
|
import * as z from "zod";
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
mailSpecSchema,
|
|
31
|
-
performMailDispatch,
|
|
32
|
-
redactEmailAddresses,
|
|
33
|
-
} from "./mail-runner.js";
|
|
28
|
+
import { redactEmailAddresses } from "../shared/index.js";
|
|
29
|
+
import { type MailSpec, mailSpecSchema, performMailDispatch } from "./mail-runner.js";
|
|
34
30
|
import {
|
|
35
31
|
performWebhookDispatch,
|
|
36
32
|
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
@@ -31,9 +31,3 @@ export function setMailRunner(fn: (spec: MailSpec) => Promise<MailDispatchResult
|
|
|
31
31
|
export async function performMailDispatch(spec: MailSpec): Promise<MailDispatchResult> {
|
|
32
32
|
return mailRunner(spec);
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
// Adapter errors usually quote the rejected recipient ("550 <ops@example.com> rejected"); the
|
|
36
|
-
// address must not outlive the crypto-shredded dispatch payload in logs.
|
|
37
|
-
export function redactEmailAddresses(text: string): string {
|
|
38
|
-
return text.replace(/[^\s<>,;"']+@[^\s<>,;"']+/g, "[redacted-address]");
|
|
39
|
-
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
import type { ReactNode } from "react";
|
|
8
|
-
import { translateOrRaw } from "
|
|
8
|
+
import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
|
|
9
9
|
|
|
10
10
|
export function MemberRolesCell({ row }: ColumnRendererProps): ReactNode {
|
|
11
11
|
const t = useTranslation();
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
|
-
import { translateOrRaw } from "
|
|
10
|
+
import { translateOrRaw } from "../../shared/web/translate-or-raw.js";
|
|
11
11
|
|
|
12
12
|
const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
|
|
13
13
|
active: "ok",
|
|
@@ -34,7 +34,11 @@ import {
|
|
|
34
34
|
} from "./events.js";
|
|
35
35
|
import { cancelDestructionWrite } from "./handlers/cancel-destruction.write.js";
|
|
36
36
|
import { requestDestructionWrite } from "./handlers/request-destruction.write.js";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
hasTenantDestructionWork,
|
|
39
|
+
resolveTenantLifecycleGate,
|
|
40
|
+
runTenantDestructionSweep,
|
|
41
|
+
} from "./run-tenant-destroy.js";
|
|
38
42
|
|
|
39
43
|
const tenantLifecycleStatusPlugin: TenantLifecycleStatusPlugin = {
|
|
40
44
|
async resolveStatus(tenantId, { db }) {
|
|
@@ -125,10 +129,16 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
125
129
|
throw new Error("run-tenant-destruction: ctx.registry required (JobContext incomplete)");
|
|
126
130
|
}
|
|
127
131
|
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
132
|
+
const now = T.Now.instant();
|
|
133
|
+
// Without crossTenantReads (undeclared hatch) fall through to the sweep.
|
|
134
|
+
if (ctx.crossTenantReads && !(await hasTenantDestructionWork(ctx.crossTenantReads, now))) {
|
|
135
|
+
// skip: no tenant is due, so the sweep would not touch anything
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
128
138
|
await runTenantDestructionSweep({
|
|
129
139
|
db: ctx.db.unsafeRaw() as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
130
140
|
registry: ctx.registry,
|
|
131
|
-
now
|
|
141
|
+
now,
|
|
132
142
|
log: (message) => ctx.log?.warn(message),
|
|
133
143
|
fileProviderResolver: ctx._fileProviderResolver,
|
|
134
144
|
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
2
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { DbRunner, TenantDb, WhereObject } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import {
|
|
6
6
|
createSystemUser,
|
|
@@ -307,6 +307,26 @@ export async function runNextDestructionStage(args: {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
function destroyRequestedDueWhere(now: Temporal.Instant): WhereObject {
|
|
311
|
+
return {
|
|
312
|
+
status: "destroyRequested",
|
|
313
|
+
gracePeriodEnd: { lte: now.subtract({ milliseconds: TEARDOWN_GATE_SETTLE_MS }) },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const destroyingWhere: WhereObject = { status: "destroying" };
|
|
318
|
+
|
|
319
|
+
export async function hasTenantDestructionWork(
|
|
320
|
+
reads: Pick<TenantDb, "count">,
|
|
321
|
+
now: Temporal.Instant,
|
|
322
|
+
): Promise<boolean> {
|
|
323
|
+
const [due, destroying] = await Promise.all([
|
|
324
|
+
reads.count(tenantTable, destroyRequestedDueWhere(now)),
|
|
325
|
+
reads.count(tenantTable, destroyingWhere),
|
|
326
|
+
]);
|
|
327
|
+
return due > 0 || destroying > 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
310
330
|
/** Cron entry: start destruction for tenants past grace, then advance stages. */
|
|
311
331
|
export async function runTenantDestructionSweep(args: {
|
|
312
332
|
readonly db: DbRunner;
|
|
@@ -319,10 +339,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
319
339
|
}): Promise<{ readonly triggered: number; readonly advanced: number }> {
|
|
320
340
|
const T = getTemporal();
|
|
321
341
|
const now = args.now ?? T.Now.instant();
|
|
322
|
-
const due = await selectMany<{ id: string }>(args.db, tenantTable,
|
|
323
|
-
status: "destroyRequested",
|
|
324
|
-
gracePeriodEnd: { lte: now.subtract({ milliseconds: TEARDOWN_GATE_SETTLE_MS }) },
|
|
325
|
-
});
|
|
342
|
+
const due = await selectMany<{ id: string }>(args.db, tenantTable, destroyRequestedDueWhere(now));
|
|
326
343
|
|
|
327
344
|
let triggered = 0;
|
|
328
345
|
for (const row of due) {
|
|
@@ -354,9 +371,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
354
371
|
}
|
|
355
372
|
|
|
356
373
|
let advanced = 0;
|
|
357
|
-
const destroying = await selectMany<{ id: string }>(args.db, tenantTable,
|
|
358
|
-
status: "destroying",
|
|
359
|
-
});
|
|
374
|
+
const destroying = await selectMany<{ id: string }>(args.db, tenantTable, destroyingWhere);
|
|
360
375
|
for (const row of destroying) {
|
|
361
376
|
try {
|
|
362
377
|
const result = await runNextDestructionStage({
|
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
} from "./db/queries/stream-tenant-backfill.js";
|
|
7
7
|
export { createUserFeature } from "./feature.js";
|
|
8
8
|
export { isPrincipalBlocked, principalStatusPlugin } from "./principal-status.js";
|
|
9
|
+
export { resolveUserDisplayNames } from "./resolve-display-names.js";
|
|
9
10
|
export type { UserStatus } from "./schema/user.js";
|
|
10
11
|
export {
|
|
11
12
|
USER_ANONYMIZED_DISPLAY_NAME,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { decryptStoredPii, mapWithConcurrency } from "../shared/index.js";
|
|
4
|
+
import { userTable } from "./schema/user.js";
|
|
5
|
+
|
|
6
|
+
// Shares the KMS adapter's small dedicated pool, same as member-directory.
|
|
7
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
8
|
+
|
|
9
|
+
// One batched lookup for a page of user ids. Users that are missing, have no
|
|
10
|
+
// display name, or whose name cannot be decrypted (e.g. crypto-shredded) are
|
|
11
|
+
// left out, so callers fall back to the raw id instead of failing the page.
|
|
12
|
+
export async function resolveUserDisplayNames(
|
|
13
|
+
db: TenantDb,
|
|
14
|
+
userIds: readonly string[],
|
|
15
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
16
|
+
const names = new Map<string, string>();
|
|
17
|
+
if (userIds.length === 0) return names;
|
|
18
|
+
const users = await selectMany(db, userTable, { id: [...userIds] });
|
|
19
|
+
const named = users.filter((user) => typeof user.displayName === "string");
|
|
20
|
+
await mapWithConcurrency(named, KMS_POOL_CONCURRENCY, async (user) => {
|
|
21
|
+
try {
|
|
22
|
+
names.set(
|
|
23
|
+
String(user.id),
|
|
24
|
+
await decryptStoredPii(
|
|
25
|
+
String(user.displayName),
|
|
26
|
+
"displayName",
|
|
27
|
+
"user:resolve-display-names",
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
// Unreadable name: the caller's fallback applies.
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
@@ -69,8 +69,8 @@ export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition
|
|
|
69
69
|
baseUrl,
|
|
70
70
|
catalog: {
|
|
71
71
|
plans: ["starter", "pro"],
|
|
72
|
-
tierLabelKey: (tier) => `gallery
|
|
73
|
-
benefits: (tier) => [{ labelKey: `gallery
|
|
72
|
+
tierLabelKey: (tier) => `gallery-screens:plan.${tier}.label`,
|
|
73
|
+
benefits: (tier) => [{ labelKey: `gallery-screens:plan.${tier}.benefit` }],
|
|
74
74
|
resolveCurrentTier,
|
|
75
75
|
viewRoles: ["TenantAdmin", "SystemAdmin"],
|
|
76
76
|
successPath: GALLERY_PLAN_PATH,
|
|
@@ -83,9 +83,8 @@ export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition
|
|
|
83
83
|
operatorEmail: GALLERY_OPERATOR_EMAIL,
|
|
84
84
|
terminationScope: "platform",
|
|
85
85
|
legalLinks: {
|
|
86
|
-
terms: "/legal/terms",
|
|
87
|
-
withdrawal: "/legal/withdrawal",
|
|
88
|
-
privacy: "/legal/privacy",
|
|
86
|
+
de: { terms: "/legal/terms", withdrawal: "/legal/withdrawal", privacy: "/legal/privacy" },
|
|
87
|
+
en: { terms: "/legal/terms", withdrawal: "/legal/withdrawal", privacy: "/legal/privacy" },
|
|
89
88
|
},
|
|
90
89
|
},
|
|
91
90
|
});
|
|
@@ -140,12 +139,12 @@ export const galleryScreensFeature: FeatureDefinition = defineFeature("gallery-s
|
|
|
140
139
|
r.translations({
|
|
141
140
|
keys: {
|
|
142
141
|
"gallery:nav.billing": { de: "Abrechnung", en: "Billing" },
|
|
143
|
-
"gallery
|
|
144
|
-
"gallery
|
|
145
|
-
"gallery
|
|
146
|
-
"gallery
|
|
147
|
-
"gallery
|
|
148
|
-
"gallery
|
|
142
|
+
"gallery-screens:plan.free.label": { de: "Free", en: "Free" },
|
|
143
|
+
"gallery-screens:plan.starter.label": { de: "Starter", en: "Starter" },
|
|
144
|
+
"gallery-screens:plan.pro.label": { de: "Pro", en: "Pro" },
|
|
145
|
+
"gallery-screens:plan.starter.benefit": { de: "Für kleine Teams", en: "For small teams" },
|
|
146
|
+
"gallery-screens:plan.pro.benefit": { de: "Für wachsende Teams", en: "For growing teams" },
|
|
147
|
+
"gallery-screens:plan.free.benefit": { de: "Zum Ausprobieren", en: "To try things out" },
|
|
149
148
|
},
|
|
150
149
|
});
|
|
151
150
|
return {};
|
|
@@ -42,8 +42,8 @@ const features = [
|
|
|
42
42
|
];
|
|
43
43
|
|
|
44
44
|
const TERMS_TEXT: Readonly<Record<string, string>> = {
|
|
45
|
-
de: "
|
|
46
|
-
en: "
|
|
45
|
+
de: "Diese Bedingungen gelten für alle Verträge über die Plattform.",
|
|
46
|
+
en: "These terms apply to every contract concluded through the platform.",
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
const seedTerms: SeedFn = async (stack) => {
|
|
File without changes
|