@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.
- package/AGENTS.md +18 -0
- package/dist/lib/crud/factory.js +33 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/string.js +5 -0
- 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 +2 -2
- package/src/lib/__tests__/string.test.ts +49 -0
- package/src/lib/crud/factory.ts +55 -0
- package/src/lib/string.ts +8 -0
package/dist/lib/string.js
CHANGED
|
@@ -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
|
};
|
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\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
|
}
|
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-develop.
|
|
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.
|
|
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.
|
|
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
|
+
})
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
+
|