@arcote.tech/arc-workspace 0.4.6 → 0.4.8

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": "@arcote.tech/arc-workspace",
3
3
  "type": "module",
4
- "version": "0.4.6",
4
+ "version": "0.4.8",
5
5
  "private": false,
6
6
  "description": "Reusable workspace module for Arc framework — multi-workspace with dual token architecture",
7
7
  "main": "./src/index.ts",
@@ -10,8 +10,8 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.4.6",
14
- "@arcote.tech/arc-auth": "^0.4.6",
13
+ "@arcote.tech/arc": "^0.4.8",
14
+ "@arcote.tech/arc-auth": "^0.4.8",
15
15
  "typescript": "^5.0.0"
16
16
  },
17
17
  "devDependencies": {
@@ -0,0 +1,219 @@
1
+ import {
2
+ aggregate,
3
+ date,
4
+ string,
5
+ type ArcId,
6
+ } from "@arcote.tech/arc";
7
+ import type { Token } from "@arcote.tech/arc-auth";
8
+ import type { WorkspaceToken } from "../tokens/workspace-token";
9
+
10
+ export type WorkspaceInvitationAggregateData = {
11
+ name: string;
12
+ workspaceInvitationId: ArcId<any>;
13
+ workspaceId: ArcId<any>;
14
+ accountId: ArcId<any>;
15
+ workspaceToken: WorkspaceToken;
16
+ userToken: Token;
17
+ };
18
+
19
+ function generateInviteToken(): string {
20
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
21
+ let result = "";
22
+ for (let i = 0; i < 32; i++) {
23
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
24
+ }
25
+ return result;
26
+ }
27
+
28
+ export const createWorkspaceInvitationAggregate = <
29
+ const Data extends WorkspaceInvitationAggregateData,
30
+ >(
31
+ data: Data,
32
+ ) => {
33
+ const {
34
+ workspaceInvitationId,
35
+ workspaceId,
36
+ accountId,
37
+ workspaceToken,
38
+ userToken,
39
+ } = data;
40
+
41
+ return aggregate(
42
+ `${data.name}WorkspaceInvitations`,
43
+ workspaceInvitationId,
44
+ {
45
+ workspaceId,
46
+ invitedBy: accountId,
47
+ role: string(),
48
+ token: string(),
49
+ email: string().optional(),
50
+ workspaceName: string(),
51
+ workspaceType: string(),
52
+ status: string(), // "pending" | "accepted" | "revoked"
53
+ createdAt: date(),
54
+ expiresAt: date(),
55
+ },
56
+ )
57
+ .publicEvent(
58
+ "invitationCreated",
59
+ {
60
+ workspaceInvitationId,
61
+ workspaceId,
62
+ invitedBy: accountId,
63
+ role: string(),
64
+ token: string(),
65
+ email: string().optional(),
66
+ workspaceName: string(),
67
+ workspaceType: string(),
68
+ expiresAt: date(),
69
+ },
70
+ async (ctx, event) => {
71
+ const p = event.payload;
72
+ await ctx.set(p.workspaceInvitationId, {
73
+ workspaceId: p.workspaceId,
74
+ invitedBy: p.invitedBy,
75
+ role: p.role,
76
+ token: p.token,
77
+ email: p.email,
78
+ workspaceName: p.workspaceName,
79
+ workspaceType: p.workspaceType,
80
+ status: "pending",
81
+ createdAt: event.createdAt,
82
+ expiresAt: p.expiresAt,
83
+ });
84
+ },
85
+ )
86
+
87
+ .publicEvent(
88
+ "invitationAccepted",
89
+ {
90
+ workspaceInvitationId,
91
+ workspaceId,
92
+ acceptedBy: accountId,
93
+ role: string(),
94
+ workspaceName: string(),
95
+ workspaceType: string(),
96
+ },
97
+ async (ctx, event) => {
98
+ await ctx.modify(event.payload.workspaceInvitationId, {
99
+ status: "accepted",
100
+ });
101
+ },
102
+ )
103
+
104
+ .publicEvent(
105
+ "invitationRevoked",
106
+ { workspaceInvitationId },
107
+ async (ctx, event) => {
108
+ await ctx.modify(event.payload.workspaceInvitationId, {
109
+ status: "revoked",
110
+ });
111
+ },
112
+ )
113
+
114
+ .mutateMethod(
115
+ "createInvitation",
116
+ {
117
+ params: {
118
+ workspaceId,
119
+ role: string(),
120
+ workspaceName: string(),
121
+ workspaceType: string(),
122
+ email: string().optional(),
123
+ },
124
+ },
125
+ ONLY_SERVER &&
126
+ (async (ctx, params) => {
127
+ const invId = workspaceInvitationId.generate();
128
+ const token = generateInviteToken();
129
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
130
+
131
+ await ctx.invitationCreated.emit({
132
+ workspaceInvitationId: invId,
133
+ workspaceId: params.workspaceId,
134
+ invitedBy: ctx.$auth.params.accountId,
135
+ role: params.role,
136
+ token,
137
+ email: params.email,
138
+ workspaceName: params.workspaceName,
139
+ workspaceType: params.workspaceType,
140
+ expiresAt,
141
+ });
142
+
143
+ return { workspaceInvitationId: invId, token };
144
+ }),
145
+ )
146
+
147
+ .mutateMethod(
148
+ "acceptInvitation",
149
+ { params: { token: string() } },
150
+ ONLY_SERVER &&
151
+ (async (ctx, params) => {
152
+ const invitation = await ctx.$query.findOne({
153
+ token: params.token,
154
+ });
155
+
156
+ if (!invitation) {
157
+ return { error: "INVITATION_NOT_FOUND" as const };
158
+ }
159
+ if (invitation.status !== "pending") {
160
+ return { error: "INVITATION_NOT_VALID" as const };
161
+ }
162
+ if (new Date(invitation.expiresAt) < new Date()) {
163
+ return { error: "INVITATION_EXPIRED" as const };
164
+ }
165
+
166
+ // Emit accepted event — WorkspaceMember handles adding the member
167
+ await ctx.invitationAccepted.emit({
168
+ workspaceInvitationId: invitation._id,
169
+ workspaceId: invitation.workspaceId,
170
+ acceptedBy: ctx.$auth.params.accountId,
171
+ role: invitation.role,
172
+ workspaceName: invitation.workspaceName,
173
+ workspaceType: invitation.workspaceType,
174
+ });
175
+
176
+ // Generate workspace token for the new member
177
+ const wsToken = workspaceToken.generateJWT({
178
+ accountId: ctx.$auth.params.accountId,
179
+ workspaceId: invitation.workspaceId,
180
+ role: invitation.role,
181
+ workspaceType: invitation.workspaceType,
182
+ });
183
+
184
+ return {
185
+ success: true,
186
+ token: wsToken,
187
+ workspaceId: invitation.workspaceId,
188
+ };
189
+ }),
190
+ )
191
+
192
+ .mutateMethod(
193
+ "revokeInvitation",
194
+ { params: { workspaceInvitationId } },
195
+ ONLY_SERVER &&
196
+ (async (ctx, params) => {
197
+ const invitation = await ctx.$query.findOne({
198
+ _id: params.workspaceInvitationId,
199
+ });
200
+ if (!invitation || invitation.status !== "pending") {
201
+ return { error: "INVITATION_NOT_VALID" as const };
202
+ }
203
+
204
+ await ctx.invitationRevoked.emit({
205
+ workspaceInvitationId: params.workspaceInvitationId,
206
+ });
207
+
208
+ return { success: true };
209
+ }),
210
+ )
211
+
212
+ .protectBy(userToken, (p: any) => ({ invitedBy: p.accountId }))
213
+ .clientQuery("getByWorkspace", async (ctx) => ctx.$query.find({}))
214
+ ;
215
+ };
216
+
217
+ export type WorkspaceInvitationAggregate<
218
+ Data extends WorkspaceInvitationAggregateData = WorkspaceInvitationAggregateData,
219
+ > = ReturnType<typeof createWorkspaceInvitationAggregate<Data>>;
@@ -2,7 +2,7 @@ import {
2
2
  aggregate,
3
3
  date,
4
4
  string,
5
- type AggregateConstructorAny,
5
+ type ArcAggregateElement,
6
6
  type ArcId,
7
7
  } from "@arcote.tech/arc";
8
8
  import type { Token } from "@arcote.tech/arc-auth";
@@ -15,7 +15,8 @@ export type WorkspaceMemberAggregateData = {
15
15
  accountId: ArcId<any>;
16
16
  workspaceToken: WorkspaceToken;
17
17
  userToken: Token;
18
- WorkspaceAggregate: AggregateConstructorAny;
18
+ WorkspaceAggregate: ArcAggregateElement<any, any, any, any, any, any>;
19
+ WorkspaceInvitationAggregate?: ArcAggregateElement<any, any, any, any, any, any>;
19
20
  };
20
21
 
21
22
  export const createWorkspaceMemberAggregate = <
@@ -30,11 +31,12 @@ export const createWorkspaceMemberAggregate = <
30
31
  workspaceToken,
31
32
  userToken,
32
33
  WorkspaceAggregate,
34
+ WorkspaceInvitationAggregate,
33
35
  } = data;
34
36
 
35
37
  const workspaceCreatedEvent = WorkspaceAggregate.getEvent("workspaceCreated");
36
38
 
37
- const Constructor = aggregate(
39
+ let Constructor = aggregate(
38
40
  `${data.name}WorkspaceMembers`,
39
41
  workspaceMemberId,
40
42
  {
@@ -109,7 +111,48 @@ export const createWorkspaceMemberAggregate = <
109
111
  });
110
112
  })
111
113
 
112
- .mutateMethod(
114
+ // Remove all members when workspace is archived
115
+ .handleEvent(WorkspaceAggregate.getEvent("workspaceArchived"), async (ctx, event) => {
116
+ const members = await ctx.find({
117
+ where: { workspaceId: event.payload.workspaceId },
118
+ });
119
+ for (const member of members) {
120
+ await ctx.remove(member._id);
121
+ }
122
+ })
123
+
124
+ // Sync workspace name to all members when workspace is renamed
125
+ .handleEvent(WorkspaceAggregate.getEvent("workspaceRenamed"), async (ctx, event) => {
126
+ const members = await ctx.find({
127
+ where: { workspaceId: event.payload.workspaceId },
128
+ });
129
+ for (const member of members) {
130
+ await ctx.modify(member._id, {
131
+ workspaceName: event.payload.name,
132
+ });
133
+ }
134
+ })
135
+
136
+ ;
137
+
138
+ // Auto-add member when invitation is accepted
139
+ if (WorkspaceInvitationAggregate) {
140
+ const invitationAcceptedEvent = WorkspaceInvitationAggregate.getEvent("invitationAccepted");
141
+ Constructor = Constructor.handleEvent(invitationAcceptedEvent, async (ctx, event) => {
142
+ const p = event.payload;
143
+ const memberId = workspaceMemberId.generate();
144
+ await ctx.set(memberId, {
145
+ workspaceId: p.workspaceId,
146
+ accountId: p.acceptedBy,
147
+ role: p.role,
148
+ workspaceName: p.workspaceName,
149
+ workspaceType: p.workspaceType,
150
+ joinedAt: event.createdAt,
151
+ });
152
+ });
153
+ }
154
+
155
+ Constructor = Constructor.mutateMethod(
113
156
  "addMember",
114
157
  {
115
158
  params: {
@@ -205,12 +248,12 @@ export const createWorkspaceMemberAggregate = <
205
248
  )
206
249
 
207
250
  .protectBy(userToken, (params: any) => ({ accountId: params.accountId }))
251
+ .protectBy(workspaceToken, (params: any) => ({ workspaceId: params.workspaceId }))
208
252
  .clientQuery("getAll", async (ctx) => ctx.$query.find({}))
209
- .build();
210
-
211
- class WorkspaceMember extends Constructor {}
253
+ .clientQuery("getByWorkspace", async (ctx) => ctx.$query.find({}))
254
+ ;
212
255
 
213
- return WorkspaceMember;
256
+ return Constructor;
214
257
  };
215
258
 
216
259
  export type WorkspaceMemberAggregate<
@@ -1,4 +1,4 @@
1
- import { aggregate, date, string, type ArcId } from "@arcote.tech/arc";
1
+ import { aggregate, boolean, date, string, type ArcId } from "@arcote.tech/arc";
2
2
  import type { WorkspaceToken } from "../tokens/workspace-token";
3
3
 
4
4
  export type WorkspaceAggregateData = {
@@ -20,6 +20,7 @@ export const createWorkspaceAggregate = <
20
20
  name: string().minLength(1).maxLength(100),
21
21
  type: string(),
22
22
  ownerId: accountId,
23
+ isArchived: boolean(),
23
24
  createdAt: date(),
24
25
  })
25
26
  .publicEvent(
@@ -37,6 +38,7 @@ export const createWorkspaceAggregate = <
37
38
  name,
38
39
  type,
39
40
  ownerId,
41
+ isArchived: false,
40
42
  createdAt: event.createdAt,
41
43
  });
42
44
  },
@@ -114,8 +116,38 @@ export const createWorkspaceAggregate = <
114
116
  }),
115
117
  )
116
118
 
119
+ .publicEvent(
120
+ "workspaceArchived",
121
+ { workspaceId },
122
+ async (ctx, event) => {
123
+ await ctx.modify(event.payload.workspaceId, {
124
+ isArchived: true,
125
+ });
126
+ },
127
+ )
128
+
129
+ .mutateMethod(
130
+ "archive",
131
+ { params: { workspaceId } },
132
+ ONLY_SERVER &&
133
+ (async (ctx, params) => {
134
+ const existing = await ctx.$query.findOne({
135
+ _id: params.workspaceId,
136
+ });
137
+ if (!existing) {
138
+ return { error: "WORKSPACE_NOT_FOUND" as const };
139
+ }
140
+
141
+ await ctx.workspaceArchived.emit({
142
+ workspaceId: params.workspaceId,
143
+ });
144
+
145
+ return { success: true };
146
+ }),
147
+ )
148
+
117
149
  .clientQuery("getAll", async (ctx) => ctx.$query.find({}))
118
- .build();
150
+ ;
119
151
  };
120
152
 
121
153
  export type WorkspaceAggregate<
@@ -0,0 +1,12 @@
1
+ import { id } from "@arcote.tech/arc";
2
+
3
+ export type WorkspaceInvitationIdData = {
4
+ name: string;
5
+ };
6
+
7
+ export const createWorkspaceInvitationId = <const Data extends WorkspaceInvitationIdData>(
8
+ data: Readonly<Data>,
9
+ ) => id(`${data.name}WorkspaceInvitation`);
10
+
11
+ export type WorkspaceInvitationId<Data extends WorkspaceInvitationIdData = WorkspaceInvitationIdData> =
12
+ ReturnType<typeof createWorkspaceInvitationId<Data>>;
package/src/index.ts CHANGED
@@ -6,12 +6,16 @@ export { createWorkspaceAggregate } from "./aggregates/workspace";
6
6
  export type { WorkspaceAggregate } from "./aggregates/workspace";
7
7
  export { createWorkspaceMemberAggregate } from "./aggregates/workspace-member";
8
8
  export type { WorkspaceMemberAggregate } from "./aggregates/workspace-member";
9
+ export { createWorkspaceInvitationAggregate } from "./aggregates/workspace-invitation";
10
+ export type { WorkspaceInvitationAggregate } from "./aggregates/workspace-invitation";
9
11
 
10
12
  // --- ID factories & types ---
11
13
  export { createWorkspaceId } from "./ids/workspace";
12
14
  export type { WorkspaceId } from "./ids/workspace";
13
15
  export { createWorkspaceMemberId } from "./ids/workspace-member";
14
16
  export type { WorkspaceMemberId } from "./ids/workspace-member";
17
+ export { createWorkspaceInvitationId } from "./ids/workspace-invitation";
18
+ export type { WorkspaceInvitationId } from "./ids/workspace-invitation";
15
19
 
16
20
  // --- Token factory & type ---
17
21
  export { createWorkspaceToken } from "./tokens/workspace-token";
@@ -1,25 +1,25 @@
1
1
  import {
2
- aggregateContextElement,
3
2
  context,
4
3
  type ArcAggregateElement,
5
4
  type ArcContextElement,
6
- type AggregateConstructorAny,
7
5
  } from "@arcote.tech/arc";
8
6
  import type { AccountId, Token } from "@arcote.tech/arc-auth";
9
7
  import { createWorkspaceId } from "./ids/workspace";
10
8
  import { createWorkspaceMemberId } from "./ids/workspace-member";
9
+ import { createWorkspaceInvitationId } from "./ids/workspace-invitation";
11
10
  import { createWorkspaceToken } from "./tokens/workspace-token";
12
11
  import { createWorkspaceAggregate } from "./aggregates/workspace";
13
12
  import { createWorkspaceMemberAggregate } from "./aggregates/workspace-member";
13
+ import { createWorkspaceInvitationAggregate } from "./aggregates/workspace-invitation";
14
14
 
15
15
  export class WorkspaceBuilder<
16
16
  WsId,
17
17
  WsMemberId,
18
+ WsInvId,
18
19
  WsToken,
19
- Workspace extends AggregateConstructorAny,
20
- WorkspaceEl extends ArcAggregateElement<Workspace>,
21
- WorkspaceMember extends AggregateConstructorAny,
22
- WorkspaceMemberEl extends ArcAggregateElement<WorkspaceMember>,
20
+ Workspace extends ArcAggregateElement,
21
+ WorkspaceMember extends ArcAggregateElement,
22
+ WorkspaceInvitation extends ArcAggregateElement,
23
23
  Types extends readonly string[],
24
24
  Elements extends ArcContextElement<any>[],
25
25
  > {
@@ -27,11 +27,11 @@ export class WorkspaceBuilder<
27
27
  private readonly _name: string,
28
28
  readonly workspaceId: WsId,
29
29
  readonly workspaceMemberId: WsMemberId,
30
+ readonly workspaceInvitationId: WsInvId,
30
31
  readonly workspaceToken: WsToken,
31
32
  readonly Workspace: Workspace,
32
- readonly workspaceElement: WorkspaceEl,
33
33
  readonly WorkspaceMember: WorkspaceMember,
34
- readonly workspaceMemberElement: WorkspaceMemberEl,
34
+ readonly WorkspaceInvitation: WorkspaceInvitation,
35
35
  readonly types: Types,
36
36
  readonly elements: Elements,
37
37
  ) {}
@@ -41,16 +41,17 @@ export class WorkspaceBuilder<
41
41
  context: context(this.elements),
42
42
  workspaceId: this.workspaceId,
43
43
  workspaceMemberId: this.workspaceMemberId,
44
+ workspaceInvitationId: this.workspaceInvitationId,
44
45
  workspaceToken: this.workspaceToken,
45
46
  Workspace: this.Workspace,
46
47
  WorkspaceMember: this.WorkspaceMember,
48
+ WorkspaceInvitation: this.WorkspaceInvitation,
47
49
  types: this.types,
48
- // Protection helpers
49
50
  isOwner: (params: { role: string }) => params.role === "owner",
50
51
  isMember: (params: { role: string }) =>
51
52
  params.role === "member" || params.role === "owner",
52
- isType: (type: string) => (params: { workspaceType: string }) =>
53
- params.workspaceType === type,
53
+ isType: (...types: string[]) => (token: { params?: { workspaceType?: string } }) =>
54
+ types.includes(token.params?.workspaceType ?? ""),
54
55
  };
55
56
  }
56
57
  }
@@ -68,6 +69,7 @@ export function workspace<
68
69
  }) {
69
70
  const workspaceId = createWorkspaceId({ name: config.name });
70
71
  const workspaceMemberId = createWorkspaceMemberId({ name: config.name });
72
+ const workspaceInvitationId = createWorkspaceInvitationId({ name: config.name });
71
73
  const workspaceToken = createWorkspaceToken({
72
74
  name: config.name,
73
75
  secret: config.secret,
@@ -80,7 +82,15 @@ export function workspace<
80
82
  accountId: config.accountId,
81
83
  workspaceToken,
82
84
  });
83
- const workspaceElement = aggregateContextElement(Workspace);
85
+
86
+ const WorkspaceInvitation = createWorkspaceInvitationAggregate({
87
+ name: config.name,
88
+ workspaceInvitationId,
89
+ workspaceId,
90
+ accountId: config.accountId,
91
+ workspaceToken,
92
+ userToken: config.userToken,
93
+ });
84
94
 
85
95
  const WorkspaceMember = createWorkspaceMemberAggregate({
86
96
  name: config.name,
@@ -90,21 +100,19 @@ export function workspace<
90
100
  workspaceToken,
91
101
  userToken: config.userToken,
92
102
  WorkspaceAggregate: Workspace,
103
+ WorkspaceInvitationAggregate: WorkspaceInvitation,
93
104
  });
94
- const workspaceMemberElement = aggregateContextElement(WorkspaceMember);
95
-
96
- const elements = [workspaceElement, workspaceMemberElement];
97
105
 
98
106
  return new WorkspaceBuilder(
99
107
  config.name,
100
108
  workspaceId,
101
109
  workspaceMemberId,
110
+ workspaceInvitationId,
102
111
  workspaceToken,
103
112
  Workspace,
104
- workspaceElement,
105
113
  WorkspaceMember,
106
- workspaceMemberElement,
114
+ WorkspaceInvitation,
107
115
  config.types,
108
- elements,
116
+ [Workspace, WorkspaceMember, WorkspaceInvitation],
109
117
  );
110
118
  }