@jskit-ai/workspaces-core 0.1.31 → 0.1.33

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.
Files changed (62) hide show
  1. package/package.descriptor.mjs +11 -22
  2. package/package.json +11 -9
  3. package/src/server/WorkspacesCoreServiceProvider.js +22 -2
  4. package/src/server/common/repositories/workspaceInvitesRepository.js +233 -78
  5. package/src/server/common/repositories/workspaceMembershipsRepository.js +177 -86
  6. package/src/server/common/repositories/workspacesRepository.js +179 -86
  7. package/src/server/common/services/workspaceContextService.js +28 -26
  8. package/src/server/common/validators/routeParamsValidator.js +36 -53
  9. package/src/server/registerWorkspaceCore.js +9 -10
  10. package/src/server/registerWorkspaceRepositories.js +7 -3
  11. package/src/server/support/workspaceServerScopeSupport.js +1 -1
  12. package/src/server/workspaceBootstrapContributor.js +5 -14
  13. package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +54 -27
  14. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +30 -24
  15. package/src/server/workspaceMembers/bootWorkspaceMembers.js +70 -32
  16. package/src/server/workspaceMembers/workspaceMembersActions.js +61 -27
  17. package/src/server/workspaceMembers/workspaceMembersService.js +43 -7
  18. package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +28 -13
  19. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +13 -15
  20. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +33 -10
  21. package/src/server/workspaceSettings/bootWorkspaceSettings.js +32 -13
  22. package/src/server/workspaceSettings/registerWorkspaceSettings.js +5 -1
  23. package/src/server/workspaceSettings/workspaceSettingsActions.js +18 -12
  24. package/src/server/workspaceSettings/workspaceSettingsRepository.js +104 -91
  25. package/src/server/workspaceSettings/workspaceSettingsService.js +5 -6
  26. package/src/shared/jsonApiTransports.js +79 -0
  27. package/src/shared/resources/workspaceInvitesResource.js +158 -0
  28. package/src/shared/resources/workspaceMembersResource.js +176 -311
  29. package/src/shared/resources/workspaceMembershipsResource.js +96 -0
  30. package/src/shared/resources/workspacePendingInvitationsResource.js +25 -72
  31. package/src/shared/resources/workspaceResource.js +113 -144
  32. package/src/shared/resources/workspaceRoleCatalogSchema.js +31 -0
  33. package/src/shared/resources/workspaceSettingsResource.js +276 -148
  34. package/test/repositoryContracts.test.js +16 -4
  35. package/test/resourcesCanonical.test.js +39 -16
  36. package/test/routeParamsValidator.test.js +37 -19
  37. package/test/usersRouteResources.test.js +27 -17
  38. package/test/workspaceActionContextContributor.test.js +1 -1
  39. package/test/workspaceInternalCrudResources.test.js +98 -0
  40. package/test/workspaceInvitesRepository.test.js +196 -148
  41. package/test/workspaceMembersResource.test.js +35 -0
  42. package/test/workspaceMembershipsRepository.test.js +155 -115
  43. package/test/workspacePendingInvitationsResource.test.js +18 -23
  44. package/test/workspacePendingInvitationsService.test.js +2 -1
  45. package/test/workspaceServerScopeSupport.test.js +77 -3
  46. package/test/workspaceService.test.js +26 -5
  47. package/test/workspaceSettingsActions.test.js +5 -7
  48. package/test/workspaceSettingsInternalResource.test.js +8 -0
  49. package/test/workspaceSettingsRepository.test.js +158 -123
  50. package/test/workspaceSettingsResource.test.js +51 -62
  51. package/test/workspaceSettingsService.test.js +0 -1
  52. package/test/workspacesRepository.test.js +318 -174
  53. package/test/workspacesRouteRequestInputValidator.test.js +25 -11
  54. package/src/server/common/resources/workspaceInvitesResource.js +0 -207
  55. package/src/server/common/resources/workspaceMembershipsResource.js +0 -154
  56. package/src/server/common/resources/workspacesResource.js +0 -170
  57. package/src/server/common/validators/authenticatedUserValidator.js +0 -43
  58. package/src/shared/resources/resolveGlobalArrayRegistry.js +0 -6
  59. package/src/shared/resources/workspaceSettingsFields.js +0 -65
  60. package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +0 -197
  61. package/test/settingsFieldRegistriesSingleton.test.js +0 -14
  62. package/test-support/registerDefaultSettingsFields.js +0 -1
@@ -3,108 +3,146 @@ import test from "node:test";
3
3
  import { toIsoString } from "@jskit-ai/database-runtime/shared";
4
4
  import { createRepository } from "../src/server/common/repositories/workspaceMembershipsRepository.js";
5
5
 
6
- function createKnexStub({
7
- rowById = new Map(),
6
+ function createKnexStub() {
7
+ const knex = Object.assign(() => {
8
+ throw new Error("query execution not expected");
9
+ }, {
10
+ async transaction(work) {
11
+ return work({ trxId: "trx-1" });
12
+ }
13
+ });
14
+
15
+ return knex;
16
+ }
17
+
18
+ function toWorkspaceMembershipResource(row = {}) {
19
+ return {
20
+ type: "workspaceMemberships",
21
+ id: String(row.id || ""),
22
+ attributes: {
23
+ roleSid: row.roleSid,
24
+ status: row.status,
25
+ createdAt: row.createdAt,
26
+ updatedAt: row.updatedAt
27
+ },
28
+ relationships: {
29
+ workspace: {
30
+ data: row?.workspace?.id == null ? null : { type: "workspaces", id: String(row.workspace.id) }
31
+ },
32
+ user: {
33
+ data: row?.user?.id == null
34
+ ? null
35
+ : {
36
+ type: "userProfiles",
37
+ id: String(row.user.id)
38
+ }
39
+ }
40
+ }
41
+ };
42
+ }
43
+
44
+ function createWorkspaceMembershipsApiStub({
8
45
  rowByComposite = new Map(),
9
- memberSummaryRows = []
46
+ memberSummaryRows = [],
47
+ rowById = new Map()
10
48
  } = {}) {
11
49
  const state = {
12
- insertPayload: null,
13
- updatePayload: null
50
+ postPayload: null,
51
+ patchPayload: null
14
52
  };
15
53
 
16
- function buildMembershipsQuery(tableName) {
17
- if (tableName === "workspace_memberships as wm") {
18
- return {
19
- join() {
20
- return this;
21
- },
22
- where() {
23
- return this;
54
+ const api = {
55
+ resources: {
56
+ workspaceMemberships: {
57
+ async query({ queryParams }) {
58
+ const filters = queryParams?.filters || {};
59
+ const includeUser = Array.isArray(queryParams?.include) && queryParams.include.includes("user");
60
+
61
+ if (Object.hasOwn(filters, "workspace") && Object.hasOwn(filters, "user")) {
62
+ const row = rowByComposite.get(`${filters.workspace}:${filters.user}`) || null;
63
+ return { data: row ? [toWorkspaceMembershipResource(row)] : [] };
64
+ }
65
+
66
+ if (Object.hasOwn(filters, "workspace") && Object.hasOwn(filters, "status") && includeUser) {
67
+ return {
68
+ data: memberSummaryRows.map((row) => toWorkspaceMembershipResource(row)),
69
+ included: memberSummaryRows
70
+ .filter((row) => row?.user?.id != null)
71
+ .map((row) => ({
72
+ type: "userProfiles",
73
+ id: String(row.user.id),
74
+ attributes: {
75
+ displayName: row.user.displayName,
76
+ email: row.user.email
77
+ }
78
+ }))
79
+ };
80
+ }
81
+
82
+ if (Object.hasOwn(filters, "user") && Object.hasOwn(filters, "status")) {
83
+ const rows = [...rowByComposite.values()].filter((row) => (
84
+ String(row?.user?.id || "") === String(filters.user) &&
85
+ String(row?.status || "") === String(filters.status)
86
+ ));
87
+ return { data: rows.map((row) => toWorkspaceMembershipResource(row)) };
88
+ }
89
+
90
+ return { data: [] };
24
91
  },
25
- orderBy() {
26
- return this;
92
+ async post(payload) {
93
+ assert.equal(payload?.simplified, false);
94
+ const inputRecord = payload?.inputRecord?.data || {};
95
+ state.postPayload = inputRecord;
96
+ const row = rowById.get("1") || {
97
+ id: "1",
98
+ workspace: { id: String(inputRecord.relationships?.workspace?.data?.id || "") },
99
+ user: { id: String(inputRecord.relationships?.user?.data?.id || "") },
100
+ roleSid: String(inputRecord.attributes?.roleSid || ""),
101
+ status: String(inputRecord.attributes?.status || ""),
102
+ createdAt: "2026-03-09 00:26:35.710",
103
+ updatedAt: "2026-03-09 00:26:35.710"
104
+ };
105
+ rowByComposite.set(`${row.workspace.id}:${row.user.id}`, row);
106
+ rowById.set(String(row.id), row);
107
+ return { data: toWorkspaceMembershipResource(row) };
27
108
  },
28
- select() {
29
- return Promise.resolve([...memberSummaryRows]);
30
- }
31
- };
32
- }
33
-
34
- const query = {
35
- criteriaList: [],
36
- select() {
37
- return this;
38
- },
39
- insert(payload) {
40
- state.insertPayload = payload;
41
- const insertedRow = rowById.get("1");
42
- if (insertedRow) {
43
- rowByComposite.set(`${payload.workspace_id}:${payload.user_id}`, insertedRow);
44
- }
45
- return Promise.resolve([1]);
46
- },
47
- where(criteria) {
48
- this.criteriaList.push(criteria);
49
- return this;
50
- },
51
- update(payload) {
52
- state.updatePayload = payload;
53
- const criteria = Object.assign({}, ...this.criteriaList);
54
- const existingRow = rowById.get(String(criteria.id));
55
- if (existingRow) {
56
- const updatedRow = {
57
- ...existingRow,
58
- ...payload
109
+ async patch(payload) {
110
+ assert.equal(payload?.simplified, false);
111
+ const inputRecord = payload?.inputRecord?.data || {};
112
+ state.patchPayload = inputRecord;
113
+ const existing = rowById.get(String(inputRecord.id));
114
+ const updated = {
115
+ ...(existing || {}),
116
+ ...(inputRecord.attributes || {}),
117
+ id: String(inputRecord.id),
118
+ workspace: existing?.workspace || { id: "" },
119
+ user: existing?.user || { id: "" }
59
120
  };
60
- rowById.set(String(criteria.id), updatedRow);
61
- rowByComposite.set(`${updatedRow.workspace_id}:${updatedRow.user_id}`, updatedRow);
121
+ rowById.set(String(updated.id), updated);
122
+ rowByComposite.set(`${updated.workspace.id}:${updated.user.id}`, updated);
123
+ return { data: toWorkspaceMembershipResource(updated) };
62
124
  }
63
- return Promise.resolve(1);
64
- },
65
- first() {
66
- const criteria = Object.assign({}, ...this.criteriaList);
67
- if (Object.hasOwn(criteria, "id")) {
68
- return Promise.resolve(rowById.get(String(criteria.id)) || null);
69
- }
70
- if (Object.hasOwn(criteria, "workspace_id") && Object.hasOwn(criteria, "user_id")) {
71
- return Promise.resolve(
72
- rowByComposite.get(`${criteria.workspace_id}:${criteria.user_id}`) || null
73
- );
74
- }
75
- return Promise.resolve(null);
76
125
  }
77
- };
78
-
79
- return query;
80
- }
81
-
82
- function knex(tableName) {
83
- if (tableName === "workspace_memberships" || tableName === "workspace_memberships as wm") {
84
- return buildMembershipsQuery(tableName);
85
126
  }
86
- throw new Error(`Unexpected table ${tableName}`);
87
- }
88
-
89
- knex.transaction = async (work) => work(knex);
127
+ };
90
128
 
91
- return { knex, state };
129
+ return { api, state };
92
130
  }
93
131
 
94
132
  test("workspaceMembershipsRepository.findByWorkspaceIdAndUserId normalizes canonical membership rows via the internal resource", async () => {
95
133
  const membershipRow = {
96
- id: 11,
97
- workspace_id: 7,
98
- user_id: 9,
99
- role_sid: "owner",
134
+ id: "11",
135
+ workspace: { id: "7" },
136
+ user: { id: "9" },
137
+ roleSid: "owner",
100
138
  status: "active",
101
- created_at: "2026-03-09 00:26:35.710",
102
- updated_at: "2026-03-10 00:26:35.710"
139
+ createdAt: "2026-03-09 00:26:35.710",
140
+ updatedAt: "2026-03-10 00:26:35.710"
103
141
  };
104
- const { knex } = createKnexStub({
142
+ const { api } = createWorkspaceMembershipsApiStub({
105
143
  rowByComposite: new Map([["7:9", membershipRow]])
106
144
  });
107
- const repository = createRepository(knex);
145
+ const repository = createRepository({ api, knex: createKnexStub() });
108
146
 
109
147
  const membership = await repository.findByWorkspaceIdAndUserId("7", "9");
110
148
 
@@ -121,31 +159,31 @@ test("workspaceMembershipsRepository.findByWorkspaceIdAndUserId normalizes canon
121
159
 
122
160
  test("workspaceMembershipsRepository.ensureOwnerMembership upgrades an existing membership through the runtime update path", async () => {
123
161
  const existingRow = {
124
- id: 11,
125
- workspace_id: 7,
126
- user_id: 9,
127
- role_sid: "member",
162
+ id: "11",
163
+ workspace: { id: "7" },
164
+ user: { id: "9" },
165
+ roleSid: "member",
128
166
  status: "pending",
129
- created_at: "2026-03-09 00:26:35.710",
130
- updated_at: "2026-03-09 00:26:35.710"
167
+ createdAt: "2026-03-09 00:26:35.710",
168
+ updatedAt: "2026-03-09 00:26:35.710"
131
169
  };
132
170
  const refreshedRow = {
133
171
  ...existingRow,
134
- role_sid: "owner",
172
+ roleSid: "owner",
135
173
  status: "active",
136
- updated_at: "2026-03-10 00:26:35.710"
174
+ updatedAt: "2026-03-10 00:26:35.710"
137
175
  };
138
- const { knex, state } = createKnexStub({
176
+ const { api, state } = createWorkspaceMembershipsApiStub({
139
177
  rowById: new Map([["11", refreshedRow]]),
140
178
  rowByComposite: new Map([["7:9", existingRow]])
141
179
  });
142
- const repository = createRepository(knex);
180
+ const repository = createRepository({ api, knex: createKnexStub() });
143
181
 
144
182
  const membership = await repository.ensureOwnerMembership("7", "9");
145
183
 
146
- assert.equal(state.updatePayload.role_sid, "owner");
147
- assert.equal(state.updatePayload.status, "active");
148
- assert.equal(typeof state.updatePayload.updated_at, "string");
184
+ assert.equal(state.patchPayload.attributes?.roleSid, "owner");
185
+ assert.equal(state.patchPayload.attributes?.status, "active");
186
+ assert.equal(typeof state.patchPayload.attributes?.updatedAt, "object");
149
187
  assert.deepEqual(membership, {
150
188
  id: "11",
151
189
  workspaceId: "7",
@@ -153,50 +191,52 @@ test("workspaceMembershipsRepository.ensureOwnerMembership upgrades an existing
153
191
  roleSid: "owner",
154
192
  status: "active",
155
193
  createdAt: toIsoString("2026-03-09 00:26:35.710"),
156
- updatedAt: toIsoString(state.updatePayload.updated_at)
194
+ updatedAt: toIsoString(state.patchPayload.attributes.updatedAt)
157
195
  });
158
196
  });
159
197
 
160
198
  test("workspaceMembershipsRepository.upsertMembership creates normalized memberships through the runtime create path", async () => {
161
199
  const createdRow = {
162
- id: 1,
163
- workspace_id: 7,
164
- user_id: 9,
165
- role_sid: "admin",
200
+ id: "1",
201
+ workspace: { id: "7" },
202
+ user: { id: "9" },
203
+ roleSid: "admin",
166
204
  status: "active",
167
- created_at: "2026-03-09 00:26:35.710",
168
- updated_at: "2026-03-09 00:26:35.710"
205
+ createdAt: "2026-03-09 00:26:35.710",
206
+ updatedAt: "2026-03-09 00:26:35.710"
169
207
  };
170
- const { knex, state } = createKnexStub({
208
+ const { api, state } = createWorkspaceMembershipsApiStub({
171
209
  rowById: new Map([["1", createdRow]]),
172
210
  rowByComposite: new Map()
173
211
  });
174
- const repository = createRepository(knex);
212
+ const repository = createRepository({ api, knex: createKnexStub() });
175
213
 
176
214
  await repository.upsertMembership("7", "9", {
177
215
  roleSid: "ADMIN",
178
216
  status: "ACTIVE"
179
217
  });
180
218
 
181
- assert.equal(state.insertPayload.workspace_id, "7");
182
- assert.equal(state.insertPayload.user_id, "9");
183
- assert.equal(state.insertPayload.role_sid, "admin");
184
- assert.equal(state.insertPayload.status, "active");
219
+ assert.equal(state.postPayload.relationships?.workspace?.data?.id, "7");
220
+ assert.equal(state.postPayload.relationships?.user?.data?.id, "9");
221
+ assert.equal(state.postPayload.attributes?.roleSid, "admin");
222
+ assert.equal(state.postPayload.attributes?.status, "active");
185
223
  });
186
224
 
187
225
  test("workspaceMembershipsRepository.listActiveByWorkspaceId keeps summary rows separate from the canonical membership resource", async () => {
188
- const { knex } = createKnexStub({
226
+ const { api } = createWorkspaceMembershipsApiStub({
189
227
  memberSummaryRows: [
190
228
  {
191
- user_id: 9,
192
- role_sid: "owner",
193
- status: "active",
194
- display_name: "Chiara",
195
- email: "CHIARA@example.com"
229
+ user: {
230
+ id: "9",
231
+ displayName: "Chiara",
232
+ email: "CHIARA@example.com"
233
+ },
234
+ roleSid: "owner",
235
+ status: "active"
196
236
  }
197
237
  ]
198
238
  });
199
- const repository = createRepository(knex);
239
+ const repository = createRepository({ api, knex: createKnexStub() });
200
240
 
201
241
  const members = await repository.listActiveByWorkspaceId("7");
202
242
 
@@ -1,38 +1,33 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
3
+ import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
4
4
  import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
5
5
 
6
- test("workspacePendingInvitationsResource output normalizer shapes raw invite rows", () => {
7
- const tokenHash = "a".repeat(64);
8
-
9
- const result = workspacePendingInvitationsResource.operations.list.outputValidator.normalize({
6
+ test("workspacePendingInvitationsResource output schema accepts already-shaped invite payloads", () => {
7
+ const outputSchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.list.output, {
8
+ context: "workspacePendingInvitations.list.output",
9
+ defaultMode: "replace"
10
+ });
11
+ const result = {
10
12
  pendingInvites: [
11
13
  {
12
14
  id: "10",
13
15
  workspaceId: "3",
14
16
  workspaceSlug: "tonymobily3",
15
- workspaceName: "",
17
+ workspaceName: "TonyMobily3",
16
18
  workspaceAvatarUrl: "",
17
- roleSid: "Member",
18
- status: "Pending",
19
+ roleSid: "member",
20
+ status: "pending",
19
21
  expiresAt: "2030-01-01T00:00:00.000Z",
20
- tokenHash
22
+ token: "opaque-token"
21
23
  }
22
24
  ]
23
- }).pendingInvites;
25
+ };
24
26
 
25
- assert.deepEqual(result, [
26
- {
27
- id: "10",
28
- workspaceId: "3",
29
- workspaceSlug: "tonymobily3",
30
- workspaceName: "tonymobily3",
31
- workspaceAvatarUrl: "",
32
- roleSid: "member",
33
- status: "pending",
34
- expiresAt: "2030-01-01T00:00:00.000Z",
35
- token: encodeInviteTokenHash(tokenHash)
36
- }
37
- ]);
27
+ assert.equal(outputSchema.type, "object");
28
+ assert.equal(outputSchema.additionalProperties, false);
29
+ assert.equal(outputSchema.properties.pendingInvites.type, "array");
30
+ assert.equal(outputSchema.properties.pendingInvites.items["x-json-rest-schema"]?.castType, "object");
31
+ assert.equal(Array.isArray(outputSchema.properties.pendingInvites.items.allOf), true);
32
+ assert.equal(result.pendingInvites[0].roleSid, "member");
38
33
  });
@@ -78,8 +78,9 @@ test("listPendingInvitesForUser returns raw pending invite rows for the action l
78
78
  });
79
79
 
80
80
  assert.equal(pendingInvites.length, 1);
81
- assert.equal(pendingInvites[0].tokenHash, tokenHash);
81
+ assert.equal(pendingInvites[0].token, encodeInviteTokenHash(tokenHash));
82
82
  assert.equal(pendingInvites[0].workspaceName, "TonyMobily3");
83
+ assert.equal(pendingInvites[0].status, "pending");
83
84
  });
84
85
 
85
86
  test("acceptInviteByToken accepts opaque invite token and resolves invite by decoded hash", async () => {
@@ -1,13 +1,26 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { AUTH_POLICY_CONTEXT_RESOLVER_TAG } from "@jskit-ai/auth-core/server/authPolicyContextResolverRegistry";
4
+ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
5
+ import { PROFILE_SYNC_LIFECYCLE_CONTRIBUTOR_TAG } from "@jskit-ai/users-core/server/profileSyncLifecycleContributorRegistry";
3
6
  import { createWorkspaceServerScopeSupport } from "../src/server/support/workspaceServerScopeSupport.js";
4
7
  import { registerWorkspaceCore } from "../src/server/registerWorkspaceCore.js";
5
8
 
6
- test("workspace server scope support exposes the canonical workspace helper surface", () => {
9
+ test("workspace server scope support exposes the canonical workspace helper surface", async () => {
7
10
  const support = createWorkspaceServerScopeSupport();
8
11
 
9
12
  assert.equal(support.available, true);
10
- assert.equal(typeof support.paramsValidator?.normalize, "function");
13
+ assert.equal(typeof support.params?.schema, "object");
14
+ assert.equal(support.params?.mode, "patch");
15
+ assert.deepEqual(
16
+ await validateSchemaPayload(support.params, { workspaceSlug: " ACME " }, {
17
+ phase: "input",
18
+ context: "workspaceServerScopeSupport.params"
19
+ }),
20
+ {
21
+ workspaceSlug: "acme"
22
+ }
23
+ );
11
24
  assert.deepEqual(support.buildInputFromRouteParams({ workspaceSlug: " ACME " }), {
12
25
  workspaceSlug: "acme"
13
26
  });
@@ -34,12 +47,17 @@ test("workspace server scope support exposes the canonical workspace helper surf
34
47
 
35
48
  test("registerWorkspaceCore registers the workspace server scope support token", () => {
36
49
  const singletons = new Map();
50
+ const tags = new Map();
37
51
  const app = {
38
52
  singleton(token, factory) {
39
53
  singletons.set(token, factory);
40
54
  return this;
41
55
  },
42
- tag() {
56
+ tag(token, tagName) {
57
+ const key = String(tagName || "");
58
+ const list = tags.get(key) || [];
59
+ list.push(String(token || ""));
60
+ tags.set(key, list);
43
61
  return this;
44
62
  },
45
63
  has() {
@@ -50,8 +68,64 @@ test("registerWorkspaceCore registers the workspace server scope support token",
50
68
  registerWorkspaceCore(app);
51
69
 
52
70
  assert.equal(singletons.has("workspaces.server.scope-support"), true);
71
+ assert.deepEqual(tags.get(AUTH_POLICY_CONTEXT_RESOLVER_TAG), ["workspaces.core.authPolicyContextResolver"]);
53
72
  const support = singletons.get("workspaces.server.scope-support")();
54
73
  assert.equal(support.available, true);
55
74
  assert.equal(typeof support.buildInputFromRouteParams, "function");
56
75
  assert.equal(typeof support.resolveWorkspace, "function");
57
76
  });
77
+
78
+ test("registerWorkspaceCore ensures personal workspace provisioning on every authenticated profile sync", async () => {
79
+ const singletons = new Map();
80
+ const tags = new Map();
81
+ const app = {
82
+ singleton(token, factory) {
83
+ singletons.set(token, factory);
84
+ return this;
85
+ },
86
+ tag(token, tagName) {
87
+ const key = String(tagName || "");
88
+ const list = tags.get(key) || [];
89
+ list.push(String(token || ""));
90
+ tags.set(key, list);
91
+ return this;
92
+ },
93
+ has() {
94
+ return false;
95
+ }
96
+ };
97
+
98
+ registerWorkspaceCore(app);
99
+
100
+ assert.deepEqual(tags.get(PROFILE_SYNC_LIFECYCLE_CONTRIBUTOR_TAG), ["workspaces.core.profileSyncLifecycleContributor"]);
101
+
102
+ const lifecycleFactory = singletons.get("workspaces.core.profileSyncLifecycleContributor");
103
+ assert.equal(typeof lifecycleFactory, "function");
104
+
105
+ const calls = [];
106
+ const contributor = lifecycleFactory({
107
+ make(token) {
108
+ if (token === "workspaces.service") {
109
+ return {
110
+ async ensureProvisionedWorkspaceForAuthenticatedUser(profile, options = {}) {
111
+ calls.push({ profile, options });
112
+ }
113
+ };
114
+ }
115
+ throw new Error(`Unexpected token: ${token}`);
116
+ }
117
+ });
118
+
119
+ await contributor.afterIdentityProfileSynced({
120
+ profile: { id: "7", username: "tonymobily" },
121
+ created: false,
122
+ options: { trx: { id: "tx-1" } }
123
+ });
124
+
125
+ assert.deepEqual(calls, [
126
+ {
127
+ profile: { id: "7", username: "tonymobily" },
128
+ options: { trx: { id: "tx-1" } }
129
+ }
130
+ ]);
131
+ });
@@ -41,7 +41,8 @@ function createWorkspaceServiceFixture({
41
41
  listForUserId: 0,
42
42
  insert: 0,
43
43
  updateById: 0,
44
- ensureOwnerMembership: 0
44
+ ensureOwnerMembership: 0,
45
+ ensureWorkspaceSettings: 0
45
46
  };
46
47
  let nextWorkspaceId = 10;
47
48
  const personalWorkspaceState =
@@ -167,6 +168,7 @@ function createWorkspaceServiceFixture({
167
168
  },
168
169
  workspaceSettingsRepository: {
169
170
  async ensureForWorkspaceId() {
171
+ calls.ensureWorkspaceSettings += 1;
170
172
  return {
171
173
  invitesEnabled: true
172
174
  };
@@ -258,13 +260,13 @@ test("workspaceService.listWorkspacesForUser returns all active memberships in p
258
260
  assert.equal(calls.listForUserId, 1);
259
261
  });
260
262
 
261
- test("workspaceService.provisionWorkspaceForNewUser provisions personal workspace only in personal tenancy", async () => {
263
+ test("workspaceService.ensureProvisionedWorkspaceForAuthenticatedUser provisions personal workspace only in personal tenancy", async () => {
262
264
  const { service, calls, insertedPayloads } = createWorkspaceServiceFixture({
263
265
  tenancyMode: "personal",
264
266
  personalWorkspace: null
265
267
  });
266
268
 
267
- const workspace = await service.provisionWorkspaceForNewUser({
269
+ const workspace = await service.ensureProvisionedWorkspaceForAuthenticatedUser({
268
270
  id: "7",
269
271
  email: "chiaramobily@gmail.com",
270
272
  displayName: "Chiara"
@@ -274,15 +276,34 @@ test("workspaceService.provisionWorkspaceForNewUser provisions personal workspac
274
276
  assert.equal(calls.findPersonalByOwnerUserId, 1);
275
277
  assert.equal(calls.insert, 1);
276
278
  assert.equal(calls.ensureOwnerMembership, 1);
279
+ assert.equal(calls.ensureWorkspaceSettings, 1);
277
280
  assert.equal(insertedPayloads[0].isPersonal, true);
278
281
  });
279
282
 
280
- test("workspaceService.provisionWorkspaceForNewUser is a no-op outside personal tenancy", async () => {
283
+ test("workspaceService.ensureProvisionedWorkspaceForAuthenticatedUser reuses the existing personal workspace in personal tenancy", async () => {
284
+ const { service, calls } = createWorkspaceServiceFixture({
285
+ tenancyMode: "personal"
286
+ });
287
+
288
+ const workspace = await service.ensureProvisionedWorkspaceForAuthenticatedUser({
289
+ id: "7",
290
+ email: "chiaramobily@gmail.com",
291
+ displayName: "Chiara"
292
+ });
293
+
294
+ assert.equal(workspace.id, "1");
295
+ assert.equal(calls.findPersonalByOwnerUserId, 1);
296
+ assert.equal(calls.insert, 0);
297
+ assert.equal(calls.ensureOwnerMembership, 1);
298
+ assert.equal(calls.ensureWorkspaceSettings, 1);
299
+ });
300
+
301
+ test("workspaceService.ensureProvisionedWorkspaceForAuthenticatedUser is a no-op outside personal tenancy", async () => {
281
302
  const { service, calls } = createWorkspaceServiceFixture({
282
303
  tenancyMode: "workspaces"
283
304
  });
284
305
 
285
- const result = await service.provisionWorkspaceForNewUser({
306
+ const result = await service.ensureProvisionedWorkspaceForAuthenticatedUser({
286
307
  id: "7",
287
308
  email: "chiaramobily@gmail.com",
288
309
  displayName: "Chiara"
@@ -1,11 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import "../test-support/registerDefaultSettingsFields.js";
4
3
  import { workspaceDirectoryActions } from "../src/server/workspaceDirectory/workspaceDirectoryActions.js";
5
4
  import { workspacePendingInvitationsActions } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js";
6
5
  import { workspaceMembersActions } from "../src/server/workspaceMembers/workspaceMembersActions.js";
7
6
  import { workspaceSettingsActions } from "../src/server/workspaceSettings/workspaceSettingsActions.js";
8
- import { workspaceResource } from "../src/shared/resources/workspaceResource.js";
9
7
 
10
8
  test("workspace settings actions live in their own action array", () => {
11
9
  assert.deepEqual(
@@ -35,18 +33,18 @@ test("workspace actions array no longer owns workspace settings actions", () =>
35
33
  );
36
34
  });
37
35
 
38
- test("workspace directory actions use the canonical workspace list resource output", () => {
36
+ test("workspace directory actions stay thin and defer output validation to routes", () => {
39
37
  const listAction = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.list");
40
38
  assert.ok(listAction);
41
- assert.equal(listAction.outputValidator, workspaceResource.operations.list.outputValidator);
39
+ assert.equal(listAction.output, null);
42
40
  });
43
41
 
44
- test("workspace directory read/update actions use canonical workspace resource validators", () => {
42
+ test("workspace directory read/update actions stay thin and defer output validation to routes", () => {
45
43
  const readAction = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.read");
46
44
  const updateAction = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.update");
47
45
 
48
46
  assert.ok(readAction);
49
47
  assert.ok(updateAction);
50
- assert.equal(readAction.outputValidator, workspaceResource.operations.view.outputValidator);
51
- assert.equal(updateAction.outputValidator, workspaceResource.operations.patch.outputValidator);
48
+ assert.equal(readAction.output, null);
49
+ assert.equal(updateAction.output, null);
52
50
  });
@@ -0,0 +1,8 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { workspaceSettingsResource } from "../src/shared/resources/workspaceSettingsResource.js";
4
+
5
+ test("shared workspace settings resource declares workspace_id as the resource id column", () => {
6
+ assert.equal(workspaceSettingsResource.idProperty, "workspace_id");
7
+ assert.equal(workspaceSettingsResource.schema.id?.storage?.column, "workspace_id");
8
+ });