@12-apps/storage 2.4.0 → 2.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/storage",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "Image uploads that land at YOUR OWN ORIGIN, never at the bucket (12-20). Isomorphic core (format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, wire schemas), host-mounted backend surface (./server: createApiStorage, the local-disk driver, the image-pipeline port, the orphan reclaim), Hono adapter (./hono), the S3-compatible driver behind its own subpath (./s3) and the bound picker + upload hook (./react). Standardized adoption contract in ADOPTING.md.",
6
6
  "exports": {
@@ -43,13 +43,14 @@
43
43
  }
44
44
  },
45
45
  "dependencies": {
46
- "@12-apps/ui": "^6.8.0",
46
+ "@12-apps/ui": "^6.9.0",
47
47
  "zod": "^4.3.5"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@12-apps/eslint-config": "^1.21.1",
51
+ "@12-apps/i18n": "^1.1.0",
51
52
  "@12-apps/typescript-config": "^1.20.1",
52
- "@12-apps/wiring": "^1.10.0",
53
+ "@12-apps/wiring": "^1.11.0",
53
54
  "@aws-sdk/client-s3": "^3.703.0",
54
55
  "@testing-library/react": "^16.1.0",
55
56
  "@types/react": "19.2.2",
package/src/en-US.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { StorageMessageContext, StorageMessages } from './problems';
2
+
3
+ /**
4
+ * The en-US pack — a NAMED factory a host passes by hand
5
+ * (`messages: EN_US_STORAGE_MESSAGES`), never a default. The filename is what
6
+ * exempts this file from the copy-portability gate, as `pt-BR.ts` beside it is
7
+ * exempt.
8
+ *
9
+ * `limit` is interpolated rather than hard-coded, for the reason the pt-BR pack
10
+ * gives: a host that raises `maxBytes` must not end up with a message naming
11
+ * the old number, which is the single commonest way this kind of copy goes
12
+ * stale. A translation that inlined the number would reintroduce exactly that.
13
+ */
14
+ export function EN_US_STORAGE_MESSAGES(context: StorageMessageContext): StorageMessages {
15
+ return {
16
+ invalid_base64: 'That image is not correctly base64-encoded.',
17
+ empty_file: 'That image is empty.',
18
+ file_too_large: `That image is larger than the ${context.limit} limit.`,
19
+ content_mismatch:
20
+ 'The image contents do not match the format you declared. Send a PNG, JPEG, WebP or GIF.',
21
+ unsupported_content_type: 'Unsupported format. Send a PNG, JPG, WebP or GIF.',
22
+ image_unreadable: 'That image could not be read — the file appears to be corrupted.',
23
+ image_dimensions_too_large: 'That image is too large in dimensions to process.',
24
+ storage_not_configured: 'Image storage has not been set up for this store yet.',
25
+ storage_unavailable: 'That image could not be saved right now. Try again in a moment.',
26
+ };
27
+ }
28
+
29
+ /** The 401 the mounted router answers when the host resolves no actor. */
30
+ export const EN_US_STORAGE_UNAUTHENTICATED = 'Not authenticated.';
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export {
76
76
  type StorageProblem,
77
77
  } from './problems';
78
78
  export { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
79
+ export { EN_US_STORAGE_MESSAGES, EN_US_STORAGE_UNAUTHENTICATED } from './en-US';
80
+ export { STORAGE_MESSAGES, STORAGE_UNAUTHENTICATED } from './locales';
79
81
 
80
82
  export {
81
83
  imageSources,
package/src/locales.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { EN_US_STORAGE_MESSAGES, EN_US_STORAGE_UNAUTHENTICATED } from './en-US';
2
+ import type { StorageMessageContext, StorageMessages } from './problems';
3
+ import { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
4
+
5
+ /**
6
+ * Both languages, keyed by tag — what a host hands to `@12-apps/i18n` when the
7
+ * reader's language is a property of the request rather than of the deployment.
8
+ *
9
+ * The pack holds FACTORIES rather than tables, because that is what this
10
+ * package's copy already is: `limit` is interpolated so a host that raises
11
+ * `maxBytes` cannot end up with a message naming the old number. `localeCopy`
12
+ * therefore hands back a factory, and the host calls it with its context:
13
+ *
14
+ * ```ts
15
+ * const messages = localeCopy(STORAGE_MESSAGES)({ locale })({ limit });
16
+ * ```
17
+ *
18
+ * `LocalePack` is mirrored here rather than imported so the package stays
19
+ * liftable into a repo that has never heard of `@12-apps/i18n`.
20
+ */
21
+ type LocalePack<T> = { readonly 'pt-BR': T; readonly 'en-US': T };
22
+
23
+ export const STORAGE_MESSAGES = {
24
+ 'pt-BR': PT_BR_STORAGE_MESSAGES,
25
+ 'en-US': EN_US_STORAGE_MESSAGES,
26
+ } as const satisfies LocalePack<(context: StorageMessageContext) => StorageMessages>;
27
+
28
+ export const STORAGE_UNAUTHENTICATED = {
29
+ 'pt-BR': PT_BR_STORAGE_UNAUTHENTICATED,
30
+ 'en-US': EN_US_STORAGE_UNAUTHENTICATED,
31
+ } as const satisfies LocalePack<string>;
package/src/problems.ts CHANGED
@@ -54,6 +54,15 @@ export type StorageMessages = Readonly<Record<StorageProblem, string>>;
54
54
  export interface StorageMessageContext {
55
55
  /** `8 MB`, already formatted. */
56
56
  limit: string;
57
+ /**
58
+ * The language to answer this caller in, as a BCP-47 tag.
59
+ *
60
+ * This field is why `messages` was already the right SHAPE: a factory called
61
+ * per refusal can answer per reader, where a value handed over at the mount
62
+ * cannot. Absent means the host's adapter populated nothing — a single
63
+ * -audience host — and the factory should then answer as it always did.
64
+ */
65
+ locale?: string;
57
66
  }
58
67
 
59
68
  /**
@@ -0,0 +1,49 @@
1
+ import type { WebStorageMessageContext, WebStorageMessages } from './failures';
2
+
3
+ /**
4
+ * The en-US pack — a NAMED factory a host passes by hand, never a default.
5
+ * `limit` is interpolated for the same reason the server pack does it: a host
6
+ * that raises the ceiling must not end up with copy naming the old number.
7
+ */
8
+ export function EN_US_WEB_STORAGE_MESSAGES(
9
+ context: WebStorageMessageContext,
10
+ ): WebStorageMessages {
11
+ return {
12
+ forbidden: 'Your account may not upload images for this store.',
13
+ unsupported_content_type: 'Unsupported format. Send a PNG, JPG, WebP or GIF.',
14
+ file_too_large: `That image is too large. The limit is ${context.limit}.`,
15
+ invalid_key: 'That image could not be found.',
16
+ not_found: 'That image is no longer available.',
17
+ session_expired: 'Your session has expired. Sign in again and repeat the upload.',
18
+ transport: 'Could not reach the server. Check your connection and try again.',
19
+ empty_file: 'Empty file (0 bytes). Pick the image again.',
20
+ file_too_large_upfront: ({ size, limit }) =>
21
+ `That image is too large: ${size}. The limit is ${limit} — shrink it and try again.`,
22
+ unknown_content_type: 'Could not identify the file type. Send a PNG, JPG or WebP.',
23
+ unsupported_content_type_upfront: ({ contentType }) =>
24
+ `Unsupported format (${contentType}). Send a PNG, JPG, WebP or GIF.`,
25
+ missing_key: 'The server accepted the image but returned no key. Try again.',
26
+ upload_failed_http: ({ status, detail }) =>
27
+ `Could not upload that image (HTTP ${status}${detail}). Try again in a moment.`,
28
+ upload_failed: 'Uploading that image failed. Try again in a moment.',
29
+
30
+ pageTitle: 'Store images',
31
+ pageIntro: `Send a PNG, JPG, WebP or GIF of up to ${context.limit}.`,
32
+ fieldLabel: 'Product photo',
33
+ fieldHelper: 'The image is resized in the browser before it is sent.',
34
+ fieldRemove: 'Remove image',
35
+ field: {
36
+ // `buttonLabel` is never read here — `ImageField` overrides it with
37
+ // `fieldLabel`. It is still answered, because the type is the whole of
38
+ // what `UploadButton` may render and a partial answer is not a pack.
39
+ buttonLabel: 'Product photo',
40
+ dropzoneHint: 'Drop the image here, or click to choose one',
41
+ dropzoneRole: (label) => `Image upload area. ${label}`,
42
+ uploading: 'Uploading…',
43
+ dropReady: 'Image ready to drop',
44
+ percentUploaded: (percent) => `${percent}% uploaded`,
45
+ uploadInProgress: (percent) => `Upload in progress: ${percent}%`,
46
+ errorAnnouncement: (message) => `Error: ${message}`,
47
+ },
48
+ };
49
+ }
@@ -42,3 +42,5 @@ export {
42
42
  type WebStorageMessages,
43
43
  } from './failures';
44
44
  export { PT_BR_WEB_STORAGE_MESSAGES } from './pt-BR';
45
+ export { EN_US_WEB_STORAGE_MESSAGES } from './en-US';
46
+ export { WEB_STORAGE_MESSAGES } from './locales';
@@ -0,0 +1,18 @@
1
+ import { EN_US_WEB_STORAGE_MESSAGES } from './en-US';
2
+ import type { WebStorageMessageContext, WebStorageMessages } from './failures';
3
+ import { PT_BR_WEB_STORAGE_MESSAGES } from './pt-BR';
4
+
5
+ /**
6
+ * Both languages of the browser half, keyed by tag. Factories, for the reason
7
+ * the server pack's docstring gives: the ceiling is interpolated so copy cannot
8
+ * name a limit the host has since changed.
9
+ *
10
+ * `LocalePack` is mirrored here rather than imported so the package stays
11
+ * liftable into a repo that has never heard of `@12-apps/i18n`.
12
+ */
13
+ type LocalePack<T> = { readonly 'pt-BR': T; readonly 'en-US': T };
14
+
15
+ export const WEB_STORAGE_MESSAGES = {
16
+ 'pt-BR': PT_BR_WEB_STORAGE_MESSAGES,
17
+ 'en-US': EN_US_WEB_STORAGE_MESSAGES,
18
+ } as const satisfies LocalePack<(context: WebStorageMessageContext) => WebStorageMessages>;
@@ -172,11 +172,14 @@ const SILENT: StorageLogger = { error: () => undefined };
172
172
  export function createApiStorage(config: ApiStorageConfig): ApiStorage {
173
173
  const specs = config.renditions ?? CATALOG_RENDITIONS;
174
174
  const keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX;
175
- const messages: StorageMessages = config.messages({ limit: megabytes(config.maxBytes) });
175
+ const limit = megabytes(config.maxBytes);
176
+ // A host write holds no request and no reader, so it takes the words resolved
177
+ // in the deployment's own language. The ROUTES resolve per caller instead —
178
+ // see `storageRoutes` below, which is handed the factory rather than this.
176
179
  const writeDeps: StoreImageDeps = {
177
180
  driver: config.driver,
178
181
  pipeline: config.imagePipeline,
179
- messages,
182
+ messages: config.messages({ limit }),
180
183
  maxBytes: config.maxBytes,
181
184
  specs,
182
185
  keyPrefix,
@@ -192,7 +195,7 @@ export function createApiStorage(config: ApiStorageConfig): ApiStorage {
192
195
  const resolve = (key: string): string => config.driver.publicUrl(key);
193
196
 
194
197
  return {
195
- routes: storageRoutes(writeDeps),
198
+ routes: storageRoutes({ ...writeDeps, messages: config.messages, limit }),
196
199
  limits: {
197
200
  maxBytes: config.maxBytes,
198
201
  maxBytesLabel: megabytes(config.maxBytes),
@@ -1,7 +1,7 @@
1
1
  import { CONTENT_TYPE_BY_EXTENSION, acceptedContentTypeOf } from '../content-types';
2
2
  import { parseObjectMemberKey } from '../keys';
3
3
  import { STORAGE_PATHS } from '../paths';
4
- import { isStorageProblem, type StorageMessages } from '../problems';
4
+ import { isStorageProblem, type StorageMessageContext, type StorageMessages } from '../problems';
5
5
  import type { StorageDriver } from './driver';
6
6
  import { readBodyCapped } from './read-body';
7
7
  import { storeImage, type StoreImageDeps } from './store-image';
@@ -54,6 +54,12 @@ export interface StorageRouteContext {
54
54
  actor: StorageActor | null;
55
55
  params: Record<string, string | undefined>;
56
56
  request: Request;
57
+ /**
58
+ * The language to answer in — the same field `@12-apps/wiring`'s
59
+ * `WireRequest` carries, mirrored here because this surface builds its own
60
+ * context. Populated by the host's adapter; absent is not an error.
61
+ */
62
+ locale?: string;
57
63
  }
58
64
 
59
65
  /** A JSON answer, an object's bytes, or a redirect to where the object lives. */
@@ -76,9 +82,15 @@ export interface StorageRoute {
76
82
  handle(context: StorageRouteContext): Promise<StorageRouteResponse>;
77
83
  }
78
84
 
79
- interface RoutesDeps extends StoreImageDeps {
85
+ interface RoutesDeps extends Omit<StoreImageDeps, "messages"> {
80
86
  driver: StorageDriver;
81
- messages: StorageMessages;
87
+ /**
88
+ * The factory, unresolved, plus the limit it needs. Resolving into a value
89
+ * here is what would re-freeze the language into the mount — this object is
90
+ * built once, per process.
91
+ */
92
+ messages: (context: StorageMessageContext) => StorageMessages;
93
+ limit: string;
82
94
  }
83
95
 
84
96
  /** Keys are content-addressed (a uuid), so an object may be cached forever. */
@@ -96,9 +108,12 @@ async function handleUpload(
96
108
  // override it (`WebStorageMessages.forbidden`).
97
109
  return { status: 403, body: { error: 'forbidden' } };
98
110
  }
111
+ // Worded HERE, per request, from the tag the adapter put on the context —
112
+ // never at the mount, which ran once at boot.
113
+ const messages = deps.messages({ limit: deps.limit, locale: context.locale });
99
114
  const contentType = acceptedContentTypeOf(context.request.headers.get('content-type'));
100
115
  if (!contentType) {
101
- return { status: 400, body: { error: deps.messages.unsupported_content_type } };
116
+ return { status: 400, body: { error: messages.unsupported_content_type } };
102
117
  }
103
118
  // Capped WHILE STREAMING, so an oversize body is refused without ever being
104
119
  // held whole in memory.
@@ -111,12 +126,12 @@ async function handleUpload(
111
126
  // "o limite é 8 MB": a ceiling nothing enforced, in the one package whose
112
127
  // headline claim is that there is exactly ONE number. The sentence is built
113
128
  // from this mount's `maxBytes` and carries this mount's `messages` overrides.
114
- return { status: 413, body: { error: deps.messages.file_too_large } };
129
+ return { status: 413, body: { error: messages.file_too_large } };
115
130
  }
116
131
  try {
117
132
  // No byte check here: `storeImage` owns it, so the endpoint and a host write
118
133
  // holding raw bytes cannot disagree about whether it ran (see store-image.ts).
119
- const imageKey = await storeImage(deps, { bytes, contentType, scope: actor.scope });
134
+ const imageKey = await storeImage({ ...deps, messages }, { bytes, contentType, scope: actor.scope });
120
135
  return { status: 200, body: { data: { imageKey } } };
121
136
  } catch (error) {
122
137
  // The write path speaks in StorageProblemError precisely so both entrances