synomem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/AGENTS.md +42 -0
  2. package/ARCHITECTURE.md +126 -0
  3. package/CHANGELOG.md +35 -0
  4. package/CODE_OF_CONDUCT.md +29 -0
  5. package/CONTRIBUTING.md +48 -0
  6. package/LICENSE +21 -0
  7. package/README.md +247 -0
  8. package/SECURITY.md +117 -0
  9. package/dist/backend.d.ts +9 -0
  10. package/dist/backend.d.ts.map +1 -0
  11. package/dist/backend.js +60 -0
  12. package/dist/backend.js.map +1 -0
  13. package/dist/cli.d.ts +34 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +1040 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/client.d.ts +168 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1031 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +33 -0
  22. package/dist/config.d.ts.map +1 -0
  23. package/dist/config.js +153 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/credentials.d.ts +36 -0
  26. package/dist/credentials.d.ts.map +1 -0
  27. package/dist/credentials.js +127 -0
  28. package/dist/credentials.js.map +1 -0
  29. package/dist/errors.d.ts +9 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +60 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/fs-utils.d.ts +7 -0
  34. package/dist/fs-utils.d.ts.map +1 -0
  35. package/dist/fs-utils.js +68 -0
  36. package/dist/fs-utils.js.map +1 -0
  37. package/dist/import.d.ts +655 -0
  38. package/dist/import.d.ts.map +1 -0
  39. package/dist/import.js +226 -0
  40. package/dist/import.js.map +1 -0
  41. package/dist/index.d.ts +20 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +11 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/mcp/index.d.ts +14 -0
  46. package/dist/mcp/index.d.ts.map +1 -0
  47. package/dist/mcp/index.js +698 -0
  48. package/dist/mcp/index.js.map +1 -0
  49. package/dist/mcp-server.d.ts +3 -0
  50. package/dist/mcp-server.d.ts.map +1 -0
  51. package/dist/mcp-server.js +45 -0
  52. package/dist/mcp-server.js.map +1 -0
  53. package/dist/oauth.d.ts +27 -0
  54. package/dist/oauth.d.ts.map +1 -0
  55. package/dist/oauth.js +201 -0
  56. package/dist/oauth.js.map +1 -0
  57. package/dist/ports/projections.d.ts +6 -0
  58. package/dist/ports/projections.d.ts.map +1 -0
  59. package/dist/ports/projections.js +2 -0
  60. package/dist/ports/projections.js.map +1 -0
  61. package/dist/ports/repository.d.ts +31 -0
  62. package/dist/ports/repository.d.ts.map +1 -0
  63. package/dist/ports/repository.js +2 -0
  64. package/dist/ports/repository.js.map +1 -0
  65. package/dist/projections.d.ts +24 -0
  66. package/dist/projections.d.ts.map +1 -0
  67. package/dist/projections.js +531 -0
  68. package/dist/projections.js.map +1 -0
  69. package/dist/remote.d.ts +120 -0
  70. package/dist/remote.d.ts.map +1 -0
  71. package/dist/remote.js +286 -0
  72. package/dist/remote.js.map +1 -0
  73. package/dist/schemas.d.ts +901 -0
  74. package/dist/schemas.d.ts.map +1 -0
  75. package/dist/schemas.js +424 -0
  76. package/dist/schemas.js.map +1 -0
  77. package/dist/service.d.ts +126 -0
  78. package/dist/service.d.ts.map +1 -0
  79. package/dist/service.js +2 -0
  80. package/dist/service.js.map +1 -0
  81. package/dist/skill-install.d.ts +34 -0
  82. package/dist/skill-install.d.ts.map +1 -0
  83. package/dist/skill-install.js +256 -0
  84. package/dist/skill-install.js.map +1 -0
  85. package/dist/storage.d.ts +94 -0
  86. package/dist/storage.d.ts.map +1 -0
  87. package/dist/storage.js +1229 -0
  88. package/dist/storage.js.map +1 -0
  89. package/dist/types.d.ts +442 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +2 -0
  92. package/dist/types.js.map +1 -0
  93. package/dist/version.d.ts +3 -0
  94. package/dist/version.d.ts.map +1 -0
  95. package/dist/version.js +14 -0
  96. package/dist/version.js.map +1 -0
  97. package/docs/cli.md +180 -0
  98. package/docs/examples.md +70 -0
  99. package/docs/mcp.md +96 -0
  100. package/docs/recovery.md +45 -0
  101. package/docs/releasing.md +61 -0
  102. package/docs/skill.md +93 -0
  103. package/docs/storage-format.md +83 -0
  104. package/openapi/synomem-v1.yaml +674 -0
  105. package/package.json +100 -0
  106. package/skills/synomem/SKILL.md +71 -0
  107. package/skills/synomem/agents/openai.yaml +8 -0
  108. package/skills/synomem/references/examples.md +28 -0
  109. package/src/backend.ts +91 -0
  110. package/src/cli.ts +1623 -0
  111. package/src/client.ts +1261 -0
  112. package/src/config.ts +170 -0
  113. package/src/credentials.ts +194 -0
  114. package/src/errors.ts +64 -0
  115. package/src/fs-utils.ts +85 -0
  116. package/src/import.ts +284 -0
  117. package/src/index.ts +61 -0
  118. package/src/mcp/index.ts +973 -0
  119. package/src/mcp-server.ts +46 -0
  120. package/src/oauth.ts +252 -0
  121. package/src/ports/projections.ts +6 -0
  122. package/src/ports/repository.ts +67 -0
  123. package/src/projections.ts +624 -0
  124. package/src/remote.ts +516 -0
  125. package/src/schemas.ts +457 -0
  126. package/src/service.ts +140 -0
  127. package/src/skill-install.ts +332 -0
  128. package/src/storage.ts +1603 -0
  129. package/src/types.ts +458 -0
  130. package/src/version.ts +16 -0
package/src/remote.ts ADDED
@@ -0,0 +1,516 @@
1
+ import { actorSchema } from './schemas.js';
2
+ import { asSynomemError, errorCodes, SynomemError, type SynomemErrorCode } from './errors.js';
3
+ import type { SynomemService, SynomemServiceCapabilities, SynomemServiceInfo } from './service.js';
4
+ import type {
5
+ ActorIdentity,
6
+ ChangesInput,
7
+ CreateAgentInput,
8
+ CreateNoteInput,
9
+ CreateTodoInput,
10
+ GiveKudosInput,
11
+ ItemListInput,
12
+ KudosListInput,
13
+ ReviseNoteInput,
14
+ SendMemoInput,
15
+ UpdateAgentInput,
16
+ UpdateTodoInput,
17
+ } from './types.js';
18
+
19
+ const defaultMaximumResponseBytes = 1024 * 1024;
20
+ const defaultTimeoutMs = 15_000;
21
+
22
+ export interface SynomemCredentialProvider {
23
+ getAccessToken(signal?: AbortSignal): Promise<string | undefined>;
24
+ }
25
+
26
+ export interface RemoteSynomemOptions {
27
+ baseUrl: string;
28
+ workspaceId: string;
29
+ expectedActor: ActorIdentity;
30
+ credentialProvider: SynomemCredentialProvider;
31
+ fetch?: typeof fetch;
32
+ signal?: AbortSignal;
33
+ timeoutMs?: number;
34
+ maximumResponseBytes?: number;
35
+ }
36
+
37
+ interface ApiErrorEnvelope {
38
+ ok: false;
39
+ error: { code: string; message: string; details?: Record<string, unknown>; requestId?: string };
40
+ }
41
+
42
+ interface ApiSuccessEnvelope<T> {
43
+ ok: true;
44
+ data: T;
45
+ requestId?: string;
46
+ }
47
+
48
+ function remoteBaseUrl(value: string): URL {
49
+ let parsed: URL;
50
+ try {
51
+ parsed = new URL(value);
52
+ } catch {
53
+ throw new SynomemError('CONFIG_INVALID', 'Remote Synomem baseUrl must be an absolute URL.');
54
+ }
55
+ const loopback =
56
+ parsed.hostname === 'localhost' ||
57
+ parsed.hostname === '127.0.0.1' ||
58
+ parsed.hostname === '[::1]';
59
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
60
+ throw new SynomemError(
61
+ 'CONFIG_INVALID',
62
+ 'Remote Synomem requires HTTPS except for explicit loopback development URLs.',
63
+ );
64
+ }
65
+ if (
66
+ parsed.username ||
67
+ parsed.password ||
68
+ parsed.search ||
69
+ parsed.hash ||
70
+ parsed.pathname !== '/'
71
+ ) {
72
+ throw new SynomemError(
73
+ 'CONFIG_INVALID',
74
+ 'Remote Synomem baseUrl cannot contain credentials, paths, query parameters, or fragments.',
75
+ );
76
+ }
77
+ return new URL(parsed.href.endsWith('/') ? parsed.href : `${parsed.href}/`);
78
+ }
79
+
80
+ function queryString(input: object): string {
81
+ const parameters = new URLSearchParams();
82
+ for (const [key, value] of Object.entries(input)) {
83
+ if (value === undefined) continue;
84
+ if (Array.isArray(value)) {
85
+ for (const item of value) parameters.append(key, String(item));
86
+ } else {
87
+ parameters.set(key, String(value));
88
+ }
89
+ }
90
+ const encoded = parameters.toString();
91
+ return encoded ? `?${encoded}` : '';
92
+ }
93
+
94
+ function knownErrorCode(value: string): SynomemErrorCode {
95
+ return errorCodes.some((code) => code === value)
96
+ ? (value as SynomemErrorCode)
97
+ : 'REMOTE_PROTOCOL';
98
+ }
99
+
100
+ async function boundedResponseBytes(response: Response, maximumBytes: number): Promise<Uint8Array> {
101
+ if (!response.body) return new Uint8Array();
102
+ const reader = response.body.getReader() as unknown as {
103
+ read(): Promise<{ done: boolean; value?: Uint8Array }>;
104
+ cancel(): Promise<void>;
105
+ };
106
+ const chunks: Uint8Array[] = [];
107
+ let length = 0;
108
+ while (true) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ if (!value) {
112
+ throw new SynomemError('REMOTE_PROTOCOL', 'Remote response stream was malformed.');
113
+ }
114
+ length += value.byteLength;
115
+ if (length > maximumBytes) {
116
+ await reader.cancel();
117
+ throw new SynomemError('REMOTE_PROTOCOL', 'Remote response exceeded the configured limit.');
118
+ }
119
+ chunks.push(value);
120
+ }
121
+ const bytes = new Uint8Array(length);
122
+ let offset = 0;
123
+ for (const chunk of chunks) {
124
+ bytes.set(chunk, offset);
125
+ offset += chunk.byteLength;
126
+ }
127
+ return bytes;
128
+ }
129
+
130
+ export function environmentCredentialProvider(
131
+ env: NodeJS.ProcessEnv = process.env,
132
+ ): SynomemCredentialProvider {
133
+ return {
134
+ async getAccessToken() {
135
+ return env.SYNOMEM_ACCESS_TOKEN;
136
+ },
137
+ };
138
+ }
139
+
140
+ export class RemoteSynomemService implements SynomemService {
141
+ readonly actor: ActorIdentity;
142
+ private readonly baseUrl: URL;
143
+ private readonly workspaceId: string;
144
+ private readonly credentialProvider: SynomemCredentialProvider;
145
+ private readonly fetchImplementation: typeof fetch;
146
+ private readonly signal?: AbortSignal;
147
+ private readonly timeoutMs: number;
148
+ private readonly maximumResponseBytes: number;
149
+ private initialized = false;
150
+ private cachedCapabilities?: SynomemServiceCapabilities;
151
+
152
+ readonly agents = {
153
+ create: (input: CreateAgentInput) =>
154
+ this.request<Awaited<ReturnType<SynomemService['agents']['create']>>>(
155
+ 'POST',
156
+ 'agents',
157
+ input,
158
+ ),
159
+ update: (id: string, changes: UpdateAgentInput) =>
160
+ this.request<Awaited<ReturnType<SynomemService['agents']['update']>>>(
161
+ 'PATCH',
162
+ `agents/${encodeURIComponent(id)}`,
163
+ changes,
164
+ ),
165
+ get: (idOrAlias: string) =>
166
+ this.request<Awaited<ReturnType<SynomemService['agents']['get']>>>(
167
+ 'GET',
168
+ `agents/${encodeURIComponent(idOrAlias)}`,
169
+ ),
170
+ list: () =>
171
+ this.request<Awaited<ReturnType<SynomemService['agents']['list']>>>('GET', 'agents'),
172
+ };
173
+
174
+ readonly kudos = {
175
+ give: (input: GiveKudosInput) =>
176
+ this.mutation<Awaited<ReturnType<SynomemService['kudos']['give']>>>('POST', 'kudos', input),
177
+ list: (input: KudosListInput = {}) =>
178
+ this.request<Awaited<ReturnType<SynomemService['kudos']['list']>>>(
179
+ 'GET',
180
+ `kudos${queryString(input)}`,
181
+ ),
182
+ changes: (input: ChangesInput = {}) =>
183
+ this.request<Awaited<ReturnType<SynomemService['kudos']['changes']>>>(
184
+ 'GET',
185
+ `kudos/changes${queryString(input)}`,
186
+ ),
187
+ get: (id: string) =>
188
+ this.request<Awaited<ReturnType<SynomemService['kudos']['get']>>>(
189
+ 'GET',
190
+ `kudos/${encodeURIComponent(id)}`,
191
+ ),
192
+ acknowledge: (input: { kudosId: string; note?: string }) =>
193
+ this.request<Awaited<ReturnType<SynomemService['kudos']['acknowledge']>>>(
194
+ 'POST',
195
+ `kudos/${encodeURIComponent(input.kudosId)}/acknowledgment`,
196
+ input.note === undefined ? {} : { note: input.note },
197
+ ),
198
+ revoke: (input: { kudosId: string; reason: string; administrative?: boolean }) =>
199
+ this.request<Awaited<ReturnType<SynomemService['kudos']['revoke']>>>(
200
+ 'POST',
201
+ `kudos/${encodeURIComponent(input.kudosId)}/revocation`,
202
+ { reason: input.reason },
203
+ ),
204
+ };
205
+
206
+ readonly memos = {
207
+ send: (input: SendMemoInput) =>
208
+ this.mutation<Awaited<ReturnType<SynomemService['memos']['send']>>>('POST', 'memos', input),
209
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
210
+ this.request<Awaited<ReturnType<SynomemService['memos']['list']>>>(
211
+ 'GET',
212
+ `memos${queryString(input)}`,
213
+ ),
214
+ get: (id: string) =>
215
+ this.request<Awaited<ReturnType<SynomemService['memos']['get']>>>(
216
+ 'GET',
217
+ `memos/${encodeURIComponent(id)}`,
218
+ ),
219
+ read: (input: { memoId: string; idempotencyKey?: string }) =>
220
+ this.mutation<Awaited<ReturnType<SynomemService['memos']['read']>>>(
221
+ 'POST',
222
+ `memos/${encodeURIComponent(input.memoId)}/read`,
223
+ input,
224
+ ['memoId'],
225
+ ),
226
+ archive: (input: { memoId: string; idempotencyKey?: string }) =>
227
+ this.mutation<Awaited<ReturnType<SynomemService['memos']['archive']>>>(
228
+ 'POST',
229
+ `memos/${encodeURIComponent(input.memoId)}/archive`,
230
+ input,
231
+ ['memoId'],
232
+ ),
233
+ };
234
+
235
+ readonly notes = {
236
+ create: (input: CreateNoteInput) =>
237
+ this.mutation<Awaited<ReturnType<SynomemService['notes']['create']>>>('POST', 'notes', input),
238
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
239
+ this.request<Awaited<ReturnType<SynomemService['notes']['list']>>>(
240
+ 'GET',
241
+ `notes${queryString(input)}`,
242
+ ),
243
+ get: (id: string) =>
244
+ this.request<Awaited<ReturnType<SynomemService['notes']['get']>>>(
245
+ 'GET',
246
+ `notes/${encodeURIComponent(id)}`,
247
+ ),
248
+ revise: (input: ReviseNoteInput) =>
249
+ this.mutation<Awaited<ReturnType<SynomemService['notes']['revise']>>>(
250
+ 'POST',
251
+ `notes/${encodeURIComponent(input.noteId)}/revisions`,
252
+ input,
253
+ ['noteId'],
254
+ ),
255
+ archive: (input: { noteId: string; idempotencyKey?: string }) =>
256
+ this.mutation<Awaited<ReturnType<SynomemService['notes']['archive']>>>(
257
+ 'POST',
258
+ `notes/${encodeURIComponent(input.noteId)}/archive`,
259
+ input,
260
+ ['noteId'],
261
+ ),
262
+ };
263
+
264
+ readonly todos = {
265
+ create: (input: CreateTodoInput) =>
266
+ this.mutation<Awaited<ReturnType<SynomemService['todos']['create']>>>('POST', 'todos', input),
267
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
268
+ this.request<Awaited<ReturnType<SynomemService['todos']['list']>>>(
269
+ 'GET',
270
+ `todos${queryString(input)}`,
271
+ ),
272
+ get: (id: string) =>
273
+ this.request<Awaited<ReturnType<SynomemService['todos']['get']>>>(
274
+ 'GET',
275
+ `todos/${encodeURIComponent(id)}`,
276
+ ),
277
+ update: (input: UpdateTodoInput) =>
278
+ this.mutation<Awaited<ReturnType<SynomemService['todos']['update']>>>(
279
+ 'POST',
280
+ `todos/${encodeURIComponent(input.todoId)}/revisions`,
281
+ input,
282
+ ['todoId'],
283
+ ),
284
+ accept: (input: { todoId: string; idempotencyKey?: string }) =>
285
+ this.todoTransition('accept', input),
286
+ reject: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
287
+ this.todoTransition('reject', input),
288
+ complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
289
+ this.todoTransition('complete', input),
290
+ reopen: (input: { todoId: string; idempotencyKey?: string }) =>
291
+ this.todoTransition('reopen', input),
292
+ cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
293
+ this.todoTransition('cancel', input),
294
+ };
295
+
296
+ readonly items = {
297
+ list: (input: ItemListInput = {}) =>
298
+ this.request<Awaited<ReturnType<SynomemService['items']['list']>>>(
299
+ 'GET',
300
+ `items${queryString(input)}`,
301
+ ),
302
+ get: (id: string) =>
303
+ this.request<Awaited<ReturnType<SynomemService['items']['get']>>>(
304
+ 'GET',
305
+ `items/${encodeURIComponent(id)}`,
306
+ ),
307
+ changes: (input: ChangesInput = {}) =>
308
+ this.request<Awaited<ReturnType<SynomemService['items']['changes']>>>(
309
+ 'GET',
310
+ `changes${queryString(input)}`,
311
+ ),
312
+ };
313
+
314
+ constructor(options: RemoteSynomemOptions) {
315
+ this.baseUrl = remoteBaseUrl(options.baseUrl);
316
+ if (!options.workspaceId.trim() || options.workspaceId.length > 100) {
317
+ throw new SynomemError('CONFIG_INVALID', 'Remote Synomem workspaceId is required.');
318
+ }
319
+ this.workspaceId = options.workspaceId;
320
+ try {
321
+ this.actor = actorSchema.parse(options.expectedActor);
322
+ } catch (error) {
323
+ throw asSynomemError(error);
324
+ }
325
+ this.credentialProvider = options.credentialProvider;
326
+ this.fetchImplementation = options.fetch ?? fetch;
327
+ this.signal = options.signal;
328
+ this.timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
329
+ this.maximumResponseBytes = options.maximumResponseBytes ?? defaultMaximumResponseBytes;
330
+ if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) {
331
+ throw new SynomemError('CONFIG_INVALID', 'Remote timeoutMs must be a positive integer.');
332
+ }
333
+ if (!Number.isSafeInteger(this.maximumResponseBytes) || this.maximumResponseBytes < 1024) {
334
+ throw new SynomemError(
335
+ 'CONFIG_INVALID',
336
+ 'Remote maximumResponseBytes must be an integer of at least 1024.',
337
+ );
338
+ }
339
+ }
340
+
341
+ async init(): Promise<void> {
342
+ if (this.initialized) return;
343
+ this.cachedCapabilities = await this.request<SynomemServiceCapabilities>(
344
+ 'GET',
345
+ '../../capabilities',
346
+ );
347
+ if (this.cachedCapabilities.backend !== 'remote') {
348
+ throw new SynomemError('REMOTE_PROTOCOL', 'The configured server is not a remote backend.');
349
+ }
350
+ const binding = this.cachedCapabilities.binding;
351
+ if (
352
+ binding.workspaceId !== this.workspaceId ||
353
+ binding.actor.kind !== this.actor.kind ||
354
+ binding.actor.id !== this.actor.id
355
+ ) {
356
+ throw new SynomemError(
357
+ 'AUTH_FORBIDDEN',
358
+ 'The authenticated Synomem actor does not match the configured actor.',
359
+ );
360
+ }
361
+ this.initialized = true;
362
+ }
363
+
364
+ async close(): Promise<void> {
365
+ this.initialized = false;
366
+ }
367
+
368
+ stats(input: KudosListInput = {}) {
369
+ return this.request<Awaited<ReturnType<SynomemService['stats']>>>(
370
+ 'GET',
371
+ `kudos/stats${queryString(input)}`,
372
+ );
373
+ }
374
+
375
+ doctor() {
376
+ return this.request<Awaited<ReturnType<SynomemService['doctor']>>>('GET', 'diagnostics');
377
+ }
378
+
379
+ async export(format: 'json' | 'jsonl' | 'markdown'): Promise<string> {
380
+ const result = await this.request<{ content: string }>(
381
+ 'GET',
382
+ `export${queryString({ format })}`,
383
+ );
384
+ return result.content;
385
+ }
386
+
387
+ rebuild() {
388
+ return this.request<Awaited<ReturnType<SynomemService['rebuild']>>>(
389
+ 'POST',
390
+ 'administration/rebuild',
391
+ {},
392
+ );
393
+ }
394
+
395
+ async capabilities(): Promise<SynomemServiceCapabilities> {
396
+ return (
397
+ this.cachedCapabilities ??
398
+ (await this.request<SynomemServiceCapabilities>('GET', '../../capabilities'))
399
+ );
400
+ }
401
+
402
+ async info(): Promise<SynomemServiceInfo> {
403
+ return { backend: 'remote', baseUrl: this.baseUrl.href, workspaceId: this.workspaceId };
404
+ }
405
+
406
+ getCanonicalEvent(id: string) {
407
+ return this.request<Awaited<ReturnType<SynomemService['getCanonicalEvent']>>>(
408
+ 'GET',
409
+ `events/${encodeURIComponent(id)}`,
410
+ );
411
+ }
412
+
413
+ private todoTransition(
414
+ transition: 'accept' | 'reject' | 'complete' | 'reopen' | 'cancel',
415
+ input: { todoId: string; idempotencyKey?: string; reason?: string; note?: string },
416
+ ) {
417
+ return this.mutation<Awaited<ReturnType<SynomemService['todos']['accept']>>>(
418
+ 'POST',
419
+ `todos/${encodeURIComponent(input.todoId)}/${transition}`,
420
+ input,
421
+ ['todoId'],
422
+ );
423
+ }
424
+
425
+ private mutation<T>(
426
+ method: 'POST' | 'PATCH',
427
+ path: string,
428
+ input: object,
429
+ omittedKeys: string[] = [],
430
+ ): Promise<T> {
431
+ const body = { ...input } as Record<string, unknown>;
432
+ const idempotencyKey =
433
+ typeof body.idempotencyKey === 'string' ? body.idempotencyKey : undefined;
434
+ delete body.idempotencyKey;
435
+ for (const key of omittedKeys) delete body[key];
436
+ return this.request<T>(method, path, body, idempotencyKey);
437
+ }
438
+
439
+ private async request<T>(
440
+ method: 'GET' | 'POST' | 'PATCH',
441
+ path: string,
442
+ body?: object,
443
+ idempotencyKey?: string,
444
+ ): Promise<T> {
445
+ const accessToken = await this.credentialProvider.getAccessToken(this.signal);
446
+ if (!accessToken) {
447
+ throw new SynomemError('AUTH_REQUIRED', 'Remote Synomem authentication is required.');
448
+ }
449
+ const workspaceBase = new URL(
450
+ `v1/workspaces/${encodeURIComponent(this.workspaceId)}/`,
451
+ this.baseUrl,
452
+ );
453
+ const url = new URL(path, workspaceBase);
454
+ if (url.origin !== this.baseUrl.origin) {
455
+ throw new SynomemError('CONFIG_INVALID', 'Remote request escaped the configured origin.');
456
+ }
457
+ const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
458
+ const signal = this.signal ? AbortSignal.any([this.signal, timeoutSignal]) : timeoutSignal;
459
+ let response: Response;
460
+ try {
461
+ response = await this.fetchImplementation(url, {
462
+ method,
463
+ redirect: 'manual',
464
+ signal,
465
+ headers: {
466
+ accept: 'application/json',
467
+ authorization: `Bearer ${accessToken}`,
468
+ ...(body ? { 'content-type': 'application/json' } : {}),
469
+ ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
470
+ },
471
+ ...(body ? { body: JSON.stringify(body) } : {}),
472
+ });
473
+ } catch (error) {
474
+ if (this.signal?.aborted) throw error;
475
+ throw new SynomemError('REMOTE_UNAVAILABLE', 'The remote Synomem service is unavailable.', {
476
+ cause: error instanceof Error ? error.name : 'network_error',
477
+ });
478
+ }
479
+ if (response.status >= 300 && response.status < 400) {
480
+ throw new SynomemError('REMOTE_PROTOCOL', 'Remote redirects are not followed.');
481
+ }
482
+ const declaredLength = Number(response.headers.get('content-length') ?? 0);
483
+ if (declaredLength > this.maximumResponseBytes) {
484
+ throw new SynomemError('REMOTE_PROTOCOL', 'Remote response exceeded the configured limit.');
485
+ }
486
+ const bytes = await boundedResponseBytes(response, this.maximumResponseBytes);
487
+ let envelope: ApiSuccessEnvelope<T> | ApiErrorEnvelope;
488
+ try {
489
+ envelope = JSON.parse(new TextDecoder().decode(bytes)) as
490
+ ApiSuccessEnvelope<T> | ApiErrorEnvelope;
491
+ } catch {
492
+ throw new SynomemError('REMOTE_PROTOCOL', 'Remote Synomem returned invalid JSON.');
493
+ }
494
+ if (!response.ok || envelope.ok !== true) {
495
+ if (envelope.ok !== false || !envelope.error || typeof envelope.error.message !== 'string') {
496
+ throw new SynomemError('REMOTE_PROTOCOL', 'Remote Synomem returned an invalid error.');
497
+ }
498
+ const code =
499
+ response.status === 401
500
+ ? 'AUTH_REQUIRED'
501
+ : response.status === 403
502
+ ? 'AUTH_FORBIDDEN'
503
+ : response.status === 429
504
+ ? 'RATE_LIMITED'
505
+ : knownErrorCode(envelope.error.code);
506
+ throw new SynomemError(code, envelope.error.message, {
507
+ ...(envelope.error.details ?? {}),
508
+ ...(envelope.error.requestId ? { requestId: envelope.error.requestId } : {}),
509
+ });
510
+ }
511
+ if (!('data' in envelope)) {
512
+ throw new SynomemError('REMOTE_PROTOCOL', 'Remote Synomem response omitted data.');
513
+ }
514
+ return envelope.data;
515
+ }
516
+ }