@ossy/platform 1.34.0 → 1.35.1
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/package.json +19 -8
- package/src/index.js +5 -0
- package/src/server.js +8 -19
- package/src/tasks/cron.spec.js +154 -0
- package/src/tasks/glob.js +37 -0
- package/src/tasks/glob.spec.js +194 -0
- package/src/test/index.js +1 -0
- package/src/test/jest.setup.js +24 -0
- package/src/test/test.util.js +185 -0
- package/src/token.service.js +45 -0
- package/src/users.middleware.js +75 -0
- package/src/workspaces.middleware.js +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.1",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
"./tasks": "./src/index.js",
|
|
21
21
|
"./resources": "./src/resources/index.js",
|
|
22
22
|
"./definition": "./src/Definition.js",
|
|
23
|
-
"./integrations": "./src/integration.service.js"
|
|
23
|
+
"./integrations": "./src/integration.service.js",
|
|
24
|
+
"./test": "./src/test/index.js"
|
|
24
25
|
},
|
|
25
26
|
"scripts": {
|
|
26
|
-
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\""
|
|
27
|
+
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\"",
|
|
28
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
|
|
27
29
|
},
|
|
28
30
|
"keywords": [],
|
|
29
31
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
@@ -34,19 +36,28 @@
|
|
|
34
36
|
"@aws-sdk/s3-request-presigner": "^3.1057.0",
|
|
35
37
|
"@aws-sdk/util-create-request": "^3.972.26",
|
|
36
38
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
37
|
-
"@ossy/event-store": "^1.
|
|
38
|
-
"@ossy/observability": "^1.
|
|
39
|
-
"@ossy/
|
|
40
|
-
"@ossy/
|
|
39
|
+
"@ossy/event-store": "^1.4.1",
|
|
40
|
+
"@ossy/observability": "^1.4.1",
|
|
41
|
+
"@ossy/policies": "^1.9.1",
|
|
42
|
+
"@ossy/router": "^1.36.1",
|
|
43
|
+
"@ossy/sdk": "^1.36.1",
|
|
44
|
+
"@ossy/tokens": "^1.9.1",
|
|
45
|
+
"@ossy/users": "^1.9.1",
|
|
41
46
|
"cookie-parser": "^1.4.7",
|
|
42
47
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
43
48
|
"express": ">=5.0.0 <6.0.0",
|
|
49
|
+
"jsonwebtoken": "^9.0.0",
|
|
44
50
|
"mongodb": "^7.2.0",
|
|
45
51
|
"morgan": ">=1.10.1 <2.0.0"
|
|
46
52
|
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@jest/globals": "^30.2.0",
|
|
55
|
+
"casual": "^1.6.2",
|
|
56
|
+
"jest": "^30.2.0"
|
|
57
|
+
},
|
|
47
58
|
"files": [
|
|
48
59
|
"src",
|
|
49
60
|
"Dockerfile"
|
|
50
61
|
],
|
|
51
|
-
"gitHead": "
|
|
62
|
+
"gitHead": "a144d7767264d96bc6ae095784d4f884f03a89a0"
|
|
52
63
|
}
|
package/src/index.js
CHANGED
|
@@ -7,3 +7,8 @@ export { Definition } from './Definition.js'
|
|
|
7
7
|
export { IntegrationService } from './integration.service.js'
|
|
8
8
|
export { ConfigService } from './config.service.js'
|
|
9
9
|
export { ActionService } from './actions/action.service.js'
|
|
10
|
+
export { TokenService } from './token.service.js'
|
|
11
|
+
export { UsersMiddleware } from './users.middleware.js'
|
|
12
|
+
export { WorkspacesMiddleware } from './workspaces.middleware.js'
|
|
13
|
+
export { matchesCron } from './tasks/cron.js'
|
|
14
|
+
export { matchesGlob, globToRegex, policyToQueryClause } from './tasks/glob.js'
|
package/src/server.js
CHANGED
|
@@ -14,6 +14,9 @@ import { registerResourceTemplate } from './resources/resource-template.registry
|
|
|
14
14
|
import { IntegrationService } from './integration.service.js'
|
|
15
15
|
import { ActionService } from './actions/action.service.js'
|
|
16
16
|
import { createLogger } from '@ossy/observability'
|
|
17
|
+
import { ConfigService } from './config.service.js'
|
|
18
|
+
import { UsersMiddleware } from './users.middleware.js'
|
|
19
|
+
import { WorkspacesMiddleware } from './workspaces.middleware.js'
|
|
17
20
|
|
|
18
21
|
const log = createLogger('@ossy/platform')
|
|
19
22
|
|
|
@@ -204,12 +207,10 @@ export async function startServer (options = {}) {
|
|
|
204
207
|
return promise
|
|
205
208
|
}
|
|
206
209
|
|
|
207
|
-
const userMiddleware = await loadMiddleware(buildDir)
|
|
208
|
-
|
|
209
210
|
const app = express()
|
|
210
211
|
app.use(morgan('tiny'))
|
|
211
212
|
app.use(express.json({ strict: false }))
|
|
212
|
-
app.use(cookieParser(
|
|
213
|
+
app.use(cookieParser(ConfigService.TokenSecret))
|
|
213
214
|
app.use((req, _res, next) => {
|
|
214
215
|
const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
|
|
215
216
|
req.userAppSettings = userSettings
|
|
@@ -220,7 +221,8 @@ export async function startServer (options = {}) {
|
|
|
220
221
|
req.isAuthenticated = cookieHeader ? cookieHeader.includes('auth=') : false
|
|
221
222
|
next()
|
|
222
223
|
})
|
|
223
|
-
|
|
224
|
+
app.use(UsersMiddleware.AuthenticateUser)
|
|
225
|
+
app.use(WorkspacesMiddleware.ExtractWorkspaceId())
|
|
224
226
|
if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
|
|
225
227
|
app.use(ProxyInternal())
|
|
226
228
|
|
|
@@ -342,24 +344,11 @@ export async function startServer (options = {}) {
|
|
|
342
344
|
return { app, server, port, close: closeServer, lifetime }
|
|
343
345
|
}
|
|
344
346
|
|
|
345
|
-
async function loadMiddleware (buildDir) {
|
|
346
|
-
const candidates = [
|
|
347
|
-
path.resolve(buildDir, 'public', 'static', 'middleware.js'),
|
|
348
|
-
path.resolve(buildDir, 'middleware.js'),
|
|
349
|
-
]
|
|
350
|
-
for (const candidate of candidates) {
|
|
351
|
-
if (!fs.existsSync(candidate)) continue
|
|
352
|
-
const mod = await import(pathToFileURL(candidate).href)
|
|
353
|
-
const value = mod.default
|
|
354
|
-
if (Array.isArray(value)) return value
|
|
355
|
-
if (typeof value === 'function') return [value]
|
|
356
|
-
}
|
|
357
|
-
return []
|
|
358
|
-
}
|
|
359
|
-
|
|
360
347
|
export default startServer
|
|
361
348
|
export { ConfigService } from './config.service.js'
|
|
362
349
|
export { ActionService } from './actions/action.service.js'
|
|
363
350
|
export { StorageClient } from './storage/storage.client.js'
|
|
364
351
|
export { S3Client } from './storage/s3.client.js'
|
|
365
352
|
export { LocalStorageClient } from './storage/local-storage.client.js'
|
|
353
|
+
export { getSystemResourceTemplates } from './resources/resource-template.registry.js'
|
|
354
|
+
export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport } from './resources/resource-template.validation.js'
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { matchesCron } from './cron.js'
|
|
2
|
+
|
|
3
|
+
function makeDate({ minute = 0, hour = 0, dom = 1, month = 1, dow = 1 } = {}) {
|
|
4
|
+
// dow: 0=Sun, 1=Mon … 6=Sat
|
|
5
|
+
// Build a Date that has exactly the requested components in local time.
|
|
6
|
+
const d = new Date(2024, month - 1, dom, hour, minute, 0, 0)
|
|
7
|
+
// Verify the day-of-week matches what was requested (some combinations are impossible).
|
|
8
|
+
// For our tests we construct dates we know are valid, so this is just a safety check.
|
|
9
|
+
if (dow !== undefined && d.getDay() !== dow) {
|
|
10
|
+
// Shift to find a date in the same month with the desired dow (best-effort).
|
|
11
|
+
const offset = (dow - d.getDay() + 7) % 7
|
|
12
|
+
d.setDate(d.getDate() + offset)
|
|
13
|
+
}
|
|
14
|
+
return d
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('matchesCron', () => {
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Guard rails
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
it('returns false for falsy expression', () => {
|
|
24
|
+
expect(matchesCron('')).toBe(false)
|
|
25
|
+
expect(matchesCron(null)).toBe(false)
|
|
26
|
+
expect(matchesCron(undefined)).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns false for wrong field count', () => {
|
|
30
|
+
expect(matchesCron('* * * *')).toBe(false) // 4 fields
|
|
31
|
+
expect(matchesCron('* * * * * *')).toBe(false) // 6 fields
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Wildcard
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
it('"* * * * *" matches any date', () => {
|
|
39
|
+
expect(matchesCron('* * * * *', makeDate({ minute: 0, hour: 0, dom: 1, month: 1, dow: 1 }))).toBe(true)
|
|
40
|
+
expect(matchesCron('* * * * *', makeDate({ minute: 59, hour: 23, dom: 28, month: 12, dow: 5 }))).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Exact minute
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
it('"0 * * * *" matches only when minute === 0', () => {
|
|
48
|
+
expect(matchesCron('0 * * * *', makeDate({ minute: 0 }))).toBe(true)
|
|
49
|
+
expect(matchesCron('0 * * * *', makeDate({ minute: 1 }))).toBe(false)
|
|
50
|
+
expect(matchesCron('0 * * * *', makeDate({ minute: 30 }))).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('"30 * * * *" matches only when minute === 30', () => {
|
|
54
|
+
expect(matchesCron('30 * * * *', makeDate({ minute: 30 }))).toBe(true)
|
|
55
|
+
expect(matchesCron('30 * * * *', makeDate({ minute: 0 }))).toBe(false)
|
|
56
|
+
expect(matchesCron('30 * * * *', makeDate({ minute: 31 }))).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Step (*/n)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
it('"*/5 * * * *" matches minutes 0,5,10,…55', () => {
|
|
64
|
+
const matches = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
|
|
65
|
+
const nonMatch = [1, 2, 3, 4, 6, 7, 29, 31, 59]
|
|
66
|
+
|
|
67
|
+
for (const m of matches) expect(matchesCron('*/5 * * * *', makeDate({ minute: m }))).toBe(true)
|
|
68
|
+
for (const m of nonMatch) expect(matchesCron('*/5 * * * *', makeDate({ minute: m }))).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('"*/15 * * * *" matches minutes 0,15,30,45', () => {
|
|
72
|
+
expect(matchesCron('*/15 * * * *', makeDate({ minute: 0 }))).toBe(true)
|
|
73
|
+
expect(matchesCron('*/15 * * * *', makeDate({ minute: 15 }))).toBe(true)
|
|
74
|
+
expect(matchesCron('*/15 * * * *', makeDate({ minute: 30 }))).toBe(true)
|
|
75
|
+
expect(matchesCron('*/15 * * * *', makeDate({ minute: 45 }))).toBe(true)
|
|
76
|
+
expect(matchesCron('*/15 * * * *', makeDate({ minute: 1 }))).toBe(false)
|
|
77
|
+
expect(matchesCron('*/15 * * * *', makeDate({ minute: 16 }))).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Exact hour
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
it('"0 9 * * *" matches 09:00 of any day', () => {
|
|
85
|
+
expect(matchesCron('0 9 * * *', makeDate({ minute: 0, hour: 9 }))).toBe(true)
|
|
86
|
+
expect(matchesCron('0 9 * * *', makeDate({ minute: 1, hour: 9 }))).toBe(false)
|
|
87
|
+
expect(matchesCron('0 9 * * *', makeDate({ minute: 0, hour: 10 }))).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Day-of-week
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
it('"0 9 * * 1" matches 09:00 on Mondays only', () => {
|
|
95
|
+
// dow=1 → Monday
|
|
96
|
+
const monday = makeDate({ minute: 0, hour: 9, dow: 1 })
|
|
97
|
+
const tuesday = makeDate({ minute: 0, hour: 9, dow: 2 })
|
|
98
|
+
const sunday = makeDate({ minute: 0, hour: 9, dow: 0 })
|
|
99
|
+
|
|
100
|
+
expect(matchesCron('0 9 * * 1', monday)).toBe(true)
|
|
101
|
+
expect(matchesCron('0 9 * * 1', tuesday)).toBe(false)
|
|
102
|
+
expect(matchesCron('0 9 * * 1', sunday)).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Range (a-b)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
it('"0 9-17 * * *" matches hours 9 through 17', () => {
|
|
110
|
+
for (const h of [9, 10, 11, 12, 17]) {
|
|
111
|
+
expect(matchesCron('0 9-17 * * *', makeDate({ minute: 0, hour: h }))).toBe(true)
|
|
112
|
+
}
|
|
113
|
+
expect(matchesCron('0 9-17 * * *', makeDate({ minute: 0, hour: 8 }))).toBe(false)
|
|
114
|
+
expect(matchesCron('0 9-17 * * *', makeDate({ minute: 0, hour: 18 }))).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Comma-separated list
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
it('"0,30 * * * *" matches minutes 0 and 30', () => {
|
|
122
|
+
expect(matchesCron('0,30 * * * *', makeDate({ minute: 0 }))).toBe(true)
|
|
123
|
+
expect(matchesCron('0,30 * * * *', makeDate({ minute: 30 }))).toBe(true)
|
|
124
|
+
expect(matchesCron('0,30 * * * *', makeDate({ minute: 15 }))).toBe(false)
|
|
125
|
+
expect(matchesCron('0,30 * * * *', makeDate({ minute: 31 }))).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Month
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
it('"0 0 1 1 *" matches only 00:00 on Jan 1', () => {
|
|
133
|
+
const jan1 = makeDate({ minute: 0, hour: 0, dom: 1, month: 1 })
|
|
134
|
+
const jan2 = makeDate({ minute: 0, hour: 0, dom: 2, month: 1 })
|
|
135
|
+
const feb1 = makeDate({ minute: 0, hour: 0, dom: 1, month: 2 })
|
|
136
|
+
|
|
137
|
+
expect(matchesCron('0 0 1 1 *', jan1)).toBe(true)
|
|
138
|
+
expect(matchesCron('0 0 1 1 *', jan2)).toBe(false)
|
|
139
|
+
expect(matchesCron('0 0 1 1 *', feb1)).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Range step (a-b/n)
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
it('"10-50/20 * * * *" matches minutes 10, 30, 50', () => {
|
|
147
|
+
expect(matchesCron('10-50/20 * * * *', makeDate({ minute: 10 }))).toBe(true)
|
|
148
|
+
expect(matchesCron('10-50/20 * * * *', makeDate({ minute: 30 }))).toBe(true)
|
|
149
|
+
expect(matchesCron('10-50/20 * * * *', makeDate({ minute: 50 }))).toBe(true)
|
|
150
|
+
expect(matchesCron('10-50/20 * * * *', makeDate({ minute: 0 }))).toBe(false)
|
|
151
|
+
expect(matchesCron('10-50/20 * * * *', makeDate({ minute: 20 }))).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
})
|
package/src/tasks/glob.js
CHANGED
|
@@ -10,3 +10,40 @@ export function matchesGlob(pattern, value) {
|
|
|
10
10
|
.replace(/§§/g, '.*')
|
|
11
11
|
return new RegExp(`^${escaped}$`).test(value)
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Converts a glob pattern to a RegExp (anchored at both ends).
|
|
16
|
+
* ** → .* (any characters including /)
|
|
17
|
+
* * → [^/]* (any characters except /)
|
|
18
|
+
*/
|
|
19
|
+
export function globToRegex(pattern) {
|
|
20
|
+
const escaped = pattern
|
|
21
|
+
.replace(/\./g, '\\.')
|
|
22
|
+
.replace(/\*\*/g, '§§') // placeholder to protect ** before replacing *
|
|
23
|
+
.replace(/\*/g, '[^/]*')
|
|
24
|
+
.replace(/§§/g, '.*')
|
|
25
|
+
return new RegExp('^' + escaped + '$')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Converts a Policy aggregate document into a MongoDB query clause that matches
|
|
30
|
+
* restricted resources the policy grants read access to.
|
|
31
|
+
* Returns null if the policy does not grant resource:read.
|
|
32
|
+
*/
|
|
33
|
+
export function policyToQueryClause(policy) {
|
|
34
|
+
const { where, actions, effect } = policy.state
|
|
35
|
+
if (effect !== 'allow' || !actions.includes('resource:read')) return null
|
|
36
|
+
|
|
37
|
+
const clause = { 'state.access': 'restricted' }
|
|
38
|
+
|
|
39
|
+
if (where.workspace && where.workspace !== '*')
|
|
40
|
+
clause['state.belongsTo'] = where.workspace
|
|
41
|
+
|
|
42
|
+
if (where.location && where.location !== '*')
|
|
43
|
+
clause['state.location'] = { $regex: globToRegex(where.location).source }
|
|
44
|
+
|
|
45
|
+
if (where.type && where.type !== '*')
|
|
46
|
+
clause['state.type'] = { $regex: globToRegex(where.type).source }
|
|
47
|
+
|
|
48
|
+
return clause
|
|
49
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { matchesGlob, globToRegex, policyToQueryClause } from './glob.js'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// matchesGlob
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
describe('matchesGlob', () => {
|
|
8
|
+
|
|
9
|
+
describe('image/* pattern', () => {
|
|
10
|
+
it('matches image/jpeg', () => {
|
|
11
|
+
expect(matchesGlob('image/*', 'image/jpeg')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('matches image/png', () => {
|
|
15
|
+
expect(matchesGlob('image/*', 'image/png')).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not match image/jpeg/extra (two levels deep)', () => {
|
|
19
|
+
expect(matchesGlob('image/*', 'image/jpeg/extra')).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('* wildcard', () => {
|
|
24
|
+
it('matches any single-segment value', () => {
|
|
25
|
+
expect(matchesGlob('*', 'hello')).toBe(true)
|
|
26
|
+
expect(matchesGlob('*', 'anything')).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('/uploads/** pattern', () => {
|
|
31
|
+
it('matches /uploads/foo/bar/baz.jpg (multi-level)', () => {
|
|
32
|
+
expect(matchesGlob('/uploads/**', '/uploads/foo/bar/baz.jpg')).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('/uploads/* pattern', () => {
|
|
37
|
+
it('does not match /uploads/foo/bar (two levels deep)', () => {
|
|
38
|
+
expect(matchesGlob('/uploads/*', '/uploads/foo/bar')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('exact strings', () => {
|
|
43
|
+
it('matches itself', () => {
|
|
44
|
+
expect(matchesGlob('image/jpeg', 'image/jpeg')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not match a different exact string', () => {
|
|
48
|
+
expect(matchesGlob('image/jpeg', 'image/png')).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('edge cases', () => {
|
|
53
|
+
it('returns false when pattern is empty/falsy', () => {
|
|
54
|
+
expect(matchesGlob('', 'image/jpeg')).toBe(false)
|
|
55
|
+
expect(matchesGlob(null, 'image/jpeg')).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns false when value is null or undefined', () => {
|
|
59
|
+
expect(matchesGlob('image/*', null)).toBe(false)
|
|
60
|
+
expect(matchesGlob('image/*', undefined)).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// globToRegex
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('globToRegex', () => {
|
|
71
|
+
|
|
72
|
+
it('returns a RegExp', () => {
|
|
73
|
+
expect(globToRegex('image/*')).toBeInstanceOf(RegExp)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('image/* pattern', () => {
|
|
77
|
+
it('matches image/jpeg', () => {
|
|
78
|
+
expect(globToRegex('image/*').test('image/jpeg')).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not match video/mp4', () => {
|
|
82
|
+
expect(globToRegex('image/*').test('video/mp4')).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('/docs/** pattern', () => {
|
|
87
|
+
it('matches /docs/ (just the prefix)', () => {
|
|
88
|
+
expect(globToRegex('/docs/**').test('/docs/')).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('matches /docs/foo (one level deep)', () => {
|
|
92
|
+
expect(globToRegex('/docs/**').test('/docs/foo')).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('matches /docs/foo/bar (two levels deep)', () => {
|
|
96
|
+
expect(globToRegex('/docs/**').test('/docs/foo/bar')).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('/docs/* pattern', () => {
|
|
101
|
+
it('matches /docs/foo (one level deep)', () => {
|
|
102
|
+
expect(globToRegex('/docs/*').test('/docs/foo')).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not match /docs/foo/bar (two levels deep)', () => {
|
|
106
|
+
expect(globToRegex('/docs/*').test('/docs/foo/bar')).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// policyToQueryClause
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
describe('policyToQueryClause', () => {
|
|
117
|
+
|
|
118
|
+
function makePolicy({ effect = 'allow', actions = ['resource:read'], where = {} } = {}) {
|
|
119
|
+
return {
|
|
120
|
+
state: {
|
|
121
|
+
effect,
|
|
122
|
+
actions,
|
|
123
|
+
where: {
|
|
124
|
+
workspace: '*',
|
|
125
|
+
location: '*',
|
|
126
|
+
type: '*',
|
|
127
|
+
...where,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('returns null when effect is not allow', () => {
|
|
134
|
+
expect(policyToQueryClause(makePolicy({ effect: 'deny' }))).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('returns null when actions do not include resource:read', () => {
|
|
138
|
+
expect(policyToQueryClause(makePolicy({ actions: ['resource:write'] }))).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('always includes state.access: restricted', () => {
|
|
142
|
+
const clause = policyToQueryClause(makePolicy())
|
|
143
|
+
expect(clause['state.access']).toBe('restricted')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('workspace field', () => {
|
|
147
|
+
it('omits state.belongsTo when workspace is *', () => {
|
|
148
|
+
const clause = policyToQueryClause(makePolicy({ where: { workspace: '*' } }))
|
|
149
|
+
expect(clause['state.belongsTo']).toBeUndefined()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('adds state.belongsTo when workspace is a specific id', () => {
|
|
153
|
+
const clause = policyToQueryClause(makePolicy({ where: { workspace: 'ws-abc' } }))
|
|
154
|
+
expect(clause['state.belongsTo']).toBe('ws-abc')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('location field', () => {
|
|
159
|
+
it('omits state.location when location is *', () => {
|
|
160
|
+
const clause = policyToQueryClause(makePolicy({ where: { location: '*' } }))
|
|
161
|
+
expect(clause['state.location']).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('adds state.location.$regex when location is a glob', () => {
|
|
165
|
+
const clause = policyToQueryClause(makePolicy({ where: { location: '/uploads/**' } }))
|
|
166
|
+
expect(clause['state.location']).toEqual({ $regex: expect.any(String) })
|
|
167
|
+
expect(clause['state.location'].$regex).toBe(globToRegex('/uploads/**').source)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('type field', () => {
|
|
172
|
+
it('omits state.type when type is *', () => {
|
|
173
|
+
const clause = policyToQueryClause(makePolicy({ where: { type: '*' } }))
|
|
174
|
+
expect(clause['state.type']).toBeUndefined()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('adds state.type.$regex when type is a glob', () => {
|
|
178
|
+
const clause = policyToQueryClause(makePolicy({ where: { type: 'image/*' } }))
|
|
179
|
+
expect(clause['state.type']).toEqual({ $regex: expect.any(String) })
|
|
180
|
+
expect(clause['state.type'].$regex).toBe(globToRegex('image/*').source)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('combines workspace, location, and type filters in a single clause', () => {
|
|
185
|
+
const clause = policyToQueryClause(makePolicy({
|
|
186
|
+
where: { workspace: 'ws-xyz', location: '/assets/**', type: 'image/*' },
|
|
187
|
+
}))
|
|
188
|
+
expect(clause['state.access']).toBe('restricted')
|
|
189
|
+
expect(clause['state.belongsTo']).toBe('ws-xyz')
|
|
190
|
+
expect(clause['state.location'].$regex).toBe(globToRegex('/assets/**').source)
|
|
191
|
+
expect(clause['state.type'].$regex).toBe(globToRegex('image/*').source)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './test.util.js'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests run in Node on the host. Mongo in Docker is often a single-node replica set
|
|
3
|
+
* whose persisted config advertises a hostname that only resolves inside Docker (e.g.
|
|
4
|
+
* host.docker.internal). Without directConnection, the driver discovers that host and fails with
|
|
5
|
+
* ENOTFOUND on the host.
|
|
6
|
+
*
|
|
7
|
+
* We always normalize DB_URL for Jest (except mongodb+srv and except when directConnection is
|
|
8
|
+
* already set).
|
|
9
|
+
*/
|
|
10
|
+
function jestMongoUrl() {
|
|
11
|
+
const url = process.env.DB_URL
|
|
12
|
+
if (!url) {
|
|
13
|
+
return 'mongodb://127.0.0.1:27017/?directConnection=true'
|
|
14
|
+
}
|
|
15
|
+
if (/^mongodb\+srv:/i.test(url)) {
|
|
16
|
+
return url
|
|
17
|
+
}
|
|
18
|
+
if (/[?&]directConnection=/i.test(url)) {
|
|
19
|
+
return url
|
|
20
|
+
}
|
|
21
|
+
return url.includes('?') ? `${url}&directConnection=true` : `${url}?directConnection=true`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process.env.DB_URL = jestMongoUrl()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import casual from 'casual'
|
|
2
|
+
import { EventStore } from '@ossy/event-store'
|
|
3
|
+
|
|
4
|
+
/** Requires Node 18+ (global `fetch`). */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Native `fetch` often omits `Set-Cookie` from `headers.get()`; use `getSetCookie()` when present.
|
|
8
|
+
*/
|
|
9
|
+
export function getSetCookieHeader(response) {
|
|
10
|
+
const { headers } = response
|
|
11
|
+
if (typeof headers.getSetCookie === 'function') {
|
|
12
|
+
return headers.getSetCookie().join('; ')
|
|
13
|
+
}
|
|
14
|
+
return headers.get('set-cookie') ?? ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base URL for HTTP integration tests (no trailing slash).
|
|
19
|
+
* With Docker Compose, the API is usually published on host **3001** → set:
|
|
20
|
+
* `API_TEST_BASE_URL=http://localhost:3001/api/v0`
|
|
21
|
+
*/
|
|
22
|
+
export function getApiTestBaseUrl() {
|
|
23
|
+
return process.env.API_TEST_BASE_URL ?? 'http://localhost:3000/api/v0'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const baseUrl = /* lazy */ () => getApiTestBaseUrl()
|
|
27
|
+
|
|
28
|
+
export class TestUtil {
|
|
29
|
+
|
|
30
|
+
/** JSON body for POST /users/sign-up (matches AuthService). */
|
|
31
|
+
static signUpBody({ email = casual.email, firstName = 'Test', lastName = 'User' } = {}) {
|
|
32
|
+
return JSON.stringify({ email, firstName, lastName })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static AssertResponse(test) {
|
|
36
|
+
return fetch(
|
|
37
|
+
`${baseUrl()}${test.endpoint}`,
|
|
38
|
+
{
|
|
39
|
+
method: test.method,
|
|
40
|
+
headers: test.headers,
|
|
41
|
+
body: test.body
|
|
42
|
+
}
|
|
43
|
+
).then(response => {
|
|
44
|
+
expect(response.status).toBe(test.expectedResponseStatus)
|
|
45
|
+
|
|
46
|
+
return response.json()
|
|
47
|
+
.then(data => expect(data).toEqual(test.expectedResponseBody))
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static AssertAuthenticationNeeded(request) {
|
|
52
|
+
describe('given no auth token is provided', () => {
|
|
53
|
+
it('must return 401 Unauthorized', async () => {
|
|
54
|
+
const response = await TestUtil.MakeRequest(request)
|
|
55
|
+
expect(response.status).toEqual(401)
|
|
56
|
+
await expect(response.json()).resolves.toMatch('');
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static MakeRequest(request) {
|
|
62
|
+
return fetch(
|
|
63
|
+
`${baseUrl()}${request.endpoint}`,
|
|
64
|
+
{
|
|
65
|
+
method: request.method,
|
|
66
|
+
headers: request.headers,
|
|
67
|
+
body: request.body
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static AssertEventExist(query) {
|
|
73
|
+
return EventStore.FindEvent(query)
|
|
74
|
+
.then(event => {
|
|
75
|
+
expect(!!event).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static GetEvent(query) {
|
|
80
|
+
return EventStore.FindEvent(query)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static GetEvents(query) {
|
|
84
|
+
return EventStore.FindEvents(query)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** JWT from the latest Verification token aggregate for this user (sign-up or sign-in request). */
|
|
88
|
+
static countVerificationTokenEvents() {
|
|
89
|
+
return EventStore.Collection.countDocuments({
|
|
90
|
+
aggregateType: 'Token',
|
|
91
|
+
type: 'Created',
|
|
92
|
+
'payload.type': 'Verification',
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static async getLatestApiTokenCreatedEventForSubject(subjectId) {
|
|
97
|
+
const ev = await EventStore.Collection.findOne(
|
|
98
|
+
{
|
|
99
|
+
aggregateType: 'Token',
|
|
100
|
+
type: 'Created',
|
|
101
|
+
'payload.type': 'Api',
|
|
102
|
+
'payload.subject': subjectId,
|
|
103
|
+
},
|
|
104
|
+
{ sort: { created: -1 } }
|
|
105
|
+
)
|
|
106
|
+
if (!ev) {
|
|
107
|
+
throw new Error('No Api token Created event for subject')
|
|
108
|
+
}
|
|
109
|
+
return ev
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static async getLatestVerificationJwtForSubject(subjectId) {
|
|
113
|
+
const ev = await EventStore.Collection.findOne(
|
|
114
|
+
{
|
|
115
|
+
aggregateType: 'Token',
|
|
116
|
+
type: 'Created',
|
|
117
|
+
'payload.type': 'Verification',
|
|
118
|
+
'payload.subject': subjectId,
|
|
119
|
+
},
|
|
120
|
+
{ sort: { created: -1 } }
|
|
121
|
+
)
|
|
122
|
+
if (!ev?.payload?.token) {
|
|
123
|
+
return Promise.reject(new Error('No verification token found for subject'))
|
|
124
|
+
}
|
|
125
|
+
return ev.payload.token
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
static GetVerificationToken() {
|
|
129
|
+
const email = `${casual.email}`
|
|
130
|
+
|
|
131
|
+
return fetch(
|
|
132
|
+
`${baseUrl()}/users/sign-up`,
|
|
133
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: TestUtil.signUpBody({ email }) }
|
|
134
|
+
)
|
|
135
|
+
.then(() => EventStore.FindEvent({
|
|
136
|
+
aggregateType: 'User',
|
|
137
|
+
type: { $in: [ 'SignedUp' ] },
|
|
138
|
+
'payload.email': email
|
|
139
|
+
}))
|
|
140
|
+
.then(event => event.payload.verificationToken)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static async GetAuthenticatedTestUser(email = casual.email) {
|
|
144
|
+
|
|
145
|
+
await TestUtil.AssertResponse({
|
|
146
|
+
endpoint: '/users/sign-up',
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json'},
|
|
149
|
+
body: TestUtil.signUpBody({ email }),
|
|
150
|
+
expectedResponseStatus: 200,
|
|
151
|
+
expectedResponseBody: ''
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const signedUpEvent = await TestUtil.GetEvent({
|
|
155
|
+
aggregateType: 'User',
|
|
156
|
+
type: { $in: [ 'SignedUp' ] },
|
|
157
|
+
'payload.email': email
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const verificationJwt = await TestUtil.getLatestVerificationJwtForSubject(signedUpEvent.aggregateId)
|
|
161
|
+
|
|
162
|
+
await fetch(
|
|
163
|
+
`${baseUrl()}/users/verify-sign-in?token=${verificationJwt}`,
|
|
164
|
+
{ method: 'GET' }
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const signInVerifiedEvent = await TestUtil.GetEvent({
|
|
168
|
+
aggregateType: 'User',
|
|
169
|
+
aggregateId: signedUpEvent.aggregateId,
|
|
170
|
+
type: { $in: [ 'SignInVerified' ] },
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
id: signedUpEvent.aggregateId,
|
|
175
|
+
token: signInVerifiedEvent.payload.token,
|
|
176
|
+
email: email,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
static CloseDbConnection() {
|
|
182
|
+
return EventStore.CloseDbConnection()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import jwt from 'jsonwebtoken'
|
|
3
|
+
import { ConfigService } from './config.service.js'
|
|
4
|
+
import { createLogger } from '@ossy/observability'
|
|
5
|
+
|
|
6
|
+
const log = createLogger('tokens')
|
|
7
|
+
|
|
8
|
+
export class TokenService {
|
|
9
|
+
|
|
10
|
+
static new(payload = {}) {
|
|
11
|
+
const secret = ConfigService.TokenSecret
|
|
12
|
+
const expiresIn = payload?.expiresIn || ConfigService.TokenValidity
|
|
13
|
+
return jwt.sign({ ...payload }, secret, { expiresIn })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static newApiToken(workspaceId) {
|
|
17
|
+
const secret = ConfigService.TokenSecret
|
|
18
|
+
const expiresIn = ConfigService.TokenValidity
|
|
19
|
+
return jwt.sign({ workspaceId }, secret, { expiresIn })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static verify(token) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
|
|
25
|
+
if (!token) {
|
|
26
|
+
log.debug('[TokenService] No token to verify')
|
|
27
|
+
return reject(new Error('No token'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
jwt.verify(token, ConfigService.TokenSecret, { algorithms: ['HS256'] }, (error, payload) => {
|
|
31
|
+
const errorType = (error || {}).name
|
|
32
|
+
|
|
33
|
+
if (errorType) {
|
|
34
|
+
log.error('[TokenService]: Token invalid', undefined, error)
|
|
35
|
+
return reject()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
log.info('[TokenService]: Token verified')
|
|
39
|
+
resolve(payload)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { TokenService } from './token.service.js'
|
|
2
|
+
import { createLogger } from '@ossy/observability'
|
|
3
|
+
import { Aggregate } from '@ossy/event-store'
|
|
4
|
+
import { User } from '@ossy/users'
|
|
5
|
+
import { Token } from '@ossy/tokens'
|
|
6
|
+
import { ConfigService } from './config.service.js'
|
|
7
|
+
import { PoliciesQueries } from '@ossy/policies'
|
|
8
|
+
|
|
9
|
+
const log = createLogger('users')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Express middleware for user authentication and context resolution.
|
|
13
|
+
* @class
|
|
14
|
+
*/
|
|
15
|
+
export class UsersMiddleware {
|
|
16
|
+
|
|
17
|
+
/** Reject revoked API tokens (JWT alone stays valid until expiry). */
|
|
18
|
+
static assertApiTokenActive(payload) {
|
|
19
|
+
if (payload.type !== 'Api' || !payload.jti) {
|
|
20
|
+
return Promise.resolve(payload)
|
|
21
|
+
}
|
|
22
|
+
return Aggregate.Of(Token, payload.jti)
|
|
23
|
+
.then(aggregate => {
|
|
24
|
+
const view = Token.View(aggregate.events, aggregate.state)
|
|
25
|
+
if (view.status === 'Revoked') {
|
|
26
|
+
return Promise.reject(new Error('Api token revoked'))
|
|
27
|
+
}
|
|
28
|
+
return payload
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the caller to a real user (with workspaces and policies attached)
|
|
34
|
+
* or to the anonymous principal. Authorization / role checks belong in a separate step.
|
|
35
|
+
*/
|
|
36
|
+
static AuthenticateUser (req, res, next) {
|
|
37
|
+
const authToken = req.signedCookies.auth
|
|
38
|
+
|| req.get('Authorization')
|
|
39
|
+
|
|
40
|
+
const asAnonymous = () => {
|
|
41
|
+
req.userId = ConfigService.AnonymousUserId
|
|
42
|
+
req.user = { id: 'anonymous', anonymous: true, workspaces: [], policies: [] }
|
|
43
|
+
next()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!authToken) {
|
|
47
|
+
log.debug('[UsersMiddleware] No auth token; anonymous')
|
|
48
|
+
return asAnonymous()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
log.info('[UsersMiddleware] Authenticating')
|
|
52
|
+
log.debug('[UsersMiddleware] authToken', { authToken })
|
|
53
|
+
|
|
54
|
+
TokenService.verify(authToken)
|
|
55
|
+
.then(UsersMiddleware.assertApiTokenActive)
|
|
56
|
+
.then(({ sub }) => Aggregate.Of(User, sub))
|
|
57
|
+
.then(Aggregate.View())
|
|
58
|
+
.then(user => {
|
|
59
|
+
const workspaces = user.workspaces ?? []
|
|
60
|
+
|
|
61
|
+
return PoliciesQueries.GetPoliciesForUser(user.id)
|
|
62
|
+
.then(policies => {
|
|
63
|
+
log.info('[UsersMiddleware] Resolved user')
|
|
64
|
+
req.userId = user.id
|
|
65
|
+
req.user = { ...user, workspaces, policies }
|
|
66
|
+
next()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
.catch(error => {
|
|
70
|
+
log.debug('[UsersMiddleware] Auth failed; anonymous', { error })
|
|
71
|
+
asAnonymous()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class WorkspacesMiddleware {
|
|
2
|
+
|
|
3
|
+
static ExtractWorkspaceId () {
|
|
4
|
+
return (req, res, next) => {
|
|
5
|
+
const raw = req.params.workspaceId || req.get('workspaceId')
|
|
6
|
+
// Duplicate headers are merged as `id, id` — take the first segment.
|
|
7
|
+
const workspaceId = raw ? String(raw).split(',')[0].trim() : null
|
|
8
|
+
|
|
9
|
+
// req.get('workspaceId') can return the string "undefined" when the header is absent
|
|
10
|
+
if (!workspaceId || workspaceId === 'undefined') {
|
|
11
|
+
return next()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
req.workspaceId = workspaceId
|
|
15
|
+
return next()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
}
|