@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 +23 -8
- package/src/actions/action.service.js +73 -0
- package/src/config.service.js +48 -0
- package/src/index.js +7 -0
- package/src/server.js +55 -19
- package/src/storage/local-storage.client.js +65 -0
- package/src/storage/s3.client.js +100 -0
- package/src/storage/storage.client.js +12 -0
- 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/src/email.integration.js +0 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"@
|
|
34
|
-
"@
|
|
35
|
-
"@
|
|
36
|
-
"@ossy/
|
|
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": "
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/email.integration.js
DELETED
|
@@ -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
|
-
}
|