@syncular/server-hono 0.0.6-96 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +5 -23
  2. package/dist/admin-page.d.ts +14 -0
  3. package/dist/admin-page.js +217 -0
  4. package/dist/admin.d.ts +39 -0
  5. package/dist/admin.js +142 -0
  6. package/dist/index.d.ts +16 -14
  7. package/dist/index.js +134 -23
  8. package/package.json +19 -53
  9. package/src/admin-page.ts +217 -0
  10. package/src/admin.ts +193 -0
  11. package/src/index.ts +175 -31
  12. package/dist/api-key-auth.d.ts +0 -49
  13. package/dist/api-key-auth.d.ts.map +0 -1
  14. package/dist/api-key-auth.js +0 -110
  15. package/dist/api-key-auth.js.map +0 -1
  16. package/dist/blobs.d.ts +0 -69
  17. package/dist/blobs.d.ts.map +0 -1
  18. package/dist/blobs.js +0 -383
  19. package/dist/blobs.js.map +0 -1
  20. package/dist/console/gateway.d.ts +0 -33
  21. package/dist/console/gateway.d.ts.map +0 -1
  22. package/dist/console/gateway.js +0 -2534
  23. package/dist/console/gateway.js.map +0 -1
  24. package/dist/console/index.d.ts +0 -11
  25. package/dist/console/index.d.ts.map +0 -1
  26. package/dist/console/index.js +0 -11
  27. package/dist/console/index.js.map +0 -1
  28. package/dist/console/routes.d.ts +0 -33
  29. package/dist/console/routes.d.ts.map +0 -1
  30. package/dist/console/routes.js +0 -3029
  31. package/dist/console/routes.js.map +0 -1
  32. package/dist/console/schemas.d.ts +0 -490
  33. package/dist/console/schemas.d.ts.map +0 -1
  34. package/dist/console/schemas.js +0 -303
  35. package/dist/console/schemas.js.map +0 -1
  36. package/dist/console/types.d.ts +0 -175
  37. package/dist/console/types.d.ts.map +0 -1
  38. package/dist/console/types.js +0 -2
  39. package/dist/console/types.js.map +0 -1
  40. package/dist/console/ui.d.ts +0 -38
  41. package/dist/console/ui.d.ts.map +0 -1
  42. package/dist/console/ui.js +0 -43
  43. package/dist/console/ui.js.map +0 -1
  44. package/dist/create-server.d.ts +0 -68
  45. package/dist/create-server.d.ts.map +0 -1
  46. package/dist/create-server.js +0 -110
  47. package/dist/create-server.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/index.js.map +0 -1
  50. package/dist/openapi.d.ts +0 -45
  51. package/dist/openapi.d.ts.map +0 -1
  52. package/dist/openapi.js +0 -59
  53. package/dist/openapi.js.map +0 -1
  54. package/dist/proxy/connection-manager.d.ts +0 -78
  55. package/dist/proxy/connection-manager.d.ts.map +0 -1
  56. package/dist/proxy/connection-manager.js +0 -251
  57. package/dist/proxy/connection-manager.js.map +0 -1
  58. package/dist/proxy/index.d.ts +0 -8
  59. package/dist/proxy/index.d.ts.map +0 -1
  60. package/dist/proxy/index.js +0 -8
  61. package/dist/proxy/index.js.map +0 -1
  62. package/dist/proxy/routes.d.ts +0 -74
  63. package/dist/proxy/routes.d.ts.map +0 -1
  64. package/dist/proxy/routes.js +0 -147
  65. package/dist/proxy/routes.js.map +0 -1
  66. package/dist/rate-limit.d.ts +0 -101
  67. package/dist/rate-limit.d.ts.map +0 -1
  68. package/dist/rate-limit.js +0 -186
  69. package/dist/rate-limit.js.map +0 -1
  70. package/dist/routes.d.ts +0 -161
  71. package/dist/routes.d.ts.map +0 -1
  72. package/dist/routes.js +0 -1493
  73. package/dist/routes.js.map +0 -1
  74. package/dist/ws.d.ts +0 -235
  75. package/dist/ws.d.ts.map +0 -1
  76. package/dist/ws.js +0 -614
  77. package/dist/ws.js.map +0 -1
  78. package/src/__tests__/blob-routes.test.ts +0 -424
  79. package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
  80. package/src/__tests__/console-gateway-routes.test.ts +0 -1364
  81. package/src/__tests__/console-routes.test.ts +0 -1626
  82. package/src/__tests__/console-ui.test.ts +0 -114
  83. package/src/__tests__/create-server.test.ts +0 -485
  84. package/src/__tests__/pull-chunk-storage.test.ts +0 -576
  85. package/src/__tests__/rate-limit.test.ts +0 -78
  86. package/src/__tests__/realtime-bridge.test.ts +0 -135
  87. package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
  88. package/src/__tests__/ws-connection-manager.test.ts +0 -176
  89. package/src/api-key-auth.ts +0 -179
  90. package/src/blobs.ts +0 -534
  91. package/src/console/gateway.ts +0 -3603
  92. package/src/console/index.ts +0 -11
  93. package/src/console/routes.ts +0 -3936
  94. package/src/console/schemas.ts +0 -434
  95. package/src/console/types.ts +0 -185
  96. package/src/console/ui.ts +0 -100
  97. package/src/create-server.ts +0 -207
  98. package/src/openapi.ts +0 -74
  99. package/src/proxy/connection-manager.ts +0 -340
  100. package/src/proxy/index.ts +0 -8
  101. package/src/proxy/routes.ts +0 -223
  102. package/src/rate-limit.ts +0 -321
  103. package/src/routes.ts +0 -2059
  104. package/src/ws.ts +0 -802
package/dist/index.js CHANGED
@@ -1,25 +1,136 @@
1
1
  /**
2
- * @syncular/server-hono - Hono adapter for sync infrastructure
3
- *
4
- * This package provides Hono-specific routes for @syncular/server.
5
- * Keeps @syncular/server framework-agnostic.
2
+ * Hono adapter (REVISE B2): a thin wrapper proving the embed boundary.
3
+ * Hono is a dependency of this adapter only, never of the server core.
4
+ * Mounts the §1.1 routes: POST /sync and GET /segments/:segmentId
5
+ * (realtime upgrades are runtime-specific and stay with the host).
6
6
  */
7
- // API Key Auth
8
- export * from './api-key-auth.js';
9
- // Blob routes
10
- export { createBlobRoutes } from './blobs.js';
11
- // Console
12
- export * from './console/index.js';
13
- // Simplified server factory
14
- export { createSyncServer, } from './create-server.js';
15
- // OpenAPI utilities
16
- export * from './openapi.js';
17
- // Proxy
18
- export * from './proxy/index.js';
19
- // Rate limiting
20
- export * from './rate-limit.js';
21
- // Route types and factory
22
- export { createSyncRoutes, getSyncRealtimeUnsubscribe, getSyncWebSocketConnectionManager, } from './routes.js';
23
- // WebSocket helpers for realtime sync
24
- export * from './ws.js';
25
- //# sourceMappingURL=index.js.map
7
+ import { encodeSegmentBody, errorBody, handleBlobDownload, handleBlobUpload, handleBlobUploadGrant, handleSegmentDownload, handleSyncRequest, SSP2_CONTENT_TYPE, SyncError, } from '@syncular/server';
8
+ import { Hono } from 'hono';
9
+ export * from './admin.js';
10
+ function errorResponse(error) {
11
+ const sync = error instanceof SyncError
12
+ ? error
13
+ : new SyncError('sync.invalid_request', String(error));
14
+ return Response.json(errorBody(sync), { status: sync.httpStatus });
15
+ }
16
+ export function createSyncularHono(options) {
17
+ const app = new Hono();
18
+ app.post('/sync', async (c) => {
19
+ const contentType = c.req.header('content-type')?.split(';')[0]?.trim();
20
+ if (contentType !== SSP2_CONTENT_TYPE) {
21
+ // §1.1: any other content type is rejected with HTTP 415.
22
+ return Response.json(errorBody(new SyncError('sync.invalid_request', 'unsupported content type')), { status: 415 });
23
+ }
24
+ const auth = await options.authenticate(c.req.raw);
25
+ if (auth === null)
26
+ return errorResponse(new SyncError('sync.auth_required'));
27
+ try {
28
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
29
+ const out = await handleSyncRequest(bytes, {
30
+ ...options.config,
31
+ ...auth,
32
+ });
33
+ return c.body(out.slice().buffer, 200, {
34
+ 'Content-Type': SSP2_CONTENT_TYPE,
35
+ });
36
+ }
37
+ catch (error) {
38
+ return errorResponse(error);
39
+ }
40
+ });
41
+ app.get('/segments/:segmentId', async (c) => {
42
+ const auth = await options.authenticate(c.req.raw);
43
+ if (auth === null)
44
+ return errorResponse(new SyncError('sync.auth_required'));
45
+ try {
46
+ const result = await handleSegmentDownload({ ...options.config, ...auth }, {
47
+ segmentId: c.req.param('segmentId'),
48
+ scopesHeader: c.req.header('x-syncular-scopes') ?? '{}',
49
+ });
50
+ if (c.req.header('if-none-match') === result.headers.ETag) {
51
+ return c.body(null, 304, result.headers);
52
+ }
53
+ // §5.8 shipped default: compress the body per Accept-Encoding
54
+ // (zstd preferred, gzip fallback, identity otherwise). Content
55
+ // addresses are over the uncompressed bytes (§5.1) — fetch
56
+ // decodes transparently on the client.
57
+ const encoded = encodeSegmentBody(result.bytes, c.req.header('accept-encoding'));
58
+ return c.body(encoded.bytes.slice().buffer, 200, {
59
+ ...result.headers,
60
+ ...(encoded.contentEncoding !== undefined
61
+ ? { 'Content-Encoding': encoded.contentEncoding }
62
+ : {}),
63
+ });
64
+ }
65
+ catch (error) {
66
+ return errorResponse(error);
67
+ }
68
+ });
69
+ // §5.9.3: blob upload with server-side content-address verification.
70
+ app.put('/blobs/:blobId', async (c) => {
71
+ const auth = await options.authenticate(c.req.raw);
72
+ if (auth === null)
73
+ return errorResponse(new SyncError('sync.auth_required'));
74
+ try {
75
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
76
+ const contentType = c.req.header('content-type')?.split(';')[0]?.trim();
77
+ await handleBlobUpload({ ...options.config, ...auth }, {
78
+ blobId: c.req.param('blobId'),
79
+ bytes,
80
+ ...(contentType !== undefined &&
81
+ contentType !== 'application/octet-stream'
82
+ ? { mediaType: contentType }
83
+ : {}),
84
+ });
85
+ return c.body(null, 200);
86
+ }
87
+ catch (error) {
88
+ return errorResponse(error);
89
+ }
90
+ });
91
+ // §5.9.3: presigned-upload grant — mint a direct-to-storage PUT URL.
92
+ app.post('/blobs/:blobId/upload-grant', async (c) => {
93
+ const auth = await options.authenticate(c.req.raw);
94
+ if (auth === null)
95
+ return errorResponse(new SyncError('sync.auth_required'));
96
+ try {
97
+ const body = (await c.req.json().catch(() => ({})));
98
+ const grant = await handleBlobUploadGrant({ ...options.config, ...auth }, {
99
+ blobId: c.req.param('blobId'),
100
+ byteLength: Number(body.byteLength ?? 0),
101
+ ...(typeof body.mediaType === 'string'
102
+ ? { mediaType: body.mediaType }
103
+ : {}),
104
+ });
105
+ return c.json(grant, 200);
106
+ }
107
+ catch (error) {
108
+ return errorResponse(error);
109
+ }
110
+ });
111
+ // §5.9.5: blob download, re-authorized against referencing rows. When the
112
+ // host configured presigned URLs, the result carries `url` (no bytes) and
113
+ // the client fetches it directly (§5.9.5 always-issue).
114
+ app.get('/blobs/:blobId', async (c) => {
115
+ const auth = await options.authenticate(c.req.raw);
116
+ if (auth === null)
117
+ return errorResponse(new SyncError('sync.auth_required'));
118
+ try {
119
+ const result = await handleBlobDownload({ ...options.config, ...auth }, c.req.param('blobId'));
120
+ if (result.url !== undefined) {
121
+ return c.json({ url: result.url, urlExpiresAtMs: result.urlExpiresAtMs }, 200);
122
+ }
123
+ if (c.req.header('if-none-match') === result.headers.ETag) {
124
+ return c.body(null, 304, result.headers);
125
+ }
126
+ const bytes = result.bytes ?? new Uint8Array(0);
127
+ return c.body(bytes.slice().buffer, 200, {
128
+ ...result.headers,
129
+ });
130
+ }
131
+ catch (error) {
132
+ return errorResponse(error);
133
+ }
134
+ });
135
+ return app;
136
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@syncular/server-hono",
3
- "version": "0.0.6-96",
4
- "description": "Hono adapter for the Syncular server with OpenAPI support",
3
+ "version": "0.2.1",
4
+ "description": "Hono adapter for the Syncular sync server",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
7
7
  "homepage": "https://syncular.dev",
@@ -18,71 +18,37 @@
18
18
  "offline-first",
19
19
  "realtime",
20
20
  "database",
21
- "typescript",
22
- "hono",
23
- "openapi"
21
+ "typescript"
24
22
  ],
25
- "private": false,
23
+ "type": "module",
24
+ "sideEffects": false,
26
25
  "publishConfig": {
27
26
  "access": "public"
28
27
  },
29
- "type": "module",
30
28
  "exports": {
31
29
  ".": {
32
30
  "bun": "./src/index.ts",
31
+ "browser": "./src/index.ts",
33
32
  "import": {
34
33
  "types": "./dist/index.d.ts",
35
34
  "default": "./dist/index.js"
36
35
  }
37
- },
38
- "./blobs": {
39
- "bun": "./src/blobs.ts",
40
- "import": {
41
- "types": "./dist/blobs.d.ts",
42
- "default": "./dist/blobs.js"
43
- }
44
- },
45
- "./create-server": {
46
- "bun": "./src/create-server.ts",
47
- "import": {
48
- "types": "./dist/create-server.d.ts",
49
- "default": "./dist/create-server.js"
50
- }
51
36
  }
52
37
  },
53
- "scripts": {
54
- "test": "bun test src/__tests__",
55
- "tsgo": "tsgo --noEmit",
56
- "generate": "bun generate:openapi",
57
- "generate:openapi": "bun scripts/generate-openapi.ts",
58
- "build": "tsgo",
59
- "release": "bunx syncular-publish"
60
- },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md",
42
+ "!src/**/*.test.ts",
43
+ "!src/**/*.test.tsx",
44
+ "!dist/**/*.test.js",
45
+ "!dist/**/*.test.d.ts"
46
+ ],
61
47
  "dependencies": {
62
- "@hono/standard-validator": "^0.2.2",
63
- "@standard-community/standard-json": "^0.3.5",
64
- "@standard-community/standard-openapi": "^0.2.9",
65
- "@syncular/console": "0.0.6-96",
66
- "@syncular/core": "0.0.6-96",
67
- "@syncular/server": "0.0.6-96",
68
- "@types/json-schema": "^7.0.15",
69
- "hono-openapi": "^1.2.0",
70
- "openapi-types": "^12.1.3"
48
+ "@syncular/server": "0.2.1",
49
+ "hono": "^4.11.0"
71
50
  },
72
51
  "devDependencies": {
73
- "@syncular/config": "0.0.0",
74
- "@syncular/dialect-pglite": "0.0.6-96",
75
- "@syncular/server-dialect-postgres": "0.0.6-96",
76
- "kysely": "*",
77
- "zod": "*"
78
- },
79
- "peerDependencies": {
80
- "hono": "^4.0.0",
81
- "kysely": "^0.28.0",
82
- "zod": "^4.0.0"
83
- },
84
- "files": [
85
- "dist",
86
- "src"
87
- ]
52
+ "@syncular/core": "0.2.1"
53
+ }
88
54
  }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The single static console page (TODO §2.5). Zero framework, no build
3
+ * step, ~one file: it fetches the sibling JSON endpoints (relative to its
4
+ * own mount path), renders tables, and offers an auto-refresh toggle. This
5
+ * is the v2 answer to v1's full React console app — 5% of the code, the
6
+ * 80% operator value.
7
+ *
8
+ * The HTML is a single exported string so the routes module can serve it
9
+ * with no filesystem read (works identically on Bun, Node, and Workers).
10
+ * All fetches are same-origin and relative ('./clients', …), so the page
11
+ * works under whatever prefix the host mounts the routes at, and the host's
12
+ * `authorize` guard applies to the page's own XHRs (same cookies/headers).
13
+ */
14
+ export const ADMIN_CONSOLE_HTML = `<!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8" />
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <title>Syncular console</title>
20
+ <style>
21
+ :root {
22
+ --bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #d7dce5;
23
+ --muted: #8a93a6; --accent: #5b9dff; --ok: #43c17a; --warn: #e0a83a;
24
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
25
+ --sans: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); }
29
+ header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
30
+ padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
31
+ header h1 { font-size: 15px; margin: 0; font-weight: 600; letter-spacing: .02em; }
32
+ header .spacer { flex: 1; }
33
+ header label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }
34
+ header input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
35
+ border-radius: 6px; padding: 5px 8px; font: 13px var(--mono); width: 140px; }
36
+ header button { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
37
+ border-radius: 6px; padding: 5px 11px; cursor: pointer; font: 13px var(--sans); }
38
+ header button:hover { border-color: var(--accent); }
39
+ #status { color: var(--muted); font: 12px var(--mono); }
40
+ main { padding: 18px 20px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
41
+ section { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
42
+ section > h2 { font-size: 13px; margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--border);
43
+ font-weight: 600; color: var(--fg); display: flex; justify-content: space-between; align-items: center; }
44
+ section > h2 .count { color: var(--muted); font: 12px var(--mono); font-weight: 400; }
45
+ .body { max-height: 360px; overflow: auto; }
46
+ table { width: 100%; border-collapse: collapse; font: 12px var(--mono); }
47
+ th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
48
+ th { color: var(--muted); font-weight: 500; position: sticky; top: 0; background: var(--panel); z-index: 1; }
49
+ td.wrap { white-space: normal; word-break: break-all; }
50
+ tr:hover td { background: #1c212b; }
51
+ .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; }
52
+ .pill.ok { background: rgba(67,193,122,.15); color: var(--ok); }
53
+ .pill.warn { background: rgba(224,168,58,.15); color: var(--warn); }
54
+ .pill.idle { background: rgba(138,147,166,.15); color: var(--muted); }
55
+ .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 10px 14px; font: 12px var(--mono); }
56
+ .kv dt { color: var(--muted); }
57
+ .empty { color: var(--muted); padding: 14px; font-style: italic; }
58
+ .evt-type { color: var(--accent); }
59
+ .full { grid-column: 1 / -1; }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <header>
64
+ <h1>Syncular console</h1>
65
+ <label>partition
66
+ <input id="partition" type="text" placeholder="(default)" />
67
+ </label>
68
+ <button id="refresh">Refresh</button>
69
+ <label><input id="auto" type="checkbox" /> auto</label>
70
+ <span class="spacer"></span>
71
+ <span id="status">idle</span>
72
+ </header>
73
+ <main>
74
+ <section><h2>Horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
75
+ <section><h2>Store stats</h2><div class="body" id="stats"></div></section>
76
+ <section><h2>Clients <span class="count" id="clients-count"></span></h2><div class="body" id="clients"></div></section>
77
+ <section><h2>Recent commits <span class="count" id="commits-count"></span></h2><div class="body" id="commits"></div></section>
78
+ <section class="full"><h2>Event stream <span class="count" id="events-count"></span></h2><div class="body" id="events"></div></section>
79
+ </main>
80
+ <script>
81
+ (function () {
82
+ var el = function (id) { return document.getElementById(id); };
83
+ var status = el('status');
84
+
85
+ function q() {
86
+ var p = el('partition').value.trim();
87
+ return p ? ('?partition=' + encodeURIComponent(p)) : '';
88
+ }
89
+ // The endpoints are siblings of the page under the SAME mount prefix. The
90
+ // page is served at the mount root (…/admin or …/admin/); strip any
91
+ // trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
92
+ // whether the URL had the trailing slash.
93
+ var base = location.pathname.replace(/\\/+$/, '');
94
+ function get(path) {
95
+ return fetch(base + path + q(), { headers: { accept: 'application/json' } })
96
+ .then(function (r) {
97
+ if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
98
+ return r.json();
99
+ });
100
+ }
101
+ function esc(v) {
102
+ return String(v == null ? '' : v)
103
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
104
+ }
105
+ function ago(ms) {
106
+ var d = Date.now() - ms;
107
+ if (d < 0) d = 0;
108
+ if (d < 1000) return d + 'ms';
109
+ if (d < 60000) return Math.round(d / 1000) + 's';
110
+ if (d < 3600000) return Math.round(d / 60000) + 'm';
111
+ return Math.round(d / 3600000) + 'h';
112
+ }
113
+ function bytes(n) {
114
+ if (n == null) return '-';
115
+ if (n < 1024) return n + ' B';
116
+ if (n < 1048576) return (n / 1024).toFixed(1) + ' KiB';
117
+ return (n / 1048576).toFixed(1) + ' MiB';
118
+ }
119
+ function table(head, rows) {
120
+ if (!rows.length) return '<div class="empty">none</div>';
121
+ var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
122
+ h += rows.map(function (r) {
123
+ return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
124
+ }).join('');
125
+ return h + '</tbody></table>';
126
+ }
127
+
128
+ function renderHorizon(h) {
129
+ el('horizon').innerHTML =
130
+ '<dl class="kv">' +
131
+ '<dt>maxCommitSeq</dt><dd>' + esc(h.maxCommitSeq) + '</dd>' +
132
+ '<dt>horizonSeq</dt><dd>' + esc(h.horizonSeq) + '</dd>' +
133
+ '<dt>retained commits</dt><dd>' + esc(h.retainedCommits) + '</dd>' +
134
+ '<dt>active cursor floor</dt><dd>' + esc(h.activeCursorFloor == null ? 'none' : h.activeCursorFloor) + '</dd>' +
135
+ '<dt>recommended horizon</dt><dd>' + esc(h.recommendedHorizonSeq) + '</dd>' +
136
+ '</dl>';
137
+ var rec = el('horizon-rec');
138
+ if (h.recommendation === 'prune-recommended') {
139
+ rec.innerHTML = '<span class="pill warn">prune recommended</span>';
140
+ } else {
141
+ rec.innerHTML = '<span class="pill ok">up to date</span>';
142
+ }
143
+ }
144
+ function renderStats(s) {
145
+ var seg = s.segments, blob = s.blobs;
146
+ var rows = '<dl class="kv">';
147
+ if (seg) {
148
+ rows += '<dt>segments</dt><dd>' + esc(seg.count) + ' (' + bytes(seg.bytes) + ')</dd>' +
149
+ '<dt>&nbsp;rows / sqlite</dt><dd>' + esc(seg.rowsSegments) + ' / ' + esc(seg.sqliteSegments) + '</dd>';
150
+ } else { rows += '<dt>segments</dt><dd>n/a</dd>'; }
151
+ if (blob) {
152
+ rows += '<dt>blobs</dt><dd>' + esc(blob.count) + ' (' + bytes(blob.bytes) + ')</dd>';
153
+ } else { rows += '<dt>blobs</dt><dd>n/a</dd>'; }
154
+ el('stats').innerHTML = rows + '</dl>';
155
+ }
156
+ function renderClients(list) {
157
+ el('clients-count').textContent = list.length + ' known';
158
+ el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],
159
+ list.map(function (c) {
160
+ var pill = c.active ? '<span class="pill ok">active</span>' : '<span class="pill idle">idle</span>';
161
+ return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
162
+ }));
163
+ }
164
+ function renderCommits(list) {
165
+ el('commits-count').textContent = list.length + ' shown';
166
+ el('commits').innerHTML = table(['seq', 'actor', 'client', 'changes', 'tables', 'when'],
167
+ list.map(function (c) {
168
+ return [esc(c.commitSeq), esc(c.actorId), esc(c.clientId), esc(c.changeCount),
169
+ '<span class="wrap">' + esc(c.tables.join(', ')) + '</span>', ago(c.createdAtMs)];
170
+ }));
171
+ }
172
+ function renderEvents(res) {
173
+ el('events-count').textContent = res.hasEventStream
174
+ ? (res.events.length + ' events')
175
+ : 'no ring buffer wired';
176
+ if (!res.hasEventStream) { el('events').innerHTML = '<div class="empty">no RingBufferEvents wired on this admin</div>'; return; }
177
+ el('events').innerHTML = table(['when', 'type', 'detail'],
178
+ res.events.map(function (e) {
179
+ var detail = Object.keys(e)
180
+ .filter(function (k) { return k !== 'type' && k !== 'atMs'; })
181
+ .map(function (k) { return k + '=' + JSON.stringify(e[k]); })
182
+ .join(' ');
183
+ return [ago(e.atMs), '<span class="evt-type">' + esc(e.type) + '</span>',
184
+ '<span class="wrap">' + esc(detail) + '</span>'];
185
+ }));
186
+ }
187
+
188
+ var loading = false;
189
+ function loadAll() {
190
+ if (loading) return;
191
+ loading = true;
192
+ status.textContent = 'loading…';
193
+ Promise.all([
194
+ get('/horizon').then(function (r) { renderHorizon(r.horizon); }),
195
+ get('/stats').then(function (r) { renderStats(r.stats); }),
196
+ get('/clients').then(function (r) { renderClients(r.clients); }),
197
+ get('/commits').then(function (r) { renderCommits(r.commits); }),
198
+ get('/events').then(renderEvents)
199
+ ]).then(function () {
200
+ status.textContent = 'updated ' + new Date().toLocaleTimeString();
201
+ }).catch(function (err) {
202
+ status.textContent = 'error: ' + err.message;
203
+ }).finally(function () { loading = false; });
204
+ }
205
+
206
+ el('refresh').addEventListener('click', loadAll);
207
+ el('partition').addEventListener('change', loadAll);
208
+ var timer = null;
209
+ el('auto').addEventListener('change', function () {
210
+ if (this.checked) { timer = setInterval(loadAll, 2000); loadAll(); }
211
+ else if (timer) { clearInterval(timer); timer = null; }
212
+ });
213
+ loadAll();
214
+ })();
215
+ </script>
216
+ </body>
217
+ </html>`;
package/src/admin.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Admin / console HTTP surface (TODO §2.5) — a mountable Hono sub-app over a
3
+ * `SyncularAdmin`. JSON endpoints mirror the read surface; a single static
4
+ * HTML page (zero framework, no build step) at `GET /` renders them.
5
+ *
6
+ * The auth seam is REQUIRED. There is NO default-open admin: the factory
7
+ * refuses to mount without a host-provided `authorize` guard (it throws at
8
+ * construction). Every request runs the guard first; a falsy result is a
9
+ * 401. Admin is a privileged surface (it reads every partition's clients,
10
+ * commit metadata, and scope activity) and SPEC.md deliberately says nothing
11
+ * about it — authorization is entirely the host's, and mandatory.
12
+ */
13
+ import {
14
+ errorBody,
15
+ type RingEventQuery,
16
+ SyncError,
17
+ type SyncularAdmin,
18
+ } from '@syncular/server';
19
+ import { Hono } from 'hono';
20
+ import { ADMIN_CONSOLE_HTML } from './admin-page';
21
+
22
+ export interface AdminAuthContext {
23
+ /** The partition the request targets (from the query string / route). */
24
+ readonly partition: string;
25
+ readonly request: Request;
26
+ }
27
+
28
+ export interface SyncularAdminRoutesOptions {
29
+ /**
30
+ * REQUIRED host guard. Return `true` (or a truthy value) to allow the
31
+ * request, a falsy value to reject it with 401. Runs before every admin
32
+ * endpoint, including the HTML page. There is no default — omitting it
33
+ * throws, by design (no default-open admin).
34
+ */
35
+ readonly authorize: (ctx: AdminAuthContext) => boolean | Promise<boolean>;
36
+ /**
37
+ * Default partition for endpoints when the request omits `?partition=`.
38
+ * When unset, `partition` is required on every data endpoint.
39
+ */
40
+ readonly defaultPartition?: string;
41
+ }
42
+
43
+ function jsonError(error: unknown): Response {
44
+ const sync =
45
+ error instanceof SyncError
46
+ ? error
47
+ : new SyncError('sync.invalid_request', String(error));
48
+ return Response.json(errorBody(sync), { status: sync.httpStatus });
49
+ }
50
+
51
+ function intParam(value: string | undefined, fallback: number): number {
52
+ if (value === undefined) return fallback;
53
+ const parsed = Number.parseInt(value, 10);
54
+ return Number.isFinite(parsed) ? parsed : fallback;
55
+ }
56
+
57
+ /**
58
+ * Build the mountable admin sub-app. Mount it under any prefix, e.g.
59
+ * `app.route('/admin', createSyncularAdminRoutes(admin, { authorize }))`.
60
+ * Throws if `authorize` is missing — admin never mounts default-open.
61
+ */
62
+ export function createSyncularAdminRoutes(
63
+ admin: SyncularAdmin,
64
+ options: SyncularAdminRoutesOptions,
65
+ ): Hono {
66
+ if (typeof options.authorize !== 'function') {
67
+ throw new Error(
68
+ 'createSyncularAdminRoutes: an `authorize` guard is required — admin never mounts default-open (TODO §2.5)',
69
+ );
70
+ }
71
+ const app = new Hono();
72
+
73
+ /** Resolve the request's partition (query, else default) — required. */
74
+ function partitionOf(c: {
75
+ req: { query: (k: string) => string | undefined };
76
+ }): string {
77
+ const partition = c.req.query('partition') ?? options.defaultPartition;
78
+ if (partition === undefined || partition.length === 0) {
79
+ throw new SyncError('sync.invalid_request', 'partition is required');
80
+ }
81
+ return partition;
82
+ }
83
+
84
+ // Guard every route (page + data) up front.
85
+ app.use('*', async (c, next) => {
86
+ let partition: string;
87
+ try {
88
+ partition = c.req.query('partition') ?? options.defaultPartition ?? '';
89
+ } catch {
90
+ partition = '';
91
+ }
92
+ const ok = await options.authorize({ partition, request: c.req.raw });
93
+ if (!ok) return jsonError(new SyncError('sync.auth_required'));
94
+ await next();
95
+ });
96
+
97
+ // The single static console page (zero framework, no build step).
98
+ app.get('/', (c) =>
99
+ c.html(ADMIN_CONSOLE_HTML, 200, { 'Cache-Control': 'no-store' }),
100
+ );
101
+
102
+ app.get('/clients', async (c) => {
103
+ try {
104
+ const clients = await admin.listClients(partitionOf(c));
105
+ return Response.json({ clients });
106
+ } catch (error) {
107
+ return jsonError(error);
108
+ }
109
+ });
110
+
111
+ app.get('/commits', async (c) => {
112
+ try {
113
+ const table = c.req.query('table');
114
+ const commits = await admin.listCommits(partitionOf(c), {
115
+ afterSeq: intParam(c.req.query('afterSeq'), 0),
116
+ limit: intParam(c.req.query('limit'), 50),
117
+ ...(table !== undefined ? { table } : {}),
118
+ });
119
+ return Response.json({ commits });
120
+ } catch (error) {
121
+ return jsonError(error);
122
+ }
123
+ });
124
+
125
+ app.get('/rows/:table/:rowId', async (c) => {
126
+ try {
127
+ const row = await admin.inspectRow(
128
+ partitionOf(c),
129
+ c.req.param('table'),
130
+ c.req.param('rowId'),
131
+ );
132
+ return Response.json({ row });
133
+ } catch (error) {
134
+ return jsonError(error);
135
+ }
136
+ });
137
+
138
+ app.get('/scope-activity', async (c) => {
139
+ try {
140
+ const variable = c.req.query('variable');
141
+ const value = c.req.query('value');
142
+ if (variable === undefined || value === undefined) {
143
+ throw new SyncError(
144
+ 'sync.invalid_request',
145
+ 'variable and value are required',
146
+ );
147
+ }
148
+ const activity = await admin.scopeActivity(
149
+ partitionOf(c),
150
+ { variable, value },
151
+ { limit: intParam(c.req.query('limit'), 50) },
152
+ );
153
+ return Response.json({ activity });
154
+ } catch (error) {
155
+ return jsonError(error);
156
+ }
157
+ });
158
+
159
+ app.get('/horizon', async (c) => {
160
+ try {
161
+ const status = await admin.horizonStatus(partitionOf(c));
162
+ return Response.json({ horizon: status });
163
+ } catch (error) {
164
+ return jsonError(error);
165
+ }
166
+ });
167
+
168
+ app.get('/stats', async (c) => {
169
+ try {
170
+ const stats = await admin.stats(partitionOf(c));
171
+ return Response.json({ stats });
172
+ } catch (error) {
173
+ return jsonError(error);
174
+ }
175
+ });
176
+
177
+ app.get('/events', async (c) => {
178
+ try {
179
+ const type = c.req.query('type') as RingEventQuery['type'];
180
+ const sinceMs = c.req.query('sinceMs');
181
+ const events = admin.events({
182
+ ...(type !== undefined ? { type } : {}),
183
+ ...(sinceMs !== undefined ? { sinceMs: intParam(sinceMs, 0) } : {}),
184
+ limit: intParam(c.req.query('limit'), 200),
185
+ });
186
+ return Response.json({ events, hasEventStream: admin.hasEventStream });
187
+ } catch (error) {
188
+ return jsonError(error);
189
+ }
190
+ });
191
+
192
+ return app;
193
+ }