@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
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { existsSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { deriveResourceRequiredMetadata } from "@jskit-ai/kernel/_testable";
7
- import "../test-support/registerDefaultSettingsFields.js";
7
+ import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
8
8
  import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
9
9
  import { workspaceResource } from "../src/shared/resources/workspaceResource.js";
10
10
  import { workspaceSettingsResource } from "../src/shared/resources/workspaceSettingsResource.js";
@@ -28,15 +28,18 @@ function assertResourceShape(resource, label) {
28
28
  `${label}.operations.${operationName} must resolve messages from operation.messages or resource.messages.`
29
29
  );
30
30
  assert.equal(
31
- typeof operation.outputValidator?.schema,
31
+ typeof resolveStructuredSchemaTransportSchema(operation.output, {
32
+ context: `${label}.operations.${operationName}.output`,
33
+ defaultMode: "replace"
34
+ }),
32
35
  "object",
33
36
  `${label}.operations.${operationName} payload schema is required.`
34
37
  );
35
38
  }
36
39
 
37
- assert.equal(typeof resource.operations.create.bodyValidator?.schema, "object", `${label}.operations.create.bodyValidator.schema is required.`);
38
- assert.equal(typeof resource.operations.replace.bodyValidator?.schema, "object", `${label}.operations.replace.bodyValidator.schema is required.`);
39
- assert.equal(typeof resource.operations.patch.bodyValidator?.schema, "object", `${label}.operations.patch.bodyValidator.schema is required.`);
40
+ assert.equal(typeof resource.operations.create.body?.schema, "object", `${label}.operations.create.body.schema is required.`);
41
+ assert.equal(typeof resource.operations.replace.body?.schema, "object", `${label}.operations.replace.body.schema is required.`);
42
+ assert.equal(typeof resource.operations.patch.body?.schema, "object", `${label}.operations.patch.body.schema is required.`);
40
43
 
41
44
  const requiredMetadata = deriveResourceRequiredMetadata(resource);
42
45
  assert.ok(Array.isArray(requiredMetadata.create), `${label}.derivedRequired.create must be an array.`);
@@ -69,15 +72,22 @@ test("workspace settings and invite operations expose canonical validators", ()
69
72
 
70
73
  for (const { label, operation } of operationSpecs) {
71
74
  assert.equal(typeof operation?.method, "string", `${label}.method must exist.`);
72
- assert.equal(typeof operation?.outputValidator?.schema, "object", `${label}.outputValidator.schema must exist.`);
73
- if (operation?.bodyValidator) {
74
- assert.equal(typeof operation.bodyValidator.schema, "object", `${label}.bodyValidator.schema must exist.`);
75
+ assert.equal(
76
+ typeof resolveStructuredSchemaTransportSchema(operation?.output, {
77
+ context: `${label}.output`,
78
+ defaultMode: "replace"
79
+ }),
80
+ "object",
81
+ `${label}.output transport schema must exist.`
82
+ );
83
+ if (operation?.body) {
84
+ assert.equal(typeof operation.body.schema, "object", `${label}.body.schema must exist.`);
75
85
  }
76
- if (operation?.paramsValidator) {
77
- assert.equal(typeof operation.paramsValidator.schema, "object", `${label}.paramsValidator.schema must exist.`);
86
+ if (operation?.params) {
87
+ assert.equal(typeof operation.params.schema, "object", `${label}.params.schema must exist.`);
78
88
  }
79
- if (operation?.queryValidator) {
80
- assert.equal(typeof operation.queryValidator.schema, "object", `${label}.queryValidator.schema must exist.`);
89
+ if (operation?.query) {
90
+ assert.equal(typeof operation.query.schema, "object", `${label}.query.schema must exist.`);
81
91
  }
82
92
  }
83
93
  });
@@ -85,13 +95,13 @@ test("workspace settings and invite operations expose canonical validators", ()
85
95
  test("workspaces-core no longer uses a workspace schema helper that exposes raw schema leaves", () => {
86
96
  const testFilePath = fileURLToPath(import.meta.url);
87
97
  const packageRoot = path.resolve(path.dirname(testFilePath), "..");
88
- const legacyWorkspaceRoutesFile = path.join(packageRoot, "src", "server", "common", "routes", "workspaceRoutes.js");
89
- assert.equal(existsSync(legacyWorkspaceRoutesFile), false, "workspaceRoutes.js must not exist.");
98
+ const workspaceRoutesFilePath = path.join(packageRoot, "src", "server", "common", "routes", "workspaceRoutes.js");
99
+ assert.equal(existsSync(workspaceRoutesFilePath), false, "workspaceRoutes.js must not exist.");
90
100
  });
91
101
 
92
- test("workspaces-core route validators no longer live under a legacy shared/schema directory", () => {
102
+ test("workspaces-core route validators do not live under src/shared/schema", () => {
93
103
  const testFilePath = fileURLToPath(import.meta.url);
94
104
  const packageRoot = path.resolve(path.dirname(testFilePath), "..");
95
- const legacySchemaDir = path.join(packageRoot, "src", "shared", "schema");
96
- assert.equal(existsSync(legacySchemaDir), false, "src/shared/schema must not exist.");
105
+ const sharedSchemaDirPath = path.join(packageRoot, "src", "shared", "schema");
106
+ assert.equal(existsSync(sharedSchemaDirPath), false, "src/shared/schema must not exist.");
97
107
  });
@@ -181,7 +181,7 @@ test("workspace action context contributor always resolves and stores resolved c
181
181
  });
182
182
  });
183
183
 
184
- test("workspace action context contributor resolves context for workspace-visible routes outside legacy action list", async () => {
184
+ test("workspace action context contributor resolves context for workspace-visible routes without an explicit action list", async () => {
185
185
  const calls = [];
186
186
  const contributor = createWorkspaceActionContextContributor({
187
187
  workspaceService: {
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
4
+ import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
5
+ import { workspaceInvitesResource } from "../src/shared/resources/workspaceInvitesResource.js";
6
+ import { workspaceMembershipsResource } from "../src/shared/resources/workspaceMembershipsResource.js";
7
+
8
+ function parseBody(operation, payload = {}) {
9
+ return validateOperationSection({
10
+ operation,
11
+ section: "body",
12
+ value: payload
13
+ });
14
+ }
15
+
16
+ test("workspace internal CRUD resources expose canonical derived operation sets", () => {
17
+ const resources = {
18
+ workspaceMemberships: workspaceMembershipsResource,
19
+ workspaceInvites: workspaceInvitesResource
20
+ };
21
+
22
+ for (const [label, resource] of Object.entries(resources)) {
23
+ assert.deepEqual(Object.keys(resource.operations), ["list", "view", "create", "patch"]);
24
+ assert.equal(typeof resource.operations.create.body?.schema, "object", `${label}.operations.create.body.schema is required.`);
25
+ assert.equal(typeof resource.operations.patch.body?.schema, "object", `${label}.operations.patch.body.schema is required.`);
26
+ assert.equal(
27
+ typeof resolveStructuredSchemaTransportSchema(resource.operations.view.output, {
28
+ context: `${label}.operations.view.output`,
29
+ defaultMode: "replace"
30
+ }),
31
+ "object",
32
+ `${label}.operations.view.output transport schema is required.`
33
+ );
34
+ }
35
+ });
36
+
37
+ test("workspace memberships derived bodies accept normalized internal writes", async () => {
38
+ const create = await parseBody(workspaceMembershipsResource.operations.create, {
39
+ workspaceId: "7",
40
+ userId: "9",
41
+ roleSid: "OWNER",
42
+ status: "ACTIVE",
43
+ createdAt: "2026-05-02T10:11:12.000Z",
44
+ updatedAt: "2026-05-02T10:11:12.000Z"
45
+ });
46
+ assert.equal(create.ok, true);
47
+ assert.equal(String(create.value.workspaceId), "7");
48
+ assert.equal(String(create.value.userId), "9");
49
+ assert.equal(create.value.roleSid, "OWNER");
50
+ assert.equal(create.value.status, "ACTIVE");
51
+ assert.equal(typeof create.value.createdAt, "object");
52
+ assert.equal(typeof create.value.updatedAt, "object");
53
+
54
+ const patch = await parseBody(workspaceMembershipsResource.operations.patch, {
55
+ roleSid: "ADMIN",
56
+ status: "ACTIVE",
57
+ updatedAt: "2026-05-02T10:11:12.000Z"
58
+ });
59
+ assert.equal(patch.ok, true);
60
+ assert.equal(patch.value.roleSid, "ADMIN");
61
+ assert.equal(patch.value.status, "ACTIVE");
62
+ assert.equal(typeof patch.value.updatedAt, "object");
63
+ });
64
+
65
+ test("workspace invites derived bodies keep lifecycle fields available for internal writes", async () => {
66
+ const create = await parseBody(workspaceInvitesResource.operations.create, {
67
+ workspaceId: "7",
68
+ email: "Invitee@Example.com",
69
+ roleSid: "ADMIN",
70
+ status: "PENDING",
71
+ tokenHash: " invite-token-hash ",
72
+ invitedByUserId: "9",
73
+ expiresAt: "2026-05-10T00:00:00.000Z",
74
+ acceptedAt: null,
75
+ revokedAt: null,
76
+ createdAt: "2026-05-02T10:11:12.000Z",
77
+ updatedAt: "2026-05-02T10:11:12.000Z"
78
+ });
79
+ assert.equal(create.ok, true);
80
+ assert.equal(String(create.value.workspaceId), "7");
81
+ assert.equal(create.value.email, "Invitee@Example.com");
82
+ assert.equal(create.value.roleSid, "ADMIN");
83
+ assert.equal(create.value.status, "PENDING");
84
+ assert.equal(create.value.tokenHash, "invite-token-hash");
85
+ assert.equal(String(create.value.invitedByUserId), "9");
86
+ assert.equal(typeof create.value.createdAt, "object");
87
+ assert.equal(typeof create.value.updatedAt, "object");
88
+
89
+ const patch = await parseBody(workspaceInvitesResource.operations.patch, {
90
+ status: "ACCEPTED",
91
+ acceptedAt: "2026-05-11T00:00:00.000Z",
92
+ updatedAt: "2026-05-11T00:00:00.000Z"
93
+ });
94
+ assert.equal(patch.ok, true);
95
+ assert.equal(patch.value.status, "ACCEPTED");
96
+ assert.equal(typeof patch.value.acceptedAt, "object");
97
+ assert.equal(typeof patch.value.updatedAt, "object");
98
+ });
@@ -1,90 +1,151 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { toIsoString, toNullableDateTime } from "@jskit-ai/database-runtime/shared";
3
+ import { toIsoString } from "@jskit-ai/database-runtime/shared";
4
4
  import { createRepository } from "../src/server/common/repositories/workspaceInvitesRepository.js";
5
5
 
6
- function createKnexStub({
7
- rowById = new Map(),
8
- pendingRow = null,
9
- joinedRows = []
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 toWorkspaceInviteResource(row = {}, { includeWorkspace = false } = {}) {
19
+ return {
20
+ type: "workspaceInvites",
21
+ id: String(row.id || ""),
22
+ attributes: {
23
+ email: row.email,
24
+ roleSid: row.roleSid,
25
+ status: row.status,
26
+ tokenHash: row.tokenHash,
27
+ expiresAt: row.expiresAt,
28
+ acceptedAt: row.acceptedAt,
29
+ revokedAt: row.revokedAt,
30
+ createdAt: row.createdAt,
31
+ updatedAt: row.updatedAt
32
+ },
33
+ relationships: {
34
+ workspace: {
35
+ data: row?.workspace?.id == null
36
+ ? null
37
+ : {
38
+ type: "workspaces",
39
+ id: String(row.workspace.id)
40
+ }
41
+ },
42
+ invitedByUser: {
43
+ data: row?.invitedByUser?.id == null ? null : { type: "userProfiles", id: String(row.invitedByUser.id) }
44
+ }
45
+ }
46
+ };
47
+ }
48
+
49
+ function createWorkspaceInvitesApiStub({
50
+ rows = [],
51
+ rowById = new Map()
10
52
  } = {}) {
11
53
  const state = {
12
- insertPayload: null,
13
- updatePayload: null
54
+ postPayload: null,
55
+ patchPayloads: []
14
56
  };
15
57
 
16
- const defaultRow = pendingRow || {
17
- id: 1,
18
- workspace_id: 1,
19
- email: "invitee@example.com",
20
- role_sid: "member",
21
- status: "pending",
22
- token_hash: "hash",
23
- invited_by_user_id: 1,
24
- expires_at: "2026-03-16 00:26:35.709",
25
- accepted_at: null,
26
- revoked_at: null,
27
- created_at: "2026-03-09 00:26:35.710",
28
- updated_at: "2026-03-09 00:26:35.710"
29
- };
58
+ const api = {
59
+ resources: {
60
+ workspaceInvites: {
61
+ async query({ queryParams }) {
62
+ const filters = queryParams?.filters || {};
63
+ const includeWorkspace = Array.isArray(queryParams?.include) && queryParams.include.includes("workspace");
64
+ const matching = rows.filter((row) => {
65
+ if (Object.hasOwn(filters, "id") && String(row.id) !== String(filters.id)) {
66
+ return false;
67
+ }
68
+ if (Object.hasOwn(filters, "workspace") && String(row?.workspace?.id || "") !== String(filters.workspace)) {
69
+ return false;
70
+ }
71
+ if (Object.hasOwn(filters, "email") && String(row.email || "") !== String(filters.email)) {
72
+ return false;
73
+ }
74
+ if (Object.hasOwn(filters, "status") && String(row.status || "") !== String(filters.status)) {
75
+ return false;
76
+ }
77
+ if (Object.hasOwn(filters, "tokenHash") && String(row.tokenHash || "") !== String(filters.tokenHash)) {
78
+ return false;
79
+ }
80
+ return true;
81
+ });
30
82
 
31
- function tableBuilder(tableName) {
32
- if (tableName === "workspace_invites as wi") {
33
- return {
34
- join() {
35
- return this;
83
+ return {
84
+ data: matching.map((row) => toWorkspaceInviteResource(row, { includeWorkspace })),
85
+ included: includeWorkspace
86
+ ? matching
87
+ .filter((row) => row?.workspace?.id != null)
88
+ .map((row) => ({
89
+ type: "workspaces",
90
+ id: String(row.workspace.id),
91
+ attributes: {
92
+ slug: row.workspace.slug,
93
+ name: row.workspace.name,
94
+ avatarUrl: row.workspace.avatarUrl
95
+ }
96
+ }))
97
+ : []
98
+ };
36
99
  },
37
- where() {
38
- return this;
100
+ async post(payload) {
101
+ assert.equal(payload?.simplified, false);
102
+ const inputRecord = payload?.inputRecord?.data || {};
103
+ state.postPayload = inputRecord;
104
+ const row = {
105
+ id: "1",
106
+ workspace: { id: String(inputRecord.relationships?.workspace?.data?.id || "") },
107
+ email: inputRecord.attributes?.email,
108
+ roleSid: inputRecord.attributes?.roleSid,
109
+ status: inputRecord.attributes?.status,
110
+ tokenHash: inputRecord.attributes?.tokenHash,
111
+ invitedByUser: inputRecord.relationships?.invitedByUser?.data
112
+ ? { id: String(inputRecord.relationships.invitedByUser.data.id) }
113
+ : null,
114
+ expiresAt: inputRecord.attributes?.expiresAt,
115
+ acceptedAt: inputRecord.attributes?.acceptedAt,
116
+ revokedAt: inputRecord.attributes?.revokedAt,
117
+ createdAt: "2026-03-09 00:26:35.710",
118
+ updatedAt: "2026-03-09 00:26:35.710"
119
+ };
120
+ rows.push(row);
121
+ rowById.set("1", row);
122
+ return { data: toWorkspaceInviteResource(row) };
39
123
  },
40
- orderBy() {
41
- return this;
42
- },
43
- select() {
44
- return Promise.resolve([...joinedRows]);
124
+ async patch(payload) {
125
+ assert.equal(payload?.simplified, false);
126
+ const inputRecord = payload?.inputRecord?.data || {};
127
+ state.patchPayloads.push(inputRecord);
128
+ const existing = rowById.get(String(inputRecord.id));
129
+ if (existing) {
130
+ const updated = {
131
+ ...existing,
132
+ ...(inputRecord.attributes || {})
133
+ };
134
+ rowById.set(String(inputRecord.id), updated);
135
+ }
136
+ const updatedRow = rowById.get(String(inputRecord.id)) || null;
137
+ return updatedRow ? { data: toWorkspaceInviteResource(updatedRow) } : null;
45
138
  }
46
- };
47
- }
48
-
49
- assert.equal(tableName, "workspace_invites");
50
- const query = {
51
- criteriaList: [],
52
- select() {
53
- return this;
54
- },
55
- insert(payload) {
56
- state.insertPayload = payload;
57
- return Promise.resolve([1]);
58
- },
59
- where(criteria) {
60
- this.criteriaList.push(criteria);
61
- return this;
62
- },
63
- orderBy() {
64
- return this;
65
- },
66
- update(payload) {
67
- state.updatePayload = payload;
68
- return Promise.resolve(1);
69
- },
70
- first() {
71
- const criteria = Object.assign({}, ...this.criteriaList);
72
- if (Object.hasOwn(criteria, "id")) {
73
- return Promise.resolve(rowById.get(String(criteria.id)) || null);
74
- }
75
- return Promise.resolve({ ...defaultRow });
76
139
  }
77
- };
78
-
79
- return query;
80
- }
140
+ }
141
+ };
81
142
 
82
- return { knexStub: tableBuilder, state };
143
+ return { api, state };
83
144
  }
84
145
 
85
- test("workspaceInvitesRepository.insert normalizes expiresAt ISO input to database datetime", async () => {
86
- const { knexStub, state } = createKnexStub();
87
- const repository = createRepository(knexStub);
146
+ test("workspaceInvitesRepository.insert preserves expiresAt and relationship fields through the resource write path", async () => {
147
+ const { api, state } = createWorkspaceInvitesApiStub();
148
+ const repository = createRepository({ api, knex: createKnexStub() });
88
149
 
89
150
  await repository.insert({
90
151
  workspaceId: "1",
@@ -96,107 +157,94 @@ test("workspaceInvitesRepository.insert normalizes expiresAt ISO input to databa
96
157
  expiresAt: "2026-03-16T00:26:35.709Z"
97
158
  });
98
159
 
99
- assert.equal(state.insertPayload.expires_at, toNullableDateTime("2026-03-16T00:26:35.709Z"));
160
+ assert.equal(state.postPayload.relationships?.workspace?.data?.id, "1");
161
+ assert.equal(state.postPayload.attributes?.email, "invitee@example.com");
162
+ assert.equal(state.postPayload.relationships?.invitedByUser?.data?.id, "1");
163
+ assert.equal(state.postPayload.attributes?.tokenHash, "hash");
164
+ assert.equal(typeof state.postPayload.attributes?.expiresAt, "string");
100
165
  });
101
166
 
102
- test("workspaceInvitesRepository.findPendingByTokenHash reads from invites table without workspace join", async () => {
103
- const calls = {
104
- tableName: "",
105
- whereCriteria: null
106
- };
107
- const row = {
108
- id: 44,
109
- workspace_id: 9,
110
- email: "invitee@example.com",
111
- role_sid: "member",
112
- status: "pending",
113
- token_hash: "hash-token",
114
- invited_by_user_id: 1,
115
- expires_at: "2030-01-01 00:00:00.000",
116
- accepted_at: null,
117
- revoked_at: null,
118
- created_at: "2026-03-09 00:26:35.710",
119
- updated_at: "2026-03-09 00:26:35.710"
120
- };
121
-
122
- const repository = createRepository((tableName) => {
123
- calls.tableName = String(tableName || "");
124
- return {
125
- select() {
126
- return this;
127
- },
128
- where(criteria) {
129
- calls.whereCriteria = criteria;
130
- return this;
131
- },
132
- first() {
133
- return Promise.resolve({ ...row });
167
+ test("workspaceInvitesRepository.findPendingByTokenHash reads from the canonical invite resource without workspace data", async () => {
168
+ const { api } = createWorkspaceInvitesApiStub({
169
+ rows: [
170
+ {
171
+ id: "44",
172
+ workspace: { id: "9" },
173
+ email: "invitee@example.com",
174
+ roleSid: "member",
175
+ status: "pending",
176
+ tokenHash: "hash-token",
177
+ invitedByUser: { id: "1" },
178
+ expiresAt: "2030-01-01 00:00:00.000",
179
+ acceptedAt: null,
180
+ revokedAt: null,
181
+ createdAt: "2026-03-09 00:26:35.710",
182
+ updatedAt: "2026-03-09 00:26:35.710"
134
183
  }
135
- };
184
+ ]
136
185
  });
186
+ const repository = createRepository({ api, knex: createKnexStub() });
137
187
 
138
188
  const invite = await repository.findPendingByTokenHash("hash-token");
139
- assert.equal(calls.tableName, "workspace_invites");
140
- assert.deepEqual(calls.whereCriteria, {
141
- token_hash: "hash-token",
142
- status: "pending"
143
- });
189
+
144
190
  assert.equal(invite?.workspaceId, "9");
145
191
  assert.equal(invite?.workspaceSlug, undefined);
146
192
  });
147
193
 
148
194
  test("workspaceInvitesRepository.markAcceptedById uses the internal invite resource for normalized patch writes", async () => {
149
- const acceptedRow = {
150
- id: 1,
151
- workspace_id: 1,
152
- email: "invitee@example.com",
153
- role_sid: "member",
154
- status: "accepted",
155
- token_hash: "hash",
156
- invited_by_user_id: 1,
157
- expires_at: "2026-03-16 00:26:35.709",
158
- accepted_at: "2026-03-10 00:26:35.710",
159
- revoked_at: null,
160
- created_at: "2026-03-09 00:26:35.710",
161
- updated_at: "2026-03-10 00:26:35.710"
162
- };
163
- const { knexStub, state } = createKnexStub({
164
- rowById: new Map([["1", acceptedRow]])
195
+ const { api, state } = createWorkspaceInvitesApiStub({
196
+ rowById: new Map([
197
+ ["1", {
198
+ id: "1",
199
+ workspace: { id: "1" },
200
+ email: "invitee@example.com",
201
+ roleSid: "member",
202
+ status: "accepted",
203
+ tokenHash: "hash",
204
+ invitedByUser: { id: "1" },
205
+ expiresAt: "2026-03-16 00:26:35.709",
206
+ acceptedAt: "2026-03-10 00:26:35.710",
207
+ revokedAt: null,
208
+ createdAt: "2026-03-09 00:26:35.710",
209
+ updatedAt: "2026-03-10 00:26:35.710"
210
+ }]
211
+ ])
165
212
  });
166
- const repository = createRepository(knexStub);
213
+ const repository = createRepository({ api, knex: createKnexStub() });
167
214
 
168
215
  await repository.markAcceptedById("1");
169
216
 
170
- assert.equal(state.updatePayload.status, "accepted");
171
- assert.equal(typeof state.updatePayload.accepted_at, "string");
172
- assert.match(state.updatePayload.accepted_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/);
173
- assert.equal(typeof state.updatePayload.updated_at, "string");
174
- assert.match(state.updatePayload.updated_at, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/);
217
+ const payload = state.patchPayloads[0];
218
+ assert.equal(payload.attributes?.status, "accepted");
219
+ assert.equal(typeof payload.attributes?.acceptedAt, "object");
220
+ assert.equal(typeof payload.attributes?.updatedAt, "object");
175
221
  });
176
222
 
177
223
  test("workspaceInvitesRepository.listPendingByWorkspaceIdWithWorkspace keeps workspace join fields outside the base resource contract", async () => {
178
- const { knexStub } = createKnexStub({
179
- joinedRows: [
224
+ const { api } = createWorkspaceInvitesApiStub({
225
+ rows: [
180
226
  {
181
- id: 1,
182
- workspace_id: 9,
227
+ id: "1",
228
+ workspace: {
229
+ id: "9",
230
+ slug: "tonymobily3",
231
+ name: "TonyMobily3",
232
+ avatarUrl: "https://example.com/avatar.png"
233
+ },
183
234
  email: "invitee@example.com",
184
- role_sid: "member",
235
+ roleSid: "member",
185
236
  status: "pending",
186
- token_hash: "hash-token",
187
- invited_by_user_id: 1,
188
- expires_at: "2030-01-01 00:00:00.000",
189
- accepted_at: null,
190
- revoked_at: null,
191
- created_at: "2026-03-09 00:26:35.710",
192
- updated_at: "2026-03-09 00:26:35.710",
193
- workspace_slug: "tonymobily3",
194
- workspace_name: "TonyMobily3",
195
- workspace_avatar_url: "https://example.com/avatar.png"
237
+ tokenHash: "hash-token",
238
+ invitedByUser: { id: "1" },
239
+ expiresAt: "2030-01-01 00:00:00.000",
240
+ acceptedAt: null,
241
+ revokedAt: null,
242
+ createdAt: "2026-03-09 00:26:35.710",
243
+ updatedAt: "2026-03-09 00:26:35.710"
196
244
  }
197
245
  ]
198
246
  });
199
- const repository = createRepository(knexStub);
247
+ const repository = createRepository({ api, knex: createKnexStub() });
200
248
 
201
249
  const invites = await repository.listPendingByWorkspaceIdWithWorkspace("9");
202
250
 
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
4
+ import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
5
+
6
+ function resolveOutputSchema(operationName) {
7
+ return resolveStructuredSchemaTransportSchema(workspaceMembersResource.operations[operationName].output, {
8
+ context: `workspaceMembers.${operationName}.output`,
9
+ defaultMode: "replace"
10
+ });
11
+ }
12
+
13
+ test("workspace members role catalog output is explicit and nested", () => {
14
+ const outputSchema = resolveOutputSchema("rolesList");
15
+ const roleItemRef = outputSchema.properties.roles.items.allOf[0].$ref.replace(/^#\/definitions\//, "");
16
+ const roleItemSchema = outputSchema.definitions[roleItemRef];
17
+
18
+ assert.equal(outputSchema.type, "object");
19
+ assert.equal(outputSchema.additionalProperties, false);
20
+ assert.equal(outputSchema.properties.roles.type, "array");
21
+ assert.equal(outputSchema.properties.roles.items["x-json-rest-schema"]?.castType, "object");
22
+ assert.equal(roleItemSchema.type, "object");
23
+ assert.equal(roleItemSchema.properties.permissions.type, "array");
24
+ });
25
+
26
+ test("workspace members invite mutation outputs expose their tracking ids explicitly", () => {
27
+ const createOutputSchema = resolveOutputSchema("createInvite");
28
+ const revokeOutputSchema = resolveOutputSchema("revokeInvite");
29
+
30
+ assert.equal(createOutputSchema.properties.createdInviteId.type, "string");
31
+ assert.equal(createOutputSchema.properties.inviteTokenPreview.type, "string");
32
+ assert.equal(revokeOutputSchema.properties.revokedInviteId.type, "string");
33
+ assert.equal(revokeOutputSchema.properties.roleCatalog["x-json-rest-schema"]?.castType, "object");
34
+ assert.equal(Array.isArray(revokeOutputSchema.properties.roleCatalog.allOf), true);
35
+ });