@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.
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 +190 -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-BBNvafSX.js +358 -0
  54. package/web/assets/index-BqoMRSUH.css +2 -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
@@ -6,6 +6,7 @@
6
6
  * call via runtime-validated Reflect, same pattern as agent-keys.ts.
7
7
  */
8
8
  import { makeSignature } from 'better-auth/crypto';
9
+ import { ensureDefaultTeamForOrg } from './identity.js';
9
10
  export const INVITATION_ROLES = ['owner', 'admin', 'member'];
10
11
  export function isInvitationRole(value) {
11
12
  return INVITATION_ROLES.includes(value);
@@ -46,6 +47,7 @@ function parseInvitation(raw) {
46
47
  email: typeof raw.email === 'string' ? raw.email : '',
47
48
  role: typeof raw.role === 'string' ? raw.role : '',
48
49
  organizationId: typeof raw.organizationId === 'string' ? raw.organizationId : '',
50
+ teamId: typeof raw.teamId === 'string' && raw.teamId.length > 0 ? raw.teamId : null,
49
51
  inviterId: typeof raw.inviterId === 'string' ? raw.inviterId : '',
50
52
  status: typeof raw.status === 'string' ? raw.status : '',
51
53
  expiresAt: raw.expiresAt instanceof Date ? raw.expiresAt : new Date(String(raw.expiresAt ?? '')),
@@ -53,8 +55,10 @@ function parseInvitation(raw) {
53
55
  };
54
56
  }
55
57
  /**
56
- * Create an invitation using the caller's better-auth session (Cookie headers).
57
- * No mailer: sendInvitationEmail is unset; caller delivers claimUrl by hand.
58
+ * Create a workspace-scoped invitation using the caller's better-auth session
59
+ * (Cookie headers). The invitee joins the org AND the team on accept
60
+ * (better-auth does both when teamId is set). No mailer: caller delivers
61
+ * claimUrl by hand.
58
62
  */
59
63
  export async function createOrganizationInvitation(auth, opts) {
60
64
  const raw = await callPluginApi(auth, 'createInvitation', {
@@ -62,6 +66,7 @@ export async function createOrganizationInvitation(auth, opts) {
62
66
  email: opts.email.trim().toLowerCase(),
63
67
  role: opts.role,
64
68
  organizationId: opts.organizationId,
69
+ teamId: opts.teamId,
65
70
  },
66
71
  headers: opts.headers,
67
72
  });
@@ -69,6 +74,8 @@ export async function createOrganizationInvitation(auth, opts) {
69
74
  return {
70
75
  invitationId: invitation.id,
71
76
  claimUrl: invitationClaimUrl(opts.baseURL, invitation.id),
77
+ organizationId: opts.organizationId,
78
+ teamId: opts.teamId,
72
79
  invitation,
73
80
  };
74
81
  }
@@ -196,13 +203,18 @@ export async function ensureShellOwner(auth, organizationId) {
196
203
  }
197
204
  /**
198
205
  * Shell-side owner invitation for `plandesk admin invite-owner` (no GitHub, no mailer).
206
+ * The owner role is org-level; teamId targets the org's default workspace so
207
+ * the bootstrap satisfies the workspace-scoped invite contract (the owner
208
+ * reaches every workspace via the owner role regardless).
199
209
  */
200
210
  export async function mintOwnerInvitation(auth, opts) {
201
211
  const { headers } = await ensureShellOwner(auth, opts.organizationId);
212
+ const teamId = await ensureDefaultTeamForOrg(auth, opts.organizationId);
202
213
  const created = await createOrganizationInvitation(auth, {
203
214
  email: opts.email,
204
215
  role: 'owner',
205
216
  organizationId: opts.organizationId,
217
+ teamId,
206
218
  headers,
207
219
  baseURL: opts.baseURL,
208
220
  });
@@ -33,6 +33,9 @@ export declare function listOrganizationMembers(auth: BetterAuthInstance, organi
33
33
  export type InvitationPreview = {
34
34
  organizationId: string;
35
35
  organizationName: string;
36
+ /** Workspace (team) the invitee will join; null for legacy team-less rows. */
37
+ workspaceId: string | null;
38
+ workspaceName: string;
36
39
  role: string;
37
40
  email: string;
38
41
  status: string;
@@ -87,9 +87,27 @@ export async function getInvitationPreview(auth, invitationId) {
87
87
  model: 'organization',
88
88
  where: [{ field: 'id', value: invitation.organizationId }],
89
89
  });
90
+ // better-auth stores team ids comma-joined on the invitation row; take the
91
+ // first. Scoped by org so a stale/foreign id cannot leak a name.
92
+ const workspaceId = typeof invitation.teamId === 'string' && invitation.teamId.length > 0
93
+ ? invitation.teamId.split(',')[0]
94
+ : null;
95
+ let workspaceName = '';
96
+ if (workspaceId !== null) {
97
+ const team = await adapter.findOne({
98
+ model: 'team',
99
+ where: [
100
+ { field: 'id', value: workspaceId },
101
+ { field: 'organizationId', value: invitation.organizationId },
102
+ ],
103
+ });
104
+ workspaceName = team?.name ?? '';
105
+ }
90
106
  return {
91
107
  organizationId: invitation.organizationId,
92
108
  organizationName: org?.name ?? '',
109
+ workspaceId,
110
+ workspaceName,
93
111
  role: invitation.role,
94
112
  email: invitation.email,
95
113
  status: invitation.status,
@@ -47,5 +47,33 @@ export type ClientView = {
47
47
  expires_at: string | null;
48
48
  };
49
49
  };
50
+ export type WorkspaceClientView = {
51
+ kind: 'workspace';
52
+ workspace: {
53
+ id: string;
54
+ name: string;
55
+ };
56
+ projects: Array<{
57
+ id: string;
58
+ name: string;
59
+ view: ClientView;
60
+ }>;
61
+ share: {
62
+ audience_name: string;
63
+ permissions: {
64
+ read: boolean;
65
+ submit: boolean;
66
+ };
67
+ expires_at: string | null;
68
+ };
69
+ };
70
+ export type AnyClientView = ClientView | WorkspaceClientView;
50
71
  export declare function buildClientView(db: Db, projectId: string, share: Share): Promise<ClientView | undefined>;
72
+ /**
73
+ * Workspace projection: the existing per-project ClientView for every project in
74
+ * the shared workspace. Reuses buildClientView per project (no reimplementation).
75
+ * The workspace name is unknown to the projects table — the caller may pass it;
76
+ * otherwise the workspace id stands in (the share was validated at creation).
77
+ */
78
+ export declare function buildWorkspaceClientView(db: Db, workspaceId: string, share: Share, workspaceName?: string): Promise<WorkspaceClientView | undefined>;
51
79
  //# sourceMappingURL=projection.d.ts.map
@@ -1,4 +1,4 @@
1
- import { getProject, listDocuments, listEdges, listTasks, parseSharePermissions, parseSharePolicy, } from '@plandesk/db';
1
+ import { getProject, listDocuments, listEdges, listProjectsByWorkspace, listTasks, parseSharePermissions, parseSharePolicy, } from '@plandesk/db';
2
2
  function buildProgress(tasks) {
3
3
  const progress = {};
4
4
  for (const task of tasks) {
@@ -72,4 +72,32 @@ export async function buildClientView(db, projectId, share) {
72
72
  },
73
73
  };
74
74
  }
75
+ /**
76
+ * Workspace projection: the existing per-project ClientView for every project in
77
+ * the shared workspace. Reuses buildClientView per project (no reimplementation).
78
+ * The workspace name is unknown to the projects table — the caller may pass it;
79
+ * otherwise the workspace id stands in (the share was validated at creation).
80
+ */
81
+ export async function buildWorkspaceClientView(db, workspaceId, share, workspaceName) {
82
+ const workspaceProjects = await listProjectsByWorkspace(db, workspaceId);
83
+ const permissions = parseSharePermissions(share);
84
+ const projects = [];
85
+ for (const project of workspaceProjects) {
86
+ const view = await buildClientView(db, project.id, share);
87
+ if (view === undefined) {
88
+ continue;
89
+ }
90
+ projects.push({ id: project.id, name: project.name, view });
91
+ }
92
+ return {
93
+ kind: 'workspace',
94
+ workspace: { id: workspaceId, name: workspaceName ?? workspaceId },
95
+ projects,
96
+ share: {
97
+ audience_name: share.audienceName,
98
+ permissions,
99
+ expires_at: share.expiresAt?.toISOString() ?? null,
100
+ },
101
+ };
102
+ }
75
103
  //# sourceMappingURL=projection.js.map
@@ -1,6 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { createOrgOwnerKey } from '../agent-keys.js';
3
3
  import { getAuthContext } from '../auth-context.js';
4
+ import { getActiveTeamForSession, listTeamsForOrg, } from '../identity.js';
4
5
  import { listOrganizationsForUser, resolveOrganizationName } from '../organizations.js';
5
6
  import { requirePermission } from '../permissions.js';
6
7
  export function createAuthRouter(deps) {
@@ -27,12 +28,25 @@ export function createAuthRouter(deps) {
27
28
  let orgs = [
28
29
  { id: org.id, name: org.name, role: ctx.role ?? 'member' },
29
30
  ];
30
- if (ctx.kind === 'session' && betterAuth !== undefined) {
31
- const session = await betterAuth.api.getSession({ headers: c.req.raw.headers });
32
- if (session === null) {
33
- return c.json({ error: 'unauthorized' }, 401);
31
+ let activeWorkspace = null;
32
+ let workspaces = [];
33
+ if (betterAuth !== undefined) {
34
+ workspaces = await listTeamsForOrg(betterAuth, ctx.orgId);
35
+ if (ctx.kind === 'session') {
36
+ const session = await betterAuth.api.getSession({ headers: c.req.raw.headers });
37
+ if (session === null) {
38
+ return c.json({ error: 'unauthorized' }, 401);
39
+ }
40
+ orgs = await listOrganizationsForUser(betterAuth, session.user.id);
41
+ const active = await getActiveTeamForSession(betterAuth, session.session.token, ctx.orgId);
42
+ activeWorkspace =
43
+ active ?? (workspaces[0] !== undefined ? workspaces[0] : null);
44
+ }
45
+ else {
46
+ // Loopback: no session row to carry an active team — default to the
47
+ // first workspace (the org's default team) so the switcher still renders.
48
+ activeWorkspace = workspaces[0] !== undefined ? workspaces[0] : null;
34
49
  }
35
- orgs = await listOrganizationsForUser(betterAuth, session.user.id);
36
50
  }
37
51
  return c.json({
38
52
  kind: ctx.kind,
@@ -40,6 +54,8 @@ export function createAuthRouter(deps) {
40
54
  role: ctx.role,
41
55
  org,
42
56
  orgs,
57
+ active_workspace: activeWorkspace,
58
+ workspaces,
43
59
  });
44
60
  });
45
61
  /**
@@ -30,7 +30,9 @@ async function handleListComments(c, commentService, target) {
30
30
  const includeResolved = parseIncludeResolved(c.req.query('include_resolved'));
31
31
  const comments = await commentService.listByTarget(target, { includeResolved });
32
32
  if (!comments) {
33
- return c.json({ error: 'not_found' }, 404);
33
+ // Fail-closed: out-of-scope target is a 404 with an empty collection body,
34
+ // leaking neither existence nor rows.
35
+ return c.json([], 404);
34
36
  }
35
37
  return c.json(comments);
36
38
  }
@@ -1,9 +1,10 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getProjectInOrg, importProject, InvalidExportVersionError, PLANDESK_EXPORT_VERSION, } from '@plandesk/db';
3
- import { createScopedAgentKey } from '../agent-keys.js';
3
+ import { createScopedAgentKey, createWorkspaceScopedAgentKey, } from '../agent-keys.js';
4
4
  import { getAuthContext, getOrgAuthContext } from '../auth-context.js';
5
5
  import { acceptOrganizationInvitation, createOrganizationInvitation, isAuthApiError, isInvitationRole, } from '../invitations.js';
6
6
  import { getInvitationPreview, getOrganizationById, listOrganizationMembers, } from '../organizations.js';
7
+ import { createTeamForOrg, getTeamInOrg } from '../identity.js';
7
8
  import { requirePermission } from '../permissions.js';
8
9
  function isPermissionSet(value) {
9
10
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
@@ -56,11 +57,11 @@ export function createOrgsRouter(db, options = {}) {
56
57
  return (await getOrganizationById(betterAuth, orgId)) !== undefined;
57
58
  }
58
59
  /**
59
- * BA4b-3: mint a project-scoped agent key for `plandesk connect --to`.
60
- * Caller must hold apiKey:create (owner key or session owner) agent keys cannot.
61
- * Raw key returned once; metadata is { projectId, orgId } (agent profile).
60
+ * REQ-A1: list teams (workspaces) in an org. Any org member may read.
61
+ * A workspace/project-scoped agent key sees only its own workspace (never
62
+ * sibling workspaces); owner keys and sessions keep full org reach.
62
63
  */
63
- router.post('/orgs/:orgId/agent-keys', async (c) => {
64
+ router.get('/orgs/:orgId/workspaces', async (c) => {
64
65
  if (betterAuth === undefined) {
65
66
  return c.json({ error: 'unavailable' }, 503);
66
67
  }
@@ -68,16 +69,67 @@ export function createOrgsRouter(db, options = {}) {
68
69
  if (!(await requireKnownOrg(orgId))) {
69
70
  return c.json({ error: 'not_found' }, 404);
70
71
  }
71
- requirePermission(getOrgAuthContext(), 'apiKey', 'create');
72
+ const adapter = (await betterAuth.$context).adapter;
73
+ let teams = await adapter.findMany({
74
+ model: 'team',
75
+ where: [{ field: 'organizationId', value: orgId }],
76
+ });
77
+ const authCtx = getOrgAuthContext();
78
+ if (authCtx.kind === 'apikey' && authCtx.profile === 'agent') {
79
+ const allowed = new Set();
80
+ if (authCtx.workspaceId !== undefined) {
81
+ allowed.add(authCtx.workspaceId);
82
+ }
83
+ else if (authCtx.projectId !== undefined) {
84
+ const project = await getProjectInOrg(db, authCtx.projectId, orgId);
85
+ if (project !== undefined) {
86
+ allowed.add(project.workspaceId);
87
+ }
88
+ }
89
+ teams = teams.filter((t) => allowed.has(t.id));
90
+ }
91
+ return c.json({ workspaces: teams.map((t) => ({ id: t.id, name: t.name })) }, 200);
92
+ });
93
+ /**
94
+ * REQ-A2: create a team (workspace) in an org. Owner only — agent apikeys are
95
+ * rejected outright (403) so even a stray custom team:create permission (which
96
+ * AGENT_FORBIDDEN_RESOURCES strips) can never create a workspace.
97
+ */
98
+ router.post('/orgs/:orgId/workspaces', async (c) => {
99
+ if (betterAuth === undefined) {
100
+ return c.json({ error: 'unavailable' }, 503);
101
+ }
102
+ const orgId = c.req.param('orgId');
103
+ if (!(await requireKnownOrg(orgId))) {
104
+ return c.json({ error: 'not_found' }, 404);
105
+ }
106
+ const authCtx = getOrgAuthContext();
107
+ if (authCtx.kind === 'apikey' && authCtx.profile === 'agent') {
108
+ return c.json({ error: 'forbidden' }, 403);
109
+ }
110
+ requirePermission(authCtx, 'team', 'create');
72
111
  const body = await c.req.json();
73
- if (typeof body.project_id !== 'string' || body.project_id.trim() === '') {
112
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
74
113
  return c.json({ error: 'invalid_argument' }, 400);
75
114
  }
76
- const projectId = body.project_id.trim();
77
- const project = await getProjectInOrg(db, projectId, orgId);
78
- if (project === undefined) {
115
+ const name = body.name.trim();
116
+ const team = await createTeamForOrg(betterAuth, orgId, name);
117
+ return c.json({ id: team.id, name: team.name }, 201);
118
+ });
119
+ /**
120
+ * BA4b-3 + REQ-A3: mint a project-scoped or workspace-scoped agent key.
121
+ * Caller must hold apiKey:create (owner key or session owner) — agent keys cannot.
122
+ */
123
+ router.post('/orgs/:orgId/agent-keys', async (c) => {
124
+ if (betterAuth === undefined) {
125
+ return c.json({ error: 'unavailable' }, 503);
126
+ }
127
+ const orgId = c.req.param('orgId');
128
+ if (!(await requireKnownOrg(orgId))) {
79
129
  return c.json({ error: 'not_found' }, 404);
80
130
  }
131
+ requirePermission(getOrgAuthContext(), 'apiKey', 'create');
132
+ const body = await c.req.json();
81
133
  let permissions;
82
134
  if (body.permissions !== undefined) {
83
135
  if (!isPermissionSet(body.permissions)) {
@@ -96,25 +148,66 @@ export function createOrgsRouter(db, options = {}) {
96
148
  if (userId === undefined) {
97
149
  return c.json({ error: 'unauthorized' }, 401);
98
150
  }
99
- const mintInput = {
100
- auth: betterAuth,
101
- userId,
102
- orgId,
103
- projectId,
104
- ...(permissions !== undefined ? { permissions } : {}),
105
- ...(name !== undefined ? { name } : {}),
106
- };
107
- const minted = await createScopedAgentKey(mintInput);
108
- return c.json({ token: minted.key, project_id: projectId }, 200);
151
+ const hasProjectId = typeof body.project_id === 'string' && body.project_id.trim() !== '';
152
+ const hasTeamId = typeof body.team_id === 'string' && body.team_id.trim() !== '';
153
+ if (hasProjectId && hasTeamId) {
154
+ return c.json({ error: 'invalid_argument' }, 400);
155
+ }
156
+ if (hasProjectId) {
157
+ const projectId = body.project_id.trim();
158
+ const project = await getProjectInOrg(db, projectId, orgId);
159
+ if (project === undefined) {
160
+ return c.json({ error: 'not_found' }, 404);
161
+ }
162
+ const mintInput = {
163
+ auth: betterAuth,
164
+ userId,
165
+ orgId,
166
+ projectId,
167
+ ...(permissions !== undefined ? { permissions } : {}),
168
+ ...(name !== undefined ? { name } : {}),
169
+ };
170
+ const minted = await createScopedAgentKey(mintInput);
171
+ return c.json({ token: minted.key, project_id: projectId }, 200);
172
+ }
173
+ if (hasTeamId) {
174
+ const teamId = body.team_id.trim();
175
+ const adapter = (await betterAuth.$context).adapter;
176
+ const team = await adapter.findOne({
177
+ model: 'team',
178
+ where: [
179
+ { field: 'id', value: teamId },
180
+ { field: 'organizationId', value: orgId },
181
+ ],
182
+ });
183
+ if (team === null) {
184
+ return c.json({ error: 'not_found' }, 404);
185
+ }
186
+ const minted = await createWorkspaceScopedAgentKey({
187
+ auth: betterAuth,
188
+ userId,
189
+ orgId,
190
+ teamId,
191
+ ...(permissions !== undefined ? { permissions } : {}),
192
+ ...(name !== undefined ? { name } : {}),
193
+ });
194
+ return c.json({ token: minted.key, team_id: teamId }, 200);
195
+ }
196
+ return c.json({ error: 'invalid_argument' }, 400);
109
197
  });
110
198
  /**
111
- * List better-auth org members (role + email). Any org member may read.
199
+ * List better-auth org members (role + email). Any org member may read; agent
200
+ * apikeys never receive member PII (403). Owner keys and sessions unchanged.
112
201
  */
113
202
  router.get('/orgs/:id/members', async (c) => {
114
203
  const orgId = c.req.param('id');
115
204
  if (!(await requireKnownOrg(orgId))) {
116
205
  return c.json({ error: 'not_found' }, 404);
117
206
  }
207
+ const authCtx = getOrgAuthContext();
208
+ if (authCtx.kind === 'apikey' && authCtx.profile === 'agent') {
209
+ return c.json({ error: 'forbidden' }, 403);
210
+ }
118
211
  if (betterAuth === undefined) {
119
212
  return c.json({ error: 'unavailable' }, 503);
120
213
  }
@@ -122,9 +215,11 @@ export function createOrgsRouter(db, options = {}) {
122
215
  return c.json({ members }, 200);
123
216
  });
124
217
  /**
125
- * BA3c: invite by email (link-only, no mailer). Owners and admins may invite
126
- * (invitation:create); better-auth still blocks a non-owner inviting an owner.
127
- * Returns claimUrl for the inviter to deliver by hand.
218
+ * BA3c / RFC§5: invite by email to a workspace (link-only, no mailer). The
219
+ * team_id is required invites are workspace-scoped; accepting joins the
220
+ * team, not just the org. Owners and admins may invite (invitation:create);
221
+ * better-auth still blocks a non-owner inviting an owner. Returns claimUrl
222
+ * for the inviter to deliver by hand.
128
223
  */
129
224
  router.post('/orgs/:id/invitations', async (c) => {
130
225
  const orgId = c.req.param('id');
@@ -147,17 +242,27 @@ export function createOrgsRouter(db, options = {}) {
147
242
  if (typeof body.role !== 'string' || !isInvitationRole(body.role)) {
148
243
  return c.json({ error: 'invalid_argument' }, 400);
149
244
  }
245
+ if (typeof body.team_id !== 'string' || body.team_id.trim() === '') {
246
+ return c.json({ error: 'invalid_argument', message: 'team_id is required' }, 400);
247
+ }
248
+ const teamId = body.team_id.trim();
249
+ const team = await getTeamInOrg(betterAuth, teamId, orgId);
250
+ if (team === undefined) {
251
+ return c.json({ error: 'not_found' }, 404);
252
+ }
150
253
  try {
151
254
  const created = await createOrganizationInvitation(betterAuth, {
152
255
  email: body.email,
153
256
  role: body.role,
154
257
  organizationId: orgId,
258
+ teamId,
155
259
  headers: c.req.raw.headers,
156
260
  baseURL,
157
261
  });
158
262
  return c.json({
159
263
  invitationId: created.invitationId,
160
264
  claimUrl: created.claimUrl,
265
+ teamId: created.teamId,
161
266
  }, 201);
162
267
  }
163
268
  catch (err) {
@@ -215,6 +320,7 @@ export function createOrgsRouter(db, options = {}) {
215
320
  organizationId: result.member.organizationId,
216
321
  role: result.member.role,
217
322
  userId: result.member.userId,
323
+ teamId: result.invitation.teamId,
218
324
  }, 200);
219
325
  }
220
326
  catch (err) {
@@ -241,6 +347,15 @@ export function createOrgsRouter(db, options = {}) {
241
347
  return c.json({ error: 'not_found' }, 404);
242
348
  }
243
349
  requirePermission(getOrgAuthContext(), 'organization', 'update');
350
+ // Import lands the project in the org-default workspace (the portable
351
+ // format carries no workspace), so a workspace/project-scoped agent key
352
+ // would escape its scope. Reject scoped callers before reading the body.
353
+ const importAuthCtx = getOrgAuthContext();
354
+ if ((importAuthCtx.kind === 'apikey' &&
355
+ (importAuthCtx.workspaceId !== undefined || importAuthCtx.projectId !== undefined)) ||
356
+ (importAuthCtx.kind === 'loopback' && importAuthCtx.workspaceId !== undefined)) {
357
+ return c.json({ error: 'forbidden' }, 403);
358
+ }
244
359
  let body;
245
360
  try {
246
361
  body = await c.req.json();
@@ -4,6 +4,7 @@ import { InvalidGoalReferenceError } from '../services/tasks.js';
4
4
  import { InvalidTagError } from '../services/tags.js';
5
5
  import { isStringArray } from './tasks.js';
6
6
  import { parsePaginationParams } from '../serialize.js';
7
+ import { WorkspaceNotFoundError } from '../services/scope.js';
7
8
  export function createProjectsRouter(projectService, taskService) {
8
9
  const router = new Hono();
9
10
  router.get('/projects', async (c) => {
@@ -18,11 +19,23 @@ export function createProjectsRouter(projectService, taskService) {
18
19
  if (typeof body.name !== 'string' || body.name.trim() === '') {
19
20
  return c.json({ error: 'invalid_argument' }, 400);
20
21
  }
21
- const project = await projectService.create({
22
- name: body.name,
23
- description: body.description,
24
- });
25
- return c.json(project, 201);
22
+ const workspaceId = typeof body.workspace_id === 'string' && body.workspace_id.length > 0
23
+ ? body.workspace_id
24
+ : undefined;
25
+ try {
26
+ const project = await projectService.create({
27
+ name: body.name,
28
+ description: body.description,
29
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
30
+ });
31
+ return c.json(project, 201);
32
+ }
33
+ catch (error) {
34
+ if (error instanceof WorkspaceNotFoundError) {
35
+ return c.json({ error: 'not_found' }, 404);
36
+ }
37
+ throw error;
38
+ }
26
39
  });
27
40
  router.get('/projects/:id', async (c) => {
28
41
  const project = await projectService.get(c.req.param('id'));
@@ -36,6 +49,24 @@ export function createProjectsRouter(projectService, taskService) {
36
49
  if (body.name !== undefined && (typeof body.name !== 'string' || body.name.trim() === '')) {
37
50
  return c.json({ error: 'invalid_argument' }, 400);
38
51
  }
52
+ if (body.workspace_id !== undefined && body.workspace_id !== null) {
53
+ if (typeof body.workspace_id !== 'string' || body.workspace_id.trim() === '') {
54
+ return c.json({ error: 'invalid_argument' }, 400);
55
+ }
56
+ try {
57
+ const moved = await projectService.moveProjectToWorkspace(c.req.param('id'), body.workspace_id);
58
+ if (!moved) {
59
+ return c.json({ error: 'not_found' }, 404);
60
+ }
61
+ return c.json(moved);
62
+ }
63
+ catch (error) {
64
+ if (error instanceof WorkspaceNotFoundError) {
65
+ return c.json({ error: 'not_found' }, 404);
66
+ }
67
+ throw error;
68
+ }
69
+ }
39
70
  const project = await projectService.update(c.req.param('id'), {
40
71
  ...(body.name !== undefined ? { name: body.name } : {}),
41
72
  ...(body.description !== undefined ? { description: body.description } : {}),
@@ -34,6 +34,34 @@ export function createSharesRouter(shareService) {
34
34
  };
35
35
  router.post('/tasks/:id/share', createShareHandler('task'));
36
36
  router.post('/documents/:id/share', createShareHandler('document'));
37
+ // Share an entire workspace (all its projects) with a client. Owner-gated in
38
+ // the service via getTeamInOrg (workspace must be in the caller's org).
39
+ router.post('/workspaces/:workspaceId/share', async (c) => {
40
+ const workspaceId = c.req.param('workspaceId');
41
+ let body;
42
+ try {
43
+ body = await c.req.json();
44
+ }
45
+ catch {
46
+ return c.json({ error: 'invalid_json' }, 400);
47
+ }
48
+ const audienceName = (body.audience_name ?? '').trim();
49
+ if (audienceName === '') {
50
+ return c.json({ error: 'invalid_argument' }, 400);
51
+ }
52
+ const mode = body.mode === 'public' ? 'public' : 'invite';
53
+ const origin = new URL(c.req.url).origin;
54
+ const result = await shareService.createWorkspaceShare(workspaceId, {
55
+ audienceName,
56
+ mode,
57
+ permissions: { read: true, submit: body.submit === true },
58
+ ...(body.invited_emails !== undefined ? { invitedEmails: body.invited_emails } : {}),
59
+ }, origin);
60
+ if (result === undefined) {
61
+ return c.json({ error: 'not_found' }, 404);
62
+ }
63
+ return c.json({ url: result.url, token: result.token }, 201);
64
+ });
37
65
  // Hono doesn't match a literal `.md` suffix inside a param, so the route
38
66
  // takes the raw segment and the handler enforces + strips the extension.
39
67
  router.get('/share/:tokenWithExt', async (c) => {
@@ -118,6 +146,7 @@ export function createSharesRouter(shareService) {
118
146
  body: body.body,
119
147
  severity: body.severity,
120
148
  task_ref: body.task_ref,
149
+ project_id: body.project_id,
121
150
  });
122
151
  if (result.status === 'unauthorized') {
123
152
  return c.json({ error: 'unauthorized' }, 401);
@@ -128,6 +157,9 @@ export function createSharesRouter(shareService) {
128
157
  if (result.status === 'title_required') {
129
158
  return c.json({ error: 'title_required' }, 400);
130
159
  }
160
+ if (result.status === 'project_required') {
161
+ return c.json({ error: 'project_required' }, 400);
162
+ }
131
163
  if (result.status === 'rate_limited') {
132
164
  return c.json({ error: 'rate_limited' }, 429);
133
165
  }
@@ -48,6 +48,9 @@ export function createTasksRouter(taskService) {
48
48
  return c.json({ error: 'invalid_argument' }, 400);
49
49
  }
50
50
  const result = await taskService.claim(c.req.param('id'), body.agent_ref);
51
+ if (result === undefined) {
52
+ return c.json({ error: 'not_found' }, 404);
53
+ }
51
54
  if (!result.claimed) {
52
55
  return c.json(result, 409);
53
56
  }
@@ -10,6 +10,7 @@ export declare function serializeProject(project: Project): {
10
10
  id: string;
11
11
  name: string;
12
12
  description: string | null;
13
+ workspace_id: string;
13
14
  created_at: string;
14
15
  updated_at: string;
15
16
  };
@@ -18,6 +19,7 @@ export declare function serializeProjectDetail(project: Project, summary: TaskSt
18
19
  id: string;
19
20
  name: string;
20
21
  description: string | null;
22
+ workspace_id: string;
21
23
  created_at: string;
22
24
  updated_at: string;
23
25
  };
package/dist/serialize.js CHANGED
@@ -26,6 +26,7 @@ export function serializeProject(project) {
26
26
  id: project.id,
27
27
  name: project.name,
28
28
  description: project.description,
29
+ workspace_id: project.workspaceId,
29
30
  created_at: project.createdAt.toISOString(),
30
31
  updated_at: project.updatedAt.toISOString(),
31
32
  };