@jskit-ai/users-core 0.1.22 → 0.1.23

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.23",
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,7 +257,7 @@ 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
  },
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.23",
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"
@@ -23,7 +23,6 @@ function mapWorkspaceSettingsPublic(workspaceSettings, { workspaceInvitationsEna
23
23
  const themePalettes = resolveWorkspaceThemePalettes(source);
24
24
 
25
25
  return {
26
- name: normalizeText(source.name),
27
26
  lightPrimaryColor: themePalettes.light.color,
28
27
  lightSecondaryColor: themePalettes.light.secondaryColor,
29
28
  lightSurfaceColor: themePalettes.light.surfaceColor,
@@ -32,7 +31,6 @@ function mapWorkspaceSettingsPublic(workspaceSettings, { workspaceInvitationsEna
32
31
  darkSecondaryColor: themePalettes.dark.secondaryColor,
33
32
  darkSurfaceColor: themePalettes.dark.surfaceColor,
34
33
  darkSurfaceVariantColor: themePalettes.dark.surfaceVariantColor,
35
- avatarUrl: normalizeText(source.avatarUrl),
36
34
  invitesEnabled,
37
35
  invitesAvailable,
38
36
  invitesEffective: invitesAvailable && invitesEnabled
@@ -45,14 +45,14 @@ function createRepository(knex) {
45
45
  throw new TypeError("workspacesRepository requires knex.");
46
46
  }
47
47
 
48
- function workspaceSelectColumns(client, { includeMembership = false } = {}) {
48
+ function workspaceSelectColumns({ includeMembership = false } = {}) {
49
49
  const columns = [
50
50
  "w.id",
51
51
  "w.slug",
52
52
  "w.name",
53
53
  "w.owner_user_id",
54
54
  "w.is_personal",
55
- client.raw("COALESCE(ws.avatar_url, w.avatar_url) as avatar_url"),
55
+ "w.avatar_url",
56
56
  "w.color",
57
57
  "w.created_at",
58
58
  "w.updated_at",
@@ -67,9 +67,8 @@ function createRepository(knex) {
67
67
  async function findById(workspaceId, options = {}) {
68
68
  const client = options?.trx || knex;
69
69
  const row = await client("workspaces as w")
70
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
71
70
  .where({ "w.id": Number(workspaceId) })
72
- .select(workspaceSelectColumns(client))
71
+ .select(workspaceSelectColumns())
73
72
  .first();
74
73
  return mapRow(row);
75
74
  }
@@ -82,9 +81,8 @@ function createRepository(knex) {
82
81
  }
83
82
 
84
83
  const row = await client("workspaces as w")
85
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
86
84
  .where({ "w.slug": normalizedSlug })
87
- .select(workspaceSelectColumns(client))
85
+ .select(workspaceSelectColumns())
88
86
  .first();
89
87
  return mapRow(row);
90
88
  }
@@ -92,10 +90,9 @@ function createRepository(knex) {
92
90
  async function findPersonalByOwnerUserId(userId, options = {}) {
93
91
  const client = options?.trx || knex;
94
92
  const row = await client("workspaces as w")
95
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
96
93
  .where({ "w.owner_user_id": Number(userId), "w.is_personal": 1 })
97
94
  .orderBy("w.id", "asc")
98
- .select(workspaceSelectColumns(client))
95
+ .select(workspaceSelectColumns())
99
96
  .first();
100
97
  return mapRow(row);
101
98
  }
@@ -161,12 +158,11 @@ function createRepository(knex) {
161
158
  const client = options?.trx || knex;
162
159
  const rows = await client("workspace_memberships as wm")
163
160
  .join("workspaces as w", "w.id", "wm.workspace_id")
164
- .leftJoin("workspace_settings as ws", "ws.workspace_id", "w.id")
165
161
  .where({ "wm.user_id": Number(userId) })
166
162
  .whereNull("w.deleted_at")
167
163
  .orderBy("w.is_personal", "desc")
168
164
  .orderBy("w.id", "asc")
169
- .select(workspaceSelectColumns(client, { includeMembership: true }));
165
+ .select(workspaceSelectColumns({ includeMembership: true }));
170
166
 
171
167
  return rows.map(mapMembershipWorkspaceRow).filter(Boolean);
172
168
  }
@@ -205,6 +205,16 @@ function createService({
205
205
  return inserted;
206
206
  }
207
207
 
208
+ async function getWorkspaceForAuthenticatedUser(user, workspaceSlug, options = {}) {
209
+ const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
210
+ return workspaceContext.workspace;
211
+ }
212
+
213
+ async function updateWorkspaceForAuthenticatedUser(user, workspaceSlug, patch = {}, options = {}) {
214
+ const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
215
+ return workspacesRepository.updateById(workspaceContext.workspace.id, patch, options);
216
+ }
217
+
208
218
  async function resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options = {}) {
209
219
  const normalizedUser = authenticatedUserValidator.normalize(user);
210
220
  if (!normalizedUser) {
@@ -261,6 +271,8 @@ function createService({
261
271
  ensurePersonalWorkspaceForUser,
262
272
  provisionWorkspaceForNewUser,
263
273
  createWorkspaceForAuthenticatedUser,
274
+ getWorkspaceForAuthenticatedUser,
275
+ updateWorkspaceForAuthenticatedUser,
264
276
  listWorkspacesForUser,
265
277
  listWorkspacesForAuthenticatedUser,
266
278
  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
 
@@ -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,7 +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);
37
+ normalized.avatarUrl = normalizeWorkspaceAvatarUrl(source.avatarUrl);
23
38
  }
24
39
  if (Object.hasOwn(source, "color")) {
25
40
  const color = normalizeText(source.color);
@@ -92,6 +107,33 @@ const createRequestBodySchema = Type.Object(
92
107
  { additionalProperties: false }
93
108
  );
94
109
 
110
+ const patchRequestBodySchema = Type.Object(
111
+ {
112
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 160 })),
113
+ avatarUrl: Type.Optional(
114
+ Type.String({
115
+ pattern: "^(https?://.+)?$",
116
+ messages: {
117
+ pattern: "Workspace avatar URL must be a valid absolute URL (http:// or https://).",
118
+ default: "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
119
+ }
120
+ })
121
+ ),
122
+ color: Type.Optional(
123
+ Type.String({
124
+ minLength: 7,
125
+ maxLength: 7,
126
+ pattern: "^#[0-9A-Fa-f]{6}$",
127
+ messages: {
128
+ pattern: "Workspace color must be a hex color like #1867C0.",
129
+ default: "Workspace color must be a hex color like #1867C0."
130
+ }
131
+ })
132
+ )
133
+ },
134
+ { additionalProperties: false }
135
+ );
136
+
95
137
  const responseRecordValidator = Object.freeze({
96
138
  schema: responseRecordSchema,
97
139
  normalize: normalizeWorkspaceOutput
@@ -138,7 +180,7 @@ const resource = {
138
180
  patch: {
139
181
  method: "PATCH",
140
182
  bodyValidator: {
141
- schema: Type.Partial(createRequestBodySchema, { additionalProperties: false }),
183
+ schema: patchRequestBodySchema,
142
184
  normalize: normalizeWorkspaceInput
143
185
  },
144
186
  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
  };
@@ -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", color: "#0F6B54" }
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", color: "#0F6B54" }
486
+ }
417
487
  });
418
488
  });
419
489
 
@@ -39,6 +39,7 @@ function createWorkspaceServiceFixture({
39
39
  findPersonalByOwnerUserId: 0,
40
40
  listForUserId: 0,
41
41
  insert: 0,
42
+ updateById: 0,
42
43
  ensureOwnerMembership: 0
43
44
  };
44
45
  let nextWorkspaceId = 10;
@@ -125,6 +126,32 @@ function createWorkspaceServiceFixture({
125
126
  };
126
127
  workspaceBySlug.set(String(inserted.slug).trim().toLowerCase(), inserted);
127
128
  return inserted;
129
+ },
130
+ async updateById(workspaceId, patch) {
131
+ calls.updateById += 1;
132
+ const targetId = Number(workspaceId);
133
+ for (const [slug, workspace] of workspaceBySlug.entries()) {
134
+ if (Number(workspace.id) !== targetId) {
135
+ continue;
136
+ }
137
+ const updated = {
138
+ ...workspace
139
+ };
140
+ if (Object.hasOwn(patch, "name")) {
141
+ updated.name = String(patch.name || "");
142
+ }
143
+ if (Object.hasOwn(patch, "avatarUrl")) {
144
+ updated.avatarUrl = String(patch.avatarUrl || "");
145
+ }
146
+ if (Object.hasOwn(patch, "color")) {
147
+ updated.color = String(patch.color || "#0F6B54");
148
+ }
149
+ workspaceBySlug.set(slug, updated);
150
+ return {
151
+ ...updated
152
+ };
153
+ }
154
+ return null;
128
155
  }
129
156
  },
130
157
  workspaceMembershipsRepository: {
@@ -478,3 +505,54 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug resolves permissions
478
505
 
479
506
  assert.deepEqual(context.permissions, ["workspace.settings.update"]);
480
507
  });
508
+
509
+ test("workspaceService.getWorkspaceForAuthenticatedUser resolves workspace from slug context", async () => {
510
+ const { service } = createWorkspaceServiceFixture({
511
+ additionalWorkspaces: [
512
+ {
513
+ id: 42,
514
+ slug: "team-alpha",
515
+ name: "Team Alpha",
516
+ ownerUserId: 99,
517
+ isPersonal: false,
518
+ avatarUrl: "",
519
+ color: "#0F6B54"
520
+ }
521
+ ]
522
+ });
523
+
524
+ const workspace = await service.getWorkspaceForAuthenticatedUser(
525
+ {
526
+ id: 7,
527
+ email: "chiaramobily@gmail.com",
528
+ displayName: "Chiara"
529
+ },
530
+ "team-alpha"
531
+ );
532
+
533
+ assert.equal(workspace.slug, "team-alpha");
534
+ assert.equal(workspace.name, "Team Alpha");
535
+ });
536
+
537
+ test("workspaceService.updateWorkspaceForAuthenticatedUser updates workspace profile fields", async () => {
538
+ const { service, calls } = createWorkspaceServiceFixture();
539
+
540
+ const workspace = await service.updateWorkspaceForAuthenticatedUser(
541
+ {
542
+ id: 7,
543
+ email: "chiaramobily@gmail.com",
544
+ displayName: "Chiara"
545
+ },
546
+ "tonymobily3",
547
+ {
548
+ name: "Updated Workspace",
549
+ avatarUrl: "https://example.com/acme.png",
550
+ color: "#123ABC"
551
+ }
552
+ );
553
+
554
+ assert.equal(calls.updateById, 1);
555
+ assert.equal(workspace.name, "Updated Workspace");
556
+ assert.equal(workspace.avatarUrl, "https://example.com/acme.png");
557
+ assert.equal(workspace.color, "#123ABC");
558
+ });
@@ -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,
@@ -26,11 +26,9 @@ function createFixture({ workspaceInvitationsEnabled = true } = {}) {
26
26
  slug: "tonymobily3",
27
27
  name: "TonyMobily3",
28
28
  ownerUserId: 9,
29
- avatarUrl: "",
30
29
  color: defaultTheme.light.color
31
30
  },
32
31
  settings: {
33
- avatarUrl: "",
34
32
  lightPrimaryColor: defaultTheme.light.color,
35
33
  lightSecondaryColor: defaultTheme.light.secondaryColor,
36
34
  lightSurfaceColor: defaultTheme.light.surfaceColor,
@@ -74,7 +72,6 @@ test("workspaceSettingsService.getWorkspaceSettings returns the stored invitesEn
74
72
  );
75
73
 
76
74
  assert.deepEqual(response.settings, {
77
- avatarUrl: "",
78
75
  lightPrimaryColor: "#0F6B54",
79
76
  lightSecondaryColor: "#48A9A6",
80
77
  lightSurfaceColor: "#FFFFFF",
@@ -104,7 +101,6 @@ test("workspaceSettingsService.updateWorkspaceSettings writes editable fields th
104
101
  invitesEnabled: false
105
102
  });
106
103
  assert.deepEqual(response.settings, {
107
- avatarUrl: "",
108
104
  lightPrimaryColor: "#0F6B54",
109
105
  lightSecondaryColor: "#48A9A6",
110
106
  lightSurfaceColor: "#FFFFFF",
@@ -130,7 +126,6 @@ test("workspaceSettingsService disables invite settings in output when app polic
130
126
  );
131
127
 
132
128
  assert.deepEqual(response.settings, {
133
- avatarUrl: "",
134
129
  lightPrimaryColor: "#0F6B54",
135
130
  lightSecondaryColor: "#48A9A6",
136
131
  lightSurfaceColor: "#FFFFFF",