@plandesk/api 1.0.0-beta.8 → 1.0.2
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.
- package/dist/agent-keys.d.ts +26 -2
- package/dist/agent-keys.js +22 -2
- package/dist/auth-context.d.ts +17 -1
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +25 -6
- package/dist/better-auth.js +4 -3
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +190 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/invitations.d.ts +13 -2
- package/dist/invitations.js +14 -2
- package/dist/organizations.d.ts +3 -0
- package/dist/organizations.js +18 -0
- package/dist/projection.d.ts +28 -0
- package/dist/projection.js +29 -1
- package/dist/routes/auth.js +21 -5
- package/dist/routes/comments.js +3 -1
- package/dist/routes/orgs.js +139 -24
- package/dist/routes/projects.js +36 -5
- package/dist/routes/shares.js +32 -0
- package/dist/routes/tasks.js +3 -0
- package/dist/serialize.d.ts +2 -0
- package/dist/serialize.js +1 -0
- package/dist/server.js +9 -6
- package/dist/services/agent-runs.js +18 -0
- package/dist/services/artifacts.js +18 -0
- package/dist/services/canvas.js +9 -0
- package/dist/services/comments.js +63 -13
- package/dist/services/documents.js +36 -0
- package/dist/services/files.js +19 -1
- package/dist/services/folders.js +27 -0
- package/dist/services/goals.js +45 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +1 -1
- package/dist/services/notes.js +27 -0
- package/dist/services/projects.d.ts +27 -0
- package/dist/services/projects.js +113 -4
- package/dist/services/scope.d.ts +4 -0
- package/dist/services/scope.js +22 -0
- package/dist/services/share.d.ts +25 -3
- package/dist/services/share.js +160 -9
- package/dist/services/sync.d.ts +1 -1
- package/dist/services/sync.js +52 -1
- package/dist/services/tags.js +18 -0
- package/dist/services/tasks.d.ts +1 -1
- package/dist/services/tasks.js +22 -0
- package/dist/test-helpers.d.ts +1 -0
- package/dist/test-helpers.js +14 -0
- package/dist/vercel.js +9 -1
- package/dist/worker.js +1 -1
- package/package.json +3 -3
- package/web/assets/index-BBNvafSX.js +358 -0
- package/web/assets/index-BqoMRSUH.css +2 -0
- package/web/index.html +2 -2
- package/web/assets/index-aQ0kLFAX.js +0 -358
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/services/scope.d.ts
CHANGED
|
@@ -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.
|
package/dist/services/scope.js
CHANGED
|
@@ -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
|
package/dist/services/share.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { type Db, type Share, type ShareMode, type SharePermissions } from '@plandesk/db';
|
|
2
|
-
import
|
|
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
|
};
|
package/dist/services/share.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
551
|
+
if (!shareMatchesGuest(share, auth)) {
|
|
401
552
|
return { status: 'unauthorized' };
|
|
402
553
|
}
|
|
403
554
|
const guest = await getGuestSessionById(db, auth.guestSessionId);
|
package/dist/services/sync.d.ts
CHANGED
|
@@ -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>;
|
package/dist/services/sync.js
CHANGED
|
@@ -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),
|
package/dist/services/tags.js
CHANGED
|
@@ -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;
|