@ossy/users 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/users",
3
3
  "description": "User domain — aggregate, events, and validators for the Ossy user model",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -19,12 +19,12 @@
19
19
  "registry": "https://registry.npmjs.org"
20
20
  },
21
21
  "dependencies": {
22
- "@ossy/observability": "^1.2.0",
22
+ "@ossy/observability": "^1.3.0",
23
23
  "nanoid": "^5.1.11"
24
24
  },
25
25
  "files": [
26
26
  "/src",
27
27
  "README.md"
28
28
  ],
29
- "gitHead": "46cdd391ec01ea9210543ee7b14661f77079a7e7"
29
+ "gitHead": "c6697078268867d88553ca0bac08faad5cea1546"
30
30
  }
@@ -0,0 +1,35 @@
1
+ import jwt from 'jsonwebtoken'
2
+ import { Aggregate } from '@ossy/event-store'
3
+ import { Token, TokenEvents } from '@ossy/tokens'
4
+ import { ConfigService } from '@ossy/platform'
5
+ import { createLogger } from '@ossy/observability'
6
+
7
+ const log = createLogger('users/create-api-token')
8
+
9
+ const SECONDS_IN_100_YEARS = 60 * 60 * 24 * 365 * 100
10
+
11
+ export const id = 'users/create-api-token'
12
+ export const access = 'authenticated'
13
+
14
+ export async function run({ payload, req }) {
15
+ const createdBy = payload?.userId ?? req?.userId
16
+ const name = payload?.name
17
+ const description = payload?.description
18
+
19
+ if (!name || typeof name !== 'string') {
20
+ log.debug('[users/create-api-token] No name provided')
21
+ throw Object.assign(new Error('No name provided'), { status: 400 })
22
+ }
23
+ if (!description || typeof description !== 'string') {
24
+ log.debug('[users/create-api-token] No description provided')
25
+ throw Object.assign(new Error('No description provided'), { status: 400 })
26
+ }
27
+
28
+ const expiresIn = SECONDS_IN_100_YEARS
29
+ const expiresAt = Date.now() + expiresIn * 1000
30
+ const token = jwt.sign({ sub: createdBy, type: 'Api' }, ConfigService.TokenSecret, { expiresIn })
31
+
32
+ const event = TokenEvents.Created({ name, description, createdBy, type: 'Api', subject: createdBy, token, expiresAt })
33
+
34
+ return Aggregate.Of(Token, event).then(Aggregate.View())
35
+ }
@@ -0,0 +1,20 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { createLogger } from '@ossy/observability'
3
+
4
+ const log = createLogger('users/get-api-tokens')
5
+
6
+ export const id = 'users/get-api-tokens'
7
+ export const access = 'authenticated'
8
+
9
+ export async function run({ payload, req }) {
10
+ const userId = payload?.userId ?? req?.userId
11
+
12
+ log.info('[users/get-api-tokens] Fetching API tokens for user')
13
+ const tokens = await Aggregate.Collection.find({
14
+ type: 'Token',
15
+ 'state.subject': userId,
16
+ 'state.type': 'Api',
17
+ }, { state: true }).toArray().then(docs => docs.map(d => d.state))
18
+
19
+ return tokens.map(({ token: _secret, ...meta }) => meta)
20
+ }
@@ -0,0 +1,11 @@
1
+ import { EventStore } from '@ossy/event-store'
2
+ import { User } from '@ossy/users'
3
+
4
+ export const id = 'users/get-current-user-history'
5
+ export const access = 'authenticated'
6
+
7
+ export async function run({ payload, req }) {
8
+ const userId = payload?.userId ?? req?.userId
9
+ const events = await EventStore.GetEventStream({ aggregateId: userId, fromVersion: 0 })
10
+ return User.History(events)
11
+ }
@@ -0,0 +1,16 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Token, TokenEvents } from '@ossy/tokens'
3
+
4
+ export const id = 'users/invalidate-api-token'
5
+ export const access = 'authenticated'
6
+
7
+ export async function run({ payload, req }) {
8
+ const createdBy = payload?.userId ?? req?.userId
9
+ const tokenId = payload?.tokenId ?? req?.params?.tokenId
10
+
11
+ if (!tokenId) throw Object.assign(new Error('tokenId is required'), { status: 400 })
12
+
13
+ const event = TokenEvents.Revoked({ createdBy })
14
+ await Aggregate.Of(Token, tokenId).then(Aggregate.Add(event))
15
+ return ''
16
+ }
@@ -0,0 +1,18 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { User, UsersEvents } from '@ossy/users'
3
+
4
+ export const id = 'users/join-workspace'
5
+ export const access = 'authenticated'
6
+
7
+ export async function run({ payload, req }) {
8
+ const userId = payload?.targetUserId ?? payload?.userId ?? req?.userId
9
+ const workspaceId = payload?.workspaceId
10
+ const createdBy = payload?.createdBy ?? userId
11
+
12
+ if (!workspaceId) throw Object.assign(new Error('workspaceId is required'), { status: 400 })
13
+
14
+ await Aggregate.Of(User, userId)
15
+ .then(Aggregate.Add(UsersEvents.WorkspaceJoined({ workspaceId, createdBy })))
16
+ .then(Aggregate.Save())
17
+ return null
18
+ }
@@ -0,0 +1,17 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { User, UsersEvents } from '@ossy/users'
3
+
4
+ export const id = 'users/leave-workspace'
5
+ export const access = 'authenticated'
6
+
7
+ export async function run({ payload, req }) {
8
+ const userId = payload?.userId ?? req?.userId
9
+ const workspaceId = payload?.workspaceId
10
+
11
+ if (!workspaceId) throw Object.assign(new Error('workspaceId is required'), { status: 400 })
12
+
13
+ await Aggregate.Of(User, userId)
14
+ .then(Aggregate.Add(UsersEvents.WorkspaceLeft({ workspaceId, createdBy: userId })))
15
+ .then(Aggregate.Save())
16
+ return null
17
+ }
@@ -0,0 +1,23 @@
1
+ import { ActionService } from '@ossy/platform'
2
+
3
+ export const metadata = {
4
+ id: 'users.list',
5
+ path: '/api/v0/users',
6
+ }
7
+
8
+ export default async function handle(req, res) {
9
+ if (req.method !== 'GET') {
10
+ res.setHeader('Allow', 'GET')
11
+ res.status(405).json({ error: 'Method Not Allowed' })
12
+ return
13
+ }
14
+ try {
15
+ const users = await ActionService.invoke('workspaces/get-users', {
16
+ payload: { workspaceId: req.workspaceId },
17
+ req,
18
+ })
19
+ res.json(users)
20
+ } catch (err) {
21
+ res.status(err?.status ?? 500).json()
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import { ActionService } from '@ossy/platform'
2
+
3
+ export const metadata = {
4
+ id: 'users.me.history',
5
+ path: '/api/v0/users/me/history',
6
+ }
7
+
8
+ export default async function handle(req, res) {
9
+ if (req.method !== 'GET') {
10
+ res.setHeader('Allow', 'GET')
11
+ res.status(405).json({ error: 'Method Not Allowed' })
12
+ return
13
+ }
14
+ try {
15
+ const history = await ActionService.invoke('users/get-current-user-history', {
16
+ payload: { userId: req.userId },
17
+ req,
18
+ })
19
+ res.status(200).json(history)
20
+ } catch (err) {
21
+ res.status(err?.status ?? 500).json('')
22
+ }
23
+ }
@@ -0,0 +1,32 @@
1
+ import { ActionService } from '@ossy/platform'
2
+
3
+ export const metadata = {
4
+ id: 'users.me.token',
5
+ path: '/api/v0/users/me/tokens/:tokenId',
6
+ }
7
+
8
+ export default async function handle(req, res) {
9
+ if (req.method !== 'DELETE') {
10
+ res.setHeader('Allow', 'DELETE')
11
+ res.status(405).json({ error: 'Method Not Allowed' })
12
+ return
13
+ }
14
+
15
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
16
+ const m = pathname.match(/^\/api\/v0\/users\/me\/tokens\/([^/]+)$/)
17
+ if (!m) {
18
+ res.status(404).json('')
19
+ return
20
+ }
21
+
22
+ const tokenId = decodeURIComponent(m[1])
23
+ try {
24
+ await ActionService.invoke('users/invalidate-api-token', {
25
+ payload: { userId: req.userId, tokenId },
26
+ req,
27
+ })
28
+ res.status(200).json('')
29
+ } catch (err) {
30
+ res.status(err?.status ?? 500).json()
31
+ }
32
+ }
@@ -0,0 +1,35 @@
1
+ import { ActionService } from '@ossy/platform'
2
+
3
+ export const metadata = {
4
+ id: 'users.me.tokens',
5
+ path: '/api/v0/users/me/tokens',
6
+ }
7
+
8
+ export default async function handle(req, res) {
9
+ if (req.method === 'GET') {
10
+ try {
11
+ const tokens = await ActionService.invoke('users/get-api-tokens', {
12
+ payload: { userId: req.userId },
13
+ req,
14
+ })
15
+ res.json(tokens)
16
+ } catch (err) {
17
+ res.status(err?.status ?? 500).json()
18
+ }
19
+ return
20
+ }
21
+ if (req.method === 'POST') {
22
+ try {
23
+ const token = await ActionService.invoke('users/create-api-token', {
24
+ payload: { userId: req.userId, name: req.body?.name, description: req.body?.description },
25
+ req,
26
+ })
27
+ res.json(token)
28
+ } catch (err) {
29
+ res.status(err?.status ?? 400).json('')
30
+ }
31
+ return
32
+ }
33
+ res.setHeader('Allow', 'GET, POST')
34
+ res.status(405).json({ error: 'Method Not Allowed' })
35
+ }
package/src/me.api.js ADDED
@@ -0,0 +1,41 @@
1
+ import { ActionService } from '@ossy/platform'
2
+
3
+ export const metadata = {
4
+ id: 'users.me',
5
+ path: '/api/v0/users/me',
6
+ }
7
+
8
+ export default async function handle(req, res) {
9
+ if (req.method === 'GET' || req.method === 'POST') {
10
+ try {
11
+ if (!req.user || req.user.anonymous) {
12
+ res.status(401).json('')
13
+ return
14
+ }
15
+ const user = await ActionService.invoke('authentication/get-current-user', { req })
16
+ res.status(200).json(user)
17
+ } catch (err) {
18
+ res.status(err?.status ?? 500).json('')
19
+ }
20
+ return
21
+ }
22
+ if (req.method === 'PUT') {
23
+ try {
24
+ const user = await ActionService.invoke('users/update-details', {
25
+ payload: {
26
+ userId: req.userId,
27
+ firstName: req.body?.firstName,
28
+ lastName: req.body?.lastName,
29
+ currentUser: req.user,
30
+ },
31
+ req,
32
+ })
33
+ res.status(200).json(user)
34
+ } catch (err) {
35
+ res.status(err?.status ?? 400).json(err?.message)
36
+ }
37
+ return
38
+ }
39
+ res.setHeader('Allow', 'GET, POST, PUT')
40
+ res.status(405).json({ error: 'Method Not Allowed' })
41
+ }
@@ -0,0 +1,29 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { User, UsersEvents } from '@ossy/users'
3
+
4
+ export const id = 'users/update-details'
5
+ export const access = 'authenticated'
6
+
7
+ export async function run({ payload, req }) {
8
+ const createdBy = payload?.userId ?? req?.userId
9
+ const currentUser = payload?.currentUser ?? req?.user
10
+
11
+ const firstName = (payload?.firstName ?? currentUser?.firstName)?.trim?.()
12
+ const lastName = (payload?.lastName ?? currentUser?.lastName)?.trim?.()
13
+
14
+ if (!firstName || typeof firstName !== 'string') {
15
+ throw Object.assign(new Error('No firstName provided'), { status: 400 })
16
+ }
17
+ if (!lastName || typeof lastName !== 'string') {
18
+ throw Object.assign(new Error('No lastName provided'), { status: 400 })
19
+ }
20
+
21
+ const hasChanges = firstName + lastName !== currentUser?.firstName + currentUser?.lastName
22
+ if (!hasChanges) {
23
+ throw Object.assign(new Error('No changes provided'), { status: 400 })
24
+ }
25
+
26
+ return Aggregate.Of(User, createdBy)
27
+ .then(Aggregate.Add(UsersEvents.NameUpdated({ firstName, lastName, createdBy })))
28
+ .then(Aggregate.View())
29
+ }
@@ -0,0 +1,72 @@
1
+ import { ConfigService } from '@ossy/platform'
2
+ import { ActionService } from '@ossy/platform'
3
+ import { createLogger } from '@ossy/observability'
4
+
5
+ const log = createLogger('users')
6
+
7
+ function isBotUser(req) {
8
+ return req.userId === ConfigService.BotUserId
9
+ }
10
+
11
+ export const metadata = {
12
+ id: 'users.workspace-membership',
13
+ path: '/api/v0/users/:userId/workspaces',
14
+ }
15
+
16
+ export default async function handle(req, res) {
17
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
18
+
19
+ const deleteMatch = pathname.match(/^\/api\/v0\/users\/([^/]+)\/workspaces\/([^/]+)$/)
20
+ if (deleteMatch && req.method === 'DELETE') {
21
+ const userId = decodeURIComponent(deleteMatch[1])
22
+ const workspaceId = decodeURIComponent(deleteMatch[2])
23
+
24
+ if (!isBotUser(req)) {
25
+ res.status(403).json({ error: 'Forbidden' })
26
+ return
27
+ }
28
+
29
+ try {
30
+ await ActionService.invoke('users/leave-workspace', {
31
+ payload: { userId, workspaceId },
32
+ req,
33
+ })
34
+ res.status(204).send()
35
+ } catch (err) {
36
+ log.error('[workspace-membership.api] remove error', undefined, err)
37
+ res.status(err?.status ?? 500).json({ error: 'Internal Server Error' })
38
+ }
39
+ return
40
+ }
41
+
42
+ const postMatch = pathname.match(/^\/api\/v0\/users\/([^/]+)\/workspaces$/)
43
+ if (postMatch && req.method === 'POST') {
44
+ const userId = decodeURIComponent(postMatch[1])
45
+ const workspaceId = req.body?.workspaceId
46
+ const createdBy = req.body?.createdBy ?? userId
47
+
48
+ if (!isBotUser(req)) {
49
+ res.status(403).json({ error: 'Forbidden' })
50
+ return
51
+ }
52
+
53
+ if (!workspaceId) {
54
+ res.status(400).json({ error: 'workspaceId is required' })
55
+ return
56
+ }
57
+
58
+ try {
59
+ await ActionService.invoke('users/join-workspace', {
60
+ payload: { targetUserId: userId, workspaceId, createdBy },
61
+ req,
62
+ })
63
+ res.status(204).send()
64
+ } catch (err) {
65
+ log.error('[workspace-membership.api] add error', undefined, err)
66
+ res.status(err?.status ?? 500).json({ error: 'Internal Server Error' })
67
+ }
68
+ return
69
+ }
70
+
71
+ res.status(404).json({ error: 'Not Found' })
72
+ }