@beignet/core 0.0.42 → 0.0.44
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/CHANGELOG.md +17 -0
- package/README.md +64 -11
- package/dist/idempotency/index.d.ts +3 -1
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js.map +1 -1
- package/dist/mail/index.d.ts +3 -0
- package/dist/mail/index.d.ts.map +1 -1
- package/dist/mail/index.js +35 -16
- package/dist/mail/index.js.map +1 -1
- package/dist/ports/index.d.ts +2 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/redaction.d.ts +7 -4
- package/dist/ports/redaction.d.ts.map +1 -1
- package/dist/ports/redaction.js +29 -7
- package/dist/ports/redaction.js.map +1 -1
- package/dist/ports/storage.d.ts +50 -0
- package/dist/ports/storage.d.ts.map +1 -1
- package/dist/ports/storage.js +85 -37
- package/dist/ports/storage.js.map +1 -1
- package/dist/server/hooks/idempotency.d.ts +3 -2
- package/dist/server/hooks/idempotency.d.ts.map +1 -1
- package/dist/server/hooks/idempotency.js +1 -1
- package/dist/server/hooks/idempotency.js.map +1 -1
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +53 -47
- package/dist/uploads/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/app-architecture/SKILL.md +33 -2
- package/src/idempotency/index.ts +3 -1
- package/src/mail/index.ts +37 -7
- package/src/ports/index.ts +9 -1
- package/src/ports/redaction.ts +51 -9
- package/src/ports/storage.ts +131 -46
- package/src/server/hooks/idempotency.ts +4 -3
- package/src/uploads/index.ts +49 -43
package/src/ports/storage.ts
CHANGED
|
@@ -139,6 +139,126 @@ export interface StoragePort {
|
|
|
139
139
|
publicUrl(key: string): Promise<string | null>;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Options for prefixing one validated storage key.
|
|
144
|
+
*/
|
|
145
|
+
export interface PrefixStorageKeyOptions {
|
|
146
|
+
/**
|
|
147
|
+
* Optional app or environment prefix. Leading and trailing slashes are
|
|
148
|
+
* removed before it is applied.
|
|
149
|
+
*/
|
|
150
|
+
keyPrefix?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Relative object key to prefix.
|
|
153
|
+
*/
|
|
154
|
+
key: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Options for formatting a public storage URL.
|
|
159
|
+
*/
|
|
160
|
+
export interface CreateStoragePublicUrlOptions {
|
|
161
|
+
/**
|
|
162
|
+
* Absolute or app-relative public base URL.
|
|
163
|
+
*/
|
|
164
|
+
publicBaseUrl: string;
|
|
165
|
+
/**
|
|
166
|
+
* Relative object key appended to the public base URL.
|
|
167
|
+
*/
|
|
168
|
+
key: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasControlCharacter(value: string): boolean {
|
|
172
|
+
for (const char of value) {
|
|
173
|
+
const code = char.charCodeAt(0);
|
|
174
|
+
if (code <= 31 || code === 127) return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Assert that a storage key follows Beignet's provider-neutral key rules.
|
|
182
|
+
*
|
|
183
|
+
* Valid keys are non-empty relative object paths. They do not contain control
|
|
184
|
+
* characters, backslashes, empty path segments, or `.` / `..` segments.
|
|
185
|
+
* Providers may enforce additional adapter-specific restrictions after this
|
|
186
|
+
* shared assertion.
|
|
187
|
+
*/
|
|
188
|
+
export function assertValidStorageKey(key: string): void {
|
|
189
|
+
if (key.length === 0) {
|
|
190
|
+
throw new Error("Storage key must not be empty.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (hasControlCharacter(key)) {
|
|
194
|
+
throw new Error("Storage key must not include control characters.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (key.startsWith("/")) {
|
|
198
|
+
throw new Error("Storage key must not start with '/'.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (key.endsWith("/")) {
|
|
202
|
+
throw new Error("Storage key must not end with '/'.");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (key.includes("\\")) {
|
|
206
|
+
throw new Error("Storage key must use '/' separators, not '\\'.");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const segments = key.split("/");
|
|
210
|
+
|
|
211
|
+
if (segments.some((segment) => segment === "")) {
|
|
212
|
+
throw new Error("Storage key must not include empty path segments.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
216
|
+
throw new Error("Storage key must not include '.' or '..' segments.");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Normalize and validate an optional storage key prefix.
|
|
222
|
+
*
|
|
223
|
+
* Empty and slash-only prefixes normalize to an empty string.
|
|
224
|
+
*/
|
|
225
|
+
export function normalizeStorageKeyPrefix(prefix: string | undefined): string {
|
|
226
|
+
if (!prefix) return "";
|
|
227
|
+
const normalized = prefix.replace(/^\/+|\/+$/g, "");
|
|
228
|
+
if (!normalized) return "";
|
|
229
|
+
assertValidStorageKey(normalized);
|
|
230
|
+
return normalized;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Prefix a storage key with an optional app or environment namespace.
|
|
235
|
+
*/
|
|
236
|
+
export function prefixStorageKey({
|
|
237
|
+
keyPrefix,
|
|
238
|
+
key,
|
|
239
|
+
}: PrefixStorageKeyOptions): string {
|
|
240
|
+
const normalizedPrefix = normalizeStorageKeyPrefix(keyPrefix);
|
|
241
|
+
assertValidStorageKey(key);
|
|
242
|
+
return normalizedPrefix ? `${normalizedPrefix}/${key}` : key;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Format an encoded public URL for a validated storage key.
|
|
247
|
+
*/
|
|
248
|
+
export function createStoragePublicUrl({
|
|
249
|
+
publicBaseUrl,
|
|
250
|
+
key,
|
|
251
|
+
}: CreateStoragePublicUrlOptions): string {
|
|
252
|
+
assertValidStorageKey(key);
|
|
253
|
+
const base = publicBaseUrl.replace(/\/+$/, "");
|
|
254
|
+
const encodedKey = key
|
|
255
|
+
.split("/")
|
|
256
|
+
.map((part) => encodeURIComponent(part))
|
|
257
|
+
.join("/");
|
|
258
|
+
|
|
259
|
+
return `${base}/${encodedKey}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
142
262
|
/**
|
|
143
263
|
* Options for `createMemoryStorage(...)`.
|
|
144
264
|
*/
|
|
@@ -271,44 +391,6 @@ function createObjectBody(entry: MemoryStorageEntry): StorageObjectBody {
|
|
|
271
391
|
};
|
|
272
392
|
}
|
|
273
393
|
|
|
274
|
-
function validateStorageKey(key: string): void {
|
|
275
|
-
if (key.length === 0) {
|
|
276
|
-
throw new Error("Storage key must not be empty.");
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (key.startsWith("/")) {
|
|
280
|
-
throw new Error("Storage key must not start with '/'.");
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (key.endsWith("/")) {
|
|
284
|
-
throw new Error("Storage key must not end with '/'.");
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (key.includes("\\")) {
|
|
288
|
-
throw new Error("Storage key must use '/' separators, not '\\'.");
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const segments = key.split("/");
|
|
292
|
-
|
|
293
|
-
if (segments.some((segment) => segment === "")) {
|
|
294
|
-
throw new Error("Storage key must not include empty path segments.");
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
298
|
-
throw new Error("Storage key must not include '.' or '..' segments.");
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function joinPublicUrl(baseUrl: string, key: string): string {
|
|
303
|
-
const base = baseUrl.replace(/\/+$/, "");
|
|
304
|
-
const encodedKey = key
|
|
305
|
-
.split("/")
|
|
306
|
-
.map((part) => encodeURIComponent(part))
|
|
307
|
-
.join("/");
|
|
308
|
-
|
|
309
|
-
return `${base}/${encodedKey}`;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
394
|
/**
|
|
313
395
|
* Create an in-memory object storage adapter for tests, examples, and
|
|
314
396
|
* single-process development.
|
|
@@ -326,7 +408,7 @@ export function createMemoryStorage(
|
|
|
326
408
|
|
|
327
409
|
return {
|
|
328
410
|
async put(key, body, putOptions) {
|
|
329
|
-
|
|
411
|
+
assertValidStorageKey(key);
|
|
330
412
|
const bytes = await storageBodyToBytes(body);
|
|
331
413
|
const entry: MemoryStorageEntry = {
|
|
332
414
|
key,
|
|
@@ -348,35 +430,38 @@ export function createMemoryStorage(
|
|
|
348
430
|
return cloneObject(entry);
|
|
349
431
|
},
|
|
350
432
|
async get(key) {
|
|
351
|
-
|
|
433
|
+
assertValidStorageKey(key);
|
|
352
434
|
const entry = objects.get(key);
|
|
353
435
|
if (!entry) return null;
|
|
354
436
|
|
|
355
437
|
return createObjectBody(entry);
|
|
356
438
|
},
|
|
357
439
|
async stat(key) {
|
|
358
|
-
|
|
440
|
+
assertValidStorageKey(key);
|
|
359
441
|
const entry = objects.get(key);
|
|
360
442
|
if (!entry) return null;
|
|
361
443
|
|
|
362
444
|
return cloneObject(entry);
|
|
363
445
|
},
|
|
364
446
|
async delete(key) {
|
|
365
|
-
|
|
447
|
+
assertValidStorageKey(key);
|
|
366
448
|
return objects.delete(key);
|
|
367
449
|
},
|
|
368
450
|
async exists(key) {
|
|
369
|
-
|
|
451
|
+
assertValidStorageKey(key);
|
|
370
452
|
return objects.has(key);
|
|
371
453
|
},
|
|
372
454
|
async publicUrl(key) {
|
|
373
|
-
|
|
455
|
+
assertValidStorageKey(key);
|
|
374
456
|
const entry = objects.get(key);
|
|
375
|
-
if (
|
|
457
|
+
if (entry?.visibility !== "public" || !options.publicBaseUrl) {
|
|
376
458
|
return null;
|
|
377
459
|
}
|
|
378
460
|
|
|
379
|
-
return
|
|
461
|
+
return createStoragePublicUrl({
|
|
462
|
+
publicBaseUrl: options.publicBaseUrl,
|
|
463
|
+
key,
|
|
464
|
+
});
|
|
380
465
|
},
|
|
381
466
|
};
|
|
382
467
|
}
|
|
@@ -58,8 +58,9 @@ export interface IdempotencyHooksOptions<Ctx> {
|
|
|
58
58
|
/**
|
|
59
59
|
* Build the idempotency scope after context exists.
|
|
60
60
|
*
|
|
61
|
-
* Defaults to a scope derived from `meta.scope`:
|
|
62
|
-
* `
|
|
61
|
+
* Defaults to a scope derived from `meta.scope`: omitted metadata scopes by
|
|
62
|
+
* `ctx.actor?.id` and also `ctx.tenant?.id` when present, `"actor"` scopes by
|
|
63
|
+
* the actor only, `"global"` stays global, `"tenant"` scopes by the tenant,
|
|
63
64
|
* and `"actor-tenant"` scopes by both.
|
|
64
65
|
*/
|
|
65
66
|
scope?: (args: {
|
|
@@ -97,7 +98,7 @@ function defaultIdempotencyScope(
|
|
|
97
98
|
ctx: CtxWithIdempotency,
|
|
98
99
|
meta: IdempotencyMeta,
|
|
99
100
|
): IdempotencyScope {
|
|
100
|
-
const mode = meta.scope ?? "
|
|
101
|
+
const mode = meta.scope ?? (ctx.tenant?.id ? "actor-tenant" : "actor");
|
|
101
102
|
|
|
102
103
|
switch (mode) {
|
|
103
104
|
case "global":
|
package/src/uploads/index.ts
CHANGED
|
@@ -1037,63 +1037,69 @@ export function createUploadRouter<Ctx>(
|
|
|
1037
1037
|
|
|
1038
1038
|
const completed: CompletedUploadFile[] = [];
|
|
1039
1039
|
const storedKeys: string[] = [];
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
metadata,
|
|
1058
|
-
file: verifiedIntent,
|
|
1059
|
-
uploadId,
|
|
1060
|
-
});
|
|
1061
|
-
const storageMetadata =
|
|
1062
|
-
(await definition.storageMetadata?.({
|
|
1040
|
+
try {
|
|
1041
|
+
for (const [index, file] of webFiles.entries()) {
|
|
1042
|
+
const intent = intents[index];
|
|
1043
|
+
if (!intent) continue;
|
|
1044
|
+
const uploadId = id();
|
|
1045
|
+
await assertAuthorized(definition, {
|
|
1046
|
+
ctx,
|
|
1047
|
+
metadata,
|
|
1048
|
+
file: intent,
|
|
1049
|
+
uploadId,
|
|
1050
|
+
});
|
|
1051
|
+
const verified = await verifyBlobUploadFile(definition, intent, file);
|
|
1052
|
+
const verifiedIntent = {
|
|
1053
|
+
...intent,
|
|
1054
|
+
...(verified.checksum ? { checksum: verified.checksum } : {}),
|
|
1055
|
+
};
|
|
1056
|
+
const key = await definition.key({
|
|
1063
1057
|
ctx,
|
|
1064
1058
|
metadata,
|
|
1065
1059
|
file: verifiedIntent,
|
|
1066
1060
|
uploadId,
|
|
1067
|
-
})
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1061
|
+
});
|
|
1062
|
+
const storageMetadata =
|
|
1063
|
+
(await definition.storageMetadata?.({
|
|
1064
|
+
ctx,
|
|
1065
|
+
metadata,
|
|
1066
|
+
file: verifiedIntent,
|
|
1067
|
+
uploadId,
|
|
1068
|
+
})) ?? {};
|
|
1069
|
+
const object = await options.storage.put(key, file, {
|
|
1070
|
+
contentType: verifiedIntent.contentType,
|
|
1071
|
+
...(definition.file.cacheControl !== undefined
|
|
1072
|
+
? { cacheControl: definition.file.cacheControl }
|
|
1073
|
+
: {}),
|
|
1074
|
+
metadata: storageMetadata,
|
|
1075
|
+
visibility: definition.file.visibility ?? "private",
|
|
1076
|
+
});
|
|
1077
|
+
storedKeys.push(key);
|
|
1078
|
+
const completedFile = {
|
|
1079
|
+
...verifiedIntent,
|
|
1080
|
+
uploadId,
|
|
1081
|
+
key,
|
|
1082
|
+
object,
|
|
1083
|
+
};
|
|
1084
1084
|
await assertVerifiedFile(definition, {
|
|
1085
1085
|
ctx,
|
|
1086
1086
|
metadata,
|
|
1087
1087
|
file: completedFile,
|
|
1088
1088
|
storage: options.storage,
|
|
1089
1089
|
});
|
|
1090
|
-
|
|
1090
|
+
completed.push(completedFile);
|
|
1091
|
+
}
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (storedKeys.length > 0) {
|
|
1091
1094
|
await cleanupRejectedServerUpload(uploadName, storedKeys);
|
|
1092
|
-
throw error;
|
|
1093
1095
|
}
|
|
1094
|
-
|
|
1096
|
+
throw error;
|
|
1095
1097
|
}
|
|
1096
1098
|
|
|
1099
|
+
// Once app-owned completion begins, the app may persist durable references
|
|
1100
|
+
// to these objects. The framework can no longer delete them safely if a
|
|
1101
|
+
// later completion step fails; transaction or compensation belongs to the
|
|
1102
|
+
// app from this point forward.
|
|
1097
1103
|
const result = await definition.onComplete?.({
|
|
1098
1104
|
ctx,
|
|
1099
1105
|
metadata,
|