@cosmicdrift/kumiko-bundled-features 0.98.0 → 0.102.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 +7 -6
- package/src/auth-email-password/auth-mailer.ts +28 -12
- package/src/auth-email-password/index.ts +3 -0
- package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
- package/src/channel-email/email-channel.ts +32 -20
- package/src/channel-email/feature.ts +2 -0
- package/src/channel-email/index.ts +6 -1
- package/src/channel-email/smtp-transport.ts +37 -0
- package/src/channel-in-app/feature.ts +1 -0
- package/src/channel-in-app/in-app-channel.ts +1 -0
- package/src/channel-push/feature.ts +1 -0
- package/src/channel-push/push-channel.ts +1 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
- package/src/delivery/attempt-log.ts +54 -0
- package/src/delivery/channel-context.ts +17 -0
- package/src/delivery/constants.ts +35 -1
- package/src/delivery/delivery-service.ts +118 -47
- package/src/delivery/events.ts +7 -1
- package/src/delivery/feature.ts +38 -14
- package/src/delivery/index.ts +3 -0
- package/src/delivery/jobs.ts +153 -0
- package/src/delivery/tables.ts +5 -1
- package/src/delivery/types.ts +25 -2
- package/src/file-foundation/feature.ts +28 -141
- package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
- package/src/presets/dsgvo-self-service.ts +30 -0
- package/src/presets/index.ts +1 -0
- package/src/secrets/feature.ts +3 -1
- package/src/secrets/index.ts +1 -0
- package/src/tags/constants.ts +6 -0
- package/src/tags/index.ts +1 -0
- package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
- package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
- package/src/tags/web/client-plugin.tsx +6 -0
- package/src/tags/web/i18n.ts +2 -2
- package/src/tags/web/index.ts +2 -0
- package/src/tags/web/tag-filter.tsx +24 -12
- package/src/tags/web/tags-cell.tsx +46 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
- package/src/text-content/seeding.ts +35 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
package/src/delivery/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type {
|
|
4
4
|
NotifyOptions,
|
|
5
|
+
NotifyPriority,
|
|
5
6
|
Registry,
|
|
6
7
|
SessionUser,
|
|
7
8
|
TenantId,
|
|
@@ -29,10 +30,31 @@ export type ChannelResult = {
|
|
|
29
30
|
readonly address?: string;
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
// Output of a channel's render step, passed into send(). Only channels that
|
|
34
|
+
// declare render() (email) produce one; inline channels (inApp) and channels
|
|
35
|
+
// without an expensive render step (push) receive `undefined`.
|
|
36
|
+
export type RenderedMessage = {
|
|
37
|
+
readonly html: string;
|
|
38
|
+
readonly subject: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// `mode` decides how the delivery-service dispatches a channel:
|
|
42
|
+
// inline — sent synchronously inside notify() (inApp: DB insert + SSE).
|
|
43
|
+
// queued — sent asynchronously via the delivery.send job; channels with a
|
|
44
|
+
// render() additionally run through delivery.render first.
|
|
45
|
+
export type DeliveryChannelMode = "inline" | "queued";
|
|
46
|
+
|
|
32
47
|
export type DeliveryChannel = {
|
|
33
48
|
readonly name: string;
|
|
49
|
+
readonly mode: DeliveryChannelMode;
|
|
34
50
|
resolve(userId: string, ctx: ChannelContext): Promise<string | null>;
|
|
35
|
-
|
|
51
|
+
render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
|
|
52
|
+
send(
|
|
53
|
+
address: string,
|
|
54
|
+
message: ChannelMessage,
|
|
55
|
+
ctx: ChannelContext,
|
|
56
|
+
rendered?: RenderedMessage,
|
|
57
|
+
): Promise<ChannelResult>;
|
|
36
58
|
};
|
|
37
59
|
|
|
38
60
|
// --- Notification Renderer ---
|
|
@@ -55,8 +77,9 @@ export type DeliveryLogEntry = {
|
|
|
55
77
|
readonly channel: string;
|
|
56
78
|
readonly recipientId: string | null;
|
|
57
79
|
readonly recipientAddress: string | null;
|
|
58
|
-
readonly status: "sent" | "failed" | "skipped";
|
|
80
|
+
readonly status: "queued" | "sent" | "failed" | "skipped";
|
|
59
81
|
readonly error: string | null;
|
|
82
|
+
readonly priority: NotifyPriority;
|
|
60
83
|
};
|
|
61
84
|
|
|
62
85
|
// --- Delivery Service ---
|
|
@@ -1,103 +1,44 @@
|
|
|
1
1
|
// kumiko-feature-version: 1
|
|
2
2
|
//
|
|
3
|
-
// file-foundation as a Kumiko bundled feature —
|
|
3
|
+
// file-foundation as a Kumiko bundled feature — declares the `fileProvider`
|
|
4
|
+
// extension point + the per-tenant `provider` config key. Provider RESOLUTION
|
|
5
|
+
// (createFileProviderForTenant) + the plugin types now live in the framework
|
|
6
|
+
// (packages/framework/src/files/provider-resolver.ts) so the upload routes,
|
|
7
|
+
// `ctx.files` and the GDPR jobs resolve through ONE path — uploads, export and
|
|
8
|
+
// erasure hit the same store by construction. This feature re-exports the moved
|
|
9
|
+
// symbols unchanged so existing imports keep working.
|
|
4
10
|
//
|
|
5
|
-
// **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// key `provider`.
|
|
11
|
+
// **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation deklariert
|
|
12
|
+
// extension-point `fileProvider`, Provider-Features (file-provider-s3, -inmemory,
|
|
13
|
+
// -s3-env, später -gcs/-azure-blob) registrieren sich namentlich. Tenant wählt
|
|
14
|
+
// zur Runtime via config-key `provider`.
|
|
10
15
|
//
|
|
11
|
-
// **
|
|
12
|
-
//
|
|
13
|
-
// forcePathStyle/accessKeyId) — die leben im Provider-Plugin.
|
|
14
|
-
// - Kein direkter Import von `createS3Provider`. Foundation kennt
|
|
15
|
-
// nur das `FileStorageProvider`-Interface (Type-Import, kein
|
|
16
|
-
// runtime-coupling).
|
|
17
|
-
//
|
|
18
|
-
// **Standalone:** Foundation ist ohne tier-engine nutzbar. Existing
|
|
19
|
-
// `files-provider-s3` (App-wide-Library) bleibt unangetastet.
|
|
20
|
-
//
|
|
21
|
-
// **Boot-Dependencies:** config (für provider-selector). Kein secrets,
|
|
22
|
-
// weil Foundation selbst keine Secrets hält.
|
|
16
|
+
// **Boot-Dependencies:** config (für provider-selector). Kein secrets, weil
|
|
17
|
+
// Foundation selbst keine Secrets hält.
|
|
23
18
|
|
|
24
|
-
import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
25
19
|
import {
|
|
26
20
|
access,
|
|
27
|
-
type ConfigAccessor,
|
|
28
21
|
createTenantConfig,
|
|
29
22
|
defineFeature,
|
|
30
|
-
|
|
23
|
+
EXT_FILE_PROVIDER,
|
|
31
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
-
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
33
|
-
|
|
34
|
-
const FEATURE_NAME = "file-foundation";
|
|
35
|
-
|
|
36
|
-
// =============================================================================
|
|
37
|
-
// Plugin-Interface — what a Provider-Plugin must implement
|
|
38
|
-
// =============================================================================
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu
|
|
42
|
-
* fett (haelt tx, actor, signal etc.) — Provider sollen sich auf die
|
|
43
|
-
* read-Felder beschraenken die fuer Tenant-Config + Secret-Lookup
|
|
44
|
-
* gebraucht werden.
|
|
45
|
-
*
|
|
46
|
-
* **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt
|
|
47
|
-
* es keinen request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider
|
|
48
|
-
* `ctx.tx` lesen wuerde, wuerde der ganze Worker-Pfad zur Runtime
|
|
49
|
-
* brechen — und das wuerde NUR mit S3 und nur in production auffallen.
|
|
50
|
-
* Die schmale Surface zwingt Provider zur expliziten Erweiterung
|
|
51
|
-
* (extra-arg) statt silent ctx-feld-ausnutzen.
|
|
52
|
-
*
|
|
53
|
-
* **Felder:**
|
|
54
|
-
* config — fuer tenant-config-reads (bucket/region/endpoint/...)
|
|
55
|
-
* registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
|
|
56
|
-
* secrets — fuer tenant-secret-reads (s3.secretAccessKey)
|
|
57
|
-
* _userId — Audit-Identity fuer secret-reads. Im Handler-Pfad setzt
|
|
58
|
-
* der dispatcher das auf die Caller-User-ID; im Worker-Pfad
|
|
59
|
-
* muss der r.job-Wrap das explizit auf eine System-Identity
|
|
60
|
-
* setzen (z.B. "system:user-data-rights:run-export-jobs").
|
|
61
|
-
*/
|
|
62
|
-
export type FileProviderContext = {
|
|
63
|
-
readonly config?: ConfigAccessor;
|
|
64
|
-
readonly registry?: Registry;
|
|
65
|
-
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
66
|
-
readonly _userId?: string | undefined;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
|
|
71
|
-
* file-provider-azure-blob, ...) registers an implementation via
|
|
72
|
-
* `r.useExtension("fileProvider", "<name>", { build })`.
|
|
73
|
-
*
|
|
74
|
-
* **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext,
|
|
75
|
-
* nicht ein voller HandlerContext. Felder ausserhalb der schmalen
|
|
76
|
-
* Surface (z.B. `ctx.tx`, `ctx.actor`, `ctx.signal`, `ctx.notify`) sind
|
|
77
|
-
* im Worker-Pfad (r.job-getriggerte Provider-Builds) NICHT vorhanden.
|
|
78
|
-
* Cast `ctx as unknown as HandlerContext` macht den Compiler happy aber
|
|
79
|
-
* fliegt zur Runtime im Worker — und der Crash kommt erst in production
|
|
80
|
-
* mit dem ersten S3-Tenant. Wenn ein Plugin Felder braucht die nicht in
|
|
81
|
-
* FileProviderContext sind: lieber FileProviderContext explizit erweitern
|
|
82
|
-
* (sichtbarer breaking change) als ctx-cast.
|
|
83
|
-
*/
|
|
84
|
-
export type FileProviderPlugin = {
|
|
85
|
-
readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
|
|
86
|
-
};
|
|
87
25
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
26
|
+
export type {
|
|
27
|
+
FileProviderContext,
|
|
28
|
+
FileProviderPlugin,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
30
|
+
// Moved into the framework — re-exported here so `@cosmicdrift/kumiko-bundled-
|
|
31
|
+
// features/file-foundation` consumers (user-data-rights, app code) keep working.
|
|
32
|
+
export {
|
|
33
|
+
createFileProviderForTenant,
|
|
34
|
+
isFileProviderPlugin,
|
|
35
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
93
36
|
|
|
94
|
-
|
|
95
|
-
// Feature-definition
|
|
96
|
-
// =============================================================================
|
|
37
|
+
const FEATURE_NAME = "file-foundation";
|
|
97
38
|
|
|
98
39
|
export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
99
40
|
r.describe(
|
|
100
|
-
"Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider`
|
|
41
|
+
"Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider` — use this feature together with at least one `file-provider-*` feature; the `files` feature builds on top of it for tracked `FileRef` entities with GDPR hooks.",
|
|
101
42
|
);
|
|
102
43
|
r.uiHints({
|
|
103
44
|
displayLabel: "File Provider Foundation",
|
|
@@ -106,7 +47,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
106
47
|
});
|
|
107
48
|
r.requires("config");
|
|
108
49
|
|
|
109
|
-
r.extendsRegistrar(
|
|
50
|
+
r.extendsRegistrar(EXT_FILE_PROVIDER, {
|
|
110
51
|
onRegister: () => {
|
|
111
52
|
// No side-effects at register-time — registry stores the usage,
|
|
112
53
|
// factory looks it up at request-time.
|
|
@@ -124,61 +65,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
124
65
|
});
|
|
125
66
|
// Readiness gating: provider-plugins' required keys/secrets count only
|
|
126
67
|
// while their plugin is the one this key selects.
|
|
127
|
-
r.extensionSelector(
|
|
68
|
+
r.extensionSelector(EXT_FILE_PROVIDER, configKeys.provider);
|
|
128
69
|
|
|
129
70
|
return { configKeys };
|
|
130
71
|
});
|
|
131
|
-
|
|
132
|
-
// =============================================================================
|
|
133
|
-
// Provider-factory — looks up the registered plugin + delegates
|
|
134
|
-
// =============================================================================
|
|
135
|
-
|
|
136
|
-
export async function createFileProviderForTenant(
|
|
137
|
-
ctx: FileProviderContext,
|
|
138
|
-
tenantId: string,
|
|
139
|
-
handlerName = "file-foundation:provider-factory",
|
|
140
|
-
): Promise<FileStorageProvider> {
|
|
141
|
-
const ctxConfig = ctx.config;
|
|
142
|
-
if (!ctxConfig) {
|
|
143
|
-
throw new Error(
|
|
144
|
-
`${handlerName}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
if (!ctx.registry) {
|
|
148
|
-
throw new Error(
|
|
149
|
-
`${handlerName}: ctx.registry is missing — required to look up registered file-provider plugins`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const provider = requireDefined(
|
|
154
|
-
await ctxConfig(fileFoundationFeature.exports.configKeys.provider),
|
|
155
|
-
FEATURE_NAME,
|
|
156
|
-
"provider",
|
|
157
|
-
) as string; // @cast-boundary engine-payload
|
|
158
|
-
if (provider.length === 0) {
|
|
159
|
-
const usages = ctx.registry.getExtensionUsages("fileProvider");
|
|
160
|
-
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
161
|
-
throw new Error(
|
|
162
|
-
`${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
|
|
163
|
-
`Mount a file-provider-* feature first if no plugins are registered.`,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const usages = ctx.registry.getExtensionUsages("fileProvider");
|
|
168
|
-
const usage = usages.find((u) => u.entityName === provider);
|
|
169
|
-
if (!usage) {
|
|
170
|
-
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
171
|
-
throw new Error(
|
|
172
|
-
`${FEATURE_NAME}: provider "${provider}" not registered. Known: ${known}. ` +
|
|
173
|
-
`Mount the matching file-provider-${provider} feature.`,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (!isFileProviderPlugin(usage.options)) {
|
|
178
|
-
throw new Error(
|
|
179
|
-
`${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
|
|
180
|
-
`extension options must be a FileProviderPlugin.`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
return usage.options.build(ctx, tenantId);
|
|
184
|
-
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { dsgvoSelfServiceFeatures } from "../dsgvo-self-service";
|
|
3
|
+
|
|
4
|
+
// Pins the DSGVO/self-service bundle: the five features in dependency order
|
|
5
|
+
// (user-data-rights requires data-retention + compliance-profiles + sessions;
|
|
6
|
+
// user-profile requires user-data-rights). Order is load-bearing, so it is
|
|
7
|
+
// asserted explicitly.
|
|
8
|
+
|
|
9
|
+
describe("dsgvoSelfServiceFeatures", () => {
|
|
10
|
+
test("returns the five features in require-order", () => {
|
|
11
|
+
const names = dsgvoSelfServiceFeatures().map((f) => f.name);
|
|
12
|
+
expect(names).toEqual([
|
|
13
|
+
"data-retention",
|
|
14
|
+
"compliance-profiles",
|
|
15
|
+
"sessions",
|
|
16
|
+
"user-data-rights",
|
|
17
|
+
"user-profile",
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("data-retention/compliance-profiles precede user-data-rights (dependency order holds)", () => {
|
|
22
|
+
const names = dsgvoSelfServiceFeatures().map((f) => f.name);
|
|
23
|
+
const udr = names.indexOf("user-data-rights");
|
|
24
|
+
expect(names.indexOf("data-retention")).toBeLessThan(udr);
|
|
25
|
+
expect(names.indexOf("compliance-profiles")).toBeLessThan(udr);
|
|
26
|
+
expect(names.indexOf("sessions")).toBeLessThan(udr);
|
|
27
|
+
expect(names.indexOf("user-profile")).toBeGreaterThan(udr);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("each call yields fresh feature instances (no shared mutable state)", () => {
|
|
31
|
+
const a = dsgvoSelfServiceFeatures();
|
|
32
|
+
const b = dsgvoSelfServiceFeatures();
|
|
33
|
+
expect(a[0]).not.toBe(b[0]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { createComplianceProfilesFeature } from "../compliance-profiles";
|
|
3
|
+
import { createDataRetentionFeature } from "../data-retention";
|
|
4
|
+
import { createSessionsFeature } from "../sessions";
|
|
5
|
+
import { createUserDataRightsFeature, type UserDataRightsOptions } from "../user-data-rights";
|
|
6
|
+
import { createUserProfileFeature } from "../user-profile";
|
|
7
|
+
|
|
8
|
+
export type DsgvoSelfServiceOptions = {
|
|
9
|
+
/** Durchgereicht an createUserDataRightsFeature — Export-/Deletion-Mail-
|
|
10
|
+
* Callbacks + Apex-Deletion-HMAC. Default {} (no-op Mail-Side). */
|
|
11
|
+
readonly userDataRights?: UserDataRightsOptions;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// DSGVO- + Account-Self-Service-Kette, die jede Kumiko-SaaS-App mountet
|
|
15
|
+
// (Privacy-Center, Account-Löschung Art. 17, Export Art. 20, Sessions).
|
|
16
|
+
// Die Reihenfolge IST load-bearing (Require-Order): user-data-rights braucht
|
|
17
|
+
// data-retention + compliance-profiles + sessions, user-profile braucht
|
|
18
|
+
// user-data-rights. Genau diese Order stand bisher in jeder App handkopiert
|
|
19
|
+
// mit Erklär-Kommentar. text-content + legal-pages bleiben bewusst draußen —
|
|
20
|
+
// legal-pages hat ein app-spezifisches wrapLayout, text-content ist
|
|
21
|
+
// standalone Foundation; beide spreaded die App selbst dazu.
|
|
22
|
+
export function dsgvoSelfServiceFeatures(opts: DsgvoSelfServiceOptions = {}): FeatureDefinition[] {
|
|
23
|
+
return [
|
|
24
|
+
createDataRetentionFeature(),
|
|
25
|
+
createComplianceProfilesFeature(),
|
|
26
|
+
createSessionsFeature(),
|
|
27
|
+
createUserDataRightsFeature(opts.userDataRights ?? {}),
|
|
28
|
+
createUserProfileFeature(),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type DsgvoSelfServiceOptions, dsgvoSelfServiceFeatures } from "./dsgvo-self-service";
|
package/src/secrets/feature.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { tenantSecretEntity, tenantSecretsTable } from "./table";
|
|
|
20
20
|
* and picks the highest as the active KEK unless
|
|
21
21
|
* `KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION` pins one explicitly.
|
|
22
22
|
*/
|
|
23
|
+
export const SECRETS_FEATURE_NAME = "secrets";
|
|
24
|
+
|
|
23
25
|
export const secretsEnvSchema = z.object({
|
|
24
26
|
KUMIKO_SECRETS_MASTER_KEY_V1: z
|
|
25
27
|
.string()
|
|
@@ -83,7 +85,7 @@ export function requireSecretsContext(
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export function createSecretsFeature(): FeatureDefinition {
|
|
86
|
-
return defineFeature(
|
|
88
|
+
return defineFeature(SECRETS_FEATURE_NAME, (r) => {
|
|
87
89
|
r.describe(
|
|
88
90
|
"Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump.",
|
|
89
91
|
);
|
package/src/secrets/index.ts
CHANGED
package/src/tags/constants.ts
CHANGED
|
@@ -19,6 +19,12 @@ export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
|
|
|
19
19
|
// after mounting tagsClient(); the renderer passes it the list's screenId.
|
|
20
20
|
export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
|
|
21
21
|
|
|
22
|
+
// Registry name for the <TagsCell> column renderer. A host entityList shows an
|
|
23
|
+
// entity's tags inline by declaring a labeled virtual column:
|
|
24
|
+
// `{ field: "tags", label: "Tags", renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } }`
|
|
25
|
+
// after mounting tagsClient() (which registers it under clientFeatures.columnRenderers).
|
|
26
|
+
export const TAGS_COLUMN_RENDERER_NAME = "TagsCell";
|
|
27
|
+
|
|
22
28
|
// Screen-id of the standalone Tags management screen (custom screen rendering
|
|
23
29
|
// TagManager). Qualified = "tags:screen:tag-list"; the app places it via r.nav.
|
|
24
30
|
export const TAGS_SCREEN_ID = "tag-list";
|
package/src/tags/index.ts
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { TagsQueries } from "../../constants";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
12
|
+
import { TagFilter } from "../tag-filter";
|
|
13
|
+
|
|
14
|
+
type TagRow = { id: string; name: string; color?: string };
|
|
15
|
+
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
16
|
+
|
|
17
|
+
let catalogRows: readonly TagRow[] = [];
|
|
18
|
+
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
|
+
const setFilterSpy = mock((_field: string, _values: readonly string[]) => {});
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
catalogRows = [
|
|
23
|
+
{ id: "t1", name: "urgent", color: "#ef4444" },
|
|
24
|
+
{ id: "t2", name: "backend", color: "#3b82f6" },
|
|
25
|
+
];
|
|
26
|
+
assignmentRows = [
|
|
27
|
+
{ tagId: "t1", entityType: "note", entityId: "n1" },
|
|
28
|
+
{ tagId: "t2", entityType: "note", entityId: "n2" },
|
|
29
|
+
];
|
|
30
|
+
setFilterSpy.mockClear();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const useQuerySpy = mock((type: string) => ({
|
|
34
|
+
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
35
|
+
loading: false,
|
|
36
|
+
error: null,
|
|
37
|
+
refetch: mock(async () => {}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
41
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
42
|
+
...actual_renderer,
|
|
43
|
+
useQuery: useQuerySpy,
|
|
44
|
+
useListUrlState: () => ({
|
|
45
|
+
sort: null,
|
|
46
|
+
q: "",
|
|
47
|
+
page: 1,
|
|
48
|
+
filters: {},
|
|
49
|
+
setSort: mock(() => {}),
|
|
50
|
+
setQ: mock(() => {}),
|
|
51
|
+
setPage: mock(() => {}),
|
|
52
|
+
setFilter: setFilterSpy,
|
|
53
|
+
clearFilters: mock(() => {}),
|
|
54
|
+
}),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Headless picker stub: a button that reports a tag selection back through the
|
|
58
|
+
// real onChange contract — no Dialog/cmdk popover to drive.
|
|
59
|
+
const StubPicker = ({
|
|
60
|
+
onChange,
|
|
61
|
+
}: {
|
|
62
|
+
readonly value: readonly string[];
|
|
63
|
+
readonly onChange: (next: readonly string[]) => void;
|
|
64
|
+
readonly entityType: string;
|
|
65
|
+
readonly open: boolean;
|
|
66
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
67
|
+
}): ReactNode => (
|
|
68
|
+
<button type="button" data-testid="picker-pick-t1" onClick={() => onChange(["t1"])}>
|
|
69
|
+
pick urgent
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
mock.module("../tag-picker", () => ({ TagPicker: StubPicker }));
|
|
73
|
+
|
|
74
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
75
|
+
return (
|
|
76
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
77
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
78
|
+
</LocaleProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("TagFilter", () => {
|
|
83
|
+
test("idle → just the filter button, no chips, no clear", () => {
|
|
84
|
+
render(
|
|
85
|
+
<Wrapper>
|
|
86
|
+
<TagFilter entityName="note" screenId="note-list" />
|
|
87
|
+
</Wrapper>,
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByTestId("tag-filter-open")).toBeTruthy();
|
|
90
|
+
expect(screen.queryByTestId("tag-chip")).toBeNull();
|
|
91
|
+
expect(screen.queryByTestId("tag-filter-clear")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("picking a tag → narrows the list to its entity ids + shows the selected chip + clear", () => {
|
|
95
|
+
render(
|
|
96
|
+
<Wrapper>
|
|
97
|
+
<TagFilter entityName="note" screenId="note-list" />
|
|
98
|
+
</Wrapper>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByTestId("picker-pick-t1"));
|
|
102
|
+
|
|
103
|
+
// urgent (t1) is assigned to n1 → list narrows to that id set
|
|
104
|
+
expect(setFilterSpy).toHaveBeenCalledWith("id", ["n1"]);
|
|
105
|
+
// the active selection is visible as a chip + a clear affordance
|
|
106
|
+
expect(screen.getByText("urgent")).toBeTruthy();
|
|
107
|
+
expect(screen.getByTestId("tag-filter-clear")).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("clear → drops the filter and the chip", () => {
|
|
111
|
+
render(
|
|
112
|
+
<Wrapper>
|
|
113
|
+
<TagFilter entityName="note" screenId="note-list" />
|
|
114
|
+
</Wrapper>,
|
|
115
|
+
);
|
|
116
|
+
fireEvent.click(screen.getByTestId("picker-pick-t1"));
|
|
117
|
+
setFilterSpy.mockClear();
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getByTestId("tag-filter-clear"));
|
|
120
|
+
|
|
121
|
+
expect(setFilterSpy).toHaveBeenCalledWith("id", []);
|
|
122
|
+
expect(screen.queryByText("urgent")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { render, screen } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { TagsQueries } from "../../constants";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
12
|
+
import { TagsCell } from "../tags-cell";
|
|
13
|
+
|
|
14
|
+
type TagRow = { id: string; name: string; color?: string };
|
|
15
|
+
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
16
|
+
|
|
17
|
+
let catalogRows: readonly TagRow[] = [];
|
|
18
|
+
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
catalogRows = [];
|
|
22
|
+
assignmentRows = [];
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const useQuerySpy = mock((type: string) => ({
|
|
26
|
+
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
27
|
+
loading: false,
|
|
28
|
+
error: null,
|
|
29
|
+
refetch: mock(async () => {}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
33
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
34
|
+
...actual_renderer,
|
|
35
|
+
useQuery: useQuerySpy,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
39
|
+
return (
|
|
40
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
41
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
42
|
+
</LocaleProvider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderCell(rowId: string): void {
|
|
47
|
+
render(
|
|
48
|
+
<Wrapper>
|
|
49
|
+
<TagsCell value={rowId} row={{ id: rowId }} column={{ field: "tags" }} />
|
|
50
|
+
</Wrapper>,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("TagsCell", () => {
|
|
55
|
+
test("renders a chip per tag assigned to the row's id, joined to the catalog", () => {
|
|
56
|
+
catalogRows = [
|
|
57
|
+
{ id: "t1", name: "important", color: "#ef4444" },
|
|
58
|
+
{ id: "t2", name: "project-x", color: "#3b82f6" },
|
|
59
|
+
];
|
|
60
|
+
assignmentRows = [
|
|
61
|
+
{ tagId: "t1", entityType: "note", entityId: "n1" },
|
|
62
|
+
{ tagId: "t2", entityType: "note", entityId: "n1" },
|
|
63
|
+
{ tagId: "t1", entityType: "note", entityId: "n2" }, // a different row
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
renderCell("n1");
|
|
67
|
+
|
|
68
|
+
expect(screen.getByTestId("tags-cell")).toBeTruthy();
|
|
69
|
+
expect(screen.getByText("important")).toBeTruthy();
|
|
70
|
+
expect(screen.getByText("project-x")).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("renders nothing when the row has no assigned tags", () => {
|
|
74
|
+
catalogRows = [{ id: "t1", name: "important" }];
|
|
75
|
+
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "other-row" }];
|
|
76
|
+
|
|
77
|
+
renderCell("n1");
|
|
78
|
+
|
|
79
|
+
expect(screen.queryByTestId("tags-cell")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("renders nothing for an empty row id", () => {
|
|
83
|
+
catalogRows = [{ id: "t1", name: "important" }];
|
|
84
|
+
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
|
|
85
|
+
|
|
86
|
+
render(
|
|
87
|
+
<Wrapper>
|
|
88
|
+
<TagsCell value="" row={{}} column={{ field: "tags" }} />
|
|
89
|
+
</Wrapper>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(screen.queryByTestId("tags-cell")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
4
|
import {
|
|
5
|
+
TAGS_COLUMN_RENDERER_NAME,
|
|
5
6
|
TAGS_FEATURE_NAME,
|
|
6
7
|
TAGS_FILTER_EXTENSION_NAME,
|
|
7
8
|
TAGS_SCREEN_ID,
|
|
@@ -11,6 +12,7 @@ import { defaultTranslations } from "./i18n";
|
|
|
11
12
|
import { TagFilter } from "./tag-filter";
|
|
12
13
|
import { TagManager } from "./tag-manager";
|
|
13
14
|
import { TagSection } from "./tag-section";
|
|
15
|
+
import { TagsCell } from "./tags-cell";
|
|
14
16
|
|
|
15
17
|
export function tagsClient(): ClientFeatureDefinition {
|
|
16
18
|
return {
|
|
@@ -20,6 +22,10 @@ export function tagsClient(): ClientFeatureDefinition {
|
|
|
20
22
|
// Header-slot tag filter for any entityList toolbar.
|
|
21
23
|
[TAGS_FILTER_EXTENSION_NAME]: TagFilter,
|
|
22
24
|
},
|
|
25
|
+
// Inline tag chips on any entityList row, via a labeled virtual column.
|
|
26
|
+
columnRenderers: {
|
|
27
|
+
[TAGS_COLUMN_RENDERER_NAME]: TagsCell,
|
|
28
|
+
},
|
|
23
29
|
// Standalone Tags management screen (custom screen → TagManager).
|
|
24
30
|
components: {
|
|
25
31
|
[TAGS_SCREEN_ID]: TagManager,
|
package/src/tags/web/i18n.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
30
30
|
"tags.picker.title": "Tags",
|
|
31
31
|
"tags.picker.done": "Fertig",
|
|
32
32
|
"tags.filter.label": "Nach Tag filtern",
|
|
33
|
-
"tags.filter.
|
|
33
|
+
"tags.filter.clear": "Filter zurücksetzen",
|
|
34
34
|
},
|
|
35
35
|
en: {
|
|
36
36
|
"tags.section.createMode": "Save the entity first to add tags.",
|
|
@@ -55,6 +55,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
55
55
|
"tags.picker.title": "Tags",
|
|
56
56
|
"tags.picker.done": "Done",
|
|
57
57
|
"tags.filter.label": "Filter by tag",
|
|
58
|
-
"tags.filter.
|
|
58
|
+
"tags.filter.clear": "Clear",
|
|
59
59
|
},
|
|
60
60
|
};
|
package/src/tags/web/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
export {
|
|
3
|
+
TAGS_COLUMN_RENDERER_NAME,
|
|
3
4
|
TAGS_FILTER_EXTENSION_NAME,
|
|
4
5
|
TAGS_SCREEN_ID,
|
|
5
6
|
TAGS_SECTION_EXTENSION_NAME,
|
|
@@ -13,3 +14,4 @@ export { TagFilter } from "./tag-filter";
|
|
|
13
14
|
export { TagManager } from "./tag-manager";
|
|
14
15
|
export { TagPicker } from "./tag-picker";
|
|
15
16
|
export { TagSection } from "./tag-section";
|
|
17
|
+
export { TagsCell } from "./tags-cell";
|