@plandesk/api 0.20.0 → 1.0.0-beta.1

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 (101) hide show
  1. package/dist/access-control.d.ts +150 -0
  2. package/dist/access-control.js +63 -0
  3. package/dist/agent-keys.d.ts +81 -0
  4. package/dist/agent-keys.js +181 -0
  5. package/dist/auth-context.d.ts +60 -0
  6. package/dist/auth-context.js +41 -0
  7. package/dist/auth.d.ts +33 -0
  8. package/dist/auth.js +357 -0
  9. package/dist/better-auth.d.ts +66 -0
  10. package/dist/better-auth.js +106 -0
  11. package/dist/github.d.ts +57 -0
  12. package/dist/github.js +115 -0
  13. package/dist/hosted-auth.d.ts +23 -0
  14. package/dist/hosted-auth.js +49 -0
  15. package/dist/identity.d.ts +38 -0
  16. package/dist/identity.js +163 -0
  17. package/dist/index.d.ts +13 -2
  18. package/dist/index.js +12 -2
  19. package/dist/invitations.d.ts +94 -0
  20. package/dist/invitations.js +211 -0
  21. package/dist/organizations.d.ts +32 -0
  22. package/dist/organizations.js +63 -0
  23. package/dist/permissions.d.ts +19 -0
  24. package/dist/permissions.js +42 -0
  25. package/dist/projection.d.ts +1 -1
  26. package/dist/projection.js +5 -5
  27. package/dist/routes/agent-runs.js +3 -3
  28. package/dist/routes/artifacts.js +6 -6
  29. package/dist/routes/auth.d.ts +13 -0
  30. package/dist/routes/auth.js +83 -0
  31. package/dist/routes/canvas.js +5 -5
  32. package/dist/routes/comments.js +11 -11
  33. package/dist/routes/documents.js +10 -10
  34. package/dist/routes/folders.js +8 -8
  35. package/dist/routes/goals.js +11 -11
  36. package/dist/routes/notes.js +8 -8
  37. package/dist/routes/orgs.d.ts +17 -0
  38. package/dist/routes/orgs.js +241 -0
  39. package/dist/routes/projects.js +13 -13
  40. package/dist/routes/shares.js +92 -3
  41. package/dist/routes/submissions.js +7 -8
  42. package/dist/routes/tags.js +6 -6
  43. package/dist/routes/tasks.js +14 -3
  44. package/dist/routes/tokens.js +4 -4
  45. package/dist/serialize.d.ts +3 -3
  46. package/dist/server.d.ts +20 -2
  47. package/dist/server.js +57 -9
  48. package/dist/services/agent-runs.d.ts +13 -14
  49. package/dist/services/agent-runs.js +39 -41
  50. package/dist/services/artifacts.d.ts +6 -7
  51. package/dist/services/artifacts.js +30 -26
  52. package/dist/services/canvas.d.ts +11 -12
  53. package/dist/services/canvas.js +58 -41
  54. package/dist/services/comments.d.ts +11 -12
  55. package/dist/services/comments.js +44 -64
  56. package/dist/services/documents.d.ts +10 -11
  57. package/dist/services/documents.js +74 -55
  58. package/dist/services/files.d.ts +2 -1
  59. package/dist/services/files.js +13 -5
  60. package/dist/services/folders.d.ts +7 -8
  61. package/dist/services/folders.js +45 -33
  62. package/dist/services/goals.d.ts +24 -25
  63. package/dist/services/goals.js +60 -53
  64. package/dist/services/index.d.ts +2 -5
  65. package/dist/services/index.js +15 -20
  66. package/dist/services/notes.d.ts +7 -8
  67. package/dist/services/notes.js +35 -23
  68. package/dist/services/org-scope.d.ts +22 -0
  69. package/dist/services/org-scope.js +38 -0
  70. package/dist/services/projects.d.ts +12 -13
  71. package/dist/services/projects.js +90 -58
  72. package/dist/services/scope.d.ts +12 -0
  73. package/dist/services/scope.js +29 -0
  74. package/dist/services/share.d.ts +74 -10
  75. package/dist/services/share.js +213 -23
  76. package/dist/services/sync.d.ts +7 -29
  77. package/dist/services/sync.js +37 -142
  78. package/dist/services/tags.d.ts +6 -7
  79. package/dist/services/tags.js +34 -22
  80. package/dist/services/tasks.d.ts +24 -17
  81. package/dist/services/tasks.js +97 -58
  82. package/dist/services/tokens.d.ts +6 -5
  83. package/dist/services/tokens.js +13 -6
  84. package/dist/session.d.ts +5 -0
  85. package/dist/session.js +8 -0
  86. package/dist/storage/local.js +16 -10
  87. package/dist/storage/s3.js +11 -6
  88. package/dist/test-ba-org.d.ts +16 -0
  89. package/dist/test-ba-org.js +35 -0
  90. package/dist/test-helpers.d.ts +21 -5
  91. package/dist/test-helpers.js +45 -8
  92. package/dist/vercel.d.ts +3 -0
  93. package/dist/vercel.js +62 -0
  94. package/dist/worker.d.ts +44 -0
  95. package/dist/worker.js +101 -0
  96. package/package.json +8 -5
  97. package/web/assets/index-D29SyYFS.css +2 -0
  98. package/web/assets/index-tBJ_K3ur.js +358 -0
  99. package/web/index.html +2 -2
  100. package/web/assets/index-CeBvZ-aq.css +0 -2
  101. package/web/assets/index-CuRAxImC.js +0 -358
@@ -1,5 +1,10 @@
1
- import { createShare as dbCreateShare, getDocument, getProject, getShare as dbGetShare, getShareByTokenHashRaw, getTask, hashShareToken, listDocuments, listShares as dbListShares, parseSharePermissions, parseSharePolicy, revokeShare as dbRevokeShare, } from '@plandesk/db';
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';
2
3
  import { buildClientView } from '../projection.js';
4
+ import { assertPermission, resolveOrgId } from './org-scope.js';
5
+ import { assertProjectInOrg, ProjectNotInOrgError } from './scope.js';
6
+ const GUEST_SUBMIT_RATE_LIMIT = 10;
7
+ const GUEST_SUBMIT_RATE_WINDOW_MS = 60_000;
3
8
  export class InvalidShareError extends Error {
4
9
  constructor(message) {
5
10
  super(message);
@@ -22,6 +27,34 @@ export function serializeShare(share) {
22
27
  };
23
28
  }
24
29
  const RESOURCE_SHARE_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
30
+ function normalizeEmail(email) {
31
+ return email.trim().toLowerCase();
32
+ }
33
+ function parseInvitedEmails(raw) {
34
+ if (raw === null || raw === '') {
35
+ return [];
36
+ }
37
+ const parsed = JSON.parse(raw);
38
+ if (!Array.isArray(parsed)) {
39
+ return [];
40
+ }
41
+ return parsed.filter((value) => typeof value === 'string').map(normalizeEmail);
42
+ }
43
+ function isEmailInvited(share, email) {
44
+ if (email === undefined || email.trim() === '') {
45
+ return false;
46
+ }
47
+ return parseInvitedEmails(share.invitedEmails).includes(normalizeEmail(email));
48
+ }
49
+ function isShareLive(share, now = new Date()) {
50
+ if (share.revokedAt !== null) {
51
+ return false;
52
+ }
53
+ if (share.expiresAt !== null && share.expiresAt <= now) {
54
+ return false;
55
+ }
56
+ return true;
57
+ }
25
58
  // The rich-text editor stores document/comment bodies as HTML; this is a
26
59
  // deliberately small converter for that constrained, known markup (not
27
60
  // arbitrary HTML) rather than pulling in a DOM/HTML-parsing dependency.
@@ -131,15 +164,21 @@ function buildShareMarkdown(view, policy, origin) {
131
164
  export function createShareService(deps) {
132
165
  const { db } = deps;
133
166
  return {
134
- createShare(projectId, input) {
135
- const project = getProject(db, projectId);
136
- if (!project) {
137
- return undefined;
167
+ async createShare(projectId, input) {
168
+ assertPermission(deps, 'document', 'create');
169
+ try {
170
+ await assertProjectInOrg(db, projectId, resolveOrgId(deps));
171
+ }
172
+ catch (error) {
173
+ if (error instanceof ProjectNotInOrgError) {
174
+ return undefined;
175
+ }
176
+ throw error;
138
177
  }
139
178
  if (input.audienceName.trim() === '') {
140
179
  throw new InvalidShareError('Share audience name must not be empty');
141
180
  }
142
- const { share, token } = dbCreateShare(db, {
181
+ const { share, token } = await dbCreateShare(db, {
143
182
  projectId,
144
183
  audienceName: input.audienceName,
145
184
  mode: input.mode,
@@ -150,34 +189,41 @@ export function createShareService(deps) {
150
189
  });
151
190
  return { share: serializeShare(share), token };
152
191
  },
153
- listShares(projectId) {
154
- const project = getProject(db, projectId);
155
- if (!project) {
156
- return undefined;
192
+ async listShares(projectId) {
193
+ try {
194
+ await assertProjectInOrg(db, projectId, resolveOrgId(deps));
157
195
  }
158
- return dbListShares(db, projectId).map(serializeShare);
196
+ catch (error) {
197
+ if (error instanceof ProjectNotInOrgError) {
198
+ return undefined;
199
+ }
200
+ throw error;
201
+ }
202
+ return (await dbListShares(db, projectId)).map(serializeShare);
159
203
  },
160
- revokeShare(id) {
161
- return dbRevokeShare(db, id) !== undefined;
204
+ async revokeShare(id) {
205
+ assertPermission(deps, 'document', 'delete');
206
+ return (await dbRevokeShare(db, id)) !== undefined;
162
207
  },
163
- buildClientView(projectId, shareId) {
164
- const share = dbGetShare(db, shareId);
208
+ async buildClientView(projectId, shareId) {
209
+ const share = await dbGetShare(db, shareId);
165
210
  if (!share || share.projectId !== projectId) {
166
211
  return undefined;
167
212
  }
168
213
  return buildClientView(db, projectId, share);
169
214
  },
170
- createResourceShare(input, origin) {
215
+ async createResourceShare(input, origin) {
216
+ assertPermission(deps, 'document', 'create');
171
217
  let projectId;
172
218
  let policy;
173
219
  let audienceName;
174
220
  if (input.resource.kind === 'task') {
175
- const task = getTask(db, input.resource.id);
221
+ const task = await getTask(db, input.resource.id);
176
222
  if (!task) {
177
223
  return undefined;
178
224
  }
179
225
  projectId = task.projectId;
180
- const linkedDocumentIds = listDocuments(db, projectId)
226
+ const linkedDocumentIds = (await listDocuments(db, projectId))
181
227
  .filter((doc) => doc.linkedTaskId === task.id)
182
228
  .map((doc) => doc.id);
183
229
  policy = {
@@ -188,7 +234,7 @@ export function createShareService(deps) {
188
234
  audienceName = `Agent link: ${task.label}`;
189
235
  }
190
236
  else {
191
- const document = getDocument(db, input.resource.id);
237
+ const document = await getDocument(db, input.resource.id);
192
238
  if (!document) {
193
239
  return undefined;
194
240
  }
@@ -199,7 +245,7 @@ export function createShareService(deps) {
199
245
  const expiresAt = input.expiresAt === null
200
246
  ? null
201
247
  : (input.expiresAt ?? new Date(Date.now() + RESOURCE_SHARE_DEFAULT_TTL_MS));
202
- const { share, token } = dbCreateShare(db, {
248
+ const { share, token } = await dbCreateShare(db, {
203
249
  projectId,
204
250
  audienceName,
205
251
  mode: 'public',
@@ -214,8 +260,8 @@ export function createShareService(deps) {
214
260
  expiresAt: share.expiresAt?.toISOString() ?? null,
215
261
  };
216
262
  },
217
- getResourceMarkdown(token, origin) {
218
- const share = getShareByTokenHashRaw(db, hashShareToken(token));
263
+ async getResourceMarkdown(token, origin) {
264
+ const share = await getShareByTokenHashRaw(db, hashShareToken(token));
219
265
  if (!share) {
220
266
  return { status: 'not_found' };
221
267
  }
@@ -223,12 +269,156 @@ export function createShareService(deps) {
223
269
  if (share.revokedAt !== null || (share.expiresAt !== null && share.expiresAt <= now)) {
224
270
  return { status: 'gone' };
225
271
  }
226
- const view = buildClientView(db, share.projectId, share);
272
+ const view = await buildClientView(db, share.projectId, share);
227
273
  if (!view) {
228
274
  return { status: 'not_found' };
229
275
  }
230
276
  return { status: 'ok', markdown: buildShareMarkdown(view, parseSharePolicy(share), origin) };
231
277
  },
278
+ // Portal meta for the join gate UI. Live shares only; unknown/revoked/expired → not_found.
279
+ async getShareMeta(token) {
280
+ const share = await getShareByTokenHashRaw(db, hashShareToken(token));
281
+ if (!share || !isShareLive(share)) {
282
+ return { status: 'not_found' };
283
+ }
284
+ return { status: 'ok', audienceName: share.audienceName, mode: share.mode };
285
+ },
286
+ // Named join: mints a guest session scoped to this share. invite mode requires
287
+ // an allow-listed email (sync-server semantics: 403 email_not_invited).
288
+ async joinShare(token, input) {
289
+ const share = await getShareByTokenHashRaw(db, hashShareToken(token));
290
+ if (!share || !isShareLive(share)) {
291
+ return { status: 'unauthorized' };
292
+ }
293
+ const name = input.name.trim();
294
+ if (name === '') {
295
+ return { status: 'name_required' };
296
+ }
297
+ if (share.mode === 'invite' && !isEmailInvited(share, input.email)) {
298
+ return { status: 'email_not_invited' };
299
+ }
300
+ const { guest, token: sessionToken } = await createGuestSession(db, {
301
+ shareId: share.id,
302
+ projectId: share.projectId,
303
+ name,
304
+ email: input.email?.trim() || undefined,
305
+ });
306
+ return {
307
+ status: 'ok',
308
+ sessionToken,
309
+ participant: { id: guest.id, name: guest.name },
310
+ share: {
311
+ audienceName: share.audienceName,
312
+ permissions: parseSharePermissions(share),
313
+ },
314
+ };
315
+ },
316
+ // Guest-gated portal view: middleware has already verified the guest session
317
+ // matches this share token. The view is COMPUTED live (not a snapshot). Every
318
+ // failure shape collapses to undefined → uniform 404. Guest context has no
319
+ // orgId; buildClientView reads only this one project.
320
+ async getClientView(token) {
321
+ const auth = getAuthContext();
322
+ if (auth.kind !== 'guest') {
323
+ return undefined;
324
+ }
325
+ const share = await getShareByTokenHashRaw(db, hashShareToken(token));
326
+ if (!share || !isShareLive(share)) {
327
+ return undefined;
328
+ }
329
+ if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
330
+ return undefined;
331
+ }
332
+ return buildClientView(db, share.projectId, share);
333
+ },
334
+ // Guest moderated inbox: write a pending share_submissions row the owner
335
+ // lists/triages on the same server (no cross-server hop).
336
+ async submitIssue(token, input) {
337
+ const auth = getAuthContext();
338
+ if (auth.kind !== 'guest') {
339
+ return { status: 'unauthorized' };
340
+ }
341
+ const share = await getShareByTokenHashRaw(db, hashShareToken(token));
342
+ if (!share || !isShareLive(share)) {
343
+ return { status: 'unauthorized' };
344
+ }
345
+ if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
346
+ return { status: 'unauthorized' };
347
+ }
348
+ const permissions = parseSharePermissions(share);
349
+ if (permissions.submit !== true) {
350
+ return { status: 'submit_not_permitted' };
351
+ }
352
+ const title = input.title.trim();
353
+ if (title === '') {
354
+ return { status: 'title_required' };
355
+ }
356
+ const guest = await getGuestSessionById(db, auth.guestSessionId);
357
+ if (guest === undefined || guest.shareId !== share.id) {
358
+ return { status: 'unauthorized' };
359
+ }
360
+ const recent = await countRecentSubmissionsByParticipant(db, {
361
+ hostedShareId: share.id,
362
+ participantName: guest.name,
363
+ since: new Date(Date.now() - GUEST_SUBMIT_RATE_WINDOW_MS),
364
+ });
365
+ if (recent >= GUEST_SUBMIT_RATE_LIMIT) {
366
+ return { status: 'rate_limited' };
367
+ }
368
+ const bodyText = input.body?.trim() === '' ? null : (input.body?.trim() ?? null);
369
+ const severity = input.severity?.trim() === '' ? null : (input.severity?.trim() ?? null);
370
+ const taskRef = input.task_ref?.trim() === '' ? null : (input.task_ref?.trim() ?? null);
371
+ const row = await createGuestSubmission(db, {
372
+ projectId: share.projectId,
373
+ hostedShareId: share.id,
374
+ participantName: guest.name,
375
+ title,
376
+ body: bodyText,
377
+ severity,
378
+ taskRef,
379
+ });
380
+ return {
381
+ status: 'ok',
382
+ submission: {
383
+ id: row.id,
384
+ title: row.title,
385
+ severity: row.severity,
386
+ status: row.status,
387
+ created_at: row.createdAt.toISOString(),
388
+ },
389
+ };
390
+ },
391
+ async listMySubmissions(token) {
392
+ const auth = getAuthContext();
393
+ if (auth.kind !== 'guest') {
394
+ return { status: 'unauthorized' };
395
+ }
396
+ const share = await getShareByTokenHashRaw(db, hashShareToken(token));
397
+ if (!share || !isShareLive(share)) {
398
+ return { status: 'unauthorized' };
399
+ }
400
+ if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
401
+ return { status: 'unauthorized' };
402
+ }
403
+ const guest = await getGuestSessionById(db, auth.guestSessionId);
404
+ if (guest === undefined || guest.shareId !== share.id) {
405
+ return { status: 'unauthorized' };
406
+ }
407
+ const rows = await listSubmissionsByShareAndParticipant(db, {
408
+ hostedShareId: share.id,
409
+ participantName: guest.name,
410
+ });
411
+ return {
412
+ status: 'ok',
413
+ submissions: rows.map((row) => ({
414
+ id: row.id,
415
+ title: row.title,
416
+ severity: row.severity,
417
+ status: row.status,
418
+ created_at: row.createdAt.toISOString(),
419
+ })),
420
+ };
421
+ },
232
422
  };
233
423
  }
234
424
  //# sourceMappingURL=share.js.map
@@ -1,6 +1,5 @@
1
1
  import { type Db, type ShareSubmission, type ShareSubmissionStatus } from '@plandesk/db';
2
- import type { EventBus } from '../events.js';
3
- import type { ShareService } from './share.js';
2
+ import { type OrgScopedDeps } from './org-scope.js';
4
3
  import type { TaskService } from './tasks.js';
5
4
  export declare class SyncUnavailableError extends Error {
6
5
  constructor(message?: string);
@@ -34,43 +33,22 @@ export type SerializedSubmission = {
34
33
  pulled_at: string;
35
34
  };
36
35
  export declare function serializeSubmission(row: ShareSubmission): SerializedSubmission;
37
- export type SyncServiceDeps = {
36
+ export type SyncServiceDeps = OrgScopedDeps & {
38
37
  db: Db;
39
- eventBus: EventBus;
40
38
  taskService: TaskService;
41
- shareService: ShareService;
42
- };
43
- export type WatchPushController = {
44
- onChange(): void;
45
- dispose(): void;
46
- };
47
- export type WatchPushOptions = {
48
- debounceMs?: number;
49
- onPushError?: (err: unknown) => void;
50
39
  };
51
40
  export declare function createSyncService(deps: SyncServiceDeps): {
52
- push(projectId: string, remote: SyncRemote): Promise<{
53
- pushed: number;
54
- }>;
55
- publishProject(projectId: string, input: {
56
- serverUrl: string;
57
- syncToken: string;
58
- }): Promise<{
59
- globalProjectId: string;
60
- pushed: number;
61
- }>;
62
41
  pull(projectId: string, remote: SyncRemote): Promise<{
63
42
  pulled: number;
64
43
  }>;
65
- listTriage(projectId: string, status?: ShareSubmissionStatus): SerializedSubmission[];
66
- getSubmission(submissionId: string): SerializedSubmission | undefined;
67
- setRemote(projectId: string, remote: SyncRemote): void;
68
- getRemote(projectId: string): SyncRemote | undefined;
69
- triage(submissionId: string, action: "accept" | "reject", remote: SyncRemote, asTask?: {
44
+ listTriage(projectId: string, status?: ShareSubmissionStatus): Promise<SerializedSubmission[]>;
45
+ getSubmission(submissionId: string): Promise<SerializedSubmission | undefined>;
46
+ setRemote(projectId: string, remote: SyncRemote): Promise<void>;
47
+ getRemote(projectId: string): Promise<SyncRemote | undefined>;
48
+ triage(submissionId: string, action: "accept" | "reject", remote?: SyncRemote, asTask?: {
70
49
  label?: string;
71
50
  description?: string;
72
51
  }, linkTaskId?: string): Promise<SerializedSubmission>;
73
- watchPush(projectId: string, remote: SyncRemote, opts?: WatchPushOptions): WatchPushController;
74
52
  };
75
53
  export type SyncService = ReturnType<typeof createSyncService>;
76
54
  //# sourceMappingURL=sync.d.ts.map
@@ -1,5 +1,5 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { getPullCursor, getShare, getSubmission as dbGetSubmission, getSyncRemote, listSubmissions, parseSharePermissions, setPullCursor, setSubmissionStatus, setSyncRemote, upsertSubmission, } from '@plandesk/db';
1
+ import { getPullCursor, getSubmission as dbGetSubmission, getSyncRemote, listSubmissions, setPullCursor, setSubmissionStatus, setSyncRemote, upsertSubmission, } from '@plandesk/db';
2
+ import { assertPermission } from './org-scope.js';
3
3
  export class SyncUnavailableError extends Error {
4
4
  constructor(message = 'sync server unavailable') {
5
5
  super(message);
@@ -40,16 +40,6 @@ export function serializeSubmission(row) {
40
40
  pulled_at: row.pulledAt.toISOString(),
41
41
  };
42
42
  }
43
- function parseInvitedEmails(raw) {
44
- if (raw === null || raw === '') {
45
- return [];
46
- }
47
- const parsed = JSON.parse(raw);
48
- if (!Array.isArray(parsed)) {
49
- return [];
50
- }
51
- return parsed.filter((value) => typeof value === 'string');
52
- }
53
43
  function buildDesc(submission) {
54
44
  const footer = `Reported by ${submission.participantName} (client) via Plan Desk`;
55
45
  if (submission.body === null || submission.body === '') {
@@ -78,108 +68,19 @@ async function ackSubmission(remote, submissionId, status) {
78
68
  throw new SyncUnavailableError(`sync server returned ${String(response.status)}`);
79
69
  }
80
70
  }
81
- async function pushProjection(remote, share, view) {
82
- const base = remote.serverUrl.replace(/\/$/, '');
83
- const url = `${base}/api/sync/v1/projects/${encodeURIComponent(remote.globalProjectId)}/projection`;
84
- let response;
85
- try {
86
- response = await fetch(url, {
87
- method: 'PUT',
88
- headers: {
89
- Authorization: `Bearer ${remote.syncToken}`,
90
- 'Content-Type': 'application/json',
91
- },
92
- body: JSON.stringify({
93
- share: {
94
- token_hash: share.tokenHash,
95
- audience_name: share.audienceName,
96
- permissions: parseSharePermissions(share),
97
- mode: share.mode,
98
- invited_emails: parseInvitedEmails(share.invitedEmails),
99
- expires_at: share.expiresAt?.toISOString() ?? null,
100
- },
101
- version: Date.now(),
102
- view,
103
- }),
104
- });
105
- }
106
- catch {
107
- throw new SyncUnavailableError();
108
- }
109
- if (response.status === 401) {
110
- throw new SyncUnauthorizedError();
111
- }
112
- if (!response.ok) {
113
- throw new SyncUnavailableError(`sync server returned ${String(response.status)}`);
71
+ /** Optional remote: single-server guest submit has no cross-server ack. */
72
+ async function maybeAck(remote, submissionId, status) {
73
+ if (remote === undefined) {
74
+ return;
114
75
  }
115
- }
116
- function createWatchPush(push, projectId, remote, opts) {
117
- const debounceMs = opts?.debounceMs ?? 1500;
118
- const onPushError = opts?.onPushError ??
119
- ((err) => {
120
- const message = err instanceof Error ? err.message : String(err);
121
- console.error(`sync watch push failed: ${message}`);
122
- });
123
- let timer;
124
- let disposed = false;
125
- const runPush = () => {
126
- timer = undefined;
127
- void push(projectId, remote).catch(onPushError);
128
- };
129
- return {
130
- onChange() {
131
- if (disposed) {
132
- return;
133
- }
134
- if (timer !== undefined) {
135
- clearTimeout(timer);
136
- }
137
- timer = setTimeout(runPush, debounceMs);
138
- },
139
- dispose() {
140
- disposed = true;
141
- if (timer !== undefined) {
142
- clearTimeout(timer);
143
- timer = undefined;
144
- }
145
- },
146
- };
76
+ await ackSubmission(remote, submissionId, status);
147
77
  }
148
78
  export function createSyncService(deps) {
149
- const { db, eventBus, taskService, shareService } = deps;
79
+ const { db, taskService } = deps;
150
80
  return {
151
- async push(projectId, remote) {
152
- const shares = shareService.listShares(projectId);
153
- if (shares === undefined) {
154
- throw new SyncUnavailableError('project not found');
155
- }
156
- const active = shares.filter((share) => share.revoked_at === null);
157
- let pushed = 0;
158
- for (const serialized of active) {
159
- const shareRow = getShare(db, serialized.id);
160
- if (shareRow === undefined) {
161
- continue;
162
- }
163
- const view = shareService.buildClientView(projectId, serialized.id);
164
- if (view === undefined) {
165
- continue;
166
- }
167
- await pushProjection(remote, shareRow, view);
168
- pushed += 1;
169
- }
170
- return { pushed };
171
- },
172
- async publishProject(projectId, input) {
173
- const globalProjectId = randomUUID();
174
- const { pushed } = await this.push(projectId, {
175
- serverUrl: input.serverUrl,
176
- globalProjectId,
177
- syncToken: input.syncToken,
178
- });
179
- return { globalProjectId, pushed };
180
- },
181
81
  async pull(projectId, remote) {
182
- const cursor = getPullCursor(db, projectId);
82
+ assertPermission(deps, 'task', 'update');
83
+ const cursor = await getPullCursor(db, projectId);
183
84
  const base = remote.serverUrl.replace(/\/$/, '');
184
85
  const url = new URL(`${base}/api/sync/v1/projects/${encodeURIComponent(remote.globalProjectId)}/submissions`);
185
86
  if (cursor !== undefined) {
@@ -205,7 +106,7 @@ export function createSyncService(deps) {
205
106
  let pulled = 0;
206
107
  let maxCreatedAt = cursor;
207
108
  for (const submission of remoteSubmissions) {
208
- const inserted = upsertSubmission(db, {
109
+ const inserted = await upsertSubmission(db, {
209
110
  id: submission.id,
210
111
  projectId,
211
112
  hostedShareId: submission.share_id,
@@ -226,29 +127,29 @@ export function createSyncService(deps) {
226
127
  }
227
128
  }
228
129
  if (maxCreatedAt !== undefined && maxCreatedAt !== cursor) {
229
- setPullCursor(db, projectId, maxCreatedAt);
130
+ await setPullCursor(db, projectId, maxCreatedAt);
230
131
  }
231
132
  if (pulled > 0) {
232
- eventBus.emit({ type: 'submissions_pulled', projectId });
233
133
  }
234
134
  return { pulled };
235
135
  },
236
- listTriage(projectId, status) {
237
- return listSubmissions(db, projectId, status).map(serializeSubmission);
136
+ async listTriage(projectId, status) {
137
+ return (await listSubmissions(db, projectId, status)).map(serializeSubmission);
238
138
  },
239
- getSubmission(submissionId) {
240
- const submission = dbGetSubmission(db, submissionId);
139
+ async getSubmission(submissionId) {
140
+ const submission = await dbGetSubmission(db, submissionId);
241
141
  return submission === undefined ? undefined : serializeSubmission(submission);
242
142
  },
243
- setRemote(projectId, remote) {
244
- setSyncRemote(db, projectId, {
143
+ async setRemote(projectId, remote) {
144
+ assertPermission(deps, 'task', 'update');
145
+ await setSyncRemote(db, projectId, {
245
146
  serverUrl: remote.serverUrl,
246
147
  globalProjectId: remote.globalProjectId,
247
148
  syncToken: remote.syncToken,
248
149
  });
249
150
  },
250
- getRemote(projectId) {
251
- const row = getSyncRemote(db, projectId);
151
+ async getRemote(projectId) {
152
+ const row = await getSyncRemote(db, projectId);
252
153
  if (row === undefined) {
253
154
  return undefined;
254
155
  }
@@ -259,39 +160,40 @@ export function createSyncService(deps) {
259
160
  };
260
161
  },
261
162
  async triage(submissionId, action, remote, asTask, linkTaskId) {
163
+ assertPermission(deps, 'task', 'create');
262
164
  if (asTask !== undefined && linkTaskId !== undefined) {
263
165
  throw new InvalidTriageInputError('as_task and link_task_id are mutually exclusive');
264
166
  }
265
- const submission = dbGetSubmission(db, submissionId);
167
+ const submission = await dbGetSubmission(db, submissionId);
266
168
  if (submission === undefined) {
267
169
  throw new InvalidTriageError();
268
170
  }
269
171
  if (submission.status !== 'pending') {
270
172
  // Idempotent retry recovery: a prior triage may have committed the terminal
271
- // status locally but failed to ack the remote (sync server briefly down),
173
+ // status locally but failed to ack the remote (legacy remote briefly down),
272
174
  // leaving local/remote divergence with no recovery path. Re-ack when the
273
175
  // retry's action matches the recorded outcome; ack is idempotent remotely.
176
+ // Single-server (no remote): local status is the only status — no re-ack.
274
177
  if ((action === 'accept' && submission.status === 'accepted') ||
275
178
  (action === 'reject' && submission.status === 'rejected')) {
276
- await ackSubmission(remote, submissionId, submission.status);
179
+ await maybeAck(remote, submissionId, submission.status);
277
180
  }
278
181
  return serializeSubmission(submission);
279
182
  }
280
183
  const projectId = submission.projectId;
281
184
  if (action === 'accept' && linkTaskId !== undefined) {
282
- const existingTask = taskService.get(linkTaskId);
185
+ const existingTask = await taskService.get(linkTaskId);
283
186
  if (existingTask === undefined || existingTask.project_id !== projectId) {
284
187
  throw new InvalidTriageInputError('link_task_id does not reference an existing task in this project');
285
188
  }
286
- const updated = setSubmissionStatus(db, submissionId, {
189
+ const updated = await setSubmissionStatus(db, submissionId, {
287
190
  status: 'accepted',
288
191
  linkedTaskId: linkTaskId,
289
192
  });
290
193
  if (updated === undefined) {
291
194
  throw new InvalidTriageError();
292
195
  }
293
- eventBus.emit({ type: 'submissions_pulled', projectId });
294
- await ackSubmission(remote, submissionId, 'accepted');
196
+ await maybeAck(remote, submissionId, 'accepted');
295
197
  return serializeSubmission(updated);
296
198
  }
297
199
  if (action === 'accept') {
@@ -300,12 +202,10 @@ export function createSyncService(deps) {
300
202
  // caller — enforced here at the single service chokepoint so both the HTTP
301
203
  // route and the MCP tool are covered.
302
204
  //
303
- // create + setSubmissionStatus run as two synchronous steps, but the submission
304
- // was validated to exist immediately above with no intervening await, so under
305
- // better-sqlite3's single-connection sync model the row cannot vanish and
306
- // setSubmissionStatus cannot spuriously return undefined. taskService.create
307
- // self-transacts, so an outer transaction here is neither possible nor needed.
308
- const task = taskService.create(projectId, {
205
+ // create + setSubmissionStatus run as two sequential steps after validating
206
+ // the submission exists. taskService.create self-transacts, so an outer
207
+ // transaction here is neither possible nor needed.
208
+ const task = await taskService.create(projectId, {
309
209
  label: asTask?.label ?? submission.title,
310
210
  status: 'scope',
311
211
  description: asTask?.description ?? buildDesc(submission),
@@ -313,28 +213,23 @@ export function createSyncService(deps) {
313
213
  if (task === undefined) {
314
214
  throw new InvalidTriageError('project not found');
315
215
  }
316
- const updated = setSubmissionStatus(db, submissionId, {
216
+ const updated = await setSubmissionStatus(db, submissionId, {
317
217
  status: 'accepted',
318
218
  linkedTaskId: task.id,
319
219
  });
320
220
  if (updated === undefined) {
321
221
  throw new InvalidTriageError();
322
222
  }
323
- eventBus.emit({ type: 'submissions_pulled', projectId });
324
- await ackSubmission(remote, submissionId, 'accepted');
223
+ await maybeAck(remote, submissionId, 'accepted');
325
224
  return serializeSubmission(updated);
326
225
  }
327
- const updated = setSubmissionStatus(db, submissionId, { status: 'rejected' });
226
+ const updated = await setSubmissionStatus(db, submissionId, { status: 'rejected' });
328
227
  if (updated === undefined) {
329
228
  throw new InvalidTriageError();
330
229
  }
331
- eventBus.emit({ type: 'submissions_pulled', projectId });
332
- await ackSubmission(remote, submissionId, 'rejected');
230
+ await maybeAck(remote, submissionId, 'rejected');
333
231
  return serializeSubmission(updated);
334
232
  },
335
- watchPush(projectId, remote, opts) {
336
- return createWatchPush((id, rem) => this.push(id, rem), projectId, remote, opts);
337
- },
338
233
  };
339
234
  }
340
235
  //# sourceMappingURL=sync.js.map