@jskit-ai/workspaces-core 0.1.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/package.descriptor.mjs +300 -0
- package/package.json +14 -0
- package/src/server/WorkspacesCoreServiceProvider.js +5 -0
- package/templates/config/roles.js +27 -0
- package/templates/migrations/workspaces_core_initial.cjs +86 -0
- package/templates/migrations/workspaces_core_workspace_settings_single_name_source.cjs +71 -0
- package/templates/migrations/workspaces_core_workspaces_drop_color.cjs +85 -0
- package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +197 -0
- package/test/exportsContract.test.js +41 -0
- package/test/workspacesRouteRequestInputValidator.test.js +474 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
export default Object.freeze({
|
|
2
|
+
packageVersion: 1,
|
|
3
|
+
packageId: "@jskit-ai/workspaces-core",
|
|
4
|
+
version: "0.1.1",
|
|
5
|
+
kind: "runtime",
|
|
6
|
+
description: "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
|
|
7
|
+
dependsOn: [
|
|
8
|
+
"@jskit-ai/users-core"
|
|
9
|
+
],
|
|
10
|
+
capabilities: {
|
|
11
|
+
provides: [
|
|
12
|
+
"workspaces.core",
|
|
13
|
+
"workspaces.server-routes"
|
|
14
|
+
],
|
|
15
|
+
requires: [
|
|
16
|
+
"users.core"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
runtime: {
|
|
20
|
+
server: {
|
|
21
|
+
providers: [
|
|
22
|
+
{
|
|
23
|
+
entrypoint: "src/server/WorkspacesCoreServiceProvider.js",
|
|
24
|
+
export: "WorkspacesCoreServiceProvider"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
client: {
|
|
29
|
+
providers: []
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
metadata: {
|
|
33
|
+
apiSummary: {
|
|
34
|
+
surfaces: [
|
|
35
|
+
{
|
|
36
|
+
subpath: "./server",
|
|
37
|
+
summary: "Exports the workspace runtime provider and workspace route registration surface."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
containerTokens: {
|
|
41
|
+
server: [],
|
|
42
|
+
client: []
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
server: {
|
|
46
|
+
routes: [
|
|
47
|
+
{
|
|
48
|
+
method: "POST",
|
|
49
|
+
path: "/api/workspaces",
|
|
50
|
+
summary: "Create a workspace for the authenticated user."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
method: "GET",
|
|
54
|
+
path: "/api/workspaces",
|
|
55
|
+
summary: "List workspaces visible to authenticated user."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
method: "GET",
|
|
59
|
+
path: "/api/workspace/invitations/pending",
|
|
60
|
+
summary: "List pending workspace invitations for authenticated user."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
method: "POST",
|
|
64
|
+
path: "/api/workspace/invitations/redeem",
|
|
65
|
+
summary: "Accept or refuse a workspace invitation using an invite token."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
method: "GET",
|
|
69
|
+
path: "/api/w/:workspaceSlug/settings",
|
|
70
|
+
summary: "Get workspace settings and role catalog by workspace slug."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
method: "PATCH",
|
|
74
|
+
path: "/api/w/:workspaceSlug/settings",
|
|
75
|
+
summary: "Update workspace settings by workspace slug."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
method: "GET",
|
|
79
|
+
path: "/api/w/:workspaceSlug/roles",
|
|
80
|
+
summary: "Get role catalog by workspace slug."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
method: "GET",
|
|
84
|
+
path: "/api/w/:workspaceSlug/members",
|
|
85
|
+
summary: "List members by workspace slug."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
method: "PATCH",
|
|
89
|
+
path: "/api/w/:workspaceSlug/members/:memberUserId/role",
|
|
90
|
+
summary: "Update workspace member role by workspace slug."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
method: "GET",
|
|
94
|
+
path: "/api/w/:workspaceSlug/invites",
|
|
95
|
+
summary: "List workspace invites by workspace slug."
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
method: "POST",
|
|
99
|
+
path: "/api/w/:workspaceSlug/invites",
|
|
100
|
+
summary: "Create workspace invite by workspace slug."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
method: "DELETE",
|
|
104
|
+
path: "/api/w/:workspaceSlug/invites/:inviteId",
|
|
105
|
+
summary: "Revoke workspace invite by workspace slug."
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
mutations: {
|
|
111
|
+
dependencies: {
|
|
112
|
+
runtime: {
|
|
113
|
+
"@jskit-ai/users-core": "0.1.35"
|
|
114
|
+
},
|
|
115
|
+
dev: {}
|
|
116
|
+
},
|
|
117
|
+
packageJson: {
|
|
118
|
+
scripts: {
|
|
119
|
+
"server:app": "SERVER_SURFACE=app node ./bin/server.js",
|
|
120
|
+
"server:admin": "SERVER_SURFACE=admin node ./bin/server.js"
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
procfile: {},
|
|
124
|
+
files: [
|
|
125
|
+
{
|
|
126
|
+
op: "install-migration",
|
|
127
|
+
from: "templates/migrations/workspaces_core_initial.cjs",
|
|
128
|
+
toDir: "migrations",
|
|
129
|
+
extension: ".cjs",
|
|
130
|
+
reason: "Install workspace tenancy schema migration.",
|
|
131
|
+
category: "migration",
|
|
132
|
+
id: "workspaces-core-initial-schema"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
op: "install-migration",
|
|
136
|
+
from: "templates/migrations/workspaces_core_workspace_settings_single_name_source.cjs",
|
|
137
|
+
toDir: "migrations",
|
|
138
|
+
extension: ".cjs",
|
|
139
|
+
reason: "Remove workspace_settings name/avatar fields so workspace identity data comes from workspaces only.",
|
|
140
|
+
category: "migration",
|
|
141
|
+
id: "users-core-workspace-settings-single-name-source"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
op: "install-migration",
|
|
145
|
+
from: "templates/migrations/workspaces_core_workspaces_drop_color.cjs",
|
|
146
|
+
toDir: "migrations",
|
|
147
|
+
extension: ".cjs",
|
|
148
|
+
reason: "Drop legacy workspaces.color now that workspace theme colors live in workspace_settings.",
|
|
149
|
+
category: "migration",
|
|
150
|
+
id: "users-core-workspaces-drop-color"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
from: "templates/packages/main/src/shared/resources/workspaceSettingsFields.js",
|
|
154
|
+
to: "packages/main/src/shared/resources/workspaceSettingsFields.js",
|
|
155
|
+
preserveOnRemove: true,
|
|
156
|
+
reason: "Install app-owned workspace settings field definitions.",
|
|
157
|
+
category: "workspaces-core",
|
|
158
|
+
id: "users-core-app-owned-workspace-settings-fields"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
from: "templates/config/roles.js",
|
|
162
|
+
to: "config/roles.js",
|
|
163
|
+
preserveOnRemove: true,
|
|
164
|
+
reason: "Install app-owned role catalog in a dedicated config file.",
|
|
165
|
+
category: "workspaces-core",
|
|
166
|
+
id: "users-core-app-owned-role-catalog-config"
|
|
167
|
+
}
|
|
168
|
+
],
|
|
169
|
+
text: [
|
|
170
|
+
{
|
|
171
|
+
op: "append-text",
|
|
172
|
+
file: "packages/main/src/shared/index.js",
|
|
173
|
+
position: "top",
|
|
174
|
+
skipIfContains: "import \"./resources/workspaceSettingsFields.js\";",
|
|
175
|
+
value: "import \"./resources/workspaceSettingsFields.js\";\n",
|
|
176
|
+
reason: "Load app-owned workspace settings field definitions inside the main shared module.",
|
|
177
|
+
category: "workspaces-core",
|
|
178
|
+
id: "users-core-main-shared-workspace-settings-field-import"
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
op: "append-text",
|
|
182
|
+
file: "config/public.js",
|
|
183
|
+
position: "top",
|
|
184
|
+
skipIfContains: "import { roleCatalog } from \"./roles.js\";",
|
|
185
|
+
value: "import { roleCatalog } from \"./roles.js\";\n",
|
|
186
|
+
reason: "Load app-owned role catalog from dedicated config file.",
|
|
187
|
+
category: "workspaces-core",
|
|
188
|
+
id: "users-core-role-catalog-public-import"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
op: "append-text",
|
|
192
|
+
file: "config/public.js",
|
|
193
|
+
position: "top",
|
|
194
|
+
skipIfContains: "import { surfaceAccessPolicies } from \"./surfaceAccessPolicies.js\";",
|
|
195
|
+
value: "import { surfaceAccessPolicies } from \"./surfaceAccessPolicies.js\";\n",
|
|
196
|
+
reason: "Load app-owned surface access policy catalog from dedicated config file.",
|
|
197
|
+
category: "workspaces-core",
|
|
198
|
+
id: "users-core-surface-access-policies-public-import"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
op: "append-text",
|
|
202
|
+
file: "config/surfaceAccessPolicies.js",
|
|
203
|
+
position: "top",
|
|
204
|
+
skipIfContains: "export const surfaceAccessPolicies = {};",
|
|
205
|
+
value: "export const surfaceAccessPolicies = {};\n\n",
|
|
206
|
+
reason: "Initialize app-owned surface access policy config if missing.",
|
|
207
|
+
category: "workspaces-core",
|
|
208
|
+
id: "users-core-surface-access-policies-config-init"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
op: "append-text",
|
|
212
|
+
file: "config/surfaceAccessPolicies.js",
|
|
213
|
+
position: "bottom",
|
|
214
|
+
skipIfContains: "surfaceAccessPolicies.workspace_member = {",
|
|
215
|
+
value: "\nsurfaceAccessPolicies.workspace_member = {\n requireAuth: true,\n requireWorkspaceMembership: true\n};\n",
|
|
216
|
+
reason: "Register workspace-member surface access policy for workspace surfaces.",
|
|
217
|
+
category: "workspaces-core",
|
|
218
|
+
id: "users-core-surface-access-policies-workspace-member"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
op: "append-text",
|
|
222
|
+
file: "config/public.js",
|
|
223
|
+
position: "bottom",
|
|
224
|
+
skipIfContains: "config.surfaceDefinitions.app = {",
|
|
225
|
+
value:
|
|
226
|
+
"\nconfig.surfaceDefinitions.app = {\n id: \"app\",\n label: \"App\",\n pagesRoot: \"w/[workspaceSlug]\",\n enabled: true,\n requiresAuth: true,\n requiresWorkspace: true,\n accessPolicyId: \"workspace_member\",\n origin: \"\"\n};\n\nconfig.surfaceDefinitions.admin = {\n id: \"admin\",\n label: \"Admin\",\n pagesRoot: \"w/[workspaceSlug]/admin\",\n enabled: true,\n requiresAuth: true,\n requiresWorkspace: true,\n accessPolicyId: \"workspace_member\",\n origin: \"\"\n};\n",
|
|
227
|
+
reason: "Append workspace surface topology when tenancy enables workspace routing.",
|
|
228
|
+
category: "workspaces-core",
|
|
229
|
+
id: "users-core-surface-config-workspace",
|
|
230
|
+
when: {
|
|
231
|
+
config: "tenancyMode",
|
|
232
|
+
in: ["personal", "workspaces"]
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
op: "append-text",
|
|
237
|
+
file: "config/public.js",
|
|
238
|
+
position: "bottom",
|
|
239
|
+
skipIfContains: "config.workspaceSwitching =",
|
|
240
|
+
value:
|
|
241
|
+
"\nconfig.workspaceSwitching = true;\nconfig.workspaceInvitations = {\n enabled: true,\n allowInPersonalMode: true\n};\nconfig.assistantEnabled = false;\nconfig.assistantRequiredPermission = \"\";\nconfig.socialEnabled = false;\nconfig.socialFederationEnabled = false;\n",
|
|
242
|
+
reason: "Append default workspace feature toggles into app-owned config.",
|
|
243
|
+
category: "workspaces-core",
|
|
244
|
+
id: "users-core-public-config"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
op: "append-text",
|
|
248
|
+
file: "config/public.js",
|
|
249
|
+
position: "bottom",
|
|
250
|
+
skipIfContains: "config.roleCatalog = roleCatalog;",
|
|
251
|
+
value: "\nconfig.roleCatalog = roleCatalog;\n",
|
|
252
|
+
reason: "Bind app-owned role catalog onto public config.",
|
|
253
|
+
category: "workspaces-core",
|
|
254
|
+
id: "users-core-role-catalog-public-config"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
op: "append-text",
|
|
258
|
+
file: "config/public.js",
|
|
259
|
+
position: "bottom",
|
|
260
|
+
skipIfContains: "config.surfaceAccessPolicies = surfaceAccessPolicies;",
|
|
261
|
+
value: "\nconfig.surfaceAccessPolicies = surfaceAccessPolicies;\n",
|
|
262
|
+
reason: "Bind app-owned surface access policies onto public config.",
|
|
263
|
+
category: "workspaces-core",
|
|
264
|
+
id: "users-core-surface-access-policies-public-config"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
op: "append-text",
|
|
268
|
+
file: "config/server.js",
|
|
269
|
+
position: "bottom",
|
|
270
|
+
skipIfContains: "config.workspaceColor =",
|
|
271
|
+
value: "\nconfig.workspaceColor = \"#1867C0\";\n",
|
|
272
|
+
reason: "Append default workspace server settings into app-owned config.",
|
|
273
|
+
category: "workspaces-core",
|
|
274
|
+
id: "users-core-server-config"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
op: "append-text",
|
|
278
|
+
file: "config/server.js",
|
|
279
|
+
position: "bottom",
|
|
280
|
+
skipIfContains: "config.workspaceSettings =",
|
|
281
|
+
value:
|
|
282
|
+
"\nconfig.workspaceSettings = {\n defaults: {\n invitesEnabled: true\n }\n};\n",
|
|
283
|
+
reason: "Append app-owned workspace settings defaults into the server config.",
|
|
284
|
+
category: "workspaces-core",
|
|
285
|
+
id: "users-core-workspace-settings-server-config"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
op: "append-text",
|
|
289
|
+
file: "config/server.js",
|
|
290
|
+
position: "bottom",
|
|
291
|
+
skipIfContains: "config.workspaceMembers =",
|
|
292
|
+
value:
|
|
293
|
+
"\nconfig.workspaceMembers = {\n defaults: {\n inviteExpiresInMs: 604800000\n }\n};\n",
|
|
294
|
+
reason: "Append app-owned workspace member invite policy defaults into the server config.",
|
|
295
|
+
category: "workspaces-core",
|
|
296
|
+
id: "users-core-workspace-members-server-config"
|
|
297
|
+
}
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/workspaces-core",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./server/WorkspacesCoreServiceProvider": "./src/server/WorkspacesCoreServiceProvider.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@jskit-ai/users-core": "0.1.35"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const roleCatalog = {
|
|
2
|
+
workspace: {
|
|
3
|
+
defaultInviteRole: "member"
|
|
4
|
+
},
|
|
5
|
+
roles: {
|
|
6
|
+
owner: {
|
|
7
|
+
assignable: false,
|
|
8
|
+
permissions: ["*"]
|
|
9
|
+
},
|
|
10
|
+
admin: {
|
|
11
|
+
assignable: true,
|
|
12
|
+
inherits: "member",
|
|
13
|
+
permissions: [
|
|
14
|
+
"workspace.roles.view",
|
|
15
|
+
"workspace.settings.update",
|
|
16
|
+
"workspace.members.view",
|
|
17
|
+
"workspace.members.invite",
|
|
18
|
+
"workspace.members.manage",
|
|
19
|
+
"workspace.invites.revoke"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
member: {
|
|
23
|
+
assignable: true,
|
|
24
|
+
permissions: ["workspace.settings.view"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {import('knex').Knex} knex
|
|
3
|
+
*/
|
|
4
|
+
exports.up = async function up(knex) {
|
|
5
|
+
const hasUsersTable = await knex.schema.hasTable("users");
|
|
6
|
+
if (!hasUsersTable) {
|
|
7
|
+
throw new Error("workspaces-core initial migration requires users table.");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const hasWorkspacesTable = await knex.schema.hasTable("workspaces");
|
|
11
|
+
if (!hasWorkspacesTable) {
|
|
12
|
+
await knex.schema.createTable("workspaces", (table) => {
|
|
13
|
+
table.increments("id").primary();
|
|
14
|
+
table.string("slug", 120).notNullable().unique();
|
|
15
|
+
table.string("name", 160).notNullable();
|
|
16
|
+
table.integer("owner_user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
|
17
|
+
table.boolean("is_personal").notNullable().defaultTo(true);
|
|
18
|
+
table.string("avatar_url", 512).notNullable().defaultTo("");
|
|
19
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
20
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
21
|
+
table.timestamp("deleted_at", { useTz: false }).nullable();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hasWorkspaceMembershipsTable = await knex.schema.hasTable("workspace_memberships");
|
|
26
|
+
if (!hasWorkspaceMembershipsTable) {
|
|
27
|
+
await knex.schema.createTable("workspace_memberships", (table) => {
|
|
28
|
+
table.increments("id").primary();
|
|
29
|
+
table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
|
|
30
|
+
table.integer("user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
|
31
|
+
table.string("role_sid", 64).notNullable().defaultTo("member");
|
|
32
|
+
table.string("status", 32).notNullable().defaultTo("active");
|
|
33
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
34
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
35
|
+
table.unique(["workspace_id", "user_id"], "uq_workspace_memberships_workspace_user");
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const hasWorkspaceSettingsTable = await knex.schema.hasTable("workspace_settings");
|
|
40
|
+
if (!hasWorkspaceSettingsTable) {
|
|
41
|
+
await knex.schema.createTable("workspace_settings", (table) => {
|
|
42
|
+
table.integer("workspace_id").unsigned().primary().references("id").inTable("workspaces").onDelete("CASCADE");
|
|
43
|
+
table.string("light_primary_color", 7).notNullable().defaultTo("#1867C0");
|
|
44
|
+
table.string("light_secondary_color", 7).notNullable().defaultTo("#48A9A6");
|
|
45
|
+
table.string("light_surface_color", 7).notNullable().defaultTo("#FFFFFF");
|
|
46
|
+
table.string("light_surface_variant_color", 7).notNullable().defaultTo("#424242");
|
|
47
|
+
table.string("dark_primary_color", 7).notNullable().defaultTo("#2196F3");
|
|
48
|
+
table.string("dark_secondary_color", 7).notNullable().defaultTo("#54B6B2");
|
|
49
|
+
table.string("dark_surface_color", 7).notNullable().defaultTo("#212121");
|
|
50
|
+
table.string("dark_surface_variant_color", 7).notNullable().defaultTo("#C8C8C8");
|
|
51
|
+
table.boolean("invites_enabled").notNullable().defaultTo(true);
|
|
52
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
53
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const hasWorkspaceInvitesTable = await knex.schema.hasTable("workspace_invites");
|
|
58
|
+
if (!hasWorkspaceInvitesTable) {
|
|
59
|
+
await knex.schema.createTable("workspace_invites", (table) => {
|
|
60
|
+
table.increments("id").primary();
|
|
61
|
+
table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
|
|
62
|
+
table.string("email", 255).notNullable();
|
|
63
|
+
table.string("role_sid", 64).notNullable().defaultTo("member");
|
|
64
|
+
table.string("status", 32).notNullable().defaultTo("pending");
|
|
65
|
+
table.string("token_hash", 191).notNullable();
|
|
66
|
+
table.integer("invited_by_user_id").unsigned().nullable().references("id").inTable("users").onDelete("SET NULL");
|
|
67
|
+
table.timestamp("expires_at", { useTz: false }).nullable();
|
|
68
|
+
table.timestamp("accepted_at", { useTz: false }).nullable();
|
|
69
|
+
table.timestamp("revoked_at", { useTz: false }).nullable();
|
|
70
|
+
table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
71
|
+
table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
|
|
72
|
+
table.unique(["token_hash"], "uq_workspace_invites_token_hash");
|
|
73
|
+
table.index(["workspace_id", "status"], "idx_workspace_invites_workspace_status");
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {import('knex').Knex} knex
|
|
80
|
+
*/
|
|
81
|
+
exports.down = async function down(knex) {
|
|
82
|
+
await knex.schema.dropTableIfExists("workspace_invites");
|
|
83
|
+
await knex.schema.dropTableIfExists("workspace_settings");
|
|
84
|
+
await knex.schema.dropTableIfExists("workspace_memberships");
|
|
85
|
+
await knex.schema.dropTableIfExists("workspaces");
|
|
86
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const WORKSPACE_SETTINGS_TABLE = "workspace_settings";
|
|
2
|
+
const WORKSPACES_TABLE = "workspaces";
|
|
3
|
+
const LEGACY_NAME_COLUMN = "name";
|
|
4
|
+
const LEGACY_AVATAR_COLUMN = "avatar_url";
|
|
5
|
+
|
|
6
|
+
async function hasTable(knex, tableName) {
|
|
7
|
+
return knex.schema.hasTable(tableName);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function hasColumn(knex, tableName, columnName) {
|
|
11
|
+
return knex.schema.hasColumn(tableName, columnName);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
exports.up = async function up(knex) {
|
|
15
|
+
const hasWorkspaceSettings = await hasTable(knex, WORKSPACE_SETTINGS_TABLE);
|
|
16
|
+
if (!hasWorkspaceSettings) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const hasLegacyName = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_NAME_COLUMN);
|
|
21
|
+
const hasLegacyAvatarUrl = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_AVATAR_COLUMN);
|
|
22
|
+
if (!hasLegacyName && !hasLegacyAvatarUrl) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await knex.schema.alterTable(WORKSPACE_SETTINGS_TABLE, (table) => {
|
|
27
|
+
if (hasLegacyName) {
|
|
28
|
+
table.dropColumn(LEGACY_NAME_COLUMN);
|
|
29
|
+
}
|
|
30
|
+
if (hasLegacyAvatarUrl) {
|
|
31
|
+
table.dropColumn(LEGACY_AVATAR_COLUMN);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
exports.down = async function down(knex) {
|
|
37
|
+
const hasWorkspaceSettings = await hasTable(knex, WORKSPACE_SETTINGS_TABLE);
|
|
38
|
+
if (!hasWorkspaceSettings) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hasLegacyName = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_NAME_COLUMN);
|
|
43
|
+
const hasLegacyAvatarUrl = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_AVATAR_COLUMN);
|
|
44
|
+
if (!hasLegacyName || !hasLegacyAvatarUrl) {
|
|
45
|
+
await knex.schema.alterTable(WORKSPACE_SETTINGS_TABLE, (table) => {
|
|
46
|
+
if (!hasLegacyName) {
|
|
47
|
+
table.string(LEGACY_NAME_COLUMN, 160).notNullable().defaultTo("Workspace");
|
|
48
|
+
}
|
|
49
|
+
if (!hasLegacyAvatarUrl) {
|
|
50
|
+
table.string(LEGACY_AVATAR_COLUMN, 512).notNullable().defaultTo("");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const hasWorkspaces = await hasTable(knex, WORKSPACES_TABLE);
|
|
56
|
+
if (!hasWorkspaces) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const workspaceRows = await knex(WORKSPACES_TABLE).select("id", "name", "avatar_url");
|
|
61
|
+
for (const workspaceRow of workspaceRows) {
|
|
62
|
+
const normalizedName = String(workspaceRow?.name || "").trim() || "Workspace";
|
|
63
|
+
const normalizedAvatarUrl = String(workspaceRow?.avatar_url || "").trim();
|
|
64
|
+
await knex(WORKSPACE_SETTINGS_TABLE)
|
|
65
|
+
.where({ workspace_id: Number(workspaceRow.id) })
|
|
66
|
+
.update({
|
|
67
|
+
name: normalizedName,
|
|
68
|
+
avatar_url: normalizedAvatarUrl
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const WORKSPACES_TABLE = "workspaces";
|
|
2
|
+
const WORKSPACE_SETTINGS_TABLE = "workspace_settings";
|
|
3
|
+
const LEGACY_COLOR_COLUMN = "color";
|
|
4
|
+
const SETTINGS_LIGHT_PRIMARY_COLOR_COLUMN = "light_primary_color";
|
|
5
|
+
const DEFAULT_WORKSPACE_COLOR = "#1867C0";
|
|
6
|
+
|
|
7
|
+
async function hasTable(knex, tableName) {
|
|
8
|
+
return knex.schema.hasTable(tableName);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function hasColumn(knex, tableName, columnName) {
|
|
12
|
+
return knex.schema.hasColumn(tableName, columnName);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeHexColor(value) {
|
|
16
|
+
const normalized = String(value || "").trim().toUpperCase();
|
|
17
|
+
return /^#[0-9A-F]{6}$/.test(normalized) ? normalized : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
exports.up = async function up(knex) {
|
|
21
|
+
const hasWorkspaces = await hasTable(knex, WORKSPACES_TABLE);
|
|
22
|
+
if (!hasWorkspaces) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const hasLegacyColor = await hasColumn(knex, WORKSPACES_TABLE, LEGACY_COLOR_COLUMN);
|
|
27
|
+
if (!hasLegacyColor) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await knex.schema.alterTable(WORKSPACES_TABLE, (table) => {
|
|
32
|
+
table.dropColumn(LEGACY_COLOR_COLUMN);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
exports.down = async function down(knex) {
|
|
37
|
+
const hasWorkspaces = await hasTable(knex, WORKSPACES_TABLE);
|
|
38
|
+
if (!hasWorkspaces) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hasLegacyColor = await hasColumn(knex, WORKSPACES_TABLE, LEGACY_COLOR_COLUMN);
|
|
43
|
+
if (!hasLegacyColor) {
|
|
44
|
+
await knex.schema.alterTable(WORKSPACES_TABLE, (table) => {
|
|
45
|
+
table.string(LEGACY_COLOR_COLUMN, 7).notNullable().defaultTo(DEFAULT_WORKSPACE_COLOR);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const hasWorkspaceSettings = await hasTable(knex, WORKSPACE_SETTINGS_TABLE);
|
|
50
|
+
if (!hasWorkspaceSettings) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hasLightPrimaryColor = await hasColumn(
|
|
55
|
+
knex,
|
|
56
|
+
WORKSPACE_SETTINGS_TABLE,
|
|
57
|
+
SETTINGS_LIGHT_PRIMARY_COLOR_COLUMN
|
|
58
|
+
);
|
|
59
|
+
if (!hasLightPrimaryColor) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const workspaceSettingsRows = await knex(WORKSPACE_SETTINGS_TABLE).select(
|
|
64
|
+
"workspace_id",
|
|
65
|
+
SETTINGS_LIGHT_PRIMARY_COLOR_COLUMN
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
for (const row of workspaceSettingsRows) {
|
|
69
|
+
const workspaceId = Number(row?.workspace_id || 0);
|
|
70
|
+
if (!Number.isInteger(workspaceId) || workspaceId < 1) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const restoredColor = normalizeHexColor(row?.[SETTINGS_LIGHT_PRIMARY_COLOR_COLUMN]);
|
|
75
|
+
if (!restoredColor) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await knex(WORKSPACES_TABLE)
|
|
80
|
+
.where({ id: workspaceId })
|
|
81
|
+
.update({
|
|
82
|
+
color: restoredColor
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @jskit-contract users.settings-fields.workspace.v1
|
|
2
|
+
// Append-only settings field registrations for workspace settings.
|
|
3
|
+
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_WORKSPACE_DARK_PALETTE,
|
|
8
|
+
DEFAULT_WORKSPACE_LIGHT_PALETTE,
|
|
9
|
+
coerceWorkspaceThemeColor
|
|
10
|
+
} from "@jskit-ai/users-core/shared/settings";
|
|
11
|
+
import {
|
|
12
|
+
defineField,
|
|
13
|
+
resetWorkspaceSettingsFields
|
|
14
|
+
} from "@jskit-ai/users-core/shared/resources/workspaceSettingsFields";
|
|
15
|
+
|
|
16
|
+
function normalizeHexColor(value) {
|
|
17
|
+
const color = normalizeText(value);
|
|
18
|
+
return /^#[0-9A-Fa-f]{6}$/.test(color) ? color.toUpperCase() : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
resetWorkspaceSettingsFields();
|
|
22
|
+
|
|
23
|
+
defineField({
|
|
24
|
+
key: "lightPrimaryColor",
|
|
25
|
+
dbColumn: "light_primary_color",
|
|
26
|
+
required: true,
|
|
27
|
+
inputSchema: Type.String({
|
|
28
|
+
minLength: 7,
|
|
29
|
+
maxLength: 7,
|
|
30
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
31
|
+
messages: {
|
|
32
|
+
required: "Light primary color is required.",
|
|
33
|
+
pattern: "Light primary color must be a hex color like #1867C0.",
|
|
34
|
+
default: "Light primary color must be a hex color like #1867C0."
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
38
|
+
normalizeInput: normalizeHexColor,
|
|
39
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_LIGHT_PALETTE.color),
|
|
40
|
+
resolveDefault: () => DEFAULT_WORKSPACE_LIGHT_PALETTE.color
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
defineField({
|
|
44
|
+
key: "lightSecondaryColor",
|
|
45
|
+
dbColumn: "light_secondary_color",
|
|
46
|
+
required: true,
|
|
47
|
+
inputSchema: Type.String({
|
|
48
|
+
minLength: 7,
|
|
49
|
+
maxLength: 7,
|
|
50
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
51
|
+
messages: {
|
|
52
|
+
required: "Light secondary color is required.",
|
|
53
|
+
pattern: "Light secondary color must be a hex color like #48A9A6.",
|
|
54
|
+
default: "Light secondary color must be a hex color like #48A9A6."
|
|
55
|
+
}
|
|
56
|
+
}),
|
|
57
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
58
|
+
normalizeInput: normalizeHexColor,
|
|
59
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_LIGHT_PALETTE.secondaryColor),
|
|
60
|
+
resolveDefault: () => DEFAULT_WORKSPACE_LIGHT_PALETTE.secondaryColor
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
defineField({
|
|
64
|
+
key: "lightSurfaceColor",
|
|
65
|
+
dbColumn: "light_surface_color",
|
|
66
|
+
required: true,
|
|
67
|
+
inputSchema: Type.String({
|
|
68
|
+
minLength: 7,
|
|
69
|
+
maxLength: 7,
|
|
70
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
71
|
+
messages: {
|
|
72
|
+
required: "Light surface color is required.",
|
|
73
|
+
pattern: "Light surface color must be a hex color like #FFFFFF.",
|
|
74
|
+
default: "Light surface color must be a hex color like #FFFFFF."
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
78
|
+
normalizeInput: normalizeHexColor,
|
|
79
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_LIGHT_PALETTE.surfaceColor),
|
|
80
|
+
resolveDefault: () => DEFAULT_WORKSPACE_LIGHT_PALETTE.surfaceColor
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
defineField({
|
|
84
|
+
key: "lightSurfaceVariantColor",
|
|
85
|
+
dbColumn: "light_surface_variant_color",
|
|
86
|
+
required: true,
|
|
87
|
+
inputSchema: Type.String({
|
|
88
|
+
minLength: 7,
|
|
89
|
+
maxLength: 7,
|
|
90
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
91
|
+
messages: {
|
|
92
|
+
required: "Light surface variant color is required.",
|
|
93
|
+
pattern: "Light surface variant color must be a hex color like #424242.",
|
|
94
|
+
default: "Light surface variant color must be a hex color like #424242."
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
98
|
+
normalizeInput: normalizeHexColor,
|
|
99
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_LIGHT_PALETTE.surfaceVariantColor),
|
|
100
|
+
resolveDefault: () => DEFAULT_WORKSPACE_LIGHT_PALETTE.surfaceVariantColor
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
defineField({
|
|
104
|
+
key: "darkPrimaryColor",
|
|
105
|
+
dbColumn: "dark_primary_color",
|
|
106
|
+
required: true,
|
|
107
|
+
inputSchema: Type.String({
|
|
108
|
+
minLength: 7,
|
|
109
|
+
maxLength: 7,
|
|
110
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
111
|
+
messages: {
|
|
112
|
+
required: "Dark primary color is required.",
|
|
113
|
+
pattern: "Dark primary color must be a hex color like #2196F3.",
|
|
114
|
+
default: "Dark primary color must be a hex color like #2196F3."
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
118
|
+
normalizeInput: normalizeHexColor,
|
|
119
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_DARK_PALETTE.color),
|
|
120
|
+
resolveDefault: () => DEFAULT_WORKSPACE_DARK_PALETTE.color
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
defineField({
|
|
124
|
+
key: "darkSecondaryColor",
|
|
125
|
+
dbColumn: "dark_secondary_color",
|
|
126
|
+
required: true,
|
|
127
|
+
inputSchema: Type.String({
|
|
128
|
+
minLength: 7,
|
|
129
|
+
maxLength: 7,
|
|
130
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
131
|
+
messages: {
|
|
132
|
+
required: "Dark secondary color is required.",
|
|
133
|
+
pattern: "Dark secondary color must be a hex color like #54B6B2.",
|
|
134
|
+
default: "Dark secondary color must be a hex color like #54B6B2."
|
|
135
|
+
}
|
|
136
|
+
}),
|
|
137
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
138
|
+
normalizeInput: normalizeHexColor,
|
|
139
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_DARK_PALETTE.secondaryColor),
|
|
140
|
+
resolveDefault: () => DEFAULT_WORKSPACE_DARK_PALETTE.secondaryColor
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
defineField({
|
|
144
|
+
key: "darkSurfaceColor",
|
|
145
|
+
dbColumn: "dark_surface_color",
|
|
146
|
+
required: true,
|
|
147
|
+
inputSchema: Type.String({
|
|
148
|
+
minLength: 7,
|
|
149
|
+
maxLength: 7,
|
|
150
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
151
|
+
messages: {
|
|
152
|
+
required: "Dark surface color is required.",
|
|
153
|
+
pattern: "Dark surface color must be a hex color like #212121.",
|
|
154
|
+
default: "Dark surface color must be a hex color like #212121."
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
158
|
+
normalizeInput: normalizeHexColor,
|
|
159
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_DARK_PALETTE.surfaceColor),
|
|
160
|
+
resolveDefault: () => DEFAULT_WORKSPACE_DARK_PALETTE.surfaceColor
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
defineField({
|
|
164
|
+
key: "darkSurfaceVariantColor",
|
|
165
|
+
dbColumn: "dark_surface_variant_color",
|
|
166
|
+
required: true,
|
|
167
|
+
inputSchema: Type.String({
|
|
168
|
+
minLength: 7,
|
|
169
|
+
maxLength: 7,
|
|
170
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
171
|
+
messages: {
|
|
172
|
+
required: "Dark surface variant color is required.",
|
|
173
|
+
pattern: "Dark surface variant color must be a hex color like #C8C8C8.",
|
|
174
|
+
default: "Dark surface variant color must be a hex color like #C8C8C8."
|
|
175
|
+
}
|
|
176
|
+
}),
|
|
177
|
+
outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
|
|
178
|
+
normalizeInput: normalizeHexColor,
|
|
179
|
+
normalizeOutput: (value) => coerceWorkspaceThemeColor(value, DEFAULT_WORKSPACE_DARK_PALETTE.surfaceVariantColor),
|
|
180
|
+
resolveDefault: () => DEFAULT_WORKSPACE_DARK_PALETTE.surfaceVariantColor
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
defineField({
|
|
184
|
+
key: "invitesEnabled",
|
|
185
|
+
dbColumn: "invites_enabled",
|
|
186
|
+
required: true,
|
|
187
|
+
inputSchema: Type.Boolean({
|
|
188
|
+
messages: {
|
|
189
|
+
required: "invitesEnabled is required.",
|
|
190
|
+
default: "invitesEnabled must be a boolean."
|
|
191
|
+
}
|
|
192
|
+
}),
|
|
193
|
+
outputSchema: Type.Boolean(),
|
|
194
|
+
normalizeInput: (value) => value === true,
|
|
195
|
+
normalizeOutput: (value) => value !== false,
|
|
196
|
+
resolveDefault: ({ defaultInvitesEnabled } = {}) => defaultInvitesEnabled !== false
|
|
197
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { evaluatePackageExportsContract } from "../../../tooling/test-support/exportsContract.mjs";
|
|
6
|
+
|
|
7
|
+
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
|
|
9
|
+
const PACKAGE_DIR = path.join(REPO_ROOT, "packages", "workspaces-core");
|
|
10
|
+
|
|
11
|
+
test("workspaces-core exports are explicit and aligned with production usage", () => {
|
|
12
|
+
const result = evaluatePackageExportsContract({
|
|
13
|
+
repoRoot: REPO_ROOT,
|
|
14
|
+
packageDir: PACKAGE_DIR,
|
|
15
|
+
packageId: "@jskit-ai/workspaces-core",
|
|
16
|
+
requiredExports: [
|
|
17
|
+
"./server/WorkspacesCoreServiceProvider"
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
result.wildcardExports,
|
|
23
|
+
[],
|
|
24
|
+
`workspaces-core exports must be explicit. Remove wildcard keys: ${result.wildcardExports.join(", ")}`
|
|
25
|
+
);
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
result.missingRequiredExports,
|
|
28
|
+
[],
|
|
29
|
+
`workspaces-core required exports missing: ${result.missingRequiredExports.join(", ")}`
|
|
30
|
+
);
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
result.missingExports,
|
|
33
|
+
[],
|
|
34
|
+
`workspaces-core imports missing from package exports:\n${result.missingExports.join("\n")}`
|
|
35
|
+
);
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
result.staleExports,
|
|
38
|
+
[],
|
|
39
|
+
`Stale workspaces-core exports found. Remove stale keys: ${result.staleExports.join(", ")}`
|
|
40
|
+
);
|
|
41
|
+
});
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { UsersCoreServiceProvider } from "../../users-core/src/server/UsersCoreServiceProvider.js";
|
|
4
|
+
import { resolveTenancyProfile } from "../../users-core/src/shared/tenancyProfile.js";
|
|
5
|
+
import { WorkspacesCoreServiceProvider } from "../src/server/WorkspacesCoreServiceProvider.js";
|
|
6
|
+
|
|
7
|
+
function createReplyDouble() {
|
|
8
|
+
return {
|
|
9
|
+
statusCode: 200,
|
|
10
|
+
payload: null,
|
|
11
|
+
redirectedTo: "",
|
|
12
|
+
code(value) {
|
|
13
|
+
this.statusCode = value;
|
|
14
|
+
return this;
|
|
15
|
+
},
|
|
16
|
+
send(value) {
|
|
17
|
+
this.payload = value;
|
|
18
|
+
return this;
|
|
19
|
+
},
|
|
20
|
+
redirect(value) {
|
|
21
|
+
this.redirectedTo = String(value || "");
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function findRoute(routes, { method, path }) {
|
|
28
|
+
return routes.find((route) => route.method === method && route.path === path) || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function registerRoutes({
|
|
32
|
+
authService = {},
|
|
33
|
+
consoleService = null,
|
|
34
|
+
workspaceEnabled = true,
|
|
35
|
+
workspaceTenancyEnabled = true,
|
|
36
|
+
workspaceInvitationsEnabled = true,
|
|
37
|
+
workspaceSelfCreateEnabled = true
|
|
38
|
+
} = {}) {
|
|
39
|
+
const registeredRoutes = [];
|
|
40
|
+
const router = {
|
|
41
|
+
register(method, path, route, handler) {
|
|
42
|
+
registeredRoutes.push({
|
|
43
|
+
...route,
|
|
44
|
+
method,
|
|
45
|
+
path,
|
|
46
|
+
handler
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const bindings = new Map([
|
|
52
|
+
["jskit.http.router", router],
|
|
53
|
+
["authService", authService],
|
|
54
|
+
[
|
|
55
|
+
"users.accountProfile.service",
|
|
56
|
+
{
|
|
57
|
+
async readAvatar() {
|
|
58
|
+
return {
|
|
59
|
+
mimeType: "image/png",
|
|
60
|
+
buffer: Buffer.from([])
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
["actionExecutor", {}],
|
|
66
|
+
["users.workspace.enabled", workspaceEnabled],
|
|
67
|
+
["users.workspace.tenancy.enabled", workspaceTenancyEnabled],
|
|
68
|
+
["users.workspace.invitations.enabled", workspaceInvitationsEnabled],
|
|
69
|
+
["users.workspace.self-create.enabled", workspaceSelfCreateEnabled]
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
if (consoleService) {
|
|
73
|
+
bindings.set("consoleService", consoleService);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const app = {
|
|
77
|
+
has(token) {
|
|
78
|
+
return bindings.has(token);
|
|
79
|
+
},
|
|
80
|
+
make(token) {
|
|
81
|
+
if (!bindings.has(token)) {
|
|
82
|
+
throw new Error(`Missing test binding for token: ${String(token)}`);
|
|
83
|
+
}
|
|
84
|
+
return bindings.get(token);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const usersCoreProvider = new UsersCoreServiceProvider();
|
|
89
|
+
const workspacesCoreProvider = new WorkspacesCoreServiceProvider();
|
|
90
|
+
await usersCoreProvider.boot(app);
|
|
91
|
+
await workspacesCoreProvider.boot(app);
|
|
92
|
+
|
|
93
|
+
return registeredRoutes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function registerRoutesForMode({
|
|
97
|
+
tenancyMode = "none",
|
|
98
|
+
tenancyPolicy = {}
|
|
99
|
+
} = {}) {
|
|
100
|
+
const tenancyProfile = resolveTenancyProfile({
|
|
101
|
+
tenancyMode,
|
|
102
|
+
tenancyPolicy
|
|
103
|
+
});
|
|
104
|
+
return registerRoutes({
|
|
105
|
+
workspaceEnabled: tenancyProfile.workspace.enabled === true,
|
|
106
|
+
workspaceTenancyEnabled: tenancyProfile.mode === "workspaces",
|
|
107
|
+
workspaceInvitationsEnabled:
|
|
108
|
+
tenancyProfile.workspace.enabled === true && tenancyProfile.mode !== "none",
|
|
109
|
+
workspaceSelfCreateEnabled: tenancyProfile.workspace.allowSelfCreate === true
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function createActionRequest({ input = {}, executeAction, file = null }) {
|
|
114
|
+
return {
|
|
115
|
+
input,
|
|
116
|
+
executeAction,
|
|
117
|
+
file,
|
|
118
|
+
user: {
|
|
119
|
+
id: 42
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("workspace and settings routes attach only the shared transport normalizers they actually use", async () => {
|
|
125
|
+
const routes = await registerRoutes();
|
|
126
|
+
|
|
127
|
+
const workspaceSettings = findRoute(routes, {
|
|
128
|
+
method: "GET",
|
|
129
|
+
path: "/api/w/:workspaceSlug/settings"
|
|
130
|
+
});
|
|
131
|
+
const workspacePatch = findRoute(routes, {
|
|
132
|
+
method: "PATCH",
|
|
133
|
+
path: "/api/w/:workspaceSlug"
|
|
134
|
+
});
|
|
135
|
+
const workspaceSettingsPatch = findRoute(routes, {
|
|
136
|
+
method: "PATCH",
|
|
137
|
+
path: "/api/w/:workspaceSlug/settings"
|
|
138
|
+
});
|
|
139
|
+
const workspaceMemberRole = findRoute(routes, {
|
|
140
|
+
method: "PATCH",
|
|
141
|
+
path: "/api/w/:workspaceSlug/members/:memberUserId/role"
|
|
142
|
+
});
|
|
143
|
+
const workspaceMemberDelete = findRoute(routes, {
|
|
144
|
+
method: "DELETE",
|
|
145
|
+
path: "/api/w/:workspaceSlug/members/:memberUserId"
|
|
146
|
+
});
|
|
147
|
+
const workspaceInviteDelete = findRoute(routes, {
|
|
148
|
+
method: "DELETE",
|
|
149
|
+
path: "/api/w/:workspaceSlug/invites/:inviteId"
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
assert.equal(typeof workspaceSettings?.paramsValidator?.normalize, "function");
|
|
153
|
+
assert.equal(typeof workspacePatch?.bodyValidator?.normalize, "function");
|
|
154
|
+
assert.equal(typeof workspaceSettingsPatch?.bodyValidator?.normalize, "function");
|
|
155
|
+
assert.equal(typeof workspaceMemberRole?.paramsValidator?.normalize, "function");
|
|
156
|
+
assert.equal(typeof workspaceMemberRole?.bodyValidator?.normalize, "function");
|
|
157
|
+
assert.equal(typeof workspaceMemberDelete?.paramsValidator?.normalize, "function");
|
|
158
|
+
assert.equal(typeof workspaceInviteDelete?.paramsValidator?.normalize, "function");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("workspace core/settings routes mount one canonical workspace endpoint", async () => {
|
|
162
|
+
const routes = await registerRoutes();
|
|
163
|
+
const workspace = findRoute(routes, {
|
|
164
|
+
method: "GET",
|
|
165
|
+
path: "/api/w/:workspaceSlug"
|
|
166
|
+
});
|
|
167
|
+
const workspacePatch = findRoute(routes, {
|
|
168
|
+
method: "PATCH",
|
|
169
|
+
path: "/api/w/:workspaceSlug"
|
|
170
|
+
});
|
|
171
|
+
const workspaceSettings = findRoute(routes, {
|
|
172
|
+
method: "GET",
|
|
173
|
+
path: "/api/w/:workspaceSlug/settings"
|
|
174
|
+
});
|
|
175
|
+
const workspaceSettingsPatch = findRoute(routes, {
|
|
176
|
+
method: "PATCH",
|
|
177
|
+
path: "/api/w/:workspaceSlug/settings"
|
|
178
|
+
});
|
|
179
|
+
const adminWorkspaceSettings = findRoute(routes, {
|
|
180
|
+
method: "GET",
|
|
181
|
+
path: "/api/admin/w/:workspaceSlug/workspace/settings"
|
|
182
|
+
});
|
|
183
|
+
const consoleWorkspaceSettings = findRoute(routes, {
|
|
184
|
+
method: "GET",
|
|
185
|
+
path: "/api/console/w/:workspaceSlug/workspace/settings"
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
assert.ok(workspace);
|
|
189
|
+
assert.equal(workspace?.visibility, "workspace");
|
|
190
|
+
assert.equal(workspacePatch?.visibility, "workspace");
|
|
191
|
+
assert.equal(workspace?.surface, "");
|
|
192
|
+
assert.equal(workspacePatch?.surface, "");
|
|
193
|
+
assert.ok(workspaceSettings);
|
|
194
|
+
assert.equal(workspaceSettings?.visibility, "workspace");
|
|
195
|
+
assert.equal(workspaceSettingsPatch?.visibility, "workspace");
|
|
196
|
+
assert.equal(workspaceSettings?.surface, "");
|
|
197
|
+
assert.equal(workspaceSettingsPatch?.surface, "");
|
|
198
|
+
assert.equal(adminWorkspaceSettings, null);
|
|
199
|
+
assert.equal(consoleWorkspaceSettings, null);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("workspaces-core boot skips workspace routes when workspace policy is disabled", async () => {
|
|
203
|
+
const routes = await registerRoutes({
|
|
204
|
+
workspaceEnabled: false,
|
|
205
|
+
workspaceTenancyEnabled: false,
|
|
206
|
+
workspaceInvitationsEnabled: false,
|
|
207
|
+
workspaceSelfCreateEnabled: false
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
assert.equal(findRoute(routes, { method: "GET", path: "/api/workspaces" }), null);
|
|
211
|
+
assert.equal(findRoute(routes, { method: "POST", path: "/api/workspaces" }), null);
|
|
212
|
+
assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug" }), null);
|
|
213
|
+
assert.equal(findRoute(routes, { method: "PATCH", path: "/api/w/:workspaceSlug" }), null);
|
|
214
|
+
assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug/settings" }), null);
|
|
215
|
+
assert.equal(findRoute(routes, { method: "GET", path: "/api/settings" })?.path, "/api/settings");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("workspaces-core boot skips workspace create route when self-create policy is disabled", async () => {
|
|
219
|
+
const routes = await registerRoutes({
|
|
220
|
+
workspaceEnabled: true,
|
|
221
|
+
workspaceTenancyEnabled: true,
|
|
222
|
+
workspaceInvitationsEnabled: true,
|
|
223
|
+
workspaceSelfCreateEnabled: false
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
assert.equal(findRoute(routes, { method: "POST", path: "/api/workspaces" }), null);
|
|
227
|
+
assert.equal(findRoute(routes, { method: "GET", path: "/api/workspaces" })?.path, "/api/workspaces");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("workspaces-core route registration follows tenancy mode matrix", async () => {
|
|
231
|
+
const noneRoutes = await registerRoutesForMode({
|
|
232
|
+
tenancyMode: "none"
|
|
233
|
+
});
|
|
234
|
+
const personalRoutes = await registerRoutesForMode({
|
|
235
|
+
tenancyMode: "personal"
|
|
236
|
+
});
|
|
237
|
+
const workspaceRoutes = await registerRoutesForMode({
|
|
238
|
+
tenancyMode: "workspaces"
|
|
239
|
+
});
|
|
240
|
+
const workspaceSelfCreateRoutes = await registerRoutesForMode({
|
|
241
|
+
tenancyMode: "workspaces",
|
|
242
|
+
tenancyPolicy: {
|
|
243
|
+
workspace: {
|
|
244
|
+
allowSelfCreate: true
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/workspaces" }), null);
|
|
250
|
+
assert.equal(findRoute(noneRoutes, { method: "POST", path: "/api/workspaces" }), null);
|
|
251
|
+
assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/w/:workspaceSlug" }), null);
|
|
252
|
+
assert.equal(findRoute(noneRoutes, { method: "PATCH", path: "/api/w/:workspaceSlug" }), null);
|
|
253
|
+
assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/w/:workspaceSlug/settings" }), null);
|
|
254
|
+
assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/workspace/invitations/pending" }), null);
|
|
255
|
+
|
|
256
|
+
assert.equal(findRoute(personalRoutes, { method: "GET", path: "/api/workspaces" })?.path, "/api/workspaces");
|
|
257
|
+
assert.equal(findRoute(personalRoutes, { method: "POST", path: "/api/workspaces" }), null);
|
|
258
|
+
assert.equal(
|
|
259
|
+
findRoute(personalRoutes, { method: "GET", path: "/api/w/:workspaceSlug" })?.path,
|
|
260
|
+
"/api/w/:workspaceSlug"
|
|
261
|
+
);
|
|
262
|
+
assert.equal(
|
|
263
|
+
findRoute(personalRoutes, { method: "PATCH", path: "/api/w/:workspaceSlug" })?.path,
|
|
264
|
+
"/api/w/:workspaceSlug"
|
|
265
|
+
);
|
|
266
|
+
assert.equal(
|
|
267
|
+
findRoute(personalRoutes, { method: "GET", path: "/api/w/:workspaceSlug/settings" })?.path,
|
|
268
|
+
"/api/w/:workspaceSlug/settings"
|
|
269
|
+
);
|
|
270
|
+
assert.equal(
|
|
271
|
+
findRoute(personalRoutes, { method: "GET", path: "/api/workspace/invitations/pending" })?.path,
|
|
272
|
+
"/api/workspace/invitations/pending"
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
assert.equal(findRoute(workspaceRoutes, { method: "GET", path: "/api/workspaces" })?.path, "/api/workspaces");
|
|
276
|
+
assert.equal(findRoute(workspaceRoutes, { method: "POST", path: "/api/workspaces" }), null);
|
|
277
|
+
assert.equal(
|
|
278
|
+
findRoute(workspaceRoutes, { method: "GET", path: "/api/w/:workspaceSlug" })?.path,
|
|
279
|
+
"/api/w/:workspaceSlug"
|
|
280
|
+
);
|
|
281
|
+
assert.equal(
|
|
282
|
+
findRoute(workspaceRoutes, { method: "PATCH", path: "/api/w/:workspaceSlug" })?.path,
|
|
283
|
+
"/api/w/:workspaceSlug"
|
|
284
|
+
);
|
|
285
|
+
assert.equal(
|
|
286
|
+
findRoute(workspaceRoutes, { method: "GET", path: "/api/w/:workspaceSlug/settings" })?.path,
|
|
287
|
+
"/api/w/:workspaceSlug/settings"
|
|
288
|
+
);
|
|
289
|
+
assert.equal(
|
|
290
|
+
findRoute(workspaceRoutes, { method: "GET", path: "/api/workspace/invitations/pending" })?.path,
|
|
291
|
+
"/api/workspace/invitations/pending"
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
assert.equal(
|
|
295
|
+
findRoute(workspaceSelfCreateRoutes, { method: "POST", path: "/api/workspaces" })?.path,
|
|
296
|
+
"/api/workspaces"
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("workspaces-core boot skips invitation redeem/list routes when workspace invitations are disabled", async () => {
|
|
301
|
+
const routes = await registerRoutes({
|
|
302
|
+
workspaceEnabled: true,
|
|
303
|
+
workspaceTenancyEnabled: true,
|
|
304
|
+
workspaceInvitationsEnabled: false,
|
|
305
|
+
workspaceSelfCreateEnabled: false
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
assert.equal(findRoute(routes, { method: "GET", path: "/api/workspace/invitations/pending" }), null);
|
|
309
|
+
assert.equal(findRoute(routes, { method: "POST", path: "/api/workspace/invitations/redeem" }), null);
|
|
310
|
+
assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug/invites" }), null);
|
|
311
|
+
assert.equal(findRoute(routes, { method: "POST", path: "/api/w/:workspaceSlug/invites" }), null);
|
|
312
|
+
assert.equal(findRoute(routes, { method: "DELETE", path: "/api/w/:workspaceSlug/invites/:inviteId" }), null);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("workspace invite and member handlers build action input from request.input", async () => {
|
|
316
|
+
const routes = await registerRoutes();
|
|
317
|
+
const workspaceCreate = findRoute(routes, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
path: "/api/workspaces"
|
|
320
|
+
});
|
|
321
|
+
const workspaceInviteRedeem = findRoute(routes, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
path: "/api/workspace/invitations/redeem"
|
|
324
|
+
});
|
|
325
|
+
const workspaceMemberRolePatch = findRoute(routes, {
|
|
326
|
+
method: "PATCH",
|
|
327
|
+
path: "/api/w/:workspaceSlug/members/:memberUserId/role"
|
|
328
|
+
});
|
|
329
|
+
const workspaceMemberDelete = findRoute(routes, {
|
|
330
|
+
method: "DELETE",
|
|
331
|
+
path: "/api/w/:workspaceSlug/members/:memberUserId"
|
|
332
|
+
});
|
|
333
|
+
const workspaceInviteCreate = findRoute(routes, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
path: "/api/w/:workspaceSlug/invites"
|
|
336
|
+
});
|
|
337
|
+
const workspaceInviteDelete = findRoute(routes, {
|
|
338
|
+
method: "DELETE",
|
|
339
|
+
path: "/api/w/:workspaceSlug/invites/:inviteId"
|
|
340
|
+
});
|
|
341
|
+
const calls = [];
|
|
342
|
+
const executeAction = async (payload) => {
|
|
343
|
+
calls.push(payload);
|
|
344
|
+
return {};
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
await workspaceCreate.handler(
|
|
348
|
+
createActionRequest({
|
|
349
|
+
input: {
|
|
350
|
+
body: { name: "Operations", slug: "operations" }
|
|
351
|
+
},
|
|
352
|
+
executeAction
|
|
353
|
+
}),
|
|
354
|
+
createReplyDouble()
|
|
355
|
+
);
|
|
356
|
+
await workspaceInviteRedeem.handler(
|
|
357
|
+
createActionRequest({
|
|
358
|
+
input: {
|
|
359
|
+
body: { token: "token-1", decision: "accept" }
|
|
360
|
+
},
|
|
361
|
+
executeAction
|
|
362
|
+
}),
|
|
363
|
+
createReplyDouble()
|
|
364
|
+
);
|
|
365
|
+
await workspaceMemberRolePatch.handler(
|
|
366
|
+
createActionRequest({
|
|
367
|
+
input: {
|
|
368
|
+
params: { workspaceSlug: "acme", memberUserId: "12" },
|
|
369
|
+
body: { roleSid: "admin" }
|
|
370
|
+
},
|
|
371
|
+
executeAction
|
|
372
|
+
}),
|
|
373
|
+
createReplyDouble()
|
|
374
|
+
);
|
|
375
|
+
await workspaceInviteCreate.handler(
|
|
376
|
+
createActionRequest({
|
|
377
|
+
input: {
|
|
378
|
+
params: { workspaceSlug: "acme" },
|
|
379
|
+
body: { email: "user@example.com", roleSid: "member" }
|
|
380
|
+
},
|
|
381
|
+
executeAction
|
|
382
|
+
}),
|
|
383
|
+
createReplyDouble()
|
|
384
|
+
);
|
|
385
|
+
await workspaceMemberDelete.handler(
|
|
386
|
+
createActionRequest({
|
|
387
|
+
input: {
|
|
388
|
+
params: { workspaceSlug: "acme", memberUserId: "44" }
|
|
389
|
+
},
|
|
390
|
+
executeAction
|
|
391
|
+
}),
|
|
392
|
+
createReplyDouble()
|
|
393
|
+
);
|
|
394
|
+
await workspaceInviteDelete.handler(
|
|
395
|
+
createActionRequest({
|
|
396
|
+
input: {
|
|
397
|
+
params: { workspaceSlug: "acme", inviteId: "55" }
|
|
398
|
+
},
|
|
399
|
+
executeAction
|
|
400
|
+
}),
|
|
401
|
+
createReplyDouble()
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
assert.deepEqual(calls[0], {
|
|
405
|
+
actionId: "workspace.workspaces.create",
|
|
406
|
+
input: { name: "Operations", slug: "operations" }
|
|
407
|
+
});
|
|
408
|
+
assert.deepEqual(calls[1].input, { payload: { token: "token-1", decision: "accept" } });
|
|
409
|
+
assert.deepEqual(calls[2].input, { workspaceSlug: "acme", memberUserId: "12", roleSid: "admin" });
|
|
410
|
+
assert.deepEqual(calls[3].input, { workspaceSlug: "acme", email: "user@example.com", roleSid: "member" });
|
|
411
|
+
assert.deepEqual(calls[4].input, { workspaceSlug: "acme", memberUserId: "44" });
|
|
412
|
+
assert.deepEqual(calls[5].input, { workspaceSlug: "acme", inviteId: "55" });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("workspace settings route handlers build action input from request.input", async () => {
|
|
416
|
+
const routes = await registerRoutes();
|
|
417
|
+
const workspaceSettingsPatch = findRoute(routes, {
|
|
418
|
+
method: "PATCH",
|
|
419
|
+
path: "/api/w/:workspaceSlug/settings"
|
|
420
|
+
});
|
|
421
|
+
const calls = [];
|
|
422
|
+
const executeAction = async (payload) => {
|
|
423
|
+
calls.push(payload);
|
|
424
|
+
return {};
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
await workspaceSettingsPatch.handler(
|
|
428
|
+
createActionRequest({
|
|
429
|
+
input: {
|
|
430
|
+
params: { workspaceSlug: "acme" },
|
|
431
|
+
body: { lightPrimaryColor: "#0F6B54" }
|
|
432
|
+
},
|
|
433
|
+
executeAction
|
|
434
|
+
}),
|
|
435
|
+
createReplyDouble()
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
assert.deepEqual(calls[0], {
|
|
439
|
+
actionId: "workspace.settings.update",
|
|
440
|
+
input: { workspaceSlug: "acme", patch: { lightPrimaryColor: "#0F6B54" } }
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("workspace route handlers build action input from request.input", async () => {
|
|
445
|
+
const routes = await registerRoutes();
|
|
446
|
+
const workspacePatch = findRoute(routes, {
|
|
447
|
+
method: "PATCH",
|
|
448
|
+
path: "/api/w/:workspaceSlug"
|
|
449
|
+
});
|
|
450
|
+
const calls = [];
|
|
451
|
+
const executeAction = async (payload) => {
|
|
452
|
+
calls.push(payload);
|
|
453
|
+
return {};
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
await workspacePatch.handler(
|
|
457
|
+
createActionRequest({
|
|
458
|
+
input: {
|
|
459
|
+
params: { workspaceSlug: "acme" },
|
|
460
|
+
body: { name: "Acme", avatarUrl: "https://example.com/acme.png" }
|
|
461
|
+
},
|
|
462
|
+
executeAction
|
|
463
|
+
}),
|
|
464
|
+
createReplyDouble()
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
assert.deepEqual(calls[0], {
|
|
468
|
+
actionId: "workspace.workspaces.update",
|
|
469
|
+
input: {
|
|
470
|
+
workspaceSlug: "acme",
|
|
471
|
+
patch: { name: "Acme", avatarUrl: "https://example.com/acme.png" }
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
});
|