@bloklabs/server 1.16.0 → 2.0.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.
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Blok's shared server is written in C#. It handles file uploads, link previews and live collaboration, and it converts saved documents. Use it inside an ASP.NET Core app, or run the same routes as a standalone npm binary or Docker image.
4
4
 
5
+ A Go version of the server lives in `packages/server/go`. It serves the same routes as the C# server. Every conformance suite runs against both. It serves health, link previews (`/unfurl`), uploads (`/upload`, `/upload-by-url`), deletes and local files. Its `blokdocs` package converts saved documents. With `--collab` it also serves live collaboration: the sync socket, `/edit`, `/state`, `/reset` and the history routes. It reads and writes the same collaboration directory as the C# server.
6
+
7
+ The npm binary, the Docker image and the NuGet packages all ship the C# server. See [Go module and archives](#go-module-and-archives) for how the Go server ships.
8
+
9
+ A Rust build is in progress in `packages/server/rust`. It ships nothing yet. Its README is `packages/server/rust/README.md`.
10
+
5
11
  Your documents stay yours. Your own app saves and loads them through a small endpoint, and that endpoint stays the record. With live collaboration on, the service keeps only a working copy of each open document. The copy lives in storage you point it at. Every few seconds, the service writes what people type back to your endpoint.
6
12
 
7
13
  Register an operation journal and that changes. The document keeps no working copy. The journal is where an edit becomes durable. Your endpoint's record is refreshed from a published checkpoint, on eviction and on drain, rather than on a timer. The packages include no database-block or MySQL integration; those follow this delivery migration.
@@ -36,6 +42,23 @@ The mapped group uses your ASP.NET Core authorization policy for upload and unfu
36
42
 
37
43
  In-process defaults expose health only. Storage and outbound routes must be enabled explicitly, and local storage requires an explicit valid `PublicUrl`. `AddBlokServer(options => { ... })` also accepts the origin, upload-limit, and S3 settings used by the standalone host.
38
44
 
45
+ ### Refuse bare LF line ends
46
+
47
+ By default Kestrel takes a bare LF (no CR) at the end of a chunked trailer line. Every other chunk line needs CRLF. A proxy in front of your app may read that trailer another way. The proxy and Kestrel then disagree on where the next request starts, and a request can be smuggled past the proxy.
48
+
49
+ The standalone host turns this off. Your own app does not get that setting from `AddBlokServer` or `MapBlokServer`, because it is a Kestrel runtime switch. Set it in your app's project file:
50
+
51
+ ```xml
52
+ <ItemGroup>
53
+ <RuntimeHostConfigurationOption Include="Microsoft.AspNetCore.Server.Kestrel.DisableHttp1LineFeedTerminators"
54
+ Value="true" />
55
+ </ItemGroup>
56
+ ```
57
+
58
+ The switch covers all of Kestrel's HTTP/1 parsing. The request line and the headers then need CRLF too. A request with a bare LF gets an empty 400, and the connection closes.
59
+
60
+ The Go and Rust hosts refuse bare LF line ends as well.
61
+
39
62
  ## Convert documents
40
63
 
41
64
  `Blok.Server` embeds Blok's own serializer as a JavaScript bundle and runs it in this process. A document therefore converts through the editor's implementation, not a port of it. A port has to be taught every block Blok gains, and it silently drops the ones nobody remembered.
@@ -217,6 +240,16 @@ docker run \
217
240
 
218
241
  `--collab` turns the sync routes on. `--doc-endpoint` names the routes in your own app that the service loads a document from and writes it back to. `BLOK_DOC_ENDPOINT_AUTH` holds the header value those routes expect, sent verbatim on every call. It has to be a single line: a value carrying a carriage return or newline refuses to start rather than losing the header on every call. `--collab-dir` (or `--collab-s3-prefix`) is where the working copy lives. It holds document content, so it must not be publicly readable, and it may not sit inside `--storage-dir`, where everything is served. In-process, the same switches are `options.CollabEnabled` and `options.DocEndpoint`, and the app must call `app.UseWebSockets()`.
219
242
 
243
+ A document also carries its page title and icon as top-level `title` and `icon` keys, for example `{"blocks": [...], "title": "Plan", "icon": {"type": "emoji", "value": "🚀"}}`. The service seeds a room from them, and writes them back on every PUT and on `GET /sync/{doc}/state`. A key is left out when the page has none. A malformed value is dropped, not refused: a title must be a non-empty string, and an icon `{"type": "emoji", "value": "…"}` or `{"type": "image", "url": "…"}`. A title or icon that holds a NUL (U+0000), say from an older client, is left out of the write-back the same way. A dropped value is gone from your record after the next write-back, because that PUT leaves the key out. A reset reseeds from your record, so a title change newer than the last PUT is lost, the same as a block edit.
244
+
245
+ **Breaking.** The write-back now carries the page title and icon. Before, the service read only `blocks` from your record, and the PUT body and `GET /sync/{doc}/state` held only `time` and `blocks`. Now the service seeds the room from your record's top-level `title` and `icon`, and sends both back on every write-back. This affects a host that keeps its own top-level `title` or `icon` with another meaning. It also affects a host that renames a page outside the room: the next PUT overwrites the new name with the room's title, or leaves the key out when the room has none.
246
+
247
+ What to change in your app:
248
+
249
+ - If your top-level `title` or `icon` means something else, rename your field.
250
+ - Otherwise treat the PUT's `title` and `icon` as Blok's page title and icon, and save them.
251
+ - Rename a page through an editor in the room, with `title.set`. The room reads your record only when it seeds, so a rename made only there is overwritten by the next PUT. The `/edit` endpoint cannot set the title.
252
+
220
253
  Documents written to your endpoint carry rich text fields as segments, the shape the editor's `save()` returns. A document your endpoint returns may hold HTML strings or segments. A custom block tool's rich fields go in `--rich-text-fields` (or `BLOK_RICH_TEXT_FIELDS`; the flag wins), as JSON keyed by block type: `--rich-text-fields '{"callout":["title"]}'`. In-process, the same list is `options.RichTextFields`. List exactly the fields the tool declares in `static richTextFields` on the client, or the two write the field in different shapes. The built-in paragraph, header, quote, toggle and list `text` fields need no entry. See [Rich text is segments](#rich-text-is-segments).
221
254
 
222
255
  Collaboration rooms are stored in format 2. Upgrade the service and the editor together. An older editor reads the format 2 room and ends its own session with `unsupported-format`. A room stored in format 1 is converted the first time it opens, and offline edits made in format 1 are not replayed.
@@ -288,12 +321,18 @@ Passes are needed whenever the routes run with `Auth = "ticket"`, including rout
288
321
  | --- | --- |
289
322
  | `GET /health` | Reports liveness and the running version |
290
323
  | `GET /unfurl?url=…` | Reads title, description, and image metadata |
324
+ | `GET /frame-check?url=…&embedder=…` | Says whether the page would let your editor show it in a frame, plus safe oEmbed details |
291
325
  | `POST /upload` | Stores an uploaded file |
292
326
  | `POST /upload-by-url` | Fetches and stores a remote file; the request media type must be `application/json` |
293
327
  | `GET /sync/{doc}` | WebSocket; the editor's live collaboration connection to one document (with `--collab`) |
294
328
  | `POST /sync/{doc}/reset` | Drops the working copy, reloads the document from your endpoint and tells every open tab to pick it up |
295
329
  | `POST /sync/{doc}/edit` | Inserts, updates or removes blocks from outside; all-or-nothing, reaches every open tab, and requires an idempotency key |
296
330
  | `GET /sync/{doc}/state` | Returns the live document as JSON, with the journal head it reflects when there is a journal |
331
+ | `GET /sync/{doc}/history` | Lists the document's past versions as points; needs an operation journal |
332
+ | `GET /sync/{doc}/history/{lineage}/{sequence}` | Returns the document as it was at one point |
333
+ | `GET /sync/{doc}/history/{lineage}/{sequence}/changes` | Lists the edits inside one version, one row per journal record |
334
+ | `POST /sync/{doc}/history/{lineage}/{sequence}/restore` | Makes the live document match one point, as a forward edit; requires an idempotency key |
335
+ | `DELETE /sync/{doc}/history/{lineage}` | Deletes one lineage that is not current |
297
336
 
298
337
  On a journal-backed service, `POST /sync/{doc}/reset` first adopts a working copy the journal does not hold yet and writes it back to your endpoint, so the reset rebaselines from a record that includes it. It then retires that working copy before it resets. If the retire fails, the reset answers 503 and changes nothing; retry it.
299
338
 
@@ -302,6 +341,7 @@ On a journal-backed service, `POST /sync/{doc}/reset` first adopts a working cop
302
341
  A rich text field in an edit may be an HTML string or segments. The service reads the HTML into segments before it applies anything. A rich text field is the `text` of a paragraph, header, quote, toggle or list block, plus every field in `--rich-text-fields`.
303
342
 
304
343
  - If reading that HTML runs past the runtime's timeout, the edit answers 503 with `Retry-After` (2 seconds by default) and applies nothing. Retry with the same key.
344
+ - One HTML string may hold at most 32768 characters (UTF-16 code units). A longer one answers 413 before any of it is read. Segments have no such limit.
305
345
  - If that HTML runs out of the runtime's memory budget, the edit answers 413 and applies nothing. The budget is per request, so the same body fails every time. Do not retry it; send less rich text.
306
346
  - HTML the reader refuses answers 422.
307
347
 
@@ -323,13 +363,157 @@ An edit may also send `If-Match: "<lineage>:<sequence>"`. It is one quoted tag,
323
363
 
324
364
  `GET /sync/{doc}/state` returns the live document as `application/json`, in the same shape your document endpoint receives, with rich text fields as segments. It includes edits made a moment ago. With a journal, it also sends `Blok-Doc-Lineage`, `Blok-Doc-Sequence` and `ETag: "<lineage>:<sequence>"`, naming the exact head the body reflects. Send that `ETag` back as `If-Match` to edit only if nothing changed in between. A working-copy-only service sends the body without those three headers. A purged document answers 403. A document that cannot be loaded, is held by another process, or is on a service that is shutting down answers 503. An export that ran past the runtime's timeout or allocation budget also answers 503, with `Retry-After` (2 seconds by default). A document the service cannot write as JSON answers 500.
325
365
 
326
- Both routes add `Blok-Doc-Lineage`, `Blok-Doc-Sequence` and `ETag` to `Access-Control-Expose-Headers` for an allowed origin, so a browser page can read them. Headers your app already exposes are kept.
366
+ `edit`, `state` and the history `restore` add `Blok-Doc-Lineage`, `Blok-Doc-Sequence` and `ETag` to `Access-Control-Expose-Headers` for an allowed origin, so a browser page can read them. Headers your app already exposes are kept.
367
+
368
+ Upload routes exist only when local or S3-compatible storage is configured. Consumer-supplied URLs pass through one guarded outbound client that blocks private and cloud-metadata addresses. It also refuses a URL whose raw text holds a control character (C0, DEL or C1), anywhere and in every redirect `Location`. Spaces at the ends are trimmed. Send `POST /upload-by-url` a `{"url":"..."}` body with an `application/json` media type; parameters such as `charset=utf-8` are allowed, but JSON suffix types are not.
327
369
 
328
- Upload routes exist only when local or S3-compatible storage is configured. Consumer-supplied URLs pass through one guarded outbound client that blocks private and cloud-metadata addresses. Send `POST /upload-by-url` a `{"url":"..."}` body with an `application/json` media type; parameters such as `charset=utf-8` are allowed, but JSON suffix types are not.
370
+ Outbound fetches follow at most 5 redirects. A redirect from `https` to `http` is refused. A response head over 64 KiB is refused. When a rate limit is on, each redirect hop counts as one more request for the caller.
371
+
372
+ At most four page fetches (`/unfurl`, `/frame-check`) and four downloads (`/upload-by-url`) run at once, in separate pools, so slow downloads never hold up link checks. When a rate limit is on, one caller (client address, or ticket user) may run at most two of each at once. A third waits for one of that caller's own slots within its own deadline, and fails as any slot wait does when the deadline runs out.
373
+
374
+ `GET /unfurl?url=…` answers `{"success": 1, "link": …, "meta": {"title", "description", "image": {"url"}, "favicon", "domain"}}`. A field with no value is left out, and `image` is then `{}`.
375
+
376
+ - `link` is the final URL after redirects.
377
+ - `image.url` and `favicon` are URLs a reader's browser loads. They pass the same filter as the `/frame-check` `thumbnail_url` below:
378
+ - They must be `https`, with no user name or password, and at most 2048 characters.
379
+ - A control character, raw or escaped (`%00`–`%1F`, `%7F`, `%C2%80`–`%C2%9F`), drops the URL.
380
+ - A loopback, private or metadata address, `localhost`, a name with no dot, a name ending in a dot and a name whose last label starts with a digit are dropped. A Unicode host comes back as punycode.
381
+ - When a candidate is dropped, the next one is tried: `og:image`, then `twitter:image`; `rel="icon"`, then `apple-touch-icon`, then `/favicon.ico` on the final page.
382
+ - So a page served over `http` gets no favicon, unless it names an `https` one.
383
+
384
+ `GET /frame-check?url=…&embedder=…` is open whenever `/unfurl` is. It answers `{"verdict": …, "link": …, "oembed": {…}}`.
385
+
386
+ - `verdict` is `allowed`, `blocked`, `insecure` or `unknown`.
387
+ - `blocked` means the final page's enforced `Content-Security-Policy: frame-ancestors`, or its `X-Frame-Options`, refuses your editor. Report-only policies and `<meta>` tags do not count. Redirect hops do not count.
388
+ - `insecure` means the final page is not `https`. `blocked` wins over it.
389
+ - `unknown` means the server could not tell who your editor is, or the page could not be fetched or did not answer 2xx.
390
+ - `embedder` is your editor page's origin, such as `https://app.example`. The editor's default check sends it. A browser sends no `Origin` on a same-origin `GET`, so this is how a service mounted on your app's own origin (say `/api/blok`) learns who is asking.
391
+ - The server picks your editor like this:
392
+ 1. A request that sends `Origin` uses it, when it is an allowed origin. Any other `Origin` is refused with 403 before the check runs. `embedder` is ignored.
393
+ 2. With no `Origin`, `embedder` is used when the request carries `Sec-Fetch-Site: same-origin` and `embedder` equals the service's own origin: the request's scheme and `Host` header.
394
+ 3. Otherwise `embedder` is used only when it is an allowed origin.
395
+ 4. Otherwise there is no editor, and the verdict is `unknown`.
396
+ - A page cannot set `Sec-Fetch-Site`, so a page on an unlisted site cannot get a verdict through the service. Origins compare without case, and must match exactly: no path, no trailing `/`.
397
+ - Behind a proxy that ends TLS, the service sees `http`, not `https`. Rule 2 then fails, so add your editor's origin to the allowed origins. The Go server reads the scheme from the connection and `Host` from the request. ASP.NET Core reads `Request.Scheme` and `Request.Host`, so it follows your app if your app rewrites them.
398
+ - In `ticket` mode every request needs an allowed `Origin`, so a same-origin check gets 403 and the editor shows `unknown`. Rule 2 helps in `none` and `proxy` modes.
399
+ - The answer sends `Vary: Origin`.
400
+ - `link` is the final URL after redirects.
401
+ - `oembed` appears when the page links a JSON oEmbed endpoint. It holds only `title`, `provider_name`, an `https` `thumbnail_url`, and `width` and `height` capped at 4096. The oEmbed `html` field is never returned.
402
+ - `thumbnail_url` is dropped when it holds a control character, raw or escaped (`%00`–`%1F`, `%7F`, `%C2%80`–`%C2%9F`).
403
+ - It is also dropped when its host is a loopback, private or metadata address, `localhost`, or a name with no dot. A Unicode host comes back as punycode.
404
+ - The server never fetches the thumbnail. Treat it as untrusted input.
405
+
406
+ The verdict is a hint for your UI, not a security check. The server cannot see the reader's cookies, the full chain of frames above your editor, or scripts that break out of frames.
329
407
 
330
408
  A request that carries `Origin` must match an allowed origin in every auth mode. In `none` and `proxy`, a genuinely originless backend request remains allowed, but an originless browser request carrying `Sec-Fetch-Site: cross-site` is rejected. `ticket` always requires an allowed `Origin`.
331
409
 
332
- A ticket with `write: false` may call `GET /unfurl`, `GET /sync/{doc}/state` and open `GET /sync/{doc}` read-only; both upload routes, `reset` and `edit` require `write: true`. The `doc` claim scopes the collaboration routes: `/sync/{doc}`, its `reset`, its `edit` and its `state` are refused when the pass names no document or a different one. `state` asks your `IBlokAuthorization` for read access only. A collaboration pass must also name its `user`: `GET /sync/{doc}` closes one with an empty `user` as 4401 `pass names no user`, because the per-user connection cap and rate window key on that name. The upload and unfurl routes ignore it, so a pass minted for one page works for every upload and preview that page can make.
410
+ With `--auth none` or `proxy`, the standalone host also checks `Host` on these routes. This stops DNS rebinding: a page whose name now points at 127.0.0.1 sends its own name as `Host`.
411
+
412
+ - Allowed: `localhost`, a `127.x.x.x` address and `[::1]`, with any port, and a request with no `Host`.
413
+ - Also allowed: the host of each `--allow-origin` entry and of `--public-url`.
414
+ - Anything else gets `421` `host not allowed`.
415
+
416
+ If a front proxy forwards your public `Host`, add that origin to `--allow-origin`, or make the proxy send a loopback `Host`. Ticket mode does not check `Host`; behind a proxy that is the proxy's job.
417
+
418
+ In your own ASP.NET Core app the check is off. Turn it on with `AddBlokServer(...).UseLoopbackHostCheck()` when the app listens on loopback only. Leave it off when the app serves the routes under its own public name. The Go handler has the same switch: `Options.LoopbackHostCheck`.
419
+
420
+ A ticket with `write: false` may call `GET /unfurl`, `GET /frame-check`, `GET /sync/{doc}/state`, the three `GET` history routes and open `GET /sync/{doc}` read-only; both upload routes, `reset`, `edit`, the history `DELETE` and `restore` require `write: true`. The `doc` claim scopes the collaboration routes: `/sync/{doc}`, its `reset`, its `edit`, its `state` and its history routes are refused when the pass names no document or a different one. `state` and the three history reads ask your `IBlokAuthorization` for read access only. A collaboration pass must also name its `user`: `GET /sync/{doc}` closes one with an empty `user` as 4401 `pass names no user`, because the per-user connection cap and rate window key on that name. The upload and unfurl routes ignore it, so a pass minted for one page works for every upload and preview that page can make.
421
+
422
+ ### Version history
423
+
424
+ A journal-backed document keeps its past. Five routes list it, read one version, list the edits inside one, restore one and delete old history. Blok ships the data and the routes. Your app builds the history UI.
425
+
426
+ A version is a point, the pair `(lineage, sequence)`. A lineage is one unbroken run of the journal. A reset, a first seed or the format migration starts a new one. Sequence 0 is the state the lineage started from. Store points in your own records, never positions in the list: the newest version keeps growing while people edit.
427
+
428
+ | Route | Access | Answer |
429
+ | --- | --- | --- |
430
+ | `GET /sync/{doc}/history` | read | `200 { lineages, versions }` |
431
+ | `GET /sync/{doc}/history/{lineage}/{sequence}` | read | `200 { time?, blocks, title?, icon?, page?, values? }` with `Blok-History-Lineage` and `Blok-History-Sequence` |
432
+ | `GET /sync/{doc}/history/{lineage}/{sequence}/changes?since=` | read | `200 { changes, truncated? }` with `Blok-History-Lineage` and `Blok-History-Sequence` |
433
+ | `POST /sync/{doc}/history/{lineage}/{sequence}/restore` | read and write | As `edit`: `204` with the new `Blok-Doc-Lineage` and `Blok-Doc-Sequence` |
434
+ | `DELETE /sync/{doc}/history/{lineage}` | read and write | `204`; `404` for an unknown lineage; `409` for the current one |
435
+
436
+ The list looks like this:
437
+
438
+ ```json
439
+ {
440
+ "lineages": [{ "lineage": "…", "epoch": 2, "format": 2, "createdAt": 1760000000000, "current": true }],
441
+ "versions": [{ "lineage": "…", "sequence": 41, "startedAt": 1760000000000, "savedAt": 1760000300000, "actors": ["u1"] }]
442
+ }
443
+ ```
444
+
445
+ - Each lineage's baseline is a version at sequence 0.
446
+ - The records after it are grouped by time. A new group starts after a gap of more than 2 minutes, or once a group spans 10 minutes. A group's `sequence` is its last record, and `actors` lists the distinct actor ids in the order they first appear.
447
+ - `?group=1`, `?group=15` or `?group=60` sets both limits to that many minutes. A new group then starts after a gap of more than that window, or once a group spans it. Any other value answers 400, and so do an empty, signed or repeated `group`. The JSON shape does not change.
448
+ - Versions come newest first.
449
+ - Times are Unix milliseconds. An unknown time is `null`, and the point read leaves `time` out.
450
+ - The point read sends the point's page title and icon as top-level `title` and `icon`, as your document endpoint gets them. Each is left out when the point has none.
451
+ - It also sends the point's `page` and `values` maps as plain objects. Only plain JSON keys are listed, the same keys a restore copies. The `page` object never repeats `title` or `icon`. An empty map is left out.
452
+
453
+ The point read's headers are deliberately not `Blok-Doc-*`. Those name the live head and feed `If-Match`. The point read and the changes read send no `ETag`. Both add `Blok-History-Lineage` and `Blok-History-Sequence` to `Access-Control-Expose-Headers` for an allowed origin. On the changes read they name the point in the path. Every answer from the history handlers sends `Cache-Control: no-store`. Guard refusals, 405 answers and preflights come from the route shell all routes share.
454
+
455
+ The changes read lists the edits inside one version. Pass `since` as the sequence of the next older version in the list you show. Only your app knows which grouping that list uses. The answer covers the records after `since`, up to and including `sequence`, oldest first. Without `since` it starts after 0.
456
+
457
+ ```json
458
+ {
459
+ "changes": [
460
+ {
461
+ "sequence": 12,
462
+ "committedAt": 1760000000000,
463
+ "actor": "u1",
464
+ "blocks": [
465
+ {
466
+ "id": "a",
467
+ "type": "paragraph",
468
+ "kind": "changed",
469
+ "before": { "id": "a", "type": "paragraph", "data": { "text": [{ "text": "one" }] } },
470
+ "after": { "id": "a", "type": "paragraph", "data": { "text": [{ "text": "two" }] } }
471
+ }
472
+ ]
473
+ },
474
+ { "sequence": 13, "committedAt": 1760000004000, "actor": null, "blocks": [], "page": ["title", "values.k"] }
475
+ ]
476
+ }
477
+ ```
478
+
479
+ - Each journal record gets one row, even one with no visible edit. Its `blocks` is then empty.
480
+ - `actor` is always there. It is `null` when the record has none.
481
+ - A block's `kind` is `added`, `removed`, `changed` or `moved`. `before` is left out for `added`, and `after` for `removed`. Blocks have the same shape as in the point read.
482
+ - `changed` means the `type`, `data` or `tunes` differ. It wins over `moved`.
483
+ - `moved` means a new parent, or a place outside the largest set of its siblings that kept their relative order. It is the same move test `diffOutputData` uses. A changed block still counts toward that order.
484
+ - `page` lists the changed keys of the `page` map, such as `title` and `icon`. Then it lists changed tracked values as `values.<key>`. It is left out when nothing there changed.
485
+ - One answer holds at most 200 rows and at most 4 MiB of rows. Past that, it holds the newest rows that fit and adds `"truncated": true`. The key is left out otherwise. The newest row always comes, even when it alone is larger.
486
+ - To read older rows, ask again with the oldest row's `sequence` minus 1 in the path and the same `since`.
487
+ - Sequence 0, or a `since` equal to the sequence, answers `{"changes":[]}`.
488
+
489
+ Restore is a forward edit, not a rewind. It runs inside the room, through the same path as `edit`, so every open tab sees it and the old versions stay listed.
490
+
491
+ - It needs a `Blok-Idempotency-Key`. A retry with the same key returns the first receipt. A missing or malformed key answers 400.
492
+ - It takes an optional `If-Match`, checked as for `edit`. A malformed tag answers 400. A stale tag answers 412 and changes nothing, even when the point no longer exists.
493
+ - A restore that changes nothing answers 204 at the current head.
494
+ - A restore whose update would not fit one sync frame answers 413 and changes nothing. The limit is `CollabMaxMessageBytes`. On the built-in journal it is also never above that journal's 1 MiB update limit.
495
+ - A planned change the converter refuses answers 422 and changes nothing.
496
+ - A block whose `type` or `tunes` changed is removed and inserted again.
497
+ - It also makes the `page` map (the built-in title and icon) and the `values` map (`history.track`) match the point. A key whose value differs is set to a copy. A key the point lacks is removed.
498
+ - That map patch is part of the same edit. It lands in the same single journal record and counts toward the 413 limit.
499
+ - A key whose value at the point is not plain JSON, such as a nested Yjs type, is left as it is.
500
+
501
+ The history routes can also answer the statuses below. The list has no lineage or sequence in its path, so it never answers 404.
502
+
503
+ | Status | When |
504
+ | --- | --- |
505
+ | 400 | The sequence is not an unsigned 64-bit whole number. Or the list's `group` is not 1, 15 or 60. Or the changes read's `since` is not a whole number, or is above the sequence. |
506
+ | 403 | The document was purged. |
507
+ | 404 | The lineage is unknown or is not 32 lowercase hex characters, or the sequence is past its durable head. |
508
+ | 500 | A stored manifest, ledger or journal could not be decoded, on the list too. Or a replay failed, or the export after it. The server logs it. |
509
+ | 501 | `history needs a journal that keeps it`: there is no journal, or your store does not keep history. |
510
+ | 503 with `Retry-After` | The converter failed for a moment, as on `state`. |
511
+
512
+ History needs a journal that keeps it. `--collab-journal` does. A store you register keeps history only if it also implements `ICollabOperationHistoryStore` (`Blok.Server.Collab`). The service finds it by a type check on your `ICollabOperationStore`, which does not change. The interface lists lineages, reads a baseline, streams record headers and records, and deletes a lineage. Its reads take no fence and no document lock, because a live room keeps writing while history is read. It also has `IsPurgedAsync`, which you must implement. It must answer without a lock, and it must still report a purge after a restart. Lineage values come from requests, so compare them and never build paths from them.
513
+
514
+ History starts at this release. The built-in journal never stored the lineage, epoch and format of the generations it finished before, so it cannot list them. Its oldest listed lineage is the one current when this release first touches the document.
515
+
516
+ Blok has no retention policy, and it never deletes history by itself. A lineage is kept whole or deleted whole. To trim, call `POST /sync/{doc}/reset`, then `DELETE` the lineage that was current before it. On the built-in journal, deleting the lineage just before the current one also gives up its fallback copy, which only matters after disk corruption.
333
517
 
334
518
  ## Rich text is segments
335
519
 
@@ -424,7 +608,7 @@ What the service requires of it:
424
608
  - **Cancellation belongs to the caller.** A store-side timeout or abort surfaces as some other exception, because the caller reads a cancellation it did not ask for as its own shutdown.
425
609
  - **Disposal releases the fence, unless it is already gone.** `DisposeAsync` lets another process open the document, and never throws because the fence was already lost; every method throws `ObjectDisposedException` afterwards. A session that HAS lost the fence releases nothing. The fence it would release now belongs to somebody else, so a `DisposeAsync` that unconditionally drops its lock row hands a third writer the document while the second is mid-write.
426
610
 
427
- A backend that is not .NET implements the wire protocol instead of this interface. `packages/server/protocol/blok-sync-v2.md` is a normative spec written so a server outside this repository can be built from it alone, and the frame vectors it pins live in `test/unit/server-conformance/fixtures/sync-frames.json`. This repository's conformance runner builds and drives the C# host only (`node scripts/test-server-conformance.mjs --target csharp`). Another backend runs those vectors in its own harness, along with the same durability scenarios: restart the process, fail the next append, inspect history.
611
+ A backend that is not .NET implements the wire protocol instead of this interface. `packages/server/protocol/blok-sync-v2.md` is a normative spec written so a server outside this repository can be built from it alone, and the frame vectors it pins live in `test/unit/server-conformance/fixtures/sync-frames.json`. This repository's conformance runner builds and drives the C# host (`node scripts/test-server-conformance.mjs --target csharp`) and the Go host (`--target go`). Another backend runs those vectors in its own harness, along with the same durability scenarios: restart the process, fail the next append, inspect history.
428
612
 
429
613
  Stock `y-websocket` never offers `blok-sync.v2`, so it negotiates v1 and is compatible with the working-copy profile alone: ordinary y-protocol sync, no acknowledgement, no durability claim. On a journal-backed document a v1 write is still journaled before it is relayed; it earns no receipt. The same holds for any client that offers only v1.
430
614
 
@@ -548,7 +732,36 @@ dotnet format packages/server/dotnet/Blok.Server.slnx --verify-no-changes
548
732
  dotnet restore packages/server/dotnet/Blok.Server.slnx
549
733
  ```
550
734
 
551
- CI collects merged production coverage and requires at least 80% line and 80% branch coverage. It also runs the SDK analyzers with warnings as errors, and audits all direct and transitive NuGet packages. It scans committed secrets with Gitleaks, scans the server tree and built image with Trivy, and analyzes C# with CodeQL. Dependabot keeps NuGet, Docker, and GitHub Actions dependencies current.
735
+ CI collects merged production coverage and requires at least 80% line and 80% branch coverage. It also runs the SDK analyzers with warnings as errors, and audits all direct and transitive NuGet packages. It scans committed secrets with Gitleaks, scans the server tree and built image with Trivy, and analyzes C# with CodeQL. Dependabot keeps NuGet, Go, Docker, and GitHub Actions dependencies current.
736
+
737
+ The Go server in `packages/server/go` has its own CI checks:
738
+
739
+ - `gofmt` must report no unformatted file.
740
+ - `go vet` must pass.
741
+ - `govulncheck` (pinned to v1.9.0) must find no reachable vulnerability.
742
+ - `go test` must cover at least 80% of statements. Go measures statements only, so there is no Go branch floor.
743
+ - Every conformance suite runs with `--target go`. `--target interop` checks that each server serves a collaboration directory the other wrote.
744
+ - `node scripts/test-server-go-module.mjs` builds `test/fixtures/go-server-consumer` from the files git tracks in the module. The fixture mounts Blok under `/api/blok` behind its own auth, converts a document, and fills the collab seams.
745
+
746
+ CodeQL analyzes the Go code in the weekly scan, not on every push.
747
+
748
+ ```bash
749
+ cd packages/server/go
750
+ gofmt -l .
751
+ go vet ./...
752
+ go test -p 2 ./...
753
+ ```
754
+
755
+ ## Go module and archives
756
+
757
+ The release workflow ships the Go server in two forms. No release has shipped them yet.
758
+
759
+ - **Module.** `yarn release X.Y.Z` also tags `packages/server/go/vX.Y.Z` on the release commit. Go reads a module in a subdirectory only from tags with that prefix. The module path is `github.com/JackUait/blok/packages/server/go/v2`. Import `blokserver` for the routes and `blokdocs` for the converter. Your own operation store and activity observer go in `Options.CollabOperationStore` and `Options.CollabActivityObserver`. Their types are in `blokserver/collabstore`. To put your own auth in front of the routes, use `Server.HandlerWithAuth(prefix, auth)`. It works like `RequireAuthorization()` on the C# group: health and CORS preflights stay open.
760
+ - **Archives.** The GitHub release gets `blok-server-go_<os>_<arch>` for macOS, Linux and Windows, on amd64 and arm64, with `checksums-go.txt`. They are built with cgo off, so they are static. The release starts the Linux amd64 one on Alpine before it uploads.
761
+
762
+ The binary reports its release version on `/health`. A local `go build` reports `dev`.
763
+
764
+ These names never overlap the C# archives or `checksums.txt`. The npm wrapper downloads only the C# archives, and a test fails if it ever names a Go one.
552
765
 
553
766
  ## Docs
554
767
 
package/dist/ticket.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let node_crypto = require("node:crypto");
3
- //#region src/ticket.ts
3
+ // src/ticket.ts
4
4
  /** Must match BlokTicket.MinimumSecretLength (C#), the floor BlokServerOptions.Validate enforces, or the service refuses to start. */
5
5
  var MIN_SECRET_LENGTH = 32;
6
6
  var DEFAULT_TTL_SECONDS = 300;
@@ -36,5 +36,4 @@ function blokTicket(secret, claims) {
36
36
  function b64(value) {
37
37
  return Buffer.from(value, "utf-8").toString("base64url");
38
38
  }
39
- //#endregion
40
39
  exports.blokTicket = blokTicket;
package/dist/ticket.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHmac } from "node:crypto";
2
- //#region src/ticket.ts
2
+ // src/ticket.ts
3
3
  /** Must match BlokTicket.MinimumSecretLength (C#), the floor BlokServerOptions.Validate enforces, or the service refuses to start. */
4
4
  var MIN_SECRET_LENGTH = 32;
5
5
  var DEFAULT_TTL_SECONDS = 300;
@@ -35,5 +35,4 @@ function blokTicket(secret, claims) {
35
35
  function b64(value) {
36
36
  return Buffer.from(value, "utf-8").toString("base64url");
37
37
  }
38
- //#endregion
39
38
  export { blokTicket };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloklabs/server",
3
- "version": "1.16.0",
3
+ "version": "2.0.0",
4
4
  "description": "Blok server delivery for ASP.NET Core, npm, and Docker — shared C# routes for file uploads and link previews.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "JackUait",