@cosmicdrift/kumiko-samples 0.390.1 → 0.391.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/event-classification.ts +5 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +15 -1
- package/packages/bundled-features/src/audit/handlers/details.query.ts +11 -4
- package/packages/bundled-features/src/audit/handlers/list.query.ts +1 -0
- package/packages/bundled-features/src/audit/handlers/resolve-audit-tenant.ts +11 -2
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +3 -0
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/feature.ts +6 -0
- package/packages/bundled-features/src/file-foundation/i18n.ts +13 -0
- package/packages/bundled-features/src/file-foundation/index.ts +4 -0
- package/packages/bundled-features/src/file-foundation/probe.ts +68 -0
- package/packages/bundled-features/src/{user-data-rights/lib → file-foundation}/storage-provider-resolver.ts +2 -2
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe-keys.ts +17 -0
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +109 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +3 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +3 -11
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +16 -0
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +2 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +27 -0
- package/packages/bundled-features/src/mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/{user-data-rights/lib → mail-foundation}/mail-transport-resolver.ts +2 -2
- package/packages/bundled-features/src/mail-foundation/probe.ts +81 -0
- package/packages/bundled-features/src/readiness/built-in-probes.ts +58 -0
- package/packages/bundled-features/src/readiness/changes.json +7 -0
- package/packages/bundled-features/src/readiness/collect-probes.ts +47 -0
- package/packages/bundled-features/src/readiness/constants.ts +25 -0
- package/packages/bundled-features/src/readiness/execute-probes.ts +116 -0
- package/packages/bundled-features/src/readiness/feature.ts +91 -3
- package/packages/bundled-features/src/readiness/handlers/latest-results.query.ts +15 -0
- package/packages/bundled-features/src/readiness/handlers/latest-summary.query.ts +53 -0
- package/packages/bundled-features/src/readiness/handlers/run-detail.query.ts +31 -0
- package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +48 -0
- package/packages/bundled-features/src/readiness/handlers/run-probes.write.ts +98 -0
- package/packages/bundled-features/src/readiness/handlers/run-results.query.ts +18 -0
- package/packages/bundled-features/src/readiness/handlers/run-tone.ts +6 -0
- package/packages/bundled-features/src/readiness/i18n.ts +55 -0
- package/packages/bundled-features/src/readiness/index.ts +1 -1
- package/packages/bundled-features/src/readiness/probe-boot-check.ts +53 -0
- package/packages/bundled-features/src/readiness/probe-context.ts +57 -0
- package/packages/bundled-features/src/readiness/run-probes-job.ts +104 -0
- package/packages/bundled-features/src/readiness/run-view.ts +48 -0
- package/packages/bundled-features/src/readiness/schema/probe-run.ts +89 -0
- package/packages/bundled-features/src/readiness/screens.ts +191 -0
- package/packages/bundled-features/src/readiness/visible-results.ts +106 -0
- package/packages/bundled-features/src/tenant/changes.json +12 -0
- package/packages/bundled-features/src/tenant/membership-stream-history.ts +224 -0
- package/packages/bundled-features/src/tenant/membership-table.ts +3 -3
- package/packages/bundled-features/src/tenant/seeding.ts +8 -13
- package/packages/bundled-features/src/tenant/stream-tenant-backfill.ts +45 -1
- package/packages/bundled-features/src/user-data-rights/feature.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
- package/packages/bundled-features/src/user-profile/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/feature.ts +20 -0
- package/packages/bundled-features/src/user-profile/web/constants.ts +5 -0
- package/packages/bundled-features/src/user-profile/web/i18n.ts +2 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +23 -4
- package/samples/apps/use-all-bundled/kumiko/migrations/0054_readiness-probe-runs.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/src/app/admin-nav-feature.ts +27 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.391.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.391.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The audit log leaves readiness probe runs to SystemAdmins",
|
|
6
|
+
"detail": "A probe run's events carry system-probe and personal-mailbox results. audit:query:list and audit:query:details now leave the probe-run stream out for every caller below SystemAdmin, also when the caller filters by that aggregate type."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -65,6 +65,11 @@ export const AUDIT_EVENT_STANCES: Readonly<Record<string, AuditEventStance>> = {
|
|
|
65
65
|
"feature-toggles:event:toggle-set": label("feature-toggle-set"),
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
// Streams whose payload holds rows that only a SystemAdmin or a single owner may read: a
|
|
69
|
+
// readiness probe run stores system-probe and personal-mailbox results next to the tenant's
|
|
70
|
+
// own. Below SystemAdmin the audit log leaves these streams out entirely.
|
|
71
|
+
export const SYSTEM_ADMIN_ONLY_AGGREGATE_TYPES: readonly string[] = ["probe-run"];
|
|
72
|
+
|
|
68
73
|
// Framework system events (kumiko:system:*) are engine plumbing, never audit-worthy on their own.
|
|
69
74
|
export const FRAMEWORK_SYSTEM_EVENT_TYPES: readonly string[] = [
|
|
70
75
|
...WORKFLOW_SYSTEM_EVENT_DEFS.keys(),
|
|
@@ -92,8 +92,18 @@ export type AuditListFilters = {
|
|
|
92
92
|
cursor?: string | undefined;
|
|
93
93
|
before?: string | undefined;
|
|
94
94
|
includeTechnical?: boolean | undefined;
|
|
95
|
+
hiddenAggregateTypes?: readonly string[] | undefined;
|
|
95
96
|
};
|
|
96
97
|
|
|
98
|
+
/** The aggregateType condition: the requested type, minus the streams hidden from the caller. */
|
|
99
|
+
export function aggregateTypeCondition(
|
|
100
|
+
aggregateType: string | undefined,
|
|
101
|
+
hidden: readonly string[] | undefined,
|
|
102
|
+
): unknown {
|
|
103
|
+
if (hidden === undefined) return aggregateType;
|
|
104
|
+
return { ...(aggregateType !== undefined && { in: [aggregateType] }), notIn: hidden };
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
/** `tenantCondition` is a tenant id, or an operator object for reads across tenants. */
|
|
98
108
|
export function buildAuditWhere(
|
|
99
109
|
tenantCondition: unknown,
|
|
@@ -101,7 +111,11 @@ export function buildAuditWhere(
|
|
|
101
111
|
direction: "asc" | "desc",
|
|
102
112
|
): WhereObject {
|
|
103
113
|
const where: WhereObject = { tenantId: tenantCondition };
|
|
104
|
-
|
|
114
|
+
const aggregateType = aggregateTypeCondition(
|
|
115
|
+
p.aggregateType || undefined,
|
|
116
|
+
p.hiddenAggregateTypes,
|
|
117
|
+
);
|
|
118
|
+
if (aggregateType !== undefined) where["aggregateType"] = aggregateType;
|
|
105
119
|
if (p.aggregateId) where["aggregateId"] = p.aggregateId;
|
|
106
120
|
if (p.eventType) where["type"] = p.eventType;
|
|
107
121
|
else if (p.search || !p.includeTechnical) {
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
import { AUDIT_SCOPE_VALUES } from "../web/constants.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
aggregateTypeCondition,
|
|
11
|
+
auditEventIdSchema,
|
|
12
|
+
createEntityNounKeyResolver,
|
|
13
|
+
loadAuditEvent,
|
|
14
|
+
} from "./audit-events.ts";
|
|
10
15
|
import { requireAuditSystemDb } from "./audit-system-db.ts";
|
|
11
16
|
import { resolveAuditScopeFilter } from "./resolve-audit-tenant.ts";
|
|
12
17
|
|
|
@@ -24,13 +29,15 @@ export const detailsQuery = defineQueryHandler({
|
|
|
24
29
|
const db = requireAuditSystemDb(ctx.systemDb, "audit:query:details").assertTenantMatch(
|
|
25
30
|
query.user.tenantId,
|
|
26
31
|
);
|
|
32
|
+
const aggregateType = aggregateTypeCondition(
|
|
33
|
+
scopeFilter.aggregateType,
|
|
34
|
+
scopeFilter.hiddenAggregateTypes,
|
|
35
|
+
);
|
|
27
36
|
return loadAuditEvent(
|
|
28
37
|
db,
|
|
29
38
|
{
|
|
30
39
|
tenantId: scopeFilter.tenantId,
|
|
31
|
-
...(
|
|
32
|
-
aggregateType: scopeFilter.aggregateType,
|
|
33
|
-
}),
|
|
40
|
+
...(aggregateType !== undefined && { aggregateType }),
|
|
34
41
|
id: BigInt(query.payload.id),
|
|
35
42
|
},
|
|
36
43
|
createEntityNounKeyResolver(ctx.registry),
|
|
@@ -2,9 +2,14 @@ import { mayOverrideTenant, type SessionUser } from "@cosmicdrift/kumiko-framewo
|
|
|
2
2
|
import { AccessDeniedError, FrameworkReasons } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { APP_INSTANCE_STREAM_TYPE } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
5
|
+
import { SYSTEM_ADMIN_ONLY_AGGREGATE_TYPES } from "../event-classification.ts";
|
|
5
6
|
import { type AuditScope, AuditScopes } from "../web/constants.ts";
|
|
6
7
|
|
|
7
|
-
export type AuditScopeFilter = {
|
|
8
|
+
export type AuditScopeFilter = {
|
|
9
|
+
readonly tenantId: string;
|
|
10
|
+
readonly aggregateType?: string;
|
|
11
|
+
readonly hiddenAggregateTypes?: readonly string[];
|
|
12
|
+
};
|
|
8
13
|
|
|
9
14
|
function denySystemAdminOnlyRead(): never {
|
|
10
15
|
throw new AccessDeniedError({
|
|
@@ -19,7 +24,11 @@ export function resolveAuditScopeFilter(
|
|
|
19
24
|
user: SessionUser,
|
|
20
25
|
scope: AuditScope | undefined,
|
|
21
26
|
): AuditScopeFilter {
|
|
22
|
-
if (scope !== AuditScopes.system)
|
|
27
|
+
if (scope !== AuditScopes.system) {
|
|
28
|
+
return mayOverrideTenant(user)
|
|
29
|
+
? { tenantId: user.tenantId }
|
|
30
|
+
: { tenantId: user.tenantId, hiddenAggregateTypes: SYSTEM_ADMIN_ONLY_AGGREGATE_TYPES };
|
|
31
|
+
}
|
|
23
32
|
if (!mayOverrideTenant(user)) denySystemAdminOnlyRead();
|
|
24
33
|
return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
|
|
25
34
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "The disable-MFA form confirms success",
|
|
6
|
+
"detail": "After a successful disable the auth-mfa-disable screen shows \"Two-factor authentication is now turned off.\" and clears the spent code. Before, the form looked unchanged. New key mfa.disable.done in all locale packages."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.389.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -35,6 +35,7 @@ export const AUTH_MFA_FEATURE_I18N: Readonly<Record<string, LocalizedString>> =
|
|
|
35
35
|
|
|
36
36
|
"screen:auth-mfa-disable.title": { en: "Disable two-factor authentication" },
|
|
37
37
|
"mfa.disable.submit": { en: "Disable" },
|
|
38
|
+
"mfa.disable.done": { en: "Two-factor authentication is now turned off." },
|
|
38
39
|
|
|
39
40
|
"screen:auth-mfa-regenerate-recovery.title": { en: "Generate new recovery codes" },
|
|
40
41
|
"mfa.regenerate.submit": { en: "Generate new codes" },
|
|
@@ -86,6 +86,9 @@ export const mfaDisableScreen: ActionFormScreenDefinition = {
|
|
|
86
86
|
layout: { sections: [{ fields: ["code"] }] },
|
|
87
87
|
submitLabel: i18nKey("mfa.disable.submit"),
|
|
88
88
|
submitStyle: "danger",
|
|
89
|
+
successMessage: i18nKey("mfa.disable.done"),
|
|
90
|
+
// The used code is spent; clearing it keeps the form from looking like it still waits.
|
|
91
|
+
resetOnSuccess: true,
|
|
89
92
|
cancelTarget: false,
|
|
90
93
|
access: {
|
|
91
94
|
openToAll: {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "File-storage probe",
|
|
6
|
+
"detail": "file-foundation registers a probe that writes, reads and deletes a probe object in the configured storage provider."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -35,6 +35,9 @@ export {
|
|
|
35
35
|
isFileProviderPlugin,
|
|
36
36
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
37
37
|
|
|
38
|
+
import { FILE_FOUNDATION_I18N } from "./i18n.ts";
|
|
39
|
+
import { registerStorageProbe } from "./probe.ts";
|
|
40
|
+
|
|
38
41
|
const FEATURE_NAME = "file-foundation";
|
|
39
42
|
|
|
40
43
|
export function createFileFoundationFeature() {
|
|
@@ -71,6 +74,9 @@ export function createFileFoundationFeature() {
|
|
|
71
74
|
// while their plugin is the one this key selects.
|
|
72
75
|
r.extensionSelector(EXT_FILE_PROVIDER, providerConfigKey);
|
|
73
76
|
|
|
77
|
+
r.translations({ keys: FILE_FOUNDATION_I18N });
|
|
78
|
+
registerStorageProbe(r);
|
|
79
|
+
|
|
74
80
|
return { providerConfigKey };
|
|
75
81
|
},
|
|
76
82
|
{ dedupeOptions: {} },
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const FILE_PROBE_KEYS = {
|
|
2
|
+
area: "file-foundation.probe.area",
|
|
3
|
+
label: "file-foundation.probe.storage-roundtrip.label",
|
|
4
|
+
reachable: "file-foundation.probe.storage-roundtrip.reachable",
|
|
5
|
+
notConfigured: "file-foundation.probe.not-configured",
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export const FILE_FOUNDATION_I18N = {
|
|
9
|
+
[FILE_PROBE_KEYS.area]: { en: "File storage" },
|
|
10
|
+
[FILE_PROBE_KEYS.label]: { en: "Write, read and delete a test file" },
|
|
11
|
+
[FILE_PROBE_KEYS.reachable]: { en: "Storage reachable" },
|
|
12
|
+
[FILE_PROBE_KEYS.notConfigured]: { en: "No file storage set up" },
|
|
13
|
+
} as const;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSystemUser,
|
|
3
|
+
type FeatureRegistrar,
|
|
4
|
+
FILE_PROVIDER_CONFIG_KEY,
|
|
5
|
+
FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { createConfigAccessor } from "../config/index.ts";
|
|
8
|
+
import { FILE_PROBE_KEYS } from "./i18n.ts";
|
|
9
|
+
import { makeTenantStorageProviderResolver } from "./storage-provider-resolver.ts";
|
|
10
|
+
|
|
11
|
+
const PROBE_CONTENT = new TextEncoder().encode("kumiko-readiness-probe");
|
|
12
|
+
|
|
13
|
+
function isSameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
14
|
+
return left.length === right.length && left.every((byte, index) => byte === right[index]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Bypasses the file-ref entity on purpose: a probe leaves neither a ref nor events behind.
|
|
18
|
+
export function registerStorageProbe(r: FeatureRegistrar<"file-foundation">): void {
|
|
19
|
+
r.probe({
|
|
20
|
+
name: "storage-roundtrip",
|
|
21
|
+
scope: "tenant",
|
|
22
|
+
cost: "free",
|
|
23
|
+
area: FILE_PROBE_KEYS.area,
|
|
24
|
+
label: FILE_PROBE_KEYS.label,
|
|
25
|
+
run: async (ctx) => {
|
|
26
|
+
const { configResolver, secrets } = ctx.services;
|
|
27
|
+
const userId = createSystemUser(ctx.tenantId).id;
|
|
28
|
+
const notConfigured = {
|
|
29
|
+
status: "skipped",
|
|
30
|
+
message: { key: FILE_PROBE_KEYS.notConfigured },
|
|
31
|
+
} as const;
|
|
32
|
+
if (!configResolver) return notConfigured;
|
|
33
|
+
const selected = await createConfigAccessor(
|
|
34
|
+
ctx.registry,
|
|
35
|
+
configResolver,
|
|
36
|
+
ctx.tenantId,
|
|
37
|
+
userId,
|
|
38
|
+
ctx.db,
|
|
39
|
+
)(FILE_PROVIDER_CONFIG_KEY);
|
|
40
|
+
if (
|
|
41
|
+
selected === undefined ||
|
|
42
|
+
selected === "" ||
|
|
43
|
+
selected === FILE_STORAGE_PROVIDER_BOOT_SENTINEL
|
|
44
|
+
) {
|
|
45
|
+
return notConfigured;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const provider = await makeTenantStorageProviderResolver({
|
|
49
|
+
registry: ctx.registry,
|
|
50
|
+
configResolver,
|
|
51
|
+
secrets,
|
|
52
|
+
db: ctx.db,
|
|
53
|
+
userId,
|
|
54
|
+
handlerName: "file-foundation:probe",
|
|
55
|
+
})(ctx.tenantId);
|
|
56
|
+
const key = `${ctx.tenantId}/__readiness-probe/${ctx.probeRunId}.txt`;
|
|
57
|
+
try {
|
|
58
|
+
await provider.write(key, PROBE_CONTENT, "text/plain");
|
|
59
|
+
if (!isSameBytes(await provider.read(key), PROBE_CONTENT)) {
|
|
60
|
+
throw new Error("storage returned different bytes than were written");
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
await provider.delete(key);
|
|
64
|
+
}
|
|
65
|
+
return { status: "ok", message: { key: FILE_PROBE_KEYS.reachable } };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -8,9 +8,9 @@ import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
|
8
8
|
import type { ConfigResolver, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
10
|
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
11
|
+
import { createFileProviderForTenant } from "@cosmicdrift/kumiko-framework/files";
|
|
11
12
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
12
|
-
import { createConfigAccessor } from "
|
|
13
|
-
import { createFileProviderForTenant } from "../../file-foundation/index.ts";
|
|
13
|
+
import { createConfigAccessor } from "../config/index.ts";
|
|
14
14
|
|
|
15
15
|
export interface TenantStorageResolverCtx {
|
|
16
16
|
readonly registry: Registry;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Inbound-mailbox probes",
|
|
6
|
+
"detail": "inbound-mail-foundation registers a probe with one result per configured mailbox. A personal mailbox is probed only when its owner starts the run, and only its owner sees the result."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -80,6 +80,7 @@ import { listAccountsQuery } from "./handlers/list-accounts.query.ts";
|
|
|
80
80
|
import { listMessagesQuery } from "./handlers/list-messages.query.ts";
|
|
81
81
|
import { updateAccountHandler } from "./handlers/update-account.write.ts";
|
|
82
82
|
import { INBOUND_MAIL_FOUNDATION_I18N } from "./i18n.ts";
|
|
83
|
+
import { registerMailboxProbe } from "./probe.ts";
|
|
83
84
|
import {
|
|
84
85
|
applyInboundMessageReceived,
|
|
85
86
|
applyMailAccountConnected,
|
|
@@ -203,6 +204,7 @@ export function createInboundMailFoundationFeature() {
|
|
|
203
204
|
r.screen(mailAccountListScreen);
|
|
204
205
|
r.screen(mailAccountEditScreen);
|
|
205
206
|
r.translations({ keys: INBOUND_MAIL_FOUNDATION_I18N });
|
|
207
|
+
registerMailboxProbe(r);
|
|
206
208
|
|
|
207
209
|
// Entity-specific GDPR destroy hooks — the gdpr-storage boot validator
|
|
208
210
|
// checks the EXT_TENANT_DATA registration for each tenant-subject entity.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
3
3
|
import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.ts";
|
|
4
|
+
import { INBOUND_PROBE_I18N } from "./probe-keys.ts";
|
|
4
5
|
import {
|
|
5
6
|
MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
6
7
|
MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
@@ -11,6 +12,7 @@ const formFieldKey = (field: string): string =>
|
|
|
11
12
|
fieldLabelKey(INBOUND_MAIL_FOUNDATION_FEATURE, ACTION_FORM_ENTITY, field);
|
|
12
13
|
|
|
13
14
|
export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en: string }>> = {
|
|
15
|
+
...INBOUND_PROBE_I18N,
|
|
14
16
|
[`screen:${MAIL_ACCOUNT_LIST_SCREEN_ID}.title`]: { en: "Mailboxes" },
|
|
15
17
|
[`screen:${MAIL_ACCOUNT_EDIT_SCREEN_ID}.title`]: { en: "Edit mailbox" },
|
|
16
18
|
"inboundMail.accounts.col.name": { en: "Name" },
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const INBOUND_PROBE_KEYS = {
|
|
2
|
+
area: "inbound-mail-foundation.probe.area",
|
|
3
|
+
label: "inbound-mail-foundation.probe.mailboxes.label",
|
|
4
|
+
mailbox: "inbound-mail-foundation.probe.mailbox",
|
|
5
|
+
reachable: "inbound-mail-foundation.probe.mailbox.reachable",
|
|
6
|
+
authFailed: "inbound-mail-foundation.probe.mailbox.auth-failed",
|
|
7
|
+
gone: "inbound-mail-foundation.probe.mailbox.gone",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const INBOUND_PROBE_I18N = {
|
|
11
|
+
[INBOUND_PROBE_KEYS.area]: { en: "Inbound email" },
|
|
12
|
+
[INBOUND_PROBE_KEYS.label]: { en: "Connected mailboxes" },
|
|
13
|
+
[INBOUND_PROBE_KEYS.mailbox]: { en: "Mailbox {name}" },
|
|
14
|
+
[INBOUND_PROBE_KEYS.reachable]: { en: "Mailbox reachable" },
|
|
15
|
+
[INBOUND_PROBE_KEYS.authFailed]: { en: "The mailbox rejected the stored login" },
|
|
16
|
+
[INBOUND_PROBE_KEYS.gone]: { en: "Mailbox no longer exists" },
|
|
17
|
+
} as const;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configuredPiiSubjectKms,
|
|
3
|
+
decryptPiiFieldValues,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
5
|
+
import {
|
|
6
|
+
createSystemUser,
|
|
7
|
+
type FeatureRegistrar,
|
|
8
|
+
type ProbeContext,
|
|
9
|
+
ProbeFailure,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { createConfigAccessor } from "../config/index.ts";
|
|
12
|
+
import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.ts";
|
|
13
|
+
import { MAIL_ACCOUNT_PII_FIELDS } from "./entities.ts";
|
|
14
|
+
import { INBOUND_PROBE_KEYS } from "./probe-keys.ts";
|
|
15
|
+
import { mailAccountsProjectionTable } from "./projection.ts";
|
|
16
|
+
import { resolveInboundProviderForAccount } from "./provider-factory.ts";
|
|
17
|
+
import { type InboundMailContext, isInboundAuthError, type MailAccountRecord } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
const SHORT_ID_LENGTH = 8;
|
|
20
|
+
|
|
21
|
+
type AccountRow = {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly tenantId: string;
|
|
24
|
+
readonly provider: string;
|
|
25
|
+
readonly authMethod: string;
|
|
26
|
+
readonly ownerUserId?: string | null;
|
|
27
|
+
readonly address: string;
|
|
28
|
+
readonly displayName?: string | null;
|
|
29
|
+
readonly watchState?: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function providerContext(ctx: ProbeContext): InboundMailContext {
|
|
33
|
+
const { configResolver, secrets } = ctx.services;
|
|
34
|
+
const auditUserId = createSystemUser(ctx.tenantId).id;
|
|
35
|
+
return {
|
|
36
|
+
registry: ctx.registry,
|
|
37
|
+
...(secrets && { secrets }),
|
|
38
|
+
auditUserId,
|
|
39
|
+
...(configResolver && {
|
|
40
|
+
config: createConfigAccessor(ctx.registry, configResolver, ctx.tenantId, auditUserId, ctx.db),
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Providers never see ciphertext, so the address is decrypted before the call.
|
|
46
|
+
async function toAccountRecord(row: AccountRow, tenantId: string): Promise<MailAccountRecord> {
|
|
47
|
+
const piiKms = configuredPiiSubjectKms();
|
|
48
|
+
const decrypted = piiKms
|
|
49
|
+
? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
|
|
50
|
+
requestId: "inbound-mail-foundation:probe",
|
|
51
|
+
})
|
|
52
|
+
: row;
|
|
53
|
+
const address = decrypted["address"];
|
|
54
|
+
if (typeof address !== "string") throw new Error("mailbox address did not decrypt to text");
|
|
55
|
+
return {
|
|
56
|
+
id: row.id,
|
|
57
|
+
tenantId,
|
|
58
|
+
provider: row.provider,
|
|
59
|
+
authMethod: row.authMethod,
|
|
60
|
+
ownerUserId: row.ownerUserId ?? null,
|
|
61
|
+
address,
|
|
62
|
+
displayName: row.displayName ?? "",
|
|
63
|
+
status: InboundMailAccountStatuses.active,
|
|
64
|
+
watchState: row.watchState ?? "idle",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function registerMailboxProbe(r: FeatureRegistrar<"inbound-mail-foundation">): void {
|
|
69
|
+
r.probe({
|
|
70
|
+
name: "mailboxes",
|
|
71
|
+
scope: "tenant",
|
|
72
|
+
cost: "free",
|
|
73
|
+
area: INBOUND_PROBE_KEYS.area,
|
|
74
|
+
label: INBOUND_PROBE_KEYS.label,
|
|
75
|
+
fixScreen: `${INBOUND_MAIL_FOUNDATION_FEATURE}:screen:mail-accounts`,
|
|
76
|
+
// The address is PII, so the target label carries only the tenant-authored display name.
|
|
77
|
+
targets: async (ctx) => {
|
|
78
|
+
const rows = await ctx.db.selectMany<AccountRow>(mailAccountsProjectionTable, {
|
|
79
|
+
status: InboundMailAccountStatuses.active,
|
|
80
|
+
});
|
|
81
|
+
return rows.map((row) => ({
|
|
82
|
+
id: row.id,
|
|
83
|
+
label: {
|
|
84
|
+
key: INBOUND_PROBE_KEYS.mailbox,
|
|
85
|
+
params: { name: row.displayName || row.id.slice(0, SHORT_ID_LENGTH) },
|
|
86
|
+
},
|
|
87
|
+
...(row.ownerUserId != null && { ownerUserId: row.ownerUserId }),
|
|
88
|
+
}));
|
|
89
|
+
},
|
|
90
|
+
run: async (ctx, target) => {
|
|
91
|
+
const row = target
|
|
92
|
+
? await ctx.db.fetchOne<AccountRow>(mailAccountsProjectionTable, { id: target.id })
|
|
93
|
+
: undefined;
|
|
94
|
+
if (!row) return { status: "skipped", message: { key: INBOUND_PROBE_KEYS.gone } };
|
|
95
|
+
const account = await toAccountRecord(row, ctx.tenantId);
|
|
96
|
+
const providerCtx = providerContext(ctx);
|
|
97
|
+
const plugin = resolveInboundProviderForAccount(providerCtx, account);
|
|
98
|
+
try {
|
|
99
|
+
await (plugin.probe ?? plugin.verify)(providerCtx, account);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isInboundAuthError(error)) {
|
|
102
|
+
throw new ProbeFailure({ key: INBOUND_PROBE_KEYS.authFailed });
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
return { status: "ok", message: { key: INBOUND_PROBE_KEYS.reachable } };
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -159,6 +159,9 @@ export type InboundMailProviderPlugin = {
|
|
|
159
159
|
/** Credential/reachability check during the connect flow — throws a
|
|
160
160
|
* typed error (InboundAuthError, etc.) and returns no value. */
|
|
161
161
|
readonly verify: (ctx: InboundMailContext, account: MailAccountRecord) => Promise<void>;
|
|
162
|
+
/** Read-only liveness check for the system check; falls back to `verify`. Must not
|
|
163
|
+
* change mailbox state (no flags, no new connection side effects). */
|
|
164
|
+
readonly probe?: (ctx: InboundMailContext, account: MailAccountRecord) => Promise<void>;
|
|
162
165
|
/** Fetch since cursor. `cursor === null` means initial population,
|
|
163
166
|
* limited by backfillWindowDays. */
|
|
164
167
|
readonly fetch: (
|
|
@@ -43,6 +43,7 @@ import { type ImapCredentialDocument, parseImapCredentialDocument } from "./cred
|
|
|
43
43
|
import {
|
|
44
44
|
assertUidValidity,
|
|
45
45
|
coerceDate,
|
|
46
|
+
connectOnce,
|
|
46
47
|
createImapClient,
|
|
47
48
|
IMAP_MAILBOX,
|
|
48
49
|
imapMailHostGuard,
|
|
@@ -259,17 +260,8 @@ async function watchMailbox(
|
|
|
259
260
|
/** Exported for the live test (imap-live.integration.test.ts) —
|
|
260
261
|
* applications use the feature, never the plugin directly. */
|
|
261
262
|
export const imapInboundMailPlugin: InboundMailProviderPlugin = {
|
|
262
|
-
verify: async (ctx, account) =>
|
|
263
|
-
|
|
264
|
-
const client = await createImapClient(doc, imapMailHostGuard());
|
|
265
|
-
try {
|
|
266
|
-
await client.connect();
|
|
267
|
-
} catch (err) {
|
|
268
|
-
throw mapImapError(err, doc.host);
|
|
269
|
-
} finally {
|
|
270
|
-
await client.logout().catch(() => {});
|
|
271
|
-
}
|
|
272
|
-
},
|
|
263
|
+
verify: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), false),
|
|
264
|
+
probe: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), true),
|
|
273
265
|
fetch: fetchMessages,
|
|
274
266
|
watch: watchMailbox,
|
|
275
267
|
// No oauth block: password authentication runs through the credential
|
|
@@ -94,6 +94,22 @@ export async function createImapClient(
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/** Server-Fehler → typisierte Foundation-Fehlerklassen (Plan §2). */
|
|
97
|
+
// examineInbox opens INBOX with EXAMINE instead of SELECT, so the system check never touches \Recent or flags.
|
|
98
|
+
export async function connectOnce(
|
|
99
|
+
doc: ImapCredentialDocument,
|
|
100
|
+
examineInbox: boolean,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const client = await createImapClient(doc, imapMailHostGuard());
|
|
103
|
+
try {
|
|
104
|
+
await client.connect();
|
|
105
|
+
if (examineInbox) await client.mailboxOpen(IMAP_MAILBOX, { readOnly: true });
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw mapImapError(err, doc.host);
|
|
108
|
+
} finally {
|
|
109
|
+
await client.logout().catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
export function mapImapError(err: unknown, host: string): Error {
|
|
98
114
|
// imapflow errors carry the server response in responseText; the
|
|
99
115
|
// message is only "Command failed", so both are included in the match;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Mail-sending probe",
|
|
6
|
+
"detail": "mail-foundation registers a probe that sends a short test mail through the tenant's mail transport to the user who started the check. The address is not stored in the result. A failed row names the reason (server not found, settings incomplete, mail not accepted) and links to the email sending settings; the raw error stays in the server log."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.388.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
type TenantId,
|
|
46
46
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
47
47
|
import { MAIL_FOUNDATION_I18N } from "./i18n.ts";
|
|
48
|
+
import { registerMailProbe } from "./probe.ts";
|
|
48
49
|
|
|
49
50
|
const FEATURE_NAME = "mail-foundation";
|
|
50
51
|
|
|
@@ -173,6 +174,7 @@ export function createMailFoundationFeature() {
|
|
|
173
174
|
r.extensionSelector(MAIL_TRANSPORT_EXTENSION, providerConfigKey);
|
|
174
175
|
|
|
175
176
|
r.translations({ keys: MAIL_FOUNDATION_I18N });
|
|
177
|
+
registerMailProbe(r);
|
|
176
178
|
|
|
177
179
|
return {
|
|
178
180
|
/** Config-key-handle for the provider-selector. */
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
export const MAIL_PROBE_KEYS = {
|
|
2
|
+
area: "mail-foundation.probe.area",
|
|
3
|
+
label: "mail-foundation.probe.send-test-mail.label",
|
|
4
|
+
accepted: "mail-foundation.probe.send-test-mail.accepted",
|
|
5
|
+
notConfigured: "mail-foundation.probe.not-configured",
|
|
6
|
+
noRecipient: "mail-foundation.probe.no-recipient",
|
|
7
|
+
hostNotFound: "mail-foundation.probe.host-not-found",
|
|
8
|
+
setupIncomplete: "mail-foundation.probe.setup-incomplete",
|
|
9
|
+
setupFailed: "mail-foundation.probe.setup-failed",
|
|
10
|
+
sendRejected: "mail-foundation.probe.send-rejected",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
1
13
|
export const MAIL_FOUNDATION_I18N = {
|
|
2
14
|
"screen:mail-foundation-tenant.title": { en: "Email sending" },
|
|
3
15
|
"mail-foundation.settings": { en: "Email sending" },
|
|
@@ -6,4 +18,19 @@ export const MAIL_FOUNDATION_I18N = {
|
|
|
6
18
|
en: "Choose who sends your emails. The platform default needs no settings of your own.",
|
|
7
19
|
},
|
|
8
20
|
"mail-foundation.provider.platformDefault": { en: "Platform sending (default)" },
|
|
21
|
+
[MAIL_PROBE_KEYS.area]: { en: "Email" },
|
|
22
|
+
[MAIL_PROBE_KEYS.label]: { en: "Test email to the person who started the check" },
|
|
23
|
+
[MAIL_PROBE_KEYS.accepted]: { en: "Accepted by the mail server" },
|
|
24
|
+
[MAIL_PROBE_KEYS.notConfigured]: { en: "No own email sending set up" },
|
|
25
|
+
[MAIL_PROBE_KEYS.noRecipient]: {
|
|
26
|
+
en: "No email address known for the person who started the check",
|
|
27
|
+
},
|
|
28
|
+
[MAIL_PROBE_KEYS.hostNotFound]: { en: "Mail server not found, check the server address" },
|
|
29
|
+
[MAIL_PROBE_KEYS.setupIncomplete]: {
|
|
30
|
+
en: "Email sending settings are incomplete or the server is not allowed",
|
|
31
|
+
},
|
|
32
|
+
[MAIL_PROBE_KEYS.setupFailed]: { en: "Email sending could not be set up with these settings" },
|
|
33
|
+
[MAIL_PROBE_KEYS.sendRejected]: {
|
|
34
|
+
en: "The mail server did not accept the test email, check address, port and login",
|
|
35
|
+
},
|
|
9
36
|
} as const;
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
// in the worker lane (the prod-bug class the file-provider resolver already fixed).
|
|
8
8
|
|
|
9
9
|
import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
10
|
-
import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
11
10
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
12
11
|
import type { ConfigResolver, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
12
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
14
|
-
import { createConfigAccessor } from "
|
|
13
|
+
import { createConfigAccessor } from "../config/index.ts";
|
|
14
|
+
import { createTransportForTenant } from "./feature.ts";
|
|
15
15
|
|
|
16
16
|
export interface TenantMailResolverCtx {
|
|
17
17
|
readonly registry: Registry;
|