@ossy/platform 1.34.0 → 1.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
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.3.0",
38
- "@ossy/observability": "^1.3.0",
39
- "@ossy/router": "^1.35.0",
40
- "@ossy/sdk": "^1.35.0",
39
+ "@ossy/event-store": "^1.4.0",
40
+ "@ossy/observability": "^1.4.0",
41
+ "@ossy/policies": "^1.9.0",
42
+ "@ossy/router": "^1.36.0",
43
+ "@ossy/sdk": "^1.36.0",
44
+ "@ossy/tokens": "^1.9.0",
45
+ "@ossy/users": "^1.9.0",
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": "c6697078268867d88553ca0bac08faad5cea1546"
62
+ "gitHead": "24df2bde0d5d8794c5a82b9e802a2594ca73a5d9"
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(process.env.OSSY_COOKIE_SECRET || 'default_secret'))
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
- for (const mw of userMiddleware) app.use(mw)
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,21 +344,6 @@ 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'
@@ -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
+ }