@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.
- package/package.json +10 -9
- package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
- package/src/audit/__tests__/audit.integration.test.ts +3 -1
- package/src/audit/__tests__/constants.test.ts +6 -1
- package/src/audit/constants.ts +10 -1
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +2 -0
- package/src/audit/web/audit-log-detail-screen.tsx +7 -3
- package/src/audit/web/audit-log-screen.tsx +27 -29
- package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
- package/src/auth-email-password/seeding.ts +3 -1
- package/src/auth-mfa/hmac-token-codec.ts +75 -0
- package/src/auth-mfa/mfa-challenge-token.ts +10 -65
- package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/channel-email/__tests__/email-channel.test.ts +19 -0
- package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
- package/src/channel-email/email-channel.ts +14 -7
- package/src/channel-email/pii-guard.ts +8 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/feature.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
- package/src/crypto-shredding/index.ts +2 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
- package/src/file-derivatives/feature.ts +2 -2
- package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
- package/src/files-provider-s3/s3-provider.ts +45 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
- package/src/files-tenant-data/changes.json +1 -0
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
- package/src/files-tenant-data/hooks.ts +93 -0
- package/src/files-tenant-data/index.ts +58 -0
- package/src/form-draft/db/queries/cleanup.ts +17 -0
- package/src/form-draft/handlers/cleanup.job.ts +20 -10
- package/src/form-draft/handlers/discard.write.ts +25 -7
- package/src/form-draft/handlers/get.query.ts +4 -2
- package/src/form-draft/handlers/list.query.ts +4 -1
- package/src/form-draft/lookup.ts +12 -5
- package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/handlers/retry.write.ts +26 -6
- package/src/jobs/i18n.ts +3 -0
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
- package/src/tenant/changes.json +1 -1
- package/src/tenant/constants.ts +5 -1
- package/src/tenant/handlers/remove-member.write.ts +12 -0
- package/src/tenant/handlers/update-member-roles.write.ts +10 -37
- package/src/tenant/last-tenant-admin.ts +39 -0
- package/src/tenant/screens.ts +2 -0
- package/src/tenant/seeding.ts +3 -1
- package/src/tenant-lifecycle/feature.ts +1 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
- package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/src/tenant-lifecycle/stages.ts +5 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
- package/src/user/changes.json +12 -5
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
- package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
- package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
- 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" ?
|
|
23
|
-
const replyTo = typeof data["replyTo"] === "string" ?
|
|
24
|
-
const
|
|
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 {
|
|
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)
|
|
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: [
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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 {
|
|
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,
|