@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.223.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.
Files changed (72) hide show
  1. package/package.json +10 -9
  2. package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
  3. package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
  4. package/src/audit/__tests__/audit.integration.test.ts +3 -1
  5. package/src/audit/__tests__/constants.test.ts +6 -1
  6. package/src/audit/constants.ts +10 -1
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +2 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +7 -3
  10. package/src/audit/web/audit-log-screen.tsx +27 -29
  11. package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
  12. package/src/auth-email-password/seeding.ts +3 -1
  13. package/src/auth-mfa/hmac-token-codec.ts +75 -0
  14. package/src/auth-mfa/mfa-challenge-token.ts +10 -65
  15. package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
  16. package/src/cap-counter/stock-cap-guard.ts +1 -1
  17. package/src/channel-email/__tests__/email-channel.test.ts +19 -0
  18. package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
  19. package/src/channel-email/email-channel.ts +14 -7
  20. package/src/channel-email/pii-guard.ts +8 -0
  21. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
  22. package/src/crypto-shredding/constants.ts +1 -0
  23. package/src/crypto-shredding/feature.ts +6 -1
  24. package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
  25. package/src/crypto-shredding/index.ts +2 -0
  26. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
  27. package/src/file-derivatives/feature.ts +2 -2
  28. package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
  29. package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
  30. package/src/files-provider-s3/s3-provider.ts +45 -0
  31. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
  32. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
  33. package/src/files-tenant-data/changes.json +1 -0
  34. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
  35. package/src/files-tenant-data/hooks.ts +93 -0
  36. package/src/files-tenant-data/index.ts +58 -0
  37. package/src/form-draft/db/queries/cleanup.ts +17 -0
  38. package/src/form-draft/handlers/cleanup.job.ts +20 -10
  39. package/src/form-draft/handlers/discard.write.ts +25 -7
  40. package/src/form-draft/handlers/get.query.ts +4 -2
  41. package/src/form-draft/handlers/list.query.ts +4 -1
  42. package/src/form-draft/lookup.ts +12 -5
  43. package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
  44. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
  45. package/src/jobs/constants.ts +1 -0
  46. package/src/jobs/handlers/retry.write.ts +26 -6
  47. package/src/jobs/i18n.ts +3 -0
  48. package/src/sessions/session-callbacks.ts +1 -0
  49. package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
  50. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
  51. package/src/tenant/changes.json +1 -1
  52. package/src/tenant/constants.ts +5 -1
  53. package/src/tenant/handlers/remove-member.write.ts +12 -0
  54. package/src/tenant/handlers/update-member-roles.write.ts +10 -37
  55. package/src/tenant/last-tenant-admin.ts +39 -0
  56. package/src/tenant/screens.ts +2 -0
  57. package/src/tenant/seeding.ts +3 -1
  58. package/src/tenant-lifecycle/feature.ts +1 -0
  59. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
  60. package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  61. package/src/tenant-lifecycle/stages.ts +5 -0
  62. package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
  63. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
  64. package/src/user/changes.json +12 -5
  65. package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
  66. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
  67. package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
  68. package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
  69. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
  70. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
  71. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
  72. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
@@ -65,6 +65,25 @@ describe("email channel envelope", () => {
65
65
  expect(sent.headers).toBeUndefined();
66
66
  });
67
67
 
68
+ test("non-string header values are filtered out", async () => {
69
+ const transport = createInMemoryTransport();
70
+ const message: ChannelMessage = {
71
+ notificationType: "x",
72
+ title: "t",
73
+ body: "b",
74
+ data: {
75
+ subject: "t",
76
+ body: "b",
77
+ headers: { "X-Ok": "v", "X-Bad": 42 },
78
+ },
79
+ };
80
+ await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
81
+
82
+ const [sent] = transport.sent;
83
+ if (!sent) throw new Error("expected a sent mail");
84
+ expect(sent.headers).toEqual({ "X-Ok": "v" });
85
+ });
86
+
68
87
  test("a non-object headers value is ignored, not forwarded", async () => {
69
88
  const transport = createInMemoryTransport();
70
89
  const message: ChannelMessage = {
@@ -45,6 +45,17 @@ describe("guardEmailMessage", () => {
45
45
  ).toThrow(/from address is a PII ciphertext/);
46
46
  });
47
47
 
48
+ test("ciphertext in a custom header is refused", () => {
49
+ expect(() =>
50
+ guardEmailMessage({
51
+ to: "ok@example.com",
52
+ headers: { "In-Reply-To": CIPHERTEXT },
53
+ subject: "Hi",
54
+ html: "x",
55
+ }),
56
+ ).toThrow(/header "In-Reply-To" is a PII ciphertext/);
57
+ });
58
+
48
59
  test("ciphertext Reply-To is refused", () => {
49
60
  expect(() =>
50
61
  guardEmailMessage({ to: "ok@example.com", replyTo: CIPHERTEXT, subject: "Hi", html: "x" }),
@@ -13,19 +13,26 @@ import type { EmailTransport } from "./types";
13
13
  // the notification's email template echoes it out of the notify() call, since
14
14
  // buildMessage collapses the raw data to the template's result. Content
15
15
  // (subject, html) is rendered; these three are copied straight to the message.
16
+ function stringHeaders(raw: unknown): Readonly<Record<string, string>> | undefined {
17
+ if (raw === undefined || raw === null || typeof raw !== "object" || Array.isArray(raw)) {
18
+ return undefined;
19
+ }
20
+ const out: Record<string, string> = {};
21
+ for (const [key, value] of Object.entries(raw)) {
22
+ if (typeof value === "string") out[key] = value;
23
+ }
24
+ return Object.keys(out).length > 0 ? out : undefined;
25
+ }
26
+
16
27
  function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined): {
17
28
  from?: string;
18
29
  replyTo?: string;
19
30
  headers?: Readonly<Record<string, string>>;
20
31
  } {
21
32
  if (!data) return {};
22
- const from = typeof data["from"] === "string" ? (data["from"] as string) : undefined;
23
- const replyTo = typeof data["replyTo"] === "string" ? (data["replyTo"] as string) : undefined;
24
- const raw = data["headers"];
25
- const headers =
26
- raw && typeof raw === "object" && !Array.isArray(raw)
27
- ? (raw as Readonly<Record<string, string>>)
28
- : undefined;
33
+ const from = typeof data["from"] === "string" ? data["from"] : undefined;
34
+ const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
35
+ const headers = stringHeaders(data["headers"]);
29
36
  return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
30
37
  }
31
38
 
@@ -26,6 +26,14 @@ export function guardEmailMessage(message: EmailMessage): EmailMessage {
26
26
  );
27
27
  }
28
28
  }
29
+ for (const [headerName, headerValue] of Object.entries(message.headers ?? {})) {
30
+ if (typeof headerValue === "string" && headerValue.includes(CIPHERTEXT_MARKER)) {
31
+ throw new Error(
32
+ `[channel-email] refusing to send: header "${headerName}" is a PII ciphertext ` +
33
+ `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
34
+ );
35
+ }
36
+ }
29
37
  const leaking =
30
38
  message.subject.includes(CIPHERTEXT_MARKER) || message.html.includes(CIPHERTEXT_MARKER);
31
39
  if (!leaking) return message;
@@ -87,10 +87,7 @@ export const setProfileWrite = defineWriteHandler({
87
87
  const parseError = e instanceof Error ? e.message : String(e);
88
88
  return writeFailure(
89
89
  new UnprocessableError("compliance_override_invalid_json", {
90
- details: {
91
- reason: "compliance_override_invalid_json",
92
- parseError,
93
- },
90
+ details: { parseError },
94
91
  }),
95
92
  );
96
93
  }
@@ -1,6 +1,7 @@
1
1
  export const CRYPTO_SHREDDING_FEATURE_NAME = "crypto-shredding";
2
2
 
3
3
  export const SUBJECT_FORGOTTEN_EVENT_NAME = "crypto-shredding:event:subject-forgotten";
4
+ export const SUBJECT_FORGET_DENIED_EVENT_NAME = "crypto-shredding:event:forget-denied";
4
5
 
5
6
  export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
6
7
 
@@ -1,6 +1,10 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { CRYPTO_SHREDDING_FEATURE_NAME } from "./constants";
3
- import { forgetSubjectWrite, subjectForgottenSchema } from "./handlers/forget-subject.write";
3
+ import {
4
+ forgetSubjectWrite,
5
+ subjectForgetDeniedSchema,
6
+ subjectForgottenSchema,
7
+ } from "./handlers/forget-subject.write";
4
8
 
5
9
  export function createCryptoShreddingFeature(): FeatureDefinition {
6
10
  return defineFeature(CRYPTO_SHREDDING_FEATURE_NAME, (r) => {
@@ -13,6 +17,7 @@ export function createCryptoShreddingFeature(): FeatureDefinition {
13
17
  });
14
18
 
15
19
  r.defineEvent("subject-forgotten", subjectForgottenSchema);
20
+ r.defineEvent("forget-denied", subjectForgetDeniedSchema);
16
21
  r.writeHandler(forgetSubjectWrite);
17
22
  });
18
23
  }
@@ -33,6 +33,7 @@ import {
33
33
  } from "../../user-data-rights";
34
34
  import {
35
35
  CRYPTO_SHREDDING_AGGREGATE_TYPE,
36
+ SUBJECT_FORGET_DENIED_EVENT_NAME,
36
37
  SUBJECT_FORGOTTEN_EVENT_NAME,
37
38
  TARGET_TENANT_NOT_ADMIN_TENANT,
38
39
  } from "../constants";
@@ -53,6 +54,14 @@ export const subjectForgottenSchema = z.object({
53
54
  forgottenBy: z.string().min(1),
54
55
  });
55
56
 
57
+ export const subjectForgetDeniedSchema = z.object({
58
+ subjectKey: z.string().min(1),
59
+ reason: z.string().min(10),
60
+ forgottenBy: z.string().min(1),
61
+ actorTenantId: z.string().min(1),
62
+ denial: z.string().min(1),
63
+ });
64
+
56
65
  type SubjectIdInput = z.infer<typeof subjectIdSchema>;
57
66
 
58
67
  // Tenant-scope guard (mh#349): DataProtectionOfficer is a tenant-scoped role,
@@ -128,7 +137,22 @@ export const forgetSubjectWrite = defineWriteHandler({
128
137
  event.user,
129
138
  raw,
130
139
  );
131
- if (tenantScopeDenial) return tenantScopeDenial;
140
+ if (tenantScopeDenial) {
141
+ // Denied cross-tenant probes must still leave an audit trail (fw#2348).
142
+ await ctx.unsafeAppendEvent({
143
+ aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
144
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
145
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
146
+ payload: {
147
+ subjectKey,
148
+ reason: event.payload.reason,
149
+ forgottenBy: event.user.id,
150
+ actorTenantId: event.user.tenantId,
151
+ denial: tenantScopeDenial.error.code,
152
+ },
153
+ });
154
+ return tenantScopeDenial;
155
+ }
132
156
 
133
157
  // Erase BEFORE the audit append: if the append throws, the key is gone
134
158
  // but no event exists — a retry is a no-op erase plus the event. The
@@ -1,11 +1,13 @@
1
1
  export {
2
2
  CRYPTO_SHREDDING_AGGREGATE_TYPE,
3
3
  CRYPTO_SHREDDING_FEATURE_NAME,
4
+ SUBJECT_FORGET_DENIED_EVENT_NAME,
4
5
  SUBJECT_FORGOTTEN_EVENT_NAME,
5
6
  } from "./constants";
6
7
  export { createCryptoShreddingFeature } from "./feature";
7
8
  export {
8
9
  forgetSubjectSchema,
10
+ subjectForgetDeniedSchema,
9
11
  subjectForgottenSchema,
10
12
  subjectIdSchema,
11
13
  } from "./handlers/forget-subject.write";
@@ -31,6 +31,8 @@ import {
31
31
  import { z } from "zod";
32
32
  import { AuditQueries } from "../../audit/constants";
33
33
  import { createAuditFeature } from "../../audit/feature";
34
+ import { createConfigFeature } from "../../config";
35
+ import { createTenantFeature } from "../../tenant";
34
36
  import { fieldDefinitionEntity } from "../entity";
35
37
  import { createCustomFieldsFeature } from "../feature";
36
38
  import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
@@ -79,7 +81,13 @@ let stack: TestStack;
79
81
 
80
82
  beforeAll(async () => {
81
83
  stack = await setupTestStack({
82
- features: [customFieldsFeature, propertyFeature, createAuditFeature()],
84
+ features: [
85
+ customFieldsFeature,
86
+ propertyFeature,
87
+ createConfigFeature(),
88
+ createTenantFeature(),
89
+ createAuditFeature(),
90
+ ],
83
91
  });
84
92
  await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
85
93
  await unsafeCreateEntityTable(stack.db, propertyEntity);
@@ -58,7 +58,7 @@ export type FileDerivativesOptions = {
58
58
  type PublicVariantQueryResult = {
59
59
  readonly dataBase64: string;
60
60
  readonly mimeType: string;
61
- readonly storageKey: string;
61
+ readonly revision: string;
62
62
  } | null;
63
63
 
64
64
  // file-derivatives — derive-on-first-use file variants (thumbnails,
@@ -168,7 +168,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
168
168
  if (!result) return c.text("not found", 404);
169
169
 
170
170
  const bytes = Buffer.from(result.dataBase64, "base64");
171
- const etag = computeRevisionEtag([tenantId, fileRefId, variant, result.storageKey]);
171
+ const etag = computeRevisionEtag([tenantId, fileRefId, variant, result.revision]);
172
172
  return cachedResponse(c.req.raw, {
173
173
  body: bytes,
174
174
  etag,
@@ -17,6 +17,7 @@
17
17
  // `anonymousAccess` resolution — tenant provenance there depends on the
18
18
  // consumer's `resolverTrust`/anonymousAccess setup, not the host.
19
19
 
20
+ import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
20
21
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
21
22
  import { resolveFieldVariant } from "@cosmicdrift/kumiko-framework/derivatives";
22
23
  import {
@@ -35,10 +36,7 @@ type FileRefRow = {
35
36
  readonly fieldName: string | null;
36
37
  };
37
38
 
38
- // A declared variant with no explicit gate in `isPublic` is implicitly
39
- // public as soon as the entityType-level check returns `true` — `fieldName`
40
- // and `variant` let a predicate opt individual fields/variants out (e.g. a
41
- // cheap `thumb` served publicly while an expensive `full` stays gated).
39
+ // Declared variants are implicitly public once the entityType check passes — `fieldName`/`variant` allow per-variant opt-out.
42
40
  export type DerivativePublicPredicateArgs = {
43
41
  readonly entityId: string;
44
42
  readonly tenantId: TenantId;
@@ -136,7 +134,8 @@ export const publicVariantQuery = defineQueryHandler({
136
134
  return {
137
135
  dataBase64: Buffer.from(bytes).toString("base64"),
138
136
  mimeType: result.mimeType,
139
- storageKey: result.storageKey,
137
+ // Opaque revision for ETag — never expose the internal storageKey over /api/query.
138
+ revision: computeRevisionEtag([result.storageKey]),
140
139
  };
141
140
  },
142
141
  });
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { S3ProviderConfig } from "../s3-provider";
3
- import { createS3Provider, resolveForcePathStyle, resolveVirtualHostedStyle } from "../s3-provider";
3
+ import {
4
+ collectPaginatedKeys,
5
+ createS3Provider,
6
+ resolveForcePathStyle,
7
+ resolveVirtualHostedStyle,
8
+ } from "../s3-provider";
4
9
 
5
10
  const baseConfig: S3ProviderConfig = {
6
11
  bucket: "b",
@@ -66,6 +71,58 @@ describe("resolveVirtualHostedStyle (inverse of forcePathStyle)", () => {
66
71
  });
67
72
  });
68
73
 
74
+ // collectPaginatedKeys owns the isTruncated/startAfter loop `list()` runs on
75
+ // top of a live client.list() — tested hermetically here (a real 1000+ key
76
+ // bucket would make this slow and flaky) so a regression in the loop itself
77
+ // (dropped page, infinite loop, wrong startAfter) fails fast without MinIO.
78
+ type FakePage = {
79
+ readonly contents?: ReadonlyArray<{ readonly key: string }>;
80
+ readonly isTruncated?: boolean;
81
+ };
82
+
83
+ describe("collectPaginatedKeys", () => {
84
+ test("stops after a single non-truncated page", async () => {
85
+ const keys = await collectPaginatedKeys(async () => ({
86
+ contents: [{ key: "a" }, { key: "b" }],
87
+ isTruncated: false,
88
+ }));
89
+ expect(keys).toEqual(["a", "b"]);
90
+ });
91
+
92
+ test("follows isTruncated across multiple pages, passing the last key as startAfter", async () => {
93
+ const requestedStartAfters: Array<string | undefined> = [];
94
+ const pages: readonly FakePage[] = [
95
+ { contents: [{ key: "a" }, { key: "b" }], isTruncated: true },
96
+ { contents: [{ key: "c" }, { key: "d" }], isTruncated: true },
97
+ { contents: [{ key: "e" }], isTruncated: false },
98
+ ];
99
+ let call = 0;
100
+ const keys = await collectPaginatedKeys(async (startAfter) => {
101
+ requestedStartAfters.push(startAfter);
102
+ const page = pages[call];
103
+ call += 1;
104
+ if (!page) throw new Error("unexpected extra page request");
105
+ return page;
106
+ });
107
+
108
+ expect(keys).toEqual(["a", "b", "c", "d", "e"]);
109
+ expect(requestedStartAfters).toEqual([undefined, "b", "d"]);
110
+ });
111
+
112
+ test("an empty page stops the loop even if isTruncated is (incorrectly) true", async () => {
113
+ const keys = await collectPaginatedKeys(async () => ({
114
+ contents: [],
115
+ isTruncated: true,
116
+ }));
117
+ expect(keys).toEqual([]);
118
+ });
119
+
120
+ test("a page with no contents field is treated as empty", async () => {
121
+ const keys = await collectPaginatedKeys(async () => ({}));
122
+ expect(keys).toEqual([]);
123
+ });
124
+ });
125
+
69
126
  // presign is a pure local signing operation (HMAC, no network), so it tests
70
127
  // hermetically with dummy credentials. Proves Bun actually signs contentDisposition
71
128
  // as the response-content-disposition query param — otherwise a download would
@@ -29,6 +29,15 @@ import {
29
29
  // AWS-CLI: `aws s3api put-bucket-lifecycle-configuration --bucket <name>
30
30
  // --lifecycle-configuration file://lifecycle.json`. Hetzner Object Storage
31
31
  // + R2 + Minio supporten dieselbe Syntax.
32
+ //
33
+ // =============================================================================
34
+ // Operator-Pflicht-Setup (IAM: list permission)
35
+ // =============================================================================
36
+ //
37
+ // GDPR-forget/tenant-destroy erasure (user-data-rights-defaults) calls
38
+ // `list()` to find derivative/variant keys alongside the original. A bucket
39
+ // policy granting only `s3:DeleteObject` (no `s3:ListBucket`) makes every
40
+ // forget run fail — grant both, not just delete.
32
41
 
33
42
  const STREAM_PART_SIZE = 5 * 1024 * 1024;
34
43
 
@@ -48,6 +57,32 @@ export type S3ProviderConfig = {
48
57
  readonly forcePathStyle?: boolean;
49
58
  };
50
59
 
60
+ // A single S3 list() page, generalized just enough that collectPaginatedKeys
61
+ // can be tested against a fake sequence of pages, no live bucket needed.
62
+ type S3ListPage = {
63
+ readonly contents?: ReadonlyArray<{ readonly key: string }>;
64
+ readonly isTruncated?: boolean;
65
+ };
66
+
67
+ // Exported for unit testing — the loop is the load-bearing part of `list`:
68
+ // S3 caps a page at 1000 keys, and a derivative set for a heavily-rendered
69
+ // original can exceed that. Owns only the isTruncated/startAfter pagination;
70
+ // `fetchPage` closes over prefix/maxKeys/credentials.
71
+ export async function collectPaginatedKeys(
72
+ fetchPage: (startAfter: string | undefined) => Promise<S3ListPage>,
73
+ ): Promise<readonly string[]> {
74
+ const keys: string[] = [];
75
+ let startAfter: string | undefined;
76
+ for (;;) {
77
+ const page = await fetchPage(startAfter);
78
+ const contents = page.contents ?? [];
79
+ for (const obj of contents) keys.push(obj.key);
80
+ if (!page.isTruncated || contents.length === 0) break;
81
+ startAfter = contents[contents.length - 1]?.key;
82
+ }
83
+ return keys;
84
+ }
85
+
51
86
  // Exported for unit testing — the branch logic (explicit override vs.
52
87
  // auto-detect from endpoint) is small but load-bearing: Minio/R2 break
53
88
  // silently if the virtual-host-style is picked.
@@ -130,6 +165,16 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
130
165
  return client.exists(key);
131
166
  },
132
167
 
168
+ async list(prefix): Promise<readonly string[]> {
169
+ return collectPaginatedKeys((startAfter) =>
170
+ client.list({
171
+ prefix,
172
+ maxKeys: 1000,
173
+ ...(startAfter !== undefined && { startAfter }),
174
+ }),
175
+ );
176
+ },
177
+
133
178
  async getSignedUrl(
134
179
  key: string,
135
180
  expiresInSeconds: number,