@ossy/platform 1.33.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.33.0",
3
+ "version": "1.35.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,29 +20,44 @@
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)",
30
32
  "license": "MIT",
31
33
  "dependencies": {
34
+ "@aws-sdk/client-s3": "^3.1057.0",
32
35
  "@aws-sdk/client-ses": "^3.0.0",
33
- "@ossy/event-store": "^1.2.0",
34
- "@ossy/observability": "^1.2.0",
35
- "@ossy/router": "^1.34.0",
36
- "@ossy/sdk": "^1.34.0",
36
+ "@aws-sdk/s3-request-presigner": "^3.1057.0",
37
+ "@aws-sdk/util-create-request": "^3.972.26",
38
+ "@aws-sdk/util-format-url": "^3.972.17",
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",
37
46
  "cookie-parser": "^1.4.7",
38
47
  "dotenv": ">=16.0.0 <18.0.0",
39
48
  "express": ">=5.0.0 <6.0.0",
49
+ "jsonwebtoken": "^9.0.0",
40
50
  "mongodb": "^7.2.0",
41
51
  "morgan": ">=1.10.1 <2.0.0"
42
52
  },
53
+ "devDependencies": {
54
+ "@jest/globals": "^30.2.0",
55
+ "casual": "^1.6.2",
56
+ "jest": "^30.2.0"
57
+ },
43
58
  "files": [
44
59
  "src",
45
60
  "Dockerfile"
46
61
  ],
47
- "gitHead": "46cdd391ec01ea9210543ee7b14661f77079a7e7"
62
+ "gitHead": "24df2bde0d5d8794c5a82b9e802a2594ca73a5d9"
48
63
  }
@@ -0,0 +1,73 @@
1
+ import { createLogger } from '@ossy/observability'
2
+
3
+ const log = createLogger('platform/actions')
4
+
5
+ /** @type {Map<string, { id: string, access: string, run: Function }>} */
6
+ const _actions = new Map()
7
+
8
+ /**
9
+ * Registry and invoker for `*.action.js` command handlers.
10
+ *
11
+ * Actions are named, discoverable functions exposed at `POST /actions/:id`.
12
+ * Each action exports:
13
+ * - `id` {string} — unique slug, e.g. `'authentication/request-sign-in'`
14
+ * - `access` {string} — `'public'` | `'authenticated'` | `'workspace'` (default `'authenticated'`)
15
+ * - `run` {Function} — async handler receiving `{ payload, sdk, log, integrations, req }`
16
+ */
17
+ export const ActionService = {
18
+ /**
19
+ * Register an action module (as imported from a bundled entry).
20
+ * Validates that `id` is a non-empty string and `run` is a function.
21
+ *
22
+ * @param {{ id: string, access?: string, run: Function }} mod
23
+ */
24
+ register (mod) {
25
+ const { id, run, access = 'authenticated' } = mod ?? {}
26
+
27
+ if (typeof id !== 'string' || id.trim() === '') {
28
+ throw new Error(`[ActionService] Action module must export a non-empty string "id" (got ${JSON.stringify(id)})`)
29
+ }
30
+ if (typeof run !== 'function') {
31
+ throw new Error(`[ActionService] Action "${id}" must export a "run" function`)
32
+ }
33
+
34
+ if (_actions.has(id)) {
35
+ log.warn(`[ActionService] Action "${id}" already registered — overwriting`)
36
+ }
37
+
38
+ _actions.set(id, { id, access, run })
39
+ log.info(`[ActionService] Registered action "${id}" (access: ${access})`)
40
+ },
41
+
42
+ /**
43
+ * Look up a registered action by id. Returns `null` when not found.
44
+ *
45
+ * @param {string} id
46
+ * @returns {{ id: string, access: string, run: Function } | null}
47
+ */
48
+ get (id) {
49
+ return _actions.get(id) ?? null
50
+ },
51
+
52
+ /**
53
+ * Returns all registered actions.
54
+ *
55
+ * @returns {{ id: string, access: string, run: Function }[]}
56
+ */
57
+ all () {
58
+ return [..._actions.values()]
59
+ },
60
+
61
+ /**
62
+ * Invoke an action by id, forwarding the provided context.
63
+ *
64
+ * @param {string} id
65
+ * @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
66
+ * @returns {Promise<unknown>}
67
+ */
68
+ async invoke (id, context = {}) {
69
+ const action = _actions.get(id)
70
+ if (!action) throw new Error(`[ActionService] Action not found: "${id}"`)
71
+ return action.run(context)
72
+ },
73
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * API Configuration
3
+ * @class
4
+ */
5
+ export class ConfigService {
6
+
7
+ static Port = process.env.BE_PORT || 3000
8
+ static Domain = process.env.DOMAIN || `localhost:${ConfigService.Port}`
9
+ static WebClientDomain = process.env.WEB_CLIENT_DOMAIN || `http://localhost:3000`
10
+ static MongoUrl = process.env.DB_URL || 'mongodb://mongodb:27017/'
11
+ static DbName = process.env.DB_NAME || 'test'
12
+ static TokenSecret = process.env.TOKEN_SECRET || 'testsecret2'
13
+ static TokenValidity = process.env.TOKEN_VALIDITY || 60 * 60 * 24 * 14
14
+ static LimitedAccessCode = process.env.LIMITED_ACCESS_CODE || 'test'
15
+ static BuildEnvironment = process.env.BUILD_ENVIRONMENT
16
+ static BotUserEmail = process.env.BOT_USER_EMAIL || 'ossybot@ossy.se'
17
+ static BotUserId = process.env.BOT_USER_ID || 'Mil5qAL7jDFCTyuKD_BKb'
18
+ /** Set on `req.userId` when there is no valid session; role checks can treat this as unauthenticated. */
19
+ static AnonymousUserId = process.env.ANONYMOUS_USER_ID || 'anonymous'
20
+ static MediaRepository = process.env.MEDIA_REPOSITORY
21
+ static MediaCdnDomainName = process.env.MEDIA_CDN_DOMAIN_NAME
22
+ static awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID
23
+ static awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY
24
+ static SesRegion = process.env.SES_REGION || 'eu-north-1'
25
+ static Debug = process.env.DEBUG
26
+
27
+ /**
28
+ * Derives the web client base URL from the incoming request's Origin or Referer
29
+ * header so that email links point back to whichever domain the user came from
30
+ * (e.g. http://ossy.local, http://localhost:3002). Falls back to the
31
+ * WEB_CLIENT_DOMAIN env var / default when no header is present.
32
+ */
33
+ static getWebClientBaseUrl(req) {
34
+ const origin = req?.headers?.origin
35
+ if (origin && origin !== 'null') return origin
36
+
37
+ const referer = req?.headers?.referer
38
+ if (referer) {
39
+ try {
40
+ const { origin: refOrigin } = new URL(referer)
41
+ if (refOrigin && refOrigin !== 'null') return refOrigin
42
+ } catch {}
43
+ }
44
+
45
+ return ConfigService.WebClientDomain
46
+ }
47
+
48
+ }
package/src/index.js CHANGED
@@ -5,3 +5,10 @@ export { registerResourceTemplate, getSystemResourceTemplates } from './resource
5
5
  export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './resources/resource-template.validation.js'
6
6
  export { Definition } from './Definition.js'
7
7
  export { IntegrationService } from './integration.service.js'
8
+ export { ConfigService } from './config.service.js'
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
@@ -12,7 +12,11 @@ import { TaskService } from './tasks/task-service.js'
12
12
  import { ChangeStream } from './tasks/change-stream.js'
13
13
  import { registerResourceTemplate } from './resources/resource-template.registry.js'
14
14
  import { IntegrationService } from './integration.service.js'
15
+ import { ActionService } from './actions/action.service.js'
15
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'
16
20
 
17
21
  const log = createLogger('@ossy/platform')
18
22
 
@@ -56,6 +60,7 @@ export function loadManifest (buildDir) {
56
60
  const aggregates = Array.isArray(manifest.aggregates) ? manifest.aggregates : []
57
61
  const integrations = Array.isArray(manifest.integrations) ? manifest.integrations : []
58
62
  const startups = Array.isArray(manifest.startups) ? manifest.startups : []
63
+ const actions = Array.isArray(manifest.actions) ? manifest.actions : []
59
64
  for (const e of entries) {
60
65
  if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
61
66
  log.warn(`Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
@@ -71,6 +76,7 @@ export function loadManifest (buildDir) {
71
76
  aggregates,
72
77
  integrations,
73
78
  startups,
79
+ actions,
74
80
  config: manifest.config || {},
75
81
  }
76
82
  }
@@ -159,6 +165,17 @@ export async function startServer (options = {}) {
159
165
  }
160
166
  }
161
167
 
168
+ // Action registration — import each bundled action module and register it
169
+ // with ActionService so it can be looked up and invoked at runtime.
170
+ for (const actionEntry of manifest.actions ?? []) {
171
+ try {
172
+ const mod = await import(resolveEntryUrl(actionEntry.entry, buildDir))
173
+ ActionService.register(mod)
174
+ } catch (err) {
175
+ log.error(`Failed to load action "${actionEntry.id}"`, undefined, err)
176
+ }
177
+ }
178
+
162
179
  // Register the SDK so all tasks receive it as `sdk`.
163
180
  // Priority: explicit options.sdk → SDK.of() from env vars → null (direct-DB fallback in tasks).
164
181
  const botSdk = (process.env.API_URL && process.env.OSSY_API_KEY)
@@ -190,12 +207,10 @@ export async function startServer (options = {}) {
190
207
  return promise
191
208
  }
192
209
 
193
- const userMiddleware = await loadMiddleware(buildDir)
194
-
195
210
  const app = express()
196
211
  app.use(morgan('tiny'))
197
212
  app.use(express.json({ strict: false }))
198
- app.use(cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'))
213
+ app.use(cookieParser(ConfigService.TokenSecret))
199
214
  app.use((req, _res, next) => {
200
215
  const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
201
216
  req.userAppSettings = userSettings
@@ -206,10 +221,41 @@ export async function startServer (options = {}) {
206
221
  req.isAuthenticated = cookieHeader ? cookieHeader.includes('auth=') : false
207
222
  next()
208
223
  })
209
- for (const mw of userMiddleware) app.use(mw)
224
+ app.use(UsersMiddleware.AuthenticateUser)
225
+ app.use(WorkspacesMiddleware.ExtractWorkspaceId())
210
226
  if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
211
227
  app.use(ProxyInternal())
212
228
 
229
+ // Actions endpoint — auto-exposes every registered `*.action.js` handler.
230
+ // Access rules are enforced here; business logic lives in the action's `run`.
231
+ app.post('/actions/:id', async (req, res) => {
232
+ const actionId = req.params.id
233
+ const action = ActionService.get(actionId)
234
+ if (!action) return res.status(404).json({ error: 'Action not found' })
235
+
236
+ if (action.access === 'authenticated' && (!req.userId || req.userId === 'anonymous')) {
237
+ return res.status(401).json({ error: 'Unauthorized' })
238
+ }
239
+ if (action.access === 'workspace' && !req.workspaceId) {
240
+ return res.status(403).json({ error: 'Forbidden' })
241
+ }
242
+
243
+ const actionLog = createLogger(actionId)
244
+ try {
245
+ const result = await ActionService.invoke(actionId, {
246
+ payload: req.body,
247
+ sdk: req.sdk ?? null,
248
+ log: actionLog,
249
+ integrations: IntegrationService,
250
+ req,
251
+ })
252
+ res.json(result ?? { ok: true })
253
+ } catch (err) {
254
+ actionLog.error('Action failed', { id: actionId }, err)
255
+ res.status(500).json({ error: err && err.message ? err.message : 'Internal error' })
256
+ }
257
+ })
258
+
213
259
  app.all('*all', async (req, res) => {
214
260
  const requestUrl = req.originalUrl || '/'
215
261
  try {
@@ -298,19 +344,9 @@ export async function startServer (options = {}) {
298
344
  return { app, server, port, close: closeServer, lifetime }
299
345
  }
300
346
 
301
- async function loadMiddleware (buildDir) {
302
- const candidates = [
303
- path.resolve(buildDir, 'public', 'static', 'middleware.js'),
304
- path.resolve(buildDir, 'middleware.js'),
305
- ]
306
- for (const candidate of candidates) {
307
- if (!fs.existsSync(candidate)) continue
308
- const mod = await import(pathToFileURL(candidate).href)
309
- const value = mod.default
310
- if (Array.isArray(value)) return value
311
- if (typeof value === 'function') return [value]
312
- }
313
- return []
314
- }
315
-
316
347
  export default startServer
348
+ export { ConfigService } from './config.service.js'
349
+ export { ActionService } from './actions/action.service.js'
350
+ export { StorageClient } from './storage/storage.client.js'
351
+ export { S3Client } from './storage/s3.client.js'
352
+ export { LocalStorageClient } from './storage/local-storage.client.js'
@@ -0,0 +1,65 @@
1
+ import path from 'path'
2
+ import os from 'os'
3
+ import { mkdir, writeFile, readFile, stat } from 'fs/promises'
4
+ import { existsSync } from 'fs'
5
+
6
+ const LOCAL_STORAGE_DIR = path.join(os.tmpdir(), 'ossy-local-media')
7
+
8
+ function normalizeKey(keyOrShape) {
9
+ const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
10
+ if (!Key || typeof Key !== 'string') {
11
+ throw new Error('[LocalStorageClient] missing Key')
12
+ }
13
+ return Key
14
+ }
15
+
16
+ function keyToLocalPath(key) {
17
+ const resolved = path.resolve(LOCAL_STORAGE_DIR, key)
18
+ if (!resolved.startsWith(LOCAL_STORAGE_DIR)) {
19
+ throw new Error('[LocalStorageClient] invalid key: path traversal detected')
20
+ }
21
+ return resolved
22
+ }
23
+
24
+ function buildUrl(key) {
25
+ const base = (process.env.LOCAL_API_URL || 'http://localhost:3001').replace(/\/$/, '')
26
+ return `${base}/local-storage?key=${encodeURIComponent(key)}`
27
+ }
28
+
29
+ export class LocalStorageClient {
30
+
31
+ static createUploadUrl({ Key }) {
32
+ return Promise.resolve(buildUrl(Key))
33
+ }
34
+
35
+ static createPresignedDownloadUrl(keyOrShape) {
36
+ const Key = normalizeKey(keyOrShape)
37
+ return Promise.resolve(buildUrl(Key))
38
+ }
39
+
40
+ static createDownloadUrl(keyOrShape) {
41
+ const Key = normalizeKey(keyOrShape)
42
+ return buildUrl(Key)
43
+ }
44
+
45
+ static async save(key, body) {
46
+ const localPath = keyToLocalPath(key)
47
+ await mkdir(path.dirname(localPath), { recursive: true })
48
+ await writeFile(localPath, body)
49
+ }
50
+
51
+ static async load(key) {
52
+ const localPath = keyToLocalPath(key)
53
+ if (!existsSync(localPath)) return { buffer: null, exists: false }
54
+ const buffer = await readFile(localPath)
55
+ return { buffer, exists: true }
56
+ }
57
+
58
+ static async headObject({ Key }) {
59
+ const localPath = keyToLocalPath(Key)
60
+ const s = await stat(localPath).catch(() => null)
61
+ if (!s) throw Object.assign(new Error('Not Found'), { $metadata: { httpStatusCode: 404 } })
62
+ return { ContentLength: s.size }
63
+ }
64
+
65
+ }
@@ -0,0 +1,100 @@
1
+ import { S3, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
2
+ import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner'
3
+ import { createRequest } from '@aws-sdk/util-create-request'
4
+ import { formatUrl } from '@aws-sdk/util-format-url'
5
+ import { ConfigService } from '../config.service.js'
6
+ import { createLogger } from '@ossy/observability'
7
+
8
+ const log = createLogger('storage')
9
+
10
+ const REGION = 'eu-north-1'
11
+
12
+ function createS3Client() {
13
+ return new S3({
14
+ region: REGION,
15
+ credentials: {
16
+ accessKeyId: ConfigService.awsAccessKeyId,
17
+ secretAccessKey: ConfigService.awsSecretAccessKey
18
+ }
19
+ })
20
+ }
21
+
22
+ function normalizeKey(keyOrShape) {
23
+ const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
24
+ if (!Key || typeof Key !== 'string') {
25
+ throw new Error('[S3Client] missing Key')
26
+ }
27
+ return Key
28
+ }
29
+
30
+ export class S3Client {
31
+
32
+ /**
33
+ * Public CDN URL (CloudFront) for objects under `media/` — use only when `access === 'public'`.
34
+ */
35
+ static createDownloadUrl(keyOrShape) {
36
+ const Key = normalizeKey(keyOrShape)
37
+ const pathAfterMedia = Key.includes('media/') ? Key.split('media/')[1] : Key
38
+ const base = ConfigService.MediaCdnDomainName || ''
39
+ return `${base.replace(/\/$/, '')}/${pathAfterMedia}`
40
+ }
41
+
42
+ /**
43
+ * Short-lived presigned GET for private bucket reads (restricted resources, workspace members).
44
+ */
45
+ static createPresignedDownloadUrl(keyOrShape, { expiresIn = 15 * 60 } = {}) {
46
+ const Key = normalizeKey(keyOrShape)
47
+ const Bucket = ConfigService.MediaRepository
48
+ if (!Bucket) {
49
+ return Promise.reject(new Error('[S3Client] MEDIA_REPOSITORY is not configured'))
50
+ }
51
+
52
+ const s3Client = createS3Client()
53
+ const signedRequest = new S3RequestPresigner(s3Client.config)
54
+
55
+ return createRequest(s3Client, new GetObjectCommand({ Bucket, Key }))
56
+ .then(request => signedRequest.presign(request, { expiresIn }))
57
+ .then(formatUrl)
58
+ .catch(error => {
59
+ log.error('S3Client.createPresignedDownloadUrl error', undefined, error)
60
+ return Promise.reject(error)
61
+ })
62
+ }
63
+
64
+ static createUploadUrl({ ContentType, ContentLength, Key, Bucket, expiresIn = 60 * 60 * 24 }) {
65
+ log.info('[S3Client][createUploadUrl()] Creating upload URL')
66
+
67
+ const bucket = Bucket ?? ConfigService.MediaRepository
68
+ if (!bucket) {
69
+ return Promise.reject(new Error('[S3Client] Bucket is not configured'))
70
+ }
71
+
72
+ const clientParams = {
73
+ Bucket: bucket,
74
+ Key,
75
+ ContentType,
76
+ ContentLength
77
+ }
78
+
79
+ const s3Client = createS3Client()
80
+ const signedRequest = new S3RequestPresigner(s3Client.config)
81
+
82
+ return createRequest(s3Client, new PutObjectCommand(clientParams))
83
+ .then(request => signedRequest.presign(request, { expiresIn }))
84
+ .then(formatUrl)
85
+ .catch(error => {
86
+ log.error('S3Client.createUploadUrl error', undefined, error)
87
+ return Promise.reject(error)
88
+ })
89
+ }
90
+
91
+ static headObject({ Key, Bucket }) {
92
+ const bucket = Bucket ?? ConfigService.MediaRepository
93
+ if (!bucket) {
94
+ return Promise.reject(new Error('[S3Client] Bucket is not configured'))
95
+ }
96
+ const s3Client = createS3Client()
97
+ return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key }))
98
+ }
99
+
100
+ }
@@ -0,0 +1,12 @@
1
+ import { S3Client } from './s3.client.js'
2
+ import { LocalStorageClient } from './local-storage.client.js'
3
+ import { ConfigService } from '../config.service.js'
4
+
5
+ /**
6
+ * Active storage backend.
7
+ *
8
+ * - When `MEDIA_REPOSITORY` is set (production): delegates to S3.
9
+ * - When `MEDIA_REPOSITORY` is unset (local dev): delegates to LocalStorageClient,
10
+ * which stores files in /tmp/ossy-local-media/ and serves them via GET /local-storage.
11
+ */
12
+ export const StorageClient = ConfigService.MediaRepository ? S3Client : LocalStorageClient
@@ -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
+ }
@@ -1,26 +0,0 @@
1
- import { SESClient } from '@aws-sdk/client-ses'
2
-
3
- export const id = 'email'
4
-
5
- export const credentials = [
6
- 'SES_REGION',
7
- 'SES_ACCESS_KEY_ID',
8
- 'SES_SECRET_ACCESS_KEY',
9
- ]
10
-
11
- /**
12
- * Returns an AWS SES client configured from env vars.
13
- * Tasks receive this via `integrations.get('email')`.
14
- *
15
- * @param {{ env: NodeJS.ProcessEnv }} opts
16
- * @returns {SESClient}
17
- */
18
- export async function connect ({ env }) {
19
- return new SESClient({
20
- region: env.SES_REGION,
21
- credentials: {
22
- accessKeyId: env.SES_ACCESS_KEY_ID,
23
- secretAccessKey: env.SES_SECRET_ACCESS_KEY,
24
- },
25
- })
26
- }