@cosmicdrift/kumiko-bundled-features 0.312.0 → 0.313.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 +9 -9
- package/src/__tests__/extension-points-typed.test.ts +4 -0
- package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
- package/src/agent-tools/agent-manifest.ts +7 -1
- package/src/agent-tools/changes.json +8 -1
- package/src/audit/changes.json +6 -0
- package/src/audit/feature.ts +1 -2
- package/src/audit/i18n.ts +3 -0
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
- package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
- package/src/auth-email-password/signup-token-store.ts +87 -1
- package/src/auth-email-password/web/auth-client.ts +10 -1
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/run-retention-cleanup.ts +160 -18
- package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
- package/src/delivery/address-opt-out.ts +87 -0
- package/src/delivery/changes.json +7 -0
- package/src/delivery/db/queries/address-opt-outs.ts +23 -0
- package/src/delivery/delivery-service.ts +32 -0
- package/src/delivery/feature.ts +9 -1
- package/src/delivery/index.ts +5 -0
- package/src/delivery/tables.ts +37 -0
- package/src/delivery/unsubscribe.ts +101 -16
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +5 -0
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
- package/src/shared/index.ts +10 -1
- package/src/shared/run-in-sub-transaction.ts +35 -0
- package/src/shared/signup-handover.ts +67 -0
- package/src/shared/single-use-token-store.ts +25 -5
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
- package/src/subscription-stripe/changes.json +9 -1
- package/src/subscription-stripe/feature.ts +13 -1
- package/src/subscription-stripe/plugin-methods.ts +17 -2
- package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant/index.ts +1 -0
- package/src/tenant/is-tenant-serving-public-content.ts +13 -0
- package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
- package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
- package/src/tenant/web/member-roles-cell.tsx +11 -2
- package/src/tenant/web/member-status-cell.tsx +6 -2
- package/src/tenant/web/translate-or-raw.ts +13 -0
- package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
- package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-handover/events.ts +6 -0
- package/src/tenant-handover/feature.ts +11 -0
- package/src/tenant-handover/handlers/claim.write.ts +8 -25
- package/src/tenant-handover/move-entity-graph.ts +30 -1
- package/src/tenant-handover/root-anchor.ts +50 -0
- package/src/tenant-handover/signup-handover-provider.ts +62 -0
- package/src/tenant-handover/transfer-graph.ts +6 -2
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +1 -2
- package/src/user-data-rights/i18n.ts +3 -0
- package/src/user-data-rights/run-forget-cleanup.ts +1 -38
|
@@ -14,9 +14,11 @@ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipelin
|
|
|
14
14
|
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
15
15
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
16
16
|
import type { Redis } from "ioredis";
|
|
17
|
+
import { hashUnsubscribeAddress } from "./address-opt-out";
|
|
17
18
|
import { appendAttemptEvent, logAttempt } from "./attempt-log";
|
|
18
19
|
import { buildChannelContext } from "./channel-context";
|
|
19
20
|
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants";
|
|
21
|
+
import { isAddressOptedOut } from "./db/queries/address-opt-outs";
|
|
20
22
|
import { selectNotificationPreferences } from "./db/queries/preferences";
|
|
21
23
|
import {
|
|
22
24
|
type ChannelContext,
|
|
@@ -479,6 +481,36 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
479
481
|
const message = buildMessage(notificationType, data, channel.name);
|
|
480
482
|
if (!address) continue;
|
|
481
483
|
|
|
484
|
+
// Address opt-out (critical priority skips it, same rule as user
|
|
485
|
+
// preferences). No blind-index key configured → no hash → nothing to
|
|
486
|
+
// look up, skip the check instead of failing the send.
|
|
487
|
+
if (priority !== "critical") {
|
|
488
|
+
const addressHash = hashUnsubscribeAddress(address);
|
|
489
|
+
if (addressHash !== undefined) {
|
|
490
|
+
const optedOut = await isAddressOptedOut(
|
|
491
|
+
db,
|
|
492
|
+
tenantId,
|
|
493
|
+
addressHash,
|
|
494
|
+
notificationType,
|
|
495
|
+
channel.name,
|
|
496
|
+
);
|
|
497
|
+
if (optedOut) {
|
|
498
|
+
await logDelivery({
|
|
499
|
+
tenantId,
|
|
500
|
+
notificationType,
|
|
501
|
+
channel: channel.name,
|
|
502
|
+
recipientId,
|
|
503
|
+
// The recipient withdrew — suppressed attempts must not keep recording the address.
|
|
504
|
+
recipientAddress: null,
|
|
505
|
+
status: "skipped",
|
|
506
|
+
error: "unsubscribed",
|
|
507
|
+
priority,
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
482
514
|
if (rateLimit) {
|
|
483
515
|
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
484
516
|
if (!allowed) {
|
package/src/delivery/feature.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { deliveryRenderJob, deliverySendJob } from "./jobs";
|
|
|
23
23
|
import {
|
|
24
24
|
deliveryAttemptsTable,
|
|
25
25
|
deliveryAttemptsTableMeta,
|
|
26
|
+
notificationAddressOptOutEntity,
|
|
27
|
+
notificationAddressOptOutsTable,
|
|
26
28
|
notificationPreferenceEntity,
|
|
27
29
|
notificationPreferencesTable,
|
|
28
30
|
} from "./tables";
|
|
@@ -40,7 +42,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
40
42
|
const resolvedAccess = options?.access ?? access.admin;
|
|
41
43
|
return defineFeature("delivery", (r) => {
|
|
42
44
|
r.describe(
|
|
43
|
-
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
|
|
45
|
+
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
|
|
44
46
|
);
|
|
45
47
|
r.uiHints({
|
|
46
48
|
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
@@ -56,6 +58,12 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
56
58
|
r.entity("notification-preference", notificationPreferenceEntity, {
|
|
57
59
|
table: notificationPreferencesTable,
|
|
58
60
|
});
|
|
61
|
+
// Same ride-along reason as notification-preference above: the
|
|
62
|
+
// (tenant,addressHash,type,channel) uniqueIndex lives only on the
|
|
63
|
+
// physical table.
|
|
64
|
+
r.entity("notification-address-opt-out", notificationAddressOptOutEntity, {
|
|
65
|
+
table: notificationAddressOptOutsTable,
|
|
66
|
+
});
|
|
59
67
|
r.storeTable(deliveryAttemptsTableMeta, {
|
|
60
68
|
reason: "read_side.delivery_attempt_log",
|
|
61
69
|
});
|
package/src/delivery/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { hashUnsubscribeAddress } from "./address-opt-out";
|
|
1
2
|
export type { DeliveryStatusValue } from "./constants";
|
|
2
3
|
export {
|
|
3
4
|
DELIVERY_CHANNEL_EXTENSION,
|
|
@@ -19,6 +20,8 @@ export {
|
|
|
19
20
|
export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature";
|
|
20
21
|
export {
|
|
21
22
|
deliveryAttemptsTable,
|
|
23
|
+
notificationAddressOptOutEntity,
|
|
24
|
+
notificationAddressOptOutsTable,
|
|
22
25
|
notificationPreferenceEntity,
|
|
23
26
|
notificationPreferencesTable,
|
|
24
27
|
} from "./tables";
|
|
@@ -38,7 +41,9 @@ export {
|
|
|
38
41
|
type RendererInput,
|
|
39
42
|
} from "./types";
|
|
40
43
|
export {
|
|
44
|
+
type AddressUnsubscribeTokenPayload,
|
|
41
45
|
createUnsubscribeRoute,
|
|
46
|
+
signAddressUnsubscribeToken,
|
|
42
47
|
signUnsubscribeToken,
|
|
43
48
|
type UnsubscribeRouteOptions,
|
|
44
49
|
type UnsubscribeTokenPayload,
|
package/src/delivery/tables.ts
CHANGED
|
@@ -105,3 +105,40 @@ export const notificationPreferencesTable = pgTable(
|
|
|
105
105
|
),
|
|
106
106
|
],
|
|
107
107
|
);
|
|
108
|
+
|
|
109
|
+
// Opt-out for a recipient ADDRESS that has no user account (direct sends via
|
|
110
|
+
// `ctx.notify(type, { route: { email } })`). addressHash is a keyed HMAC
|
|
111
|
+
// (computeBlindIndex over the normalized address) — the plaintext address
|
|
112
|
+
// never reaches this entity or the token that unsubscribes it.
|
|
113
|
+
// A row's mere existence is the opt-out; there is no `enabled` toggle to flip
|
|
114
|
+
// back (re-subscribing a no-account address has no signed-in flow to do it from).
|
|
115
|
+
export const notificationAddressOptOutEntity = createEntity({
|
|
116
|
+
table: "read_notification_address_opt_outs",
|
|
117
|
+
fields: {
|
|
118
|
+
addressHash: createTextField({ required: true, personal: false, reason: "keyed_hash" }),
|
|
119
|
+
notificationType: createTextField({
|
|
120
|
+
required: true,
|
|
121
|
+
personal: false,
|
|
122
|
+
reason: "technical_reference",
|
|
123
|
+
}),
|
|
124
|
+
channel: createTextField({ required: true, personal: false, reason: "technical_reference" }),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const notificationAddressOptOutsTable = pgTable(
|
|
129
|
+
"read_notification_address_opt_outs",
|
|
130
|
+
{
|
|
131
|
+
...buildBaseColumns(false, "uuid"),
|
|
132
|
+
addressHash: text("address_hash").notNull(),
|
|
133
|
+
notificationType: text("notification_type").notNull(),
|
|
134
|
+
channel: text("channel").notNull(),
|
|
135
|
+
},
|
|
136
|
+
(table) => [
|
|
137
|
+
uniqueIndex("read_notification_address_opt_outs_unique").on(
|
|
138
|
+
table.tenantId,
|
|
139
|
+
table.addressHash,
|
|
140
|
+
table.notificationType,
|
|
141
|
+
table.channel,
|
|
142
|
+
),
|
|
143
|
+
],
|
|
144
|
+
);
|
|
@@ -3,17 +3,38 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import * as jose from "jose";
|
|
5
5
|
import * as z from "zod";
|
|
6
|
+
import { hashUnsubscribeAddress, upsertAddressOptOut } from "./address-opt-out";
|
|
6
7
|
import { upsertPreference } from "./upsert-preference";
|
|
7
8
|
|
|
8
9
|
// Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
|
|
9
10
|
// wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
|
|
11
|
+
//
|
|
12
|
+
// `kind` is explicitly typed as "absent or undefined, nothing else" here
|
|
13
|
+
// (not bare `.optional()`, which would also accept any other value on an
|
|
14
|
+
// unrelated key) so an address-token payload — which always carries
|
|
15
|
+
// `kind: "address"` — fails this schema instead of silently parsing with
|
|
16
|
+
// `addressHash` mistaken for a userId.
|
|
10
17
|
const unsubscribeJwtPayloadSchema = z.object({
|
|
18
|
+
kind: z.undefined().optional(),
|
|
11
19
|
sub: z.string().min(1),
|
|
12
20
|
tenantId: z.string().min(1),
|
|
13
21
|
notificationType: z.string().min(1),
|
|
14
22
|
channel: z.string().min(1),
|
|
15
23
|
});
|
|
16
24
|
|
|
25
|
+
// Address-token payload. `sub` and `addressHash` are the same value (the
|
|
26
|
+
// subject claim IS the blind-index hash) — kept as two fields because the
|
|
27
|
+
// subject claim is jose's own mechanism while `addressHash` is what the
|
|
28
|
+
// route actually writes; the handler cross-checks them below.
|
|
29
|
+
const addressUnsubscribeJwtPayloadSchema = z.object({
|
|
30
|
+
kind: z.literal("address"),
|
|
31
|
+
sub: z.string().min(1),
|
|
32
|
+
tenantId: z.string().min(1),
|
|
33
|
+
notificationType: z.string().min(1),
|
|
34
|
+
channel: z.string().min(1),
|
|
35
|
+
addressHash: z.string().min(1),
|
|
36
|
+
});
|
|
37
|
+
|
|
17
38
|
export type UnsubscribeTokenPayload = {
|
|
18
39
|
readonly userId: string;
|
|
19
40
|
readonly tenantId: TenantId;
|
|
@@ -21,6 +42,13 @@ export type UnsubscribeTokenPayload = {
|
|
|
21
42
|
readonly channel: string;
|
|
22
43
|
};
|
|
23
44
|
|
|
45
|
+
export type AddressUnsubscribeTokenPayload = {
|
|
46
|
+
readonly tenantId: TenantId;
|
|
47
|
+
readonly address: string;
|
|
48
|
+
readonly notificationType: string;
|
|
49
|
+
readonly channel: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
24
52
|
export type UnsubscribeRouteOptions = {
|
|
25
53
|
readonly db: DbConnection;
|
|
26
54
|
readonly jwtSecret: string;
|
|
@@ -51,6 +79,45 @@ export async function signUnsubscribeToken(
|
|
|
51
79
|
.sign(encodedSecret);
|
|
52
80
|
}
|
|
53
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Signs an unsubscribe link for a recipient ADDRESS with no user account
|
|
84
|
+
* (direct sends via `ctx.notify(type, { route: { email } })`). Throws when
|
|
85
|
+
* no blind-index key is configured — fail closed, since without a key the
|
|
86
|
+
* opt-out row this token later writes could never be matched against a
|
|
87
|
+
* future send (the hash wouldn't be reproducible).
|
|
88
|
+
*/
|
|
89
|
+
export async function signAddressUnsubscribeToken(
|
|
90
|
+
payload: AddressUnsubscribeTokenPayload,
|
|
91
|
+
secret: string,
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
const addressHash = hashUnsubscribeAddress(payload.address);
|
|
94
|
+
if (addressHash === undefined) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"signAddressUnsubscribeToken requires a configured blind-index key (configureBlindIndexKey)",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const encodedSecret = new TextEncoder().encode(secret);
|
|
100
|
+
return (
|
|
101
|
+
new jose.SignJWT({
|
|
102
|
+
tenantId: payload.tenantId,
|
|
103
|
+
notificationType: payload.notificationType,
|
|
104
|
+
channel: payload.channel,
|
|
105
|
+
addressHash,
|
|
106
|
+
kind: "address",
|
|
107
|
+
})
|
|
108
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
109
|
+
.setSubject(addressHash)
|
|
110
|
+
.setIssuer("kumiko:unsubscribe")
|
|
111
|
+
.setIssuedAt()
|
|
112
|
+
// No expiry, unlike the user token above: a leaked address token can
|
|
113
|
+
// only opt that one address out of one notificationType/channel, and
|
|
114
|
+
// an unsubscribe link mailed out today must keep working indefinitely
|
|
115
|
+
// — there is no signed-in flow for a no-account address to re-subscribe
|
|
116
|
+
// or request a fresh link from.
|
|
117
|
+
.sign(encodedSecret)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
54
121
|
export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): Hono {
|
|
55
122
|
const { db, jwtSecret } = options;
|
|
56
123
|
const encodedSecret = new TextEncoder().encode(jwtSecret);
|
|
@@ -62,34 +129,52 @@ export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): Hono {
|
|
|
62
129
|
return c.text("Missing token", 400);
|
|
63
130
|
}
|
|
64
131
|
|
|
65
|
-
let
|
|
66
|
-
let tenantId: TenantId;
|
|
67
|
-
let notificationType: string;
|
|
68
|
-
let channel: string;
|
|
132
|
+
let verifiedPayload: jose.JWTPayload;
|
|
69
133
|
try {
|
|
70
|
-
|
|
134
|
+
({ payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
|
|
71
135
|
issuer: "kumiko:unsubscribe",
|
|
72
|
-
});
|
|
73
|
-
const parsed = unsubscribeJwtPayloadSchema.parse(payload);
|
|
74
|
-
userId = parsed.sub;
|
|
75
|
-
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
76
|
-
tenantId = parsed.tenantId as TenantId;
|
|
77
|
-
notificationType = parsed.notificationType;
|
|
78
|
-
channel = parsed.channel;
|
|
136
|
+
}));
|
|
79
137
|
} catch {
|
|
80
138
|
return c.text("Invalid or expired token", 400);
|
|
81
139
|
}
|
|
82
140
|
|
|
141
|
+
const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
142
|
+
if (addressAttempt.success) {
|
|
143
|
+
const parsed = addressAttempt.data;
|
|
144
|
+
if (parsed.sub !== parsed.addressHash) {
|
|
145
|
+
return c.text("Invalid or expired token", 400);
|
|
146
|
+
}
|
|
147
|
+
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
148
|
+
const tenantId = parsed.tenantId as TenantId;
|
|
149
|
+
const actor = { id: parsed.addressHash, tenantId, roles: SYSTEM_ROLES };
|
|
150
|
+
const tdb = createTenantDb(db, tenantId, "system");
|
|
151
|
+
await upsertAddressOptOut(tdb, actor, {
|
|
152
|
+
tenantId,
|
|
153
|
+
addressHash: parsed.addressHash,
|
|
154
|
+
notificationType: parsed.notificationType,
|
|
155
|
+
channel: parsed.channel,
|
|
156
|
+
});
|
|
157
|
+
return c.text("You have been unsubscribed.", 200);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
161
|
+
if (!userAttempt.success) {
|
|
162
|
+
return c.text("Invalid or expired token", 400);
|
|
163
|
+
}
|
|
164
|
+
const parsed = userAttempt.data;
|
|
165
|
+
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
166
|
+
const tenantId = parsed.tenantId as TenantId;
|
|
167
|
+
|
|
83
168
|
// Token-verify passed — everything below is a legitimate write. Don't
|
|
84
169
|
// swallow write-errors as "invalid token", that would mask real bugs
|
|
85
170
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
86
|
-
const actor = { id:
|
|
171
|
+
const actor = { id: parsed.sub, tenantId, roles: SYSTEM_ROLES };
|
|
87
172
|
const tdb = createTenantDb(db, tenantId, "system");
|
|
88
173
|
await upsertPreference(tdb, actor, {
|
|
89
174
|
tenantId,
|
|
90
|
-
userId,
|
|
91
|
-
notificationType,
|
|
92
|
-
channel,
|
|
175
|
+
userId: parsed.sub,
|
|
176
|
+
notificationType: parsed.notificationType,
|
|
177
|
+
channel: parsed.channel,
|
|
93
178
|
enabled: false,
|
|
94
179
|
});
|
|
95
180
|
return c.text("You have been unsubscribed.", 200);
|
|
@@ -130,7 +130,7 @@ export async function upsertPreference(
|
|
|
130
130
|
// (SQLSTATE 23505). Drivers wrap the DB error in varying envelopes; we
|
|
131
131
|
// check the `code` field plus a string-match fallback so the match survives
|
|
132
132
|
// minor driver-version shifts without drifting wide.
|
|
133
|
-
function isUniqueViolation(err: unknown): boolean {
|
|
133
|
+
export function isUniqueViolation(err: unknown): boolean {
|
|
134
134
|
if (typeof err !== "object" || err === null) return false;
|
|
135
135
|
const e = err as { code?: unknown; cause?: { code?: unknown }; message?: unknown }; // @cast-boundary error-details
|
|
136
136
|
if (e.code === "23505") return true;
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
createTestUser,
|
|
23
23
|
setupTestStack,
|
|
24
24
|
type TestStack,
|
|
25
|
+
TestUsers,
|
|
25
26
|
testTenantId,
|
|
26
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
28
|
import {
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
32
33
|
import { createConfigFeature } from "../../config";
|
|
33
34
|
import { fileFoundationFeature } from "../../file-foundation";
|
|
35
|
+
import { createTenantFeature, TenantHandlers } from "../../tenant";
|
|
34
36
|
import { createFileDerivativesFeature } from "../feature";
|
|
35
37
|
import type { DerivativePublicPredicateArgs } from "../handlers/public-variant.query";
|
|
36
38
|
import { PUBLIC_VARIANT_BY_FILE_REF_QN } from "../handlers/public-variant-by-file-ref.query";
|
|
@@ -94,6 +96,7 @@ describe("file-derivatives :: publicTenantResolution 'fileRef' — cross-tenant
|
|
|
94
96
|
stack = await setupTestStack({
|
|
95
97
|
features: [
|
|
96
98
|
createConfigFeature(),
|
|
99
|
+
createTenantFeature(),
|
|
97
100
|
fileFoundationFeature,
|
|
98
101
|
createFilesFeature(),
|
|
99
102
|
createFileDerivativesFeature({
|
|
@@ -104,6 +107,16 @@ describe("file-derivatives :: publicTenantResolution 'fileRef' — cross-tenant
|
|
|
104
107
|
],
|
|
105
108
|
files: { storageProvider: createInMemoryFileProvider() },
|
|
106
109
|
});
|
|
110
|
+
await stack.http.writeOk(
|
|
111
|
+
TenantHandlers.create,
|
|
112
|
+
{ id: TENANT_A, key: "cross-tenant-a", name: "Cross Tenant A" },
|
|
113
|
+
TestUsers.systemAdmin,
|
|
114
|
+
);
|
|
115
|
+
await stack.http.writeOk(
|
|
116
|
+
TenantHandlers.create,
|
|
117
|
+
{ id: TENANT_B, key: "cross-tenant-b", name: "Cross Tenant B" },
|
|
118
|
+
TestUsers.systemAdmin,
|
|
119
|
+
);
|
|
107
120
|
});
|
|
108
121
|
|
|
109
122
|
afterAll(async () => {
|
|
@@ -169,6 +182,33 @@ describe("file-derivatives :: publicTenantResolution 'fileRef' — cross-tenant
|
|
|
169
182
|
test("PUBLIC_VARIANT_BY_FILE_REF_QN is registered on the stack in 'fileRef' mode", () => {
|
|
170
183
|
expect(stack.registry.getQueryHandler(PUBLIC_VARIANT_BY_FILE_REF_QN)).toBeDefined();
|
|
171
184
|
});
|
|
185
|
+
|
|
186
|
+
// Runs last and deliberately leaves tenant B disabled — mirrors the
|
|
187
|
+
// "disabled tenant" ordering convention in
|
|
188
|
+
// tenant/__tests__/multi-tenant.integration.test.ts.
|
|
189
|
+
describe("a disabled tenant answers like an unknown fileRef", () => {
|
|
190
|
+
test("tenant B's public variant 200s before disable, then 404s identically to an unknown fileRef after disable", async () => {
|
|
191
|
+
const fileId = await uploadImage(stack, userB, "public-1");
|
|
192
|
+
const beforeRes = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
193
|
+
expect(beforeRes.status).toBe(200);
|
|
194
|
+
|
|
195
|
+
const disableResult = await stack.http.writeOk(
|
|
196
|
+
TenantHandlers.disable,
|
|
197
|
+
{ id: TENANT_B },
|
|
198
|
+
TestUsers.systemAdmin,
|
|
199
|
+
);
|
|
200
|
+
expect(disableResult).toBeTruthy();
|
|
201
|
+
|
|
202
|
+
const afterRes = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
203
|
+
const unknownFileRefRes = await stack.app.request(
|
|
204
|
+
`http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(afterRes.status).toBe(404);
|
|
208
|
+
expect(unknownFileRefRes.status).toBe(404);
|
|
209
|
+
expect(await afterRes.text()).toBe(await unknownFileRefRes.text());
|
|
210
|
+
});
|
|
211
|
+
});
|
|
172
212
|
});
|
|
173
213
|
|
|
174
214
|
describe("file-derivatives :: default ('host') mode is unchanged", () => {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.313.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "file-derivatives fileRef mode now 404s a disabled/destroy-lifecycle tenant like an unknown fileRef",
|
|
6
|
+
"detail": "publicTenantResolution:\"fileRef\" resolved a variant's tenant straight off\nthe FileRef row, without checking whether that tenant was still enabled or\npast `active` in its destroy lifecycle — a disabled or destroy-requested\ntenant's public variants stayed reachable through the shared host. The\nby-fileRef handler now also loads the FileRef-tenant's row and, via the\nnew `isTenantServingPublicContent` predicate (isEnabled && status ===\n\"active\"), 404s identically to an unknown fileRef when it isn't serving.\n`publicTenantResolution: \"fileRef\"` now requires the `tenant` feature to\nbe mounted (fails boot otherwise) — no migration needed for apps that\nalready mount `tenant`, which every fileRef-mode consumer does today."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.310.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -160,6 +160,11 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
160
160
|
// would expose its unsafeRaw, cross-tenant FileRef lookup via the
|
|
161
161
|
// generic `/api` query dispatch even in default "host" mode.
|
|
162
162
|
if (byFileRef) {
|
|
163
|
+
// The handler reads the FileRef-tenant's row to keep a blocked
|
|
164
|
+
// tenant's public variants from staying reachable — require the
|
|
165
|
+
// dependency so a missing mount fails boot, not the tenant check
|
|
166
|
+
// silently no-op-ing at request time.
|
|
167
|
+
r.requires("tenant");
|
|
163
168
|
r.queryHandler(publicVariantByFileRefQuery);
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -6,14 +6,24 @@ import {
|
|
|
6
6
|
type TenantId,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
9
|
+
import {
|
|
10
|
+
isTenantServingPublicContent,
|
|
11
|
+
type TenantLifecycleStatus,
|
|
12
|
+
tenantTable,
|
|
13
|
+
} from "../../tenant";
|
|
9
14
|
import { PUBLIC_VARIANT_QN, publicVariantPayloadSchema } from "./public-variant.query";
|
|
10
15
|
|
|
11
16
|
type FileRefTenantRow = {
|
|
12
17
|
readonly tenantId: TenantId;
|
|
13
18
|
};
|
|
14
19
|
|
|
20
|
+
type TenantGateRow = {
|
|
21
|
+
readonly isEnabled: boolean;
|
|
22
|
+
readonly status: TenantLifecycleStatus;
|
|
23
|
+
};
|
|
24
|
+
|
|
15
25
|
const BY_FILE_REF_ESCAPE_HATCH_REASON =
|
|
16
|
-
"shared public host serves every tenant's public variants; the FileRef row names the tenant, and publicVariantQuery's isPublic gate still runs inside that tenant via queryAs";
|
|
26
|
+
"shared public host serves every tenant's public variants; the FileRef row names the tenant, and publicVariantQuery's isPublic gate still runs inside that tenant via queryAs — reading the FileRef's tenant row (isEnabled/status) first so a blocked tenant 404s identically to an unknown fileRef";
|
|
17
27
|
|
|
18
28
|
// Full QN this handler is registered under, mirrors PUBLIC_VARIANT_QN.
|
|
19
29
|
export const PUBLIC_VARIANT_BY_FILE_REF_QN = "file-derivatives:query:public-variant-by-file-ref";
|
|
@@ -28,13 +38,20 @@ export const publicVariantByFileRefQuery = defineQueryHandler({
|
|
|
28
38
|
reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
|
|
29
39
|
},
|
|
30
40
|
handler: async (query, ctx) => {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
);
|
|
41
|
+
const db = ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON);
|
|
42
|
+
const row = await fetchOne<FileRefTenantRow>(db, fileRefsTable, {
|
|
43
|
+
id: query.payload.fileRefId,
|
|
44
|
+
isDeleted: false,
|
|
45
|
+
});
|
|
36
46
|
if (!row || !isUuid(row.tenantId)) return null;
|
|
37
47
|
|
|
48
|
+
// A blocked tenant (disabled, or past active in its destroy lifecycle)
|
|
49
|
+
// must 404 identically to an unknown fileRef — otherwise this shared
|
|
50
|
+
// public host would keep serving a tenant's public variants after it
|
|
51
|
+
// was disabled or asked to be destroyed.
|
|
52
|
+
const tenant = await fetchOne<TenantGateRow>(db, tenantTable, { id: row.tenantId });
|
|
53
|
+
if (!tenant || !isTenantServingPublicContent(tenant)) return null;
|
|
54
|
+
|
|
38
55
|
return ctx.queryAs(createAnonymousUser(row.tenantId), PUBLIC_VARIANT_QN, query.payload);
|
|
39
56
|
},
|
|
40
57
|
});
|
package/src/shared/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export {
|
|
|
25
25
|
redeemRowBoundGrant,
|
|
26
26
|
signRowBoundGrant,
|
|
27
27
|
} from "./row-bound-grant";
|
|
28
|
+
export { runInSubTransaction } from "./run-in-sub-transaction";
|
|
28
29
|
export { sessionField } from "./session-field";
|
|
29
30
|
export { sessionLocaleField } from "./session-locale-field";
|
|
30
31
|
export { sessionTimezoneField } from "./session-timezone-field";
|
|
@@ -35,6 +36,14 @@ export {
|
|
|
35
36
|
type VerifyResult,
|
|
36
37
|
verifyToken,
|
|
37
38
|
} from "./signed-token";
|
|
38
|
-
export {
|
|
39
|
+
export {
|
|
40
|
+
EXT_SIGNUP_HANDOVER,
|
|
41
|
+
findSignupHandoverProvider,
|
|
42
|
+
isSignupHandoverProvider,
|
|
43
|
+
SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE,
|
|
44
|
+
type SignupHandoverBinding,
|
|
45
|
+
type SignupHandoverProvider,
|
|
46
|
+
} from "./signup-handover";
|
|
47
|
+
export { createSingleUseTokenStore, hashSingleUseToken } from "./single-use-token-store";
|
|
39
48
|
export type { SystemQueryFn } from "./system-query";
|
|
40
49
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
|
|
3
|
+
// Per-unit sub-transaction (one user, one purged row), nesting-aware across both db shapes:
|
|
4
|
+
// - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
|
|
5
|
+
// - TransactionSql (inside the dispatcher's outer tx, where every
|
|
6
|
+
// writeHandler already runs) → `.savepoint` (SAVEPOINT)
|
|
7
|
+
// A TransactionSql has no `.begin`, so the previous unconditional `.begin`
|
|
8
|
+
// threw "is not a function" on every user when invoked through the dispatcher
|
|
9
|
+
// (the cron path) → zero deletions in production, while direct-connection tests
|
|
10
|
+
// stayed green. Selecting the available method makes the sub-tx work in both
|
|
11
|
+
// contexts; on throw the savepoint rolls back just this user (others survive).
|
|
12
|
+
export async function runInSubTransaction<T>(
|
|
13
|
+
db: DbRunner,
|
|
14
|
+
fn: (tx: DbRunner) => Promise<T>,
|
|
15
|
+
): Promise<T> {
|
|
16
|
+
// `db` is already the raw runner (the handler passes ctx.db.unsafeRaw(...),
|
|
17
|
+
// the tests a top-level connection) — cast to read the transaction surface directly,
|
|
18
|
+
// without asRawClient (a test-only escape hatch).
|
|
19
|
+
const runner = db as {
|
|
20
|
+
begin?: (f: (tx: DbRunner) => Promise<T>) => Promise<T>;
|
|
21
|
+
savepoint?: (f: (tx: DbRunner) => Promise<T>) => Promise<T>;
|
|
22
|
+
};
|
|
23
|
+
// savepoint-first: empirically (Bun 1.3.14, not re-checked on 1.4.0) the
|
|
24
|
+
// surfaces are NOT mutually exclusive — a TransactionSql exposes begin AND
|
|
25
|
+
// savepoint, only the top-level connection has begin alone. begin-first
|
|
26
|
+
// picked the nested BEGIN inside a tx (the incident class above);
|
|
27
|
+
// savepoint-first hits the savepoint there and falls back to begin top-level.
|
|
28
|
+
const open = runner.savepoint ?? runner.begin;
|
|
29
|
+
if (!open) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"runInSubTransaction: db exposes neither .begin nor .savepoint — cannot open a sub-transaction",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return open.call(runner, fn);
|
|
35
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Cross-device try-first handover (kumiko-framework#3035 follow-up,
|
|
2
|
+
// offlot-app#454): signup-request runs in whichever browser the activation
|
|
3
|
+
// link ends up opened in, which may not be the one holding the anonymous
|
|
4
|
+
// visitor's tenant-handover grant. This extension point lets
|
|
5
|
+
// auth-email-password bind a verified grant to the signup token server-side
|
|
6
|
+
// — without knowing anything about tenant-handover's row-bound-grant
|
|
7
|
+
// mechanics — so signup-confirm can claim the data into the freshly
|
|
8
|
+
// provisioned tenant regardless of which browser it runs in.
|
|
9
|
+
//
|
|
10
|
+
// The grant itself never crosses this boundary: verifyGrant returns only
|
|
11
|
+
// the { entityType, rowId, sourceTenantId } binding, and mintClaim returns a
|
|
12
|
+
// freshly signed short-lived token rather than handing back the original.
|
|
13
|
+
|
|
14
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
|
|
17
|
+
export const EXT_SIGNUP_HANDOVER = "signupHandover" as const;
|
|
18
|
+
|
|
19
|
+
// Part of the contract: a provider's minted claim write may reject two
|
|
20
|
+
// different ways. Benign — this code, nothing was written (forged,
|
|
21
|
+
// expired, or already spent) — a caller redeeming the claim nested in its
|
|
22
|
+
// own write can log and carry on. Anything else means the provider's write
|
|
23
|
+
// already applied a PARTIAL change before failing (e.g. a root row moved
|
|
24
|
+
// but a declared child couldn't) — the caller must roll its own work back
|
|
25
|
+
// together with it, never treat that as a skippable no-op.
|
|
26
|
+
export const SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE = "invalid_or_expired_grant" as const;
|
|
27
|
+
|
|
28
|
+
export type SignupHandoverBinding = {
|
|
29
|
+
readonly entityType: string;
|
|
30
|
+
readonly rowId: string;
|
|
31
|
+
readonly sourceTenantId: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SignupHandoverProvider = {
|
|
35
|
+
verifyGrant(args: {
|
|
36
|
+
readonly db: DbRunner;
|
|
37
|
+
readonly registry: Registry;
|
|
38
|
+
readonly entityType: string;
|
|
39
|
+
readonly token: string;
|
|
40
|
+
}): Promise<SignupHandoverBinding | null>;
|
|
41
|
+
mintClaim(binding: SignupHandoverBinding): {
|
|
42
|
+
readonly qualifiedName: string;
|
|
43
|
+
readonly payload: { readonly token: string; readonly entityType: string };
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function isSignupHandoverProvider(options: unknown): options is SignupHandoverProvider {
|
|
48
|
+
return (
|
|
49
|
+
typeof options === "object" &&
|
|
50
|
+
options !== null &&
|
|
51
|
+
typeof (options as Record<string, unknown>)["verifyGrant"] === "function" &&
|
|
52
|
+
typeof (options as Record<string, unknown>)["mintClaim"] === "function"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function findSignupHandoverProvider(registry: Registry): SignupHandoverProvider | undefined {
|
|
57
|
+
for (const usage of registry.getExtensionUsages(EXT_SIGNUP_HANDOVER)) {
|
|
58
|
+
if (isSignupHandoverProvider(usage.options)) return usage.options;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
64
|
+
interface KumikoExtensionOptionsMap {
|
|
65
|
+
[EXT_SIGNUP_HANDOVER]: SignupHandoverProvider;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -49,7 +49,10 @@
|
|
|
49
49
|
import { createHash } from "node:crypto";
|
|
50
50
|
import type Redis from "ioredis";
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// Exported so a caller that needs a byte-compatible key in a sibling Redis
|
|
53
|
+
// namespace (e.g. a token↔binding side-mapping) can derive it without
|
|
54
|
+
// re-implementing sha256-of-token — see auth-email-password/signup-token-store.ts.
|
|
55
|
+
export function hashSingleUseToken(token: string): string {
|
|
53
56
|
return createHash("sha256").update(token).digest("hex");
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -59,7 +62,7 @@ export function createSingleUseTokenStore(prefixes: {
|
|
|
59
62
|
readonly burnPrefix: string;
|
|
60
63
|
}) {
|
|
61
64
|
function tokenKey(token: string): string {
|
|
62
|
-
return `${prefixes.tokenPrefix}${
|
|
65
|
+
return `${prefixes.tokenPrefix}${hashSingleUseToken(token)}`;
|
|
63
66
|
}
|
|
64
67
|
// Builds the forward key from an already-hashed value (e.g. read back
|
|
65
68
|
// from the by-subject entry) — does NOT hash again. Kept separate from
|
|
@@ -72,7 +75,7 @@ export function createSingleUseTokenStore(prefixes: {
|
|
|
72
75
|
return `${prefixes.subjectPrefix}${subjectId}`;
|
|
73
76
|
}
|
|
74
77
|
function burnKey(token: string): string {
|
|
75
|
-
return `${prefixes.burnPrefix}${
|
|
78
|
+
return `${prefixes.burnPrefix}${hashSingleUseToken(token)}`;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
// Stores the pair bidirectionally and sets TTL on both keys. Idempotent —
|
|
@@ -84,7 +87,7 @@ export function createSingleUseTokenStore(prefixes: {
|
|
|
84
87
|
): Promise<void> {
|
|
85
88
|
await Promise.all([
|
|
86
89
|
redis.set(tokenKey(args.token), args.subjectId, "EX", args.ttlSeconds),
|
|
87
|
-
redis.set(subjectKey(args.subjectId),
|
|
90
|
+
redis.set(subjectKey(args.subjectId), hashSingleUseToken(args.token), "EX", args.ttlSeconds),
|
|
88
91
|
]);
|
|
89
92
|
}
|
|
90
93
|
|
|
@@ -132,5 +135,22 @@ export function createSingleUseTokenStore(prefixes: {
|
|
|
132
135
|
await redis.del(burnKey(token));
|
|
133
136
|
}
|
|
134
137
|
|
|
135
|
-
|
|
138
|
+
// The by-subject entry's raw value — a token hash, not a token (see file
|
|
139
|
+
// header) — for a caller that needs to key a SIBLING Redis namespace off
|
|
140
|
+
// the same live token without recovering it (e.g. a resend that must
|
|
141
|
+
// carry a side-binding over to the fresh token before the old one is
|
|
142
|
+
// invalidated).
|
|
143
|
+
async function getTokenHashForSubject(redis: Redis, subjectId: string): Promise<string | null> {
|
|
144
|
+
return redis.get(subjectKey(subjectId));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
store,
|
|
149
|
+
getSubjectForToken,
|
|
150
|
+
invalidateExistingBySubject,
|
|
151
|
+
burn,
|
|
152
|
+
deleteBoth,
|
|
153
|
+
unburn,
|
|
154
|
+
getTokenHashForSubject,
|
|
155
|
+
};
|
|
136
156
|
}
|