@open-mercato/shared 0.6.6-develop.6543.1.49fadb4c31 → 0.6.6

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.
@@ -11,14 +11,9 @@ const RAW_COUNTRIES = registry.filter(isIsoAlpha2).map((entry) => ({
11
11
  code: entry.Subtag,
12
12
  name: entry.Description.join(", ")
13
13
  }));
14
- const SUPPLEMENTAL_COUNTRIES = [
15
- { code: "XK", name: "Kosovo" }
16
- ];
17
- const RAW_COUNTRY_CODES = new Set(RAW_COUNTRIES.map((entry) => entry.code));
18
- const ISO_COUNTRIES = [
19
- ...RAW_COUNTRIES,
20
- ...SUPPLEMENTAL_COUNTRIES.filter((entry) => !RAW_COUNTRY_CODES.has(entry.code))
21
- ].sort((a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }));
14
+ const ISO_COUNTRIES = [...RAW_COUNTRIES].sort(
15
+ (a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" })
16
+ );
22
17
  const COUNTRY_PRIORITY = ["PL", "DE", "ES", "FR", "IT", "US", "GB", "CA"];
23
18
  const displayNameCache = /* @__PURE__ */ new Map();
24
19
  function getDisplayNames(locale) {
@@ -42,8 +37,7 @@ function resolveCountryName(code, options = {}) {
42
37
  if (!displayNames) return fallback;
43
38
  try {
44
39
  const label = displayNames.of(normalized);
45
- if (typeof label !== "string" || label === normalized) return fallback;
46
- return label;
40
+ return typeof label === "string" ? label : fallback;
47
41
  } catch {
48
42
  return fallback;
49
43
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/location/countries.ts"],
4
- "sourcesContent": ["import registry from 'language-subtag-registry/data/json/registry.json'\n\ntype RegistryEntry = {\n Type: string\n Subtag?: string\n Description?: string[]\n Deprecated?: string\n}\n\nexport type IsoCountry = {\n code: string\n name: string\n}\n\nfunction isIsoAlpha2(entry: RegistryEntry): entry is RegistryEntry & { Subtag: string; Description: string[] } {\n if (entry.Type !== 'region') return false\n if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false\n if (entry.Deprecated) return false\n if (!entry.Description || !entry.Description.length) return false\n if (entry.Description[0] === 'Private use') return false\n return true\n}\n\nconst RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])\n .filter(isIsoAlpha2)\n .map((entry) => ({\n code: entry.Subtag,\n name: entry.Description.join(', '),\n }))\n\n// Codes in common use that the language-subtag registry does not list as region subtags.\n// XK is user-assigned rather than ISO 3166-1 assigned, so it never appears in the registry.\nconst SUPPLEMENTAL_COUNTRIES: IsoCountry[] = [\n { code: 'XK', name: 'Kosovo' },\n]\n\nconst RAW_COUNTRY_CODES = new Set(RAW_COUNTRIES.map((entry) => entry.code))\n\nexport const ISO_COUNTRIES: IsoCountry[] = [\n ...RAW_COUNTRIES,\n ...SUPPLEMENTAL_COUNTRIES.filter((entry) => !RAW_COUNTRY_CODES.has(entry.code)),\n].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))\n\nexport const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']\n\nconst displayNameCache = new Map<string, Intl.DisplayNames>()\n\nfunction getDisplayNames(locale: string): Intl.DisplayNames | null {\n if (typeof Intl === 'undefined' || typeof Intl.DisplayNames === 'undefined') return null\n const key = locale || 'en'\n let instance = displayNameCache.get(key)\n if (!instance) {\n try {\n instance = new Intl.DisplayNames([key], { type: 'region' })\n displayNameCache.set(key, instance)\n } catch {\n return null\n }\n }\n return instance\n}\n\nexport function resolveCountryName(code: string, options: { locale?: string } = {}): string {\n const normalized = code.toUpperCase()\n const fallback = ISO_COUNTRIES.find((entry) => entry.code === normalized)?.name ?? normalized\n const displayNames = getDisplayNames(options.locale ?? 'en')\n if (!displayNames) return fallback\n try {\n const label = displayNames.of(normalized as any)\n // Intl echoes the raw code back when its ICU data cannot resolve the region.\n if (typeof label !== 'string' || label === normalized) return fallback\n return label\n } catch {\n return fallback\n }\n}\n\nexport function buildCountryOptions(options: {\n locale?: string\n priority?: string[]\n transformLabel?: (code: string, defaultLabel: string) => string\n} = {}): Array<{ code: string; label: string }> {\n const { locale, transformLabel } = options\n const priority = (options.priority ?? COUNTRY_PRIORITY).map((code) => code.toUpperCase())\n const prioritySet = new Set(priority)\n const labelFor = (code: string) => {\n const base = resolveCountryName(code, { locale })\n return transformLabel ? transformLabel(code, base) : base\n }\n\n const byCode = new Map(ISO_COUNTRIES.map((entry) => [entry.code, entry]))\n\n const prioritized = priority\n .map((code) => {\n const base = byCode.get(code)\n if (!base) return null\n return { code, label: labelFor(code) }\n })\n .filter((entry): entry is { code: string; label: string } => !!entry)\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n const remaining = ISO_COUNTRIES\n .filter((entry) => !prioritySet.has(entry.code))\n .map((entry) => ({ code: entry.code, label: labelFor(entry.code) }))\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n return [...prioritized, ...remaining]\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import registry from 'language-subtag-registry/data/json/registry.json'\n\ntype RegistryEntry = {\n Type: string\n Subtag?: string\n Description?: string[]\n Deprecated?: string\n}\n\nexport type IsoCountry = {\n code: string\n name: string\n}\n\nfunction isIsoAlpha2(entry: RegistryEntry): entry is RegistryEntry & { Subtag: string; Description: string[] } {\n if (entry.Type !== 'region') return false\n if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false\n if (entry.Deprecated) return false\n if (!entry.Description || !entry.Description.length) return false\n if (entry.Description[0] === 'Private use') return false\n return true\n}\n\nconst RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])\n .filter(isIsoAlpha2)\n .map((entry) => ({\n code: entry.Subtag,\n name: entry.Description.join(', '),\n }))\n\nexport const ISO_COUNTRIES: IsoCountry[] = [...RAW_COUNTRIES].sort((a, b) =>\n a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })\n)\n\nexport const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']\n\nconst displayNameCache = new Map<string, Intl.DisplayNames>()\n\nfunction getDisplayNames(locale: string): Intl.DisplayNames | null {\n if (typeof Intl === 'undefined' || typeof Intl.DisplayNames === 'undefined') return null\n const key = locale || 'en'\n let instance = displayNameCache.get(key)\n if (!instance) {\n try {\n instance = new Intl.DisplayNames([key], { type: 'region' })\n displayNameCache.set(key, instance)\n } catch {\n return null\n }\n }\n return instance\n}\n\nexport function resolveCountryName(code: string, options: { locale?: string } = {}): string {\n const normalized = code.toUpperCase()\n const fallback = ISO_COUNTRIES.find((entry) => entry.code === normalized)?.name ?? normalized\n const displayNames = getDisplayNames(options.locale ?? 'en')\n if (!displayNames) return fallback\n try {\n const label = displayNames.of(normalized as any)\n return typeof label === 'string' ? label : fallback\n } catch {\n return fallback\n }\n}\n\nexport function buildCountryOptions(options: {\n locale?: string\n priority?: string[]\n transformLabel?: (code: string, defaultLabel: string) => string\n} = {}): Array<{ code: string; label: string }> {\n const { locale, transformLabel } = options\n const priority = (options.priority ?? COUNTRY_PRIORITY).map((code) => code.toUpperCase())\n const prioritySet = new Set(priority)\n const labelFor = (code: string) => {\n const base = resolveCountryName(code, { locale })\n return transformLabel ? transformLabel(code, base) : base\n }\n\n const byCode = new Map(ISO_COUNTRIES.map((entry) => [entry.code, entry]))\n\n const prioritized = priority\n .map((code) => {\n const base = byCode.get(code)\n if (!base) return null\n return { code, label: labelFor(code) }\n })\n .filter((entry): entry is { code: string; label: string } => !!entry)\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n const remaining = ISO_COUNTRIES\n .filter((entry) => !prioritySet.has(entry.code))\n .map((entry) => ({ code: entry.code, label: labelFor(entry.code) }))\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n return [...prioritized, ...remaining]\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,12 +4,7 @@ function trimToUndefined(value) {
4
4
  const trimmed = value.trim();
5
5
  return trimmed.length > 0 ? trimmed : void 0;
6
6
  }
7
- function parseCommaSeparatedList(value) {
8
- if (typeof value !== "string") return [];
9
- return value.split(",").map((entry) => entry.trim()).filter(Boolean);
10
- }
11
7
  export {
12
- parseCommaSeparatedList,
13
8
  toOptionalString,
14
9
  trimToUndefined
15
10
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/string.ts"],
4
- "sourcesContent": ["export { toOptionalString } from './string/coerce'\n\nexport function trimToUndefined(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function parseCommaSeparatedList(value: string | null | undefined): string[] {\n if (typeof value !== 'string') return []\n return value\n .split(',')\n .map((entry) => entry.trim())\n .filter(Boolean)\n}\n\n"],
5
- "mappings": "AAAA,SAAS,wBAAwB;AAE1B,SAAS,gBAAgB,OAAoC;AAClE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAEO,SAAS,wBAAwB,OAA4C;AAClF,MAAI,OAAO,UAAU,SAAU,QAAO,CAAC;AACvC,SAAO,MACJ,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,OAAO;AACnB;",
4
+ "sourcesContent": ["export { toOptionalString } from './string/coerce'\n\nexport function trimToUndefined(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\n"],
5
+ "mappings": "AAAA,SAAS,wBAAwB;AAE1B,SAAS,gBAAgB,OAAoC;AAClE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.6-develop.6543.1.49fadb4c31";
1
+ const APP_VERSION = "0.6.6";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6543.1.49fadb4c31'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.6-develop.6543.1.49fadb4c31",
3
+ "version": "0.6.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -97,7 +97,7 @@
97
97
  "@mikro-orm/core": "^7.1.5",
98
98
  "@mikro-orm/decorators": "^7.1.5",
99
99
  "@mikro-orm/postgresql": "^7.1.5",
100
- "@open-mercato/cache": "0.6.6-develop.6543.1.49fadb4c31",
100
+ "@open-mercato/cache": "0.6.6",
101
101
  "@types/sanitize-html": "^2.16.1",
102
102
  "dotenv": "^17.4.2",
103
103
  "pino": "^10.3.1",
@@ -120,6 +120,5 @@
120
120
  "type": "git",
121
121
  "url": "https://github.com/open-mercato/open-mercato",
122
122
  "directory": "packages/shared"
123
- },
124
- "stableVersion": "0.6.5"
123
+ }
125
124
  }
@@ -82,6 +82,45 @@ describe('auth server integrity checks', () => {
82
82
  expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, auth)
83
83
  })
84
84
 
85
+ it('reports a transient DB failure as "error", never "invalid" (issue #4176 — no mass logout)', async () => {
86
+ const { resolveAuthFromRequestDetailed, resolveAuthFromCookiesDetailed } = await import('@open-mercato/shared/lib/auth/server')
87
+ const auth = {
88
+ sub: '11111111-1111-4111-8111-111111111111',
89
+ tenantId: '22222222-2222-4222-8222-222222222222',
90
+ orgId: '33333333-3333-4333-8333-333333333333',
91
+ roles: [],
92
+ }
93
+
94
+ verifyJwt.mockReturnValue(auth)
95
+ // A DB blip / pool exhaustion / timeout throws from the canonical lookup.
96
+ resolveCanonicalStaffAuthContext.mockRejectedValue(new Error('connection pool exhausted'))
97
+
98
+ const request = new Request('https://example.test/api/test', {
99
+ headers: { cookie: 'auth_token=jwt-token' },
100
+ })
101
+
102
+ await expect(resolveAuthFromRequestDetailed(request)).resolves.toEqual({ auth: null, status: 'error' })
103
+
104
+ cookieStore.get.mockImplementation((name: string) => {
105
+ if (name === 'auth_token') return { value: 'jwt-token' }
106
+ return undefined
107
+ })
108
+ await expect(resolveAuthFromCookiesDetailed()).resolves.toEqual({ auth: null, status: 'error' })
109
+ })
110
+
111
+ it('still reports a genuinely bad token as "invalid" when it throws during verify', async () => {
112
+ const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
113
+ verifyJwt.mockImplementation(() => {
114
+ throw new Error('malformed token')
115
+ })
116
+
117
+ const request = new Request('https://example.test/api/test', {
118
+ headers: { cookie: 'auth_token=jwt-token' },
119
+ })
120
+
121
+ await expect(resolveAuthFromRequestDetailed(request)).resolves.toEqual({ auth: null, status: 'invalid' })
122
+ })
123
+
85
124
  it('replaces stale JWT roles with canonical roles from the database', async () => {
86
125
  const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
87
126
  const jwtAuth = {
@@ -23,12 +23,29 @@ export type AuthContext = {
23
23
  } | null
24
24
 
25
25
  type CookieOverride = { applied: boolean; value: string | null }
26
- type AuthResolutionStatus = 'authenticated' | 'missing' | 'invalid'
26
+ // 'error' means auth could not be determined because of a transient/unexpected
27
+ // failure (DB unavailable, connection-pool exhaustion, statement timeout, …) —
28
+ // NOT because the token is genuinely invalid. Callers MUST NOT clear the user's
29
+ // session cookies for 'error'; doing so would log everyone out at once during a
30
+ // shared infrastructure blip (issue #4176).
31
+ type AuthResolutionStatus = 'authenticated' | 'missing' | 'invalid' | 'error'
27
32
  type AuthResolution = {
28
33
  auth: AuthContext
29
34
  status: AuthResolutionStatus
30
35
  }
31
36
 
37
+ // Thrown by `resolveCanonicalInteractiveAuthContext` when the canonical auth
38
+ // lookup itself fails unexpectedly (as opposed to resolving to a genuine
39
+ // "not authorized" null). Kept distinct so resolvers can map it to the
40
+ // non-destructive 'error' status instead of the cookie-clearing 'invalid'.
41
+ export class AuthResolutionUnavailableError extends Error {
42
+ constructor(cause?: unknown) {
43
+ super('Auth resolution unavailable')
44
+ this.name = 'AuthResolutionUnavailableError'
45
+ if (cause !== undefined) (this as { cause?: unknown }).cause = cause
46
+ }
47
+ }
48
+
32
49
  // Symbol-keyed trusted auth context. Set on synthetic Request objects by
33
50
  // callers that have already authenticated (e.g. the AI in-process operation
34
51
  // runner) so downstream auth resolution short-circuits without re-running
@@ -263,8 +280,12 @@ async function resolveCanonicalInteractiveAuthContext(auth: AuthContext): Promis
263
280
  const container = await createRequestContainer()
264
281
  const em = container.resolve('em') as EntityManager
265
282
  return await resolveCanonicalStaffAuthContext(em, auth)
266
- } catch {
267
- return null
283
+ } catch (err) {
284
+ // A thrown error here means we could not check the token against the
285
+ // database (DB down, pool exhausted, timeout). This is NOT the same as the
286
+ // token being invalid — surface it so callers keep the session intact and
287
+ // fail the request transiently instead of force-logging the user out.
288
+ throw new AuthResolutionUnavailableError(err)
268
289
  }
269
290
  }
270
291
 
@@ -284,7 +305,8 @@ export async function resolveAuthFromCookiesDetailed(): Promise<AuthResolution>
284
305
  auth: applySuperAdminScope(canonicalAuth, tenantCookie, orgCookie),
285
306
  status: 'authenticated',
286
307
  }
287
- } catch {
308
+ } catch (err) {
309
+ if (err instanceof AuthResolutionUnavailableError) return { auth: null, status: 'error' }
288
310
  return { auth: null, status: 'invalid' }
289
311
  }
290
312
  }
@@ -307,6 +329,7 @@ export async function resolveAuthFromRequestDetailed(req: Request): Promise<Auth
307
329
  const authHeader = (req.headers.get('authorization') || '').trim()
308
330
  let token: string | undefined
309
331
  let hadInvalidInteractiveToken = false
332
+ let hadUnavailableResolution = false
310
333
  if (authHeader.toLowerCase().startsWith('bearer ')) token = authHeader.slice(7).trim()
311
334
  if (!token) {
312
335
  const match = cookieHeader.match(/(?:^|;\s*)auth_token=([^;]+)/)
@@ -326,18 +349,25 @@ export async function resolveAuthFromRequestDetailed(req: Request): Promise<Auth
326
349
  }
327
350
  hadInvalidInteractiveToken = true
328
351
  }
329
- } catch {
330
- hadInvalidInteractiveToken = true
352
+ } catch (err) {
353
+ // Transient/unexpected canonical-resolution failures must not invalidate
354
+ // the token — surface them so we return 'error' (retryable) rather than
355
+ // clearing the session cookies.
356
+ if (err instanceof AuthResolutionUnavailableError) hadUnavailableResolution = true
357
+ else hadInvalidInteractiveToken = true
331
358
  }
332
359
  }
333
360
 
361
+ const resolveUnauthenticatedStatus = (): AuthResolutionStatus =>
362
+ hadUnavailableResolution ? 'error' : hadInvalidInteractiveToken ? 'invalid' : 'missing'
363
+
334
364
  const apiKey = extractApiKey(req)
335
365
  if (!apiKey) {
336
- return { auth: null, status: hadInvalidInteractiveToken ? 'invalid' : 'missing' }
366
+ return { auth: null, status: resolveUnauthenticatedStatus() }
337
367
  }
338
368
  const apiAuth = await resolveApiKeyAuth(apiKey)
339
369
  if (!apiAuth) {
340
- return { auth: null, status: hadInvalidInteractiveToken ? 'invalid' : 'missing' }
370
+ return { auth: null, status: resolveUnauthenticatedStatus() }
341
371
  }
342
372
  return {
343
373
  auth: applySuperAdminScope(apiAuth, tenantCookie, orgCookie),
@@ -510,14 +510,6 @@ function json(data: any, init?: ResponseInit) {
510
510
  })
511
511
  }
512
512
 
513
- // Name of the selected-organization cookie (mirrors the directory module's
514
- // OrganizationSwitcher, which writes `om_selected_org=...; path=/; samesite=lax`).
515
- // Kept as a local literal so shared has no import dependency on a domain package.
516
- const SELECTED_ORG_COOKIE = 'om_selected_org'
517
- // Set-Cookie value that expires the stale selection so the next request falls
518
- // back to the caller's home org. Attributes mirror how the switcher sets it.
519
- const CLEAR_SELECTED_ORG_COOKIE = `${SELECTED_ORG_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
520
-
521
513
  function attachOperationHeader(res: Response, logEntry: any) {
522
514
  if (!res || !(res instanceof Response)) return res
523
515
  if (!logEntry || typeof logEntry !== 'object') return res
@@ -1347,42 +1339,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1347
1339
  return { container, auth: scopedAuth, organizationScope: scope, selectedOrganizationId, organizationIds, request }
1348
1340
  }
1349
1341
 
1350
- // The caller explicitly selected an organization (selected-org cookie) that no
1351
- // longer resolves to a real, accessible org — e.g. a stale cookie after a DB
1352
- // reset, or after the caller lost access to that org. Rather than silently
1353
- // acting against a fallback org the caller did not select (writes) or showing
1354
- // a different org's data than the one selected (reads), fail loud on every
1355
- // org-scoped record operation so the client re-selects. Recovery surfaces
1356
- // (org switcher, nav, profile) are custom routes, not this factory, so they
1357
- // keep working. Returns a 422 Response when rejected, otherwise null.
1358
- function rejectInvalidOrgSelection(ctx: CrudCtx, action: 'list' | 'create' | 'update' | 'delete'): Response | null {
1359
- if (!ormCfg.orgField) return null
1360
- if (!(ctx.organizationScope as OrganizationScope | null | undefined)?.selectionRejected) return null
1361
- logForbidden({
1362
- resourceKind,
1363
- action,
1364
- reason: 'organization_selection_invalid',
1365
- userId: ctx.auth?.sub ?? null,
1366
- tenantId: ctx.auth?.tenantId ?? null,
1367
- organizationIds: ctx.organizationIds,
1368
- })
1369
- // Self-heal reads: expire the stale selected-org cookie so the caller's next
1370
- // request falls back to their home org and the session recovers on its own
1371
- // (important for single-org users, who have no org switcher to re-select
1372
- // from). Writes intentionally do NOT clear it — a mutation must go through an
1373
- // explicit, valid re-selection, never silently target a fallback org.
1374
- const headers: Record<string, string> = action === 'list'
1375
- ? { 'set-cookie': CLEAR_SELECTED_ORG_COOKIE }
1376
- : {}
1377
- return json(
1378
- {
1379
- error: 'Your selected organization is no longer available. Please re-select an organization and try again.',
1380
- code: 'organization_selection_invalid',
1381
- },
1382
- { status: 422, headers },
1383
- )
1384
- }
1385
-
1386
1342
  async function GET(request: Request) {
1387
1343
  const profiler = createCrudProfiler(resourceKind, 'list')
1388
1344
  const requestMeta: Record<string, unknown> = { method: request.method }
@@ -1410,11 +1366,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1410
1366
  finishProfile({ reason: 'unauthorized' })
1411
1367
  return json({ error: 'Unauthorized' }, { status: 401 })
1412
1368
  }
1413
- const listSelectionRejected = rejectInvalidOrgSelection(ctx, 'list')
1414
- if (listSelectionRejected) {
1415
- finishProfile({ reason: 'organization_selection_invalid' })
1416
- return listSelectionRejected
1417
- }
1418
1369
  if (!opts.list) {
1419
1370
  finishProfile({ reason: 'list_not_configured' })
1420
1371
  return json({ error: 'Not implemented' }, { status: 501 })
@@ -2081,8 +2032,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2081
2032
  if (!opts.create && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2082
2033
  const ctx = await withCtx(request)
2083
2034
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2084
- const createSelectionRejected = rejectInvalidOrgSelection(ctx, 'create')
2085
- if (createSelectionRejected) return createSelectionRejected
2086
2035
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2087
2036
  logForbidden({
2088
2037
  resourceKind,
@@ -2353,8 +2302,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2353
2302
  if (!opts.update && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2354
2303
  const ctx = await withCtx(request)
2355
2304
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2356
- const updateSelectionRejected = rejectInvalidOrgSelection(ctx, 'update')
2357
- if (updateSelectionRejected) return updateSelectionRejected
2358
2305
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2359
2306
  logForbidden({
2360
2307
  resourceKind,
@@ -2684,8 +2631,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2684
2631
  try {
2685
2632
  const ctx = await withCtx(request)
2686
2633
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2687
- const deleteSelectionRejected = rejectInvalidOrgSelection(ctx, 'delete')
2688
- if (deleteSelectionRejected) return deleteSelectionRejected
2689
2634
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2690
2635
  logForbidden({
2691
2636
  resourceKind,
@@ -28,18 +28,9 @@ const RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])
28
28
  name: entry.Description.join(', '),
29
29
  }))
30
30
 
31
- // Codes in common use that the language-subtag registry does not list as region subtags.
32
- // XK is user-assigned rather than ISO 3166-1 assigned, so it never appears in the registry.
33
- const SUPPLEMENTAL_COUNTRIES: IsoCountry[] = [
34
- { code: 'XK', name: 'Kosovo' },
35
- ]
36
-
37
- const RAW_COUNTRY_CODES = new Set(RAW_COUNTRIES.map((entry) => entry.code))
38
-
39
- export const ISO_COUNTRIES: IsoCountry[] = [
40
- ...RAW_COUNTRIES,
41
- ...SUPPLEMENTAL_COUNTRIES.filter((entry) => !RAW_COUNTRY_CODES.has(entry.code)),
42
- ].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
31
+ export const ISO_COUNTRIES: IsoCountry[] = [...RAW_COUNTRIES].sort((a, b) =>
32
+ a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
33
+ )
43
34
 
44
35
  export const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']
45
36
 
@@ -67,9 +58,7 @@ export function resolveCountryName(code: string, options: { locale?: string } =
67
58
  if (!displayNames) return fallback
68
59
  try {
69
60
  const label = displayNames.of(normalized as any)
70
- // Intl echoes the raw code back when its ICU data cannot resolve the region.
71
- if (typeof label !== 'string' || label === normalized) return fallback
72
- return label
61
+ return typeof label === 'string' ? label : fallback
73
62
  } catch {
74
63
  return fallback
75
64
  }
package/src/lib/string.ts CHANGED
@@ -6,11 +6,3 @@ export function trimToUndefined(value: unknown): string | undefined {
6
6
  return trimmed.length > 0 ? trimmed : undefined
7
7
  }
8
8
 
9
- export function parseCommaSeparatedList(value: string | null | undefined): string[] {
10
- if (typeof value !== 'string') return []
11
- return value
12
- .split(',')
13
- .map((entry) => entry.trim())
14
- .filter(Boolean)
15
- }
16
-
@@ -1,49 +0,0 @@
1
- import { parseCommaSeparatedList, trimToUndefined } from '../string'
2
-
3
- describe('parseCommaSeparatedList', () => {
4
- it('splits a comma-separated string into entries', () => {
5
- expect(parseCommaSeparatedList('low,medium,high')).toEqual(['low', 'medium', 'high'])
6
- })
7
-
8
- it('trims surrounding whitespace from every entry', () => {
9
- expect(parseCommaSeparatedList(' admin , editor,viewer ')).toEqual(['admin', 'editor', 'viewer'])
10
- })
11
-
12
- it('drops empty entries produced by repeated or trailing separators', () => {
13
- expect(parseCommaSeparatedList('a,,b,')).toEqual(['a', 'b'])
14
- expect(parseCommaSeparatedList(',a, ,b')).toEqual(['a', 'b'])
15
- })
16
-
17
- it('returns an empty array when every entry is blank', () => {
18
- expect(parseCommaSeparatedList(',,')).toEqual([])
19
- expect(parseCommaSeparatedList(' , ')).toEqual([])
20
- })
21
-
22
- it('returns an empty array for blank or non-string inputs', () => {
23
- expect(parseCommaSeparatedList('')).toEqual([])
24
- expect(parseCommaSeparatedList(' ')).toEqual([])
25
- expect(parseCommaSeparatedList(null)).toEqual([])
26
- expect(parseCommaSeparatedList(undefined)).toEqual([])
27
- })
28
-
29
- it('returns a single entry when the input has no separator', () => {
30
- expect(parseCommaSeparatedList('admin')).toEqual(['admin'])
31
- expect(parseCommaSeparatedList(' admin ')).toEqual(['admin'])
32
- })
33
-
34
- it('preserves interior whitespace within an entry', () => {
35
- expect(parseCommaSeparatedList('order created, order shipped')).toEqual(['order created', 'order shipped'])
36
- })
37
- })
38
-
39
- describe('trimToUndefined', () => {
40
- it('returns the trimmed value when it is a non-blank string', () => {
41
- expect(trimToUndefined(' value ')).toBe('value')
42
- })
43
-
44
- it('returns undefined for blank or non-string values', () => {
45
- expect(trimToUndefined(' ')).toBeUndefined()
46
- expect(trimToUndefined(null)).toBeUndefined()
47
- expect(trimToUndefined(42)).toBeUndefined()
48
- })
49
- })
@@ -1,82 +0,0 @@
1
- import {
2
- COUNTRY_PRIORITY,
3
- ISO_COUNTRIES,
4
- buildCountryOptions,
5
- resolveCountryName,
6
- } from '../countries'
7
-
8
- describe('ISO_COUNTRIES', () => {
9
- it('includes Kosovo, which the language-subtag registry does not list', () => {
10
- const kosovo = ISO_COUNTRIES.filter((entry) => entry.code === 'XK')
11
- expect(kosovo).toEqual([{ code: 'XK', name: 'Kosovo' }])
12
- })
13
-
14
- it('does not duplicate any country code', () => {
15
- const codes = ISO_COUNTRIES.map((entry) => entry.code)
16
- expect(codes).toHaveLength(new Set(codes).size)
17
- })
18
-
19
- it('keeps registry-derived countries intact', () => {
20
- expect(ISO_COUNTRIES).toEqual(
21
- expect.arrayContaining([
22
- { code: 'PL', name: 'Poland' },
23
- { code: 'DE', name: 'Germany' },
24
- ])
25
- )
26
- })
27
-
28
- it('stays sorted by name', () => {
29
- const names = ISO_COUNTRIES.map((entry) => entry.name)
30
- const sorted = [...names].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
31
- expect(names).toEqual(sorted)
32
- })
33
- })
34
-
35
- describe('resolveCountryName', () => {
36
- it('resolves Kosovo for XK', () => {
37
- expect(resolveCountryName('XK')).toBe('Kosovo')
38
- })
39
-
40
- it('resolves Kosovo for a lowercase xk', () => {
41
- expect(resolveCountryName('xk')).toBe('Kosovo')
42
- })
43
-
44
- it('still resolves registry-backed countries', () => {
45
- expect(resolveCountryName('PL')).toBe('Poland')
46
- })
47
-
48
- it('falls back to the raw code for an unknown region', () => {
49
- expect(resolveCountryName('ZZZ')).toBe('ZZZ')
50
- })
51
- })
52
-
53
- describe('buildCountryOptions', () => {
54
- it('offers Kosovo as a selectable option', () => {
55
- const options = buildCountryOptions()
56
- expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo' }]))
57
- })
58
-
59
- it('offers exactly one Kosovo option', () => {
60
- const options = buildCountryOptions().filter((option) => option.code === 'XK')
61
- expect(options).toHaveLength(1)
62
- })
63
-
64
- it('lists Kosovo after the prioritized countries', () => {
65
- const options = buildCountryOptions()
66
- const kosovoIndex = options.findIndex((option) => option.code === 'XK')
67
- expect(kosovoIndex).toBeGreaterThanOrEqual(COUNTRY_PRIORITY.length)
68
- })
69
-
70
- it('keeps the prioritized countries at the top', () => {
71
- const options = buildCountryOptions()
72
- const leading = options.slice(0, COUNTRY_PRIORITY.length).map((option) => option.code)
73
- expect(leading.slice().sort()).toEqual(COUNTRY_PRIORITY.slice().sort())
74
- })
75
-
76
- it('applies transformLabel to the supplemental country too', () => {
77
- const options = buildCountryOptions({
78
- transformLabel: (code, defaultLabel) => `${defaultLabel} (${code})`,
79
- })
80
- expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo (XK)' }]))
81
- })
82
- })