@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.
@@ -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(`${jobTenant}/exports/${jobId}.zip`, new Uint8Array([1, 2, 3]));
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(`${jobTenant}/exports/${jobId}.zip`);
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 = `${tenantA}/exports/${jobId}.zip`;
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`)).toBe(true);
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(`${tenantA}/exports/${jobId}.zip`);
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 = `${tenantA}/exports/${jobId}.zip`;
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 = `${tenantA}/exports/${jobId}.zip`;
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 = `${tenantA}/exports/${jobId}.zip`;
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 = `${tenantA}/exports/${jobId}.zip`;
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`)).toBe(true);
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 = `${tenantA}/exports/${jobId}.zip`;
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`);
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
- // Tenant-prefix damit der Storage-Layout pro-Tenant separat liegt.
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
  /**