@openparachute/vault 0.7.0-rc.8 → 0.7.0-rc.9

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.
@@ -306,6 +306,50 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
306
306
  return { unresolved, count: total };
307
307
  }
308
308
 
309
+ /** One note's dangling outbound link, as surfaced on a note read (vault#555). */
310
+ export interface BrokenLink {
311
+ target: string;
312
+ relationship: string;
313
+ }
314
+
315
+ /**
316
+ * Batch-fetch each note's pending `unresolved_wikilinks` rows — the
317
+ * `include_broken_links` surfacing on `query-notes` / `GET /notes`. ONE
318
+ * query for the whole page (mirrors `getLinksHydratedForNotes`'s batching),
319
+ * not one per note. Returns a map with an entry (possibly `[]`) for every
320
+ * requested id whenever the table exists; when the table has never been
321
+ * created (no note in this vault has ever had a broken link) every id maps
322
+ * to `[]` without a query attempt.
323
+ */
324
+ export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map<string, BrokenLink[]> {
325
+ const result = new Map<string, BrokenLink[]>(noteIds.map((id) => [id, []]));
326
+ if (noteIds.length === 0) return result;
327
+
328
+ const rows: { source_id: string; target_path: string; relationship: string }[] = [];
329
+ try {
330
+ ensureRelationshipColumn(db);
331
+ for (const chunk of chunkForInClause(noteIds)) {
332
+ const placeholders = chunk.map(() => "?").join(", ");
333
+ rows.push(...db.prepare(
334
+ `SELECT source_id, target_path, relationship FROM unresolved_wikilinks WHERE source_id IN (${placeholders})`,
335
+ ).all(...chunk) as typeof rows);
336
+ }
337
+ } catch {
338
+ // Table doesn't exist — every id already maps to [] above.
339
+ return result;
340
+ }
341
+
342
+ for (const row of rows) {
343
+ result.get(row.source_id)?.push({ target: row.target_path, relationship: row.relationship || WIKILINK_REL });
344
+ }
345
+ return result;
346
+ }
347
+
348
+ /** Single-note convenience wrapper around {@link getUnresolvedLinksForNotes}. */
349
+ export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenLink[] {
350
+ return getUnresolvedLinksForNotes(db, [noteId]).get(noteId) ?? [];
351
+ }
352
+
309
353
  // ---------------------------------------------------------------------------
310
354
  // Sync — maintain wikilink-based links for a note
311
355
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.8",
3
+ "version": "0.7.0-rc.9",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/mcp-tools.ts CHANGED
@@ -174,6 +174,20 @@ export function generateScopedMcpTools(
174
174
  )
175
175
  : undefined;
176
176
 
177
+ // Tag-scope guard for `create-note` `if_exists` (vault#555 auth-review
178
+ // must-fix): the core `if_exists` upsert resolves the target path VAULT-WIDE
179
+ // and returns/updates/replaces the found note, so a scoped caller could
180
+ // read/overwrite an out-of-scope note by naming its path. Inject a
181
+ // visibility predicate the core `applyExistingNote` consults on the RESOLVED
182
+ // existing note — covering BOTH the proactive check AND the concurrent-INSERT
183
+ // race backstop (a wrapper-only pre-check misses the latter). Reads from the
184
+ // SAME shared `allowedHolder` the create-note wrapper's `getAllowed()`
185
+ // populates before core's execute runs. Unscoped sessions install no
186
+ // predicate (unchanged). Same closure as `expandVisibility`.
187
+ const ifExistsVisible = scoped
188
+ ? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
189
+ : undefined;
190
+
177
191
  // Write-attribution (vault#298). Every write through an MCP session arrives
178
192
  // on the `mcp` channel — so we REFINE the auth's base `via` (the generic
179
193
  // credential class) to `mcp` here, where the path/channel is known. The
@@ -197,10 +211,11 @@ export function generateScopedMcpTools(
197
211
 
198
212
  const tools = generateMcpTools(
199
213
  store,
200
- expandVisibility || nearTraversable || writeContext || strictBypass
214
+ expandVisibility || nearTraversable || ifExistsVisible || writeContext || strictBypass
201
215
  ? {
202
216
  ...(expandVisibility ? { expandVisibility } : {}),
203
217
  ...(nearTraversable ? { nearTraversable } : {}),
218
+ ...(ifExistsVisible ? { ifExistsVisible } : {}),
204
219
  ...(writeContext ? { writeContext } : {}),
205
220
  ...(strictBypass ? { strictBypass } : {}),
206
221
  ...(onStrictBypass ? { onStrictBypass } : {}),
@@ -498,6 +513,13 @@ function applyTagScopeWrappers(
498
513
  return forbidden("create-note: every note must carry at least one tag in the token's allowlist");
499
514
  }
500
515
  }
516
+ // `if_exists` scope enforcement (vault#555 auth-review must-fix) is NOT
517
+ // done here as a wrapper pre-check — that would miss core's concurrent-
518
+ // INSERT race backstop. Instead the `ifExistsVisible` predicate wired into
519
+ // generateMcpTools above fires INSIDE core's `applyExistingNote`, covering
520
+ // both the proactive site and the race-backstop site with one guard. The
521
+ // `await getAllowed()` at the top of this wrapper populates the shared
522
+ // `allowedHolder` the predicate reads, before core's execute runs.
501
523
  return await orig(params);
502
524
  });
503
525
 
@@ -825,8 +847,8 @@ function buildManageTokenTool(
825
847
  ":admin'. List + revoke are scoped to tokens this session minted; " +
826
848
  "CLI/REST-minted tokens are not surfaced here.\n\n" +
827
849
  "Actions (discriminator: `action`):\n" +
828
- "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
829
- "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
850
+ "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
851
+ "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean, already_revoked?: boolean } — idempotent; a jti not in this session's ledger, or already revoked, still returns ok:true. A genuine failure additionally carries error/message (and, for a hub-side rejection, hub_status).\n" +
830
852
  "- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
831
853
  inputSchema: {
832
854
  type: "object",
package/src/routes.ts CHANGED
@@ -22,9 +22,16 @@ import {
22
22
  type QueryWarning,
23
23
  } from "../core/src/query-warnings.ts";
24
24
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
25
- import { listUnresolvedWikilinks, resolveOrQueueLink, resolveStructuredLinkNote } from "../core/src/wikilinks.ts";
25
+ import {
26
+ listUnresolvedWikilinks,
27
+ resolveOrQueueLink,
28
+ resolveStructuredLinkNote,
29
+ getUnresolvedLinksForNote,
30
+ getUnresolvedLinksForNotes,
31
+ } from "../core/src/wikilinks.ts";
26
32
  import { transactionAsync } from "../core/src/txn.ts";
27
- import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
33
+ import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError } from "../core/src/notes.ts";
34
+ import { normalizePath } from "../core/src/paths.ts";
28
35
  import {
29
36
  parseContentRange,
30
37
  applyContentRange,
@@ -812,6 +819,9 @@ export function parseNotesQueryOpts(url: URL): {
812
819
  excludeTags: parseQueryList(url, "exclude_tag"),
813
820
  hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
814
821
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
822
+ // Presence filter on dangling outbound wikilinks/structured links
823
+ // (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
824
+ hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
815
825
  path: parseQuery(url, "path") ?? undefined,
816
826
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
817
827
  extension: parseExtensionFilter(url),
@@ -1017,6 +1027,13 @@ async function handleNotesInner(
1017
1027
  tagScope.raw,
1018
1028
  );
1019
1029
  }
1030
+ if (parseBool(parseQuery(url, "include_broken_links"), false)) {
1031
+ // No tag-scope filtering needed (unlike include_links above): a
1032
+ // broken-link `target` never resolved to a note, so there's no
1033
+ // neighbor identity/content to leak — just the string this note's
1034
+ // own [[wikilink]]/structured link already named.
1035
+ result.broken_links = getUnresolvedLinksForNote(db, note.id);
1036
+ }
1020
1037
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
1021
1038
  result.attachments = await store.getAttachments(note.id);
1022
1039
  }
@@ -1336,6 +1353,7 @@ async function handleNotesInner(
1336
1353
  const contentRange = parseContentRangeQuery(url, includeContent);
1337
1354
  if (contentRange.error) return contentRange.error;
1338
1355
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
1356
+ const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
1339
1357
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
1340
1358
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
1341
1359
  const inclMeta = parseIncludeMetadata(url);
@@ -1427,13 +1445,19 @@ async function handleNotesInner(
1427
1445
  );
1428
1446
  }
1429
1447
 
1430
- if (includeLinks || includeAttachments) {
1448
+ if (includeLinks || includeBrokenLinks || includeAttachments) {
1431
1449
  // Whole-page link hydration in a constant number of queries — the
1432
1450
  // per-note variant cost (1 link query + 1 summary query + N tag
1433
1451
  // queries) × page size. 2026-06-10 perf measurements.
1434
1452
  const linksByNote = includeLinks
1435
1453
  ? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
1436
1454
  : null;
1455
+ // Same batched-per-page shape as links (vault#555). No tag-scope
1456
+ // filtering needed — a broken-link `target` never resolved to a
1457
+ // note, so there's no neighbor identity to leak.
1458
+ const brokenLinksByNote = includeBrokenLinks
1459
+ ? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
1460
+ : null;
1437
1461
  const enrichedOut: any[] = [];
1438
1462
  for (const n of output) {
1439
1463
  const enriched: any = { ...n };
@@ -1445,6 +1469,7 @@ async function handleNotesInner(
1445
1469
  tagScope.raw,
1446
1470
  );
1447
1471
  }
1472
+ if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
1448
1473
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
1449
1474
  enrichedOut.push(enriched);
1450
1475
  }
@@ -1512,13 +1537,103 @@ async function handleNotesInner(
1512
1537
  // layers share `resolveOrQueueLink` from core/wikilinks.ts).
1513
1538
  const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
1514
1539
  const linkWarningsByNote = new Map<string, QueryWarning[]>();
1540
+ // `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
1541
+ // create-note tool exactly (same contract, independent REST-layer
1542
+ // reimplementation sharing the same core primitives). See that file's
1543
+ // doc comments for the full rationale.
1544
+ const existedMap = new Map<string, boolean>();
1545
+ const noMutateIds = new Set<string>();
1546
+ const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
1547
+ existedMap.set(resultNote.id, true);
1548
+ if (noMutate) noMutateIds.add(resultNote.id);
1549
+ created.push(resultNote);
1550
+ };
1551
+ const applyExistingNote = async (
1552
+ item: any,
1553
+ existingNote: Note,
1554
+ mode: "ignore" | "update" | "replace",
1555
+ ): Promise<{ note: Note; noMutate: boolean }> => {
1556
+ // CRITICAL scope guard (vault#555 auth-review must-fix): `existingNote`
1557
+ // was resolved VAULT-WIDE by path (store.getNoteByPath) at BOTH call
1558
+ // sites below (proactive + race-backstop). The batch's incoming-tag
1559
+ // pre-validation guards only each item's OWN tags — NOT this resolved
1560
+ // note — so without this a token scoped to `public` could READ (ignore)
1561
+ // or OVERWRITE (update/replace) a note carrying only an out-of-scope
1562
+ // tag `secret` just by naming its path. When the note is outside the
1563
+ // caller's tag scope, treat it as a path conflict — the path is taken,
1564
+ // but invisible to this caller — throwing BEFORE any read/mutation so
1565
+ // we never expose content or write. `noteWithinTagScope` is a no-op for
1566
+ // unscoped tokens (tagScope.raw === null → always true). The throw
1567
+ // propagates to runBatch's PATH_CONFLICT catch → 409 path_conflict,
1568
+ // byte-identical to a genuine conflict (leaks nothing about WHY).
1569
+ if (!noteWithinTagScope(existingNote, tagScope.allowed, tagScope.raw)) {
1570
+ throw new PathConflictError(existingNote.path ?? "");
1571
+ }
1572
+ if (mode === "ignore") {
1573
+ return { note: existingNote, noMutate: true };
1574
+ }
1575
+ const updates: { content?: string; metadata?: Record<string, unknown> } = {};
1576
+ if (mode === "update") {
1577
+ if (item.content !== undefined) updates.content = item.content;
1578
+ if (item.metadata !== undefined) {
1579
+ updates.metadata = mergeMetadata(
1580
+ existingNote.metadata as Record<string, unknown> | null | undefined,
1581
+ item.metadata,
1582
+ );
1583
+ }
1584
+ } else {
1585
+ // "replace" — PUT semantics: content/metadata become exactly this
1586
+ // payload (empty default when omitted), never merged.
1587
+ updates.content = item.content ?? "";
1588
+ updates.metadata = item.metadata ?? {};
1589
+ }
1590
+
1591
+ const incomingTags: string[] = item.tags ?? [];
1592
+ const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
1593
+ const projectedMetadata = updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
1594
+ gateStrictWrite(store, writeCtx, { path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
1595
+
1596
+ // vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): gate the
1597
+ // core UPDATE on the tag/link mutation too, not just `updates` — a
1598
+ // tags-only/links-only "update" leaves `updates` empty, and skipping
1599
+ // store.updateNote would freeze `updated_at` even though store.tagNote
1600
+ // genuinely mutates the note (breaking cursor polling + updated_at
1601
+ // sync). Mirrors the update-note/PATCH hasTagMutation||hasLinkMutation
1602
+ // gate. "replace" always sets content+metadata, so it was unaffected.
1603
+ const hasLinkMutation = item.links !== undefined;
1604
+ let result: Note = existingNote;
1605
+ if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
1606
+ result = await store.updateNote(existingNote.id, {
1607
+ ...updates,
1608
+ actor: writeCtx.actor,
1609
+ via: writeCtx.via,
1610
+ });
1611
+ }
1612
+
1613
+ if (incomingTags.length > 0) {
1614
+ await store.tagNote(result.id, incomingTags);
1615
+ // Redundant with the outer batch loop's applySchemaDefaults (this
1616
+ // note isn't in `noMutateIds` — only "ignore" hits are), but harmless
1617
+ // (idempotent) — kept so the update/replace branch is self-contained.
1618
+ await applySchemaDefaults(store, db, [result.id], incomingTags);
1619
+ }
1620
+
1621
+ if (item.links) {
1622
+ pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
1623
+ }
1624
+
1625
+ return { note: (await store.getNote(result.id)) ?? result, noMutate: false };
1626
+ };
1627
+
1515
1628
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
1516
1629
  // failure (path conflict, etc.) rolls back every prior insert. Without
1517
1630
  // this, callers got half-applied batches where the prefix landed and
1518
1631
  // the offending entry surfaced the 409 — see #236. Single-item posts
1519
1632
  // are already atomic at the store layer and skip the wrap so they
1520
1633
  // don't collide with concurrent single-item callers on the shared
1521
- // bun:sqlite connection.
1634
+ // bun:sqlite connection. A caught PATH_CONFLICT (the if_exists race
1635
+ // backstop below) does NOT roll back the transaction — only a throw
1636
+ // that escapes `runBatch` does (see core/src/txn.ts).
1522
1637
  const batched = items.length > 1;
1523
1638
  const runBatch = async (): Promise<void> => {
1524
1639
  for (const item of items) {
@@ -1528,6 +1643,26 @@ async function handleNotesInner(
1528
1643
  const extension = item.extension !== undefined
1529
1644
  ? validateExtension(item.extension)
1530
1645
  : undefined;
1646
+ const effectiveExtension = extension ?? "md";
1647
+ const ifExists: string = item.if_exists ?? "error";
1648
+ const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
1649
+
1650
+ // Proactive existence check (vault#555) — only possible with a
1651
+ // path. Handles the common, non-racing case cleanly: skips the
1652
+ // raw-item strict gate below (it would validate the wrong shape —
1653
+ // see `applyExistingNote`) and goes straight to the collision
1654
+ // branch. The catch below is the backstop for a true race.
1655
+ let existingNote: Note | null = null;
1656
+ if (upsertMode && item.path) {
1657
+ const normalized = normalizePath(item.path);
1658
+ existingNote = normalized ? await store.getNoteByPath(normalized, effectiveExtension) : null;
1659
+ }
1660
+ if (existingNote) {
1661
+ const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
1662
+ recordExistedBranch(resultNote, noMutate);
1663
+ continue;
1664
+ }
1665
+
1531
1666
  // Strict-schema gate (vault#299) — reject before any write so a
1532
1667
  // mid-batch violation rolls back the batch transaction.
1533
1668
  gateStrictWrite(store, writeCtx, {
@@ -1535,17 +1670,36 @@ async function handleNotesInner(
1535
1670
  tags: item.tags,
1536
1671
  metadata: item.metadata,
1537
1672
  });
1538
- const note = await store.createNote(item.content ?? "", {
1539
- id: item.id,
1540
- path: item.path,
1541
- tags: item.tags,
1542
- metadata: item.metadata,
1543
- created_at: item.createdAt ?? item.created_at,
1544
- ...(extension !== undefined ? { extension } : {}),
1545
- // Write-attribution (vault#298) — REST batch create.
1546
- actor: writeCtx.actor,
1547
- via: writeCtx.via,
1548
- });
1673
+ let note: Note;
1674
+ try {
1675
+ note = await store.createNote(item.content ?? "", {
1676
+ id: item.id,
1677
+ path: item.path,
1678
+ tags: item.tags,
1679
+ metadata: item.metadata,
1680
+ created_at: item.createdAt ?? item.created_at,
1681
+ ...(extension !== undefined ? { extension } : {}),
1682
+ // Write-attribution (vault#298) — REST batch create.
1683
+ actor: writeCtx.actor,
1684
+ via: writeCtx.via,
1685
+ });
1686
+ } catch (err: any) {
1687
+ // Race backstop (vault#555): a concurrent writer's INSERT
1688
+ // committed between our proactive check and this one.
1689
+ // Re-resolve the now-existing row and fall through to the SAME
1690
+ // branch a proactive hit would have taken.
1691
+ if (upsertMode && err?.code === "PATH_CONFLICT") {
1692
+ const winner = await store.getNoteByPath(err.path, effectiveExtension);
1693
+ if (winner) {
1694
+ const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
1695
+ recordExistedBranch(resultNote, noMutate);
1696
+ continue;
1697
+ }
1698
+ }
1699
+ throw err;
1700
+ }
1701
+
1702
+ if (upsertMode) existedMap.set(note.id, false);
1549
1703
 
1550
1704
  if (item.links) {
1551
1705
  pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
@@ -1624,6 +1778,10 @@ async function handleNotesInner(
1624
1778
  // applied so the common no-defaults path adds zero extra reads.
1625
1779
  const mutatedIds = new Set<string>();
1626
1780
  for (const note of created) {
1781
+ // "ignore" hits (vault#555) promise ZERO mutation, including
1782
+ // schema-default backfill of a since-added default the existing
1783
+ // note predates — skip them entirely.
1784
+ if (noMutateIds.has(note.id)) continue;
1627
1785
  if (note.tags?.length) {
1628
1786
  for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
1629
1787
  mutatedIds.add(id);
@@ -1643,12 +1801,35 @@ async function handleNotesInner(
1643
1801
  // unchanged when no tag declares fields, so vaults without any tag
1644
1802
  // schemas see no behavior change. Fold in any `unresolved_link`
1645
1803
  // warnings (vault#555) — additive, present only when this note's
1646
- // `links` had a target that didn't resolve.
1804
+ // `links` had a target that didn't resolve. `existed` (vault#555) is
1805
+ // attached ONLY for items whose `if_exists` actually engaged — the
1806
+ // default "error" path's response shape is untouched.
1647
1807
  const final = refreshed.map((n) => {
1648
1808
  const validated = attachValidationStatus(store, db, n);
1649
1809
  const warnings = linkWarningsByNote.get(n.id);
1650
- return warnings && warnings.length > 0 ? { ...validated, warnings } : validated;
1810
+ const existed = existedMap.get(n.id);
1811
+ let out: any = validated;
1812
+ if (warnings && warnings.length > 0) out = { ...out, warnings };
1813
+ if (existed !== undefined) out = { ...out, existed };
1814
+ return out;
1651
1815
  });
1816
+
1817
+ // Batch summary (vault#555) — compact shape instead of N full note
1818
+ // objects. Batch-only (a `notes` array); `summary` is ignored on a
1819
+ // single-note POST. Mirrors the MCP create-note tool exactly.
1820
+ if (body.notes && body.summary === true) {
1821
+ return json(
1822
+ {
1823
+ created: final.filter((n: any) => n.existed !== true).length,
1824
+ ids: final.map((n: any) => n.id),
1825
+ // Reserved for future partial-batch-failure reporting — a batch
1826
+ // create is all-or-nothing today, so this is always empty.
1827
+ failed: [] as unknown[],
1828
+ },
1829
+ 201,
1830
+ );
1831
+ }
1832
+
1652
1833
  return json(body.notes ? final : final[0], 201);
1653
1834
  }
1654
1835