main · last commit
13 days ago ·
7g0stsfu
ah-gxa internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth)
Past Stand
bd reopen ah-gxa
| Created by | Eugene Blikh |
| Owner | bigbes@gmail.com |
| Created | 2026-07-18T15:03:36Z |
| Started | 2026-07-18T15:06:56Z |
| Updated | 2026-07-18T15:59:07Z |
| Closed | 2026-07-18T15:59:07Z |
Why: agenthubd integrates Yonote (Outline-fork docs product, live at bigbes.yonote.ru) in three slices — claim-time artifact export, finalize-time publishing, later Q&A. All need one light client. What: internal/yonote in the internal/mem0 style (New(baseURL, token, opts), ctx-first, typed *APIError, stdlib-only, httptest tests, doc.go pinning contract+quirks), scoped to exactly the integration surface: AuthInfo, DocumentInfo (uuid|urlId|slug-urlId), ExportMarkdown (/api/v2/documents/{uuid}/markdown), Search/List, CreateDocument/UpdateDocument, comments create/list/resolve, pure ParseDocRefs URL helper. Runs under a BOT token (minting runbook in design). wave 2.5 — implement after feat/wave2-archive-links merges.
# internal/yonote — light API client (stdlib-only, mem0 style)
Mirror internal/mem0 exactly in shape: `New(baseURL, token string, opts ...Option) *Client`
(trailing "/" trimmed, default `http.Client{Timeout: 30s}`, `WithHTTPClient`), ctx-first methods,
one `do(ctx, method, path, query, in, out)` round-tripper, typed `*APIError`, package doc.go
pinning the contract + quirks, httptest unit tests. No third-party deps.
Contract pinned to live bigbes.yonote.ru, x-app-version 1.47.1, verified 2026-07-18.
Yonote = Outline fork; API is RPC-style `POST /api/<resource>.<verb>` (JSON body) PLUS a newer
`/api/v2/*` REST namespace. Auth on every request: `Authorization: Bearer <token>` (tokens are
JWTs; treat as opaque). Success envelope `{data, status, ok, [pagination, count|total, policies]}`.
Error envelope `{ok:false, error:"<machine_code>", status:<int, SOMETIMES ABSENT>, message:"<human>"}`
— key off the HTTP status, carry error/message.
## Endpoint table (methods to implement; L = live-verified, S = spec-only)
| Method | HTTP + path | Request | Response essentials |
|------------------------------------------|--------------------------------------------|----------------------------------------------------|---------------------|
| AuthInfo(ctx) | POST /api/auth.info | {} | L data.user{id,name,email,isBot,isAdmin,isViewer}, data.team{name,url,subdomain} |
| DocumentInfo(ctx, id) | POST /api/documents.info | {"id"} — uuid OR urlId OR slug-urlId (all L) | L data = Document |
| ExportMarkdown(ctx, uuid) | GET /api/v2/documents/{uuid}/markdown | path uuid ONLY (urlId → 400 invalid_format, L) | L {"data":"<markdown string>"} — may be "" (see quirks) |
| SearchDocuments(ctx, req{Query,Limit}) | POST /api/documents.search | {"query","limit"} | L data[] of {context string, ranking float64, document Document}; top-level count |
| ListCollections(ctx, limit, offset) | POST /api/collections.list | {"limit","offset"} | L data[] Collection{ID,Name,URLID,URL,Permission,Sharing}; pagination{limit,offset,nextPath}; top-level count |
| ListDocuments(ctx, req) | POST /api/documents.list | {"collectionId","parentDocumentId","limit","offset"} (all optional) | L data[] Document; pagination; top-level total |
| CreateDocument(ctx, req) | POST /api/documents.create | {"title"!,"collectionId"!,"text","parentDocumentId","publish"} | L data = Document (text echoed VERBATIM markdown; publish:false ⇒ publishedAt null even with team autoPublishDocs) |
| UpdateDocument(ctx, req) | POST /api/documents.update | {"id"!(uuid|urlId),"title","text","append","publish"} | S (v1 spec; same Document envelope) |
| CreateComment(ctx, req) | POST /api/comments.create | {"entityType":"document","entityId"!,"text"!,"parentCommentId"} | L data.comment{id,entityId,threadId,text,quote,isResolved,createdById,createdAt,updatedAt} — NESTED under data.comment |
| ListComments(ctx, req) | POST /api/comments.list | {"entityId"!,"isResolved"(*bool),"threadId","limit"≤100,"offset"} | L data.comments[] (nested; items also carry threadCommentsCount, attachments[]); pagination; total |
| ResolveComment(ctx, id, resolved) | POST /api/comments.resolve | {"id"!,"isResolved"!} | S (v2-preview spec) data.comment |
| ParseDocRefs(baseURL, text) []string | (pure, no HTTP) | — | ordered dedup'd doc-id tokens from free text |
Deliberately OUT of the client (documented in doc.go): documents.export RPC (now an ASYNC
fileOperation on live — see quirks), attachments.* (follow-up if ever needed: attachments.list
{documentId} → items carry redirectUrl/url/contentType/size/name; attachments.redirect {id} → 302
signed URL — both spec'd, redirect flow live-verified via fileOperations), events.list +
webhookSubscriptions.* (Q&A bead extends the client then), bots CRUD (one-time provisioning, curl
runbook below), documents.delete.
## Types
Document{ID, URLID, Title, URL (site-relative "/doc/<slug>-<urlId>"), CollectionID string,
ParentDocumentID *string, Text string, Type string ("document"|"database"|"row"|"whiteboard"|"embed"),
Revision int, ArchivedAt, DeletedAt, PublishedAt *string (keep RFC3339 strings, lossless),
CreatedAt, UpdatedAt string, ChildrenCount int}. Collection{ID, URLID, Name, URL, Permission string,
Sharing bool}. Comment{ID, EntityID string, ThreadID *string, Text string, Quote *string,
IsResolved bool, CreatedByID, CreatedAt, UpdatedAt string}. AuthInfo{User{ID, Name, Email string,
IsBot, IsAdmin, IsViewer bool}, Team{Name, URL, Subdomain string}}.
ParseDocRefs(baseURL, text): regexp `https?://<escaped-host>/doc/([A-Za-z0-9._~-]+)` where host
comes from url.Parse(baseURL). Token = full last path segment (slug-urlId form — documents.info
accepts it whole, no urlId slicing needed); query/#fragment excluded by the charset; works on both
raw markdown and TipTap HTML (`<a href="...">`); dedup preserving first occurrence.
## Errors
`*APIError{StatusCode int, Method, Path, Code, Message, Snippet string}` parsed from the error
envelope; `(e *APIError) NotFound() bool` (StatusCode==404). Body snippet capped 2KiB; success-body
read capped 8MiB (mem0 constants). Observed error bodies (assert in tests):
- 401 authentication_required, message "Unable to decode JWT token" (bad token) / "Authentication or shareId required" (no token)
- 404 not_found, "Resource not found" (documents.info, unknown uuid AND unknown urlId) / "Document not found" (v2 markdown)
- 400 invalid_format, "Invalid UUID (id)" — NOTE: this body has NO "status" key
- 500 internal_server_error (markdown export of a whiteboard doc)
## Quirks for doc.go (all verified live unless noted)
1. Identity: doc URL is `https://<team>.yonote.ru/doc/<slug>-<urlId>`; documents.info resolves
uuid | bare urlId | slug-urlId; ALL /api/v2/* paths take the uuid only.
2. Document.Text is NOT reliable markdown: after editor edits it is a PLAINTEXT projection (all
markup stripped — verified on a real doc: text had no #/**/`` while /markdown had them).
/v2/.../markdown is canonical BUT returned "" for an API-created never-edited draft whose .text
still held the submitted markdown verbatim, and returns "" for database-type docs. Whether the
draft case is "draft" or "no editor state yet" is UNVERIFIED — callers use the fallback chain
(markdown → info.Text → unavailable) which covers both.
3. documents.export RPC diverges from Outline AND from the v1 docs: live it requires a uuid and
returns an async fileOperation {state waiting→complete} (poll fileOperations.info, download via
fileOperations.redirect → 302 signed Yandex-cloud URL). Not wrapped by this client.
4. type=whiteboard → markdown export 500s; type=database → "" (v2 search/accessible spec says
types document|database|row|whiteboard|embed).
5. Pagination: {limit, offset, nextPath}; totals are INCONSISTENT — v1 RPC puts count/total at the
top level, /api/v2/* puts total inside pagination. Don't share one envelope type.
6. No rate-limit headers at 1.47.1; no documented limits. No client-side retry (callers own it,
mem0 precedent); surface 429 as *APIError if it ever appears.
7. Live (1.47.1) is newer than the published docs (1.38.1). The v2-preview OpenAPI spec exists
ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next/static/chunks/pages/
developers-*.js, two JSON.parse('...') literals: v2-preview then legacy v1);
yonote.ru/openapi-3.json serves the OLD v1 spec. Re-extract from the chunk when in doubt.
8. Team feature flags seen in auth.info: mcpServer:true + mcpSettings (Yonote hosts a native MCP
endpoint at /api/v2/mcp — the operator's claude.ai integration uses it; irrelevant to this
client but explains the product's own tool surface).
## Bot-token provisioning runbook (one-time, operator; do BEFORE deploying the integration beads)
The daemon must authenticate as a BOT, not as bigbes's personal token (attribution + blast
radius). Bots are Yonote-native (not in Outline). Admin-only. As of 2026-07-18 GET /api/v2/bots
returns [] — none exist yet.
1. POST /api/v2/bots {"name":"agent-hub","username":"agent-hub"} → data: bot user (grab id; user has isBot:true)
2. POST /api/v2/bots/<botId>/token {"name":"agenthubd"} → data{id,name,secret,...} — `secret` IS the API token, shown ONCE → store as YONOTE_TOKEN in /etc/agent-hub/env
3. Grant collections (bots see nothing by default per Bots tag doc "granted access to collections
with read or write permissions"): POST /api/collections.add_user {"id":<collectionId>,
"userId":<botId>,"permission":"read"|"read_write"} (v1-spec'd, NOT live-verified).
4. Verify with the minted token: POST /api/auth.info → user.isBot must be true; then
documents.info on a granted doc, CreateDocument in the write collection.
UNVERIFIED until then: auth.info under a bot token, bot default visibility, bot create rights.
Rotation: GET /api/v2/bots/{id}/token (list, no secrets), DELETE /api/v2/bots/{id}/token/{tokenId}.
Daemon behavior: on startup call AuthInfo, log identity, WARN when IsBot==false (personal token in
prod must be visible, not fatal).
## Test plan (httptest, mem0-test style)
Per method: happy path asserting HTTP method, exact path, Bearer header, JSON body; decode into
typed result. Error mapping: 401/404 envelopes, 400 body WITHOUT "status" key, non-JSON body →
Snippet only. Nested data.comment / data.comments decode. ExportMarkdown "" passthrough (no error).
DocumentInfo with the three id forms (same handler, three paths asserted). ParseDocRefs table:
bare URL in markdown, TipTap `<a href="https://host/doc/x-abc">`, uuid-in-URL, trailing
punctuation/")"/query/fragment, cross-host rejected, http vs https, dedup order, cap-free (cap is
bead-2 policy).
Live-verified 2026-07-18 against bigbes.yonote.ru x-app-version 1.47.1 (docs page documents 1.38.1 — live is newer). Evidence: (1) auth.info via Bearer OK — the operator token in use today is PERSONAL (user.isBot=false, isAdmin=true); no bots exist yet (GET /api/v2/bots → []); mint one before deploy (runbook in design). (2) documents.info resolved the same doc by uuid, bare urlId, slug-urlId. (3) /api/v2/documents/{uuid}/markdown returned faithful markdown for an editor-edited doc while documents.info .text for the SAME doc was plaintext with all markup stripped — .text is NOT markdown after editor edits. (4) One-probe-doc lifecycle (created→deleted, approved): documents.create with markdown text echoed text VERBATIM and honored publish:false (publishedAt null despite team autoPublishDocs:true); /markdown for that fresh API-created draft returned '' — hence the markdown→.text fallback chain; comments.create → {data:{comment}} nested, text stored verbatim, comments.list → {data:{comments:[…]}}; documents.delete {permanent:true} → {success:true}; info after delete → 404. (5) documents.export RPC live = ASYNC fileOperation (uuid-only, 400 invalid_format on urlId); fileOperations.info reached state complete and fileOperations.redirect 302'd to a signed Yandex-storage URL (Camera-export.md). SIDE EFFECT LEFT BEHIND: one accidental export fileOperation record (id 6b6e2056-9487-4f91-b10b-818d7c1a486c, doc 'Camera', 604B, complete) lingers in the workspace export history — harmless, operator may delete. (6) Error bodies: 401 authentication_required 'Unable to decode JWT token' / 'Authentication or shareId required'; 404 not_found; 400 invalid_format WITHOUT a status key; 500 internal_server_error on whiteboard markdown export; database-type docs export ''. (7) No rate-limit headers observed. (8) v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next chunk pages/developers-*.js, two JSON.parse single-quoted literals: v2-preview 1.38.1 with /v2/bots + /v2/documents, then legacy v1 0.1.0); yonote.ru/openapi-3.json serves the OLD v1 spec. (9) webhookSubscriptions.list responds live (empty) though undocumented in both specs; events.list live-verified. UNVERIFIED (implementer/provisioning): auth.info under a bot token; bot default visibility + collections.add_user grant; bot create rights; documents.update; comments.resolve; whether /markdown '' means draft-only or missing-editor-state (fallback chain covers both).
No outgoing dependencies.
ah-25e
— Yonote publish lane: .task/publish.json → bot-authored docs at finalize
blocks
closed
ah-ptu
— Yonote Q&A bot loop: poll doc comments, answer via board tasks (post-wave-2.5)
blocks
closed
ah-eje
— Q&A loop rollout: live-verify events.list ordering + comments.resolve, wire qa role/config on agent-1
blocks
closed
ah-2lh
— Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest
blocks
closed
| id | ah-gxa |
| content_hash | dffb086c7b7fb989cce7ee0c79a50575efa7059b98fd57b9c4d41e23de6555fb |
| title | internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth) |
| description | Why: agenthubd integrates Yonote (Outline-fork docs product, live at bigbes.yonote.ru) in three slices — claim-time artifact export, finalize-time publishing, later Q&A. All need one light client. What: internal/yonote in the internal/mem0 style (New(baseURL, token, opts), ctx-first, typed *APIError, stdlib-only, httptest tests, doc.go pinning contract+quirks), scoped to exactly the integration surface: AuthInfo, DocumentInfo (uuid|urlId|slug-urlId), ExportMarkdown (/api/v2/documents/{uuid}/markdown), Search/List, CreateDocument/UpdateDocument, comments create/list/resolve, pure ParseDocRefs URL helper. Runs under a BOT token (minting runbook in design). wave 2.5 — implement after feat/wave2-archive-links merges. |
| design | # internal/yonote — light API client (stdlib-only, mem0 style) Mirror internal/mem0 exactly in shape: `New(baseURL, token string, opts ...Option) *Client` (trailing "/" trimmed, default `http.Client{Timeout: 30s}`, `WithHTTPClient`), ctx-first methods, one `do(ctx, method, path, query, in, out)` round-tripper, typed `*APIError`, package doc.go pinning the contract + quirks, httptest unit tests. No third-party deps. Contract pinned to live bigbes.yonote.ru, x-app-version 1.47.1, verified 2026-07-18. Yonote = Outline fork; API is RPC-style `POST /api/<resource>.<verb>` (JSON body) PLUS a newer `/api/v2/*` REST namespace. Auth on every request: `Authorization: Bearer <token>` (tokens are JWTs; treat as opaque). Success envelope `{data, status, ok, [pagination, count|total, policies]}`. Error envelope `{ok:false, error:"<machine_code>", status:<int, SOMETIMES ABSENT>, message:"<human>"}` — key off the HTTP status, carry error/message. ## Endpoint table (methods to implement; L = live-verified, S = spec-only) | Method | HTTP + path | Request | Response essentials | |------------------------------------------|--------------------------------------------|----------------------------------------------------|---------------------| | AuthInfo(ctx) | POST /api/auth.info | {} | L data.user{id,name,email,isBot,isAdmin,isViewer}, data.team{name,url,subdomain} | | DocumentInfo(ctx, id) | POST /api/documents.info | {"id"} — uuid OR urlId OR slug-urlId (all L) | L data = Document | | ExportMarkdown(ctx, uuid) | GET /api/v2/documents/{uuid}/markdown | path uuid ONLY (urlId → 400 invalid_format, L) | L {"data":"<markdown string>"} — may be "" (see quirks) | | SearchDocuments(ctx, req{Query,Limit}) | POST /api/documents.search | {"query","limit"} | L data[] of {context string, ranking float64, document Document}; top-level count | | ListCollections(ctx, limit, offset) | POST /api/collections.list | {"limit","offset"} | L data[] Collection{ID,Name,URLID,URL,Permission,Sharing}; pagination{limit,offset,nextPath}; top-level count | | ListDocuments(ctx, req) | POST /api/documents.list | {"collectionId","parentDocumentId","limit","offset"} (all optional) | L data[] Document; pagination; top-level total | | CreateDocument(ctx, req) | POST /api/documents.create | {"title"!,"collectionId"!,"text","parentDocumentId","publish"} | L data = Document (text echoed VERBATIM markdown; publish:false ⇒ publishedAt null even with team autoPublishDocs) | | UpdateDocument(ctx, req) | POST /api/documents.update | {"id"!(uuid|urlId),"title","text","append","publish"} | S (v1 spec; same Document envelope) | | CreateComment(ctx, req) | POST /api/comments.create | {"entityType":"document","entityId"!,"text"!,"parentCommentId"} | L data.comment{id,entityId,threadId,text,quote,isResolved,createdById,createdAt,updatedAt} — NESTED under data.comment | | ListComments(ctx, req) | POST /api/comments.list | {"entityId"!,"isResolved"(*bool),"threadId","limit"≤100,"offset"} | L data.comments[] (nested; items also carry threadCommentsCount, attachments[]); pagination; total | | ResolveComment(ctx, id, resolved) | POST /api/comments.resolve | {"id"!,"isResolved"!} | S (v2-preview spec) data.comment | | ParseDocRefs(baseURL, text) []string | (pure, no HTTP) | — | ordered dedup'd doc-id tokens from free text | Deliberately OUT of the client (documented in doc.go): documents.export RPC (now an ASYNC fileOperation on live — see quirks), attachments.* (follow-up if ever needed: attachments.list {documentId} → items carry redirectUrl/url/contentType/size/name; attachments.redirect {id} → 302 signed URL — both spec'd, redirect flow live-verified via fileOperations), events.list + webhookSubscriptions.* (Q&A bead extends the client then), bots CRUD (one-time provisioning, curl runbook below), documents.delete. ## Types Document{ID, URLID, Title, URL (site-relative "/doc/<slug>-<urlId>"), CollectionID string, ParentDocumentID *string, Text string, Type string ("document"|"database"|"row"|"whiteboard"|"embed"), Revision int, ArchivedAt, DeletedAt, PublishedAt *string (keep RFC3339 strings, lossless), CreatedAt, UpdatedAt string, ChildrenCount int}. Collection{ID, URLID, Name, URL, Permission string, Sharing bool}. Comment{ID, EntityID string, ThreadID *string, Text string, Quote *string, IsResolved bool, CreatedByID, CreatedAt, UpdatedAt string}. AuthInfo{User{ID, Name, Email string, IsBot, IsAdmin, IsViewer bool}, Team{Name, URL, Subdomain string}}. ParseDocRefs(baseURL, text): regexp `https?://<escaped-host>/doc/([A-Za-z0-9._~-]+)` where host comes from url.Parse(baseURL). Token = full last path segment (slug-urlId form — documents.info accepts it whole, no urlId slicing needed); query/#fragment excluded by the charset; works on both raw markdown and TipTap HTML (`<a href="...">`); dedup preserving first occurrence. ## Errors `*APIError{StatusCode int, Method, Path, Code, Message, Snippet string}` parsed from the error envelope; `(e *APIError) NotFound() bool` (StatusCode==404). Body snippet capped 2KiB; success-body read capped 8MiB (mem0 constants). Observed error bodies (assert in tests): - 401 authentication_required, message "Unable to decode JWT token" (bad token) / "Authentication or shareId required" (no token) - 404 not_found, "Resource not found" (documents.info, unknown uuid AND unknown urlId) / "Document not found" (v2 markdown) - 400 invalid_format, "Invalid UUID (id)" — NOTE: this body has NO "status" key - 500 internal_server_error (markdown export of a whiteboard doc) ## Quirks for doc.go (all verified live unless noted) 1. Identity: doc URL is `https://<team>.yonote.ru/doc/<slug>-<urlId>`; documents.info resolves uuid | bare urlId | slug-urlId; ALL /api/v2/* paths take the uuid only. 2. Document.Text is NOT reliable markdown: after editor edits it is a PLAINTEXT projection (all markup stripped — verified on a real doc: text had no #/**/`` while /markdown had them). /v2/.../markdown is canonical BUT returned "" for an API-created never-edited draft whose .text still held the submitted markdown verbatim, and returns "" for database-type docs. Whether the draft case is "draft" or "no editor state yet" is UNVERIFIED — callers use the fallback chain (markdown → info.Text → unavailable) which covers both. 3. documents.export RPC diverges from Outline AND from the v1 docs: live it requires a uuid and returns an async fileOperation {state waiting→complete} (poll fileOperations.info, download via fileOperations.redirect → 302 signed Yandex-cloud URL). Not wrapped by this client. 4. type=whiteboard → markdown export 500s; type=database → "" (v2 search/accessible spec says types document|database|row|whiteboard|embed). 5. Pagination: {limit, offset, nextPath}; totals are INCONSISTENT — v1 RPC puts count/total at the top level, /api/v2/* puts total inside pagination. Don't share one envelope type. 6. No rate-limit headers at 1.47.1; no documented limits. No client-side retry (callers own it, mem0 precedent); surface 429 as *APIError if it ever appears. 7. Live (1.47.1) is newer than the published docs (1.38.1). The v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next/static/chunks/pages/ developers-*.js, two JSON.parse('...') literals: v2-preview then legacy v1); yonote.ru/openapi-3.json serves the OLD v1 spec. Re-extract from the chunk when in doubt. 8. Team feature flags seen in auth.info: mcpServer:true + mcpSettings (Yonote hosts a native MCP endpoint at /api/v2/mcp — the operator's claude.ai integration uses it; irrelevant to this client but explains the product's own tool surface). ## Bot-token provisioning runbook (one-time, operator; do BEFORE deploying the integration beads) The daemon must authenticate as a BOT, not as bigbes's personal token (attribution + blast radius). Bots are Yonote-native (not in Outline). Admin-only. As of 2026-07-18 GET /api/v2/bots returns [] — none exist yet. 1. POST /api/v2/bots {"name":"agent-hub","username":"agent-hub"} → data: bot user (grab id; user has isBot:true) 2. POST /api/v2/bots/<botId>/token {"name":"agenthubd"} → data{id,name,secret,...} — `secret` IS the API token, shown ONCE → store as YONOTE_TOKEN in /etc/agent-hub/env 3. Grant collections (bots see nothing by default per Bots tag doc "granted access to collections with read or write permissions"): POST /api/collections.add_user {"id":<collectionId>, "userId":<botId>,"permission":"read"|"read_write"} (v1-spec'd, NOT live-verified). 4. Verify with the minted token: POST /api/auth.info → user.isBot must be true; then documents.info on a granted doc, CreateDocument in the write collection. UNVERIFIED until then: auth.info under a bot token, bot default visibility, bot create rights. Rotation: GET /api/v2/bots/{id}/token (list, no secrets), DELETE /api/v2/bots/{id}/token/{tokenId}. Daemon behavior: on startup call AuthInfo, log identity, WARN when IsBot==false (personal token in prod must be visible, not fatal). ## Test plan (httptest, mem0-test style) Per method: happy path asserting HTTP method, exact path, Bearer header, JSON body; decode into typed result. Error mapping: 401/404 envelopes, 400 body WITHOUT "status" key, non-JSON body → Snippet only. Nested data.comment / data.comments decode. ExportMarkdown "" passthrough (no error). DocumentInfo with the three id forms (same handler, three paths asserted). ParseDocRefs table: bare URL in markdown, TipTap `<a href="https://host/doc/x-abc">`, uuid-in-URL, trailing punctuation/")"/query/fragment, cross-host rejected, http vs https, dedup order, cap-free (cap is bead-2 policy). |
| acceptance_criteria | |
| notes | Live-verified 2026-07-18 against bigbes.yonote.ru x-app-version 1.47.1 (docs page documents 1.38.1 — live is newer). Evidence: (1) auth.info via Bearer OK — the operator token in use today is PERSONAL (user.isBot=false, isAdmin=true); no bots exist yet (GET /api/v2/bots → []); mint one before deploy (runbook in design). (2) documents.info resolved the same doc by uuid, bare urlId, slug-urlId. (3) /api/v2/documents/{uuid}/markdown returned faithful markdown for an editor-edited doc while documents.info .text for the SAME doc was plaintext with all markup stripped — .text is NOT markdown after editor edits. (4) One-probe-doc lifecycle (created→deleted, approved): documents.create with markdown text echoed text VERBATIM and honored publish:false (publishedAt null despite team autoPublishDocs:true); /markdown for that fresh API-created draft returned '' — hence the markdown→.text fallback chain; comments.create → {data:{comment}} nested, text stored verbatim, comments.list → {data:{comments:[…]}}; documents.delete {permanent:true} → {success:true}; info after delete → 404. (5) documents.export RPC live = ASYNC fileOperation (uuid-only, 400 invalid_format on urlId); fileOperations.info reached state complete and fileOperations.redirect 302'd to a signed Yandex-storage URL (Camera-export.md). SIDE EFFECT LEFT BEHIND: one accidental export fileOperation record (id 6b6e2056-9487-4f91-b10b-818d7c1a486c, doc 'Camera', 604B, complete) lingers in the workspace export history — harmless, operator may delete. (6) Error bodies: 401 authentication_required 'Unable to decode JWT token' / 'Authentication or shareId required'; 404 not_found; 400 invalid_format WITHOUT a status key; 500 internal_server_error on whiteboard markdown export; database-type docs export ''. (7) No rate-limit headers observed. (8) v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next chunk pages/developers-*.js, two JSON.parse single-quoted literals: v2-preview 1.38.1 with /v2/bots + /v2/documents, then legacy v1 0.1.0); yonote.ru/openapi-3.json serves the OLD v1 spec. (9) webhookSubscriptions.list responds live (empty) though undocumented in both specs; events.list live-verified. UNVERIFIED (implementer/provisioning): auth.info under a bot token; bot default visibility + collections.add_user grant; bot create rights; documents.update; comments.resolve; whether /markdown '' means draft-only or missing-editor-state (fallback chain covers both). |
| status | closed |
| priority | 2 |
| issue_type | feature |
| assignee | Eugene Blikh |
| estimated_minutes | NULL |
| created_at | 2026-07-18T15:03:36Z |
| created_by | Eugene Blikh |
| owner | bigbes@gmail.com |
| updated_at | 2026-07-18T15:59:07Z |
| closed_at | 2026-07-18T15:59:07Z |
| closed_by_session | |
| external_ref | NULL |
| spec_id | |
| compaction_level | 0 |
| compacted_at | NULL |
| compacted_at_commit | NULL |
| original_size | NULL |
| sender | |
| ephemeral | 0 |
| wisp_type | |
| pinned | 0 |
| is_template | 0 |
| mol_type | |
| work_type | |
| source_system | |
| metadata | �{} |
| source_repo | |
| close_reason | Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes. |
| event_kind | |
| actor | |
| target | |
| payload | |
| await_type | |
| await_id | |
| timeout_ns | 0 |
| waiters | |
| hook_bead | |
| role_bead | |
| agent_state | |
| last_activity | NULL |
| role_type | |
| rig | |
| due_at | NULL |
| defer_until | NULL |
| no_history | 0 |
| started_at | 2026-07-18T15:06:56Z |
| is_blocked | 0 |
| id | 5b6045e4-6bd4-5f00-bb39-d36a8c263ace |
| issue_id | ah-25e |
| type | blocks |
| created_at | 2026-07-18T18:04:56Z |
| created_by | Eugene Blikh |
| metadata | �{} |
| thread_id | |
| depends_on_issue_id | ah-gxa |
| depends_on_wisp_id | NULL |
| depends_on_external | NULL |
| id | 6a912c84-0b5c-5b5f-9646-941067cace98 |
| issue_id | ah-ptu |
| type | blocks |
| created_at | 2026-07-18T18:04:57Z |
| created_by | Eugene Blikh |
| metadata | �{} |
| thread_id | |
| depends_on_issue_id | ah-gxa |
| depends_on_wisp_id | NULL |
| depends_on_external | NULL |
| id | c216a252-8df4-59d4-b191-6d2ab71ef4f5 |
| issue_id | ah-2lh |
| type | blocks |
| created_at | 2026-07-18T18:04:55Z |
| created_by | Eugene Blikh |
| metadata | �{} |
| thread_id | |
| depends_on_issue_id | ah-gxa |
| depends_on_wisp_id | NULL |
| depends_on_external | NULL |
| id | 019f75c1-169f-7b9c-a3eb-441bc5115cb0 |
| issue_id | ah-gxa |
| event_type | created |
| actor | Eugene Blikh |
| old_value | |
| new_value | |
| comment | NULL |
| created_at | 2026-07-18T18:03:36Z |
| id | 019f75c4-23da-727c-ac89-db89963919f6 |
| issue_id | ah-gxa |
| event_type | claimed |
| actor | Eugene Blikh |
| old_value | {"id":"ah-gxa","title":"internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth)","description":"Why: agenthubd integrates Yonote (Outline-fork docs product, live at bigbes.yonote.ru) in three slices — claim-time artifact export, finalize-time publishing, later Q\u0026A. All need one light client. What: internal/yonote in the internal/mem0 style (New(baseURL, token, opts), ctx-first, typed *APIError, stdlib-only, httptest tests, doc.go pinning contract+quirks), scoped to exactly the integration surface: AuthInfo, DocumentInfo (uuid|urlId|slug-urlId), ExportMarkdown (/api/v2/documents/{uuid}/markdown), Search/List, CreateDocument/UpdateDocument, comments create/list/resolve, pure ParseDocRefs URL helper. Runs under a BOT token (minting runbook in design). wave 2.5 — implement after feat/wave2-archive-links merges.","design":"# internal/yonote — light API client (stdlib-only, mem0 style)\n\nMirror internal/mem0 exactly in shape: `New(baseURL, token string, opts ...Option) *Client`\n(trailing \"/\" trimmed, default `http.Client{Timeout: 30s}`, `WithHTTPClient`), ctx-first methods,\none `do(ctx, method, path, query, in, out)` round-tripper, typed `*APIError`, package doc.go\npinning the contract + quirks, httptest unit tests. No third-party deps.\n\nContract pinned to live bigbes.yonote.ru, x-app-version 1.47.1, verified 2026-07-18.\nYonote = Outline fork; API is RPC-style `POST /api/\u003cresource\u003e.\u003cverb\u003e` (JSON body) PLUS a newer\n`/api/v2/*` REST namespace. Auth on every request: `Authorization: Bearer \u003ctoken\u003e` (tokens are\nJWTs; treat as opaque). Success envelope `{data, status, ok, [pagination, count|total, policies]}`.\nError envelope `{ok:false, error:\"\u003cmachine_code\u003e\", status:\u003cint, SOMETIMES ABSENT\u003e, message:\"\u003chuman\u003e\"}`\n— key off the HTTP status, carry error/message.\n\n## Endpoint table (methods to implement; L = live-verified, S = spec-only)\n\n| Method | HTTP + path | Request | Response essentials |\n|------------------------------------------|--------------------------------------------|----------------------------------------------------|---------------------|\n| AuthInfo(ctx) | POST /api/auth.info | {} | L data.user{id,name,email,isBot,isAdmin,isViewer}, data.team{name,url,subdomain} |\n| DocumentInfo(ctx, id) | POST /api/documents.info | {\"id\"} — uuid OR urlId OR slug-urlId (all L) | L data = Document |\n| ExportMarkdown(ctx, uuid) | GET /api/v2/documents/{uuid}/markdown | path uuid ONLY (urlId → 400 invalid_format, L) | L {\"data\":\"\u003cmarkdown string\u003e\"} — may be \"\" (see quirks) |\n| SearchDocuments(ctx, req{Query,Limit}) | POST /api/documents.search | {\"query\",\"limit\"} | L data[] of {context string, ranking float64, document Document}; top-level count |\n| ListCollections(ctx, limit, offset) | POST /api/collections.list | {\"limit\",\"offset\"} | L data[] Collection{ID,Name,URLID,URL,Permission,Sharing}; pagination{limit,offset,nextPath}; top-level count |\n| ListDocuments(ctx, req) | POST /api/documents.list | {\"collectionId\",\"parentDocumentId\",\"limit\",\"offset\"} (all optional) | L data[] Document; pagination; top-level total |\n| CreateDocument(ctx, req) | POST /api/documents.create | {\"title\"!,\"collectionId\"!,\"text\",\"parentDocumentId\",\"publish\"} | L data = Document (text echoed VERBATIM markdown; publish:false ⇒ publishedAt null even with team autoPublishDocs) |\n| UpdateDocument(ctx, req) | POST /api/documents.update | {\"id\"!(uuid|urlId),\"title\",\"text\",\"append\",\"publish\"} | S (v1 spec; same Document envelope) |\n| CreateComment(ctx, req) | POST /api/comments.create | {\"entityType\":\"document\",\"entityId\"!,\"text\"!,\"parentCommentId\"} | L data.comment{id,entityId,threadId,text,quote,isResolved,createdById,createdAt,updatedAt} — NESTED under data.comment |\n| ListComments(ctx, req) | POST /api/comments.list | {\"entityId\"!,\"isResolved\"(*bool),\"threadId\",\"limit\"≤100,\"offset\"} | L data.comments[] (nested; items also carry threadCommentsCount, attachments[]); pagination; total |\n| ResolveComment(ctx, id, resolved) | POST /api/comments.resolve | {\"id\"!,\"isResolved\"!} | S (v2-preview spec) data.comment |\n| ParseDocRefs(baseURL, text) []string | (pure, no HTTP) | — | ordered dedup'd doc-id tokens from free text |\n\nDeliberately OUT of the client (documented in doc.go): documents.export RPC (now an ASYNC\nfileOperation on live — see quirks), attachments.* (follow-up if ever needed: attachments.list\n{documentId} → items carry redirectUrl/url/contentType/size/name; attachments.redirect {id} → 302\nsigned URL — both spec'd, redirect flow live-verified via fileOperations), events.list +\nwebhookSubscriptions.* (Q\u0026A bead extends the client then), bots CRUD (one-time provisioning, curl\nrunbook below), documents.delete.\n\n## Types\n\nDocument{ID, URLID, Title, URL (site-relative \"/doc/\u003cslug\u003e-\u003curlId\u003e\"), CollectionID string,\nParentDocumentID *string, Text string, Type string (\"document\"|\"database\"|\"row\"|\"whiteboard\"|\"embed\"),\nRevision int, ArchivedAt, DeletedAt, PublishedAt *string (keep RFC3339 strings, lossless),\nCreatedAt, UpdatedAt string, ChildrenCount int}. Collection{ID, URLID, Name, URL, Permission string,\nSharing bool}. Comment{ID, EntityID string, ThreadID *string, Text string, Quote *string,\nIsResolved bool, CreatedByID, CreatedAt, UpdatedAt string}. AuthInfo{User{ID, Name, Email string,\nIsBot, IsAdmin, IsViewer bool}, Team{Name, URL, Subdomain string}}.\n\nParseDocRefs(baseURL, text): regexp `https?://\u003cescaped-host\u003e/doc/([A-Za-z0-9._~-]+)` where host\ncomes from url.Parse(baseURL). Token = full last path segment (slug-urlId form — documents.info\naccepts it whole, no urlId slicing needed); query/#fragment excluded by the charset; works on both\nraw markdown and TipTap HTML (`\u003ca href=\"...\"\u003e`); dedup preserving first occurrence.\n\n## Errors\n\n`*APIError{StatusCode int, Method, Path, Code, Message, Snippet string}` parsed from the error\nenvelope; `(e *APIError) NotFound() bool` (StatusCode==404). Body snippet capped 2KiB; success-body\nread capped 8MiB (mem0 constants). Observed error bodies (assert in tests):\n- 401 authentication_required, message \"Unable to decode JWT token\" (bad token) / \"Authentication or shareId required\" (no token)\n- 404 not_found, \"Resource not found\" (documents.info, unknown uuid AND unknown urlId) / \"Document not found\" (v2 markdown)\n- 400 invalid_format, \"Invalid UUID (id)\" — NOTE: this body has NO \"status\" key\n- 500 internal_server_error (markdown export of a whiteboard doc)\n\n## Quirks for doc.go (all verified live unless noted)\n\n1. Identity: doc URL is `https://\u003cteam\u003e.yonote.ru/doc/\u003cslug\u003e-\u003curlId\u003e`; documents.info resolves\n uuid | bare urlId | slug-urlId; ALL /api/v2/* paths take the uuid only.\n2. Document.Text is NOT reliable markdown: after editor edits it is a PLAINTEXT projection (all\n markup stripped — verified on a real doc: text had no #/**/`` while /markdown had them).\n /v2/.../markdown is canonical BUT returned \"\" for an API-created never-edited draft whose .text\n still held the submitted markdown verbatim, and returns \"\" for database-type docs. Whether the\n draft case is \"draft\" or \"no editor state yet\" is UNVERIFIED — callers use the fallback chain\n (markdown → info.Text → unavailable) which covers both.\n3. documents.export RPC diverges from Outline AND from the v1 docs: live it requires a uuid and\n returns an async fileOperation {state waiting→complete} (poll fileOperations.info, download via\n fileOperations.redirect → 302 signed Yandex-cloud URL). Not wrapped by this client.\n4. type=whiteboard → markdown export 500s; type=database → \"\" (v2 search/accessible spec says\n types document|database|row|whiteboard|embed).\n5. Pagination: {limit, offset, nextPath}; totals are INCONSISTENT — v1 RPC puts count/total at the\n top level, /api/v2/* puts total inside pagination. Don't share one envelope type.\n6. No rate-limit headers at 1.47.1; no documented limits. No client-side retry (callers own it,\n mem0 precedent); surface 429 as *APIError if it ever appears.\n7. Live (1.47.1) is newer than the published docs (1.38.1). The v2-preview OpenAPI spec exists\n ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next/static/chunks/pages/\n developers-*.js, two JSON.parse('...') literals: v2-preview then legacy v1);\n yonote.ru/openapi-3.json serves the OLD v1 spec. Re-extract from the chunk when in doubt.\n8. Team feature flags seen in auth.info: mcpServer:true + mcpSettings (Yonote hosts a native MCP\n endpoint at /api/v2/mcp — the operator's claude.ai integration uses it; irrelevant to this\n client but explains the product's own tool surface).\n\n## Bot-token provisioning runbook (one-time, operator; do BEFORE deploying the integration beads)\n\nThe daemon must authenticate as a BOT, not as bigbes's personal token (attribution + blast\nradius). Bots are Yonote-native (not in Outline). Admin-only. As of 2026-07-18 GET /api/v2/bots\nreturns [] — none exist yet.\n\n1. POST /api/v2/bots {\"name\":\"agent-hub\",\"username\":\"agent-hub\"} → data: bot user (grab id; user has isBot:true)\n2. POST /api/v2/bots/\u003cbotId\u003e/token {\"name\":\"agenthubd\"} → data{id,name,secret,...} — `secret` IS the API token, shown ONCE → store as YONOTE_TOKEN in /etc/agent-hub/env\n3. Grant collections (bots see nothing by default per Bots tag doc \"granted access to collections\n with read or write permissions\"): POST /api/collections.add_user {\"id\":\u003ccollectionId\u003e,\n \"userId\":\u003cbotId\u003e,\"permission\":\"read\"|\"read_write\"} (v1-spec'd, NOT live-verified).\n4. Verify with the minted token: POST /api/auth.info → user.isBot must be true; then\n documents.info on a granted doc, CreateDocument in the write collection.\n UNVERIFIED until then: auth.info under a bot token, bot default visibility, bot create rights.\nRotation: GET /api/v2/bots/{id}/token (list, no secrets), DELETE /api/v2/bots/{id}/token/{tokenId}.\n\nDaemon behavior: on startup call AuthInfo, log identity, WARN when IsBot==false (personal token in\nprod must be visible, not fatal).\n\n## Test plan (httptest, mem0-test style)\n\nPer method: happy path asserting HTTP method, exact path, Bearer header, JSON body; decode into\ntyped result. Error mapping: 401/404 envelopes, 400 body WITHOUT \"status\" key, non-JSON body →\nSnippet only. Nested data.comment / data.comments decode. ExportMarkdown \"\" passthrough (no error).\nDocumentInfo with the three id forms (same handler, three paths asserted). ParseDocRefs table:\nbare URL in markdown, TipTap `\u003ca href=\"https://host/doc/x-abc\"\u003e`, uuid-in-URL, trailing\npunctuation/\")\"/query/fragment, cross-host rejected, http vs https, dedup order, cap-free (cap is\nbead-2 policy).\n","notes":"Live-verified 2026-07-18 against bigbes.yonote.ru x-app-version 1.47.1 (docs page documents 1.38.1 — live is newer). Evidence: (1) auth.info via Bearer OK — the operator token in use today is PERSONAL (user.isBot=false, isAdmin=true); no bots exist yet (GET /api/v2/bots → []); mint one before deploy (runbook in design). (2) documents.info resolved the same doc by uuid, bare urlId, slug-urlId. (3) /api/v2/documents/{uuid}/markdown returned faithful markdown for an editor-edited doc while documents.info .text for the SAME doc was plaintext with all markup stripped — .text is NOT markdown after editor edits. (4) One-probe-doc lifecycle (created→deleted, approved): documents.create with markdown text echoed text VERBATIM and honored publish:false (publishedAt null despite team autoPublishDocs:true); /markdown for that fresh API-created draft returned '' — hence the markdown→.text fallback chain; comments.create → {data:{comment}} nested, text stored verbatim, comments.list → {data:{comments:[…]}}; documents.delete {permanent:true} → {success:true}; info after delete → 404. (5) documents.export RPC live = ASYNC fileOperation (uuid-only, 400 invalid_format on urlId); fileOperations.info reached state complete and fileOperations.redirect 302'd to a signed Yandex-storage URL (Camera-export.md). SIDE EFFECT LEFT BEHIND: one accidental export fileOperation record (id 6b6e2056-9487-4f91-b10b-818d7c1a486c, doc 'Camera', 604B, complete) lingers in the workspace export history — harmless, operator may delete. (6) Error bodies: 401 authentication_required 'Unable to decode JWT token' / 'Authentication or shareId required'; 404 not_found; 400 invalid_format WITHOUT a status key; 500 internal_server_error on whiteboard markdown export; database-type docs export ''. (7) No rate-limit headers observed. (8) v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next chunk pages/developers-*.js, two JSON.parse single-quoted literals: v2-preview 1.38.1 with /v2/bots + /v2/documents, then legacy v1 0.1.0); yonote.ru/openapi-3.json serves the OLD v1 spec. (9) webhookSubscriptions.list responds live (empty) though undocumented in both specs; events.list live-verified. UNVERIFIED (implementer/provisioning): auth.info under a bot token; bot default visibility + collections.add_user grant; bot create rights; documents.update; comments.resolve; whether /markdown '' means draft-only or missing-editor-state (fallback chain covers both).","status":"open","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-18T15:03:36Z","created_by":"Eugene Blikh","updated_at":"2026-07-18T15:03:36Z"} |
| new_value | {"assignee":"Eugene Blikh","status":"in_progress"} |
| comment | NULL |
| created_at | 2026-07-18T18:06:56Z |
| id | 019f75f3-ea08-74a7-8e20-cde907d25363 |
| issue_id | ah-gxa |
| event_type | closed |
| actor | Eugene Blikh |
| old_value | |
| new_value | Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes. |
| comment | NULL |
| created_at | 2026-07-18T18:59:07Z |
| id | 019f7cda-aa89-74f1-acc1-890e9f5bfe77 |
| issue_id | ah-gxa |
| event_type | label_added |
| actor | Eugene Blikh |
| old_value | NULL |
| new_value | NULL |
| comment | Added label: milestone:yonote |
| created_at | 2026-07-20T03:08:53Z |
| id | 019f7cdc-28e1-7bea-941c-8dfb787b36ac |
| issue_id | ah-gxa |
| event_type | label_removed |
| actor | Eugene Blikh |
| old_value | NULL |
| new_value | NULL |
| comment | Removed label: milestone:yonote |
| created_at | 2026-07-20T03:10:31Z |
No comments.
Close reason