@ossy/platform 1.39.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +2 -1
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +85 -7
- package/src/audit/action-invocation-service.js +143 -0
- package/src/audit/action-invocation.aggregate.js +57 -0
- package/src/audit/audit-helpers.js +61 -0
- package/src/audit/detect-channel.js +19 -0
- package/src/audit/list-task-runs.action.js +5 -0
- package/src/audit/list-task-runs.task.js +17 -0
- package/src/audit/task-run-list.aggregate.js +76 -0
- package/src/audit/task-run-service.js +208 -0
- package/src/audit/task-run.aggregate.js +58 -0
- package/src/auth/action-scopes.js +3 -0
- package/src/capability-schemas/action-capability.schema.js +1 -0
- package/src/capability-schemas/action-meta.schema.js +1 -0
- package/src/capability-schemas/component-capability.schema.js +1 -0
- package/src/capability-schemas/page-capability.schema.js +1 -0
- package/src/capability-schemas/page-meta.schema.js +1 -0
- package/src/capability-schemas/task-capability.schema.js +1 -0
- package/src/capability-schemas/task-meta.schema.js +1 -0
- package/src/capability-schemas/task-output.schema.js +1 -0
- package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
- package/src/capability-schemas/task-trigger.schema.js +1 -0
- package/src/directory.schema.js +7 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +11 -0
- package/src/index.js +12 -3
- package/src/mcp/create-ossy-mcp-server.js +97 -0
- package/src/mcp/json-schema-to-zod.js +64 -0
- package/src/mcp/mount-ossy-mcp.js +72 -0
- package/src/mcp/mount-platform-mcp.js +101 -0
- package/src/mcp/upload-file-tool.js +62 -0
- package/src/metering/metering-service.js +91 -0
- package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
- package/src/proxy-internal.js +13 -16
- package/src/push/mount-push-sse.js +91 -0
- package/src/request-diagnostics.js +144 -0
- package/src/request-diagnostics.spec.js +40 -0
- package/src/resources/index.js +3 -2
- package/src/resources/schema.registry.js +26 -0
- package/src/resources/schema.service.js +54 -0
- package/src/resources/schema.validation.js +90 -0
- package/src/runtime.js +20 -6
- package/src/server.js +281 -62
- package/src/storage/filesystem-storage.client.js +109 -0
- package/src/storage/local-storage.client.js +2 -65
- package/src/storage/resource-read-url.js +36 -0
- package/src/storage/resource-read-url.spec.js +22 -0
- package/src/storage/s3-storage.client.js +102 -0
- package/src/storage/s3.client.js +27 -23
- package/src/storage/storage-keys.js +37 -0
- package/src/storage/storage-keys.spec.js +16 -0
- package/src/storage/storage.client.js +52 -8
- package/src/storage/storage.integration.js +40 -0
- package/src/tasks/change-stream.js +78 -11
- package/src/tasks/task-service.js +211 -34
- package/src/tasks/task-service.spec.js +187 -0
- package/src/test/e2e.util.js +18 -39
- package/src/test/flow-runner.js +476 -0
- package/src/test/test.util.js +30 -29
- package/src/user-app-settings.js +44 -0
- package/src/users.middleware.js +61 -17
- package/src/resources/resource-template.registry.js +0 -29
- package/src/resources/resource-template.validation.js +0 -232
|
@@ -1,65 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
}
|
|
1
|
+
/** @deprecated Import from filesystem-storage.client.js */
|
|
2
|
+
export { LocalStorageClient, createFilesystemStorageClient, resolveFilesystemStorageRoot } from './filesystem-storage.client.js'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { assertStorageKey } from './storage-keys.js'
|
|
2
|
+
|
|
3
|
+
function normalizeLogicalKey (keyOrShape) {
|
|
4
|
+
const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
|
|
5
|
+
assertStorageKey(Key)
|
|
6
|
+
return Key
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Predictable app-relative read path for a storage key.
|
|
11
|
+
* Original: `/r/{resourceId}` — derivative: `/r/{resourceId}/{variant}`.
|
|
12
|
+
*
|
|
13
|
+
* @param {string | { Key?: string }} keyOrShape
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function storageKeyToReadPath (keyOrShape) {
|
|
17
|
+
const key = normalizeLogicalKey(keyOrShape)
|
|
18
|
+
const colon = key.indexOf(':')
|
|
19
|
+
if (colon === -1) return `/r/${key}`
|
|
20
|
+
const resourceId = key.slice(0, colon)
|
|
21
|
+
const variant = key.slice(colon + 1)
|
|
22
|
+
return `/r/${resourceId}/${variant}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Absolute resource read URL (same shape for filesystem and S3 backends).
|
|
27
|
+
*
|
|
28
|
+
* @param {string | { Key?: string }} keyOrShape
|
|
29
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function createResourceReadUrl (keyOrShape, env = process.env) {
|
|
33
|
+
const path = storageKeyToReadPath(keyOrShape)
|
|
34
|
+
const base = (env.WEB_CLIENT_DOMAIN || env.LOCAL_API_URL || '').replace(/\/$/, '')
|
|
35
|
+
return base ? `${base}${path}` : path
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { createResourceReadUrl, storageKeyToReadPath } from './resource-read-url.js'
|
|
3
|
+
|
|
4
|
+
describe('resource-read-url', () => {
|
|
5
|
+
it('maps original key to /r/{resourceId}', () => {
|
|
6
|
+
expect(storageKeyToReadPath('abc123XYZ')).toBe('/r/abc123XYZ')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('maps derivative key to /r/{resourceId}/{variant}', () => {
|
|
10
|
+
expect(storageKeyToReadPath('abc123:galleryLarge')).toBe('/r/abc123/galleryLarge')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('builds absolute URL from WEB_CLIENT_DOMAIN', () => {
|
|
14
|
+
expect(createResourceReadUrl('abc123', { WEB_CLIENT_DOMAIN: 'https://app.example.com' }))
|
|
15
|
+
.toBe('https://app.example.com/r/abc123')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('falls back to LOCAL_API_URL', () => {
|
|
19
|
+
expect(createResourceReadUrl('abc123', { LOCAL_API_URL: 'http://localhost:3006' }))
|
|
20
|
+
.toBe('http://localhost:3006/r/abc123')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
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 { createLogger } from '@ossy/observability'
|
|
6
|
+
import { assertStorageKey } from './storage-keys.js'
|
|
7
|
+
import { createResourceReadUrl } from './resource-read-url.js'
|
|
8
|
+
|
|
9
|
+
const log = createLogger('storage')
|
|
10
|
+
|
|
11
|
+
const REGION = 'eu-north-1'
|
|
12
|
+
|
|
13
|
+
/** S3/CloudFront origin uses `/media` — flat keys become `media/{resourceId}`. */
|
|
14
|
+
const S3_KEY_PREFIX = 'media/'
|
|
15
|
+
|
|
16
|
+
function normalizeLogicalKey (keyOrShape) {
|
|
17
|
+
const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
|
|
18
|
+
assertStorageKey(Key)
|
|
19
|
+
return Key
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toS3Key (logicalKey) {
|
|
23
|
+
return `${S3_KEY_PREFIX}${logicalKey}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createS3StorageClient ({
|
|
27
|
+
bucket,
|
|
28
|
+
cdnDomainName,
|
|
29
|
+
accessKeyId,
|
|
30
|
+
secretAccessKey,
|
|
31
|
+
region = REGION,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (!bucket) {
|
|
34
|
+
throw new Error('[S3Storage] bucket is required')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createAwsClient () {
|
|
38
|
+
return new S3({
|
|
39
|
+
region,
|
|
40
|
+
credentials: {
|
|
41
|
+
accessKeyId,
|
|
42
|
+
secretAccessKey,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
backend: 's3',
|
|
49
|
+
bucket,
|
|
50
|
+
|
|
51
|
+
createDownloadUrl (keyOrShape) {
|
|
52
|
+
return createResourceReadUrl(normalizeLogicalKey(keyOrShape))
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
createPresignedDownloadUrl (keyOrShape) {
|
|
56
|
+
return Promise.resolve(createResourceReadUrl(normalizeLogicalKey(keyOrShape)))
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
createUploadUrl ({ ContentType, ContentLength, Key, expiresIn = 60 * 60 * 24 }) {
|
|
60
|
+
log.info('[S3Storage][createUploadUrl] Creating upload URL')
|
|
61
|
+
|
|
62
|
+
const clientParams = {
|
|
63
|
+
Bucket: bucket,
|
|
64
|
+
Key: toS3Key(normalizeLogicalKey({ Key })),
|
|
65
|
+
ContentType,
|
|
66
|
+
ContentLength,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const s3Client = createAwsClient()
|
|
70
|
+
const signedRequest = new S3RequestPresigner(s3Client.config)
|
|
71
|
+
|
|
72
|
+
return createRequest(s3Client, new PutObjectCommand(clientParams))
|
|
73
|
+
.then(request => signedRequest.presign(request, { expiresIn }))
|
|
74
|
+
.then(formatUrl)
|
|
75
|
+
.catch(error => {
|
|
76
|
+
log.error('S3Storage.createUploadUrl error', undefined, error)
|
|
77
|
+
return Promise.reject(error)
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
headObject ({ Key }) {
|
|
82
|
+
const s3Client = createAwsClient()
|
|
83
|
+
return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: toS3Key(normalizeLogicalKey({ Key })) }))
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async load (keyOrShape) {
|
|
87
|
+
const Key = toS3Key(normalizeLogicalKey(keyOrShape))
|
|
88
|
+
const s3Client = createAwsClient()
|
|
89
|
+
try {
|
|
90
|
+
const response = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key }))
|
|
91
|
+
const bytes = await response.Body?.transformToByteArray?.()
|
|
92
|
+
if (!bytes) return { buffer: null, exists: false }
|
|
93
|
+
return { buffer: Buffer.from(bytes), exists: true }
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NoSuchKey') {
|
|
96
|
+
return { buffer: null, exists: false }
|
|
97
|
+
}
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/storage/s3.client.js
CHANGED
|
@@ -2,8 +2,9 @@ import { S3, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-
|
|
|
2
2
|
import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner'
|
|
3
3
|
import { createRequest } from '@aws-sdk/util-create-request'
|
|
4
4
|
import { formatUrl } from '@aws-sdk/util-format-url'
|
|
5
|
-
import { ConfigService } from '../config.service.js'
|
|
6
5
|
import { createLogger } from '@ossy/observability'
|
|
6
|
+
import { createResourceReadUrl } from './resource-read-url.js'
|
|
7
|
+
import { ConfigService } from '../config.service.js'
|
|
7
8
|
|
|
8
9
|
const log = createLogger('storage')
|
|
9
10
|
|
|
@@ -30,35 +31,18 @@ function normalizeKey(keyOrShape) {
|
|
|
30
31
|
export class S3Client {
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
*
|
|
34
|
+
* App read URL — same shape as local filesystem (`/r/{resourceId}`).
|
|
34
35
|
*/
|
|
35
36
|
static createDownloadUrl(keyOrShape) {
|
|
36
37
|
const Key = normalizeKey(keyOrShape)
|
|
37
|
-
|
|
38
|
-
const base = ConfigService.MediaCdnDomainName || ''
|
|
39
|
-
return `${base.replace(/\/$/, '')}/${pathAfterMedia}`
|
|
38
|
+
return createResourceReadUrl(Key)
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
|
-
*
|
|
42
|
+
* Same as createDownloadUrl — auth is enforced by `/r/` route handlers.
|
|
44
43
|
*/
|
|
45
|
-
static createPresignedDownloadUrl(keyOrShape
|
|
46
|
-
|
|
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
|
-
})
|
|
44
|
+
static createPresignedDownloadUrl(keyOrShape) {
|
|
45
|
+
return Promise.resolve(S3Client.createDownloadUrl(keyOrShape))
|
|
62
46
|
}
|
|
63
47
|
|
|
64
48
|
static createUploadUrl({ ContentType, ContentLength, Key, Bucket, expiresIn = 60 * 60 * 24 }) {
|
|
@@ -97,4 +81,24 @@ export class S3Client {
|
|
|
97
81
|
return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key }))
|
|
98
82
|
}
|
|
99
83
|
|
|
84
|
+
static async load(keyOrShape) {
|
|
85
|
+
const Key = normalizeKey(keyOrShape)
|
|
86
|
+
const Bucket = ConfigService.MediaRepository
|
|
87
|
+
if (!Bucket) {
|
|
88
|
+
return { buffer: null, exists: false }
|
|
89
|
+
}
|
|
90
|
+
const s3Client = createS3Client()
|
|
91
|
+
try {
|
|
92
|
+
const response = await s3Client.send(new GetObjectCommand({ Bucket, Key }))
|
|
93
|
+
const bytes = await response.Body?.transformToByteArray?.()
|
|
94
|
+
if (!bytes) return { buffer: null, exists: false }
|
|
95
|
+
return { buffer: Buffer.from(bytes), exists: true }
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NoSuchKey') {
|
|
98
|
+
return { buffer: null, exists: false }
|
|
99
|
+
}
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
100
104
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flat blob keys — folder hierarchy lives in Mongo resource metadata (`location`), not in storage.
|
|
3
|
+
*
|
|
4
|
+
* Original binary: `{resourceId}`
|
|
5
|
+
* Derivatives (thumbnails, etc.): `{resourceId}:{variant}`
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const KEY_PATTERN = /^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)?$/
|
|
9
|
+
|
|
10
|
+
export function originalObjectKey (resourceId) {
|
|
11
|
+
assertResourceId(resourceId)
|
|
12
|
+
return resourceId
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function derivativeObjectKey (resourceId, variant) {
|
|
16
|
+
assertResourceId(resourceId)
|
|
17
|
+
if (!variant || typeof variant !== 'string') {
|
|
18
|
+
throw new Error('[storage-keys] variant is required')
|
|
19
|
+
}
|
|
20
|
+
if (!/^[A-Za-z0-9_-]+$/.test(variant)) {
|
|
21
|
+
throw new Error('[storage-keys] invalid variant')
|
|
22
|
+
}
|
|
23
|
+
return `${resourceId}:${variant}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertStorageKey (key) {
|
|
27
|
+
if (!key || typeof key !== 'string' || !KEY_PATTERN.test(key)) {
|
|
28
|
+
throw new Error(`[storage-keys] invalid storage key: ${key}`)
|
|
29
|
+
}
|
|
30
|
+
return key
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertResourceId (resourceId) {
|
|
34
|
+
if (!resourceId || typeof resourceId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(resourceId)) {
|
|
35
|
+
throw new Error(`[storage-keys] invalid resourceId: ${resourceId}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage-keys.js'
|
|
3
|
+
|
|
4
|
+
describe('storage-keys', () => {
|
|
5
|
+
it('originalObjectKey returns flat resourceId', () => {
|
|
6
|
+
expect(originalObjectKey('abc123XYZ')).toBe('abc123XYZ')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('derivativeObjectKey uses resourceId:variant', () => {
|
|
10
|
+
expect(derivativeObjectKey('abc123', 'galleryMedium')).toBe('abc123:galleryMedium')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('rejects path-like keys', () => {
|
|
14
|
+
expect(() => assertStorageKey('media/ws/file')).toThrow()
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -1,12 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { LocalStorageClient } from './local-storage.client.js'
|
|
1
|
+
import { IntegrationService } from '../integration.service.js'
|
|
3
2
|
import { ConfigService } from '../config.service.js'
|
|
3
|
+
import { createFilesystemStorageClient } from './filesystem-storage.client.js'
|
|
4
|
+
import { createS3StorageClient } from './s3-storage.client.js'
|
|
5
|
+
|
|
6
|
+
/** @type {import('./filesystem-storage.client.js').createFilesystemStorageClient extends (...args: any) => infer R ? R : never | null} */
|
|
7
|
+
let fallbackClient = null
|
|
8
|
+
|
|
9
|
+
function getFallbackClient () {
|
|
10
|
+
if (!fallbackClient) {
|
|
11
|
+
if (ConfigService.MediaRepository && ConfigService.awsAccessKeyId && ConfigService.awsSecretAccessKey) {
|
|
12
|
+
fallbackClient = createS3StorageClient({
|
|
13
|
+
bucket: ConfigService.MediaRepository,
|
|
14
|
+
cdnDomainName: ConfigService.MediaCdnDomainName,
|
|
15
|
+
accessKeyId: ConfigService.awsAccessKeyId,
|
|
16
|
+
secretAccessKey: ConfigService.awsSecretAccessKey,
|
|
17
|
+
})
|
|
18
|
+
} else {
|
|
19
|
+
fallbackClient = createFilesystemStorageClient()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return fallbackClient
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStorageClient () {
|
|
26
|
+
return IntegrationService.get('storage') ?? getFallbackClient()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bindMethod (name) {
|
|
30
|
+
return (...args) => {
|
|
31
|
+
const client = getStorageClient()
|
|
32
|
+
const fn = client[name]
|
|
33
|
+
if (typeof fn !== 'function') {
|
|
34
|
+
throw new Error(`[StorageClient] storage backend missing method "${name}"`)
|
|
35
|
+
}
|
|
36
|
+
return fn.apply(client, args)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
4
39
|
|
|
5
40
|
/**
|
|
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.
|
|
41
|
+
* Active primary storage backend (filesystem or S3 via `storage` integration).
|
|
11
42
|
*/
|
|
12
|
-
export const StorageClient =
|
|
43
|
+
export const StorageClient = {
|
|
44
|
+
get backend () {
|
|
45
|
+
return getStorageClient().backend
|
|
46
|
+
},
|
|
47
|
+
createUploadUrl: bindMethod('createUploadUrl'),
|
|
48
|
+
createPresignedDownloadUrl: bindMethod('createPresignedDownloadUrl'),
|
|
49
|
+
createDownloadUrl: bindMethod('createDownloadUrl'),
|
|
50
|
+
load: bindMethod('load'),
|
|
51
|
+
headObject: bindMethod('headObject'),
|
|
52
|
+
save: bindMethod('save'),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { LocalStorageClient } from './filesystem-storage.client.js'
|
|
56
|
+
export { S3Client } from './s3.client.js'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
import { createFilesystemStorageClient } from './filesystem-storage.client.js'
|
|
3
|
+
import { createS3StorageClient } from './s3-storage.client.js'
|
|
4
|
+
|
|
5
|
+
const log = createLogger('storage')
|
|
6
|
+
|
|
7
|
+
export const id = 'storage'
|
|
8
|
+
|
|
9
|
+
/** Always connect — filesystem when S3 is not configured. */
|
|
10
|
+
export const credentials = []
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Primary blob storage: S3 when MEDIA_REPOSITORY is set, otherwise local filesystem.
|
|
14
|
+
*
|
|
15
|
+
* Logical keys are flat resource ids (see storage-keys.js). Mongo holds folder paths.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ env: NodeJS.ProcessEnv }} opts
|
|
18
|
+
*/
|
|
19
|
+
export async function connect ({ env }) {
|
|
20
|
+
const bucket = env.MEDIA_REPOSITORY
|
|
21
|
+
|
|
22
|
+
if (bucket) {
|
|
23
|
+
const missing = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].filter((key) => !(key in env))
|
|
24
|
+
if (missing.length) {
|
|
25
|
+
log.warn(`[storage] MEDIA_REPOSITORY set but missing ${missing.join(', ')} — using filesystem fallback`)
|
|
26
|
+
} else {
|
|
27
|
+
log.info('[storage] Using S3 backend')
|
|
28
|
+
return createS3StorageClient({
|
|
29
|
+
bucket,
|
|
30
|
+
cdnDomainName: env.MEDIA_CDN_DOMAIN_NAME,
|
|
31
|
+
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
32
|
+
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const client = createFilesystemStorageClient({ env })
|
|
38
|
+
log.info(`[storage] Using filesystem backend at ${client.root}`)
|
|
39
|
+
return client
|
|
40
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TaskService } from './task-service.js'
|
|
2
2
|
import { createLogger } from '@ossy/observability'
|
|
3
|
-
import { resolveMongoUrl } from '@ossy/event-store'
|
|
3
|
+
import { ProjectionRebuild, PushInvalidation, resolveMongoUrl } from '@ossy/event-store'
|
|
4
4
|
|
|
5
5
|
const log = createLogger('platform')
|
|
6
6
|
|
|
@@ -17,6 +17,10 @@ export class ChangeStream {
|
|
|
17
17
|
static _reconnectAttempts = 0
|
|
18
18
|
static _dbUrl = null
|
|
19
19
|
static _client = null
|
|
20
|
+
/** @type {import('mongodb').ChangeStream | null} */
|
|
21
|
+
static _changeStream = null
|
|
22
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
23
|
+
static _reconnectTimer = null
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* Opens the MongoDB changestream and wires up reconnect logic.
|
|
@@ -33,12 +37,35 @@ export class ChangeStream {
|
|
|
33
37
|
})
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
static stop() {
|
|
40
|
+
static async stop () {
|
|
37
41
|
ChangeStream._stopped = true
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
ChangeStream.
|
|
42
|
+
|
|
43
|
+
if (ChangeStream._reconnectTimer) {
|
|
44
|
+
clearTimeout(ChangeStream._reconnectTimer)
|
|
45
|
+
ChangeStream._reconnectTimer = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stream = ChangeStream._changeStream
|
|
49
|
+
ChangeStream._changeStream = null
|
|
50
|
+
if (stream) {
|
|
51
|
+
try {
|
|
52
|
+
await stream.close()
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore — stream may already be closed
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const client = ChangeStream._client
|
|
59
|
+
ChangeStream._client = null
|
|
60
|
+
if (client) {
|
|
61
|
+
try {
|
|
62
|
+
await client.close()
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
41
66
|
}
|
|
67
|
+
|
|
68
|
+
log.info('[ChangeStream] Stopped')
|
|
42
69
|
}
|
|
43
70
|
|
|
44
71
|
static async _getMongoClient() {
|
|
@@ -46,7 +73,10 @@ export class ChangeStream {
|
|
|
46
73
|
return MongoClient
|
|
47
74
|
}
|
|
48
75
|
|
|
49
|
-
static async _getClient() {
|
|
76
|
+
static async _getClient () {
|
|
77
|
+
if (ChangeStream._stopped) {
|
|
78
|
+
throw new Error('ChangeStream stopped')
|
|
79
|
+
}
|
|
50
80
|
if (!ChangeStream._client) {
|
|
51
81
|
const MongoClient = await ChangeStream._getMongoClient()
|
|
52
82
|
ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
|
|
@@ -56,7 +86,8 @@ export class ChangeStream {
|
|
|
56
86
|
return ChangeStream._client
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
static async _resetClient() {
|
|
89
|
+
static async _resetClient () {
|
|
90
|
+
if (ChangeStream._stopped) return
|
|
60
91
|
const MongoClient = await ChangeStream._getMongoClient()
|
|
61
92
|
const prev = ChangeStream._client
|
|
62
93
|
ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
|
|
@@ -68,7 +99,7 @@ export class ChangeStream {
|
|
|
68
99
|
log.info('[ChangeStream] New MongoClient instance created')
|
|
69
100
|
}
|
|
70
101
|
|
|
71
|
-
static _scheduleReconnect() {
|
|
102
|
+
static _scheduleReconnect () {
|
|
72
103
|
if (ChangeStream._stopped) return
|
|
73
104
|
|
|
74
105
|
const delaySecs = Math.min(Math.pow(2, ChangeStream._reconnectAttempts), 30)
|
|
@@ -76,7 +107,12 @@ export class ChangeStream {
|
|
|
76
107
|
|
|
77
108
|
log.info(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
|
|
78
109
|
|
|
79
|
-
|
|
110
|
+
if (ChangeStream._reconnectTimer) {
|
|
111
|
+
clearTimeout(ChangeStream._reconnectTimer)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ChangeStream._reconnectTimer = setTimeout(() => {
|
|
115
|
+
ChangeStream._reconnectTimer = null
|
|
80
116
|
if (ChangeStream._stopped) return
|
|
81
117
|
ChangeStream._open().catch((error) => {
|
|
82
118
|
log.error('[ChangeStream] Change stream could not be opened', undefined, error)
|
|
@@ -85,16 +121,30 @@ export class ChangeStream {
|
|
|
85
121
|
}, delaySecs * 1000)
|
|
86
122
|
}
|
|
87
123
|
|
|
88
|
-
static async _open() {
|
|
124
|
+
static async _open () {
|
|
125
|
+
if (ChangeStream._stopped) return
|
|
126
|
+
|
|
89
127
|
log.info('[ChangeStream] Watching for changes')
|
|
90
128
|
|
|
91
129
|
const dbName = process.env.DB_NAME || 'test'
|
|
92
130
|
const client = await ChangeStream._getClient()
|
|
131
|
+
if (ChangeStream._stopped) return
|
|
132
|
+
|
|
93
133
|
const collection = client.db(dbName).collection('eventstore')
|
|
94
134
|
|
|
135
|
+
if (ChangeStream._changeStream) {
|
|
136
|
+
try {
|
|
137
|
+
await ChangeStream._changeStream.close()
|
|
138
|
+
} catch {
|
|
139
|
+
// ignore
|
|
140
|
+
}
|
|
141
|
+
ChangeStream._changeStream = null
|
|
142
|
+
}
|
|
143
|
+
|
|
95
144
|
const changeStream = collection.watch([
|
|
96
145
|
{ $match: { operationType: 'insert' } },
|
|
97
146
|
])
|
|
147
|
+
ChangeStream._changeStream = changeStream
|
|
98
148
|
|
|
99
149
|
const wasReconnecting = ChangeStream._reconnectAttempts > 0
|
|
100
150
|
ChangeStream._reconnectAttempts = 0
|
|
@@ -105,6 +155,7 @@ export class ChangeStream {
|
|
|
105
155
|
// Ensures only one reconnect is scheduled if both 'error' and 'close' fire for the same failure.
|
|
106
156
|
let reconnectScheduled = false
|
|
107
157
|
const scheduleOnce = () => {
|
|
158
|
+
if (ChangeStream._stopped) return
|
|
108
159
|
if (!reconnectScheduled) {
|
|
109
160
|
reconnectScheduled = true
|
|
110
161
|
ChangeStream._scheduleReconnect()
|
|
@@ -112,6 +163,7 @@ export class ChangeStream {
|
|
|
112
163
|
}
|
|
113
164
|
|
|
114
165
|
changeStream.on('error', (error) => {
|
|
166
|
+
if (ChangeStream._stopped) return
|
|
115
167
|
log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
|
|
116
168
|
if (isMongoTopologyClosedError(error)) {
|
|
117
169
|
ChangeStream._resetClient().catch(() => {})
|
|
@@ -121,15 +173,30 @@ export class ChangeStream {
|
|
|
121
173
|
|
|
122
174
|
changeStream.on('change', (change) => {
|
|
123
175
|
log.info('[ChangeStream] Change detected')
|
|
124
|
-
|
|
176
|
+
const event = change.fullDocument
|
|
177
|
+
if (event) {
|
|
178
|
+
ProjectionRebuild.dispatch(event).catch((error) => {
|
|
179
|
+
log.error('[ChangeStream] Projection rebuild failed', undefined, error)
|
|
180
|
+
})
|
|
181
|
+
PushInvalidation.publish(event)
|
|
182
|
+
}
|
|
183
|
+
TaskService.dispatch(event)
|
|
125
184
|
})
|
|
126
185
|
|
|
127
186
|
changeStream.on('close', () => {
|
|
187
|
+
if (ChangeStream._changeStream === changeStream) {
|
|
188
|
+
ChangeStream._changeStream = null
|
|
189
|
+
}
|
|
190
|
+
if (ChangeStream._stopped) return
|
|
128
191
|
log.info('[ChangeStream] close detected')
|
|
129
192
|
scheduleOnce()
|
|
130
193
|
})
|
|
131
194
|
|
|
132
195
|
changeStream.on('end', () => {
|
|
196
|
+
if (ChangeStream._changeStream === changeStream) {
|
|
197
|
+
ChangeStream._changeStream = null
|
|
198
|
+
}
|
|
199
|
+
if (ChangeStream._stopped) return
|
|
133
200
|
log.info('[ChangeStream] end detected')
|
|
134
201
|
scheduleOnce()
|
|
135
202
|
})
|