@jskit-ai/users-core 0.1.22 → 0.1.25

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/users-core",
4
- version: "0.1.22",
4
+ version: "0.1.25",
5
5
  description: "Users/workspace domain runtime plus HTTP routes for workspace, account, and console features.",
6
6
  dependsOn: [
7
7
  "@jskit-ai/auth-core",
@@ -203,10 +203,10 @@ export default Object.freeze({
203
203
  mutations: {
204
204
  dependencies: {
205
205
  runtime: {
206
- "@jskit-ai/auth-core": "0.1.15",
207
- "@jskit-ai/database-runtime": "0.1.16",
208
- "@jskit-ai/http-runtime": "0.1.15",
209
- "@jskit-ai/kernel": "0.1.16",
206
+ "@jskit-ai/auth-core": "0.1.16",
207
+ "@jskit-ai/database-runtime": "0.1.17",
208
+ "@jskit-ai/http-runtime": "0.1.16",
209
+ "@jskit-ai/kernel": "0.1.17",
210
210
  "@fastify/multipart": "^9.4.0",
211
211
  "@fastify/type-provider-typebox": "^6.1.0",
212
212
  "typebox": "^1.0.81"
@@ -257,10 +257,19 @@ export default Object.freeze({
257
257
  from: "templates/migrations/users_core_workspace_settings_single_name_source.cjs",
258
258
  toDir: "migrations",
259
259
  extension: ".cjs",
260
- reason: "Remove workspace_settings.name so workspace names come from workspaces only.",
260
+ reason: "Remove workspace_settings name/avatar fields so workspace identity data comes from workspaces only.",
261
261
  category: "migration",
262
262
  id: "users-core-workspace-settings-single-name-source"
263
263
  },
264
+ {
265
+ op: "install-migration",
266
+ from: "templates/migrations/users_core_workspaces_drop_color.cjs",
267
+ toDir: "migrations",
268
+ extension: ".cjs",
269
+ reason: "Drop legacy workspaces.color now that workspace theme colors live in workspace_settings.",
270
+ category: "migration",
271
+ id: "users-core-workspaces-drop-color"
272
+ },
264
273
  {
265
274
  from: "templates/packages/main/src/shared/resources/workspaceSettingsFields.js",
266
275
  to: "packages/main/src/shared/resources/workspaceSettingsFields.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-core",
3
- "version": "0.1.22",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -16,6 +16,7 @@
16
16
  "./shared/support/usersVisibility": "./src/shared/support/usersVisibility.js",
17
17
  "./shared/support/workspacePathModel": "./src/shared/support/workspacePathModel.js",
18
18
  "./shared/support/usersApiPaths": "./src/shared/support/usersApiPaths.js",
19
+ "./shared/resources/workspaceResource": "./src/shared/resources/workspaceResource.js",
19
20
  "./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js",
20
21
  "./shared/resources/workspaceSettingsFields": "./src/shared/resources/workspaceSettingsFields.js",
21
22
  "./shared/resources/userProfileResource": "./src/shared/resources/userProfileResource.js",
@@ -24,10 +25,10 @@
24
25
  "./shared/resources/consoleSettingsFields": "./src/shared/resources/consoleSettingsFields.js"
25
26
  },
26
27
  "dependencies": {
27
- "@jskit-ai/auth-core": "0.1.15",
28
- "@jskit-ai/database-runtime": "0.1.16",
29
- "@jskit-ai/http-runtime": "0.1.15",
30
- "@jskit-ai/kernel": "0.1.16",
28
+ "@jskit-ai/auth-core": "0.1.16",
29
+ "@jskit-ai/database-runtime": "0.1.17",
30
+ "@jskit-ai/http-runtime": "0.1.16",
31
+ "@jskit-ai/kernel": "0.1.17",
31
32
  "@fastify/multipart": "^9.4.0",
32
33
  "@fastify/type-provider-typebox": "^6.1.0",
33
34
  "typebox": "^1.0.81"
@@ -1,7 +1,4 @@
1
- import {
2
- coerceWorkspaceColor,
3
- resolveWorkspaceThemePalettes
4
- } from "../../../shared/settings.js";
1
+ import { resolveWorkspaceThemePalettes } from "../../../shared/settings.js";
5
2
  import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
6
3
 
7
4
  function mapWorkspaceSummary(workspace, membership) {
@@ -9,7 +6,6 @@ function mapWorkspaceSummary(workspace, membership) {
9
6
  id: Number(workspace.id),
10
7
  slug: normalizeText(workspace.slug),
11
8
  name: normalizeText(workspace.name),
12
- color: coerceWorkspaceColor(workspace.color),
13
9
  avatarUrl: normalizeText(workspace.avatarUrl),
14
10
  roleId: normalizeLowerText(membership?.roleId || "member") || "member",
15
11
  isAccessible: normalizeLowerText(membership?.status || "active") === "active"
@@ -23,7 +19,6 @@ function mapWorkspaceSettingsPublic(workspaceSettings, { workspaceInvitationsEna
23
19
  const themePalettes = resolveWorkspaceThemePalettes(source);
24
20
 
25
21
  return {
26
- name: normalizeText(source.name),
27
22
  lightPrimaryColor: themePalettes.light.color,
28
23
  lightSecondaryColor: themePalettes.light.secondaryColor,
29
24
  lightSurfaceColor: themePalettes.light.surfaceColor,
@@ -32,7 +27,6 @@ function mapWorkspaceSettingsPublic(workspaceSettings, { workspaceInvitationsEna
32
27
  darkSecondaryColor: themePalettes.dark.secondaryColor,
33
28
  darkSurfaceColor: themePalettes.dark.surfaceColor,
34
29
  darkSurfaceVariantColor: themePalettes.dark.surfaceVariantColor,
35
- avatarUrl: normalizeText(source.avatarUrl),
36
30
  invitesEnabled,
37
31
  invitesAvailable,
38
32
  invitesEffective: invitesAvailable && invitesEnabled
@@ -6,13 +6,11 @@ import {
6
6
  nowDb,
7
7
  isDuplicateEntryError
8
8
  } from "./repositoryUtils.js";
9
- import { coerceWorkspaceColor } from "../../../shared/settings.js";
10
9
 
11
10
  function mapRow(row) {
12
11
  if (!row) {
13
12
  return null;
14
13
  }
15
- const color = coerceWorkspaceColor(row.color);
16
14
 
17
15
  return {
18
16
  id: Number(row.id),
@@ -21,7 +19,6 @@ function mapRow(row) {
21
19
  ownerUserId: Number(row.owner_user_id),
22
20
  isPersonal: Boolean(row.is_personal),
23
21
  avatarUrl: row.avatar_url ? normalizeText(row.avatar_url) : "",
24
- color,
25
22
  createdAt: toIsoString(row.created_at),
26
23
  updatedAt: toIsoString(row.updated_at),
27
24
  deletedAt: toNullableIso(row.deleted_at)
@@ -45,15 +42,14 @@ function createRepository(knex) {
45
42
  throw new TypeError("workspacesRepository requires knex.");
46
43
  }
47
44
 
48
- function workspaceSelectColumns(client, { includeMembership = false } = {}) {
45
+ function workspaceSelectColumns({ includeMembership = false } = {}) {
49
46
  const columns = [
50
47
  "w.id",
51
48
  "w.slug",
52
49
  "w.name",
53
50
  "w.owner_user_id",
54
51
  "w.is_personal",
55
- client.raw("COALESCE(ws.avatar_url, w.avatar_url) as avatar_url"),
56
- "w.color",
52
+ "w.avatar_url",
57
53
  "w.created_at",
58
54
  "w.updated_at",
59
55
  "w.deleted_at"
@@ -67,9 +63,8 @@ function createRepository(knex) {
67
63
  async function findById(workspaceId, options = {}) {
68
64
  const client = options?.trx || knex;
69
65
  const row = await client("workspaces as w")
70
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
71
66
  .where({ "w.id": Number(workspaceId) })
72
- .select(workspaceSelectColumns(client))
67
+ .select(workspaceSelectColumns())
73
68
  .first();
74
69
  return mapRow(row);
75
70
  }
@@ -82,9 +77,8 @@ function createRepository(knex) {
82
77
  }
83
78
 
84
79
  const row = await client("workspaces as w")
85
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
86
80
  .where({ "w.slug": normalizedSlug })
87
- .select(workspaceSelectColumns(client))
81
+ .select(workspaceSelectColumns())
88
82
  .first();
89
83
  return mapRow(row);
90
84
  }
@@ -92,10 +86,9 @@ function createRepository(knex) {
92
86
  async function findPersonalByOwnerUserId(userId, options = {}) {
93
87
  const client = options?.trx || knex;
94
88
  const row = await client("workspaces as w")
95
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
96
89
  .where({ "w.owner_user_id": Number(userId), "w.is_personal": 1 })
97
90
  .orderBy("w.id", "asc")
98
- .select(workspaceSelectColumns(client))
91
+ .select(workspaceSelectColumns())
99
92
  .first();
100
93
  return mapRow(row);
101
94
  }
@@ -110,7 +103,6 @@ function createRepository(knex) {
110
103
  owner_user_id: Number(source.ownerUserId),
111
104
  is_personal: source.isPersonal ? 1 : 0,
112
105
  avatar_url: normalizeText(source.avatarUrl),
113
- color: coerceWorkspaceColor(source.color),
114
106
  created_at: nowDb(),
115
107
  updated_at: nowDb(),
116
108
  deleted_at: null
@@ -149,9 +141,6 @@ function createRepository(knex) {
149
141
  if (Object.hasOwn(source, "avatarUrl")) {
150
142
  dbPatch.avatar_url = normalizeText(source.avatarUrl);
151
143
  }
152
- if (Object.hasOwn(source, "color")) {
153
- dbPatch.color = coerceWorkspaceColor(source.color);
154
- }
155
144
 
156
145
  await client("workspaces").where({ id: Number(workspaceId) }).update(dbPatch);
157
146
  return findById(workspaceId, { trx: client });
@@ -161,12 +150,11 @@ function createRepository(knex) {
161
150
  const client = options?.trx || knex;
162
151
  const rows = await client("workspace_memberships as wm")
163
152
  .join("workspaces as w", "w.id", "wm.workspace_id")
164
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
165
153
  .where({ "wm.user_id": Number(userId) })
166
154
  .whereNull("w.deleted_at")
167
155
  .orderBy("w.is_personal", "desc")
168
156
  .orderBy("w.id", "asc")
169
- .select(workspaceSelectColumns(client, { includeMembership: true }));
157
+ .select(workspaceSelectColumns({ includeMembership: true }));
170
158
 
171
159
  return rows.map(mapMembershipWorkspaceRow).filter(Boolean);
172
160
  }
@@ -4,7 +4,6 @@ import {
4
4
  TENANCY_MODE_NONE,
5
5
  resolveTenancyProfile
6
6
  } from "../../../shared/tenancyProfile.js";
7
- import { coerceWorkspaceColor } from "../../../shared/settings.js";
8
7
  import {
9
8
  resolveRolePermissions
10
9
  } from "../../../shared/roles.js";
@@ -84,7 +83,6 @@ function createService({
84
83
  const resolvedTenancyProfile = resolveTenancyProfile(appConfig);
85
84
  const resolvedTenancyMode = resolvedTenancyProfile.mode;
86
85
  const workspacePolicy = resolvedTenancyProfile.workspace;
87
- const resolvedWorkspaceColor = coerceWorkspaceColor(appConfig.workspaceColor);
88
86
  async function ensureUniqueWorkspaceSlug(baseSlug, options = {}) {
89
87
  let suffix = 0;
90
88
  while (suffix < 1000) {
@@ -125,8 +123,7 @@ function createService({
125
123
  name: buildWorkspaceName(normalizedUser),
126
124
  ownerUserId: normalizedUser.id,
127
125
  isPersonal: true,
128
- avatarUrl: "",
129
- color: resolvedWorkspaceColor
126
+ avatarUrl: ""
130
127
  },
131
128
  options
132
129
  );
@@ -194,8 +191,7 @@ function createService({
194
191
  name: createInput.name,
195
192
  ownerUserId: normalizedUser.id,
196
193
  isPersonal: false,
197
- avatarUrl: "",
198
- color: resolvedWorkspaceColor
194
+ avatarUrl: ""
199
195
  },
200
196
  options
201
197
  );
@@ -205,6 +201,16 @@ function createService({
205
201
  return inserted;
206
202
  }
207
203
 
204
+ async function getWorkspaceForAuthenticatedUser(user, workspaceSlug, options = {}) {
205
+ const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
206
+ return workspaceContext.workspace;
207
+ }
208
+
209
+ async function updateWorkspaceForAuthenticatedUser(user, workspaceSlug, patch = {}, options = {}) {
210
+ const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
211
+ return workspacesRepository.updateById(workspaceContext.workspace.id, patch, options);
212
+ }
213
+
208
214
  async function resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options = {}) {
209
215
  const normalizedUser = authenticatedUserValidator.normalize(user);
210
216
  if (!normalizedUser) {
@@ -261,6 +267,8 @@ function createService({
261
267
  ensurePersonalWorkspaceForUser,
262
268
  provisionWorkspaceForNewUser,
263
269
  createWorkspaceForAuthenticatedUser,
270
+ getWorkspaceForAuthenticatedUser,
271
+ updateWorkspaceForAuthenticatedUser,
264
272
  listWorkspacesForUser,
265
273
  listWorkspacesForAuthenticatedUser,
266
274
  resolveWorkspaceContextForUserBySlug
@@ -4,6 +4,9 @@ import { workspaceResource } from "../../shared/resources/workspaceResource.js";
4
4
  import {
5
5
  USERS_WORKSPACE_SELF_CREATE_ENABLED_TOKEN
6
6
  } from "../common/diTokens.js";
7
+ import { resolveWorkspaceRoutePath } from "../common/support/workspaceRoutePaths.js";
8
+ import { workspaceSlugParamsValidator } from "../common/validators/routeParamsValidator.js";
9
+ import { resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig } from "../support/workspaceActionSurfaces.js";
7
10
 
8
11
  function bootWorkspaceDirectoryRoutes(app) {
9
12
  if (!app || typeof app.make !== "function" || typeof app.has !== "function") {
@@ -11,6 +14,8 @@ function bootWorkspaceDirectoryRoutes(app) {
11
14
  }
12
15
 
13
16
  const router = app.make(KERNEL_TOKENS.HttpRouter);
17
+ const appConfig = app.has("appConfig") ? app.make("appConfig") : {};
18
+ const workspaceRouteSurfaceId = resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig(appConfig);
14
19
  const workspaceSelfCreateEnabled = app.has(USERS_WORKSPACE_SELF_CREATE_ENABLED_TOKEN)
15
20
  ? app.make(USERS_WORKSPACE_SELF_CREATE_ENABLED_TOKEN) === true
16
21
  : false;
@@ -68,6 +73,65 @@ function bootWorkspaceDirectoryRoutes(app) {
68
73
  reply.code(200).send(response);
69
74
  }
70
75
  );
76
+
77
+ router.register(
78
+ "GET",
79
+ resolveWorkspaceRoutePath("/"),
80
+ {
81
+ auth: "required",
82
+ surface: workspaceRouteSurfaceId,
83
+ visibility: "workspace",
84
+ meta: {
85
+ tags: ["workspace"],
86
+ summary: "Get workspace profile by workspace slug"
87
+ },
88
+ paramsValidator: workspaceSlugParamsValidator,
89
+ responseValidators: withStandardErrorResponses({
90
+ 200: workspaceResource.operations.view.outputValidator
91
+ })
92
+ },
93
+ async function (request, reply) {
94
+ const response = await request.executeAction({
95
+ actionId: "workspace.workspaces.read",
96
+ input: {
97
+ workspaceSlug: request.input.params.workspaceSlug
98
+ }
99
+ });
100
+ reply.code(200).send(response);
101
+ }
102
+ );
103
+
104
+ router.register(
105
+ "PATCH",
106
+ resolveWorkspaceRoutePath("/"),
107
+ {
108
+ auth: "required",
109
+ surface: workspaceRouteSurfaceId,
110
+ visibility: "workspace",
111
+ meta: {
112
+ tags: ["workspace"],
113
+ summary: "Update workspace profile by workspace slug"
114
+ },
115
+ paramsValidator: workspaceSlugParamsValidator,
116
+ bodyValidator: workspaceResource.operations.patch.bodyValidator,
117
+ responseValidators: withStandardErrorResponses(
118
+ {
119
+ 200: workspaceResource.operations.patch.outputValidator
120
+ },
121
+ { includeValidation400: true }
122
+ )
123
+ },
124
+ async function (request, reply) {
125
+ const response = await request.executeAction({
126
+ actionId: "workspace.workspaces.update",
127
+ input: {
128
+ workspaceSlug: request.input.params.workspaceSlug,
129
+ patch: request.input.body
130
+ }
131
+ });
132
+ reply.code(200).send(response);
133
+ }
134
+ );
71
135
  }
72
136
 
73
137
  export { bootWorkspaceDirectoryRoutes };
@@ -3,6 +3,7 @@ import {
3
3
  resolveRequest
4
4
  } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
5
5
  import { workspaceResource } from "../../shared/resources/workspaceResource.js";
6
+ import { workspaceSlugParamsValidator } from "../common/validators/routeParamsValidator.js";
6
7
  import { resolveActionUser } from "../common/support/resolveActionUser.js";
7
8
 
8
9
  const workspaceDirectoryActions = Object.freeze([
@@ -59,6 +60,73 @@ const workspaceDirectoryActions = Object.freeze([
59
60
  nextCursor: null
60
61
  };
61
62
  }
63
+ },
64
+ {
65
+ id: "workspace.workspaces.read",
66
+ version: 1,
67
+ kind: "query",
68
+ channels: ["api", "automation", "internal"],
69
+ surfacesFrom: "workspace",
70
+ permission: {
71
+ require: "any",
72
+ permissions: ["workspace.settings.view", "workspace.settings.update"]
73
+ },
74
+ inputValidator: workspaceSlugParamsValidator,
75
+ outputValidator: workspaceResource.operations.view.outputValidator,
76
+ idempotency: "none",
77
+ audit: {
78
+ actionName: "workspace.workspaces.read"
79
+ },
80
+ observability: {},
81
+ async execute(input, context, deps) {
82
+ return deps.workspaceService.getWorkspaceForAuthenticatedUser(
83
+ resolveActionUser(context, input),
84
+ input.workspaceSlug,
85
+ {
86
+ request: resolveRequest(context),
87
+ context
88
+ }
89
+ );
90
+ }
91
+ },
92
+ {
93
+ id: "workspace.workspaces.update",
94
+ version: 1,
95
+ kind: "command",
96
+ channels: ["api", "assistant_tool", "automation", "internal"],
97
+ surfacesFrom: "workspace",
98
+ permission: {
99
+ require: "all",
100
+ permissions: ["workspace.settings.update"]
101
+ },
102
+ inputValidator: [
103
+ workspaceSlugParamsValidator,
104
+ {
105
+ patch: workspaceResource.operations.patch.bodyValidator
106
+ }
107
+ ],
108
+ outputValidator: workspaceResource.operations.patch.outputValidator,
109
+ idempotency: "optional",
110
+ audit: {
111
+ actionName: "workspace.workspaces.update"
112
+ },
113
+ observability: {},
114
+ extensions: {
115
+ assistant: {
116
+ description: "Update workspace profile fields."
117
+ }
118
+ },
119
+ async execute(input, context, deps) {
120
+ return deps.workspaceService.updateWorkspaceForAuthenticatedUser(
121
+ resolveActionUser(context, input),
122
+ input.workspaceSlug,
123
+ input.patch,
124
+ {
125
+ request: resolveRequest(context),
126
+ context
127
+ }
128
+ );
129
+ }
62
130
  }
63
131
  ]);
64
132
 
@@ -11,8 +11,7 @@ const workspaceSummaryOutputSchema = Type.Object(
11
11
  slug: Type.String({ minLength: 1 }),
12
12
  name: Type.String({ minLength: 1 }),
13
13
  ownerUserId: Type.Integer({ minimum: 1 }),
14
- avatarUrl: Type.String(),
15
- color: Type.String({ minLength: 1 })
14
+ avatarUrl: Type.String()
16
15
  },
17
16
  { additionalProperties: false }
18
17
  );
@@ -49,8 +48,7 @@ function normalizeWorkspaceAdminSummary(workspace) {
49
48
  slug: normalizeText(source.slug),
50
49
  name: normalizeText(source.name),
51
50
  ownerUserId: Number(source.ownerUserId),
52
- avatarUrl: normalizeText(source.avatarUrl),
53
- color: normalizeText(source.color)
51
+ avatarUrl: normalizeText(source.avatarUrl)
54
52
  };
55
53
  }
56
54
 
@@ -5,6 +5,21 @@ import {
5
5
  createCursorListValidator
6
6
  } from "@jskit-ai/kernel/shared/validators";
7
7
 
8
+ function normalizeWorkspaceAvatarUrl(value) {
9
+ const avatarUrl = normalizeText(value);
10
+ if (!avatarUrl) {
11
+ return "";
12
+ }
13
+ if (!avatarUrl.startsWith("http://") && !avatarUrl.startsWith("https://")) {
14
+ return null;
15
+ }
16
+ try {
17
+ return new URL(avatarUrl).toString();
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
8
23
  function normalizeWorkspaceInput(payload = {}) {
9
24
  const source = normalizeObjectInput(payload);
10
25
  const normalized = {};
@@ -19,11 +34,7 @@ function normalizeWorkspaceInput(payload = {}) {
19
34
  normalized.ownerUserId = Number(source.ownerUserId);
20
35
  }
21
36
  if (Object.hasOwn(source, "avatarUrl")) {
22
- normalized.avatarUrl = normalizeText(source.avatarUrl);
23
- }
24
- if (Object.hasOwn(source, "color")) {
25
- const color = normalizeText(source.color);
26
- normalized.color = /^#[0-9A-Fa-f]{6}$/.test(color) ? color.toUpperCase() : null;
37
+ normalized.avatarUrl = normalizeWorkspaceAvatarUrl(source.avatarUrl);
27
38
  }
28
39
  if (Object.hasOwn(source, "isPersonal")) {
29
40
  normalized.isPersonal = source.isPersonal === true;
@@ -40,8 +51,7 @@ function normalizeWorkspaceOutput(payload = {}) {
40
51
  slug: normalizeLowerText(source.slug),
41
52
  name: normalizeText(source.name),
42
53
  ownerUserId: Number(source.ownerUserId),
43
- avatarUrl: normalizeText(source.avatarUrl),
44
- color: normalizeText(source.color).toUpperCase()
54
+ avatarUrl: normalizeText(source.avatarUrl)
45
55
  };
46
56
  }
47
57
 
@@ -52,7 +62,6 @@ function normalizeWorkspaceListItemOutput(payload = {}) {
52
62
  id: Number(source.id),
53
63
  slug: normalizeLowerText(source.slug),
54
64
  name: normalizeText(source.name),
55
- color: normalizeText(source.color).toUpperCase(),
56
65
  avatarUrl: normalizeText(source.avatarUrl),
57
66
  roleId: normalizeLowerText(source.roleId || "member") || "member",
58
67
  isAccessible: source.isAccessible !== false
@@ -65,8 +74,7 @@ const responseRecordSchema = Type.Object(
65
74
  slug: Type.String({ minLength: 1 }),
66
75
  name: Type.String({ minLength: 1, maxLength: 160 }),
67
76
  ownerUserId: Type.Integer({ minimum: 1 }),
68
- avatarUrl: Type.String(),
69
- color: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" })
77
+ avatarUrl: Type.String()
70
78
  },
71
79
  { additionalProperties: false }
72
80
  );
@@ -76,7 +84,6 @@ const listItemSchema = Type.Object(
76
84
  id: Type.Integer({ minimum: 1 }),
77
85
  slug: Type.String({ minLength: 1 }),
78
86
  name: Type.String({ minLength: 1, maxLength: 160 }),
79
- color: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
80
87
  avatarUrl: Type.String(),
81
88
  roleId: Type.String({ minLength: 1 }),
82
89
  isAccessible: Type.Boolean()
@@ -92,6 +99,22 @@ const createRequestBodySchema = Type.Object(
92
99
  { additionalProperties: false }
93
100
  );
94
101
 
102
+ const patchRequestBodySchema = Type.Object(
103
+ {
104
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 160 })),
105
+ avatarUrl: Type.Optional(
106
+ Type.String({
107
+ pattern: "^(https?://.+)?$",
108
+ messages: {
109
+ pattern: "Workspace avatar URL must be a valid absolute URL (http:// or https://).",
110
+ default: "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
111
+ }
112
+ })
113
+ )
114
+ },
115
+ { additionalProperties: false }
116
+ );
117
+
95
118
  const responseRecordValidator = Object.freeze({
96
119
  schema: responseRecordSchema,
97
120
  normalize: normalizeWorkspaceOutput
@@ -138,7 +161,7 @@ const resource = {
138
161
  patch: {
139
162
  method: "PATCH",
140
163
  bodyValidator: {
141
- schema: Type.Partial(createRequestBodySchema, { additionalProperties: false }),
164
+ schema: patchRequestBodySchema,
142
165
  normalize: normalizeWorkspaceInput
143
166
  },
144
167
  outputValidator: responseRecordValidator
@@ -44,6 +44,7 @@ exports.up = async function up(knex) {
44
44
 
45
45
  await knex.schema.createTable("workspace_settings", (table) => {
46
46
  table.integer("workspace_id").unsigned().primary().references("id").inTable("workspaces").onDelete("CASCADE");
47
+ table.string("name", 160).notNullable().defaultTo("Workspace");
47
48
  table.string("avatar_url", 512).notNullable().defaultTo("");
48
49
  table.string("light_primary_color", 7).notNullable().defaultTo("#1867C0");
49
50
  table.string("light_secondary_color", 7).notNullable().defaultTo("#48A9A6");
@@ -1,6 +1,7 @@
1
1
  const WORKSPACE_SETTINGS_TABLE = "workspace_settings";
2
2
  const WORKSPACES_TABLE = "workspaces";
3
3
  const LEGACY_NAME_COLUMN = "name";
4
+ const LEGACY_AVATAR_COLUMN = "avatar_url";
4
5
 
5
6
  async function hasTable(knex, tableName) {
6
7
  return knex.schema.hasTable(tableName);
@@ -17,12 +18,18 @@ exports.up = async function up(knex) {
17
18
  }
18
19
 
19
20
  const hasLegacyName = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_NAME_COLUMN);
20
- if (!hasLegacyName) {
21
+ const hasLegacyAvatarUrl = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_AVATAR_COLUMN);
22
+ if (!hasLegacyName && !hasLegacyAvatarUrl) {
21
23
  return;
22
24
  }
23
25
 
24
26
  await knex.schema.alterTable(WORKSPACE_SETTINGS_TABLE, (table) => {
25
- table.dropColumn(LEGACY_NAME_COLUMN);
27
+ if (hasLegacyName) {
28
+ table.dropColumn(LEGACY_NAME_COLUMN);
29
+ }
30
+ if (hasLegacyAvatarUrl) {
31
+ table.dropColumn(LEGACY_AVATAR_COLUMN);
32
+ }
26
33
  });
27
34
  };
28
35
 
@@ -33,9 +40,15 @@ exports.down = async function down(knex) {
33
40
  }
34
41
 
35
42
  const hasLegacyName = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_NAME_COLUMN);
36
- if (!hasLegacyName) {
43
+ const hasLegacyAvatarUrl = await hasColumn(knex, WORKSPACE_SETTINGS_TABLE, LEGACY_AVATAR_COLUMN);
44
+ if (!hasLegacyName || !hasLegacyAvatarUrl) {
37
45
  await knex.schema.alterTable(WORKSPACE_SETTINGS_TABLE, (table) => {
38
- table.string(LEGACY_NAME_COLUMN, 160).notNullable().defaultTo("Workspace");
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
+ }
39
52
  });
40
53
  }
41
54
 
@@ -44,11 +57,15 @@ exports.down = async function down(knex) {
44
57
  return;
45
58
  }
46
59
 
47
- const workspaceRows = await knex(WORKSPACES_TABLE).select("id", "name");
60
+ const workspaceRows = await knex(WORKSPACES_TABLE).select("id", "name", "avatar_url");
48
61
  for (const workspaceRow of workspaceRows) {
49
62
  const normalizedName = String(workspaceRow?.name || "").trim() || "Workspace";
63
+ const normalizedAvatarUrl = String(workspaceRow?.avatar_url || "").trim();
50
64
  await knex(WORKSPACE_SETTINGS_TABLE)
51
65
  .where({ workspace_id: Number(workspaceRow.id) })
52
- .update({ name: normalizedName });
66
+ .update({
67
+ name: normalizedName,
68
+ avatar_url: normalizedAvatarUrl
69
+ });
53
70
  }
54
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
+ };
@@ -13,21 +13,6 @@ import {
13
13
  resetWorkspaceSettingsFields
14
14
  } from "@jskit-ai/users-core/shared/resources/workspaceSettingsFields";
15
15
 
16
- function normalizeAvatarUrl(value) {
17
- const avatarUrl = normalizeText(value);
18
- if (!avatarUrl) {
19
- return "";
20
- }
21
- if (!avatarUrl.startsWith("http://") && !avatarUrl.startsWith("https://")) {
22
- return null;
23
- }
24
- try {
25
- return new URL(avatarUrl).toString();
26
- } catch {
27
- return null;
28
- }
29
- }
30
-
31
16
  function normalizeHexColor(value) {
32
17
  const color = normalizeText(value);
33
18
  return /^#[0-9A-Fa-f]{6}$/.test(color) ? color.toUpperCase() : null;
@@ -35,23 +20,6 @@ function normalizeHexColor(value) {
35
20
 
36
21
  resetWorkspaceSettingsFields();
37
22
 
38
- defineField({
39
- key: "avatarUrl",
40
- dbColumn: "avatar_url",
41
- required: false,
42
- inputSchema: Type.String({
43
- pattern: "^(https?://.+)?$",
44
- messages: {
45
- pattern: "Workspace avatar URL must be a valid absolute URL (http:// or https://).",
46
- default: "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
47
- }
48
- }),
49
- outputSchema: Type.String(),
50
- normalizeInput: normalizeAvatarUrl,
51
- normalizeOutput: (value) => normalizeText(value),
52
- resolveDefault: ({ workspace = {} } = {}) => normalizeText(workspace.avatarUrl)
53
- });
54
-
55
23
  defineField({
56
24
  key: "lightPrimaryColor",
57
25
  dbColumn: "light_primary_color",
@@ -22,5 +22,10 @@ test("registerWorkspaceDirectory registers workspace directory actions without r
22
22
  const app = createAppDouble();
23
23
 
24
24
  registerWorkspaceDirectory(app);
25
- assert.deepEqual(listActionIds(app), ["workspace.workspaces.create", "workspace.workspaces.list"]);
25
+ assert.deepEqual(listActionIds(app), [
26
+ "workspace.workspaces.create",
27
+ "workspace.workspaces.list",
28
+ "workspace.workspaces.read",
29
+ "workspace.workspaces.update"
30
+ ]);
26
31
  });
@@ -126,6 +126,10 @@ test("workspace and settings routes attach only the shared transport normalizers
126
126
  method: "GET",
127
127
  path: "/api/w/:workspaceSlug/workspace/settings"
128
128
  });
129
+ const workspacePatch = findRoute(routes, {
130
+ method: "PATCH",
131
+ path: "/api/w/:workspaceSlug/workspace"
132
+ });
129
133
  const workspaceSettingsPatch = findRoute(routes, {
130
134
  method: "PATCH",
131
135
  path: "/api/w/:workspaceSlug/workspace/settings"
@@ -156,6 +160,7 @@ test("workspace and settings routes attach only the shared transport normalizers
156
160
  });
157
161
 
158
162
  assert.equal(typeof workspaceSettings?.paramsValidator?.normalize, "function");
163
+ assert.equal(typeof workspacePatch?.bodyValidator?.normalize, "function");
159
164
  assert.equal(typeof workspaceSettingsPatch?.bodyValidator?.normalize, "function");
160
165
  assert.equal(typeof workspaceMemberRole?.paramsValidator?.normalize, "function");
161
166
  assert.equal(typeof workspaceMemberRole?.bodyValidator?.normalize, "function");
@@ -167,8 +172,16 @@ test("workspace and settings routes attach only the shared transport normalizers
167
172
  assert.equal(typeof consoleSettingsPatch?.bodyValidator?.normalize, "function");
168
173
  });
169
174
 
170
- test("workspace settings routes mount one canonical workspace endpoint", async () => {
175
+ test("workspace core/settings routes mount one canonical workspace endpoint", async () => {
171
176
  const routes = await registerRoutes();
177
+ const workspace = findRoute(routes, {
178
+ method: "GET",
179
+ path: "/api/w/:workspaceSlug/workspace"
180
+ });
181
+ const workspacePatch = findRoute(routes, {
182
+ method: "PATCH",
183
+ path: "/api/w/:workspaceSlug/workspace"
184
+ });
172
185
  const workspaceSettings = findRoute(routes, {
173
186
  method: "GET",
174
187
  path: "/api/w/:workspaceSlug/workspace/settings"
@@ -186,6 +199,11 @@ test("workspace settings routes mount one canonical workspace endpoint", async (
186
199
  path: "/api/console/w/:workspaceSlug/workspace/settings"
187
200
  });
188
201
 
202
+ assert.ok(workspace);
203
+ assert.equal(workspace?.visibility, "workspace");
204
+ assert.equal(workspacePatch?.visibility, "workspace");
205
+ assert.equal(workspace?.surface, "");
206
+ assert.equal(workspacePatch?.surface, "");
189
207
  assert.ok(workspaceSettings);
190
208
  assert.equal(workspaceSettings?.visibility, "workspace");
191
209
  assert.equal(workspaceSettingsPatch?.visibility, "workspace");
@@ -205,6 +223,8 @@ test("users-core boot skips workspace routes when workspace policy is disabled",
205
223
 
206
224
  assert.equal(findRoute(routes, { method: "GET", path: "/api/workspaces" }), null);
207
225
  assert.equal(findRoute(routes, { method: "POST", path: "/api/workspaces" }), null);
226
+ assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug/workspace" }), null);
227
+ assert.equal(findRoute(routes, { method: "PATCH", path: "/api/w/:workspaceSlug/workspace" }), null);
208
228
  assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug/workspace/settings" }), null);
209
229
  assert.equal(findRoute(routes, { method: "GET", path: "/api/settings" })?.path, "/api/settings");
210
230
  });
@@ -242,11 +262,21 @@ test("users-core route registration follows tenancy mode matrix", async () => {
242
262
 
243
263
  assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/workspaces" }), null);
244
264
  assert.equal(findRoute(noneRoutes, { method: "POST", path: "/api/workspaces" }), null);
265
+ assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/w/:workspaceSlug/workspace" }), null);
266
+ assert.equal(findRoute(noneRoutes, { method: "PATCH", path: "/api/w/:workspaceSlug/workspace" }), null);
245
267
  assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/w/:workspaceSlug/workspace/settings" }), null);
246
268
  assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/workspace/invitations/pending" }), null);
247
269
 
248
270
  assert.equal(findRoute(personalRoutes, { method: "GET", path: "/api/workspaces" })?.path, "/api/workspaces");
249
271
  assert.equal(findRoute(personalRoutes, { method: "POST", path: "/api/workspaces" }), null);
272
+ assert.equal(
273
+ findRoute(personalRoutes, { method: "GET", path: "/api/w/:workspaceSlug/workspace" })?.path,
274
+ "/api/w/:workspaceSlug/workspace"
275
+ );
276
+ assert.equal(
277
+ findRoute(personalRoutes, { method: "PATCH", path: "/api/w/:workspaceSlug/workspace" })?.path,
278
+ "/api/w/:workspaceSlug/workspace"
279
+ );
250
280
  assert.equal(
251
281
  findRoute(personalRoutes, { method: "GET", path: "/api/w/:workspaceSlug/workspace/settings" })?.path,
252
282
  "/api/w/:workspaceSlug/workspace/settings"
@@ -258,6 +288,14 @@ test("users-core route registration follows tenancy mode matrix", async () => {
258
288
 
259
289
  assert.equal(findRoute(workspaceRoutes, { method: "GET", path: "/api/workspaces" })?.path, "/api/workspaces");
260
290
  assert.equal(findRoute(workspaceRoutes, { method: "POST", path: "/api/workspaces" }), null);
291
+ assert.equal(
292
+ findRoute(workspaceRoutes, { method: "GET", path: "/api/w/:workspaceSlug/workspace" })?.path,
293
+ "/api/w/:workspaceSlug/workspace"
294
+ );
295
+ assert.equal(
296
+ findRoute(workspaceRoutes, { method: "PATCH", path: "/api/w/:workspaceSlug/workspace" })?.path,
297
+ "/api/w/:workspaceSlug/workspace"
298
+ );
261
299
  assert.equal(
262
300
  findRoute(workspaceRoutes, { method: "GET", path: "/api/w/:workspaceSlug/workspace/settings" })?.path,
263
301
  "/api/w/:workspaceSlug/workspace/settings"
@@ -404,7 +442,7 @@ test("workspace settings route handlers build action input from request.input",
404
442
  createActionRequest({
405
443
  input: {
406
444
  params: { workspaceSlug: "acme" },
407
- body: { avatarUrl: "https://example.com/acme.png" }
445
+ body: { lightPrimaryColor: "#0F6B54" }
408
446
  },
409
447
  executeAction
410
448
  }),
@@ -413,7 +451,39 @@ test("workspace settings route handlers build action input from request.input",
413
451
 
414
452
  assert.deepEqual(calls[0], {
415
453
  actionId: "workspace.settings.update",
416
- input: { workspaceSlug: "acme", patch: { avatarUrl: "https://example.com/acme.png" } }
454
+ input: { workspaceSlug: "acme", patch: { lightPrimaryColor: "#0F6B54" } }
455
+ });
456
+ });
457
+
458
+ test("workspace route handlers build action input from request.input", async () => {
459
+ const routes = await registerRoutes();
460
+ const workspacePatch = findRoute(routes, {
461
+ method: "PATCH",
462
+ path: "/api/w/:workspaceSlug/workspace"
463
+ });
464
+ const calls = [];
465
+ const executeAction = async (payload) => {
466
+ calls.push(payload);
467
+ return {};
468
+ };
469
+
470
+ await workspacePatch.handler(
471
+ createActionRequest({
472
+ input: {
473
+ params: { workspaceSlug: "acme" },
474
+ body: { name: "Acme", avatarUrl: "https://example.com/acme.png" }
475
+ },
476
+ executeAction
477
+ }),
478
+ createReplyDouble()
479
+ );
480
+
481
+ assert.deepEqual(calls[0], {
482
+ actionId: "workspace.workspaces.update",
483
+ input: {
484
+ workspaceSlug: "acme",
485
+ patch: { name: "Acme", avatarUrl: "https://example.com/acme.png" }
486
+ }
417
487
  });
418
488
  });
419
489
 
@@ -42,8 +42,7 @@ function createFixture() {
42
42
  slug: "tonymobily3",
43
43
  name: "TonyMobily3",
44
44
  ownerUserId: 9,
45
- avatarUrl: "",
46
- color: "#0F6B54"
45
+ avatarUrl: ""
47
46
  };
48
47
 
49
48
  const service = createService({
@@ -248,8 +247,7 @@ test("workspaceMembersService.listMembers uses the resolved workspace directly",
248
247
  slug: "tonymobily3",
249
248
  name: "TonyMobily3",
250
249
  ownerUserId: 9,
251
- avatarUrl: "",
252
- color: "#0F6B54"
250
+ avatarUrl: ""
253
251
  });
254
252
  assert.equal(response.members.length, 1);
255
253
  assert.equal(response.members[0].displayName, "Alice");
@@ -278,8 +276,7 @@ test("workspaceMembersService.removeMember marks membership revoked and returns
278
276
  slug: "tonymobily3",
279
277
  name: "TonyMobily3",
280
278
  ownerUserId: 9,
281
- avatarUrl: "",
282
- color: "#0F6B54"
279
+ avatarUrl: ""
283
280
  };
284
281
  const service = createService({
285
282
  workspaceMembershipsRepository: {
@@ -349,8 +346,7 @@ test("workspaceMembersService.removeMember rejects removing the owner", async ()
349
346
  slug: "tonymobily3",
350
347
  name: "TonyMobily3",
351
348
  ownerUserId: 9,
352
- avatarUrl: "",
353
- color: "#0F6B54"
349
+ avatarUrl: ""
354
350
  };
355
351
  const service = createService({
356
352
  workspaceMembershipsRepository: {
@@ -31,14 +31,14 @@ function createWorkspaceServiceFixture({
31
31
  name: "TonyMobily3",
32
32
  ownerUserId: 7,
33
33
  isPersonal: true,
34
- avatarUrl: "",
35
- color: "#0F6B54"
34
+ avatarUrl: ""
36
35
  }
37
36
  } = {}) {
38
37
  const calls = {
39
38
  findPersonalByOwnerUserId: 0,
40
39
  listForUserId: 0,
41
40
  insert: 0,
41
+ updateById: 0,
42
42
  ensureOwnerMembership: 0
43
43
  };
44
44
  let nextWorkspaceId = 10;
@@ -95,7 +95,6 @@ function createWorkspaceServiceFixture({
95
95
  slug: "tonymobily3",
96
96
  name: "TonyMobily3",
97
97
  avatarUrl: "",
98
- color: "#0F6B54",
99
98
  roleId: "owner",
100
99
  membershipStatus: "active"
101
100
  },
@@ -104,7 +103,6 @@ function createWorkspaceServiceFixture({
104
103
  slug: "pending-workspace",
105
104
  name: "Pending Workspace",
106
105
  avatarUrl: "",
107
- color: "#0F6B54",
108
106
  roleId: "member",
109
107
  membershipStatus: "pending"
110
108
  }
@@ -120,11 +118,33 @@ function createWorkspaceServiceFixture({
120
118
  name: String(payload.name || ""),
121
119
  ownerUserId: Number(payload.ownerUserId),
122
120
  isPersonal: payload.isPersonal === true,
123
- avatarUrl: String(payload.avatarUrl || ""),
124
- color: String(payload.color || "#0F6B54")
121
+ avatarUrl: String(payload.avatarUrl || "")
125
122
  };
126
123
  workspaceBySlug.set(String(inserted.slug).trim().toLowerCase(), inserted);
127
124
  return inserted;
125
+ },
126
+ async updateById(workspaceId, patch) {
127
+ calls.updateById += 1;
128
+ const targetId = Number(workspaceId);
129
+ for (const [slug, workspace] of workspaceBySlug.entries()) {
130
+ if (Number(workspace.id) !== targetId) {
131
+ continue;
132
+ }
133
+ const updated = {
134
+ ...workspace
135
+ };
136
+ if (Object.hasOwn(patch, "name")) {
137
+ updated.name = String(patch.name || "");
138
+ }
139
+ if (Object.hasOwn(patch, "avatarUrl")) {
140
+ updated.avatarUrl = String(patch.avatarUrl || "");
141
+ }
142
+ workspaceBySlug.set(slug, updated);
143
+ return {
144
+ ...updated
145
+ };
146
+ }
147
+ return null;
128
148
  }
129
149
  },
130
150
  workspaceMembershipsRepository: {
@@ -200,7 +220,6 @@ test("workspaceService.listWorkspacesForUser returns all active memberships in p
200
220
  slug: "chiaramobily",
201
221
  name: "Chiara Personal",
202
222
  avatarUrl: "",
203
- color: "#0F6B54",
204
223
  roleId: "owner",
205
224
  membershipStatus: "active"
206
225
  },
@@ -209,7 +228,6 @@ test("workspaceService.listWorkspacesForUser returns all active memberships in p
209
228
  slug: "tonymobily",
210
229
  name: "Tony Workspace",
211
230
  avatarUrl: "",
212
- color: "#0F6B54",
213
231
  roleId: "member",
214
232
  membershipStatus: "active"
215
233
  },
@@ -218,7 +236,6 @@ test("workspaceService.listWorkspacesForUser returns all active memberships in p
218
236
  slug: "pending-workspace",
219
237
  name: "Pending Workspace",
220
238
  avatarUrl: "",
221
- color: "#0F6B54",
222
239
  roleId: "member",
223
240
  membershipStatus: "pending"
224
241
  }
@@ -352,8 +369,7 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug allows personal tena
352
369
  name: "My Personal",
353
370
  ownerUserId: 7,
354
371
  isPersonal: true,
355
- avatarUrl: "",
356
- color: "#0F6B54"
372
+ avatarUrl: ""
357
373
  },
358
374
  additionalWorkspaces: [
359
375
  {
@@ -362,8 +378,7 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug allows personal tena
362
378
  name: "Team Alpha",
363
379
  ownerUserId: 99,
364
380
  isPersonal: false,
365
- avatarUrl: "",
366
- color: "#0F6B54"
381
+ avatarUrl: ""
367
382
  }
368
383
  ]
369
384
  });
@@ -402,8 +417,7 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug grants owner access
402
417
  name: "TonyMobily",
403
418
  ownerUserId: 7,
404
419
  isPersonal: true,
405
- avatarUrl: "",
406
- color: "#0F6B54"
420
+ avatarUrl: ""
407
421
  };
408
422
  },
409
423
  async findPersonalByOwnerUserId() {
@@ -478,3 +492,51 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug resolves permissions
478
492
 
479
493
  assert.deepEqual(context.permissions, ["workspace.settings.update"]);
480
494
  });
495
+
496
+ test("workspaceService.getWorkspaceForAuthenticatedUser resolves workspace from slug context", async () => {
497
+ const { service } = createWorkspaceServiceFixture({
498
+ additionalWorkspaces: [
499
+ {
500
+ id: 42,
501
+ slug: "team-alpha",
502
+ name: "Team Alpha",
503
+ ownerUserId: 99,
504
+ isPersonal: false,
505
+ avatarUrl: ""
506
+ }
507
+ ]
508
+ });
509
+
510
+ const workspace = await service.getWorkspaceForAuthenticatedUser(
511
+ {
512
+ id: 7,
513
+ email: "chiaramobily@gmail.com",
514
+ displayName: "Chiara"
515
+ },
516
+ "team-alpha"
517
+ );
518
+
519
+ assert.equal(workspace.slug, "team-alpha");
520
+ assert.equal(workspace.name, "Team Alpha");
521
+ });
522
+
523
+ test("workspaceService.updateWorkspaceForAuthenticatedUser updates workspace profile fields", async () => {
524
+ const { service, calls } = createWorkspaceServiceFixture();
525
+
526
+ const workspace = await service.updateWorkspaceForAuthenticatedUser(
527
+ {
528
+ id: 7,
529
+ email: "chiaramobily@gmail.com",
530
+ displayName: "Chiara"
531
+ },
532
+ "tonymobily3",
533
+ {
534
+ name: "Updated Workspace",
535
+ avatarUrl: "https://example.com/acme.png"
536
+ }
537
+ );
538
+
539
+ assert.equal(calls.updateById, 1);
540
+ assert.equal(workspace.name, "Updated Workspace");
541
+ assert.equal(workspace.avatarUrl, "https://example.com/acme.png");
542
+ });
@@ -40,3 +40,13 @@ test("workspace directory actions use the canonical workspace list resource outp
40
40
  assert.ok(listAction);
41
41
  assert.equal(listAction.outputValidator, workspaceResource.operations.list.outputValidator);
42
42
  });
43
+
44
+ test("workspace directory read/update actions use canonical workspace resource validators", () => {
45
+ const readAction = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.read");
46
+ const updateAction = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.update");
47
+
48
+ assert.ok(readAction);
49
+ assert.ok(updateAction);
50
+ assert.equal(readAction.outputValidator, workspaceResource.operations.view.outputValidator);
51
+ assert.equal(updateAction.outputValidator, workspaceResource.operations.patch.outputValidator);
52
+ });
@@ -16,7 +16,6 @@ function createKnexStub(rowOverrides = {}) {
16
16
  updatePayload: null,
17
17
  row: {
18
18
  workspace_id: 1,
19
- avatar_url: "",
20
19
  light_primary_color: DEFAULT_WORKSPACE_THEME.light.color,
21
20
  light_secondary_color: DEFAULT_WORKSPACE_THEME.light.secondaryColor,
22
21
  light_surface_color: DEFAULT_WORKSPACE_THEME.light.surfaceColor,
@@ -40,7 +39,6 @@ function createKnexStub(rowOverrides = {}) {
40
39
  state.insertedRow = { ...payload };
41
40
  state.row = {
42
41
  workspace_id: payload.workspace_id,
43
- avatar_url: payload.avatar_url,
44
42
  light_primary_color: payload.light_primary_color,
45
43
  light_secondary_color: payload.light_secondary_color,
46
44
  light_surface_color: payload.light_surface_color,
@@ -67,9 +65,6 @@ function createKnexStub(rowOverrides = {}) {
67
65
  if (Object.hasOwn(payload, "invites_enabled")) {
68
66
  state.row.invites_enabled = payload.invites_enabled;
69
67
  }
70
- if (Object.hasOwn(payload, "avatar_url")) {
71
- state.row.avatar_url = payload.avatar_url;
72
- }
73
68
  if (Object.hasOwn(payload, "light_primary_color")) {
74
69
  state.row.light_primary_color = payload.light_primary_color;
75
70
  }
@@ -117,7 +112,6 @@ test("workspaceSettingsRepository.findByWorkspaceId maps the stored row", async
117
112
 
118
113
  assert.deepEqual(record, {
119
114
  workspaceId: 1,
120
- avatarUrl: "",
121
115
  lightPrimaryColor: DEFAULT_WORKSPACE_THEME.light.color,
122
116
  lightSecondaryColor: DEFAULT_WORKSPACE_THEME.light.secondaryColor,
123
117
  lightSurfaceColor: DEFAULT_WORKSPACE_THEME.light.surfaceColor,
@@ -156,7 +150,6 @@ test("workspaceSettingsRepository.ensureForWorkspaceId inserts the injected defa
156
150
  const record = await repository.ensureForWorkspaceId(5);
157
151
 
158
152
  assert.equal(state.insertedRow.workspace_id, 5);
159
- assert.equal(state.insertedRow.avatar_url, "");
160
153
  assert.equal(state.insertedRow.light_primary_color, DEFAULT_WORKSPACE_THEME.light.color);
161
154
  assert.equal(state.insertedRow.light_secondary_color, DEFAULT_WORKSPACE_THEME.light.secondaryColor);
162
155
  assert.equal(state.insertedRow.light_surface_color, DEFAULT_WORKSPACE_THEME.light.surfaceColor);
@@ -172,7 +165,6 @@ test("workspaceSettingsRepository.ensureForWorkspaceId inserts the injected defa
172
165
  DEFAULT_WORKSPACE_THEME.dark.surfaceVariantColor
173
166
  );
174
167
  assert.equal(state.insertedRow.invites_enabled, false);
175
- assert.equal(record.avatarUrl, "");
176
168
  assert.equal(record.lightPrimaryColor, DEFAULT_WORKSPACE_THEME.light.color);
177
169
  assert.equal(record.lightSecondaryColor, DEFAULT_WORKSPACE_THEME.light.secondaryColor);
178
170
  assert.equal(record.lightSurfaceColor, DEFAULT_WORKSPACE_THEME.light.surfaceColor);
@@ -184,20 +176,17 @@ test("workspaceSettingsRepository.ensureForWorkspaceId inserts the injected defa
184
176
  assert.equal(record.invitesEnabled, false);
185
177
  });
186
178
 
187
- test("workspaceSettingsRepository.updateSettingsByWorkspaceId updates avatar/color columns", async () => {
179
+ test("workspaceSettingsRepository.updateSettingsByWorkspaceId updates workspace settings columns", async () => {
188
180
  const { knexStub, state } = createKnexStub();
189
181
  const repository = createRepository(knexStub, {
190
182
  defaultInvitesEnabled: true
191
183
  });
192
184
 
193
185
  const updated = await repository.updateSettingsByWorkspaceId(1, {
194
- avatarUrl: "https://example.com/avatar.png",
195
186
  lightPrimaryColor: "#123abc"
196
187
  });
197
188
 
198
- assert.equal(state.updatePayload.avatar_url, "https://example.com/avatar.png");
199
189
  assert.equal(state.updatePayload.light_primary_color, "#123ABC");
200
- assert.equal(updated.avatarUrl, "https://example.com/avatar.png");
201
190
  assert.equal(updated.lightPrimaryColor, "#123ABC");
202
191
  });
203
192
 
@@ -46,7 +46,6 @@ function parseBody(operation, payload = {}) {
46
46
 
47
47
  test("workspace settings patch body normalizes valid payload before validation", () => {
48
48
  const parsed = parseBody(workspaceSettingsResource.operations.patch, {
49
- avatarUrl: "https://example.com/avatar.png",
50
49
  lightPrimaryColor: "#0f6b54",
51
50
  lightSecondaryColor: "#0b4d3c",
52
51
  lightSurfaceColor: "#eef5f3",
@@ -61,7 +60,6 @@ test("workspace settings patch body normalizes valid payload before validation",
61
60
  assert.equal(parsed.ok, true);
62
61
  assert.deepEqual(parsed.fieldErrors, {});
63
62
  assert.deepEqual(parsed.value, {
64
- avatarUrl: "https://example.com/avatar.png",
65
63
  lightPrimaryColor: "#0F6B54",
66
64
  lightSecondaryColor: "#0B4D3C",
67
65
  lightSurfaceColor: "#EEF5F3",
@@ -74,16 +72,14 @@ test("workspace settings patch body normalizes valid payload before validation",
74
72
  });
75
73
  });
76
74
 
77
- test("workspace settings patch body validates avatar URL protocol", () => {
75
+ test("workspace settings patch body ignores unknown fields after normalization", () => {
78
76
  const parsed = parseBody(workspaceSettingsResource.operations.patch, {
79
- avatarUrl: "ftp://example.com/avatar.png"
77
+ avatarUrl: "https://example.com/avatar.png"
80
78
  });
81
79
 
82
- assert.equal(parsed.ok, false);
83
- assert.equal(
84
- parsed.fieldErrors.avatarUrl,
85
- "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
86
- );
80
+ assert.equal(parsed.ok, true);
81
+ assert.deepEqual(parsed.fieldErrors, {});
82
+ assert.deepEqual(parsed.value, {});
87
83
  });
88
84
 
89
85
  test("workspace settings create body requires full-write fields", () => {
@@ -112,7 +108,6 @@ test("workspace settings output normalizes raw service payloads", () => {
112
108
  ownerUserId: "9"
113
109
  },
114
110
  settings: {
115
- avatarUrl: " https://example.com/avatar.png ",
116
111
  lightPrimaryColor: "#0f6b54",
117
112
  invitesEnabled: false
118
113
  },
@@ -126,7 +121,6 @@ test("workspace settings output normalizes raw service payloads", () => {
126
121
  ownerUserId: 9
127
122
  },
128
123
  settings: {
129
- avatarUrl: "https://example.com/avatar.png",
130
124
  lightPrimaryColor: "#0F6B54",
131
125
  lightSecondaryColor: expectedTheme.light.secondaryColor,
132
126
  lightSurfaceColor: expectedTheme.light.surfaceColor,
@@ -25,12 +25,9 @@ function createFixture({ workspaceInvitationsEnabled = true } = {}) {
25
25
  id: 7,
26
26
  slug: "tonymobily3",
27
27
  name: "TonyMobily3",
28
- ownerUserId: 9,
29
- avatarUrl: "",
30
- color: defaultTheme.light.color
28
+ ownerUserId: 9
31
29
  },
32
30
  settings: {
33
- avatarUrl: "",
34
31
  lightPrimaryColor: defaultTheme.light.color,
35
32
  lightSecondaryColor: defaultTheme.light.secondaryColor,
36
33
  lightSurfaceColor: defaultTheme.light.surfaceColor,
@@ -74,7 +71,6 @@ test("workspaceSettingsService.getWorkspaceSettings returns the stored invitesEn
74
71
  );
75
72
 
76
73
  assert.deepEqual(response.settings, {
77
- avatarUrl: "",
78
74
  lightPrimaryColor: "#0F6B54",
79
75
  lightSecondaryColor: "#48A9A6",
80
76
  lightSurfaceColor: "#FFFFFF",
@@ -104,7 +100,6 @@ test("workspaceSettingsService.updateWorkspaceSettings writes editable fields th
104
100
  invitesEnabled: false
105
101
  });
106
102
  assert.deepEqual(response.settings, {
107
- avatarUrl: "",
108
103
  lightPrimaryColor: "#0F6B54",
109
104
  lightSecondaryColor: "#48A9A6",
110
105
  lightSurfaceColor: "#FFFFFF",
@@ -130,7 +125,6 @@ test("workspaceSettingsService disables invite settings in output when app polic
130
125
  );
131
126
 
132
127
  assert.deepEqual(response.settings, {
133
- avatarUrl: "",
134
128
  lightPrimaryColor: "#0F6B54",
135
129
  lightSecondaryColor: "#48A9A6",
136
130
  lightSurfaceColor: "#FFFFFF",