@lucaapp/service-utils 5.25.0 → 5.27.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.
@@ -28,6 +28,7 @@ export declare class Api {
28
28
  apiTitle: string;
29
29
  apiDescription: string;
30
30
  private prefixPath;
31
+ private rootPrefixPath;
31
32
  debug: boolean;
32
33
  tags: string[];
33
34
  constructor(options?: ApiConstructorOptions);
@@ -10,6 +10,7 @@ const swagger_ui_express_1 = __importDefault(require("swagger-ui-express"));
10
10
  const zod_to_openapi_1 = require("@asteasolutions/zod-to-openapi");
11
11
  const endpoint_1 = require("./endpoint");
12
12
  const openapi_1 = require("./openapi");
13
+ const security_1 = require("./security");
13
14
  const body_parser_1 = __importDefault(require("body-parser"));
14
15
  const rawBodySaver = function (req, res, buf, encoding) {
15
16
  if (buf && buf.length) {
@@ -22,6 +23,7 @@ class Api {
22
23
  this.router = options.router || (0, express_1.Router)();
23
24
  this.registry = options.registry || new zod_to_openapi_1.OpenAPIRegistry();
24
25
  this.prefixPath = options.prefixPath || '';
26
+ this.rootPrefixPath = this.prefixPath;
25
27
  this.openApiVersion = options.openApiVersion || '3.0.0';
26
28
  this.apiVersion = options.apiVersion || '1.0.0';
27
29
  this.apiTitle = options.apiTitle || 'API';
@@ -31,33 +33,35 @@ class Api {
31
33
  this.router.use(body_parser_1.default.json({ verify: rawBodySaver, limit: '2mb' }));
32
34
  }
33
35
  child(options) {
34
- return new Api({
36
+ const child = new Api({
35
37
  prefixPath: this.prefixPath + (options.prefixPath || ''),
36
38
  debug: this.debug,
37
39
  tags: [...this.tags, ...(options.tags || [])],
38
40
  registry: this.registry,
39
41
  router: this.router,
40
42
  });
43
+ child.rootPrefixPath = this.rootPrefixPath;
44
+ return child;
41
45
  }
42
46
  get(path, summary, options, handler) {
43
47
  (0, endpoint_1.mountEndpoint)(this.router, 'get', path, options, handler, this.debug);
44
- (0, openapi_1.registerEndpoint)(this.registry, 'get', this.prefixPath, path, summary, options, this.tags, this.debug);
48
+ (0, openapi_1.registerEndpoint)(this.registry, 'get', this.prefixPath, this.rootPrefixPath, path, summary, options, this.tags, this.debug);
45
49
  }
46
50
  post(path, summary, options, handler) {
47
51
  (0, endpoint_1.mountEndpoint)(this.router, 'post', path, options, handler, this.debug);
48
- (0, openapi_1.registerEndpoint)(this.registry, 'post', this.prefixPath, path, summary, options, this.tags, this.debug);
52
+ (0, openapi_1.registerEndpoint)(this.registry, 'post', this.prefixPath, this.rootPrefixPath, path, summary, options, this.tags, this.debug);
49
53
  }
50
54
  patch(path, summary, options, handler) {
51
55
  (0, endpoint_1.mountEndpoint)(this.router, 'patch', path, options, handler, this.debug);
52
- (0, openapi_1.registerEndpoint)(this.registry, 'patch', this.prefixPath, path, summary, options, this.tags, this.debug);
56
+ (0, openapi_1.registerEndpoint)(this.registry, 'patch', this.prefixPath, this.rootPrefixPath, path, summary, options, this.tags, this.debug);
53
57
  }
54
58
  put(path, summary, options, handler) {
55
59
  (0, endpoint_1.mountEndpoint)(this.router, 'put', path, options, handler, this.debug);
56
- (0, openapi_1.registerEndpoint)(this.registry, 'put', this.prefixPath, path, summary, options, this.tags, this.debug);
60
+ (0, openapi_1.registerEndpoint)(this.registry, 'put', this.prefixPath, this.rootPrefixPath, path, summary, options, this.tags, this.debug);
57
61
  }
58
62
  delete(path, summary, options, handler) {
59
63
  (0, endpoint_1.mountEndpoint)(this.router, 'delete', path, options, handler, this.debug);
60
- (0, openapi_1.registerEndpoint)(this.registry, 'delete', this.prefixPath, path, summary, options, this.tags, this.debug);
64
+ (0, openapi_1.registerEndpoint)(this.registry, 'delete', this.prefixPath, this.rootPrefixPath, path, summary, options, this.tags, this.debug);
61
65
  }
62
66
  generateOpenAPISpec() {
63
67
  let generator;
@@ -74,8 +78,11 @@ class Api {
74
78
  title: this.apiTitle,
75
79
  description: this.apiDescription,
76
80
  },
81
+ servers: [{ url: '/' }],
77
82
  });
78
83
  (0, openapi_1.forcePathParamsRequired)(spec);
84
+ (0, openapi_1.hoistCombinatorRequiredFields)(spec);
85
+ spec.components = { ...spec.components, securitySchemes: security_1.SECURITY_SCHEMES };
79
86
  return spec;
80
87
  }
81
88
  mountSwaggerMiddlewares() {
@@ -3,4 +3,6 @@ export * from './api';
3
3
  export * from './endpoint';
4
4
  export * from './middleware';
5
5
  export * from './response';
6
+ export type { SecurityScheme } from './security';
6
7
  export * from './send';
8
+ export { hoistCombinatorRequiredFields } from './openapi';
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.extendZodWithOpenApi = void 0;
17
+ exports.hoistCombinatorRequiredFields = exports.extendZodWithOpenApi = void 0;
18
18
  var zod_to_openapi_1 = require("@asteasolutions/zod-to-openapi");
19
19
  Object.defineProperty(exports, "extendZodWithOpenApi", { enumerable: true, get: function () { return zod_to_openapi_1.extendZodWithOpenApi; } });
20
20
  __exportStar(require("./api"), exports);
@@ -22,3 +22,9 @@ __exportStar(require("./endpoint"), exports);
22
22
  __exportStar(require("./middleware"), exports);
23
23
  __exportStar(require("./response"), exports);
24
24
  __exportStar(require("./send"), exports);
25
+ // Normalizes an OpenAPI spec's anyOf/allOf combinators the same way
26
+ // generateOpenAPISpec() does, so a frozen/historical spec (a POS compat
27
+ // baseline that predates this generator's fix) can be diffed against a
28
+ // current spec on equal terms. See openapi.ts for what "normalize" means.
29
+ var openapi_1 = require("./openapi");
30
+ Object.defineProperty(exports, "hoistCombinatorRequiredFields", { enumerable: true, get: function () { return openapi_1.hoistCombinatorRequiredFields; } });
@@ -7,5 +7,23 @@ export declare const forcePathParamsRequired: (spec: {
7
7
  parameters?: Record<string, unknown>;
8
8
  };
9
9
  }) => void;
10
- export declare const registerEndpoint: (registry: OpenAPIRegistry, method: HttpMethod, prefixPath: string, path: string, summary: string, options: EndpointOptions<any, any, any, any, any, any>, tags: string[], debug: boolean) => void;
10
+ /**
11
+ * zod-to-openapi represents a same-status multi-error response as `anyOf`, and
12
+ * `.extend()` on a registered/ref'd schema as `allOf: [$ref, inline]`. Either
13
+ * shape hides required/property information that used to be flat at the parent
14
+ * level inside the combinator, which oasdiff's breaking-change diff cannot see
15
+ * through — it reports every such change as fields being removed, even when the
16
+ * change was purely additive. This walks the finished spec and restores the
17
+ * safely-hoistable subset of that information to the parent schema (see
18
+ * hoistAnyOf / hoistAllOf for what "safe" means for each combinator — they are
19
+ * NOT the same rule). Ref topology is untouched: refs are only read to compute
20
+ * what's safe to hoist, never rewritten.
21
+ */
22
+ export declare const hoistCombinatorRequiredFields: (spec: {
23
+ paths?: Record<string, unknown>;
24
+ components?: {
25
+ schemas?: Record<string, unknown>;
26
+ };
27
+ }) => void;
28
+ export declare const registerEndpoint: (registry: OpenAPIRegistry, method: HttpMethod, prefixPath: string, rootPrefixPath: string, path: string, summary: string, options: EndpointOptions<any, any, any, any, any, any>, tags: string[], debug: boolean) => void;
11
29
  export {};
@@ -1,15 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.registerEndpoint = exports.forcePathParamsRequired = void 0;
3
+ exports.registerEndpoint = exports.hoistCombinatorRequiredFields = exports.forcePathParamsRequired = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const http_1 = require("./types/http");
6
6
  const mapExpressParamsToOpenAPIParams = (path) => path.replaceAll(/\:([^\:\/]+)/g, '{$1}');
7
+ const deriveOperationId = (registry, method, path) => {
8
+ const operationId = method +
9
+ path
10
+ .replaceAll(':', 'by-')
11
+ .split(/[^A-Za-z0-9]+/)
12
+ .filter(Boolean)
13
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
14
+ .join('');
15
+ const isTaken = registry.definitions.some(definition => definition.type === 'route' &&
16
+ definition.route.operationId === operationId);
17
+ if (isTaken) {
18
+ throw new Error(`Duplicate operationId ${operationId} for ${method.toUpperCase()} ${path}, set options.operationId`);
19
+ }
20
+ return operationId;
21
+ };
7
22
  const buildResponseConfig = (responseOptions) => {
8
23
  const responses = {};
9
24
  responseOptions.forEach(response => {
10
- // raw responses end the request outside the schema layer and carry the
11
- // placeholder status 0, which is not a valid OpenAPI response key
12
- if (response.isRawResponseEnd) {
25
+ // Raw responses that declare no real schema (rawResponseEnd()'s default,
26
+ // schema: z.void()) end the request outside the schema layer and carry
27
+ // the placeholder status 0, which is not a valid OpenAPI response key —
28
+ // skip them; the fallback below documents a generic 200 instead. A route
29
+ // that sets isRawResponseEnd but ALSO declares a real schema (bypassing
30
+ // the response() helper for streaming/binary output while still telling
31
+ // callers what the body looks like) must still be documented normally —
32
+ // skipping unconditionally silently drops real, intentional schema
33
+ // information (LUCA-35888).
34
+ if (response.isRawResponseEnd && response.schema instanceof zod_1.z.ZodVoid) {
13
35
  return;
14
36
  }
15
37
  const isAlreadyPresent = !!responses[String(response.status)];
@@ -81,7 +103,129 @@ const forcePathParamsRequired = (spec) => {
81
103
  });
82
104
  };
83
105
  exports.forcePathParamsRequired = forcePathParamsRequired;
84
- const registerEndpoint = (registry, method, prefixPath, path, summary,
106
+ const resolveSchemaRef = (node, schemas) => {
107
+ if (node && typeof node === 'object' && typeof node.$ref === 'string') {
108
+ return schemas[node.$ref.split('/').pop()];
109
+ }
110
+ return node;
111
+ };
112
+ // Two same-named properties across anyOf branches are only safe to assert at the
113
+ // parent level if they agree on primitive type — an enum/const literal that differs
114
+ // per branch (e.g. a discriminating `error` code) must NOT leak into the hoisted
115
+ // type, or the parent schema would wrongly forbid the other branches' literals.
116
+ const widenAnyOfProperty = (fragments) => {
117
+ if (fragments.some(f => f === undefined || typeof f !== 'object')) {
118
+ return undefined;
119
+ }
120
+ const types = new Set(fragments.map(f => f.type));
121
+ if (types.size !== 1 || fragments[0].type === undefined) {
122
+ return undefined;
123
+ }
124
+ const widened = { type: fragments[0].type };
125
+ if (fragments.some(f => f.nullable)) {
126
+ widened.nullable = true;
127
+ }
128
+ return widened;
129
+ };
130
+ // anyOf is OR — an object only ever matches ONE branch, so a field is safe to
131
+ // assert at the parent only if every branch requires it, and its type is widened
132
+ // to what every branch agrees on (see widenAnyOfProperty). A field required in
133
+ // some but not all branches must stay inside the combinator: hoisting it would
134
+ // assert something false for the branches that lack it, and would wrongly
135
+ // suppress a real breaking-change finding on that branch in the future.
136
+ const hoistAnyOf = (node, schemas) => {
137
+ const branches = node.anyOf.map(b => resolveSchemaRef(b, schemas));
138
+ if (branches.length === 0 ||
139
+ branches.some(b => !b || typeof b !== 'object')) {
140
+ return;
141
+ }
142
+ const requiredSets = branches.map(b => new Set(b.required || []));
143
+ const commonRequiredNames = [...requiredSets[0]].filter(name => requiredSets.every(set => set.has(name)));
144
+ const hoistedProperties = {};
145
+ const hoistedRequired = [];
146
+ for (const name of commonRequiredNames) {
147
+ const fragments = branches.map(b => b.properties?.[name]);
148
+ const widened = widenAnyOfProperty(fragments);
149
+ if (widened) {
150
+ hoistedProperties[name] = widened;
151
+ hoistedRequired.push(name);
152
+ }
153
+ }
154
+ if (hoistedRequired.length === 0) {
155
+ return; // nothing intersects safely — leave the combinator untouched, no-op
156
+ }
157
+ node.type = 'object';
158
+ node.properties = { ...(node.properties || {}), ...hoistedProperties };
159
+ node.required = [...new Set([...(node.required || []), ...hoistedRequired])];
160
+ };
161
+ // allOf is AND — an object must match every branch simultaneously, so unioning
162
+ // every branch's properties/required to the parent is always safe: nothing is
163
+ // asserted that isn't already true of any value satisfying the allOf. No type
164
+ // widening needed (unlike anyOf) since branches aren't alternatives.
165
+ const hoistAllOf = (node, schemas) => {
166
+ const branches = node.allOf.map(b => resolveSchemaRef(b, schemas));
167
+ if (branches.some(b => !b || typeof b !== 'object')) {
168
+ return;
169
+ }
170
+ const unionProperties = {};
171
+ const unionRequired = new Set();
172
+ for (const branch of branches) {
173
+ for (const [key, value] of Object.entries(branch.properties || {})) {
174
+ if (!(key in unionProperties)) {
175
+ unionProperties[key] = value;
176
+ }
177
+ }
178
+ for (const name of branch.required || []) {
179
+ unionRequired.add(name);
180
+ }
181
+ }
182
+ if (Object.keys(unionProperties).length === 0) {
183
+ return; // nothing to hoist — leave the combinator untouched, no-op
184
+ }
185
+ node.type = 'object';
186
+ node.properties = { ...(node.properties || {}), ...unionProperties };
187
+ node.required = [...new Set([...(node.required || []), ...unionRequired])];
188
+ };
189
+ /**
190
+ * zod-to-openapi represents a same-status multi-error response as `anyOf`, and
191
+ * `.extend()` on a registered/ref'd schema as `allOf: [$ref, inline]`. Either
192
+ * shape hides required/property information that used to be flat at the parent
193
+ * level inside the combinator, which oasdiff's breaking-change diff cannot see
194
+ * through — it reports every such change as fields being removed, even when the
195
+ * change was purely additive. This walks the finished spec and restores the
196
+ * safely-hoistable subset of that information to the parent schema (see
197
+ * hoistAnyOf / hoistAllOf for what "safe" means for each combinator — they are
198
+ * NOT the same rule). Ref topology is untouched: refs are only read to compute
199
+ * what's safe to hoist, never rewritten.
200
+ */
201
+ const hoistCombinatorRequiredFields = (spec) => {
202
+ const schemas = (spec.components?.schemas || {});
203
+ const visit = (node) => {
204
+ if (!node || typeof node !== 'object') {
205
+ return;
206
+ }
207
+ if (Array.isArray(node)) {
208
+ node.forEach(visit);
209
+ return;
210
+ }
211
+ const obj = node;
212
+ if (Array.isArray(obj.anyOf)) {
213
+ obj.anyOf.forEach(visit);
214
+ hoistAnyOf(obj, schemas);
215
+ }
216
+ else if (Array.isArray(obj.allOf)) {
217
+ obj.allOf.forEach(visit);
218
+ hoistAllOf(obj, schemas);
219
+ }
220
+ else {
221
+ Object.values(obj).forEach(visit);
222
+ }
223
+ };
224
+ visit(spec.paths || {});
225
+ visit(schemas);
226
+ };
227
+ exports.hoistCombinatorRequiredFields = hoistCombinatorRequiredFields;
228
+ const registerEndpoint = (registry, method, prefixPath, rootPrefixPath, path, summary,
85
229
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
230
  options, tags, debug) => {
87
231
  let bodySchema = options.schemas?.body || undefined;
@@ -89,6 +233,7 @@ options, tags, debug) => {
89
233
  let querySchema = options.schemas?.query || undefined;
90
234
  let headersSchema = options.schemas?.headers || undefined;
91
235
  let responseSchemas = options.responses;
236
+ const securitySchemes = new Set();
92
237
  if (options.errors) {
93
238
  for (const errorType of Object.keys(options.errors)) {
94
239
  const statusCode = options.errors[errorType];
@@ -149,6 +294,9 @@ options, tags, debug) => {
149
294
  });
150
295
  }
151
296
  }
297
+ for (const scheme of middleware.options.security || []) {
298
+ securitySchemes.add(scheme);
299
+ }
152
300
  }
153
301
  // Add ZodError response
154
302
  responseSchemas.push({
@@ -205,7 +353,11 @@ options, tags, debug) => {
205
353
  headers: headersSchema ? [headersSchema] : [],
206
354
  },
207
355
  responses: responseMap,
208
- ...(options.operationId && { operationId: options.operationId }),
356
+ operationId: options.operationId ||
357
+ deriveOperationId(registry, method, prefixPath.slice(rootPrefixPath.length) + path),
358
+ ...(securitySchemes.size > 0 && {
359
+ security: [...securitySchemes].map(scheme => ({ [scheme]: [] })),
360
+ }),
209
361
  });
210
362
  };
211
363
  exports.registerEndpoint = registerEndpoint;
@@ -0,0 +1,3 @@
1
+ import type { SecuritySchemeObject } from 'openapi3-ts/oas30';
2
+ export type SecurityScheme = 'jwt_operator' | 'jwt_support' | 'jwt_operator_device' | 'jwt_consumer' | 'api_key';
3
+ export declare const SECURITY_SCHEMES: Record<SecurityScheme, SecuritySchemeObject>;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SECURITY_SCHEMES = void 0;
4
+ const X_AUTH_HEADER = {
5
+ type: 'apiKey',
6
+ in: 'header',
7
+ name: 'X-Auth',
8
+ };
9
+ exports.SECURITY_SCHEMES = {
10
+ jwt_operator: X_AUTH_HEADER,
11
+ jwt_support: X_AUTH_HEADER,
12
+ jwt_operator_device: X_AUTH_HEADER,
13
+ jwt_consumer: X_AUTH_HEADER,
14
+ api_key: { type: 'apiKey', in: 'header', name: 'X-Api-Key' },
15
+ };
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { SecurityScheme } from '../security';
2
3
  import { ArrayToUnion, ExtractZodOutput, ZodSchemaOuput } from './utils';
3
4
  export type EndpointResponseSchema = {
4
5
  status: number;
@@ -45,6 +46,7 @@ export type MiddlewareOptions<TResponseSchemas extends ReadonlyArray<EndpointRes
45
46
  returnRawBody?: boolean;
46
47
  };
47
48
  errors?: Record<string, number>;
49
+ security?: ReadonlyArray<SecurityScheme>;
48
50
  };
49
51
  export type Middleware<TResponseSchemas extends ReadonlyArray<EndpointResponseSchema>, TRequestBodySchema, TRequestParamsSchema, TRequestQuerySchema, TRequestHeadersSchema, TContextSchema> = {
50
52
  options: MiddlewareOptions<TResponseSchemas, TRequestBodySchema, TRequestParamsSchema, TRequestQuerySchema, TRequestHeadersSchema, TContextSchema>;
@@ -43,6 +43,18 @@ export type Payment = PaymentBase & {
43
43
  };
44
44
  export declare const PAYMENT_EVENT_VERSION = 2;
45
45
  export type PaymentEventVersion = typeof PAYMENT_EVENT_VERSION;
46
+ /**
47
+ * Stable acquirer snapshot fields for CARD fiscalization (LUCA-35099).
48
+ * Populated by backend-pay when available on V3Payment at produce time.
49
+ * Fees are intentionally omitted — pay remains source of truth at report/close.
50
+ */
51
+ export type PaymentEventCardAcquirerFields = {
52
+ cardLast4?: string | null;
53
+ authCode?: string | null;
54
+ cardScheme?: string | null;
55
+ cardBrandLabel?: string | null;
56
+ terminalId?: string | null;
57
+ };
46
58
  export type PaymentEvent = {
47
59
  version: PaymentEventVersion;
48
60
  uuid: string;
@@ -60,7 +72,7 @@ export type PaymentEvent = {
60
72
  orderId?: string;
61
73
  statusCompleteAt?: Date | null;
62
74
  merchantDetails?: PaymentMerchantDetails;
63
- };
75
+ } & PaymentEventCardAcquirerFields;
64
76
  export declare const consumerPayAmount: (event: PaymentEvent) => number;
65
77
  export declare const operatorReceivableAmount: (event: PaymentEvent) => number;
66
78
  export type PaymentMerchantDetails = {
@@ -18,9 +18,44 @@ type LanguageOptions = typeof AVAILABLE_LANGUAGES[number];
18
18
  * ```
19
19
  */
20
20
  export declare const isInternalLanguage: (lang: string) => lang is InternalLanguages;
21
+ /**
22
+ * Canonicalises a language code for storage: `DE-de` -> `de-DE`.
23
+ * Returns null when the value is absent or not a plausible language code.
24
+ *
25
+ * The region is dropped when keeping it would not fit the `STRING(5)` language
26
+ * columns, so `fil-PH` stores as `fil` rather than overflowing or being thrown
27
+ * away — the language is the part we care about.
28
+ *
29
+ * Unlike {@link toInternalLanguage} this does NOT narrow to the languages our
30
+ * templates support — storage keeps what the user actually asked for, and
31
+ * narrowing happens on retrieval.
32
+ */
33
+ export declare const normalizeStoredLanguage: (lang?: string | null) => string | null;
34
+ /**
35
+ * Reads the caller's preferred language without narrowing it to the languages
36
+ * our templates support. Use for values that get persisted; use
37
+ * {@link getPreferredLanguageFromRequestHeaders} when the result immediately
38
+ * selects a template.
39
+ *
40
+ * Returns null when no usable `Accept-Language` was sent, so callers can leave
41
+ * the column untouched rather than writing a guessed default.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * getStoredLanguageFromRequestHeaders({ 'accept-language': 'fr-FR,fr;q=0.9' }) // 'fr-FR'
46
+ * getStoredLanguageFromRequestHeaders({}) // null
47
+ * ```
48
+ */
49
+ export declare const getStoredLanguageFromRequestHeaders: (headers?: Record<string, string>) => string | null;
50
+ export declare const getStoredLanguageFromRequest: (request: Request) => string | null;
21
51
  /**
22
52
  * Safely converts a language code to an InternalLanguage.
23
- * If the language is not supported, falls back to 'en'.
53
+ * The region subtag is dropped before matching, so `de-DE` resolves to `de`
54
+ * rather than falling back. If the language is not supported, falls back to 'en'.
55
+ *
56
+ * This is the retrieval-side counterpart to {@link normalizeStoredLanguage}:
57
+ * columns may hold any language the user asked for, and every read that feeds
58
+ * a template goes through here.
24
59
  *
25
60
  * @param lang - Language code to convert
26
61
  * @returns The language if supported, otherwise 'en'
@@ -28,11 +63,12 @@ export declare const isInternalLanguage: (lang: string) => lang is InternalLangu
28
63
  * @example
29
64
  * ```typescript
30
65
  * toInternalLanguage('de') // returns 'de'
66
+ * toInternalLanguage('de-DE') // returns 'de'
31
67
  * toInternalLanguage('en') // returns 'en'
32
68
  * toInternalLanguage('fr') // returns 'en' (fallback)
33
69
  * ```
34
70
  */
35
- export declare const toInternalLanguage: (lang: string) => InternalLanguages;
71
+ export declare const toInternalLanguage: (lang?: string | null) => InternalLanguages;
36
72
  export declare const parseAcceptLanguageHeader: (header?: string) => LanguageOptions;
37
73
  /**
38
74
  * @deprecated Use getPreferredLanguageFromRequestHeaders instead.
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getPreferredLanguageFromRequestHeaders = exports.getPreferredLanguageFromRequest = exports.parseAcceptLanguageHeader = exports.toInternalLanguage = exports.isInternalLanguage = exports.AVAILABLE_LANGUAGES = exports.DEFAULT_LANGUAGE_WHEN_HEADER = exports.DEFAULT_LANGUAGE_WHEN_NO_HEADER = void 0;
6
+ exports.getPreferredLanguageFromRequestHeaders = exports.getPreferredLanguageFromRequest = exports.parseAcceptLanguageHeader = exports.toInternalLanguage = exports.getStoredLanguageFromRequest = exports.getStoredLanguageFromRequestHeaders = exports.normalizeStoredLanguage = exports.isInternalLanguage = exports.AVAILABLE_LANGUAGES = exports.DEFAULT_LANGUAGE_WHEN_HEADER = exports.DEFAULT_LANGUAGE_WHEN_NO_HEADER = void 0;
7
7
  const negotiator_1 = __importDefault(require("negotiator"));
8
8
  exports.DEFAULT_LANGUAGE_WHEN_NO_HEADER = 'de';
9
9
  exports.DEFAULT_LANGUAGE_WHEN_HEADER = 'en';
@@ -25,9 +25,80 @@ const isInternalLanguage = (lang) => {
25
25
  return exports.AVAILABLE_LANGUAGES.includes(lang);
26
26
  };
27
27
  exports.isInternalLanguage = isInternalLanguage;
28
+ /**
29
+ * Shape of a language code we are willing to persist: a 2-3 letter primary
30
+ * subtag, optionally followed by a 2-letter region. Deliberately narrower than
31
+ * full BCP 47 so an attacker-controlled `Accept-Language` cannot write junk;
32
+ * script subtags (`zh-Hant`) are rejected rather than truncated.
33
+ */
34
+ const STORED_LANGUAGE_PATTERN = /^[a-z]{2,3}(-[a-z]{2})?$/i;
35
+ /**
36
+ * Longest value the `language` columns accept — they are `STRING(5)`, which
37
+ * fits `pt-BR` but not a 3-letter primary subtag with a region (`fil-PH`).
38
+ */
39
+ const STORED_LANGUAGE_MAX_LENGTH = 5;
40
+ /**
41
+ * Canonicalises a language code for storage: `DE-de` -> `de-DE`.
42
+ * Returns null when the value is absent or not a plausible language code.
43
+ *
44
+ * The region is dropped when keeping it would not fit the `STRING(5)` language
45
+ * columns, so `fil-PH` stores as `fil` rather than overflowing or being thrown
46
+ * away — the language is the part we care about.
47
+ *
48
+ * Unlike {@link toInternalLanguage} this does NOT narrow to the languages our
49
+ * templates support — storage keeps what the user actually asked for, and
50
+ * narrowing happens on retrieval.
51
+ */
52
+ const normalizeStoredLanguage = (lang) => {
53
+ const trimmed = lang?.trim();
54
+ if (!trimmed || !STORED_LANGUAGE_PATTERN.test(trimmed)) {
55
+ return null;
56
+ }
57
+ const [primary, region] = trimmed.split('-');
58
+ const canonical = region
59
+ ? `${primary.toLowerCase()}-${region.toUpperCase()}`
60
+ : primary.toLowerCase();
61
+ return canonical.length > STORED_LANGUAGE_MAX_LENGTH
62
+ ? primary.toLowerCase()
63
+ : canonical;
64
+ };
65
+ exports.normalizeStoredLanguage = normalizeStoredLanguage;
66
+ /**
67
+ * Reads the caller's preferred language without narrowing it to the languages
68
+ * our templates support. Use for values that get persisted; use
69
+ * {@link getPreferredLanguageFromRequestHeaders} when the result immediately
70
+ * selects a template.
71
+ *
72
+ * Returns null when no usable `Accept-Language` was sent, so callers can leave
73
+ * the column untouched rather than writing a guessed default.
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * getStoredLanguageFromRequestHeaders({ 'accept-language': 'fr-FR,fr;q=0.9' }) // 'fr-FR'
78
+ * getStoredLanguageFromRequestHeaders({}) // null
79
+ * ```
80
+ */
81
+ const getStoredLanguageFromRequestHeaders = (headers) => {
82
+ const acceptLanguageHeader = headers?.['accept-language'];
83
+ if (!acceptLanguageHeader) {
84
+ return null;
85
+ }
86
+ // Take the highest-priority entry we can actually store, rather than giving
87
+ // up when the first one happens to be unstorable (e.g. `zh-Hant-TW, zh-TW`).
88
+ const negotiator = new negotiator_1.default({ headers });
89
+ return (negotiator.languages().map(exports.normalizeStoredLanguage).find(Boolean) ?? null);
90
+ };
91
+ exports.getStoredLanguageFromRequestHeaders = getStoredLanguageFromRequestHeaders;
92
+ const getStoredLanguageFromRequest = (request) => (0, exports.getStoredLanguageFromRequestHeaders)(request.headers);
93
+ exports.getStoredLanguageFromRequest = getStoredLanguageFromRequest;
28
94
  /**
29
95
  * Safely converts a language code to an InternalLanguage.
30
- * If the language is not supported, falls back to 'en'.
96
+ * The region subtag is dropped before matching, so `de-DE` resolves to `de`
97
+ * rather than falling back. If the language is not supported, falls back to 'en'.
98
+ *
99
+ * This is the retrieval-side counterpart to {@link normalizeStoredLanguage}:
100
+ * columns may hold any language the user asked for, and every read that feeds
101
+ * a template goes through here.
31
102
  *
32
103
  * @param lang - Language code to convert
33
104
  * @returns The language if supported, otherwise 'en'
@@ -35,12 +106,14 @@ exports.isInternalLanguage = isInternalLanguage;
35
106
  * @example
36
107
  * ```typescript
37
108
  * toInternalLanguage('de') // returns 'de'
109
+ * toInternalLanguage('de-DE') // returns 'de'
38
110
  * toInternalLanguage('en') // returns 'en'
39
111
  * toInternalLanguage('fr') // returns 'en' (fallback)
40
112
  * ```
41
113
  */
42
114
  const toInternalLanguage = (lang) => {
43
- return (0, exports.isInternalLanguage)(lang) ? lang : 'en';
115
+ const primary = lang?.trim().split('-')[0]?.toLowerCase();
116
+ return primary && (0, exports.isInternalLanguage)(primary) ? primary : 'en';
44
117
  };
45
118
  exports.toInternalLanguage = toInternalLanguage;
46
119
  const parseAcceptLanguageHeader = (header) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.25.0",
3
+ "version": "5.27.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [
@@ -88,7 +88,7 @@
88
88
  "tar": "7.5.21",
89
89
  "body-parser": "1.20.6",
90
90
  "lodash": "^4.18.1",
91
- "qs": "6.15.2",
91
+ "qs": "6.16.0",
92
92
  "dottie": "2.0.7",
93
93
  "fast-xml-parser": "^5.7.1",
94
94
  "yaml": "^2.8.3",