sh3-server 0.6.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.
Files changed (49) hide show
  1. package/README.md +9 -0
  2. package/app/assets/SH3-BG5NVpSD.png +0 -0
  3. package/app/assets/icons-CnAqUqbR.svg +1126 -0
  4. package/app/assets/index-B1mKDfA-.css +1 -0
  5. package/app/assets/index-C7fUtJvb.js +9 -0
  6. package/app/assets/tauri-backend-B3LR3-lo.js +1 -0
  7. package/app/index.html +13 -0
  8. package/dist/auth.d.ts +34 -0
  9. package/dist/auth.js +107 -0
  10. package/dist/cli.d.ts +9 -0
  11. package/dist/cli.js +30 -0
  12. package/dist/index.d.ts +35 -0
  13. package/dist/index.js +228 -0
  14. package/dist/keys.d.ts +33 -0
  15. package/dist/keys.js +68 -0
  16. package/dist/packages.d.ts +38 -0
  17. package/dist/packages.js +256 -0
  18. package/dist/routes/admin.d.ts +10 -0
  19. package/dist/routes/admin.js +126 -0
  20. package/dist/routes/auth.d.ts +13 -0
  21. package/dist/routes/auth.js +97 -0
  22. package/dist/routes/boot.d.ts +11 -0
  23. package/dist/routes/boot.js +56 -0
  24. package/dist/routes/docs.d.ts +14 -0
  25. package/dist/routes/docs.js +133 -0
  26. package/dist/routes/env-state.d.ts +8 -0
  27. package/dist/routes/env-state.js +62 -0
  28. package/dist/sessions.d.ts +26 -0
  29. package/dist/sessions.js +59 -0
  30. package/dist/settings.d.ts +22 -0
  31. package/dist/settings.js +63 -0
  32. package/dist/shard-router.d.ts +68 -0
  33. package/dist/shard-router.js +145 -0
  34. package/dist/shell-shard/history-store.d.ts +11 -0
  35. package/dist/shell-shard/history-store.js +65 -0
  36. package/dist/shell-shard/index.d.ts +14 -0
  37. package/dist/shell-shard/index.js +51 -0
  38. package/dist/shell-shard/runner.d.ts +22 -0
  39. package/dist/shell-shard/runner.js +84 -0
  40. package/dist/shell-shard/session-manager.d.ts +56 -0
  41. package/dist/shell-shard/session-manager.js +161 -0
  42. package/dist/shell-shard/tokenize.d.ts +1 -0
  43. package/dist/shell-shard/tokenize.js +68 -0
  44. package/dist/shell-shard/ws.d.ts +2 -0
  45. package/dist/shell-shard/ws.js +78 -0
  46. package/dist/users.d.ts +44 -0
  47. package/dist/users.js +113 -0
  48. package/package.json +49 -0
  49. package/static/404.html +53 -0
package/dist/auth.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Auth middleware — three-tier model.
3
+ *
4
+ * 1. Public routes — no auth check (handled by route ordering, not this middleware)
5
+ * 2. Session required — validates session token from cookie or Authorization header
6
+ * 3. Admin required — session with admin role OR valid API key
7
+ *
8
+ * Session tokens use the `sh3_session` cookie (httpOnly, SameSite=Lax)
9
+ * or the Authorization header with `Bearer sh3s_*` prefix.
10
+ * API keys use the Authorization header with `Bearer sh3_*` prefix.
11
+ */
12
+ import type { MiddlewareHandler, Context } from 'hono';
13
+ import type { KeyStore } from './keys.js';
14
+ import type { SessionStore } from './sessions.js';
15
+ import type { SettingsStore } from './settings.js';
16
+ /**
17
+ * Session middleware — requires a valid session on non-GET requests
18
+ * when auth is required. Attaches session to context variable.
19
+ * GET requests pass through with session attached if present.
20
+ */
21
+ export declare function sessionAuth(sessions: SessionStore, settings: SettingsStore): MiddlewareHandler;
22
+ /**
23
+ * Admin middleware — requires admin session OR valid API key.
24
+ * Must be mounted after sessionAuth so c.get('session') is available.
25
+ */
26
+ export declare function adminAuth(sessions: SessionStore, keys: KeyStore, settings?: SettingsStore): MiddlewareHandler;
27
+ /**
28
+ * Helper to set the session cookie on login.
29
+ */
30
+ export declare function setSessionCookie(c: Context, token: string, maxAgeSeconds: number): void;
31
+ /**
32
+ * Helper to clear the session cookie on logout.
33
+ */
34
+ export declare function clearSessionCookie(c: Context): void;
package/dist/auth.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Auth middleware — three-tier model.
3
+ *
4
+ * 1. Public routes — no auth check (handled by route ordering, not this middleware)
5
+ * 2. Session required — validates session token from cookie or Authorization header
6
+ * 3. Admin required — session with admin role OR valid API key
7
+ *
8
+ * Session tokens use the `sh3_session` cookie (httpOnly, SameSite=Lax)
9
+ * or the Authorization header with `Bearer sh3s_*` prefix.
10
+ * API keys use the Authorization header with `Bearer sh3_*` prefix.
11
+ */
12
+ import { setCookie } from 'hono/cookie';
13
+ /** Extract session token from cookie or Authorization header. */
14
+ function extractSessionToken(c) {
15
+ // Cookie first
16
+ const cookie = c.req.raw.headers.get('cookie');
17
+ if (cookie) {
18
+ const match = cookie.match(/(?:^|;\s*)sh3_session=([^\s;]+)/);
19
+ if (match)
20
+ return match[1];
21
+ }
22
+ // Fallback to Authorization header (session tokens start with sh3s_)
23
+ const auth = c.req.header('Authorization');
24
+ if (auth?.startsWith('Bearer sh3s_'))
25
+ return auth.slice(7);
26
+ return null;
27
+ }
28
+ /** Extract API key from Authorization header. */
29
+ function extractApiKey(c) {
30
+ const auth = c.req.header('Authorization');
31
+ if (auth?.startsWith('Bearer sh3_'))
32
+ return auth.slice(7);
33
+ return null;
34
+ }
35
+ /**
36
+ * Session middleware — requires a valid session on non-GET requests
37
+ * when auth is required. Attaches session to context variable.
38
+ * GET requests pass through with session attached if present.
39
+ */
40
+ export function sessionAuth(sessions, settings) {
41
+ return async (c, next) => {
42
+ const token = extractSessionToken(c);
43
+ const session = token ? sessions.validate(token) : null;
44
+ // Attach session to context (may be null for guests)
45
+ c.set('session', session);
46
+ // GET/HEAD always pass through — data is filtered by the route handlers
47
+ if (c.req.method === 'GET' || c.req.method === 'HEAD') {
48
+ return next();
49
+ }
50
+ // Write operations: check if auth is required
51
+ const config = settings.get();
52
+ if (!config.auth.required) {
53
+ // Auth not enforced — allow all writes (open mode)
54
+ return next();
55
+ }
56
+ // Auth required — need a valid session for writes
57
+ if (!session) {
58
+ return c.json({ error: 'Authentication required' }, 401);
59
+ }
60
+ return next();
61
+ };
62
+ }
63
+ /**
64
+ * Admin middleware — requires admin session OR valid API key.
65
+ * Must be mounted after sessionAuth so c.get('session') is available.
66
+ */
67
+ export function adminAuth(sessions, keys, settings) {
68
+ return async (c, next) => {
69
+ // When auth is disabled (--no-auth), skip admin checks entirely
70
+ if (settings && !settings.get().auth.required) {
71
+ return next();
72
+ }
73
+ // Check session first (set by sessionAuth)
74
+ const session = c.get('session');
75
+ if (session?.role === 'admin') {
76
+ return next();
77
+ }
78
+ // Fallback: API key (for external tools / CLI)
79
+ const apiKey = extractApiKey(c);
80
+ if (apiKey && keys.validate(apiKey)) {
81
+ return next();
82
+ }
83
+ return c.json({ error: 'Admin privileges required' }, 403);
84
+ };
85
+ }
86
+ /**
87
+ * Helper to set the session cookie on login.
88
+ */
89
+ export function setSessionCookie(c, token, maxAgeSeconds) {
90
+ setCookie(c, 'sh3_session', token, {
91
+ httpOnly: true,
92
+ sameSite: 'Lax',
93
+ path: '/',
94
+ maxAge: maxAgeSeconds,
95
+ });
96
+ }
97
+ /**
98
+ * Helper to clear the session cookie on logout.
99
+ */
100
+ export function clearSessionCookie(c) {
101
+ setCookie(c, 'sh3_session', '', {
102
+ httpOnly: true,
103
+ sameSite: 'Lax',
104
+ path: '/',
105
+ maxAge: 0,
106
+ });
107
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry for sh3-server.
4
+ * Usage: sh3-server [--port 3000] [--data ./data] [--dist <path>]
5
+ *
6
+ * When --dist is not provided, serves the pre-built frontend embedded
7
+ * in the package (../app relative to this file).
8
+ */
9
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry for sh3-server.
4
+ * Usage: sh3-server [--port 3000] [--data ./data] [--dist <path>]
5
+ *
6
+ * When --dist is not provided, serves the pre-built frontend embedded
7
+ * in the package (../app relative to this file).
8
+ */
9
+ import { parseArgs } from 'node:util';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, resolve } from 'node:path';
12
+ import { createServer } from './index.js';
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const defaultDistDir = resolve(__dirname, '../app');
16
+ const { values } = parseArgs({
17
+ options: {
18
+ port: { type: 'string', default: '3000' },
19
+ data: { type: 'string', default: './data' },
20
+ dist: { type: 'string', default: defaultDistDir },
21
+ 'no-auth': { type: 'boolean', default: false },
22
+ },
23
+ });
24
+ const server = await createServer({
25
+ port: parseInt(values.port, 10),
26
+ dataDir: values.data,
27
+ distDir: values.dist,
28
+ noAuth: values['no-auth'],
29
+ });
30
+ await server.start();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * sh3-server — lightweight Node.js server for web-hosted SH3.
3
+ *
4
+ * Serves the SH3 frontend (static files), document backend API,
5
+ * public registry endpoints, and auth/admin APIs. Filesystem-based
6
+ * storage. Three-tier auth: public / session / admin-or-apikey.
7
+ */
8
+ import { Hono } from 'hono';
9
+ import { KeyStore } from './keys.js';
10
+ import { UserStore } from './users.js';
11
+ import { SessionStore } from './sessions.js';
12
+ import { SettingsStore } from './settings.js';
13
+ export interface ServerOptions {
14
+ /** Port to listen on. Default: 3000 */
15
+ port?: number;
16
+ /** Directory for persistent data (docs, registry, keys). Default: './data' */
17
+ dataDir?: string;
18
+ /** Directory containing the built SH3 frontend. Default: './dist' */
19
+ distDir?: string;
20
+ /** Disable auth enforcement (for Tauri sidecar / local-owner mode). */
21
+ noAuth?: boolean;
22
+ }
23
+ export declare function createServer(options?: ServerOptions): Promise<{
24
+ app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
25
+ port: number;
26
+ dataDir: string;
27
+ distDir: string;
28
+ keys: KeyStore;
29
+ users: UserStore;
30
+ sessions: SessionStore;
31
+ settings: SettingsStore;
32
+ start(): Promise<void>;
33
+ }>;
34
+ export { KeyStore } from './keys.js';
35
+ export type { ApiKey } from './keys.js';
package/dist/index.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * sh3-server — lightweight Node.js server for web-hosted SH3.
3
+ *
4
+ * Serves the SH3 frontend (static files), document backend API,
5
+ * public registry endpoints, and auth/admin APIs. Filesystem-based
6
+ * storage. Three-tier auth: public / session / admin-or-apikey.
7
+ */
8
+ import { Hono } from 'hono';
9
+ import { cors } from 'hono/cors';
10
+ import { serve } from '@hono/node-server';
11
+ import { serveStatic } from '@hono/node-server/serve-static';
12
+ import { createNodeWebSocket } from '@hono/node-ws';
13
+ import { readFileSync, existsSync, renameSync } from 'node:fs';
14
+ import { mkdirSync } from 'node:fs';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { dirname, join } from 'node:path';
17
+ import { KeyStore } from './keys.js';
18
+ import { UserStore } from './users.js';
19
+ import { SessionStore } from './sessions.js';
20
+ import { SettingsStore } from './settings.js';
21
+ import { sessionAuth, adminAuth } from './auth.js';
22
+ import { createAuthRouter } from './routes/auth.js';
23
+ import { createBootRouter } from './routes/boot.js';
24
+ import { createAdminRouter } from './routes/admin.js';
25
+ import { createDocsRouter } from './routes/docs.js';
26
+ import { createEnvStateRouter } from './routes/env-state.js';
27
+ import { loadPackages, scanPackages, servePackageBundles, createPackageManagementRoutes } from './packages.js';
28
+ import { ShardRouter, adminOnly } from './shard-router.js';
29
+ import shellShardServer from './shell-shard/index.js';
30
+ export async function createServer(options = {}) {
31
+ const port = options.port ?? 3000;
32
+ const dataDir = options.dataDir ?? './data';
33
+ const distDir = options.distDir ?? './dist';
34
+ // Ensure data directory exists
35
+ mkdirSync(dataDir, { recursive: true });
36
+ // Migrate keys.json from legacy location (data/registry/) to data/
37
+ const legacyKeysPath = join(dataDir, 'registry', 'keys.json');
38
+ const newKeysPath = join(dataDir, 'keys.json');
39
+ if (existsSync(legacyKeysPath) && !existsSync(newKeysPath)) {
40
+ renameSync(legacyKeysPath, newKeysPath);
41
+ console.log('[sh3] Migrated keys.json from data/registry/ to data/');
42
+ }
43
+ // Stores
44
+ const keys = new KeyStore(dataDir);
45
+ const users = new UserStore(dataDir);
46
+ const settings = new SettingsStore(dataDir);
47
+ // --no-auth: disable auth enforcement (Tauri sidecar / local-owner mode)
48
+ if (options.noAuth) {
49
+ settings.update({ auth: { required: false, guestAllowed: true } });
50
+ }
51
+ const sessions = new SessionStore(settings.get().auth.sessionTTL);
52
+ const app = new Hono();
53
+ const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
54
+ const shardRouter = new ShardRouter();
55
+ // Factory shards call to register a WebSocket upgrade handler. The
56
+ // returned value is the Hono handler for `app.get(path, ..., handler)`.
57
+ // The onConnect callback receives both the raw WS and the Hono Context
58
+ // from the upgrade request, so shards can read `c.get('session')` to
59
+ // route the connection to the correct per-user state.
60
+ const wsRegister = (onConnect) => upgradeWebSocket((c) => ({
61
+ onOpen(_evt, ws) {
62
+ onConnect(ws, c);
63
+ },
64
+ }));
65
+ // CORS
66
+ app.use('*', cors({ origin: '*', credentials: true }));
67
+ // --- Public routes (no auth) ---
68
+ // Server version
69
+ app.get('/api/version', (c) => {
70
+ let version;
71
+ try {
72
+ version = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8')).version;
73
+ }
74
+ catch {
75
+ // In a SEA binary, import.meta.url is unavailable; esbuild define inlines __SEA_VERSION__.
76
+ version = typeof __SEA_VERSION__ !== 'undefined' ? __SEA_VERSION__ : 'unknown';
77
+ }
78
+ return c.json({ version });
79
+ });
80
+ // Boot config
81
+ app.route('/api/boot', createBootRouter(sessions, users, settings));
82
+ // Auth endpoints (login, logout, register, verify)
83
+ app.route('/api/auth', createAuthRouter(keys, users, sessions, settings));
84
+ // Document backend API
85
+ app.route('/api/docs', createDocsRouter(dataDir));
86
+ // --- Session-gated routes ---
87
+ app.use('/api/*', sessionAuth(sessions, settings));
88
+ // Environment state API (per-shard server-backed config)
89
+ app.route('/api/env-state', createEnvStateRouter(dataDir));
90
+ // Package listing (public read, writes need admin — handled by admin middleware on management routes)
91
+ app.get('/api/packages', (c) => {
92
+ const packages = scanPackages(dataDir);
93
+ return c.json(packages.filter(p => p.clientPath).map(p => {
94
+ const manifestPath = join(dataDir, 'packages', p.id, 'manifest.json');
95
+ let manifest = {};
96
+ try {
97
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
98
+ }
99
+ catch {
100
+ // Fall back to scanned data.
101
+ }
102
+ return {
103
+ id: p.id,
104
+ type: p.type,
105
+ label: p.label,
106
+ version: p.version,
107
+ bundleUrl: `/packages/${p.id}/client.js`,
108
+ sourceRegistry: manifest.sourceRegistry ?? '',
109
+ contractVersion: String(manifest.contractVersion ?? p.contractVersion ?? ''),
110
+ installedAt: manifest.installedAt ?? '',
111
+ };
112
+ }));
113
+ });
114
+ // Route introspection
115
+ app.get('/api/routes', (c) => {
116
+ const seen = new Set();
117
+ const routes = [];
118
+ for (const r of app.routes) {
119
+ const key = `${r.method} ${r.path}`;
120
+ if (seen.has(key))
121
+ continue;
122
+ seen.add(key);
123
+ routes.push({ method: r.method, path: r.path });
124
+ }
125
+ for (const r of shardRouter.listRoutes()) {
126
+ const key = `${r.method} ${r.path}`;
127
+ if (seen.has(key))
128
+ continue;
129
+ seen.add(key);
130
+ routes.push(r);
131
+ }
132
+ return c.json(routes);
133
+ });
134
+ // --- Admin-gated routes ---
135
+ // Admin API
136
+ const adminRouter = createAdminRouter(users, settings, sessions, keys, dataDir);
137
+ app.use('/api/admin/*', adminAuth(sessions, keys, settings));
138
+ app.route('/api/admin', adminRouter);
139
+ // Package management (install/uninstall) — admin-gated
140
+ app.use('/api/packages/install', adminAuth(sessions, keys, settings));
141
+ app.use('/api/packages/uninstall', adminAuth(sessions, keys, settings));
142
+ app.route('/api/packages', createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister));
143
+ // Serve client bundles from discovered packages
144
+ servePackageBundles(app, dataDir);
145
+ // Framework built-in: shell-shard server routes.
146
+ //
147
+ // Mounted directly on the top-level Hono app (not via shardRouter) so
148
+ // that `@hono/node-ws` can find the WebSocket upgrade handler for
149
+ // `/api/shell/session`. The dynamic shardRouter dispatches requests by
150
+ // re-running `subApp.fetch()`, which creates a new Context per call —
151
+ // WS metadata stored on that inner context never propagates back to
152
+ // the upgrade event on the outer request, so WS upgrades silently
153
+ // fail. `app.route(prefix, subApp)` instead merges the sub-app's
154
+ // routes into the main route tree at construction time, keeping the
155
+ // upgrade middleware reachable.
156
+ {
157
+ const shellPkgDir = join(dataDir, 'shell-shard');
158
+ const shellDataDir = join(shellPkgDir, 'data');
159
+ mkdirSync(shellDataDir, { recursive: true });
160
+ const shellSubApp = new Hono();
161
+ await shellShardServer.routes(shellSubApp, {
162
+ shardId: 'shell',
163
+ dataDir: shellDataDir,
164
+ adminOnly: adminOnly(keys, settings),
165
+ wsRegister,
166
+ });
167
+ app.route('/api/shell', shellSubApp);
168
+ }
169
+ // Dynamic shard routes (packages). The catch-all comes after static
170
+ // framework mounts above so /api/shell/* is claimed first.
171
+ app.all('/api/:shardId/*', shardRouter.handler());
172
+ return {
173
+ app,
174
+ port,
175
+ dataDir,
176
+ distDir,
177
+ keys,
178
+ users,
179
+ sessions,
180
+ settings,
181
+ async start() {
182
+ // First-boot: generate admin key + admin user
183
+ if (keys.isEmpty()) {
184
+ const initial = keys.generate('Initial admin key');
185
+ const tempPassword = UserStore.generatePassword();
186
+ await users.create({
187
+ username: 'admin',
188
+ displayName: 'Administrator',
189
+ password: tempPassword,
190
+ role: 'admin',
191
+ });
192
+ console.log('');
193
+ console.log('╔══════════════════════════════════════════════════════════╗');
194
+ console.log('║ SH3 First Boot ║');
195
+ console.log('╠══════════════════════════════════════════════════════════╣');
196
+ console.log(`║ API key: ${initial.key} ║`);
197
+ console.log(`║ Admin: admin / ${tempPassword.padEnd(37)}║`);
198
+ console.log('║ Change the admin password immediately. ║');
199
+ console.log('╚══════════════════════════════════════════════════════════╝');
200
+ console.log('');
201
+ }
202
+ await loadPackages(shardRouter, dataDir, keys, settings, wsRegister);
203
+ // Periodic session cleanup (every 15 minutes)
204
+ setInterval(() => sessions.cleanup(), 15 * 60 * 1000);
205
+ // API 404
206
+ app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404));
207
+ // Static assets
208
+ app.use('/*', serveStatic({ root: distDir }));
209
+ app.get('/', serveStatic({ root: distDir, path: 'index.html' }));
210
+ // 404 fallback
211
+ // In a SEA binary, import.meta.url is unavailable; esbuild define inlines __SEA_404_HTML__.
212
+ let page404;
213
+ try {
214
+ const staticDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static');
215
+ page404 = readFileSync(join(staticDir, '404.html'), 'utf-8');
216
+ }
217
+ catch {
218
+ page404 = typeof __SEA_404_HTML__ !== 'undefined' ? __SEA_404_HTML__ : '<html><body>404 Not Found</body></html>';
219
+ }
220
+ app.all('*', (c) => c.html(page404, 404));
221
+ const server = serve({ fetch: app.fetch, port }, () => {
222
+ console.log(`sh3-server listening on http://localhost:${port}`);
223
+ });
224
+ injectWebSocket(server);
225
+ },
226
+ };
227
+ }
228
+ export { KeyStore } from './keys.js';
package/dist/keys.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * API key management — generate, validate, list, revoke.
3
+ *
4
+ * Keys are stored in {dataDir}/keys.json. Each key has an id
5
+ * (short identifier for display/revocation), the full key value (used
6
+ * for auth), a label, and creation timestamp.
7
+ */
8
+ export interface ApiKey {
9
+ /** Short id for display and revocation. */
10
+ id: string;
11
+ /** The full bearer token value. */
12
+ key: string;
13
+ /** Human-readable label. */
14
+ label: string;
15
+ /** ISO timestamp of creation. */
16
+ createdAt: string;
17
+ }
18
+ export declare class KeyStore {
19
+ #private;
20
+ constructor(dataDir: string);
21
+ /** Validate a bearer token. Returns true if the key exists. */
22
+ validate(token: string): boolean;
23
+ /** List all keys (returns id, label, createdAt — never the full key). */
24
+ list(): Omit<ApiKey, 'key'>[];
25
+ /** List all keys including full key values (admin only). */
26
+ listFull(): ApiKey[];
27
+ /** Generate a new API key. Returns the full key (only shown once). */
28
+ generate(label: string): ApiKey;
29
+ /** Revoke a key by id. Returns true if found and removed. */
30
+ revoke(id: string): boolean;
31
+ /** True if no keys exist (first boot). */
32
+ isEmpty(): boolean;
33
+ }
package/dist/keys.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * API key management — generate, validate, list, revoke.
3
+ *
4
+ * Keys are stored in {dataDir}/keys.json. Each key has an id
5
+ * (short identifier for display/revocation), the full key value (used
6
+ * for auth), a label, and creation timestamp.
7
+ */
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ export class KeyStore {
12
+ #path;
13
+ #keys = [];
14
+ constructor(dataDir) {
15
+ this.#path = join(dataDir, 'keys.json');
16
+ this.#load();
17
+ }
18
+ #load() {
19
+ if (existsSync(this.#path)) {
20
+ try {
21
+ this.#keys = JSON.parse(readFileSync(this.#path, 'utf-8'));
22
+ }
23
+ catch {
24
+ this.#keys = [];
25
+ }
26
+ }
27
+ }
28
+ #save() {
29
+ const dir = dirname(this.#path);
30
+ mkdirSync(dir, { recursive: true });
31
+ writeFileSync(this.#path, JSON.stringify(this.#keys, null, 2));
32
+ }
33
+ /** Validate a bearer token. Returns true if the key exists. */
34
+ validate(token) {
35
+ return this.#keys.some((k) => k.key === token);
36
+ }
37
+ /** List all keys (returns id, label, createdAt — never the full key). */
38
+ list() {
39
+ return this.#keys.map(({ id, label, createdAt }) => ({ id, label, createdAt }));
40
+ }
41
+ /** List all keys including full key values (admin only). */
42
+ listFull() {
43
+ return this.#keys.map((k) => ({ ...k }));
44
+ }
45
+ /** Generate a new API key. Returns the full key (only shown once). */
46
+ generate(label) {
47
+ const id = randomBytes(4).toString('hex');
48
+ const key = `sh3_${randomBytes(32).toString('hex')}`;
49
+ const entry = { id, key, label, createdAt: new Date().toISOString() };
50
+ this.#keys.push(entry);
51
+ this.#save();
52
+ return entry;
53
+ }
54
+ /** Revoke a key by id. Returns true if found and removed. */
55
+ revoke(id) {
56
+ const before = this.#keys.length;
57
+ this.#keys = this.#keys.filter((k) => k.id !== id);
58
+ if (this.#keys.length < before) {
59
+ this.#save();
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ /** True if no keys exist (first boot). */
65
+ isEmpty() {
66
+ return this.#keys.length === 0;
67
+ }
68
+ }
@@ -0,0 +1,38 @@
1
+ import { Hono } from 'hono';
2
+ import type { KeyStore } from './keys.js';
3
+ import type { SettingsStore } from './settings.js';
4
+ import { ShardRouter } from './shard-router.js';
5
+ import type { MountContext } from './shard-router.js';
6
+ /** Type of the `wsRegister` factory field from MountContext. */
7
+ type WsRegister = MountContext['wsRegister'];
8
+ export interface DiscoveredPackage {
9
+ id: string;
10
+ type: string;
11
+ label: string;
12
+ version: string;
13
+ contractVersion: number;
14
+ clientPath?: string;
15
+ serverPath?: string;
16
+ }
17
+ /**
18
+ * Scan `<dataDir>/packages/` for directories containing `manifest.json`.
19
+ * For each valid package, mount server routes (if server.js exists) and
20
+ * return the full list of discovered packages.
21
+ */
22
+ export declare function loadPackages(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister): Promise<DiscoveredPackage[]>;
23
+ /**
24
+ * Re-scan `<dataDir>/packages/` and return metadata for all valid packages.
25
+ * Unlike `loadPackages`, this does NOT mount server routes — it only reads
26
+ * manifests so the listing endpoint always reflects what is on disk.
27
+ */
28
+ export declare function scanPackages(dataDir: string): DiscoveredPackage[];
29
+ /**
30
+ * Register `GET /packages/:id/client.js` to serve client bundles from disk.
31
+ */
32
+ export declare function servePackageBundles(app: Hono, dataDir: string): void;
33
+ /**
34
+ * Returns a Hono router with POST /install and POST /uninstall.
35
+ * Protected by the blanket `/api/*` auth middleware already applied upstream.
36
+ */
37
+ export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister): Hono;
38
+ export {};