@furystack/core 15.0.36 → 15.2.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.
- package/CHANGELOG.md +59 -0
- package/esm/filter-items.d.ts +11 -0
- package/esm/filter-items.d.ts.map +1 -0
- package/esm/filter-items.js +130 -0
- package/esm/filter-items.js.map +1 -0
- package/esm/filter-items.spec.d.ts +2 -0
- package/esm/filter-items.spec.d.ts.map +1 -0
- package/esm/filter-items.spec.js +174 -0
- package/esm/filter-items.spec.js.map +1 -0
- package/esm/in-memory-store.d.ts +0 -2
- package/esm/in-memory-store.d.ts.map +1 -1
- package/esm/in-memory-store.js +4 -117
- package/esm/in-memory-store.js.map +1 -1
- package/esm/index.d.ts +2 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +2 -0
- package/esm/index.js.map +1 -1
- package/esm/models/physical-store.d.ts +8 -1
- package/esm/models/physical-store.d.ts.map +1 -1
- package/esm/system-identity-context.d.ts +68 -0
- package/esm/system-identity-context.d.ts.map +1 -0
- package/esm/system-identity-context.js +75 -0
- package/esm/system-identity-context.js.map +1 -0
- package/esm/system-identity-context.spec.d.ts +2 -0
- package/esm/system-identity-context.spec.d.ts.map +1 -0
- package/esm/system-identity-context.spec.js +90 -0
- package/esm/system-identity-context.spec.js.map +1 -0
- package/package.json +2 -2
- package/src/filter-items.spec.ts +216 -0
- package/src/filter-items.ts +148 -0
- package/src/in-memory-store.ts +4 -136
- package/src/index.ts +2 -0
- package/src/models/physical-store.ts +8 -1
- package/src/system-identity-context.spec.ts +101 -0
- package/src/system-identity-context.ts +83 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { FilterType } from './models/physical-store.js'
|
|
2
|
+
import { isLogicalOperator, isOperator } from './models/physical-store.js'
|
|
3
|
+
|
|
4
|
+
type FieldOperatorFilter = {
|
|
5
|
+
$eq?: unknown
|
|
6
|
+
$ne?: unknown
|
|
7
|
+
$in?: unknown[]
|
|
8
|
+
$nin?: unknown[]
|
|
9
|
+
$gt?: unknown
|
|
10
|
+
$gte?: unknown
|
|
11
|
+
$lt?: unknown
|
|
12
|
+
$lte?: unknown
|
|
13
|
+
$startsWith?: string
|
|
14
|
+
$endsWith?: string
|
|
15
|
+
$like?: string
|
|
16
|
+
$regex?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const escapeRegexMeta = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
20
|
+
|
|
21
|
+
const evaluateLike = (value: string, likeString: string) => {
|
|
22
|
+
const likeRegex = `^${likeString.split('%').map(escapeRegexMeta).join('.*')}$`
|
|
23
|
+
return value.match(new RegExp(likeRegex, 'i'))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Filters an array of items using a {@link FilterType} expression.
|
|
28
|
+
* Supports field-level operators ($eq, $ne, $in, $nin, $gt, $gte, $lt, $lte, $startsWith, $endsWith, $like, $regex)
|
|
29
|
+
* and logical operators ($and, $or).
|
|
30
|
+
* @param values The array of items to filter
|
|
31
|
+
* @param filter The filter expression to apply
|
|
32
|
+
* @returns The filtered array of items
|
|
33
|
+
*/
|
|
34
|
+
export function filterItems<T>(values: T[], filter?: FilterType<T>): T[] {
|
|
35
|
+
if (!filter) {
|
|
36
|
+
return values
|
|
37
|
+
}
|
|
38
|
+
return values.filter((item) => {
|
|
39
|
+
const filterRecord = filter as Record<string, Array<FilterType<T>> | FieldOperatorFilter | undefined>
|
|
40
|
+
const itemRecord = item as Record<string, unknown>
|
|
41
|
+
|
|
42
|
+
for (const key in filterRecord) {
|
|
43
|
+
if (isLogicalOperator(key)) {
|
|
44
|
+
const filterValue = filterRecord[key] as Array<FilterType<T>>
|
|
45
|
+
switch (key) {
|
|
46
|
+
case '$and':
|
|
47
|
+
if (filterValue.some((v: FilterType<T>) => !filterItems([item], v).length)) {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
break
|
|
51
|
+
case '$or':
|
|
52
|
+
if (filterValue.some((v: FilterType<T>) => filterItems([item], v).length)) {
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`The logical operation '${key}' is not a valid operation`)
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
const fieldFilter = filterRecord[key] as FieldOperatorFilter | undefined
|
|
61
|
+
if (typeof fieldFilter === 'object' && fieldFilter !== null) {
|
|
62
|
+
for (const filterKey in fieldFilter) {
|
|
63
|
+
if (isOperator(filterKey)) {
|
|
64
|
+
const itemValue = itemRecord[key]
|
|
65
|
+
const filterValue = fieldFilter[filterKey as keyof FieldOperatorFilter]
|
|
66
|
+
switch (filterKey) {
|
|
67
|
+
case '$eq':
|
|
68
|
+
if (filterValue !== itemValue) {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
break
|
|
72
|
+
case '$ne':
|
|
73
|
+
if (filterValue === itemValue) {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
break
|
|
77
|
+
case '$in':
|
|
78
|
+
if (!(filterValue as unknown[]).includes(itemValue)) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
break
|
|
82
|
+
case '$nin':
|
|
83
|
+
if ((filterValue as unknown[]).includes(itemValue)) {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
break
|
|
87
|
+
case '$lt':
|
|
88
|
+
if ((itemValue as number) < (filterValue as number)) {
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
return false
|
|
92
|
+
case '$lte':
|
|
93
|
+
if ((itemValue as number) <= (filterValue as number)) {
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
case '$gt':
|
|
98
|
+
if ((itemValue as number) > (filterValue as number)) {
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
return false
|
|
102
|
+
case '$gte':
|
|
103
|
+
if ((itemValue as number) >= (filterValue as number)) {
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
case '$regex':
|
|
108
|
+
try {
|
|
109
|
+
if (!new RegExp(filterValue as string).test(String(itemValue))) {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Invalid regular expression for $regex filter on field '${key}': ${(e as Error).message}`,
|
|
115
|
+
{ cause: e },
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
break
|
|
119
|
+
case '$startsWith':
|
|
120
|
+
if (!(itemValue as string).startsWith(filterValue as string)) {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
break
|
|
124
|
+
case '$endsWith':
|
|
125
|
+
if (!(itemValue as string).endsWith(filterValue as string)) {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
break
|
|
129
|
+
case '$like':
|
|
130
|
+
if (!evaluateLike(itemValue as string, filterValue as string)) {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
break
|
|
134
|
+
default:
|
|
135
|
+
throw new Error(`The expression (${filterKey}) is not a supported filter operation`)
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
throw new Error(`The filter key '${filterKey}' is not a valid operation`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
throw new Error(`The filter has to be an object, got ${typeof fieldFilter} for field '${key}'`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return true
|
|
147
|
+
})
|
|
148
|
+
}
|
package/src/in-memory-store.ts
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
import type { Constructable } from '@furystack/inject'
|
|
2
2
|
import { EventHub } from '@furystack/utils'
|
|
3
3
|
import type { CreateResult, FilterType, FindOptions, PartialResult, PhysicalStore } from './models/physical-store.js'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Helper type representing all possible field filter operations
|
|
8
|
-
*/
|
|
9
|
-
type FieldOperatorFilter = {
|
|
10
|
-
$eq?: unknown
|
|
11
|
-
$ne?: unknown
|
|
12
|
-
$in?: unknown[]
|
|
13
|
-
$nin?: unknown[]
|
|
14
|
-
$gt?: unknown
|
|
15
|
-
$gte?: unknown
|
|
16
|
-
$lt?: unknown
|
|
17
|
-
$lte?: unknown
|
|
18
|
-
$startsWith?: string
|
|
19
|
-
$endsWith?: string
|
|
20
|
-
$like?: string
|
|
21
|
-
$regex?: string
|
|
22
|
-
}
|
|
4
|
+
import { selectFields } from './models/physical-store.js'
|
|
5
|
+
import { filterItems } from './filter-items.js'
|
|
23
6
|
|
|
24
7
|
export class InMemoryStore<T, TPrimaryKey extends keyof T>
|
|
25
8
|
extends EventHub<{
|
|
@@ -59,123 +42,8 @@ export class InMemoryStore<T, TPrimaryKey extends keyof T>
|
|
|
59
42
|
return Promise.resolve(item && select ? selectFields(item, ...select) : item)
|
|
60
43
|
}
|
|
61
44
|
|
|
62
|
-
private evaluateLike = (value: string, likeString: string) => {
|
|
63
|
-
const likeRegex = `^${likeString.replace(/%/g, '.*')}$`
|
|
64
|
-
return value.match(new RegExp(likeRegex, 'i'))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
private filterInternal(values: T[], filter?: FilterType<T>): T[] {
|
|
68
|
-
if (!filter) {
|
|
69
|
-
return values
|
|
70
|
-
}
|
|
71
|
-
return values.filter((item) => {
|
|
72
|
-
const filterRecord = filter as Record<string, Array<FilterType<T>> | FieldOperatorFilter | undefined>
|
|
73
|
-
const itemRecord = item as Record<string, unknown>
|
|
74
|
-
|
|
75
|
-
for (const key in filterRecord) {
|
|
76
|
-
if (isLogicalOperator(key)) {
|
|
77
|
-
const filterValue = filterRecord[key] as Array<FilterType<T>>
|
|
78
|
-
switch (key) {
|
|
79
|
-
case '$and':
|
|
80
|
-
if (filterValue.some((v: FilterType<T>) => !this.filterInternal([item], v).length)) {
|
|
81
|
-
return false
|
|
82
|
-
}
|
|
83
|
-
break
|
|
84
|
-
case '$or':
|
|
85
|
-
if (filterValue.some((v: FilterType<T>) => this.filterInternal([item], v).length)) {
|
|
86
|
-
break
|
|
87
|
-
}
|
|
88
|
-
return false
|
|
89
|
-
default:
|
|
90
|
-
throw new Error(`The logical operation '${key}' is not a valid operation`)
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
const fieldFilter = filterRecord[key] as FieldOperatorFilter | undefined
|
|
94
|
-
if (typeof fieldFilter === 'object' && fieldFilter !== null) {
|
|
95
|
-
for (const filterKey in fieldFilter) {
|
|
96
|
-
if (isOperator(filterKey)) {
|
|
97
|
-
const itemValue = itemRecord[key]
|
|
98
|
-
const filterValue = fieldFilter[filterKey as keyof FieldOperatorFilter]
|
|
99
|
-
switch (filterKey) {
|
|
100
|
-
case '$eq':
|
|
101
|
-
if (filterValue !== itemValue) {
|
|
102
|
-
return false
|
|
103
|
-
}
|
|
104
|
-
break
|
|
105
|
-
case '$ne':
|
|
106
|
-
if (filterValue === itemValue) {
|
|
107
|
-
return false
|
|
108
|
-
}
|
|
109
|
-
break
|
|
110
|
-
case '$in':
|
|
111
|
-
if (!(filterValue as unknown[]).includes(itemValue)) {
|
|
112
|
-
return false
|
|
113
|
-
}
|
|
114
|
-
break
|
|
115
|
-
|
|
116
|
-
case '$nin':
|
|
117
|
-
if ((filterValue as unknown[]).includes(itemValue)) {
|
|
118
|
-
return false
|
|
119
|
-
}
|
|
120
|
-
break
|
|
121
|
-
case '$lt':
|
|
122
|
-
if ((itemValue as number) < (filterValue as number)) {
|
|
123
|
-
break
|
|
124
|
-
}
|
|
125
|
-
return false
|
|
126
|
-
case '$lte':
|
|
127
|
-
if ((itemValue as number) <= (filterValue as number)) {
|
|
128
|
-
break
|
|
129
|
-
}
|
|
130
|
-
return false
|
|
131
|
-
case '$gt':
|
|
132
|
-
if ((itemValue as number) > (filterValue as number)) {
|
|
133
|
-
break
|
|
134
|
-
}
|
|
135
|
-
return false
|
|
136
|
-
case '$gte':
|
|
137
|
-
if ((itemValue as number) >= (filterValue as number)) {
|
|
138
|
-
break
|
|
139
|
-
}
|
|
140
|
-
return false
|
|
141
|
-
case '$regex':
|
|
142
|
-
if (!new RegExp(filterValue as string).test(String(itemValue))) {
|
|
143
|
-
return false
|
|
144
|
-
}
|
|
145
|
-
break
|
|
146
|
-
case '$startsWith':
|
|
147
|
-
if (!(itemValue as string).startsWith(filterValue as string)) {
|
|
148
|
-
return false
|
|
149
|
-
}
|
|
150
|
-
break
|
|
151
|
-
case '$endsWith':
|
|
152
|
-
if (!(itemValue as string).endsWith(filterValue as string)) {
|
|
153
|
-
return false
|
|
154
|
-
}
|
|
155
|
-
break
|
|
156
|
-
case '$like':
|
|
157
|
-
if (!this.evaluateLike(itemValue as string, filterValue as string)) {
|
|
158
|
-
return false
|
|
159
|
-
}
|
|
160
|
-
break
|
|
161
|
-
default:
|
|
162
|
-
throw new Error(`The expression (${filterKey}) is not supported by '${this.constructor.name}'!`)
|
|
163
|
-
}
|
|
164
|
-
} else {
|
|
165
|
-
throw new Error(`The filter key '${filterKey}' is not a valid operation`)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
} else {
|
|
169
|
-
throw new Error(`The filter has to be an object, got ${typeof fieldFilter} for field '${key}'`)
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return true
|
|
174
|
-
})
|
|
175
|
-
}
|
|
176
|
-
|
|
177
45
|
public async find<TFields extends Array<keyof T>>(searchOptions: FindOptions<T, TFields>) {
|
|
178
|
-
let value: Array<PartialResult<T, TFields>> =
|
|
46
|
+
let value: Array<PartialResult<T, TFields>> = filterItems([...this.cache.values()], searchOptions.filter)
|
|
179
47
|
|
|
180
48
|
if (searchOptions.order) {
|
|
181
49
|
const orderRecord = searchOptions.order as Record<string, 'ASC' | 'DESC'>
|
|
@@ -203,7 +71,7 @@ export class InMemoryStore<T, TPrimaryKey extends keyof T>
|
|
|
203
71
|
}
|
|
204
72
|
|
|
205
73
|
public async count(filter?: FilterType<T>) {
|
|
206
|
-
return
|
|
74
|
+
return filterItems([...this.cache.values()], filter).length
|
|
207
75
|
}
|
|
208
76
|
|
|
209
77
|
public async update(id: T[TPrimaryKey], data: T) {
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from './errors/index.js'
|
|
2
2
|
export * from './models/physical-store.js'
|
|
3
3
|
export * from './models/user.js'
|
|
4
|
+
export * from './filter-items.js'
|
|
4
5
|
export * from './in-memory-store.js'
|
|
5
6
|
export * from './store-manager.js'
|
|
6
7
|
export * from './global-disposables.js'
|
|
7
8
|
export * from './identity-context.js'
|
|
9
|
+
export * from './system-identity-context.js'
|
|
8
10
|
export * from './helpers.js'
|
|
@@ -88,7 +88,14 @@ export const selectFields = <T extends object, TField extends Array<keyof T>>(en
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* Interface that defines a physical store implementation
|
|
91
|
+
* Interface that defines a physical store implementation.
|
|
92
|
+
*
|
|
93
|
+
* **Important:** Writing directly to a physical store bypasses the Repository {@link DataSet} layer.
|
|
94
|
+
* This means authorization, modification hooks, and DataSet events (used by entity sync) will **not** be triggered.
|
|
95
|
+
* For any write operation that should be observable by other parts of the system (e.g. entity sync, audit logging),
|
|
96
|
+
* use the corresponding {@link DataSet} method instead via `getDataSetFor()`.
|
|
97
|
+
*
|
|
98
|
+
* @see {@link DataSet} for the authorized, event-emitting write gateway
|
|
92
99
|
*/
|
|
93
100
|
export interface PhysicalStore<
|
|
94
101
|
T,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject'
|
|
2
|
+
import { usingAsync } from '@furystack/utils'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { getCurrentUser, isAuthenticated, isAuthorized } from './helpers.js'
|
|
5
|
+
import { IdentityContext } from './identity-context.js'
|
|
6
|
+
import { SystemIdentityContext, useSystemIdentityContext } from './system-identity-context.js'
|
|
7
|
+
|
|
8
|
+
describe('SystemIdentityContext', () => {
|
|
9
|
+
it('isAuthenticated should return true', async () => {
|
|
10
|
+
const ctx = new SystemIdentityContext()
|
|
11
|
+
expect(await ctx.isAuthenticated()).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('isAuthorized should return true without roles', async () => {
|
|
15
|
+
const ctx = new SystemIdentityContext()
|
|
16
|
+
expect(await ctx.isAuthorized()).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('isAuthorized should return true with roles', async () => {
|
|
20
|
+
const ctx = new SystemIdentityContext()
|
|
21
|
+
expect(await ctx.isAuthorized('admin', 'superuser')).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('getCurrentUser should return default system user', async () => {
|
|
25
|
+
const ctx = new SystemIdentityContext()
|
|
26
|
+
const user = await ctx.getCurrentUser()
|
|
27
|
+
expect(user).toEqual({ username: 'system', roles: [] })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('getCurrentUser should respect custom username', async () => {
|
|
31
|
+
const ctx = new SystemIdentityContext({ username: 'migration-job' })
|
|
32
|
+
const user = await ctx.getCurrentUser()
|
|
33
|
+
expect(user).toEqual({ username: 'migration-job', roles: [] })
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('useSystemIdentityContext', () => {
|
|
38
|
+
it('should return a child injector, not the parent', async () => {
|
|
39
|
+
await usingAsync(new Injector(), async (parent) => {
|
|
40
|
+
const child = useSystemIdentityContext({ injector: parent })
|
|
41
|
+
expect(child).not.toBe(parent)
|
|
42
|
+
await child[Symbol.asyncDispose]()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should resolve IdentityContext to a SystemIdentityContext', async () => {
|
|
47
|
+
await usingAsync(new Injector(), async (parent) => {
|
|
48
|
+
await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
|
|
49
|
+
const ctx = child.getInstance(IdentityContext)
|
|
50
|
+
expect(ctx).toBeInstanceOf(SystemIdentityContext)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should be authenticated and authorized via helpers', async () => {
|
|
56
|
+
await usingAsync(new Injector(), async (parent) => {
|
|
57
|
+
await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
|
|
58
|
+
expect(await isAuthenticated(child)).toBe(true)
|
|
59
|
+
expect(await isAuthorized(child, 'admin')).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should return the configured username via getCurrentUser', async () => {
|
|
65
|
+
await usingAsync(new Injector(), async (parent) => {
|
|
66
|
+
await usingAsync(useSystemIdentityContext({ injector: parent, username: 'seed-script' }), async (child) => {
|
|
67
|
+
const user = await getCurrentUser(child)
|
|
68
|
+
expect(user).toEqual({ username: 'seed-script', roles: [] })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should use default username when not specified', async () => {
|
|
74
|
+
await usingAsync(new Injector(), async (parent) => {
|
|
75
|
+
await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
|
|
76
|
+
const user = await getCurrentUser(child)
|
|
77
|
+
expect(user.username).toBe('system')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should dispose the child injector after usingAsync completes', async () => {
|
|
83
|
+
await usingAsync(new Injector(), async (parent) => {
|
|
84
|
+
let childRef: Injector | undefined
|
|
85
|
+
await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
|
|
86
|
+
childRef = child
|
|
87
|
+
})
|
|
88
|
+
expect(childRef).toBeDefined()
|
|
89
|
+
expect(() => childRef!.getInstance(IdentityContext)).toThrow('Injector already disposed')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should not dispose the parent injector', async () => {
|
|
94
|
+
await usingAsync(new Injector(), async (parent) => {
|
|
95
|
+
await usingAsync(useSystemIdentityContext({ injector: parent }), async () => {
|
|
96
|
+
// no-op
|
|
97
|
+
})
|
|
98
|
+
expect(() => parent.getInstance(IdentityContext)).not.toThrow()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Injector } from '@furystack/inject'
|
|
2
|
+
|
|
3
|
+
import { IdentityContext } from './identity-context.js'
|
|
4
|
+
import type { User } from './models/user.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An elevated {@link IdentityContext} that is always authenticated and authorized.
|
|
8
|
+
* Intended for trusted server-side operations such as background jobs, migrations, and seed scripts.
|
|
9
|
+
*
|
|
10
|
+
* **Warning:** This context bypasses **all** authorization checks. Never use it in user-facing
|
|
11
|
+
* request pipelines or any context where untrusted input could reach the DataSet.
|
|
12
|
+
* Prefer {@link useSystemIdentityContext} for scoped usage with automatic cleanup.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { useSystemIdentityContext } from '@furystack/core'
|
|
17
|
+
* import { getDataSetFor } from '@furystack/repository'
|
|
18
|
+
* import { usingAsync } from '@furystack/utils'
|
|
19
|
+
*
|
|
20
|
+
* await usingAsync(
|
|
21
|
+
* useSystemIdentityContext({ injector, username: 'migration-job' }),
|
|
22
|
+
* async (systemInjector) => {
|
|
23
|
+
* const dataSet = getDataSetFor(systemInjector, MyModel, 'id')
|
|
24
|
+
* await dataSet.add(systemInjector, newEntity)
|
|
25
|
+
* },
|
|
26
|
+
* )
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export class SystemIdentityContext extends IdentityContext {
|
|
30
|
+
private readonly username: string
|
|
31
|
+
|
|
32
|
+
constructor(options?: { username?: string }) {
|
|
33
|
+
super()
|
|
34
|
+
this.username = options?.username ?? 'system'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public override isAuthenticated() {
|
|
38
|
+
return Promise.resolve(true)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public override isAuthorized(..._roles: string[]) {
|
|
42
|
+
return Promise.resolve(true)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public override getCurrentUser<TUser extends User>(): Promise<TUser> {
|
|
46
|
+
return Promise.resolve({ username: this.username, roles: [] } as unknown as TUser)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a scoped child injector with an elevated {@link SystemIdentityContext}.
|
|
52
|
+
* The returned injector is {@link AsyncDisposable} and works with `usingAsync()` for automatic cleanup.
|
|
53
|
+
*
|
|
54
|
+
* **Warning:** The returned injector bypasses **all** authorization checks. Only use this in trusted
|
|
55
|
+
* server-side contexts (background jobs, migrations, seed scripts). Never pass the returned injector
|
|
56
|
+
* to user-facing request handlers.
|
|
57
|
+
*
|
|
58
|
+
* @param options.injector The parent injector to create a child from
|
|
59
|
+
* @param options.username The username for the system identity (defaults to `'system'`)
|
|
60
|
+
* @returns A child injector with the SystemIdentityContext set as the IdentityContext
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { useSystemIdentityContext } from '@furystack/core'
|
|
65
|
+
* import { getDataSetFor } from '@furystack/repository'
|
|
66
|
+
* import { usingAsync } from '@furystack/utils'
|
|
67
|
+
*
|
|
68
|
+
* await usingAsync(
|
|
69
|
+
* useSystemIdentityContext({ injector, username: 'seed-script' }),
|
|
70
|
+
* async (systemInjector) => {
|
|
71
|
+
* const dataSet = getDataSetFor(systemInjector, MyModel, 'id')
|
|
72
|
+
* await dataSet.add(systemInjector, { value: 'seeded' })
|
|
73
|
+
* },
|
|
74
|
+
* )
|
|
75
|
+
* // systemInjector is disposed here -- all scoped instances cleaned up
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export const useSystemIdentityContext = (options: { injector: Injector; username?: string }): Injector => {
|
|
79
|
+
const ctx = new SystemIdentityContext({ username: options.username })
|
|
80
|
+
const childInjector = options.injector.createChild({ owner: 'SystemIdentityContext' })
|
|
81
|
+
childInjector.setExplicitInstance(ctx, IdentityContext)
|
|
82
|
+
return childInjector
|
|
83
|
+
}
|