@open-mercato/shared 0.6.6-develop.6536.1.799f7f795b → 0.6.6-develop.6540.1.ae856df1ec

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.
@@ -4,7 +4,12 @@ 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
+ }
7
11
  export {
12
+ parseCommaSeparatedList,
8
13
  toOptionalString,
9
14
  trimToUndefined
10
15
  };
@@ -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\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\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;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.6-develop.6536.1.799f7f795b";
1
+ const APP_VERSION = "0.6.6-develop.6540.1.ae856df1ec";
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.6536.1.799f7f795b'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6540.1.ae856df1ec'\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.6536.1.799f7f795b",
3
+ "version": "0.6.6-develop.6540.1.ae856df1ec",
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.6536.1.799f7f795b",
100
+ "@open-mercato/cache": "0.6.6-develop.6540.1.ae856df1ec",
101
101
  "@types/sanitize-html": "^2.16.1",
102
102
  "dotenv": "^17.4.2",
103
103
  "pino": "^10.3.1",
@@ -0,0 +1,49 @@
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
+ })
@@ -510,6 +510,14 @@ 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
+
513
521
  function attachOperationHeader(res: Response, logEntry: any) {
514
522
  if (!res || !(res instanceof Response)) return res
515
523
  if (!logEntry || typeof logEntry !== 'object') return res
@@ -1339,6 +1347,42 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1339
1347
  return { container, auth: scopedAuth, organizationScope: scope, selectedOrganizationId, organizationIds, request }
1340
1348
  }
1341
1349
 
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
+
1342
1386
  async function GET(request: Request) {
1343
1387
  const profiler = createCrudProfiler(resourceKind, 'list')
1344
1388
  const requestMeta: Record<string, unknown> = { method: request.method }
@@ -1366,6 +1410,11 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1366
1410
  finishProfile({ reason: 'unauthorized' })
1367
1411
  return json({ error: 'Unauthorized' }, { status: 401 })
1368
1412
  }
1413
+ const listSelectionRejected = rejectInvalidOrgSelection(ctx, 'list')
1414
+ if (listSelectionRejected) {
1415
+ finishProfile({ reason: 'organization_selection_invalid' })
1416
+ return listSelectionRejected
1417
+ }
1369
1418
  if (!opts.list) {
1370
1419
  finishProfile({ reason: 'list_not_configured' })
1371
1420
  return json({ error: 'Not implemented' }, { status: 501 })
@@ -2032,6 +2081,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2032
2081
  if (!opts.create && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2033
2082
  const ctx = await withCtx(request)
2034
2083
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2084
+ const createSelectionRejected = rejectInvalidOrgSelection(ctx, 'create')
2085
+ if (createSelectionRejected) return createSelectionRejected
2035
2086
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2036
2087
  logForbidden({
2037
2088
  resourceKind,
@@ -2302,6 +2353,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2302
2353
  if (!opts.update && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
2303
2354
  const ctx = await withCtx(request)
2304
2355
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2356
+ const updateSelectionRejected = rejectInvalidOrgSelection(ctx, 'update')
2357
+ if (updateSelectionRejected) return updateSelectionRejected
2305
2358
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2306
2359
  logForbidden({
2307
2360
  resourceKind,
@@ -2631,6 +2684,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2631
2684
  try {
2632
2685
  const ctx = await withCtx(request)
2633
2686
  if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
2687
+ const deleteSelectionRejected = rejectInvalidOrgSelection(ctx, 'delete')
2688
+ if (deleteSelectionRejected) return deleteSelectionRejected
2634
2689
  if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
2635
2690
  logForbidden({
2636
2691
  resourceKind,
package/src/lib/string.ts CHANGED
@@ -6,3 +6,11 @@ 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
+