@plandesk/api 1.0.0-beta.8 → 1.0.0

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 (57) hide show
  1. package/dist/agent-keys.d.ts +26 -2
  2. package/dist/agent-keys.js +22 -2
  3. package/dist/auth-context.d.ts +17 -1
  4. package/dist/auth.d.ts +10 -0
  5. package/dist/auth.js +25 -6
  6. package/dist/better-auth.js +4 -3
  7. package/dist/identity.d.ts +34 -0
  8. package/dist/identity.js +182 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/invitations.d.ts +13 -2
  12. package/dist/invitations.js +14 -2
  13. package/dist/organizations.d.ts +3 -0
  14. package/dist/organizations.js +18 -0
  15. package/dist/projection.d.ts +28 -0
  16. package/dist/projection.js +29 -1
  17. package/dist/routes/auth.js +21 -5
  18. package/dist/routes/comments.js +3 -1
  19. package/dist/routes/orgs.js +139 -24
  20. package/dist/routes/projects.js +36 -5
  21. package/dist/routes/shares.js +32 -0
  22. package/dist/routes/tasks.js +3 -0
  23. package/dist/serialize.d.ts +2 -0
  24. package/dist/serialize.js +1 -0
  25. package/dist/server.js +9 -6
  26. package/dist/services/agent-runs.js +18 -0
  27. package/dist/services/artifacts.js +18 -0
  28. package/dist/services/canvas.js +9 -0
  29. package/dist/services/comments.js +63 -13
  30. package/dist/services/documents.js +36 -0
  31. package/dist/services/files.js +19 -1
  32. package/dist/services/folders.js +27 -0
  33. package/dist/services/goals.js +45 -0
  34. package/dist/services/index.d.ts +3 -0
  35. package/dist/services/index.js +1 -1
  36. package/dist/services/notes.js +27 -0
  37. package/dist/services/projects.d.ts +27 -0
  38. package/dist/services/projects.js +113 -4
  39. package/dist/services/scope.d.ts +4 -0
  40. package/dist/services/scope.js +22 -0
  41. package/dist/services/share.d.ts +25 -3
  42. package/dist/services/share.js +160 -9
  43. package/dist/services/sync.d.ts +1 -1
  44. package/dist/services/sync.js +52 -1
  45. package/dist/services/tags.js +18 -0
  46. package/dist/services/tasks.d.ts +1 -1
  47. package/dist/services/tasks.js +22 -0
  48. package/dist/test-helpers.d.ts +1 -0
  49. package/dist/test-helpers.js +14 -0
  50. package/dist/vercel.js +9 -1
  51. package/dist/worker.js +1 -1
  52. package/package.json +3 -3
  53. package/web/assets/index-BpOwO6_E.css +2 -0
  54. package/web/assets/index-CM5NiDOW.js +358 -0
  55. package/web/index.html +2 -2
  56. package/web/assets/index-aQ0kLFAX.js +0 -358
  57. package/web/assets/index-nXT4L9C-.css +0 -2
@@ -1,7 +1,10 @@
1
- import { withTransaction, clearDocumentParentRefsByProject, clearFolderParentRefsByProject, createDocument, createEdge, createProject as dbCreateProject, createTask, deleteAgentRun, deleteAgentRunEventsByRunId, deleteCommentsByProjectId, deleteDocumentsByProjectId, deleteFoldersByProjectId, deleteNotesByProjectId, deleteEdgesByProjectId, deleteGoalsByProjectId, deleteShareSubmissionsByProjectId, deleteSharesByProjectId, deleteTagsByProjectId, deleteSyncRemoteByProjectId, deleteSyncStateByProjectId, deleteProject as dbDeleteProject, deleteTasksByProjectId, getOrCreateDefaultGoal, getProject as dbGetProject, InvalidTaskStatusError, isTaskStatus, listAgentRuns, listProjects as dbListProjects, listTasks, updateProject as dbUpdateProject, } from '@plandesk/db';
1
+ import { withTransaction, clearDocumentParentRefsByProject, clearFolderParentRefsByProject, createDocument, createEdge, createProject as dbCreateProject, createTask, deleteAgentRun, deleteAgentRunEventsByRunId, deleteCommentsByProjectId, deleteDocumentsByProjectId, deleteFoldersByProjectId, deleteNotesByProjectId, deleteEdgesByProjectId, deleteGoalsByProjectId, deleteShareSubmissionsByProjectId, deleteSharesByProjectId, deleteTagsByProjectId, deleteSyncRemoteByProjectId, deleteSyncStateByProjectId, deleteProject as dbDeleteProject, deleteTasksByProjectId, getOrCreateDefaultGoal, getProject as dbGetProject, getProjectInOrg, InvalidTaskStatusError, isTaskStatus, listAgentRuns, listProjects as dbListProjects, listTasks, updateProject as dbUpdateProject, } from '@plandesk/db';
2
+ import { ensureDefaultTeamForOrg, getTeamInOrg } from '../identity.js';
2
3
  import { emptyTaskStatusSummary, serializeDocument, serializeEdge, serializeProject, serializeProjectDetail, serializeTask, } from '../serialize.js';
4
+ import { tryGetAuthContext } from '../auth-context.js';
3
5
  import { assertPermission, resolveOrgId } from './org-scope.js';
4
- import { assertProjectInOrg, ProjectNotInOrgError } from './scope.js';
6
+ import { PermissionDeniedError } from '../permissions.js';
7
+ import { assertProjectInOrg, ProjectNotInOrgError, WorkspaceNotFoundError, } from './scope.js';
5
8
  export class InvalidScaffoldError extends Error {
6
9
  constructor(message) {
7
10
  super(message);
@@ -42,6 +45,52 @@ function validateScaffoldInput(input) {
42
45
  }
43
46
  }
44
47
  }
48
+ /**
49
+ * Resolve the workspace a new project lands in. A workspace/project-scoped
50
+ * agent key is forced into its own workspace (no explicit target, or a target
51
+ * that must equal its scope); an org-wide caller may pass a workspace
52
+ * (validated in-org) or fall back to the org default. Shared by create() and
53
+ * scaffoldFromPlan() so the two new-project paths cannot drift again.
54
+ */
55
+ async function resolveWorkspaceForNewProject(deps, client, requestedWorkspaceId) {
56
+ const orgId = resolveOrgId(deps);
57
+ const ctx = tryGetAuthContext();
58
+ let callerWorkspaceId;
59
+ if (ctx?.kind === 'apikey') {
60
+ if (ctx.workspaceId !== undefined) {
61
+ callerWorkspaceId = ctx.workspaceId;
62
+ }
63
+ else if (ctx.projectId !== undefined) {
64
+ callerWorkspaceId = (await getProjectInOrg(client, ctx.projectId, orgId))?.workspaceId;
65
+ }
66
+ }
67
+ let workspaceId;
68
+ if (requestedWorkspaceId !== undefined && requestedWorkspaceId.length > 0) {
69
+ if (deps.auth === undefined) {
70
+ throw new WorkspaceNotFoundError(requestedWorkspaceId);
71
+ }
72
+ const team = await getTeamInOrg(deps.auth, requestedWorkspaceId, orgId);
73
+ if (team === undefined) {
74
+ throw new WorkspaceNotFoundError(requestedWorkspaceId);
75
+ }
76
+ workspaceId = team.id;
77
+ }
78
+ else if (callerWorkspaceId !== undefined) {
79
+ // Scoped key without an explicit target: force its own workspace so it
80
+ // can never land a project in the org-default (or any other) workspace.
81
+ workspaceId = callerWorkspaceId;
82
+ }
83
+ else {
84
+ workspaceId = deps.auth ? await ensureDefaultTeamForOrg(deps.auth, orgId) : undefined;
85
+ }
86
+ if (workspaceId === undefined) {
87
+ throw new Error('cannot resolve workspace for project');
88
+ }
89
+ if (callerWorkspaceId !== undefined && workspaceId !== callerWorkspaceId) {
90
+ throw new WorkspaceNotFoundError(workspaceId);
91
+ }
92
+ return workspaceId;
93
+ }
45
94
  function summarizeTasks(tasks) {
46
95
  const summary = emptyTaskStatusSummary();
47
96
  for (const task of tasks) {
@@ -55,12 +104,29 @@ export function createProjectService(deps) {
55
104
  async create(input) {
56
105
  assertPermission(deps, 'project', 'create');
57
106
  const orgId = resolveOrgId(deps);
58
- const project = await dbCreateProject(db, { ...input, orgId });
107
+ const workspaceId = await resolveWorkspaceForNewProject(deps, db, input.workspaceId);
108
+ const project = await dbCreateProject(db, { ...input, orgId, workspaceId });
59
109
  return serializeProject(project);
60
110
  },
61
111
  async list(pagination = {}) {
62
112
  const orgId = resolveOrgId(deps);
63
- return (await dbListProjects(db, orgId, pagination)).map(serializeProject);
113
+ const ctx = tryGetAuthContext();
114
+ // RFC§12: a session member sees only projects in their workspaces.
115
+ // Owner/admin and other contexts keep their existing scoping below.
116
+ if (ctx?.kind === 'session' && ctx.role === 'member') {
117
+ return (await dbListProjects(db, orgId, { ...pagination, workspaceIds: ctx.memberWorkspaceIds })).map(serializeProject);
118
+ }
119
+ // A project-scoped agent key sees only its own project (a key with
120
+ // ctx.projectId and no workspaceId would otherwise enumerate every org
121
+ // project). getProjectInOrg also enforces org membership.
122
+ if (ctx?.kind === 'apikey' && ctx.projectId !== undefined) {
123
+ const project = await getProjectInOrg(db, ctx.projectId, orgId);
124
+ return project === undefined ? [] : [serializeProject(project)];
125
+ }
126
+ const workspaceId = (ctx?.kind === 'apikey' || ctx?.kind === 'loopback') && ctx.workspaceId !== undefined
127
+ ? ctx.workspaceId
128
+ : undefined;
129
+ return (await dbListProjects(db, orgId, { ...pagination, workspaceId })).map(serializeProject);
64
130
  },
65
131
  async get(id) {
66
132
  const orgId = resolveOrgId(deps);
@@ -94,6 +160,47 @@ export function createProjectService(deps) {
94
160
  }
95
161
  return serializeProject(project);
96
162
  },
163
+ /**
164
+ * Move a project to another workspace (team) in the caller's org.
165
+ *
166
+ * Owner-gated: a workspace/project-scoped agent key must not move projects
167
+ * (it would drag a project out of its scope), so scoped callers are rejected
168
+ * before any existence check. Requires project:create authority (owner/admin).
169
+ * The target team must exist in the caller's org; otherwise 404.
170
+ */
171
+ async moveProjectToWorkspace(id, workspaceId) {
172
+ const ctx = tryGetAuthContext();
173
+ const scoped = ctx !== undefined &&
174
+ ((ctx.kind === 'apikey' &&
175
+ (ctx.workspaceId !== undefined || ctx.projectId !== undefined)) ||
176
+ (ctx.kind === 'loopback' && ctx.workspaceId !== undefined));
177
+ if (scoped) {
178
+ throw new PermissionDeniedError('project', 'create');
179
+ }
180
+ assertPermission(deps, 'project', 'create');
181
+ const orgId = resolveOrgId(deps);
182
+ try {
183
+ await assertProjectInOrg(db, id, orgId);
184
+ }
185
+ catch (error) {
186
+ if (error instanceof ProjectNotInOrgError) {
187
+ return undefined;
188
+ }
189
+ throw error;
190
+ }
191
+ if (deps.auth === undefined) {
192
+ throw new WorkspaceNotFoundError(workspaceId);
193
+ }
194
+ const team = await getTeamInOrg(deps.auth, workspaceId, orgId);
195
+ if (team === undefined) {
196
+ throw new WorkspaceNotFoundError(workspaceId);
197
+ }
198
+ const project = await dbUpdateProject(db, id, { workspaceId: team.id });
199
+ if (!project) {
200
+ return undefined;
201
+ }
202
+ return serializeProject(project);
203
+ },
97
204
  async delete(id) {
98
205
  assertPermission(deps, 'project', 'delete');
99
206
  const orgId = resolveOrgId(deps);
@@ -166,10 +273,12 @@ export function createProjectService(deps) {
166
273
  if (input.name === undefined || input.name.trim() === '') {
167
274
  throw new InvalidScaffoldError('name is required to create a new project (or pass projectId to add to an existing one)');
168
275
  }
276
+ const workspaceId = await resolveWorkspaceForNewProject(deps, tx, input.workspaceId);
169
277
  const project = await dbCreateProject(tx, {
170
278
  name: input.name,
171
279
  description: input.description,
172
280
  orgId,
281
+ workspaceId,
173
282
  });
174
283
  projectId = project.id;
175
284
  }
@@ -3,6 +3,10 @@ import { type DbClient, type Project } from '@plandesk/db';
3
3
  export declare class ProjectNotInOrgError extends Error {
4
4
  constructor(projectId: string);
5
5
  }
6
+ /** Thrown when a target workspace (team) is missing or outside the caller's org. Maps to HTTP 404. */
7
+ export declare class WorkspaceNotFoundError extends Error {
8
+ constructor(workspaceId: string);
9
+ }
6
10
  /**
7
11
  * Fail-closed tenant boundary: project must exist and belong to orgId.
8
12
  * Returns 404-shaped error (not 403) so existence is not leaked across orgs.
@@ -7,6 +7,13 @@ export class ProjectNotInOrgError extends Error {
7
7
  this.name = 'ProjectNotInOrgError';
8
8
  }
9
9
  }
10
+ /** Thrown when a target workspace (team) is missing or outside the caller's org. Maps to HTTP 404. */
11
+ export class WorkspaceNotFoundError extends Error {
12
+ constructor(workspaceId) {
13
+ super(`Workspace not found: ${workspaceId}`);
14
+ this.name = 'WorkspaceNotFoundError';
15
+ }
16
+ }
10
17
  /**
11
18
  * Fail-closed tenant boundary: project must exist and belong to orgId.
12
19
  * Returns 404-shaped error (not 403) so existence is not leaked across orgs.
@@ -24,6 +31,21 @@ export async function assertProjectInOrg(db, projectId, orgId) {
24
31
  ctx.projectId !== projectId) {
25
32
  throw new ProjectNotInOrgError(projectId);
26
33
  }
34
+ if (ctx !== undefined &&
35
+ (ctx.kind === 'apikey' || ctx.kind === 'loopback') &&
36
+ ctx.workspaceId !== undefined &&
37
+ project.workspaceId !== ctx.workspaceId) {
38
+ throw new ProjectNotInOrgError(projectId);
39
+ }
40
+ // RFC§12: a session member may reach only projects in workspaces they belong
41
+ // to (a teamMember row in the active org). Owner/admin manage the org and
42
+ // bypass this gate. Same 404 no-leak shape as the cross-workspace guard.
43
+ if (ctx !== undefined &&
44
+ ctx.kind === 'session' &&
45
+ ctx.role === 'member' &&
46
+ !ctx.memberWorkspaceIds.includes(project.workspaceId)) {
47
+ throw new ProjectNotInOrgError(projectId);
48
+ }
27
49
  return project;
28
50
  }
29
51
  //# sourceMappingURL=scope.js.map
@@ -1,15 +1,19 @@
1
1
  import { type Db, type Share, type ShareMode, type SharePermissions } from '@plandesk/db';
2
- import { type ClientView, type SharePolicy } from '../projection.js';
2
+ import type { BetterAuthInstance } from '../better-auth.js';
3
+ import { type ClientView, type SharePolicy, type WorkspaceClientView } from '../projection.js';
3
4
  import { type OrgScopedDeps } from './org-scope.js';
4
5
  export declare class InvalidShareError extends Error {
5
6
  constructor(message: string);
6
7
  }
7
8
  export type ShareServiceDeps = OrgScopedDeps & {
8
9
  db: Db;
10
+ /** better-auth instance for workspace (team) validation on workspace shares. */
11
+ auth?: BetterAuthInstance;
9
12
  };
10
13
  export type SerializedShare = {
11
14
  id: string;
12
- project_id: string;
15
+ project_id: string | null;
16
+ workspace_id: string | null;
13
17
  audience_name: string;
14
18
  mode: ShareMode;
15
19
  permissions: SharePermissions;
@@ -27,6 +31,19 @@ export type CreateShareInput = {
27
31
  invitedEmails?: string[];
28
32
  expiresAt?: Date;
29
33
  };
34
+ export type CreateWorkspaceShareInput = {
35
+ audienceName: string;
36
+ mode: ShareMode;
37
+ permissions?: SharePermissions;
38
+ policy?: SharePolicy;
39
+ invitedEmails?: string[];
40
+ expiresAt?: Date;
41
+ };
42
+ export type WorkspaceShareResult = {
43
+ share: SerializedShare;
44
+ token: string;
45
+ url: string;
46
+ };
30
47
  export type ShareResourceRef = {
31
48
  kind: 'task';
32
49
  id: string;
@@ -93,6 +110,8 @@ export type SubmitIssueInput = {
93
110
  body?: string;
94
111
  severity?: string;
95
112
  task_ref?: string;
113
+ /** Target project for a workspace share (ignored for project shares). */
114
+ project_id?: string;
96
115
  };
97
116
  export type SubmitIssueResult = {
98
117
  status: 'ok';
@@ -103,6 +122,8 @@ export type SubmitIssueResult = {
103
122
  status: 'submit_not_permitted';
104
123
  } | {
105
124
  status: 'title_required';
125
+ } | {
126
+ status: 'project_required';
106
127
  } | {
107
128
  status: 'rate_limited';
108
129
  };
@@ -117,6 +138,7 @@ export declare function createShareService(deps: ShareServiceDeps): {
117
138
  share: SerializedShare;
118
139
  token: string;
119
140
  } | undefined>;
141
+ createWorkspaceShare(workspaceId: string, input: CreateWorkspaceShareInput, origin: string): Promise<WorkspaceShareResult | undefined>;
120
142
  listShares(projectId: string): Promise<SerializedShare[] | undefined>;
121
143
  revokeShare(id: string): Promise<boolean>;
122
144
  buildClientView(projectId: string, shareId: string): Promise<ClientView | undefined>;
@@ -124,7 +146,7 @@ export declare function createShareService(deps: ShareServiceDeps): {
124
146
  getResourceMarkdown(token: string, origin: string): Promise<ResourceMarkdownResult>;
125
147
  getShareMeta(token: string): Promise<ShareMetaResult>;
126
148
  joinShare(token: string, input: JoinShareInput): Promise<JoinShareResult>;
127
- getClientView(token: string): Promise<ClientView | undefined>;
149
+ getClientView(token: string): Promise<ClientView | WorkspaceClientView | undefined>;
128
150
  submitIssue(token: string, input: SubmitIssueInput): Promise<SubmitIssueResult>;
129
151
  listMySubmissions(token: string): Promise<ListMySubmissionsResult>;
130
152
  };
@@ -1,6 +1,7 @@
1
- import { countRecentSubmissionsByParticipant, createGuestSession, createGuestSubmission, createShare as dbCreateShare, getDocument, getGuestSessionById, getProject, getShare as dbGetShare, getShareByTokenHashRaw, getTask, hashShareToken, listDocuments, listShares as dbListShares, listSubmissionsByShareAndParticipant, parseSharePermissions, parseSharePolicy, revokeShare as dbRevokeShare, } from '@plandesk/db';
2
- import { getAuthContext } from '../auth-context.js';
3
- import { buildClientView } from '../projection.js';
1
+ import { countRecentSubmissionsByParticipant, createGuestSession, createGuestSubmission, createShare as dbCreateShare, getDocument, getGuestSessionById, getProject, getProjectInOrg, getShare as dbGetShare, getShareByTokenHashRaw, getTask, hashShareToken, listDocuments, listShares as dbListShares, listSubmissionsByShareAndParticipant, parseSharePermissions, parseSharePolicy, revokeShare as dbRevokeShare, } from '@plandesk/db';
2
+ import { getAuthContext, tryGetAuthContext } from '../auth-context.js';
3
+ import { getTeamInOrg } from '../identity.js';
4
+ import { buildClientView, buildWorkspaceClientView, } from '../projection.js';
4
5
  import { assertPermission, resolveOrgId } from './org-scope.js';
5
6
  import { assertProjectInOrg, ProjectNotInOrgError } from './scope.js';
6
7
  const GUEST_SUBMIT_RATE_LIMIT = 10;
@@ -17,6 +18,7 @@ export function serializeShare(share) {
17
18
  return {
18
19
  id: share.id,
19
20
  project_id: share.projectId,
21
+ workspace_id: share.workspaceId,
20
22
  audience_name: share.audienceName,
21
23
  mode: share.mode,
22
24
  permissions: parseSharePermissions(share),
@@ -55,6 +57,18 @@ function isShareLive(share, now = new Date()) {
55
57
  }
56
58
  return true;
57
59
  }
60
+ // Does this share belong to this guest's scope? Share id must match, and the
61
+ // scoping column (projectId for a project share, workspaceId for a workspace
62
+ // share) must match the guest context. Fail-closed on any mismatch.
63
+ function shareMatchesGuest(share, auth) {
64
+ if (share.id !== auth.shareId) {
65
+ return false;
66
+ }
67
+ if (share.workspaceId !== null) {
68
+ return auth.workspaceId === share.workspaceId;
69
+ }
70
+ return share.projectId === auth.projectId;
71
+ }
58
72
  // The rich-text editor stores document/comment bodies as HTML; this is a
59
73
  // deliberately small converter for that constrained, known markup (not
60
74
  // arbitrary HTML) rather than pulling in a DOM/HTML-parsing dependency.
@@ -189,6 +203,57 @@ export function createShareService(deps) {
189
203
  });
190
204
  return { share: serializeShare(share), token };
191
205
  },
206
+ // Share an entire workspace: the portal then shows every project in it.
207
+ // Validates the workspace (team) belongs to the caller's org via getTeamInOrg;
208
+ // fail-closed (undefined → 404) when the workspace is unknown or cross-org,
209
+ // or when better-auth is not configured (workspace shares need team lookup).
210
+ async createWorkspaceShare(workspaceId, input, origin) {
211
+ assertPermission(deps, 'document', 'create');
212
+ if (deps.auth === undefined) {
213
+ return undefined;
214
+ }
215
+ // A workspace-scoped key may only share its own workspace; a different
216
+ // workspace id is a 404 even when it lives in the same org.
217
+ const ctx = tryGetAuthContext();
218
+ // A project-scoped key cannot publish an entire workspace — its scope is
219
+ // a single project, so deny before any existence check (uniform 404).
220
+ if (ctx?.kind === 'apikey' && ctx.projectId !== undefined) {
221
+ return undefined;
222
+ }
223
+ const scopedWorkspaceId = ctx?.kind === 'apikey' || ctx?.kind === 'loopback' ? ctx.workspaceId : undefined;
224
+ if (scopedWorkspaceId !== undefined && scopedWorkspaceId !== workspaceId) {
225
+ return undefined;
226
+ }
227
+ // A session member may only publish a workspace they belong to (a
228
+ // teamMember row in this org); a workspace outside that set is a 404
229
+ // even within the same org. Owner/admin bypass by role.
230
+ if (ctx?.kind === 'session' &&
231
+ ctx.role === 'member' &&
232
+ !ctx.memberWorkspaceIds.includes(workspaceId)) {
233
+ return undefined;
234
+ }
235
+ const team = await getTeamInOrg(deps.auth, workspaceId, resolveOrgId(deps));
236
+ if (team === undefined) {
237
+ return undefined;
238
+ }
239
+ if (input.audienceName.trim() === '') {
240
+ throw new InvalidShareError('Share audience name must not be empty');
241
+ }
242
+ const { share, token } = await dbCreateShare(db, {
243
+ workspaceId: team.id,
244
+ audienceName: input.audienceName,
245
+ mode: input.mode,
246
+ permissions: input.permissions ?? DEFAULT_PERMISSIONS,
247
+ policy: input.policy ?? DEFAULT_POLICY,
248
+ invitedEmails: input.invitedEmails,
249
+ expiresAt: input.expiresAt,
250
+ });
251
+ return {
252
+ share: serializeShare(share),
253
+ token,
254
+ url: `${origin}/p/${token}`,
255
+ };
256
+ },
192
257
  async listShares(projectId) {
193
258
  try {
194
259
  await assertProjectInOrg(db, projectId, resolveOrgId(deps));
@@ -203,6 +268,31 @@ export function createShareService(deps) {
203
268
  },
204
269
  async revokeShare(id) {
205
270
  assertPermission(deps, 'document', 'delete');
271
+ const existing = await dbGetShare(db, id);
272
+ if (!existing) {
273
+ return false;
274
+ }
275
+ // Workspace share: validate the workspace is in the caller's org (or
276
+ // fail-closed when better-auth is unavailable to confirm it).
277
+ if (existing.workspaceId !== null) {
278
+ if (deps.auth === undefined) {
279
+ return false;
280
+ }
281
+ const team = await getTeamInOrg(deps.auth, existing.workspaceId, resolveOrgId(deps));
282
+ if (team === undefined) {
283
+ return false;
284
+ }
285
+ return (await dbRevokeShare(db, id)) !== undefined;
286
+ }
287
+ try {
288
+ await assertProjectInOrg(db, existing.projectId, resolveOrgId(deps));
289
+ }
290
+ catch (error) {
291
+ if (error instanceof ProjectNotInOrgError) {
292
+ return false;
293
+ }
294
+ throw error;
295
+ }
206
296
  return (await dbRevokeShare(db, id)) !== undefined;
207
297
  },
208
298
  async buildClientView(projectId, shareId) {
@@ -242,6 +332,17 @@ export function createShareService(deps) {
242
332
  policy = { tasks: [], documentIds: [document.id], fields: {} };
243
333
  audienceName = `Agent link: ${document.title}`;
244
334
  }
335
+ // Cross-org chokepoint: the resolved resource's project must be in the
336
+ // caller's org (an org-B key sharing an org-A resource → 404, no leak).
337
+ try {
338
+ await assertProjectInOrg(db, projectId, resolveOrgId(deps));
339
+ }
340
+ catch (error) {
341
+ if (error instanceof ProjectNotInOrgError) {
342
+ return undefined;
343
+ }
344
+ throw error;
345
+ }
245
346
  const expiresAt = input.expiresAt === null
246
347
  ? null
247
348
  : (input.expiresAt ?? new Date(Date.now() + RESOURCE_SHARE_DEFAULT_TTL_MS));
@@ -269,6 +370,17 @@ export function createShareService(deps) {
269
370
  if (share.revokedAt !== null || (share.expiresAt !== null && share.expiresAt <= now)) {
270
371
  return { status: 'gone' };
271
372
  }
373
+ // The unauthenticated markdown route is an agent-context feed: only
374
+ // public shares are served here. invite-only shares require a joined
375
+ // guest session (the /view route); serving them unauthenticated would
376
+ // bypass the join gate, so collapse to the same not_found shape.
377
+ if (share.mode !== 'public') {
378
+ return { status: 'not_found' };
379
+ }
380
+ // Workspace shares have no single-project agent markdown link.
381
+ if (share.projectId === null) {
382
+ return { status: 'not_found' };
383
+ }
272
384
  const view = await buildClientView(db, share.projectId, share);
273
385
  if (!view) {
274
386
  return { status: 'not_found' };
@@ -299,7 +411,11 @@ export function createShareService(deps) {
299
411
  }
300
412
  const { guest, token: sessionToken } = await createGuestSession(db, {
301
413
  shareId: share.id,
302
- projectId: share.projectId,
414
+ // Bind the guest to the share's scope: a workspace share binds by
415
+ // workspaceId (projectId null); a project share binds by projectId.
416
+ ...(share.workspaceId !== null
417
+ ? { workspaceId: share.workspaceId }
418
+ : { projectId: share.projectId }),
303
419
  name,
304
420
  email: input.email?.trim() || undefined,
305
421
  });
@@ -316,7 +432,8 @@ export function createShareService(deps) {
316
432
  // Guest-gated portal view: middleware has already verified the guest session
317
433
  // matches this share token. The view is COMPUTED live (not a snapshot). Every
318
434
  // failure shape collapses to undefined → uniform 404. Guest context has no
319
- // orgId; buildClientView reads only this one project.
435
+ // orgId; the projection reads only the share's scope (one project OR one
436
+ // workspace's project set).
320
437
  async getClientView(token) {
321
438
  const auth = getAuthContext();
322
439
  if (auth.kind !== 'guest') {
@@ -326,7 +443,16 @@ export function createShareService(deps) {
326
443
  if (!share || !isShareLive(share)) {
327
444
  return undefined;
328
445
  }
329
- if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
446
+ if (share.id !== auth.shareId) {
447
+ return undefined;
448
+ }
449
+ if (share.workspaceId !== null) {
450
+ if (auth.workspaceId !== share.workspaceId) {
451
+ return undefined;
452
+ }
453
+ return buildWorkspaceClientView(db, share.workspaceId, share);
454
+ }
455
+ if (share.projectId !== auth.projectId) {
330
456
  return undefined;
331
457
  }
332
458
  return buildClientView(db, share.projectId, share);
@@ -342,9 +468,34 @@ export function createShareService(deps) {
342
468
  if (!share || !isShareLive(share)) {
343
469
  return { status: 'unauthorized' };
344
470
  }
345
- if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
471
+ if (share.id !== auth.shareId) {
346
472
  return { status: 'unauthorized' };
347
473
  }
474
+ // Resolve the target project this submission lands against, and enforce
475
+ // the guest's scope. A project share is bound to share.projectId; a
476
+ // workspace share requires an explicit project_id AND that the project
477
+ // belongs to the shared workspace (fail-closed otherwise).
478
+ let targetProjectId;
479
+ if (share.workspaceId !== null) {
480
+ if (auth.workspaceId !== share.workspaceId) {
481
+ return { status: 'unauthorized' };
482
+ }
483
+ const requestedProjectId = input.project_id?.trim();
484
+ if (requestedProjectId === undefined || requestedProjectId === '') {
485
+ return { status: 'project_required' };
486
+ }
487
+ const project = await getProject(db, requestedProjectId);
488
+ if (project === undefined || project.workspaceId !== share.workspaceId) {
489
+ return { status: 'unauthorized' };
490
+ }
491
+ targetProjectId = project.id;
492
+ }
493
+ else {
494
+ if (share.projectId !== auth.projectId) {
495
+ return { status: 'unauthorized' };
496
+ }
497
+ targetProjectId = share.projectId;
498
+ }
348
499
  const permissions = parseSharePermissions(share);
349
500
  if (permissions.submit !== true) {
350
501
  return { status: 'submit_not_permitted' };
@@ -369,7 +520,7 @@ export function createShareService(deps) {
369
520
  const severity = input.severity?.trim() === '' ? null : (input.severity?.trim() ?? null);
370
521
  const taskRef = input.task_ref?.trim() === '' ? null : (input.task_ref?.trim() ?? null);
371
522
  const row = await createGuestSubmission(db, {
372
- projectId: share.projectId,
523
+ projectId: targetProjectId,
373
524
  hostedShareId: share.id,
374
525
  participantName: guest.name,
375
526
  title,
@@ -397,7 +548,7 @@ export function createShareService(deps) {
397
548
  if (!share || !isShareLive(share)) {
398
549
  return { status: 'unauthorized' };
399
550
  }
400
- if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
551
+ if (!shareMatchesGuest(share, auth)) {
401
552
  return { status: 'unauthorized' };
402
553
  }
403
554
  const guest = await getGuestSessionById(db, auth.guestSessionId);
@@ -41,7 +41,7 @@ export declare function createSyncService(deps: SyncServiceDeps): {
41
41
  pull(projectId: string, remote: SyncRemote): Promise<{
42
42
  pulled: number;
43
43
  }>;
44
- listTriage(projectId: string, status?: ShareSubmissionStatus): Promise<SerializedSubmission[]>;
44
+ listTriage(projectId: string, status?: ShareSubmissionStatus): Promise<SerializedSubmission[] | undefined>;
45
45
  getSubmission(submissionId: string): Promise<SerializedSubmission | undefined>;
46
46
  setRemote(projectId: string, remote: SyncRemote): Promise<void>;
47
47
  getRemote(projectId: string): Promise<SyncRemote | undefined>;
@@ -1,5 +1,6 @@
1
1
  import { getPullCursor, getSubmission as dbGetSubmission, getSyncRemote, listSubmissions, setPullCursor, setSubmissionStatus, setSyncRemote, upsertSubmission, } from '@plandesk/db';
2
- import { assertPermission } from './org-scope.js';
2
+ import { assertPermission, resolveOrgId } from './org-scope.js';
3
+ import { assertProjectInOrg, ProjectNotInOrgError } from './scope.js';
3
4
  export class SyncUnavailableError extends Error {
4
5
  constructor(message = 'sync server unavailable') {
5
6
  super(message);
@@ -80,6 +81,18 @@ export function createSyncService(deps) {
80
81
  return {
81
82
  async pull(projectId, remote) {
82
83
  assertPermission(deps, 'task', 'update');
84
+ // Cross-scope chokepoint: a workspace-A key must not pull a remote into
85
+ // (or read state from) a project outside its scope. Same 404 no-leak
86
+ // shape as the rest of the service: out-of-scope → no rows pulled.
87
+ try {
88
+ await assertProjectInOrg(db, projectId, resolveOrgId(deps));
89
+ }
90
+ catch (error) {
91
+ if (error instanceof ProjectNotInOrgError) {
92
+ return { pulled: 0 };
93
+ }
94
+ throw error;
95
+ }
83
96
  const cursor = await getPullCursor(db, projectId);
84
97
  const base = remote.serverUrl.replace(/\/$/, '');
85
98
  const url = new URL(`${base}/api/sync/v1/projects/${encodeURIComponent(remote.globalProjectId)}/submissions`);
@@ -134,6 +147,15 @@ export function createSyncService(deps) {
134
147
  return { pulled };
135
148
  },
136
149
  async listTriage(projectId, status) {
150
+ try {
151
+ await assertProjectInOrg(db, projectId, resolveOrgId(deps));
152
+ }
153
+ catch (error) {
154
+ if (error instanceof ProjectNotInOrgError) {
155
+ return undefined;
156
+ }
157
+ throw error;
158
+ }
137
159
  return (await listSubmissions(db, projectId, status)).map(serializeSubmission);
138
160
  },
139
161
  async getSubmission(submissionId) {
@@ -142,6 +164,15 @@ export function createSyncService(deps) {
142
164
  },
143
165
  async setRemote(projectId, remote) {
144
166
  assertPermission(deps, 'task', 'update');
167
+ try {
168
+ await assertProjectInOrg(db, projectId, resolveOrgId(deps));
169
+ }
170
+ catch (error) {
171
+ if (error instanceof ProjectNotInOrgError) {
172
+ return;
173
+ }
174
+ throw error;
175
+ }
145
176
  await setSyncRemote(db, projectId, {
146
177
  serverUrl: remote.serverUrl,
147
178
  globalProjectId: remote.globalProjectId,
@@ -149,6 +180,15 @@ export function createSyncService(deps) {
149
180
  });
150
181
  },
151
182
  async getRemote(projectId) {
183
+ try {
184
+ await assertProjectInOrg(db, projectId, resolveOrgId(deps));
185
+ }
186
+ catch (error) {
187
+ if (error instanceof ProjectNotInOrgError) {
188
+ return undefined;
189
+ }
190
+ throw error;
191
+ }
152
192
  const row = await getSyncRemote(db, projectId);
153
193
  if (row === undefined) {
154
194
  return undefined;
@@ -168,6 +208,17 @@ export function createSyncService(deps) {
168
208
  if (submission === undefined) {
169
209
  throw new InvalidTriageError();
170
210
  }
211
+ // Workspace/org chokepoint: the submission's project must be in the
212
+ // caller's scope before any triage mutation (cross-workspace/org → 404).
213
+ try {
214
+ await assertProjectInOrg(db, submission.projectId, resolveOrgId(deps));
215
+ }
216
+ catch (error) {
217
+ if (error instanceof ProjectNotInOrgError) {
218
+ throw new InvalidTriageError();
219
+ }
220
+ throw error;
221
+ }
171
222
  if (submission.status !== 'pending') {
172
223
  // Idempotent retry recovery: a prior triage may have committed the terminal
173
224
  // status locally but failed to ack the remote (legacy remote briefly down),
@@ -56,6 +56,15 @@ export function createTagService(deps) {
56
56
  if (!existing) {
57
57
  return undefined;
58
58
  }
59
+ try {
60
+ await assertProjectInOrg(db, existing.projectId, resolveOrgId(deps));
61
+ }
62
+ catch (error) {
63
+ if (error instanceof ProjectNotInOrgError) {
64
+ return undefined;
65
+ }
66
+ throw error;
67
+ }
59
68
  let name;
60
69
  if (input.name !== undefined) {
61
70
  name = normalizeTagName(input.name);
@@ -80,6 +89,15 @@ export function createTagService(deps) {
80
89
  if (!existing) {
81
90
  return false;
82
91
  }
92
+ try {
93
+ await assertProjectInOrg(db, existing.projectId, resolveOrgId(deps));
94
+ }
95
+ catch (error) {
96
+ if (error instanceof ProjectNotInOrgError) {
97
+ return false;
98
+ }
99
+ throw error;
100
+ }
83
101
  const deleted = await dbDeleteTag(db, id);
84
102
  if (!deleted) {
85
103
  return false;