@bloklabs/server 1.11.0 → 1.13.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
@@ -1,21 +1,183 @@
1
1
  # @bloklabs/server
2
2
 
3
- A small Go sidecar for [Blok](https://blokeditor.com). It answers the two requests a rich-text editor cannot make from the browser — storing an uploaded file, and reading the title/description/image of a pasted link — so you do not write a backend for either.
3
+ Blok's shared C# server handles file uploads, link previews and live collaboration, and 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
- It stores no documents. There is no database, and nothing here is a hosted service you depend on: you run it.
5
+ Documents stay yours: saving and loading them is a small endpoint in your own app, and that endpoint remains the record. With live collaboration on, the service keeps only a working copy of each open document, in storage you point it at, and writes what people type back to your endpoint every few seconds. The packages include no database-block or MySQL integration; those follow this delivery migration.
6
6
 
7
- ## Run it
7
+ ## ASP.NET Core
8
+
9
+ Install the public integration package. NuGet brings in `Blok.Server` transitively.
10
+
11
+ ```bash
12
+ dotnet add package Blok.Server.AspNetCore
13
+ ```
14
+
15
+ Register the services, use your application's authorization policy, and map the routes under one prefix:
16
+
17
+ ```csharp
18
+ using Blok.Server.AspNetCore;
19
+
20
+ builder.Services.AddBlokServer(options =>
21
+ {
22
+ options.StorageDirectory = "./blok-uploads";
23
+ options.PublicUrl = "https://uploads.example.com/files";
24
+ options.UnfurlDisabled = false;
25
+ });
26
+
27
+ var app = builder.Build();
28
+
29
+ app.MapBlokServer("/api/blok").RequireAuthorization();
30
+ app.Run();
31
+ ```
32
+
33
+ The mapped group uses your ASP.NET Core authorization policy for upload and unfurl routes. Health and validated CORS preflight remain anonymous.
34
+
35
+ 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.
36
+
37
+ ## Convert documents
38
+
39
+ `Blok.Server` carries Blok's own serializer as an embedded JavaScript bundle and runs it in this process, so a document converts through the editor's implementation rather than a port of it. A port has to be taught every block Blok gains and silently drops the ones nobody remembered.
40
+
41
+ Conversion needs no storage, no outbound access and no route, so it registers on its own:
42
+
43
+ ```csharp
44
+ using Blok.Server.Documents;
45
+
46
+ builder.Services.AddBlokDocuments();
47
+ ```
48
+
49
+ `AddBlokServer` already includes it. Outside dependency injection, `BlokDocuments.Create()` returns the same thing.
50
+
51
+ ```csharp
52
+ public sealed class ArticleExport(IBlokDocumentConverter blok, ILogger<ArticleExport> logger)
53
+ {
54
+ public async Task<string> ToMarkdownAsync(string documentJson, CancellationToken ct)
55
+ {
56
+ var conversion = await blok.ToMarkdownAsync(documentJson, ct);
57
+
58
+ foreach (var warning in conversion.Warnings)
59
+ {
60
+ // e.g. construct "callout", action "degraded", detail "rendered as a blockquote…"
61
+ logger.LogInformation("{Construct} {Action}: {Detail}", warning.Construct, warning.Action, warning.Detail);
62
+ }
63
+
64
+ return conversion.Markdown;
65
+ }
66
+ }
67
+ ```
68
+
69
+ | Method | Returns |
70
+ |--------|---------|
71
+ | `ToMarkdownAsync` | the Markdown, plus every construct Markdown could not carry |
72
+ | `ToHtmlAsync` | the document's HTML |
73
+ | `ToPlainTextAsync` | the document's readable text |
74
+ | `FromMarkdownAsync` | the saved document, plus what Markdown could not carry into it |
75
+ | `ExtractTextsAsync` / `InjectTextsAsync` | the document's translatable strings, and the document with them put back |
76
+ | `GetVersionAsync` | the `version` the editor stamps into a saved document |
77
+ | `GetSchemaAsync` | the saved format as JSON Schema (draft 2020-12) |
78
+
79
+ ### Translate a document without handing a model its JSON
80
+
81
+ A model asked to translate a document's JSON breaks the structure — it drops
82
+ ids, reorders blocks, invents fields. Take the strings out instead, translate
83
+ the list, and put it back; the model never sees the structure, so it cannot
84
+ break it.
85
+
86
+ ```csharp
87
+ var texts = await blok.ExtractTextsAsync(documentJson, cancellationToken: ct);
88
+ var translated = await TranslateAsync(texts, ct); // your own model call
89
+ var document = await blok.InjectTextsAsync(documentJson, translated, cancellationToken: ct);
90
+ ```
91
+
92
+ The list is in document order, skips empty values, and holds no URLs — nor a
93
+ file's name, which is what the reader downloads rather than prose. Code blocks
94
+ are out by default; pass `includeCode: true` to both calls if you want them.
95
+ A list whose length does not match the document is an `ArgumentException`
96
+ rather than a silently misplaced translation, and a block too malformed to read
97
+ is carried through untouched — the result of `InjectTextsAsync` is what you
98
+ store.
99
+
100
+ ### Stamp the version the editor stamps
101
+
102
+ `version` in a saved document is whatever wrote it. A service writing documents
103
+ outside the browser should ask rather than invent a number, or the same column
104
+ ends up holding two different answers:
105
+
106
+ ```csharp
107
+ var document = new JsonObject
108
+ {
109
+ ["time"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
110
+ ["blocks"] = blocks,
111
+ ["version"] = await blok.GetVersionAsync(ct),
112
+ };
113
+ ```
114
+
115
+ Markdown cannot express every block — a callout becomes a blockquote, columns flatten, a spacer disappears — so both directions report what changed. A caller handing the result to something that cannot ask a follow-up question, an export or a model, should read that report rather than assume the round trip was lossless.
116
+
117
+ An instance holds a pool of engines and is expensive to construct — every engine parses the embedded bundle, about a second in total — so register one for the lifetime of the process. `AddBlokDocuments` builds it at startup rather than during the first request; pass `warmUp: false` where a host starts often and converts rarely, such as a test host. The pool size bounds how many documents convert at once; further callers wait. A conversion is bounded by a timeout and a recursion limit, so a pathological document fails rather than wedging the process.
118
+
119
+ ## Standalone
120
+
121
+ Run the self-contained host through npm:
8
122
 
9
123
  ```bash
10
124
  npx @bloklabs/server --listen 127.0.0.1:4000
11
125
  ```
12
126
 
13
- The npm package is a wrapper. On first run it downloads the binary for your platform from the matching GitHub release, checks it against the release's `checksums.txt`, and caches it. If your network blocks that download, the container image is the same build:
127
+ The npm package is a small wrapper. On first run it downloads the C# host for macOS, Windows or Linux (including Alpine/musl), verifies it against `checksums.txt`, and caches it. Both x64 and arm64 are published. The host is fully managed: every archive and the NuGet package are built from managed code alone, with no native library, so there is no extraction directory and nothing to set for a service account with no home.
128
+
129
+ The same host is available at the existing image name. In proxy mode it must stay on loopback, so this example uses the host network. The named volume keeps uploads in the image's writable `/data` directory:
14
130
 
15
131
  ```bash
16
- docker run -p 4000:4000 ghcr.io/jackuait/blok-server --listen 0.0.0.0:4000
132
+ docker run --rm \
133
+ --network host \
134
+ --mount type=volume,source=blok-server-data,target=/data \
135
+ ghcr.io/jackuait/blok-server \
136
+ --listen 127.0.0.1:4000 \
137
+ --auth proxy \
138
+ --storage-dir /data \
139
+ --public-url https://uploads.example.com/files
17
140
  ```
18
141
 
142
+ For an internet-facing deployment, use ticket authentication and publish the container port only on loopback for the local reverse proxy:
143
+
144
+ ```bash
145
+ docker run --rm \
146
+ -p 127.0.0.1:4000:4000 \
147
+ --mount type=volume,source=blok-server-data,target=/data \
148
+ -e BLOK_SECRET \
149
+ ghcr.io/jackuait/blok-server \
150
+ --listen 0.0.0.0:4000 \
151
+ --auth ticket \
152
+ --allow-origin https://myapp.com \
153
+ --storage-dir /data \
154
+ --public-url https://uploads.example.com/files
155
+ ```
156
+
157
+ Set `BLOK_SECRET` to a random value of at least 32 characters. Put the service behind a reverse proxy or hosting platform that terminates TLS before forwarding plain HTTP to it; the host does not manage certificates. The process refuses unsafe public configurations instead of starting with a warning.
158
+
159
+ The same service, with live collaboration:
160
+
161
+ ```bash
162
+ docker run \
163
+ -p 127.0.0.1:4000:4000 \
164
+ --mount type=volume,source=blok-server-data,target=/data \
165
+ --mount type=volume,source=blok-collab,target=/collab \
166
+ -e BLOK_SECRET \
167
+ -e BLOK_DOC_ENDPOINT_AUTH \
168
+ ghcr.io/jackuait/blok-server \
169
+ --listen 0.0.0.0:4000 \
170
+ --auth ticket \
171
+ --allow-origin https://myapp.com \
172
+ --storage-dir /data \
173
+ --public-url https://blok.myapp.com/files \
174
+ --collab \
175
+ --collab-dir /collab \
176
+ --doc-endpoint https://myapp.com/api/documents
177
+ ```
178
+
179
+ `--collab` turns the sync routes on. `--doc-endpoint` names the routes in your own app 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, and 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 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()`.
180
+
19
181
  ## Point the editor at it
20
182
 
21
183
  ```ts
@@ -23,43 +185,74 @@ import { Blok } from '@bloklabs/core';
23
185
 
24
186
  new Blok({
25
187
  holder: 'editor',
26
- uploader: {
27
- async uploadByFile(file) {
28
- const body = new FormData();
188
+ server: '/api/blok',
189
+ });
190
+ ```
191
+
192
+ One key fills in the uploader and the link-preview endpoint. Anything you set yourself wins, so uploading into your own storage while keeping previews here needs no extra wiring.
193
+
194
+ The standalone host uses routes at the root. An ASP.NET Core app uses the prefix passed to `MapBlokServer`.
195
+
196
+ ## Access passes
197
+
198
+ A standalone host cannot see who your user is, so your own backend vouches for them with a short-lived pass:
199
+
200
+ ```ts
201
+ import { blokTicket } from '@bloklabs/server/ticket';
202
+
203
+ export async function GET() {
204
+ const session = await getSession();
29
205
 
30
- body.append('file', file);
206
+ if (!session) {
207
+ return new Response('Not signed in', { status: 401 });
208
+ }
31
209
 
32
- const res = await fetch('http://127.0.0.1:4000/upload', { method: 'POST', body });
210
+ return Response.json({
211
+ ticket: blokTicket(process.env.BLOK_SECRET, { user: session.userId, write: true }),
212
+ });
213
+ }
214
+ ```
33
215
 
34
- return res.json();
35
- },
36
- },
216
+ ```ts
217
+ new Blok({
218
+ holder: 'editor',
219
+ server: 'https://blok.myapp.com',
220
+ ticket: '/api/blok-ticket',
37
221
  });
38
222
  ```
39
223
 
40
- The response shapes match what Blok's own parsers already expect, so nothing on the editor side needs configuring beyond the URL.
224
+ The editor caches the pass and replaces it ahead of expiry, and uploads and link previews share the same one.
225
+
226
+ A pass is a plain HS256 JWT carrying `user`, `doc`, `write` and `exp`, signed with the secret the service runs with (at least 32 characters). Any backend can mint one with its own JWT library — `blokTicket` exists so a JavaScript one does not have to. Routes running inside your own ASP.NET app need none of this: they already know who the caller is.
41
227
 
42
228
  ## Routes
43
229
 
44
230
  | Route | What it does |
45
231
  | --- | --- |
46
- | `GET /health` | Liveness plus the running version |
47
- | `GET /unfurl?url=…` | Title, description and image for a pasted link |
48
- | `POST /upload` | Stores an uploaded file, returns where it landed |
49
- | `POST /upload-by-url` | Fetches a remote URL server-side and re-hosts it |
232
+ | `GET /health` | Reports liveness and the running version |
233
+ | `GET /unfurl?url=…` | Reads title, description, and image metadata |
234
+ | `POST /upload` | Stores an uploaded file |
235
+ | `POST /upload-by-url` | Fetches and stores a remote file; the request media type must be `application/json` |
236
+ | `GET /sync/{doc}` | WebSocket; the editor's live collaboration connection to one document (with `--collab`) |
237
+ | `POST /sync/{doc}/reset` | Drops the working copy, reloads the document from your endpoint and tells every open tab to pick it up |
238
+ | `POST /sync/{doc}/edit` | Inserts, updates or removes blocks from outside; all-or-nothing, and reaches every open tab |
239
+
240
+ 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.
50
241
 
51
- Both routes that fetch a URL the browser supplied go through one SSRF-guarded HTTP client, so a pasted `http://169.254.169.254/…` cannot reach your cloud metadata endpoint.
242
+ 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`. A ticket with `write: false` may call `GET /unfurl` 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` and its `edit` are refused when the pass names no document or a different one. 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.
52
243
 
53
- ## Access modes
244
+ ## Quality gates
54
245
 
55
- `--auth` picks who may call the service, and the process refuses to start on a combination that would leave it open:
246
+ The .NET solution keeps three test layers: `Blok.Server.Tests` for core behavior, `Blok.Server.AspNetCore.Tests` for in-process integration, and `Blok.Server.Host.Tests` for real-process end-to-end behavior. CI also runs the cross-runtime conformance and package smoke tests.
56
247
 
57
- - `none` — no check, and therefore only allowed to bind loopback.
58
- - `proxy` — trusts every caller, for when something in front of it already authenticated them; loopback only for the same reason.
59
- - `ticket` — each request carries a short-lived pass your app signs with the shared secret. The only mode allowed to bind a public address.
248
+ ```bash
249
+ dotnet test packages/server/dotnet/Blok.Server.slnx --configuration Release
250
+ dotnet format packages/server/dotnet/Blok.Server.slnx --verify-no-changes
251
+ dotnet restore packages/server/dotnet/Blok.Server.slnx
252
+ ```
60
253
 
61
- Storage is a local directory by default (`--storage-dir`), or any S3-compatible bucket via `--s3-bucket` and friends. Credentials are read from `BLOK_S3_ACCESS_KEY` / `BLOK_S3_SECRET_KEY` — never from flags, which land in process listings.
254
+ 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, audits all direct and transitive NuGet packages, 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.
62
255
 
63
256
  ## Docs
64
257
 
65
- Full flag reference and deployment notes: [https://blokeditor.com/docs](https://blokeditor.com/docs)
258
+ Full configuration and deployment guidance: [https://blokeditor.com/docs](https://blokeditor.com/docs)
@@ -1,17 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * npm wrapper for the blok-server Go binary.
3
+ * npm wrapper for the published blok-server binary.
4
4
  *
5
- * The package ships no binary. On first run it resolves the archive goreleaser
6
- * published for this platform on the GitHub release matching this package's
7
- * version, verifies it against that release's checksums.txt, unpacks it into a
8
- * cache directory, and execs it. Later runs go straight to the cached binary.
5
+ * The package ships no binary. On first run it resolves the archive published
6
+ * for this platform on the matching GitHub release, verifies checksums.txt,
7
+ * unpacks it into a cache directory, and runs it. Later runs use the cache.
9
8
  *
10
- * The version is the family version, not a server version: every release moves
11
- * it whether or not the Go sources changed, which is why .github/workflows/
12
- * release-server.yml publishes archives on EVERY tag and skips only the image.
9
+ * The release and npm package use the same family version.
13
10
  */
14
- import { spawnSync } from 'node:child_process';
11
+ import { spawn, spawnSync } from 'node:child_process';
15
12
  import { createHash } from 'node:crypto';
16
13
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
17
14
  import { homedir, tmpdir } from 'node:os';
@@ -21,31 +18,54 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
21
18
  const REPO = 'https://github.com/JackUait/blok';
22
19
  const IMAGE = 'ghcr.io/jackuait/blok-server';
23
20
  const CHECKSUMS_FILE = 'checksums.txt';
21
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
24
22
 
25
23
  const here = dirname(fileURLToPath(import.meta.url));
26
24
 
27
- /** Mirrors the goos/goarch matrix in .goreleaser.yaml. */
28
25
  const PLATFORMS = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
29
26
  const ARCHITECTURES = { x64: 'amd64', arm64: 'arm64' };
27
+ const RUNTIMES = {
28
+ darwin: { x64: 'osx-x64', arm64: 'osx-arm64' },
29
+ linux: { x64: 'linux-x64', arm64: 'linux-arm64' },
30
+ win32: { x64: 'win-x64', arm64: 'win-arm64' },
31
+ };
32
+
33
+ /**
34
+ * @param {{ header?: { glibcVersionRuntime?: string } }} [report] Node process report.
35
+ * @returns {'glibc' | 'musl'} Linux C library.
36
+ */
37
+ export function detectLinuxLibc(
38
+ report = process.report === undefined ? undefined : process.report.getReport(),
39
+ ) {
40
+ return report?.header?.glibcVersionRuntime === undefined ? 'musl' : 'glibc';
41
+ }
30
42
 
31
43
  /**
32
44
  * @param {string} platform - process.platform
33
45
  * @param {string} arch - process.arch
34
- * @returns {{ os: string, arch: string, archive: string, binary: string } | null}
46
+ * @param {'glibc' | 'musl'} [libc] Linux C library.
47
+ * @returns {{ os: string, arch: string, rid: string, archive: string, binary: string } | null}
35
48
  */
36
- export function resolveTarget(platform, arch) {
49
+ export function resolveTarget(platform, arch, libc = 'glibc') {
37
50
  const os = PLATFORMS[platform];
38
- const goarch = ARCHITECTURES[arch];
51
+ const archiveArch = ARCHITECTURES[arch];
52
+ const rid = platform === 'linux' && libc === 'musl'
53
+ ? RUNTIMES.linux?.[arch]?.replace('linux-', 'linux-musl-')
54
+ : RUNTIMES[platform]?.[arch];
39
55
 
40
- if (os === undefined || goarch === undefined) {
56
+ if (os === undefined || archiveArch === undefined || rid === undefined) {
41
57
  return null;
42
58
  }
43
59
 
44
- // Must match archives.name_template + format_overrides in .goreleaser.yaml.
60
+ const archiveOs = platform === 'linux' && libc === 'musl'
61
+ ? 'linux_musl'
62
+ : os;
63
+
45
64
  return {
46
65
  os,
47
- arch: goarch,
48
- archive: `blok-server_${os}_${goarch}.${os === 'windows' ? 'zip' : 'tar.gz'}`,
66
+ arch: archiveArch,
67
+ rid,
68
+ archive: `blok-server_${archiveOs}_${archiveArch}.${os === 'windows' ? 'zip' : 'tar.gz'}`,
49
69
  binary: os === 'windows' ? 'blok-server.exe' : 'blok-server',
50
70
  };
51
71
  }
@@ -57,8 +77,8 @@ export function supportedTargets() {
57
77
  }
58
78
 
59
79
  /**
60
- * Read one asset's expected digest out of a goreleaser checksums file, whose
61
- * lines are `<sha256><two spaces><filename>`.
80
+ * Read one asset's expected digest from a release checksums file, whose lines
81
+ * are `<sha256><two spaces><filename>`.
62
82
  *
63
83
  * @param {string} contents - checksums.txt body
64
84
  * @param {string} assetName - the archive filename to look up
@@ -101,40 +121,55 @@ export function cacheRoot(env, platform) {
101
121
  }
102
122
 
103
123
  /**
104
- * First of the candidate roots that actually accepts a write. A cache that
105
- * cannot be written is not an error yet — the temp directory is a fine second
106
- * home for a single run — so this only throws when nothing is writable.
124
+ * Use the persistent cache when it is writable. Otherwise make a private,
125
+ * per-run directory so another local user cannot predict the launcher path.
107
126
  *
108
- * @param {string[]} candidates
109
- * @returns {string}
127
+ * @param {string} persistentRoot
128
+ * @param {string} temporaryParent
129
+ * @returns {{ root: string, temporary: boolean }}
110
130
  */
111
- function firstWritable(candidates) {
112
- const failures = [];
131
+ export function prepareInstallRoot(persistentRoot, temporaryParent = tmpdir()) {
132
+ let persistentFailure;
113
133
 
114
- for (const candidate of candidates) {
115
- try {
116
- mkdirSync(candidate, { recursive: true });
134
+ try {
135
+ mkdirSync(persistentRoot, { recursive: true });
117
136
 
118
- const probe = join(candidate, `.write-probe-${process.pid}`);
137
+ const probe = mkdtempSync(join(persistentRoot, '.write-probe-'));
119
138
 
120
- writeFileSync(probe, '');
121
- rmSync(probe, { force: true });
139
+ rmSync(probe, { recursive: true, force: true });
122
140
 
123
- return candidate;
124
- } catch (error) {
125
- failures.push(`${candidate} (${error.message})`);
126
- }
141
+ return { root: persistentRoot, temporary: false };
142
+ } catch (error) {
143
+ persistentFailure = `${persistentRoot} (${error.message})`;
127
144
  }
128
145
 
129
- throw new Error(`no writable cache directory: ${failures.join('; ')}`);
146
+ let temporaryRoot;
147
+
148
+ try {
149
+ temporaryRoot = mkdtempSync(join(temporaryParent, 'blok-server-'));
150
+ chmodSync(temporaryRoot, 0o700);
151
+
152
+ return { root: temporaryRoot, temporary: true };
153
+ } catch (error) {
154
+ if (temporaryRoot !== undefined) {
155
+ rmSync(temporaryRoot, { recursive: true, force: true });
156
+ }
157
+
158
+ throw new Error(
159
+ `no writable cache directory: ${persistentFailure}; ${temporaryParent} (${error.message})`,
160
+ );
161
+ }
130
162
  }
131
163
 
132
164
  /**
133
165
  * @param {string} url
166
+ * @param {typeof fetch} fetchImpl
134
167
  * @returns {Promise<Buffer>}
135
168
  */
136
- async function download(url) {
137
- const response = await fetch(url);
169
+ async function download(url, fetchImpl) {
170
+ const response = await fetchImpl(url, {
171
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
172
+ });
138
173
 
139
174
  if (!response.ok) {
140
175
  throw new Error(`GET ${url} → ${response.status} ${response.statusText}`);
@@ -151,13 +186,19 @@ async function download(url) {
151
186
  * any point leaves a scratch directory, never a truncated binary at the path the
152
187
  * next run would execute.
153
188
  *
154
- * @param {{ version: string, target: ReturnType<typeof resolveTarget>, destination: string }} opts
189
+ * @param {{
190
+ * version: string,
191
+ * target: NonNullable<ReturnType<typeof resolveTarget>>,
192
+ * destination: string,
193
+ * fetchImpl?: typeof fetch,
194
+ * }} opts
155
195
  */
156
- async function fetchBinary({ version, target, destination }) {
196
+ export async function installBinary({ version, target, destination, fetchImpl = fetch }) {
157
197
  const base = `${REPO}/releases/download/v${version}`;
158
- // A 404 here means an unsigned download, so it aborts the run rather than
159
- // falling through to "verification unavailable, running it anyway".
160
- const checksums = (await download(`${base}/${CHECKSUMS_FILE}`)).toString('utf-8');
198
+ // A 404 here means an unsigned download, so the run must stop.
199
+ const checksums = (
200
+ await download(`${base}/${CHECKSUMS_FILE}`, fetchImpl)
201
+ ).toString('utf-8');
161
202
  const expected = checksumFor(checksums, target.archive);
162
203
 
163
204
  if (expected === null) {
@@ -167,7 +208,7 @@ async function fetchBinary({ version, target, destination }) {
167
208
  const scratch = mkdtempSync(join(dirname(destination), '.download-'));
168
209
 
169
210
  try {
170
- const archive = await download(`${base}/${target.archive}`);
211
+ const archive = await download(`${base}/${target.archive}`, fetchImpl);
171
212
  const actual = createHash('sha256').update(archive).digest('hex');
172
213
 
173
214
  if (actual !== expected) {
@@ -212,6 +253,38 @@ async function fetchBinary({ version, target, destination }) {
212
253
  }
213
254
  }
214
255
 
256
+ /**
257
+ * Run the native host while keeping the npm wrapper responsive to termination.
258
+ *
259
+ * @param {string} binary
260
+ * @param {string[]} args
261
+ * @returns {Promise<{ code: number | null, signal: NodeJS.Signals | null }>}
262
+ */
263
+ export function runBinary(binary, args) {
264
+ const child = spawn(binary, args, { stdio: 'inherit' });
265
+
266
+ return new Promise((resolvePromise, reject) => {
267
+ const forwardSigint = () => child.kill('SIGINT');
268
+ const forwardSigterm = () => child.kill('SIGTERM');
269
+ const removeSignalHandlers = () => {
270
+ process.off('SIGINT', forwardSigint);
271
+ process.off('SIGTERM', forwardSigterm);
272
+ };
273
+
274
+ process.on('SIGINT', forwardSigint);
275
+ process.on('SIGTERM', forwardSigterm);
276
+
277
+ child.once('error', (error) => {
278
+ removeSignalHandlers();
279
+ reject(error);
280
+ });
281
+ child.once('exit', (code, signal) => {
282
+ removeSignalHandlers();
283
+ resolvePromise({ code, signal });
284
+ });
285
+ });
286
+ }
287
+
215
288
  /**
216
289
  * @param {string} version
217
290
  * @param {string} reason
@@ -222,9 +295,7 @@ export function fallbackMessage(version, reason) {
222
295
  `blok-server: ${reason}`,
223
296
  '',
224
297
  'The same build is published as a container image — run that instead:',
225
- // The service defaults to loopback, which nothing outside the container can
226
- // reach, so the published port needs an explicit bind address to match.
227
- ` docker run -p 4000:4000 ${IMAGE}:${version} --listen 0.0.0.0:4000`,
298
+ ` docker run --network host ${IMAGE}:${version} --listen 127.0.0.1:4000 --auth proxy`,
228
299
  '',
229
300
  `Or download it by hand: ${REPO}/releases/tag/v${version}`,
230
301
  ].join('\n');
@@ -232,7 +303,8 @@ export function fallbackMessage(version, reason) {
232
303
 
233
304
  async function main() {
234
305
  const { version } = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf-8'));
235
- const target = resolveTarget(process.platform, process.arch);
306
+ const libc = process.platform === 'linux' ? detectLinuxLibc() : 'glibc';
307
+ const target = resolveTarget(process.platform, process.arch, libc);
236
308
 
237
309
  if (target === null) {
238
310
  console.error(fallbackMessage(
@@ -243,31 +315,47 @@ async function main() {
243
315
  }
244
316
 
245
317
  let binary;
318
+ let temporaryRoot;
246
319
 
247
320
  try {
248
- const root = firstWritable([
249
- join(cacheRoot(process.env, process.platform), version),
250
- join(tmpdir(), 'blok-server', version),
251
- ]);
321
+ const prepared = prepareInstallRoot(
322
+ join(cacheRoot(process.env, process.platform), version, target.rid),
323
+ );
252
324
 
253
- binary = join(root, target.binary);
325
+ binary = join(prepared.root, target.binary);
326
+ temporaryRoot = prepared.temporary ? prepared.root : undefined;
254
327
 
255
328
  if (!existsSync(binary)) {
256
- await fetchBinary({ version, target, destination: binary });
329
+ await installBinary({ version, target, destination: binary });
257
330
  }
258
331
  } catch (error) {
332
+ if (temporaryRoot !== undefined) {
333
+ rmSync(temporaryRoot, { recursive: true, force: true });
334
+ }
335
+
259
336
  console.error(fallbackMessage(version, error.message));
260
337
  process.exit(1);
261
338
  }
262
339
 
263
- const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
340
+ let result;
264
341
 
265
- if (result.error) {
266
- console.error(fallbackMessage(version, `could not start ${binary}: ${result.error.message}`));
342
+ try {
343
+ result = await runBinary(binary, process.argv.slice(2));
344
+ } catch (error) {
345
+ console.error(fallbackMessage(version, `could not start ${binary}: ${error.message}`));
267
346
  process.exit(1);
347
+ } finally {
348
+ if (temporaryRoot !== undefined) {
349
+ rmSync(temporaryRoot, { recursive: true, force: true });
350
+ }
351
+ }
352
+
353
+ if (result.signal !== null) {
354
+ process.kill(process.pid, result.signal);
355
+ return;
268
356
  }
269
357
 
270
- process.exit(result.status ?? 1);
358
+ process.exit(result.code ?? 1);
271
359
  }
272
360
 
273
361
  // Importing this file (the unit tests do) must not start a download.
@@ -0,0 +1,40 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_crypto = require("node:crypto");
3
+ //#region src/ticket.ts
4
+ /** Must match the floor BlokServerOptions.Validate enforces, or the service refuses to start. */
5
+ var MIN_SECRET_LENGTH = 32;
6
+ var DEFAULT_TTL_SECONDS = 300;
7
+ /**
8
+ * Byte-exact, never negotiated: the service compares the ENCODED header segment
9
+ * against a hard-coded constant, ordinally. Reordering these two keys, or adding
10
+ * a space, is rejected — see the noncanonicalHeaderTicket conformance fixture.
11
+ */
12
+ var HEADER = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
13
+ /**
14
+ * Mints an access pass for one of your users, in your own backend.
15
+ *
16
+ * Not a Blok concept: it is a plain HS256 JWT. Any language can produce the
17
+ * same thing with its own JWT library — this exists so a JavaScript backend
18
+ * does not have to.
19
+ * @param secret - the same secret the service runs with (BLOK_SECRET)
20
+ * @param claims - who the pass is for and how long it lives
21
+ */
22
+ function blokTicket(secret, claims) {
23
+ if (secret.length < MIN_SECRET_LENGTH) throw new Error(`blokTicket: the secret must be at least ${MIN_SECRET_LENGTH} characters`);
24
+ const payload = {
25
+ user: claims.user,
26
+ ...claims.doc === void 0 ? {} : { doc: claims.doc },
27
+ write: claims.write ?? false,
28
+ exp: Math.floor(Date.now() / 1e3) + (claims.ttlSeconds ?? DEFAULT_TTL_SECONDS)
29
+ };
30
+ const signing = `${b64(HEADER)}.${b64(JSON.stringify(payload))}`;
31
+ return `${signing}.${(0, node_crypto.createHmac)("sha256", secret).update(signing).digest("base64url")}`;
32
+ }
33
+ /**
34
+ * @param value - text to encode
35
+ */
36
+ function b64(value) {
37
+ return Buffer.from(value, "utf-8").toString("base64url");
38
+ }
39
+ //#endregion
40
+ exports.blokTicket = blokTicket;
@@ -0,0 +1,39 @@
1
+ import { createHmac } from "node:crypto";
2
+ //#region src/ticket.ts
3
+ /** Must match the floor BlokServerOptions.Validate enforces, or the service refuses to start. */
4
+ var MIN_SECRET_LENGTH = 32;
5
+ var DEFAULT_TTL_SECONDS = 300;
6
+ /**
7
+ * Byte-exact, never negotiated: the service compares the ENCODED header segment
8
+ * against a hard-coded constant, ordinally. Reordering these two keys, or adding
9
+ * a space, is rejected — see the noncanonicalHeaderTicket conformance fixture.
10
+ */
11
+ var HEADER = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
12
+ /**
13
+ * Mints an access pass for one of your users, in your own backend.
14
+ *
15
+ * Not a Blok concept: it is a plain HS256 JWT. Any language can produce the
16
+ * same thing with its own JWT library — this exists so a JavaScript backend
17
+ * does not have to.
18
+ * @param secret - the same secret the service runs with (BLOK_SECRET)
19
+ * @param claims - who the pass is for and how long it lives
20
+ */
21
+ function blokTicket(secret, claims) {
22
+ if (secret.length < MIN_SECRET_LENGTH) throw new Error(`blokTicket: the secret must be at least ${MIN_SECRET_LENGTH} characters`);
23
+ const payload = {
24
+ user: claims.user,
25
+ ...claims.doc === void 0 ? {} : { doc: claims.doc },
26
+ write: claims.write ?? false,
27
+ exp: Math.floor(Date.now() / 1e3) + (claims.ttlSeconds ?? DEFAULT_TTL_SECONDS)
28
+ };
29
+ const signing = `${b64(HEADER)}.${b64(JSON.stringify(payload))}`;
30
+ return `${signing}.${createHmac("sha256", secret).update(signing).digest("base64url")}`;
31
+ }
32
+ /**
33
+ * @param value - text to encode
34
+ */
35
+ function b64(value) {
36
+ return Buffer.from(value, "utf-8").toString("base64url");
37
+ }
38
+ //#endregion
39
+ export { blokTicket };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bloklabs/server",
3
- "version": "1.11.0",
4
- "description": "Blok backend service — a small Go sidecar that handles file uploads and link previews, so you write no backend code for either.",
3
+ "version": "1.13.0",
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",
7
7
  "homepage": "https://blokeditor.com",
@@ -26,16 +26,32 @@
26
26
  "sidecar",
27
27
  "ssrf"
28
28
  ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "exports": {
32
+ "./ticket": {
33
+ "types": "./types/index.d.ts",
34
+ "import": "./dist/ticket.mjs",
35
+ "require": "./dist/ticket.cjs"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
29
39
  "bin": {
30
40
  "blok-server": "bin/blok-server.mjs"
31
41
  },
32
42
  "files": [
33
- "bin"
43
+ "bin",
44
+ "dist",
45
+ "types"
34
46
  ],
35
47
  "engines": {
36
48
  "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
37
49
  },
38
50
  "publishConfig": {
39
51
  "access": "public"
52
+ },
53
+ "scripts": {
54
+ "build": "yarn run -T vite build --mode production",
55
+ "test": "yarn run -T vitest run"
40
56
  }
41
57
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Public types for `@bloklabs/server/ticket`.
3
+ *
4
+ * Hand-authored, not generated: a published `.d.ts` may not reach outside its
5
+ * own tarball, so it cannot import from `src/`. Keep it in step with
6
+ * `src/ticket.ts` — `packages/server/src/ticket.test.ts` pins the runtime
7
+ * behaviour, and the shape below is the whole public surface.
8
+ */
9
+
10
+ export interface BlokTicketClaims {
11
+ /** Your own user id. The service stores it but never interprets it. */
12
+ user: string;
13
+ /**
14
+ * Scope the pass to one document for sync (collaboration) access. The HTTP
15
+ * upload and unfurl routes do not read it — those stay user-scoped.
16
+ */
17
+ doc?: string;
18
+ /** Whether the holder may write. Defaults to false. */
19
+ write?: boolean;
20
+ /**
21
+ * Lifetime in seconds. Defaults to 300 — short on purpose. A collaboration
22
+ * pass outlives its handshake (verified at connect only), so ~1800 is a
23
+ * reasonable ceiling there.
24
+ */
25
+ ttlSeconds?: number;
26
+ }
27
+
28
+ /**
29
+ * Mints an access pass for one of your users, in your own backend.
30
+ *
31
+ * Not a Blok concept: it is a plain HS256 JWT. Any language can produce the
32
+ * same thing with its own JWT library — this exists so a JavaScript backend
33
+ * does not have to.
34
+ * @param secret - the same secret the service runs with (BLOK_SECRET)
35
+ * @param claims - who the pass is for and how long it lives
36
+ */
37
+ export declare function blokTicket(secret: string, claims: BlokTicketClaims): string;