@cosmicdrift/kumiko-bundled-features 0.313.0 → 0.315.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/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +66 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +10 -4
- package/src/compliance-profiles/resolve-for-tenant.ts +15 -5
- package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +6 -6
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +1 -1
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +10 -10
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/feature.ts +1 -1
- package/src/data-retention/handlers/policy-for.query.ts +14 -31
- package/src/data-retention/index.ts +2 -0
- package/src/data-retention/resolve-for-tenant.ts +25 -17
- package/src/data-retention/resolve-tenant-preset.ts +5 -2
- package/src/data-retention/run-retention-cleanup.ts +5 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +189 -19
- package/src/delivery/address-opt-out.ts +49 -21
- package/src/delivery/changes.json +12 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/delivery-service.ts +48 -27
- package/src/delivery/feature.ts +5 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +40 -0
- package/src/delivery/handlers/unsubscribe-user.write.ts +43 -0
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +6 -0
- package/src/delivery/unsubscribe.ts +144 -67
- package/src/delivery/upsert-preference.ts +74 -60
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +1 -0
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +2 -1
- package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +2 -1
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +4 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/is-tenant-db.ts +9 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +5 -1
- package/src/user-data-rights/__tests__/retention-preset-forget.integration.test.ts +272 -0
- package/src/user-data-rights/run-forget-cleanup.ts +6 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +9 -7
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +7 -2
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ExtraRouteDefinition,
|
|
3
|
+
ExtraRouteRejection,
|
|
4
|
+
signatureRoute,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
2
6
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { Hono } from "hono";
|
|
4
7
|
import * as jose from "jose";
|
|
5
8
|
import * as z from "zod";
|
|
6
|
-
import { hashUnsubscribeAddress
|
|
7
|
-
import {
|
|
9
|
+
import { hashUnsubscribeAddress } from "./address-opt-out";
|
|
10
|
+
import { DELIVERY_UNSUBSCRIBE_PATH, DeliveryHandlers } from "./constants";
|
|
8
11
|
|
|
9
12
|
// Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
|
|
10
13
|
// wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
|
|
@@ -49,22 +52,36 @@ export type AddressUnsubscribeTokenPayload = {
|
|
|
49
52
|
readonly channel: string;
|
|
50
53
|
};
|
|
51
54
|
|
|
55
|
+
/**
|
|
56
|
+
* `secret` must be a value dedicated to unsubscribe-token signing — do NOT
|
|
57
|
+
* reuse the app's session `JWT_SECRET`. The app mounting `extraRoutes:
|
|
58
|
+
* [createUnsubscribeRoute({ secret })]` signs outgoing links with the same
|
|
59
|
+
* value via `signUnsubscribeToken` / `signAddressUnsubscribeToken`.
|
|
60
|
+
*/
|
|
52
61
|
export type UnsubscribeRouteOptions = {
|
|
53
|
-
readonly
|
|
54
|
-
readonly jwtSecret: string;
|
|
62
|
+
readonly secret: string;
|
|
55
63
|
};
|
|
56
64
|
|
|
57
65
|
const UNSUBSCRIBE_EXPIRY = "7d";
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
|
|
67
|
+
|
|
68
|
+
function assertUnsubscribeSecret(secret: string, caller: string): void {
|
|
69
|
+
if (secret.length < MIN_UNSUBSCRIBE_SECRET_LENGTH) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`${caller}: secret must be ≥${MIN_UNSUBSCRIBE_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
|
|
77
|
+
error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
|
|
78
|
+
} as const;
|
|
63
79
|
|
|
64
80
|
export async function signUnsubscribeToken(
|
|
65
81
|
payload: UnsubscribeTokenPayload,
|
|
66
82
|
secret: string,
|
|
67
83
|
): Promise<string> {
|
|
84
|
+
assertUnsubscribeSecret(secret, "signUnsubscribeToken");
|
|
68
85
|
const encodedSecret = new TextEncoder().encode(secret);
|
|
69
86
|
return new jose.SignJWT({
|
|
70
87
|
tenantId: payload.tenantId,
|
|
@@ -90,6 +107,7 @@ export async function signAddressUnsubscribeToken(
|
|
|
90
107
|
payload: AddressUnsubscribeTokenPayload,
|
|
91
108
|
secret: string,
|
|
92
109
|
): Promise<string> {
|
|
110
|
+
assertUnsubscribeSecret(secret, "signAddressUnsubscribeToken");
|
|
93
111
|
const addressHash = hashUnsubscribeAddress(payload.address);
|
|
94
112
|
if (addressHash === undefined) {
|
|
95
113
|
throw new Error(
|
|
@@ -118,67 +136,126 @@ export async function signAddressUnsubscribeToken(
|
|
|
118
136
|
);
|
|
119
137
|
}
|
|
120
138
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (!token) {
|
|
129
|
-
return c.text("Missing token", 400);
|
|
139
|
+
type VerifiedUnsubscribe =
|
|
140
|
+
| {
|
|
141
|
+
readonly kind: "address";
|
|
142
|
+
readonly tenantId: TenantId;
|
|
143
|
+
readonly addressHash: string;
|
|
144
|
+
readonly notificationType: string;
|
|
145
|
+
readonly channel: string;
|
|
130
146
|
}
|
|
147
|
+
| {
|
|
148
|
+
readonly kind: "user";
|
|
149
|
+
readonly tenantId: TenantId;
|
|
150
|
+
readonly userId: string;
|
|
151
|
+
readonly notificationType: string;
|
|
152
|
+
readonly channel: string;
|
|
153
|
+
};
|
|
131
154
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
issuer: "kumiko:unsubscribe",
|
|
136
|
-
}));
|
|
137
|
-
} catch {
|
|
138
|
-
return c.text("Invalid or expired token", 400);
|
|
139
|
-
}
|
|
155
|
+
export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): ExtraRouteDefinition {
|
|
156
|
+
assertUnsubscribeSecret(options.secret, "createUnsubscribeRoute");
|
|
157
|
+
const encodedSecret = new TextEncoder().encode(options.secret);
|
|
140
158
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
159
|
+
return signatureRoute<VerifiedUnsubscribe>({
|
|
160
|
+
method: "GET",
|
|
161
|
+
path: DELIVERY_UNSUBSCRIBE_PATH,
|
|
162
|
+
entry: "signature",
|
|
163
|
+
// Every throw here must become the same ExtraRouteRejection(400,
|
|
164
|
+
// unsubscribe_token_invalid) — anything else falls through to the
|
|
165
|
+
// framework's generic 401 extra_route_signature_invalid mapping, which
|
|
166
|
+
// would leak jose's internal error text into the response body.
|
|
167
|
+
verify: async ({ query }) => {
|
|
168
|
+
try {
|
|
169
|
+
const token = query["token"];
|
|
170
|
+
if (!token) {
|
|
171
|
+
throw new ExtraRouteRejection(
|
|
172
|
+
400,
|
|
173
|
+
UNSUBSCRIBE_TOKEN_INVALID_BODY,
|
|
174
|
+
"missing unsubscribe token",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
|
|
179
|
+
issuer: "kumiko:unsubscribe",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
183
|
+
if (addressAttempt.success) {
|
|
184
|
+
const parsed = addressAttempt.data;
|
|
185
|
+
if (parsed.sub !== parsed.addressHash) {
|
|
186
|
+
throw new ExtraRouteRejection(
|
|
187
|
+
400,
|
|
188
|
+
UNSUBSCRIBE_TOKEN_INVALID_BODY,
|
|
189
|
+
"address token subject mismatch",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
193
|
+
const tenantId = parsed.tenantId as TenantId;
|
|
194
|
+
return {
|
|
195
|
+
kind: "address",
|
|
196
|
+
tenantId,
|
|
197
|
+
addressHash: parsed.addressHash,
|
|
198
|
+
notificationType: parsed.notificationType,
|
|
199
|
+
channel: parsed.channel,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
204
|
+
if (!userAttempt.success) {
|
|
205
|
+
throw new ExtraRouteRejection(
|
|
206
|
+
400,
|
|
207
|
+
UNSUBSCRIBE_TOKEN_INVALID_BODY,
|
|
208
|
+
"unsubscribe token payload matched neither schema",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const parsed = userAttempt.data;
|
|
212
|
+
// @cast-boundary engine-bridge — string post-zod → branded TenantId
|
|
213
|
+
const tenantId = parsed.tenantId as TenantId;
|
|
214
|
+
return {
|
|
215
|
+
kind: "user",
|
|
216
|
+
tenantId,
|
|
217
|
+
userId: parsed.sub,
|
|
218
|
+
notificationType: parsed.notificationType,
|
|
219
|
+
channel: parsed.channel,
|
|
220
|
+
};
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err instanceof ExtraRouteRejection) throw err;
|
|
223
|
+
throw new ExtraRouteRejection(
|
|
224
|
+
400,
|
|
225
|
+
UNSUBSCRIBE_TOKEN_INVALID_BODY,
|
|
226
|
+
"unsubscribe token verification failed",
|
|
227
|
+
);
|
|
146
228
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
229
|
+
},
|
|
230
|
+
handler: async (c, verified, deps) => {
|
|
231
|
+
const payload =
|
|
232
|
+
verified.kind === "address"
|
|
233
|
+
? {
|
|
234
|
+
addressHash: verified.addressHash,
|
|
235
|
+
notificationType: verified.notificationType,
|
|
236
|
+
channel: verified.channel,
|
|
237
|
+
}
|
|
238
|
+
: {
|
|
239
|
+
userId: verified.userId,
|
|
240
|
+
notificationType: verified.notificationType,
|
|
241
|
+
channel: verified.channel,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Token-verify passed — everything below is a legitimate write. Don't
|
|
245
|
+
// swallow write-errors as "invalid token", that would mask real bugs
|
|
246
|
+
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
247
|
+
const dispatched = await deps.dispatchSystemWrite({
|
|
248
|
+
handlerQn:
|
|
249
|
+
verified.kind === "address"
|
|
250
|
+
? DeliveryHandlers.unsubscribeAddress
|
|
251
|
+
: DeliveryHandlers.unsubscribeUser,
|
|
252
|
+
tenantId: verified.tenantId,
|
|
253
|
+
payload,
|
|
156
254
|
});
|
|
255
|
+
if (!dispatched.isSuccess) {
|
|
256
|
+
return c.text("Unsubscribe failed", 500);
|
|
257
|
+
}
|
|
157
258
|
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
|
-
|
|
168
|
-
// Token-verify passed — everything below is a legitimate write. Don't
|
|
169
|
-
// swallow write-errors as "invalid token", that would mask real bugs
|
|
170
|
-
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
171
|
-
const actor = { id: parsed.sub, tenantId, roles: SYSTEM_ROLES };
|
|
172
|
-
const tdb = createTenantDb(db, tenantId, "system");
|
|
173
|
-
await upsertPreference(tdb, actor, {
|
|
174
|
-
tenantId,
|
|
175
|
-
userId: parsed.sub,
|
|
176
|
-
notificationType: parsed.notificationType,
|
|
177
|
-
channel: parsed.channel,
|
|
178
|
-
enabled: false,
|
|
179
|
-
});
|
|
180
|
-
return c.text("You have been unsubscribed.", 200);
|
|
259
|
+
},
|
|
181
260
|
});
|
|
182
|
-
|
|
183
|
-
return app;
|
|
184
261
|
}
|
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
// concurrent requests — typical case: a user clicks the same "unsubscribe"
|
|
6
6
|
// email link three times in a second.
|
|
7
7
|
//
|
|
8
|
-
// The fix:
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// The fix: the aggregate id is deterministic (tenant+user+type+channel), so
|
|
9
|
+
// concurrent first-time clicks collide at the event-store append itself
|
|
10
|
+
// (expectedVersion 0, confined to a savepoint) instead of racing past two
|
|
11
|
+
// independent creates and leaving an orphaned `created` event behind. The
|
|
12
|
+
// loser's create() comes back as a `version_conflict` failure; re-lookup and
|
|
13
|
+
// fall through to update, or no-op if the winner already set the target
|
|
14
|
+
// `enabled` state.
|
|
12
15
|
|
|
13
16
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
17
|
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
15
18
|
import type { SessionUser, TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
|
|
16
20
|
import { notificationPreferenceEntity, notificationPreferencesTable } from "./tables";
|
|
17
21
|
|
|
18
22
|
const executor = createEventStoreExecutor(
|
|
@@ -21,11 +25,15 @@ const executor = createEventStoreExecutor(
|
|
|
21
25
|
{ entityName: "notification-preference" },
|
|
22
26
|
);
|
|
23
27
|
|
|
24
|
-
// Konkretes Lookup-Result-Shape — nur die
|
|
28
|
+
// Konkretes Lookup-Result-Shape — nur die Felder die der upsert
|
|
25
29
|
// tatsächlich für den Update-Path braucht. Vermeidet `row["x"] as T` index-
|
|
26
30
|
// access casts; der Generic-Param am fetchOne macht den Cast zentralisiert
|
|
27
31
|
// im Helper (db-row-boundary), nicht 4× pro Callsite.
|
|
28
|
-
type PreferenceLookupRow = {
|
|
32
|
+
type PreferenceLookupRow = {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly version: number;
|
|
35
|
+
readonly enabled: boolean;
|
|
36
|
+
};
|
|
29
37
|
|
|
30
38
|
// @wrapper-known semantic-alias
|
|
31
39
|
async function lookup(
|
|
@@ -51,11 +59,25 @@ export type UpsertPreferenceInput = {
|
|
|
51
59
|
readonly enabled: boolean;
|
|
52
60
|
};
|
|
53
61
|
|
|
62
|
+
// One row per (tenant, user, type, channel): folding the tuple into the
|
|
63
|
+
// aggregate id means two concurrent first-time clicks collide on the same
|
|
64
|
+
// stream at append instead of racing two independent creates.
|
|
65
|
+
function preferenceAggregateId(
|
|
66
|
+
tenantId: TenantId,
|
|
67
|
+
userId: string,
|
|
68
|
+
notificationType: string,
|
|
69
|
+
channel: string,
|
|
70
|
+
): string {
|
|
71
|
+
return generateDeterministicId(
|
|
72
|
+
"delivery:notification-preference",
|
|
73
|
+
`${tenantId}|${userId}|${notificationType}|${channel}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
/**
|
|
55
78
|
* Idempotent "set-this-preference-to-enabled-state" against the preferences
|
|
56
79
|
* aggregate stream. Emits either `.created` (first time) or `.updated`
|
|
57
|
-
* (subsequent) and
|
|
58
|
-
* fallback to update.
|
|
80
|
+
* (subsequent) and converges a concurrent create-race onto the winner's row.
|
|
59
81
|
*/
|
|
60
82
|
export async function upsertPreference(
|
|
61
83
|
db: TenantDb,
|
|
@@ -71,6 +93,7 @@ export async function upsertPreference(
|
|
|
71
93
|
);
|
|
72
94
|
|
|
73
95
|
if (existing) {
|
|
96
|
+
if (existing.enabled === input.enabled) return { isSuccess: true, data: input };
|
|
74
97
|
const result = await executor.update(
|
|
75
98
|
{
|
|
76
99
|
id: existing.id,
|
|
@@ -84,57 +107,48 @@ export async function upsertPreference(
|
|
|
84
107
|
return { isSuccess: true, data: input };
|
|
85
108
|
}
|
|
86
109
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (!isUniqueViolation(err)) throw err;
|
|
107
|
-
const afterRace = await lookup(
|
|
108
|
-
db,
|
|
109
|
-
input.tenantId,
|
|
110
|
-
input.userId,
|
|
111
|
-
input.notificationType,
|
|
112
|
-
input.channel,
|
|
113
|
-
);
|
|
114
|
-
if (!afterRace) throw err;
|
|
115
|
-
const result = await executor.update(
|
|
116
|
-
{
|
|
117
|
-
id: afterRace.id,
|
|
118
|
-
version: afterRace.version,
|
|
119
|
-
changes: { enabled: input.enabled },
|
|
120
|
-
},
|
|
121
|
-
actor,
|
|
122
|
-
db,
|
|
123
|
-
);
|
|
124
|
-
if (!result.isSuccess) return result;
|
|
125
|
-
return { isSuccess: true, data: input };
|
|
126
|
-
}
|
|
127
|
-
}
|
|
110
|
+
const id = preferenceAggregateId(
|
|
111
|
+
input.tenantId,
|
|
112
|
+
input.userId,
|
|
113
|
+
input.notificationType,
|
|
114
|
+
input.channel,
|
|
115
|
+
);
|
|
116
|
+
const created = await executor.create(
|
|
117
|
+
{
|
|
118
|
+
id,
|
|
119
|
+
userId: input.userId,
|
|
120
|
+
notificationType: input.notificationType,
|
|
121
|
+
channel: input.channel,
|
|
122
|
+
enabled: input.enabled,
|
|
123
|
+
},
|
|
124
|
+
actor,
|
|
125
|
+
db,
|
|
126
|
+
);
|
|
127
|
+
if (created.isSuccess) return { isSuccess: true, data: input };
|
|
128
|
+
if (created.error.code !== "version_conflict") return created;
|
|
128
129
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
130
|
+
// Race-fallback: another request's create already won this deterministic
|
|
131
|
+
// id between our lookup and this create. Re-lookup and converge onto its
|
|
132
|
+
// row — no-op if it already set the target `enabled`, since relooking the
|
|
133
|
+
// same version and updating would version_conflict all but one loser.
|
|
134
|
+
const afterRace = await lookup(
|
|
135
|
+
db,
|
|
136
|
+
input.tenantId,
|
|
137
|
+
input.userId,
|
|
138
|
+
input.notificationType,
|
|
139
|
+
input.channel,
|
|
140
|
+
);
|
|
141
|
+
if (!afterRace) return created;
|
|
142
|
+
if (afterRace.enabled === input.enabled) return { isSuccess: true, data: input };
|
|
143
|
+
const result = await executor.update(
|
|
144
|
+
{
|
|
145
|
+
id: afterRace.id,
|
|
146
|
+
version: afterRace.version,
|
|
147
|
+
changes: { enabled: input.enabled },
|
|
148
|
+
},
|
|
149
|
+
actor,
|
|
150
|
+
db,
|
|
151
|
+
);
|
|
152
|
+
if (!result.isSuccess) return result;
|
|
153
|
+
return { isSuccess: true, data: input };
|
|
140
154
|
}
|
|
@@ -122,6 +122,7 @@ const pngBytes = new Uint8Array([
|
|
|
122
122
|
]);
|
|
123
123
|
const textBytes = new TextEncoder().encode("plain text, not a supported mime type");
|
|
124
124
|
const oversizedPdfBytes = new Uint8Array(PROVIDER_A_MAX_BYTES + 100);
|
|
125
|
+
oversizedPdfBytes.set(new TextEncoder().encode("%PDF-1.4"));
|
|
125
126
|
// Real ZIP local-file-header signature — validateFileContent content-verifies
|
|
126
127
|
// docx uploads against it independent of options.accept.
|
|
127
128
|
const docxBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...Array(32).fill(0)]);
|
|
@@ -73,7 +73,8 @@ async function uploadImage(
|
|
|
73
73
|
): Promise<string> {
|
|
74
74
|
const token = await stack.jwt.sign(asUser);
|
|
75
75
|
const fd = new FormData();
|
|
76
|
-
|
|
76
|
+
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
|
|
77
|
+
fd.append("file", new File([Buffer.from(jpegBytes)], "img.jpg", { type: "image/jpeg" }));
|
|
77
78
|
fd.append("entityType", "gadget");
|
|
78
79
|
fd.append("entityId", entityId);
|
|
79
80
|
fd.append("fieldName", "img");
|
|
@@ -98,7 +98,8 @@ beforeEach(async () => {
|
|
|
98
98
|
async function uploadGadgetImage(): Promise<string> {
|
|
99
99
|
const token = await stack.jwt.sign(user);
|
|
100
100
|
const fd = new FormData();
|
|
101
|
-
|
|
101
|
+
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
|
|
102
|
+
fd.append("file", new File([Buffer.from(jpegBytes)], "img.jpg", { type: "image/jpeg" }));
|
|
102
103
|
fd.append("entityType", "gadget");
|
|
103
104
|
fd.append("entityId", ENTITY_ID);
|
|
104
105
|
fd.append("fieldName", "img");
|
|
@@ -158,7 +158,10 @@ async function uploadFile(
|
|
|
158
158
|
// extension, not the declared part Content-Type — so the extension must
|
|
159
159
|
// match `mimeType` for the upload to actually carry it through.
|
|
160
160
|
const extension = mimeType.split("/")[1]?.split("+")[0] ?? "bin";
|
|
161
|
-
|
|
161
|
+
// Real JPEG magic bytes so validateFileContent doesn't 400 the default
|
|
162
|
+
// image/jpeg uploads; svg has no signature so this covers both cases.
|
|
163
|
+
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(10).fill(0)]);
|
|
164
|
+
fd.append("file", new File([Buffer.from(jpegBytes)], `img.${extension}`, { type: mimeType }));
|
|
162
165
|
fd.append("entityType", attach.entityType);
|
|
163
166
|
fd.append("entityId", attach.entityId);
|
|
164
167
|
fd.append("fieldName", fieldName);
|
package/src/shared/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { entitiesOf } from "./entities-of";
|
|
|
16
16
|
export { isWithinGracePeriod } from "./grace-period";
|
|
17
17
|
export { hasWhereRule } from "./has-where-rule";
|
|
18
18
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
19
|
+
export { isTenantDb } from "./is-tenant-db";
|
|
19
20
|
export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter";
|
|
20
21
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
21
22
|
export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
|
|
3
|
+
// Structural probe: a TenantDb and a raw DbRunner both expose fetchOne/selectMany,
|
|
4
|
+
// so the shape alone can't tell them apart — `unsafeRaw` only exists on TenantDb.
|
|
5
|
+
// Shared so retention/compliance-profile resolvers that accept either (bulk cron
|
|
6
|
+
// vs. per-hook TenantDb) branch through one predicate instead of duplicating it.
|
|
7
|
+
export function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
|
|
8
|
+
return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
|
|
9
|
+
}
|
|
@@ -141,7 +141,11 @@ describe("#608 unified file-storage :: route upload + GDPR erasure hit one store
|
|
|
141
141
|
await seed.seedMembership(userId, TENANT_SYSTEM);
|
|
142
142
|
const uploader: SessionUser = { id: userId, tenantId: TENANT_SYSTEM, roles: ["Member"] };
|
|
143
143
|
|
|
144
|
-
const res = await uploadAs(
|
|
144
|
+
const res = await uploadAs(
|
|
145
|
+
uploader,
|
|
146
|
+
"secret.pdf",
|
|
147
|
+
new TextEncoder().encode("%PDF-1.4 minimal"),
|
|
148
|
+
);
|
|
145
149
|
expect(res.status).toBe(201);
|
|
146
150
|
const { storageKey } = (await res.json()) as { storageKey: string };
|
|
147
151
|
|