@ossy/platform 1.39.3 → 3.0.2

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.
@@ -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
- if (ChangeStream._client) {
39
- ChangeStream._client.close().catch(() => {})
40
- ChangeStream._client = null
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
- setTimeout(() => {
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(() => {})
@@ -132,11 +184,19 @@ export class ChangeStream {
132
184
  })
133
185
 
134
186
  changeStream.on('close', () => {
187
+ if (ChangeStream._changeStream === changeStream) {
188
+ ChangeStream._changeStream = null
189
+ }
190
+ if (ChangeStream._stopped) return
135
191
  log.info('[ChangeStream] close detected')
136
192
  scheduleOnce()
137
193
  })
138
194
 
139
195
  changeStream.on('end', () => {
196
+ if (ChangeStream._changeStream === changeStream) {
197
+ ChangeStream._changeStream = null
198
+ }
199
+ if (ChangeStream._stopped) return
140
200
  log.info('[ChangeStream] end detected')
141
201
  scheduleOnce()
142
202
  })
@@ -5,8 +5,14 @@ import { User } from '@ossy/users/server'
5
5
  import { Token } from '@ossy/tokens/server'
6
6
  import { ConfigService } from './config.service.js'
7
7
  import { PoliciesQueries } from '@ossy/policies/server'
8
+ import {
9
+ OperationTimeoutError,
10
+ resolveTimeoutMs,
11
+ withTimeout,
12
+ } from './request-diagnostics.js'
8
13
 
9
14
  const log = createLogger('users')
15
+ const AUTH_TIMEOUT_MS = resolveTimeoutMs('OSSY_AUTH_TIMEOUT_MS', 10_000)
10
16
 
11
17
  function normalizeAuthToken (token) {
12
18
  const trimmed = String(token ?? '').trim()
@@ -58,29 +64,55 @@ export class UsersMiddleware {
58
64
  log.info('[UsersMiddleware] Authenticating')
59
65
  log.debug('[UsersMiddleware] authToken', { authToken })
60
66
 
61
- TokenService.verify(authToken)
62
- .then(UsersMiddleware.assertApiTokenActive)
63
- .then(payload => {
64
- req.authPayload = payload
65
- req.tokenScopes = payload?.type === 'Api' ? (payload.scopes ?? ['*']) : null
66
- return payload
67
- })
68
- .then(({ sub }) => Aggregate.Of(User, sub))
69
- .then(Aggregate.View())
70
- .then(user => {
71
- const workspaces = user.workspaces ?? []
67
+ const authStarted = performance.now()
68
+ let authFinished = false
69
+ const finishAuth = (apply) => {
70
+ if (authFinished) return
71
+ authFinished = true
72
+ apply()
73
+ }
72
74
 
73
- return PoliciesQueries.GetPoliciesForUser(user.id)
74
- .then(policies => {
75
- log.info('[UsersMiddleware] Resolved user')
76
- req.userId = user.id
77
- req.user = { ...user, workspaces, policies }
78
- next()
79
- })
80
- })
75
+ withTimeout(
76
+ TokenService.verify(authToken)
77
+ .then(UsersMiddleware.assertApiTokenActive)
78
+ .then(payload => {
79
+ req.authPayload = payload
80
+ req.tokenScopes = payload?.type === 'Api' ? (payload.scopes ?? ['*']) : null
81
+ return payload
82
+ })
83
+ .then(({ sub }) => Aggregate.Of(User, sub))
84
+ .then(Aggregate.View())
85
+ .then(user => {
86
+ const workspaces = user.workspaces ?? []
87
+
88
+ return PoliciesQueries.GetPoliciesForUser(user.id)
89
+ .then(policies => {
90
+ finishAuth(() => {
91
+ log.info('[UsersMiddleware] Resolved user', {
92
+ elapsedMs: Math.round(performance.now() - authStarted),
93
+ })
94
+ req.userId = user.id
95
+ req.user = { ...user, workspaces, policies }
96
+ next()
97
+ })
98
+ })
99
+ }),
100
+ AUTH_TIMEOUT_MS,
101
+ 'UsersMiddleware.AuthenticateUser',
102
+ )
81
103
  .catch(error => {
104
+ if (error instanceof OperationTimeoutError) {
105
+ log.warn('[UsersMiddleware] Auth timed out', {
106
+ timeoutMs: error.timeoutMs,
107
+ elapsedMs: Math.round(performance.now() - authStarted),
108
+ })
109
+ finishAuth(() => {
110
+ res.status(504).json({ error: 'Authentication timed out', code: 'AUTH_TIMEOUT' })
111
+ })
112
+ return
113
+ }
82
114
  log.debug('[UsersMiddleware] Auth failed; anonymous', { error })
83
- asAnonymous()
115
+ finishAuth(asAnonymous)
84
116
  })
85
117
  }
86
118
 
package/src/schema-ids.js DELETED
@@ -1,9 +0,0 @@
1
- /** Platform envelope schemas (ADR 0008). */
2
- export const PlatformSchema = Object.freeze({
3
- config: '@ossy/platform/schema/config',
4
- directory: '@ossy/platform/schema/directory',
5
- file: '@ossy/platform/schema/file',
6
- taskRun: '@ossy/platform/schema/task-run',
7
- actionInvocation: '@ossy/platform/schema/action-invocation',
8
- meterEvent: '@ossy/platform/schema/meter-event',
9
- })