@cosmicdrift/kumiko-bundled-features 0.79.3 → 0.81.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.79.3",
3
+ "version": "0.81.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -41,6 +41,7 @@
41
41
  "./mail-transport-inmemory": "./src/mail-transport-inmemory/index.ts",
42
42
  "./file-foundation": "./src/file-foundation/index.ts",
43
43
  "./file-provider-s3": "./src/file-provider-s3/index.ts",
44
+ "./file-provider-s3-env": "./src/file-provider-s3-env/index.ts",
44
45
  "./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
45
46
  "./files": "./src/files/index.ts",
46
47
  "./user-data-rights": "./src/user-data-rights/index.ts",
@@ -84,11 +85,11 @@
84
85
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
85
86
  },
86
87
  "dependencies": {
87
- "@cosmicdrift/kumiko-dispatcher-live": "0.79.3",
88
- "@cosmicdrift/kumiko-framework": "0.79.3",
89
- "@cosmicdrift/kumiko-headless": "0.79.3",
90
- "@cosmicdrift/kumiko-renderer": "0.79.3",
91
- "@cosmicdrift/kumiko-renderer-web": "0.79.3",
88
+ "@cosmicdrift/kumiko-dispatcher-live": "0.81.0",
89
+ "@cosmicdrift/kumiko-framework": "0.81.0",
90
+ "@cosmicdrift/kumiko-headless": "0.81.0",
91
+ "@cosmicdrift/kumiko-renderer": "0.81.0",
92
+ "@cosmicdrift/kumiko-renderer-web": "0.81.0",
92
93
  "@mollie/api-client": "^4.5.0",
93
94
  "@node-rs/argon2": "^2.0.2",
94
95
  "@types/nodemailer": "^8.0.0",
@@ -15,6 +15,7 @@
15
15
  // authMutedLinkClass — Subtle-Link-Style.
16
16
  // parseUrlToken — URL-Param-Helper (window.location.search).
17
17
 
18
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
18
19
  import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer-web";
19
20
  import { createContext, type ReactNode, useContext } from "react";
20
21
 
@@ -51,17 +52,30 @@ export type AuthCardProps = {
51
52
  };
52
53
 
53
54
  export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode {
55
+ const { Card } = usePrimitives();
54
56
  const shell = useAuthShell() ?? defaultAuthShell;
57
+ // h1 (Seiten-Hauptüberschrift) via Header-Slot erhalten — die Card-Default-
58
+ // Header wäre h3. padded:false = Form sitzt randlos wie bisher (bare form).
55
59
  const card = (
56
- <div className="w-full max-w-sm rounded-lg border bg-card text-card-foreground shadow-sm">
57
- {(title !== undefined || subtitle !== undefined) && (
58
- <div className="flex flex-col space-y-1.5 p-6 pb-4">
59
- {title !== undefined && <h1 className="text-xl font-semibold tracking-tight">{title}</h1>}
60
- {subtitle !== undefined && <p className="text-sm text-muted-foreground">{subtitle}</p>}
61
- </div>
62
- )}
60
+ <Card
61
+ className="w-full max-w-sm"
62
+ options={{ padded: false }}
63
+ slots={{
64
+ header:
65
+ title !== undefined || subtitle !== undefined ? (
66
+ <div className="flex flex-col space-y-1.5 p-6 pb-4">
67
+ {title !== undefined && (
68
+ <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
69
+ )}
70
+ {subtitle !== undefined && (
71
+ <p className="text-sm text-muted-foreground">{subtitle}</p>
72
+ )}
73
+ </div>
74
+ ) : undefined,
75
+ }}
76
+ >
63
77
  <BareFormProvider>{children}</BareFormProvider>
64
- </div>
78
+ </Card>
65
79
  );
66
80
  return shell(card);
67
81
  }
@@ -27,6 +27,7 @@ import { createConfigAccessorFactory } from "../../config/feature";
27
27
  import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
28
28
  import { configValuesTable } from "../../config/table";
29
29
  import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../../file-provider-s3";
30
+ import { fileProviderS3EnvFeature } from "../../file-provider-s3-env";
30
31
  import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
31
32
  import { createTenantFeature } from "../../tenant/feature";
32
33
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -90,6 +91,7 @@ beforeAll(async () => {
90
91
  createSecretsFeature(),
91
92
  fileFoundationFeature,
92
93
  fileProviderS3Feature,
94
+ fileProviderS3EnvFeature,
93
95
  testProbeFeature,
94
96
  ],
95
97
  masterKeyProvider: providerRef,
@@ -243,3 +245,40 @@ describe("scenario 3: tenant isolation", () => {
243
245
  expect(b["hasWrite"]).toBe(true);
244
246
  });
245
247
  });
248
+
249
+ // --- Scenario 4: s3-env provider builds from env, NO per-tenant secret ---
250
+ //
251
+ // The "wire-into-any-app" proof for file-provider-s3-env: select the
252
+ // "s3-env" provider, set the S3_* env vars, and the factory builds a working
253
+ // provider WITHOUT any secrets:write:set call. This is the single-bucket /
254
+ // Hetzner deploy path — no admin seeding, no secrets store.
255
+
256
+ const S3_ENV_KEYS = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
257
+
258
+ describe("scenario 4: s3-env provider (app-wide env, no secrets)", () => {
259
+ test("env vars set + provider=s3-env → factory builds provider without a per-tenant secret", async () => {
260
+ const admin = adminFor(504);
261
+ const saved = S3_ENV_KEYS.map((k) => [k, process.env[k]] as const);
262
+ Object.assign(process.env, {
263
+ S3_BUCKET: "shared-bucket",
264
+ S3_REGION: "fsn1",
265
+ S3_ACCESS_KEY: "AKIAENV",
266
+ S3_SECRET_KEY: "env-secret-not-real",
267
+ });
268
+ try {
269
+ await setConfig(admin, "file-foundation:config:provider", "s3-env");
270
+ const result = (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<
271
+ string,
272
+ unknown
273
+ >;
274
+ expect(result["hasWrite"]).toBe(true);
275
+ expect(result["hasRead"]).toBe(true);
276
+ expect(result["hasDelete"]).toBe(true);
277
+ } finally {
278
+ for (const [k, v] of saved) {
279
+ if (v === undefined) delete process.env[k];
280
+ else process.env[k] = v;
281
+ }
282
+ }
283
+ });
284
+ });
@@ -0,0 +1,58 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // file-provider-s3-env — S3 file provider configured entirely from
4
+ // process.env. ONE shared bucket for ALL tenants: wire it by setting the
5
+ // `S3_*` env vars + mounting — no per-tenant admin seeding and no
6
+ // `secrets`-feature dependency.
7
+ //
8
+ // **vs file-provider-s3:** the config-based `"s3"` provider owns per-tenant
9
+ // config keys + an encrypted `s3.secretAccessKey` secret (per-tenant
10
+ // buckets, admin-seeded). This `"s3-env"` provider reads one credential set
11
+ // from env and serves every tenant from one bucket — the
12
+ // Hetzner-Object-Storage / single-bucket deploy case. Tenant isolation
13
+ // still holds: file keys are tenant-prefixed (export ZIPs:
14
+ // `<tenantId>/exports/<jobId>.zip`) or globally-unique UUIDs (fileRefs).
15
+ //
16
+ // **Pattern-Vorbild:** mirrors file-provider-inmemory (zero admin seeding,
17
+ // only the file-foundation plugin-point required).
18
+ //
19
+ // **Env vars** (read by createS3ProviderFromEnv, prefix `S3_`):
20
+ // required — S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY
21
+ // optional — S3_ENDPOINT (S3-compat stores incl. Hetzner Object Storage),
22
+ // S3_FORCE_PATH_STYLE
23
+ // Missing required vars throw on the FIRST file op (inside the export cron).
24
+ // The user-data-rights boot guard surfaces that at boot instead.
25
+
26
+ import type {
27
+ FileProviderContext,
28
+ FileProviderPlugin,
29
+ } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
30
+ import { createS3ProviderFromEnv } from "@cosmicdrift/kumiko-bundled-features/files-provider-s3";
31
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
32
+ import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
33
+
34
+ const FEATURE_NAME = "file-provider-s3-env";
35
+
36
+ export const fileProviderS3EnvFeature = defineFeature(FEATURE_NAME, (r) => {
37
+ r.describe(
38
+ 'Registers an `"s3-env"` provider for `file-foundation` that reads one S3 credential set from `process.env` (`S3_BUCKET`/`S3_REGION`/`S3_ACCESS_KEY`/`S3_SECRET_KEY`, optional `S3_ENDPOINT`/`S3_FORCE_PATH_STYLE`) and serves every tenant from one shared bucket — no per-tenant config or secret seeding. Use this for single-bucket S3-compatible deploys (e.g. Hetzner Object Storage); use `file-provider-s3` instead when each tenant needs its own bucket/credentials.',
39
+ );
40
+ r.uiHints({
41
+ displayLabel: "File Provider · S3 (env)",
42
+ category: "storage",
43
+ recommended: false,
44
+ });
45
+ // No r.requires("config") / r.requires("secrets") — credentials come from
46
+ // env, not the per-tenant config + secrets store. Only the file-foundation
47
+ // plugin-extension-point must be mounted.
48
+ r.requires("file-foundation");
49
+
50
+ const plugin: FileProviderPlugin = {
51
+ // tenantId ignored: one shared bucket serves all tenants (isolation via
52
+ // tenant-prefixed / UUID keys). Built fresh per call like file-provider-s3;
53
+ // the S3 client opens no connection at construction.
54
+ build: async (_ctx: FileProviderContext, _tenantId: string): Promise<FileStorageProvider> =>
55
+ createS3ProviderFromEnv(), // @wrapper-known semantic-alias
56
+ };
57
+ r.useExtension("fileProvider", "s3-env", plugin);
58
+ });
@@ -0,0 +1,3 @@
1
+ // Public API of the file-provider-s3-env bundled-feature.
2
+
3
+ export { fileProviderS3EnvFeature } from "./feature";
@@ -8,8 +8,10 @@
8
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
9
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
10
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
11
12
  import {
12
13
  createInMemoryFileProvider,
14
+ type FileStorageProvider,
13
15
  fileRefsTable,
14
16
  type InMemoryFileProvider,
15
17
  } from "@cosmicdrift/kumiko-framework/files";
@@ -21,24 +23,36 @@ import {
21
23
  } from "@cosmicdrift/kumiko-framework/stack";
22
24
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
23
25
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
26
+ import { createConfigFeature } from "../../config";
27
+ import { createConfigAccessorFactory } from "../../config/feature";
28
+ import { createConfigResolver } from "../../config/resolver";
29
+ import { configValuesTable } from "../../config/table";
24
30
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
31
+ import { fileFoundationFeature } from "../../file-foundation";
25
32
  import { createFilesFeature } from "../../files";
26
33
  import { createSessionsFeature } from "../../sessions";
27
34
  import { createUserFeature, userEntity, userTable } from "../../user";
28
35
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
29
36
  import { createUserDataRightsFeature } from "../feature";
37
+ import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
30
38
  import { runForgetCleanup } from "../run-forget-cleanup";
31
39
  import {
32
40
  createForgetSeeders,
41
+ createTestFileProviderFeature,
33
42
  type ForgetSeeders,
34
43
  nowInstant,
35
44
  READ_TENANT_MEMBERSHIPS_DDL,
36
45
  } from "./forget-test-helpers";
37
46
 
47
+ const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
48
+
38
49
  let stack: TestStack;
39
50
  let db: DbConnection;
40
51
  let provider: InMemoryFileProvider;
41
52
  let seed: ForgetSeeders;
53
+ // Per-tenant resolver the forget pipeline uses — built from the stack's
54
+ // configResolver, resolves "test" → the test provider plugin → `provider`.
55
+ let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
42
56
 
43
57
  const TENANT = "00000000-0000-4000-8000-00000000000c";
44
58
 
@@ -48,24 +62,44 @@ function uuid(suffix: number): string {
48
62
 
49
63
  beforeAll(async () => {
50
64
  provider = createInMemoryFileProvider();
65
+ // Forget resolves the binary store through file-foundation (the production
66
+ // path) — mount it + a test plugin returning THIS provider, selected app-wide
67
+ // via a config app-override (no admin write needed).
68
+ const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
69
+ const resolver = createConfigResolver({ appOverrides });
51
70
  stack = await setupTestStack({
52
71
  features: [
72
+ createConfigFeature(),
53
73
  createUserFeature(),
54
74
  createFilesFeature(),
75
+ fileFoundationFeature,
76
+ createTestFileProviderFeature(provider, "test"),
55
77
  createDataRetentionFeature(),
56
78
  createComplianceProfilesFeature(),
57
79
  createSessionsFeature(),
58
80
  createUserDataRightsFeature(),
59
- createUserDataRightsDefaultsFeature({ storageProvider: provider }),
81
+ createUserDataRightsDefaultsFeature(),
60
82
  ],
61
83
  files: { storageProvider: provider },
84
+ extraContext: ({ registry }) => ({
85
+ configResolver: resolver,
86
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
87
+ }),
62
88
  });
63
89
  db = stack.db;
64
90
  seed = createForgetSeeders(db, provider);
91
+ buildStorageProvider = makeTenantStorageProviderResolver({
92
+ registry: stack.registry,
93
+ configResolver: resolver,
94
+ secrets: undefined,
95
+ db,
96
+ userId: SYSTEM_USER_ID,
97
+ handlerName: "test-forget-cleanup",
98
+ });
65
99
 
66
100
  await unsafeCreateEntityTable(db, userEntity);
67
101
  await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
68
- await unsafePushTables(db, { fileRefsTable });
102
+ await unsafePushTables(db, { fileRefsTable, configValuesTable });
69
103
  await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
70
104
  });
71
105
 
@@ -86,7 +120,12 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
86
120
  const key = await seed.seedFile(uuid(101), TENANT, userId);
87
121
  expect(await provider.exists(key)).toBe(true);
88
122
 
89
- const result = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
123
+ const result = await runForgetCleanup({
124
+ db,
125
+ registry: stack.registry,
126
+ now: nowInstant(),
127
+ buildStorageProvider,
128
+ });
90
129
 
91
130
  expect(result.processedUserIds).toContain(userId);
92
131
  expect(await provider.exists(key)).toBe(false);
@@ -104,7 +143,12 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
104
143
  ]);
105
144
  expect(provider.keys()).toHaveLength(3);
106
145
 
107
- await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
146
+ await runForgetCleanup({
147
+ db,
148
+ registry: stack.registry,
149
+ now: nowInstant(),
150
+ buildStorageProvider,
151
+ });
108
152
 
109
153
  for (const key of keys) {
110
154
  expect(await provider.exists(key)).toBe(false);
@@ -122,7 +166,12 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
122
166
  // The other-tenant file is owned by a different user; the forget run for
123
167
  // userId must NOT touch it.
124
168
 
125
- await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
169
+ await runForgetCleanup({
170
+ db,
171
+ registry: stack.registry,
172
+ now: nowInstant(),
173
+ buildStorageProvider,
174
+ });
126
175
 
127
176
  expect(await provider.exists(myKey)).toBe(false);
128
177
  expect(await provider.exists(otherKey)).toBe(true);
@@ -12,8 +12,10 @@
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
15
16
  import {
16
17
  createInMemoryFileProvider,
18
+ type FileStorageProvider,
17
19
  fileRefsTable,
18
20
  type InMemoryFileProvider,
19
21
  } from "@cosmicdrift/kumiko-framework/files";
@@ -25,24 +27,36 @@ import {
25
27
  } from "@cosmicdrift/kumiko-framework/stack";
26
28
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
27
29
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
30
+ import { createConfigFeature } from "../../config";
31
+ import { createConfigAccessorFactory } from "../../config/feature";
32
+ import { createConfigResolver } from "../../config/resolver";
33
+ import { configValuesTable } from "../../config/table";
28
34
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
35
+ import { fileFoundationFeature } from "../../file-foundation";
29
36
  import { createFilesFeature } from "../../files";
30
37
  import { createSessionsFeature } from "../../sessions";
31
38
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
32
39
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
33
40
  import { createUserDataRightsFeature } from "../feature";
41
+ import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
34
42
  import { runForgetCleanup } from "../run-forget-cleanup";
35
43
  import {
36
44
  createForgetSeeders,
45
+ createTestFileProviderFeature,
37
46
  type ForgetSeeders,
38
47
  nowInstant,
39
48
  READ_TENANT_MEMBERSHIPS_DDL,
40
49
  } from "./forget-test-helpers";
41
50
 
51
+ const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
52
+
42
53
  let stack: TestStack;
43
54
  let db: DbConnection;
44
55
  let base: InMemoryFileProvider;
45
56
  let seed: ForgetSeeders;
57
+ // Per-tenant resolver for the direct runForgetCleanup calls — resolves through
58
+ // file-foundation to the flaky provider, same path the dispatcher handler uses.
59
+ let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
46
60
  // `delete` throws while set; flip off to simulate storage recovery on retry.
47
61
  let failDeletes = true;
48
62
 
@@ -59,26 +73,45 @@ beforeAll(async () => {
59
73
  return base.delete(key);
60
74
  },
61
75
  };
76
+ // Forget resolves the binary store through file-foundation (production path).
77
+ // The test plugin returns the flaky provider; selected app-wide via override.
78
+ const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
79
+ const resolver = createConfigResolver({ appOverrides });
62
80
  stack = await setupTestStack({
63
81
  features: [
82
+ createConfigFeature(),
64
83
  createUserFeature(),
65
84
  createFilesFeature(),
85
+ fileFoundationFeature,
86
+ createTestFileProviderFeature(flakyProvider, "test"),
66
87
  createDataRetentionFeature(),
67
88
  createComplianceProfilesFeature(),
68
89
  createSessionsFeature(),
69
90
  createUserDataRightsFeature(),
70
- createUserDataRightsDefaultsFeature({ storageProvider: flakyProvider }),
91
+ createUserDataRightsDefaultsFeature(),
71
92
  ],
72
93
  files: { storageProvider: flakyProvider },
94
+ extraContext: ({ registry }) => ({
95
+ configResolver: resolver,
96
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
97
+ }),
73
98
  });
74
99
  db = stack.db;
75
100
  // Seeders write binaries through the real store (`base`), not the flaky
76
101
  // wrapper — the wrapper's `delete` failure is what the test exercises.
77
102
  seed = createForgetSeeders(db, base);
103
+ buildStorageProvider = makeTenantStorageProviderResolver({
104
+ registry: stack.registry,
105
+ configResolver: resolver,
106
+ secrets: undefined,
107
+ db,
108
+ userId: SYSTEM_USER_ID,
109
+ handlerName: "test-forget-failure",
110
+ });
78
111
 
79
112
  await unsafeCreateEntityTable(db, userEntity);
80
113
  await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
81
- await unsafePushTables(db, { fileRefsTable });
114
+ await unsafePushTables(db, { fileRefsTable, configValuesTable });
82
115
  await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
83
116
  });
84
117
 
@@ -107,7 +140,12 @@ describe("forget fail-closed :: storage.delete failure aborts the row hard-delet
107
140
  await seed.seedMembership(userId, TENANT);
108
141
  const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000001", TENANT, userId);
109
142
 
110
- const result = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
143
+ const result = await runForgetCleanup({
144
+ db,
145
+ registry: stack.registry,
146
+ now: nowInstant(),
147
+ buildStorageProvider,
148
+ });
111
149
 
112
150
  // NOT flipped to Deleted — the sub-tx rolled back.
113
151
  expect(result.processedUserIds).not.toContain(userId);
@@ -128,13 +166,23 @@ describe("forget fail-closed :: storage.delete failure aborts the row hard-delet
128
166
  const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000002", TENANT, userId);
129
167
 
130
168
  // First run: storage down → abort.
131
- const first = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
169
+ const first = await runForgetCleanup({
170
+ db,
171
+ registry: stack.registry,
172
+ now: nowInstant(),
173
+ buildStorageProvider,
174
+ });
132
175
  expect(first.processedUserIds).not.toContain(userId);
133
176
  expect(await base.exists(key)).toBe(true);
134
177
 
135
178
  // Storage recovers; retry converges.
136
179
  failDeletes = false;
137
- const second = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
180
+ const second = await runForgetCleanup({
181
+ db,
182
+ registry: stack.registry,
183
+ now: nowInstant(),
184
+ buildStorageProvider,
185
+ });
138
186
 
139
187
  expect(second.processedUserIds).toContain(userId);
140
188
  expect(await base.exists(key)).toBe(false);
@@ -6,11 +6,27 @@
6
6
 
7
7
  import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
8
8
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
10
+ import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
9
11
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
10
12
  import { USER_STATUS, userTable } from "../../user";
11
13
 
12
14
  export const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
13
15
 
16
+ // Test file-provider plugin: registers `provider` under the file-foundation
17
+ // `fileProvider` extension point so the forget pipeline resolves THIS instance
18
+ // through createFileProviderForTenant — the same path production uses. Set
19
+ // `file-foundation:config:provider` to `name` to select it.
20
+ export function createTestFileProviderFeature(
21
+ provider: FileStorageProvider,
22
+ name = "test",
23
+ ): FeatureDefinition {
24
+ return defineFeature(`test-file-provider-${name}`, (r) => {
25
+ r.requires("file-foundation");
26
+ r.useExtension("fileProvider", name, { build: async () => provider });
27
+ });
28
+ }
29
+
14
30
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
15
31
  export const nowInstant = (): Instant => getTemporal().Now.instant();
16
32
  export const pastInstant = (): Instant =>
@@ -269,6 +269,35 @@ describe("runForgetCleanup :: happy path (Cross-Tenant Account-Deletion)", () =>
269
269
  });
270
270
  });
271
271
 
272
+ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
273
+ // Beweist C1: der Forget-Cleanup ist als autonomer Cron registriert, nicht
274
+ // nur als manueller runForget-API. Ohne die r.job-Registrierung liefert
275
+ // getJob undefined und die Loeschung laeuft nach Grace-Ablauf NIE. Wir
276
+ // treiben den ECHTEN registrierten Job durch den echten JobContext (db +
277
+ // registry, kein config) — nicht den inneren runForgetCleanup-Helper.
278
+ test("registered job exists + erases through the real JobContext", async () => {
279
+ const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
280
+ expect(job).toBeTruthy();
281
+
282
+ const CRON_USER = uuid(7);
283
+ await seedUser(CRON_USER, {
284
+ status: USER_STATUS.DeletionRequested,
285
+ gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
286
+ email: "cron-delete@example.com",
287
+ displayName: "CronDelete",
288
+ });
289
+ await seedMembership(CRON_USER, TENANT_A);
290
+
291
+ const jobCtx = { db: stack.db, registry: stack.registry };
292
+ await job?.handler({}, jobCtx as never);
293
+
294
+ const row = await fetchUser(CRON_USER);
295
+ expect(row?.status).toBe(USER_STATUS.Deleted);
296
+ expect(row?.email).not.toContain("cron-delete@example.com");
297
+ expect(row?.email).toContain("anonymized.invalid");
298
+ });
299
+ });
300
+
272
301
  describe("runForgetCleanup :: time-window guards", () => {
273
302
  test("Future-grace User wird NICHT bearbeitet", async () => {
274
303
  await seedUser(FUTURE_USER_ID, {
@@ -4,8 +4,6 @@ import {
4
4
  type FeatureDefinition,
5
5
  SYSTEM_USER_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { createConfigAccessor } from "../config";
8
- import { createFileProviderForTenant } from "../file-foundation";
9
7
  import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
10
8
  import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
11
9
  import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
@@ -26,12 +24,13 @@ import {
26
24
  import { requestExportWrite } from "./handlers/request-export.write";
27
25
  import { restrictAccountWrite } from "./handlers/restrict-account.write";
28
26
  import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
27
+ import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
29
28
  import {
30
29
  runExportJobs,
31
30
  type SendExportFailedEmailFn,
32
31
  type SendExportReadyEmailFn,
33
32
  } from "./run-export-jobs";
34
- import type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
33
+ import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
35
34
  import { downloadAttemptEntity } from "./schema/download-attempt";
36
35
  import { exportDownloadTokenEntity } from "./schema/download-token";
37
36
  import { exportJobEntity } from "./schema/export-job";
@@ -276,29 +275,18 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
276
275
  await runExportJobs({
277
276
  db: exportDb,
278
277
  registry: exportRegistry,
279
- buildStorageProvider: async (tenantId) => {
280
- // ctx.config (per-request ConfigAccessor) existiert nur im HTTP-
281
- // Dispatcher; der Cron-Job-Kontext trägt ctx.configResolver. Den
282
- // per-Tenant-Accessor daraus bauen (wie der HTTP-Pfad via
283
- // _configAccessorFactory) — sonst wirft createFileProviderForTenant
284
- // "ctx.config is missing" und jeder Export landet auf failed.
285
- const config =
286
- ctx.config ??
287
- (ctx.configResolver
288
- ? createConfigAccessor(
289
- exportRegistry,
290
- ctx.configResolver,
291
- tenantId as Parameters<typeof createConfigAccessor>[2],
292
- exportUserId,
293
- exportDb,
294
- )
295
- : undefined);
296
- return createFileProviderForTenant(
297
- { config, registry: exportRegistry, secrets: ctx.secrets, _userId: exportUserId },
298
- tenantId,
299
- "user-data-rights:run-export-jobs",
300
- );
301
- },
278
+ // Per-tenant provider from the mounted file-foundation. The cron
279
+ // context carries configResolver (not the per-request ConfigAccessor),
280
+ // so the resolver builds a per-tenant accessor from it — otherwise
281
+ // createFileProviderForTenant throws and every export lands on failed.
282
+ buildStorageProvider: makeTenantStorageProviderResolver({
283
+ registry: exportRegistry,
284
+ configResolver: ctx.configResolver,
285
+ secrets: ctx.secrets,
286
+ db: exportDb,
287
+ userId: exportUserId,
288
+ handlerName: "user-data-rights:run-export-jobs",
289
+ }),
302
290
  now: T.Now.instant(),
303
291
  // Atom 5 — App-Author-Callbacks fuer Email-Notification.
304
292
  // Optional: wenn nicht gesetzt, kein Email; User pollt
@@ -315,6 +303,44 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
315
303
  });
316
304
  },
317
305
  );
306
+
307
+ // Autonomer Art.17-Forget-Cron. Spiegelt run-export-jobs: nach Ablauf der
308
+ // Grace-Period laeuft runForgetCleanup unbeaufsichtigt (der manuelle
309
+ // userDataRights.runForget-API bleibt fuer Operator-Runs). Ohne diesen
310
+ // Cron bleibt jeder Loesch-Antrag fuer immer in DeletionRequested haengen
311
+ // — Art.17 wuerde nie ausgefuehrt.
312
+ r.job(
313
+ "run-forget-cleanup",
314
+ { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
315
+ async (_payload, ctx) => {
316
+ if (!ctx.db || !ctx.registry) {
317
+ throw new Error(
318
+ "run-forget-cleanup: ctx.db + ctx.registry required (JobContext incomplete)",
319
+ );
320
+ }
321
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
322
+ const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
323
+ const forgetDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
324
+ await runForgetCleanup({
325
+ db: forgetDb,
326
+ registry: ctx.registry,
327
+ now: T.Now.instant(),
328
+ // Same per-tenant provider resolution as the export cron — forget
329
+ // deletes binaries from the store upload + export use.
330
+ buildStorageProvider: makeTenantStorageProviderResolver({
331
+ registry: ctx.registry,
332
+ configResolver: ctx.configResolver,
333
+ secrets: ctx.secrets,
334
+ db: forgetDb,
335
+ userId: forgetUserId,
336
+ handlerName: "user-data-rights:run-forget-cleanup",
337
+ }),
338
+ ...(opts.sendDeletionExecutedEmail && {
339
+ sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail,
340
+ }),
341
+ });
342
+ },
343
+ );
318
344
  });
319
345
  }
320
346