@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.
@@ -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
- validateStorageKey(key);
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
- validateStorageKey(key);
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
- validateStorageKey(key);
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
- validateStorageKey(key);
447
+ assertValidStorageKey(key);
366
448
  return objects.delete(key);
367
449
  },
368
450
  async exists(key) {
369
- validateStorageKey(key);
451
+ assertValidStorageKey(key);
370
452
  return objects.has(key);
371
453
  },
372
454
  async publicUrl(key) {
373
- validateStorageKey(key);
455
+ assertValidStorageKey(key);
374
456
  const entry = objects.get(key);
375
- if (!entry || entry.visibility !== "public" || !options.publicBaseUrl) {
457
+ if (entry?.visibility !== "public" || !options.publicBaseUrl) {
376
458
  return null;
377
459
  }
378
460
 
379
- return joinPublicUrl(options.publicBaseUrl, key);
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`: `"global"` stays global,
62
- * `"actor"` scopes by `ctx.actor?.id`, `"tenant"` scopes by `ctx.tenant?.id`,
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 ?? "global";
101
+ const mode = meta.scope ?? (ctx.tenant?.id ? "actor-tenant" : "actor");
101
102
 
102
103
  switch (mode) {
103
104
  case "global":
@@ -1037,63 +1037,69 @@ export function createUploadRouter<Ctx>(
1037
1037
 
1038
1038
  const completed: CompletedUploadFile[] = [];
1039
1039
  const storedKeys: string[] = [];
1040
- for (const [index, file] of webFiles.entries()) {
1041
- const intent = intents[index];
1042
- if (!intent) continue;
1043
- const verified = await verifyBlobUploadFile(definition, intent, file);
1044
- const verifiedIntent = {
1045
- ...intent,
1046
- ...(verified.checksum ? { checksum: verified.checksum } : {}),
1047
- };
1048
- const uploadId = id();
1049
- await assertAuthorized(definition, {
1050
- ctx,
1051
- metadata,
1052
- file: verifiedIntent,
1053
- uploadId,
1054
- });
1055
- const key = await definition.key({
1056
- ctx,
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
- const object = await options.storage.put(key, file, {
1069
- contentType: verifiedIntent.contentType,
1070
- ...(definition.file.cacheControl !== undefined
1071
- ? { cacheControl: definition.file.cacheControl }
1072
- : {}),
1073
- metadata: storageMetadata,
1074
- visibility: definition.file.visibility ?? "private",
1075
- });
1076
- storedKeys.push(key);
1077
- const completedFile = {
1078
- ...verifiedIntent,
1079
- uploadId,
1080
- key,
1081
- object,
1082
- };
1083
- try {
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
- } catch (error) {
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
- completed.push(completedFile);
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,