@cosmicdrift/kumiko-bundled-features 0.285.1 → 0.286.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 +10 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/invite-token-store.ts +47 -96
- package/src/auth-email-password/lockout-store.ts +13 -102
- package/src/auth-email-password/signup-token-store.test.ts +29 -0
- package/src/auth-email-password/signup-token-store.ts +40 -103
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
- package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
- package/src/billing-foundation/billing-info-query.ts +95 -0
- package/src/billing-foundation/changes.json +8 -1
- package/src/billing-foundation/index.ts +5 -0
- package/src/file-provider-s3-env/feature.ts +2 -2
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +9 -0
- package/src/files-tenant-data/hooks.ts +13 -10
- package/src/shared/index.ts +2 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/download.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -12
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/run-export-jobs.ts +2 -3
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSingleUseTokenStore } from "./single-use-token-store";
|
|
3
|
+
|
|
4
|
+
// Production Redis has active signup/invite tokens under these exact keys —
|
|
5
|
+
// asserting the generated Redis key strings (not just behavior through a
|
|
6
|
+
// mocked client) catches a prefix typo that would silently make existing
|
|
7
|
+
// tokens unreachable. Only integration tests exercised this logic before
|
|
8
|
+
// (which never assert on the raw key string), so this is new coverage.
|
|
9
|
+
function fakeRedis() {
|
|
10
|
+
const calls: { method: string; args: unknown[] }[] = [];
|
|
11
|
+
const redis = {
|
|
12
|
+
set: async (...args: unknown[]) => {
|
|
13
|
+
calls.push({ method: "set", args });
|
|
14
|
+
return "OK";
|
|
15
|
+
},
|
|
16
|
+
get: async (...args: unknown[]) => {
|
|
17
|
+
calls.push({ method: "get", args });
|
|
18
|
+
return null;
|
|
19
|
+
},
|
|
20
|
+
del: async (...args: unknown[]) => {
|
|
21
|
+
calls.push({ method: "del", args });
|
|
22
|
+
return 1;
|
|
23
|
+
},
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
|
|
25
|
+
} as any;
|
|
26
|
+
return { redis, calls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TOKEN = "tok-1";
|
|
30
|
+
|
|
31
|
+
describe("signup Redis key strings", () => {
|
|
32
|
+
const store = createSingleUseTokenStore({
|
|
33
|
+
tokenPrefix: "signup:by-token:",
|
|
34
|
+
subjectPrefix: "signup:by-email:",
|
|
35
|
+
burnPrefix: "signup:burn:",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("store writes forward key under signup:by-token: and reverse key under signup:by-email:", async () => {
|
|
39
|
+
const { redis, calls } = fakeRedis();
|
|
40
|
+
await store.store(redis, { subjectId: "user@example.com", token: TOKEN, ttlSeconds: 60 });
|
|
41
|
+
const forwardKey = calls[0]?.args[0];
|
|
42
|
+
const subjectKey = calls[1]?.args[0];
|
|
43
|
+
expect(String(forwardKey)).toStartWith("signup:by-token:");
|
|
44
|
+
expect(subjectKey).toBe("signup:by-email:user@example.com");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("burn writes under signup:burn:", async () => {
|
|
48
|
+
const { redis, calls } = fakeRedis();
|
|
49
|
+
await store.burn(redis, TOKEN);
|
|
50
|
+
expect(String(calls[0]?.args[0])).toStartWith("signup:burn:");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("invite Redis key strings", () => {
|
|
55
|
+
const store = createSingleUseTokenStore({
|
|
56
|
+
tokenPrefix: "invite:by-token:",
|
|
57
|
+
subjectPrefix: "invite:by-id:",
|
|
58
|
+
burnPrefix: "invite:burn:",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("store writes forward key under invite:by-token: and reverse key under invite:by-id:", async () => {
|
|
62
|
+
const { redis, calls } = fakeRedis();
|
|
63
|
+
await store.store(redis, { subjectId: "inv-1", token: TOKEN, ttlSeconds: 60 });
|
|
64
|
+
const forwardKey = calls[0]?.args[0];
|
|
65
|
+
const subjectKey = calls[1]?.args[0];
|
|
66
|
+
expect(String(forwardKey)).toStartWith("invite:by-token:");
|
|
67
|
+
expect(subjectKey).toBe("invite:by-id:inv-1");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("burn writes under invite:burn:", async () => {
|
|
71
|
+
const { redis, calls } = fakeRedis();
|
|
72
|
+
await store.burn(redis, TOKEN);
|
|
73
|
+
expect(String(calls[0]?.args[0])).toStartWith("invite:burn:");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Generic Redis-backed pre-activation token store: bidirectional
|
|
2
|
+
// token↔subject mapping plus single-use burn/unburn semantics. Extracted
|
|
3
|
+
// from auth-email-password/signup-token-store.ts and
|
|
4
|
+
// auth-email-password/invite-token-store.ts (infra#446) — both were the
|
|
5
|
+
// same Redis layout, differing only in their key prefixes and which field
|
|
6
|
+
// (email vs. invitationId) plays the "subject" role.
|
|
7
|
+
//
|
|
8
|
+
// Public subpath export (./shared/single-use-token-store in package.json):
|
|
9
|
+
// offlot-app (a separate repo, external consumer) carries its own copy of
|
|
10
|
+
// this exact logic under `src/features/waitlist/signup-token-store.ts`,
|
|
11
|
+
// with a comment noting it "must stay byte-compatible with
|
|
12
|
+
// auth-email-password/signup-token-store" because signup-confirm resolves
|
|
13
|
+
// tokens via the same Redis key layout. offlot-app#418 (separate issue,
|
|
14
|
+
// after this ships) will replace that copy with
|
|
15
|
+
// `createSingleUseTokenStore({ tokenPrefix: "signup:by-token:", subjectPrefix:
|
|
16
|
+
// "signup:by-email:", burnPrefix: "signup:burn:" })` — i.e. the exact same
|
|
17
|
+
// prefix strings the framework's own signup store below uses, so both stay
|
|
18
|
+
// byte-compatible by construction instead of by hand-copied logic.
|
|
19
|
+
//
|
|
20
|
+
// Token material: opaque random 256-bit (e.g. crypto.randomBytes,
|
|
21
|
+
// base64url-encoded). Not designed for human typing — the subject clicks a
|
|
22
|
+
// mail link, nobody types the token.
|
|
23
|
+
//
|
|
24
|
+
// Why a server-side lookup at all (not a stateless HMAC-signed token, like
|
|
25
|
+
// password-reset/email-verification)? Some subjects (e.g. a not-yet-created
|
|
26
|
+
// signup) have no stable identity claim yet for an HMAC to bind to. We map
|
|
27
|
+
// token ↔ subject bidirectionally in Redis and delete the pair on confirm.
|
|
28
|
+
// Bidirectional because:
|
|
29
|
+
// - forward (by-token): confirm/accept needs token → subject
|
|
30
|
+
// - reverse (by-subject): the create/request flow needs to know whether a
|
|
31
|
+
// token is still live for this subject, so a resend can invalidate it
|
|
32
|
+
// instead of leaving two valid tokens around
|
|
33
|
+
//
|
|
34
|
+
// Every key is derived from sha256(token), never the raw token — Redis key
|
|
35
|
+
// names, MONITOR output, replica traffic, and memory/backup dumps never
|
|
36
|
+
// carry the bearer secret in the clear (#2174). The by-subject entry stores
|
|
37
|
+
// the *hash* of the live token, not the token itself, so it can only be
|
|
38
|
+
// used to invalidate (delete the matching forward entry) — never to
|
|
39
|
+
// recover or resend the original token. A resend therefore always mints a
|
|
40
|
+
// fresh token and invalidates the previous one, rather than reusing the
|
|
41
|
+
// same link.
|
|
42
|
+
//
|
|
43
|
+
// Single-use burn: `SET burn:<hash> "1" EX 3600 NX` — first caller to
|
|
44
|
+
// confirm/accept wins ("OK"), a concurrent second tab racing the same link
|
|
45
|
+
// gets "already-used". TTL is 1h (short enough that the burn-key doesn't
|
|
46
|
+
// permanently tax Redis, long enough to catch replays inside any realistic
|
|
47
|
+
// race window).
|
|
48
|
+
|
|
49
|
+
import { createHash } from "node:crypto";
|
|
50
|
+
import type Redis from "ioredis";
|
|
51
|
+
|
|
52
|
+
function hashToken(token: string): string {
|
|
53
|
+
return createHash("sha256").update(token).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createSingleUseTokenStore(prefixes: {
|
|
57
|
+
readonly tokenPrefix: string;
|
|
58
|
+
readonly subjectPrefix: string;
|
|
59
|
+
readonly burnPrefix: string;
|
|
60
|
+
}) {
|
|
61
|
+
function tokenKey(token: string): string {
|
|
62
|
+
return `${prefixes.tokenPrefix}${hashToken(token)}`;
|
|
63
|
+
}
|
|
64
|
+
// Builds the forward key from an already-hashed value (e.g. read back
|
|
65
|
+
// from the by-subject entry) — does NOT hash again. Kept separate from
|
|
66
|
+
// tokenKey() (which hashes a raw token) so a double-hash mistake is
|
|
67
|
+
// visible at the call site instead of silently no-op'ing a delete.
|
|
68
|
+
function forwardKeyForHash(tokenHash: string): string {
|
|
69
|
+
return `${prefixes.tokenPrefix}${tokenHash}`;
|
|
70
|
+
}
|
|
71
|
+
function subjectKey(subjectId: string): string {
|
|
72
|
+
return `${prefixes.subjectPrefix}${subjectId}`;
|
|
73
|
+
}
|
|
74
|
+
function burnKey(token: string): string {
|
|
75
|
+
return `${prefixes.burnPrefix}${hashToken(token)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Stores the pair bidirectionally and sets TTL on both keys. Idempotent —
|
|
79
|
+
// re-writing the same token/subject pair is fine. The by-subject value is
|
|
80
|
+
// the token's hash, not the token — see file header.
|
|
81
|
+
async function store(
|
|
82
|
+
redis: Redis,
|
|
83
|
+
args: { subjectId: string; token: string; ttlSeconds: number },
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
await Promise.all([
|
|
86
|
+
redis.set(tokenKey(args.token), args.subjectId, "EX", args.ttlSeconds),
|
|
87
|
+
redis.set(subjectKey(args.subjectId), hashToken(args.token), "EX", args.ttlSeconds),
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Lookup: subject for a token. Null when the token doesn't (or no longer)
|
|
92
|
+
// exist (expired, already consumed, or invalid).
|
|
93
|
+
async function getSubjectForToken(redis: Redis, token: string): Promise<string | null> {
|
|
94
|
+
return redis.get(tokenKey(token));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Deletes a still-live token for this subject, if one exists — both the
|
|
98
|
+
// forward entry (built from the hash already stored in the by-subject
|
|
99
|
+
// entry, never recovers the raw token) and the by-subject entry itself.
|
|
100
|
+
// Returns whether a live token existed. Deleting the by-subject entry
|
|
101
|
+
// here too (not just the forward key) avoids leaving a dangling hash
|
|
102
|
+
// pointing at nothing if the caller crashes before the following store().
|
|
103
|
+
async function invalidateExistingBySubject(redis: Redis, subjectId: string): Promise<boolean> {
|
|
104
|
+
const existingHash = await redis.get(subjectKey(subjectId));
|
|
105
|
+
if (existingHash === null) return false;
|
|
106
|
+
await Promise.all([
|
|
107
|
+
redis.del(forwardKeyForHash(existingHash)),
|
|
108
|
+
redis.del(subjectKey(subjectId)),
|
|
109
|
+
]);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// SET NX EX — atomic check-and-set. Returns "OK" when the key is new,
|
|
114
|
+
// null when it's already there.
|
|
115
|
+
async function burn(redis: Redis, token: string): Promise<"burned" | "already-used"> {
|
|
116
|
+
const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
|
|
117
|
+
return result === "OK" ? "burned" : "already-used";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cleanup after a successful confirm/accept — both lookup keys. The
|
|
121
|
+
// burn-key stays (prevents a replay for the rest of the burn TTL).
|
|
122
|
+
async function deleteBoth(
|
|
123
|
+
redis: Redis,
|
|
124
|
+
args: { subjectId: string; token: string },
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await Promise.all([redis.del(tokenKey(args.token)), redis.del(subjectKey(args.subjectId))]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Burn-release for a failed confirm/accept path (e.g. a DB error) so a
|
|
130
|
+
// legitimate retry isn't blocked by a stale burn marker.
|
|
131
|
+
async function unburn(redis: Redis, token: string): Promise<void> {
|
|
132
|
+
await redis.del(burnKey(token));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { store, getSubjectForToken, invalidateExistingBySubject, burn, deleteBoth, unburn };
|
|
136
|
+
}
|
|
@@ -141,7 +141,7 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
|
|
|
141
141
|
);
|
|
142
142
|
const jobId = requestRes.jobId;
|
|
143
143
|
const provider = await buildProvider(jobTenant);
|
|
144
|
-
await provider.write(
|
|
144
|
+
await provider.write(`exports/${jobTenant}/${jobId}.zip`, new Uint8Array([1, 2, 3]));
|
|
145
145
|
|
|
146
146
|
const result = await runExportJobs({
|
|
147
147
|
db: stack.db,
|
|
@@ -164,6 +164,6 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
|
|
|
164
164
|
expect(res.status).toBe(200);
|
|
165
165
|
const body = (await res.json()) as { data?: { url?: string } };
|
|
166
166
|
expect(body.data?.url).toMatch(/^memory:\/\//);
|
|
167
|
-
expect(body.data?.url).toContain(
|
|
167
|
+
expect(body.data?.url).toContain(`exports/${jobTenant}/${jobId}.zip`);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
@@ -211,7 +211,7 @@ async function seedDoneJobWithToken(): Promise<{ jobId: string; plainToken: stri
|
|
|
211
211
|
|
|
212
212
|
// 2. seed in-memory ZIP file at path that worker would write
|
|
213
213
|
const provider = await buildProvider(tenantA);
|
|
214
|
-
const storageKey =
|
|
214
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
215
215
|
await provider.write(storageKey, new Uint8Array([1, 2, 3]));
|
|
216
216
|
|
|
217
217
|
// 3. Run worker → done-flip + Token-Create
|
|
@@ -250,7 +250,7 @@ describe("download-by-token :: happy path", () => {
|
|
|
250
250
|
const result = body.data;
|
|
251
251
|
|
|
252
252
|
expect(result.url).toMatch(/^memory:\/\//);
|
|
253
|
-
expect(result.url).toContain(
|
|
253
|
+
expect(result.url).toContain(`exports/${tenantA}/${jobId}.zip`);
|
|
254
254
|
expect(result.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
255
255
|
|
|
256
256
|
// Audit-Update: useCount=1, lastUsedAt set, IP+UA persistiert
|
|
@@ -183,13 +183,13 @@ describe("runExportJobs :: happy path", () => {
|
|
|
183
183
|
bytesWritten: number | null;
|
|
184
184
|
}>;
|
|
185
185
|
expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
|
|
186
|
-
expect(row?.downloadStorageKey).toBe(
|
|
186
|
+
expect(row?.downloadStorageKey).toBe(`exports/${tenantA}/${jobId}.zip`);
|
|
187
187
|
expect(row?.expiresAt).not.toBeNull();
|
|
188
188
|
expect(row?.bytesWritten).toBeGreaterThan(0);
|
|
189
189
|
|
|
190
190
|
// ZIP wirklich im Storage
|
|
191
191
|
const provider = await buildProvider(tenantA);
|
|
192
|
-
expect(await provider.exists(
|
|
192
|
+
expect(await provider.exists(`exports/${tenantA}/${jobId}.zip`)).toBe(true);
|
|
193
193
|
});
|
|
194
194
|
|
|
195
195
|
test("ZIP ist real entpackbar via Info-ZIP + enthaelt bundle.json", async () => {
|
|
@@ -203,7 +203,7 @@ describe("runExportJobs :: happy path", () => {
|
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
const provider = await buildProvider(tenantA);
|
|
206
|
-
const zipBytes = await provider.read(
|
|
206
|
+
const zipBytes = await provider.read(`exports/${tenantA}/${jobId}.zip`);
|
|
207
207
|
|
|
208
208
|
// Real-Decoder-Roundtrip via Info-ZIP unzip
|
|
209
209
|
const dir = await mkdtemp(join(tmpdir(), "kumiko-worker-test-"));
|
|
@@ -291,7 +291,7 @@ describe("runExportJobs :: stale-detection", () => {
|
|
|
291
291
|
const jobId = await seedPendingJob();
|
|
292
292
|
const T = getTemporal();
|
|
293
293
|
const twoHoursAgo = T.Instant.fromEpochMilliseconds(Date.now() - 2 * 60 * 60 * 1000);
|
|
294
|
-
const storageKey =
|
|
294
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
295
295
|
|
|
296
296
|
// Simuliert real-Pfad: claim-update hatte status=running + storageKey
|
|
297
297
|
// gesetzt + ZIP geschrieben. Worker dann gecrashed (kein done-flip).
|
|
@@ -335,7 +335,7 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
335
335
|
const longAgo = T.Instant.fromEpochMilliseconds(
|
|
336
336
|
Date.now() - 365 * 24 * 60 * 60 * 1000, // 1 Jahr ago
|
|
337
337
|
);
|
|
338
|
-
const storageKey =
|
|
338
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
339
339
|
const provider = await buildProvider(tenantA);
|
|
340
340
|
await provider.write(storageKey, new Uint8Array([1, 2, 3]));
|
|
341
341
|
|
|
@@ -374,7 +374,7 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
374
374
|
const jobId = await seedPendingJob();
|
|
375
375
|
const T = getTemporal();
|
|
376
376
|
const oneHourAgo = T.Instant.fromEpochMilliseconds(Date.now() - 60 * 60 * 1000);
|
|
377
|
-
const storageKey =
|
|
377
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
378
378
|
const provider = await buildProvider(tenantA);
|
|
379
379
|
await provider.write(storageKey, new Uint8Array([4, 5, 6]));
|
|
380
380
|
|
|
@@ -406,7 +406,7 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
406
406
|
const jobId = await seedPendingJob();
|
|
407
407
|
const T = getTemporal();
|
|
408
408
|
const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
|
409
|
-
const storageKey =
|
|
409
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
410
410
|
const provider = await buildProvider(tenantA);
|
|
411
411
|
await provider.write(storageKey, new Uint8Array([7, 8, 9]));
|
|
412
412
|
|
|
@@ -505,11 +505,11 @@ describe("runExportJobs :: concurrency", () => {
|
|
|
505
505
|
}>;
|
|
506
506
|
expect(rows).toHaveLength(1);
|
|
507
507
|
expect(rows[0]?.status).toBe(EXPORT_JOB_STATUS.Done);
|
|
508
|
-
expect(rows[0]?.downloadStorageKey).toBe(
|
|
508
|
+
expect(rows[0]?.downloadStorageKey).toBe(`exports/${tenantA}/${jobId}.zip`);
|
|
509
509
|
|
|
510
510
|
// Storage hat genau ein ZIP — kein Race-induziertes Doppel-Schreiben
|
|
511
511
|
const provider = await buildProvider(tenantA);
|
|
512
|
-
expect(await provider.exists(
|
|
512
|
+
expect(await provider.exists(`exports/${tenantA}/${jobId}.zip`)).toBe(true);
|
|
513
513
|
});
|
|
514
514
|
});
|
|
515
515
|
|
|
@@ -875,7 +875,7 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
875
875
|
// Storage-Cleanup-Pass fuer failed-Jobs: sofort ZIP loeschen (kein
|
|
876
876
|
// Grace), DB-Spalte nullen.
|
|
877
877
|
const jobId = await seedPendingJob();
|
|
878
|
-
const storageKey =
|
|
878
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
879
879
|
|
|
880
880
|
// ZIP in storage seeden + Job manuell auf failed mit storageKey
|
|
881
881
|
// (simuliert orphan-state nach Worker-crash).
|
|
@@ -1050,7 +1050,7 @@ describe("runExportJobs :: Atom 3c file-binaries", () => {
|
|
|
1050
1050
|
|
|
1051
1051
|
// ZIP entpacken + file-bytes verifizieren
|
|
1052
1052
|
const provider = await buildProvider(tenantA);
|
|
1053
|
-
const zipBytes = await provider.read(
|
|
1053
|
+
const zipBytes = await provider.read(`exports/${tenantA}/${jobId}.zip`);
|
|
1054
1054
|
const dir = await mkdtemp(join(tmpdir(), "kumiko-3c-test-"));
|
|
1055
1055
|
try {
|
|
1056
1056
|
const zipPath = join(dir, "out.zip");
|
|
@@ -1155,7 +1155,7 @@ describe("runExportJobs :: Atom 3c file-binaries", () => {
|
|
|
1155
1155
|
expect(result.completedJobIds).toContain(jobId);
|
|
1156
1156
|
|
|
1157
1157
|
const provider = await buildProvider(tenantA);
|
|
1158
|
-
const zipBytes = await provider.read(
|
|
1158
|
+
const zipBytes = await provider.read(`exports/${tenantA}/${jobId}.zip`);
|
|
1159
1159
|
const dir = await mkdtemp(join(tmpdir(), "kumiko-3c-malicious-"));
|
|
1160
1160
|
try {
|
|
1161
1161
|
const zipPath = join(dir, "out.zip");
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.285.2",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "export ZIPs write to exports/{tenantId}/{jobId}.zip, enabling one bucket-wide S3 lifecycle rule",
|
|
6
|
+
"detail": "buildExportStorageKey previously put the tenant first ({tenantId}/exports/{jobId}.zip), so no S3 prefix rule could target \"all tenants, then /exports/\" without also matching normal user uploads in the same bucket. The key now starts with the fixed exports/ segment via the new @cosmicdrift/kumiko-framework/files tenantExportPrefix() helper, tenant stays as the second segment. downloadStorageKey is stored per job and never recomputed, so existing rows keep resolving through their old key — no migration needed. tenantStoragePrefixes() (also new in framework/files) lists every prefix a tenant's binaries can live under; files-tenant-data's fileRefStorageDestroyHook now sweeps all of them on tenant-destroy instead of only {tenantId}/, closing a gap where a destroyed tenant's decrypted GDPR export bundles would have survived under the new exports/ prefix."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.241.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -58,6 +58,7 @@ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
58
58
|
import {
|
|
59
59
|
createZipStream,
|
|
60
60
|
type FileStorageProvider,
|
|
61
|
+
tenantExportPrefix,
|
|
61
62
|
type ZipEntry,
|
|
62
63
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
63
64
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -709,9 +710,7 @@ function systemTenantDb(db: DbConnection, tenantId: TenantId) {
|
|
|
709
710
|
}
|
|
710
711
|
|
|
711
712
|
function buildExportStorageKey(job: JobRow): string {
|
|
712
|
-
|
|
713
|
-
// job.id ist UUID → URL-safe.
|
|
714
|
-
return `${job.requestedFromTenantId}/exports/${job.id}.zip`;
|
|
713
|
+
return `${tenantExportPrefix(job.requestedFromTenantId)}${job.id}.zip`;
|
|
715
714
|
}
|
|
716
715
|
|
|
717
716
|
/**
|