@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.
- package/AGENTS.md +0 -18
- package/dist/lib/auth/server.js +19 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/crud/factory.js +0 -33
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/location/countries.js +4 -10
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/string.js +0 -5
- package/dist/lib/string.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +3 -4
- package/src/lib/auth/__tests__/server.test.ts +39 -0
- package/src/lib/auth/server.ts +38 -8
- package/src/lib/crud/factory.ts +0 -55
- package/src/lib/location/countries.ts +4 -15
- package/src/lib/string.ts +0 -8
- package/src/lib/__tests__/string.test.ts +0 -49
- package/src/lib/location/__tests__/countries.test.ts +0 -82
|
@@ -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
|
|
15
|
-
|
|
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
|
-
|
|
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\
|
|
5
|
-
"mappings": "AAAA,OAAO,cAAc;AAcrB,SAAS,YAAY,OAA0F;AAC7G,MAAI,MAAM,SAAS,SAAU,QAAO;AACpC,MAAI,CAAC,MAAM,UAAU,CAAC,aAAa,KAAK,MAAM,MAAM,EAAG,QAAO;AAC9D,MAAI,MAAM,WAAY,QAAO;AAC7B,MAAI,CAAC,MAAM,eAAe,CAAC,MAAM,YAAY,OAAQ,QAAO;AAC5D,MAAI,MAAM,YAAY,CAAC,MAAM,cAAe,QAAO;AACnD,SAAO;AACT;AAEA,MAAM,gBAA+B,SAClC,OAAO,WAAW,EAClB,IAAI,CAAC,WAAW;AAAA,EACf,MAAM,MAAM;AAAA,EACZ,MAAM,MAAM,YAAY,KAAK,IAAI;AACnC,EAAE;
|
|
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
|
}
|
package/dist/lib/string.js
CHANGED
|
@@ -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
|
};
|
package/dist/lib/string.js.map
CHANGED
|
@@ -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\
|
|
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;
|
|
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
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 = {
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -23,12 +23,29 @@ export type AuthContext = {
|
|
|
23
23
|
} | null
|
|
24
24
|
|
|
25
25
|
type CookieOverride = { applied: boolean; value: string | null }
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
366
|
+
return { auth: null, status: resolveUnauthenticatedStatus() }
|
|
337
367
|
}
|
|
338
368
|
const apiAuth = await resolveApiKeyAuth(apiKey)
|
|
339
369
|
if (!apiAuth) {
|
|
340
|
-
return { auth: null, status:
|
|
370
|
+
return { auth: null, status: resolveUnauthenticatedStatus() }
|
|
341
371
|
}
|
|
342
372
|
return {
|
|
343
373
|
auth: applySuperAdminScope(apiAuth, tenantCookie, orgCookie),
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
})
|