@cosmicdrift/kumiko-samples 0.371.1 → 0.372.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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/auth-mfa/db/queries.ts +5 -7
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +2 -1
- package/packages/bundled-features/src/config/web/index.ts +2 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +75 -34
- package/packages/bundled-features/src/crypto-shredding/resolve-file-bytes-deleter.ts +29 -0
- package/packages/bundled-features/src/data-retention/changes.json +6 -0
- package/packages/bundled-features/src/data-retention/index.ts +6 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +37 -213
- package/packages/bundled-features/src/delivery/changes.json +13 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +7 -2
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +2 -2
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +42 -23
- package/packages/bundled-features/src/delivery/recipient-address-seal.ts +72 -0
- package/packages/bundled-features/src/delivery/testing.ts +4 -1
- package/packages/bundled-features/src/delivery/unsubscribe.ts +23 -2
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +2 -2
- package/packages/bundled-features/src/files/changes.json +7 -0
- package/packages/bundled-features/src/personal-access-tokens/changes.json +12 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +15 -9
- package/packages/bundled-features/src/shared/index.ts +14 -0
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +206 -0
- package/packages/bundled-features/src/shared/purge-row-file-refs.ts +214 -0
- package/packages/bundled-features/src/template-resolver/changes.json +6 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -4
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +2 -1
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +1 -9
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +46 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +5 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +9 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +6 -0
- package/samples/apps/ui-walkthrough/src/features/rental/entities.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +27 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +9 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +22 -1
- package/samples/apps/use-all-bundled/package.json +3 -1
- package/samples/recipes/custom-handlers/src/feature.ts +3 -5
- package/samples/recipes/embedded-entity-form/src/feature.ts +1 -1
- package/samples/recipes/pdf-audit-export/README.md +1 -1
- package/samples/recipes/user-data-rights/README.md +22 -0
- package/samples/recipes/user-data-rights/src/retained-docs.ts +35 -0
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
assertNotifyTargetsOwnTenant,
|
|
25
25
|
createEscapeHatchReporter,
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
27
|
-
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
27
|
+
import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
28
28
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
29
29
|
import type { Redis } from "ioredis";
|
|
30
30
|
import { fileContextFromResolver } from "../file-foundation/index.ts";
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
} from "./db/queries/address-opt-outs.ts";
|
|
47
47
|
import { selectNotificationPreferences } from "./db/queries/preferences.ts";
|
|
48
48
|
import { notificationTypeNames } from "./notification-type-aliases.ts";
|
|
49
|
+
import { sealRecipientAddress } from "./recipient-address-seal.ts";
|
|
49
50
|
import {
|
|
50
51
|
type ChannelContext,
|
|
51
52
|
type ChannelMessage,
|
|
@@ -95,6 +96,9 @@ export type DeliveryServiceOptions = {
|
|
|
95
96
|
// Credentials source for inline-delivered chat channels (webhook URL / bot
|
|
96
97
|
// token). Jobs get ctx.secrets from the job runner instead.
|
|
97
98
|
readonly secrets?: SecretsContext;
|
|
99
|
+
// Seals the recipient address inside queued job payloads (tenant scope). Absent means the
|
|
100
|
+
// payload carries the plaintext address.
|
|
101
|
+
readonly addressCipher?: EnvelopeCipher;
|
|
98
102
|
// File storage for inline-delivered mails with attachments; jobs use their own ctx instead.
|
|
99
103
|
readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
|
|
100
104
|
// Tenant config for inline rendering (mail layout reads tenant name, locale);
|
|
@@ -134,6 +138,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
134
138
|
idempotencyRedis,
|
|
135
139
|
jobRunner,
|
|
136
140
|
secrets,
|
|
141
|
+
addressCipher,
|
|
137
142
|
filesForTenant,
|
|
138
143
|
tenantConfigFor,
|
|
139
144
|
escapeHatchAuditSink,
|
|
@@ -429,7 +434,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
429
434
|
channel.render ? DeliveryJobs.render : DeliveryJobs.send,
|
|
430
435
|
{
|
|
431
436
|
channelName: channel.name,
|
|
432
|
-
address,
|
|
437
|
+
...(await sealRecipientAddress(address, tenantId, addressCipher)),
|
|
433
438
|
tenantId,
|
|
434
439
|
recipientId,
|
|
435
440
|
notificationType,
|
|
@@ -29,8 +29,8 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
|
|
|
29
29
|
name: "unsubscribePageChrome",
|
|
30
30
|
schema: z.object({ locale: z.string().min(2).max(35) }),
|
|
31
31
|
access: { roles: access.systemAdmin },
|
|
32
|
-
// Only reachable via
|
|
33
|
-
//
|
|
32
|
+
// Only reachable via a system dispatch: the signed unsubscribe routes after verify(),
|
|
33
|
+
// or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
|
|
34
34
|
agent: { expose: false },
|
|
35
35
|
description:
|
|
36
36
|
"Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route to dress its pages like the mail, not meant for UI callers.",
|
|
@@ -96,11 +96,13 @@ export {
|
|
|
96
96
|
} from "./types.ts";
|
|
97
97
|
export {
|
|
98
98
|
type AddressUnsubscribeTokenPayload,
|
|
99
|
+
createUnsubscribePageChromeReader,
|
|
99
100
|
createUnsubscribeRoutes,
|
|
100
101
|
DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
|
|
101
102
|
renderUnsubscribeResultPage,
|
|
102
103
|
signAddressUnsubscribeToken,
|
|
103
104
|
signUnsubscribeToken,
|
|
105
|
+
type UnsubscribePageChromeReader,
|
|
104
106
|
type UnsubscribeResultPage,
|
|
105
107
|
type UnsubscribeRouteOptions,
|
|
106
108
|
type UnsubscribeTokenPayload,
|
|
@@ -26,6 +26,11 @@ import { appendAttemptEvent } from "./attempt-log.ts";
|
|
|
26
26
|
import { buildChannelContext } from "./channel-context.ts";
|
|
27
27
|
import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.ts";
|
|
28
28
|
import { collectChannels, redactedMessageOf } from "./delivery-service.ts";
|
|
29
|
+
import {
|
|
30
|
+
openRecipientAddress,
|
|
31
|
+
type RecipientAddressField,
|
|
32
|
+
recipientAddressFieldSchema,
|
|
33
|
+
} from "./recipient-address-seal.ts";
|
|
29
34
|
import type {
|
|
30
35
|
ChannelMessage,
|
|
31
36
|
DeliveryChannel,
|
|
@@ -41,9 +46,8 @@ const channelMessageSchema = z.object({
|
|
|
41
46
|
locale: z.string().optional(),
|
|
42
47
|
});
|
|
43
48
|
|
|
44
|
-
const
|
|
49
|
+
const renderJobBaseSchema = z.object({
|
|
45
50
|
channelName: z.string(),
|
|
46
|
-
address: z.string(),
|
|
47
51
|
tenantId: z.string(),
|
|
48
52
|
recipientId: z.string().nullable(),
|
|
49
53
|
notificationType: z.string(),
|
|
@@ -53,18 +57,22 @@ const renderJobPayloadSchema = z.object({
|
|
|
53
57
|
idempotencyKey: z.string().optional(),
|
|
54
58
|
});
|
|
55
59
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
const renderJobPayloadSchema = renderJobBaseSchema.and(recipientAddressFieldSchema);
|
|
61
|
+
|
|
62
|
+
const sendJobPayloadSchema = renderJobBaseSchema
|
|
63
|
+
.extend({
|
|
64
|
+
rendered: z
|
|
65
|
+
.object({
|
|
66
|
+
html: z.string(),
|
|
67
|
+
subject: z.string(),
|
|
68
|
+
text: z.string().optional(),
|
|
69
|
+
fromName: z.string().optional(),
|
|
70
|
+
replyTo: z.string().optional(),
|
|
71
|
+
attachments: z.array(emailAttachmentRefSchema).max(MAX_EMAIL_ATTACHMENTS).optional(),
|
|
72
|
+
})
|
|
73
|
+
.optional(),
|
|
74
|
+
})
|
|
75
|
+
.and(recipientAddressFieldSchema);
|
|
68
76
|
|
|
69
77
|
type RenderJobPayload = z.infer<typeof renderJobPayloadSchema>;
|
|
70
78
|
|
|
@@ -189,6 +197,22 @@ async function releasingKeyOnTerminalFailure<T>(
|
|
|
189
197
|
}
|
|
190
198
|
}
|
|
191
199
|
|
|
200
|
+
// A missing or rotated key must not hide the original failure, so the log row just omits the address.
|
|
201
|
+
async function addressForAttemptLog(
|
|
202
|
+
p: RecipientAddressField & { readonly tenantId: string },
|
|
203
|
+
ctx: JobContext,
|
|
204
|
+
): Promise<string | null> {
|
|
205
|
+
try {
|
|
206
|
+
return await openRecipientAddress(
|
|
207
|
+
p,
|
|
208
|
+
p.tenantId as TenantId, // @cast-boundary engine-payload — job payload string is the stream tenant
|
|
209
|
+
ctx.configEncryption,
|
|
210
|
+
);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
192
216
|
// The job runner stores err.message in the run row, and BullMQ keeps failedReason and
|
|
193
217
|
// the stack trace in Redis in plain text, so the rethrown error carries only the code.
|
|
194
218
|
// The redacted message goes to the log.
|
|
@@ -209,7 +233,7 @@ async function failAttempt(
|
|
|
209
233
|
db,
|
|
210
234
|
registry,
|
|
211
235
|
p.deliveryAttemptId,
|
|
212
|
-
entryFor(p, "failed", code, p
|
|
236
|
+
entryFor(p, "failed", code, await addressForAttemptLog(p, ctx)),
|
|
213
237
|
);
|
|
214
238
|
throw new Error(code);
|
|
215
239
|
}
|
|
@@ -280,18 +304,13 @@ async function sendStep(p: z.infer<typeof sendJobPayloadSchema>, ctx: JobContext
|
|
|
280
304
|
);
|
|
281
305
|
|
|
282
306
|
try {
|
|
283
|
-
const
|
|
307
|
+
const address = await openRecipientAddress(p, tenantId, ctx.configEncryption);
|
|
308
|
+
const result = await channel.send(address, toMessage(p), channelCtx, p.rendered);
|
|
284
309
|
await appendAttemptEvent(
|
|
285
310
|
db,
|
|
286
311
|
registry,
|
|
287
312
|
p.deliveryAttemptId,
|
|
288
|
-
entryFor(
|
|
289
|
-
p,
|
|
290
|
-
result.status,
|
|
291
|
-
result.error ?? null,
|
|
292
|
-
result.address ?? p.address,
|
|
293
|
-
result.confirmed,
|
|
294
|
-
),
|
|
313
|
+
entryFor(p, result.status, result.error ?? null, result.address ?? address, result.confirmed),
|
|
295
314
|
);
|
|
296
315
|
// A soft failure ends the job successfully, so no retry follows and the caller's
|
|
297
316
|
// retry must not be skipped as a duplicate.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
|
|
6
|
+
// Queued delivery jobs sit in Redis and the job-run table, so the recipient address travels
|
|
7
|
+
// sealed under the app master key. Without a master key it stays plaintext (`address`); jobs
|
|
8
|
+
// queued before sealing existed carry `address` too and keep working.
|
|
9
|
+
// Exactly one of the two fields: both or neither fails the payload parse.
|
|
10
|
+
export const recipientAddressFieldSchema = z.union([
|
|
11
|
+
z.object({ address: z.string(), sealedAddress: z.undefined().optional() }),
|
|
12
|
+
z.object({ sealedAddress: z.string(), address: z.undefined().optional() }),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export type RecipientAddressField = z.infer<typeof recipientAddressFieldSchema>;
|
|
16
|
+
|
|
17
|
+
// The cipher is shared with config values and PII entity fields and has no AAD, so the purpose is
|
|
18
|
+
// bound inside the authenticated plaintext. Without it a foreign ciphertext of the same tenant
|
|
19
|
+
// placed into sealedAddress would be decrypted and its plaintext sent to the channel. The tenant
|
|
20
|
+
// is bound there too: the env key provider ignores the encrypt scope, so a sealedAddress of tenant
|
|
21
|
+
// B would otherwise open inside a job of tenant A.
|
|
22
|
+
const RECIPIENT_ADDRESS_PURPOSE = "delivery.recipientAddress";
|
|
23
|
+
|
|
24
|
+
const sealedRecipientAddressSchema = z.object({
|
|
25
|
+
purpose: z.literal(RECIPIENT_ADDRESS_PURPOSE),
|
|
26
|
+
tenantId: z.string(),
|
|
27
|
+
address: z.string(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export async function sealRecipientAddress(
|
|
31
|
+
address: string,
|
|
32
|
+
tenantId: TenantId,
|
|
33
|
+
cipher: EnvelopeCipher | undefined,
|
|
34
|
+
): Promise<RecipientAddressField> {
|
|
35
|
+
if (!cipher) return { address };
|
|
36
|
+
const plaintext = JSON.stringify({ purpose: RECIPIENT_ADDRESS_PURPOSE, tenantId, address });
|
|
37
|
+
return { sealedAddress: await cipher.encrypt(plaintext, { tenantId }) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseSealedRecipientAddress(plaintext: string, tenantId: TenantId): string {
|
|
41
|
+
let decoded: unknown;
|
|
42
|
+
try {
|
|
43
|
+
decoded = JSON.parse(plaintext);
|
|
44
|
+
} catch {
|
|
45
|
+
decoded = undefined;
|
|
46
|
+
}
|
|
47
|
+
const parsed = sealedRecipientAddressSchema.safeParse(decoded);
|
|
48
|
+
if (!parsed.success || parsed.data.tenantId !== tenantId) {
|
|
49
|
+
throw new InternalError({
|
|
50
|
+
message: "delivery job: sealedAddress was not sealed as a delivery recipient address",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return parsed.data.address;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function openRecipientAddress(
|
|
57
|
+
payload: RecipientAddressField,
|
|
58
|
+
tenantId: TenantId,
|
|
59
|
+
cipher: EnvelopeCipher | undefined,
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
if (payload.sealedAddress === undefined) return payload.address;
|
|
62
|
+
if (!cipher) {
|
|
63
|
+
throw new InternalError({
|
|
64
|
+
message:
|
|
65
|
+
"delivery job: payload carries a sealed recipient address but this process has no master key (configEncryption); run the worker with the same KUMIKO_SECRETS_MASTER_KEY_V<n> as the web process",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return parseSealedRecipientAddress(
|
|
69
|
+
await cipher.decrypt(payload.sealedAddress, { tenantId }),
|
|
70
|
+
tenantId,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
10
10
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
11
11
|
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
12
|
-
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
12
|
+
import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
13
13
|
import type { Redis } from "ioredis";
|
|
14
14
|
import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.ts";
|
|
15
15
|
import { collectChannels, createDeliveryService } from "./delivery-service.ts";
|
|
@@ -25,6 +25,8 @@ export type CreateDeliveryTestContextOptions = {
|
|
|
25
25
|
readonly log?: Logger;
|
|
26
26
|
readonly jobRunner?: JobRunner;
|
|
27
27
|
readonly secrets?: SecretsContext;
|
|
28
|
+
// Seals queued job payload addresses and, like the boot wiring, doubles as the jobs' ctx.configEncryption.
|
|
29
|
+
readonly addressCipher?: EnvelopeCipher;
|
|
28
30
|
readonly idempotencyRedis?: Redis;
|
|
29
31
|
readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
|
|
30
32
|
readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
|
|
@@ -58,5 +60,6 @@ export function createDeliveryTestContext(
|
|
|
58
60
|
return {
|
|
59
61
|
deliveryService, // exposed so tests can inspect/call directly if needed
|
|
60
62
|
_notifyFactory: createDeliveryNotifyFactory(deliveryService),
|
|
63
|
+
...(options.addressCipher && { configEncryption: options.addressCipher }),
|
|
61
64
|
};
|
|
62
65
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ExtraRouteRejection,
|
|
4
4
|
type SignatureExtraRouteDeps,
|
|
5
5
|
type SignatureExtraRouteVerifyRequest,
|
|
6
|
+
type SystemDispatchArgs,
|
|
6
7
|
signatureRoute,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
8
9
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -423,7 +424,9 @@ export type UnsubscribeResultPage =
|
|
|
423
424
|
|
|
424
425
|
/** The result page the unsubscribe routes answer with, for apps serving their own
|
|
425
426
|
* (e.g. legacy) unsubscribe links. Texts come from the mail translations of `locale`;
|
|
426
|
-
* `chrome`
|
|
427
|
+
* `chrome` adds the tenant's brand row and footer: read it with
|
|
428
|
+
* createUnsubscribePageChromeReader for a tenant the server resolved itself (e.g. by
|
|
429
|
+
* Host), never for a tenantId taken from the query string or body.
|
|
427
430
|
* Served under runProdApp's default CSP the style block and the same-origin font pass;
|
|
428
431
|
* an https logo additionally needs its origin, e.g.
|
|
429
432
|
* `securityHeaders: { csp: { extend: { "img-src": [logoOrigin] } } }`. A hand-written CSP
|
|
@@ -454,10 +457,14 @@ const mailChromeSchema = z
|
|
|
454
457
|
})
|
|
455
458
|
.nullable();
|
|
456
459
|
|
|
460
|
+
type SystemQueryDispatch = {
|
|
461
|
+
readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
|
|
462
|
+
};
|
|
463
|
+
|
|
457
464
|
// Branding is decoration: an opt-out that went through must never turn into an
|
|
458
465
|
// error page because a footer resolver or the config read failed.
|
|
459
466
|
async function readPageChrome(
|
|
460
|
-
deps:
|
|
467
|
+
deps: SystemQueryDispatch,
|
|
461
468
|
tenantId: TenantId,
|
|
462
469
|
locale: string,
|
|
463
470
|
): Promise<MailChrome | null> {
|
|
@@ -477,6 +484,20 @@ async function readPageChrome(
|
|
|
477
484
|
}
|
|
478
485
|
}
|
|
479
486
|
|
|
487
|
+
export type UnsubscribePageChromeReader = (
|
|
488
|
+
tenantId: TenantId,
|
|
489
|
+
locale: string | undefined,
|
|
490
|
+
) => Promise<MailChrome | null>;
|
|
491
|
+
|
|
492
|
+
/** Reads the chrome for renderUnsubscribeResultPage in an app route without
|
|
493
|
+
* public access: build it once in the `wire` hook from its dispatchSystemQuery.
|
|
494
|
+
* Null without a mail renderer or when the read fails. */
|
|
495
|
+
export function createUnsubscribePageChromeReader(
|
|
496
|
+
deps: SystemQueryDispatch,
|
|
497
|
+
): UnsubscribePageChromeReader {
|
|
498
|
+
return (tenantId, locale) => readPageChrome(deps, tenantId, pageLocale(locale));
|
|
499
|
+
}
|
|
500
|
+
|
|
480
501
|
type PageScope = { readonly tenantId: TenantId | undefined; readonly locale: string | undefined };
|
|
481
502
|
|
|
482
503
|
// no-referrer on every page: the GET page carries the token in its URL, and the
|
|
@@ -2,7 +2,7 @@ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { isUuid, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
createTenantFileContext,
|
|
6
6
|
type FileContext,
|
|
7
7
|
type FileProviderResolver,
|
|
8
8
|
fileRefsTable,
|
|
@@ -44,7 +44,7 @@ export function fileContextFromResolver(
|
|
|
44
44
|
): FileContext {
|
|
45
45
|
// @cast-boundary engine-payload — tenantId comes from a trusted job payload or session
|
|
46
46
|
const brandedTenantId = tenantId as TenantId;
|
|
47
|
-
return
|
|
47
|
+
return createTenantFileContext(resolver, brandedTenantId);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export class FileRefTooLargeError extends Error {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.372.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Orphaned upload bytes are swept through a new upload ledger",
|
|
6
|
+
"detail": "The files feature registers the store_file_upload_ledger table and an hourly sweep job. FileContext has the new methods writeUploadBytes and forgetUploadBytes, which the four upload paths use; a custom FileContext or resolver must provide the ledger (withUploadLedger wraps a resolver). TenantDb.deleteMany resolves to the number of deleted rows instead of void. A fileRef commit whose ledger row was already swept throws InternalError \"upload expired\".",
|
|
7
|
+
"migration": "Generate and apply a migration with `kumiko-schema generate` — uploads fail without the new store_file_upload_ledger table"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.354.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.372.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenants whose tier excludes API tokens now reach the lockedFallbackScreen",
|
|
6
|
+
"detail": "GET /api/schema removed every screen of a feature the tenant has switched off, and the role projection then dropped the navs of other features that pointed at them. The token list screen was gone, so the upgrade screen set via lockedFallbackScreen was never shown. Screens that declare a fallback (and the same-feature screens those fallbacks name) now stay in the schema; the server still rejects their queries and writes with feature_disabled, which makes the renderer show the fallback. Navs, content collections and workspaces of the disabled feature are still removed."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.372.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "The token list looks like any other Kumiko list",
|
|
12
|
+
"detail": "The create button now sits in the page header next to the title, and an empty list shows the standard hint with a create button. The German title of the list screen is \"API-Tokens\" instead of \"Personal Access Tokens\", which also fixes the breadcrumb of the mint screen. Renderer, all apps: a projection list without an entity create button now shows its first primary toolbar action (one that needs no confirmation dialog) in the page header on desktop as well, with its label, instead of in the toolbar row; on phones it already did. On desktop only an icon derived from the action id (or declared on the action) is shown; the plus fallback exists only on phones. Consumers get this after the bump without code changes."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.368.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -32,6 +32,15 @@ const PAT_SCREEN_VISIBLE_WHEN = {
|
|
|
32
32
|
eq: true,
|
|
33
33
|
} as const;
|
|
34
34
|
|
|
35
|
+
const PAT_CREATE_ACTION = {
|
|
36
|
+
kind: "navigate",
|
|
37
|
+
id: "create",
|
|
38
|
+
label: i18nKey("pat.create.title"),
|
|
39
|
+
screen: PAT_MINT_SCREEN_ID,
|
|
40
|
+
style: "primary",
|
|
41
|
+
icon: "plus",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
35
44
|
export const patListScreen: ProjectionListScreenDefinition = {
|
|
36
45
|
id: PAT_SCREEN_ID,
|
|
37
46
|
type: "projectionList",
|
|
@@ -82,15 +91,12 @@ export const patListScreen: ProjectionListScreenDefinition = {
|
|
|
82
91
|
visible: { field: "status", ne: "revoked" },
|
|
83
92
|
},
|
|
84
93
|
],
|
|
85
|
-
toolbarActions: [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
style: "primary",
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
+
toolbarActions: [PAT_CREATE_ACTION],
|
|
95
|
+
emptyState: {
|
|
96
|
+
title: i18nKey("kumiko.list.empty.title"),
|
|
97
|
+
description: i18nKey("kumiko.list.empty.hint"),
|
|
98
|
+
action: PAT_CREATE_ACTION,
|
|
99
|
+
},
|
|
94
100
|
access: {
|
|
95
101
|
openToAll: {
|
|
96
102
|
reason:
|
|
@@ -31,6 +31,20 @@ export {
|
|
|
31
31
|
parentRowIsVisible,
|
|
32
32
|
} from "./parent-visibility.ts";
|
|
33
33
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.ts";
|
|
34
|
+
export {
|
|
35
|
+
type DeleteFileBytes,
|
|
36
|
+
FileSweepError,
|
|
37
|
+
type MissingStoragePolicy,
|
|
38
|
+
purgeFilesReachingSubject,
|
|
39
|
+
} from "./purge-files-reaching-subject.ts";
|
|
40
|
+
export {
|
|
41
|
+
collectSingleFileHolders,
|
|
42
|
+
type FileFieldPlan,
|
|
43
|
+
type FileRefPurgeOutcome,
|
|
44
|
+
planFileFields,
|
|
45
|
+
purgeRowFileRefs,
|
|
46
|
+
type SingleFileHolder,
|
|
47
|
+
} from "./purge-row-file-refs.ts";
|
|
34
48
|
export { rateLimitedTextResponse } from "./rate-limited-text-response.ts";
|
|
35
49
|
export {
|
|
36
50
|
redactBotTokens,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveSubjectForField,
|
|
3
|
+
type SubjectId,
|
|
4
|
+
SubjectResolutionError,
|
|
5
|
+
subjectIdToKey,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
|
+
import {
|
|
8
|
+
createEventStoreExecutor,
|
|
9
|
+
createTenantDb,
|
|
10
|
+
type DbRunner,
|
|
11
|
+
selectMany,
|
|
12
|
+
type WhereObject,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
+
import {
|
|
15
|
+
createSystemUser,
|
|
16
|
+
type EntityDefinition,
|
|
17
|
+
parseTenantId,
|
|
18
|
+
type Registry,
|
|
19
|
+
type TenantId,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
22
|
+
import {
|
|
23
|
+
collectSingleFileHolders,
|
|
24
|
+
type FileRefPurgeOutcome,
|
|
25
|
+
planFileFields,
|
|
26
|
+
purgeRowFileRefs,
|
|
27
|
+
type SingleFileHolder,
|
|
28
|
+
} from "./purge-row-file-refs.ts";
|
|
29
|
+
|
|
30
|
+
// Callers' error labels read err.code, so audit events name the failure class.
|
|
31
|
+
export class FileSweepError extends Error {
|
|
32
|
+
constructor(
|
|
33
|
+
readonly code: Exclude<FileRefPurgeOutcome, "purged">,
|
|
34
|
+
readonly entityName: string,
|
|
35
|
+
) {
|
|
36
|
+
super(`[files-purge] ${code} while purging files of entity ${entityName}`);
|
|
37
|
+
this.name = "FileSweepError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type FileEntity = {
|
|
42
|
+
readonly entityName: string;
|
|
43
|
+
readonly entity: EntityDefinition;
|
|
44
|
+
readonly table: Parameters<typeof selectMany>[1];
|
|
45
|
+
readonly fileFields: readonly string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function collectFileEntities(registry: Registry): readonly FileEntity[] {
|
|
49
|
+
const found: FileEntity[] = [];
|
|
50
|
+
for (const proj of registry.getAllProjections().values()) {
|
|
51
|
+
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
|
|
52
|
+
const entity = registry.getEntity(proj.source);
|
|
53
|
+
if (!entity) continue;
|
|
54
|
+
const { singleFields, multiFields } = planFileFields(entity.fields);
|
|
55
|
+
const fileFields = [...singleFields, ...multiFields];
|
|
56
|
+
if (fileFields.length === 0) continue;
|
|
57
|
+
found.push({ entityName: proj.source, entity, table: proj.table, fileFields });
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Candidate rows only; the exact per-field subject is verified with resolveSubjectForField,
|
|
63
|
+
// the same resolver that encrypted the field.
|
|
64
|
+
function candidateWheres(
|
|
65
|
+
subject: SubjectId,
|
|
66
|
+
recordTenantId: TenantId | undefined,
|
|
67
|
+
{ entityName, entity, fileFields }: FileEntity,
|
|
68
|
+
): readonly WhereObject[] {
|
|
69
|
+
if (subject.kind === "record") {
|
|
70
|
+
// Without the owning tenant the record cannot be told apart from a same-id row elsewhere.
|
|
71
|
+
return entityName === subject.entity && recordTenantId !== undefined
|
|
72
|
+
? [{ id: subject.id, tenantId: recordTenantId }]
|
|
73
|
+
: [];
|
|
74
|
+
}
|
|
75
|
+
if (subject.kind !== "user") return [];
|
|
76
|
+
const wheres: WhereObject[] = [];
|
|
77
|
+
for (const fieldName of fileFields) {
|
|
78
|
+
const field = entity.fields[fieldName];
|
|
79
|
+
if (field && "userOwned" in field && field.userOwned !== undefined) {
|
|
80
|
+
wheres.push({ [field.userOwned.ownerField]: subject.userId });
|
|
81
|
+
} else if (field && "pii" in field && field.pii === true && entity.idType !== "serial") {
|
|
82
|
+
wheres.push({ id: subject.userId });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return wheres;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fieldsResolvingToSubject(
|
|
89
|
+
fileEntity: FileEntity,
|
|
90
|
+
row: Record<string, unknown>,
|
|
91
|
+
rowTenantId: TenantId,
|
|
92
|
+
subjectKey: string,
|
|
93
|
+
): readonly string[] {
|
|
94
|
+
return fileEntity.fileFields.filter((fieldName) => {
|
|
95
|
+
try {
|
|
96
|
+
const resolved = resolveSubjectForField(fileEntity.entity, fieldName, row, {
|
|
97
|
+
tenantId: rowTenantId,
|
|
98
|
+
entityName: fileEntity.entityName,
|
|
99
|
+
});
|
|
100
|
+
return resolved !== null && subjectIdToKey(resolved) === subjectKey;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// skip: a field whose owner cannot be resolved on this row never encrypted under this subject
|
|
103
|
+
if (err instanceof SubjectResolutionError) return false;
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
110
|
+
entityName: "fileRef",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export type DeleteFileBytes = (storageKey: string) => Promise<readonly string[]>;
|
|
114
|
+
|
|
115
|
+
// What to do when a tenant has files to delete but no storage: fail closed (the files would
|
|
116
|
+
// leak while the erasure reads as done) or skip with a warning, for callers whose erasure must
|
|
117
|
+
// not be blocked forever by a misconfigured store.
|
|
118
|
+
export type MissingStoragePolicy =
|
|
119
|
+
| { readonly mode: "fail" }
|
|
120
|
+
| { readonly mode: "skip"; readonly warn: (tenantId: TenantId) => void };
|
|
121
|
+
|
|
122
|
+
async function deleteBytesFailingClosed(
|
|
123
|
+
deleteBytes: DeleteFileBytes,
|
|
124
|
+
storageKey: string,
|
|
125
|
+
): Promise<readonly string[]> {
|
|
126
|
+
try {
|
|
127
|
+
return await deleteBytes(storageKey);
|
|
128
|
+
} catch {
|
|
129
|
+
// skip: an unsafe key or missing list permission fails this file closed, not the whole sweep
|
|
130
|
+
return [storageKey];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Forgetting a subject erases keys; the files behind a personal file field are not ciphertext,
|
|
135
|
+
// so they need their own purge (bytes, derivatives, fileRef rows). The entity row stays:
|
|
136
|
+
// forgetting is crypto-shredding, not deletion. Tenant subjects are covered by
|
|
137
|
+
// files-tenant-data on tenant destroy. The storage is resolved per owning tenant because a
|
|
138
|
+
// subject's rows can live in any tenant.
|
|
139
|
+
export async function purgeFilesReachingSubject(args: {
|
|
140
|
+
readonly registry: Registry;
|
|
141
|
+
readonly db: DbRunner;
|
|
142
|
+
readonly subject: SubjectId;
|
|
143
|
+
readonly subjectKey: string;
|
|
144
|
+
// Tenant owning the row of a record subject; a record subject without it purges nothing.
|
|
145
|
+
readonly recordTenantId?: TenantId;
|
|
146
|
+
readonly resolveDeleteBytes: (tenantId: TenantId) => Promise<DeleteFileBytes | undefined>;
|
|
147
|
+
readonly missingStorage: MissingStoragePolicy;
|
|
148
|
+
}): Promise<void> {
|
|
149
|
+
const { registry, db, subject, subjectKey, recordTenantId, resolveDeleteBytes, missingStorage } =
|
|
150
|
+
args;
|
|
151
|
+
// skip: tenant subjects are covered by files-tenant-data on tenant destroy
|
|
152
|
+
if (subject.kind === "tenant") return;
|
|
153
|
+
|
|
154
|
+
const fileHolders: readonly SingleFileHolder[] = collectSingleFileHolders(registry);
|
|
155
|
+
const storageByTenant = new Map<TenantId, Promise<DeleteFileBytes | undefined>>();
|
|
156
|
+
let firstFailure: FileSweepError | undefined;
|
|
157
|
+
|
|
158
|
+
for (const fileEntity of collectFileEntities(registry)) {
|
|
159
|
+
const seenRowIds = new Set<string>();
|
|
160
|
+
for (const where of candidateWheres(subject, recordTenantId, fileEntity)) {
|
|
161
|
+
const rows = await selectMany<Record<string, unknown>>(db, fileEntity.table, where);
|
|
162
|
+
for (const row of rows) {
|
|
163
|
+
const rowId = String(row["id"]);
|
|
164
|
+
const tenantId = parseTenantId(row["tenantId"]);
|
|
165
|
+
// skip: a row without a tenant cannot own tenant-scoped file refs
|
|
166
|
+
if (seenRowIds.has(rowId) || tenantId === null) continue;
|
|
167
|
+
seenRowIds.add(rowId);
|
|
168
|
+
|
|
169
|
+
const fieldNames = fieldsResolvingToSubject(fileEntity, row, tenantId, subjectKey);
|
|
170
|
+
// skip: none of the row's file fields belongs to this subject
|
|
171
|
+
if (fieldNames.length === 0) continue;
|
|
172
|
+
|
|
173
|
+
let storagePromise = storageByTenant.get(tenantId);
|
|
174
|
+
if (storagePromise === undefined) {
|
|
175
|
+
storagePromise = resolveDeleteBytes(tenantId);
|
|
176
|
+
storageByTenant.set(tenantId, storagePromise);
|
|
177
|
+
}
|
|
178
|
+
const deleteBytes = await storagePromise;
|
|
179
|
+
const systemUser = createSystemUser(tenantId);
|
|
180
|
+
const tdb = createTenantDb({ db, tenantId, mode: "system" });
|
|
181
|
+
|
|
182
|
+
const outcome = await purgeRowFileRefs({
|
|
183
|
+
db,
|
|
184
|
+
tenantId,
|
|
185
|
+
entityName: fileEntity.entityName,
|
|
186
|
+
entity: fileEntity.entity,
|
|
187
|
+
table: fileEntity.table,
|
|
188
|
+
fileHolders,
|
|
189
|
+
row,
|
|
190
|
+
fieldNames,
|
|
191
|
+
deleteBytes: deleteBytes
|
|
192
|
+
? (key) => deleteBytesFailingClosed(deleteBytes, key)
|
|
193
|
+
: undefined,
|
|
194
|
+
fileRefForget: async (fileRefId) =>
|
|
195
|
+
(await fileRefExecutor.forget({ id: fileRefId }, systemUser, tdb)).isSuccess,
|
|
196
|
+
});
|
|
197
|
+
if (outcome === "missing_file_storage" && missingStorage.mode === "skip") {
|
|
198
|
+
missingStorage.warn(tenantId);
|
|
199
|
+
} else if (outcome !== "purged") {
|
|
200
|
+
firstFailure ??= new FileSweepError(outcome, fileEntity.entityName);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (firstFailure) throw firstFailure;
|
|
206
|
+
}
|