sh3-server 0.7.5 → 0.8.2
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/app/assets/index-Cb-zoqb1.js +17 -0
- package/app/assets/index-Cb-zoqb1.js.map +1 -0
- package/app/assets/index-DPcN5Lor.css +1 -0
- package/app/index.html +2 -2
- package/dist/auth.d.ts +10 -3
- package/dist/auth.js +14 -22
- package/dist/caller.d.ts +16 -0
- package/dist/caller.js +54 -0
- package/dist/cli.js +9 -7
- package/dist/fs-backend.d.ts +10 -0
- package/dist/fs-backend.js +105 -0
- package/dist/index.js +30 -12
- package/dist/keys.d.ts +33 -19
- package/dist/keys.js +172 -49
- package/dist/packages.d.ts +23 -3
- package/dist/packages.js +67 -6
- package/dist/routes/admin.js +7 -3
- package/dist/routes/docs.d.ts +2 -0
- package/dist/routes/docs.js +30 -0
- package/dist/routes/keys.d.ts +21 -0
- package/dist/routes/keys.js +164 -0
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +25 -0
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +33 -0
- package/dist/shard-router.d.ts +9 -2
- package/dist/shard-router.js +58 -29
- package/dist/shell-shard/index.d.ts +6 -1
- package/dist/shell-shard/index.js +3 -1
- package/dist/shell-shard/session-manager.d.ts +2 -1
- package/dist/shell-shard/session-manager.js +15 -2
- package/dist/shell-shard/ws.js +14 -14
- package/dist/tenant-fs/http.d.ts +15 -0
- package/dist/tenant-fs/http.js +109 -0
- package/dist/tenant-fs/index.d.ts +4 -0
- package/dist/tenant-fs/index.js +4 -0
- package/dist/tenant-fs/paths.d.ts +23 -0
- package/dist/tenant-fs/paths.js +51 -0
- package/dist/tenant-fs/resolve.d.ts +16 -0
- package/dist/tenant-fs/resolve.js +48 -0
- package/dist/tenant-fs/session-required.d.ts +11 -0
- package/dist/tenant-fs/session-required.js +19 -0
- package/package.json +2 -2
- package/app/assets/index-25fXNyG3.js +0 -12
- package/app/assets/index-25fXNyG3.js.map +0 -1
- package/app/assets/index-BcQ1cruS.css +0 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-tenant key management endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Auth: uses `caller.tenantId` set by resolveCaller. Each caller can only
|
|
5
|
+
* see and revoke keys in their own tenant.
|
|
6
|
+
*
|
|
7
|
+
* Mint flow:
|
|
8
|
+
* 1. Shell calls POST /consent (session-only) → receives a short-lived ticket.
|
|
9
|
+
* 2. Shard calls POST / with the ticket → key is generated and returned once.
|
|
10
|
+
*
|
|
11
|
+
* Tickets are single-use and expire after TICKET_TTL_MS (60 s).
|
|
12
|
+
*/
|
|
13
|
+
import { Hono } from 'hono';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { tenantRequired } from '../scope.js';
|
|
16
|
+
const TICKET_TTL_MS = 60_000;
|
|
17
|
+
function sweepExpired(tickets) {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
for (const [token, entry] of tickets) {
|
|
20
|
+
if (now - entry.issuedAt > TICKET_TTL_MS)
|
|
21
|
+
tickets.delete(token);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function createKeysRouter(keys, onRevoke) {
|
|
25
|
+
const router = new Hono();
|
|
26
|
+
const tickets = new Map();
|
|
27
|
+
// SSE subscribers — one entry per connected browser tab.
|
|
28
|
+
const sseSubscribers = new Set();
|
|
29
|
+
/** Push a revocation event to all connected SSE listeners. */
|
|
30
|
+
function pushToBus(ev) {
|
|
31
|
+
for (const fn of sseSubscribers)
|
|
32
|
+
fn(ev);
|
|
33
|
+
}
|
|
34
|
+
router.use('*', tenantRequired);
|
|
35
|
+
router.get('/', (c) => {
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
const caller = c.get('caller');
|
|
38
|
+
return c.json(keys.listForTenant(caller.tenantId));
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* POST /consent — shell-only endpoint that issues a single-use consent ticket.
|
|
42
|
+
*
|
|
43
|
+
* Only session-authenticated callers may call this — bearer-token clients
|
|
44
|
+
* (background shards) must not be able to self-issue consent proofs.
|
|
45
|
+
*/
|
|
46
|
+
router.post('/consent', async (c) => {
|
|
47
|
+
sweepExpired(tickets);
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
const caller = c.get('caller');
|
|
50
|
+
if (caller.source !== 'session' || !caller.userId) {
|
|
51
|
+
return c.json({ error: 'Consent requires a logged-in session.' }, 403);
|
|
52
|
+
}
|
|
53
|
+
const body = (await c.req.json());
|
|
54
|
+
if (!body.shardId || !body.label || !Array.isArray(body.scopes)) {
|
|
55
|
+
return c.json({ error: 'Missing shardId/label/scopes' }, 400);
|
|
56
|
+
}
|
|
57
|
+
if (!body.scopes.every((s) => typeof s === 'string')) {
|
|
58
|
+
return c.json({ error: 'scopes must be an array of strings' }, 400);
|
|
59
|
+
}
|
|
60
|
+
const ticket = `tk_${randomBytes(16).toString('hex')}`;
|
|
61
|
+
tickets.set(ticket, {
|
|
62
|
+
shardId: body.shardId,
|
|
63
|
+
label: body.label,
|
|
64
|
+
scopes: body.scopes,
|
|
65
|
+
connectorId: body.connectorId,
|
|
66
|
+
expiresIn: body.expiresIn,
|
|
67
|
+
tenantId: caller.tenantId,
|
|
68
|
+
userId: caller.userId,
|
|
69
|
+
issuedAt: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
return c.json({ ticket });
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* POST / — mint a key using a valid consent ticket.
|
|
75
|
+
*
|
|
76
|
+
* Ticket is consumed on first use (single-use). Expired or unknown tickets
|
|
77
|
+
* return 400. The full key value is returned exactly once.
|
|
78
|
+
*/
|
|
79
|
+
router.post('/', async (c) => {
|
|
80
|
+
sweepExpired(tickets);
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
const caller = c.get('caller');
|
|
83
|
+
const body = (await c.req.json());
|
|
84
|
+
if (!body.ticket)
|
|
85
|
+
return c.json({ error: 'Missing ticket' }, 400);
|
|
86
|
+
const entry = tickets.get(body.ticket);
|
|
87
|
+
tickets.delete(body.ticket); // consume immediately (single-use)
|
|
88
|
+
if (!entry)
|
|
89
|
+
return c.json({ error: 'Ticket invalid or already used' }, 400);
|
|
90
|
+
if (Date.now() - entry.issuedAt > TICKET_TTL_MS) {
|
|
91
|
+
return c.json({ error: 'Ticket invalid or already used' }, 400);
|
|
92
|
+
}
|
|
93
|
+
if (entry.tenantId !== caller.tenantId) {
|
|
94
|
+
return c.json({ error: 'Ticket invalid or already used' }, 400);
|
|
95
|
+
}
|
|
96
|
+
const expiresAt = entry.expiresIn
|
|
97
|
+
? new Date(Date.now() + entry.expiresIn).toISOString()
|
|
98
|
+
: undefined;
|
|
99
|
+
const row = keys.generate({
|
|
100
|
+
label: entry.label,
|
|
101
|
+
tenantId: entry.tenantId,
|
|
102
|
+
ownerUserId: entry.userId,
|
|
103
|
+
mintedByShardId: entry.shardId,
|
|
104
|
+
scopes: entry.scopes,
|
|
105
|
+
connectorId: entry.connectorId,
|
|
106
|
+
expiresAt,
|
|
107
|
+
});
|
|
108
|
+
return c.json({ id: row.id, key: row.key });
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* GET /events — server-sent events stream.
|
|
112
|
+
*
|
|
113
|
+
* Delivers `{ id, shardId }` messages whenever a key belonging to this
|
|
114
|
+
* tenant is revoked from any source. The client-side revocation bus
|
|
115
|
+
* consumes this stream and dispatches `onKeyRevoked` on the owning shard.
|
|
116
|
+
*
|
|
117
|
+
* Auth: tenantRequired (already applied via the `*` middleware above).
|
|
118
|
+
* Each connected tab gets its own stream. Connections are cleaned up
|
|
119
|
+
* automatically when the client disconnects (abort signal).
|
|
120
|
+
*/
|
|
121
|
+
router.get('/events', (c) => {
|
|
122
|
+
return new Response(new ReadableStream({
|
|
123
|
+
start(controller) {
|
|
124
|
+
const enc = new TextEncoder();
|
|
125
|
+
const send = (data) => {
|
|
126
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
127
|
+
};
|
|
128
|
+
const fn = (ev) => send(ev);
|
|
129
|
+
sseSubscribers.add(fn);
|
|
130
|
+
// Send a keepalive comment every 20 s so proxies don't kill idle connections.
|
|
131
|
+
const heartbeat = setInterval(() => {
|
|
132
|
+
try {
|
|
133
|
+
controller.enqueue(enc.encode(': ping\n\n'));
|
|
134
|
+
}
|
|
135
|
+
catch { /* stream closed */ }
|
|
136
|
+
}, 20_000);
|
|
137
|
+
c.req.raw.signal?.addEventListener('abort', () => {
|
|
138
|
+
clearInterval(heartbeat);
|
|
139
|
+
sseSubscribers.delete(fn);
|
|
140
|
+
controller.close();
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
}), {
|
|
144
|
+
headers: {
|
|
145
|
+
'content-type': 'text/event-stream',
|
|
146
|
+
'cache-control': 'no-cache',
|
|
147
|
+
connection: 'keep-alive',
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
router.delete('/:id', async (c) => {
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
+
const caller = c.get('caller');
|
|
154
|
+
const id = c.req.param('id');
|
|
155
|
+
const removed = keys.revoke(caller.tenantId, id);
|
|
156
|
+
if (!removed)
|
|
157
|
+
return c.json({ error: 'Key not found' }, 404);
|
|
158
|
+
await onRevoke({ tenantId: caller.tenantId, id, row: removed });
|
|
159
|
+
// Broadcast to SSE subscribers so other browser tabs learn of the revocation.
|
|
160
|
+
pushToBus({ id, shardId: removed.mintedByShardId ?? null });
|
|
161
|
+
return c.json({ ok: true });
|
|
162
|
+
});
|
|
163
|
+
return router;
|
|
164
|
+
}
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope-based route guards. Operate on the `caller` set by resolveCaller.
|
|
3
|
+
*
|
|
4
|
+
* scopeRequired(scope) — 403 unless caller has that scope or admin:*.
|
|
5
|
+
* tenantRequired — 401 unless caller.tenantId is non-null.
|
|
6
|
+
*/
|
|
7
|
+
import type { MiddlewareHandler } from 'hono';
|
|
8
|
+
export declare function scopeRequired(scope: string): MiddlewareHandler;
|
|
9
|
+
export declare const tenantRequired: MiddlewareHandler;
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope-based route guards. Operate on the `caller` set by resolveCaller.
|
|
3
|
+
*
|
|
4
|
+
* scopeRequired(scope) — 403 unless caller has that scope or admin:*.
|
|
5
|
+
* tenantRequired — 401 unless caller.tenantId is non-null.
|
|
6
|
+
*/
|
|
7
|
+
export function scopeRequired(scope) {
|
|
8
|
+
return async (c, next) => {
|
|
9
|
+
const caller = c.get('caller');
|
|
10
|
+
if (!caller)
|
|
11
|
+
return c.json({ error: 'Caller not resolved' }, 500);
|
|
12
|
+
if (caller.scopes.includes('admin:*') || caller.scopes.includes(scope)) {
|
|
13
|
+
return next();
|
|
14
|
+
}
|
|
15
|
+
return c.json({ error: `Missing required scope: ${scope}` }, 403);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export const tenantRequired = async (c, next) => {
|
|
19
|
+
const caller = c.get('caller');
|
|
20
|
+
if (!caller)
|
|
21
|
+
return c.json({ error: 'Caller not resolved' }, 500);
|
|
22
|
+
if (!caller.tenantId)
|
|
23
|
+
return c.json({ error: 'Tenant-scoped credentials required' }, 401);
|
|
24
|
+
return next();
|
|
25
|
+
};
|
package/dist/settings.d.ts
CHANGED
|
@@ -9,6 +9,17 @@ export interface GlobalSettings {
|
|
|
9
9
|
sessionTTL: number;
|
|
10
10
|
selfRegistration: boolean;
|
|
11
11
|
};
|
|
12
|
+
tenants: {
|
|
13
|
+
/** Absolute or dataDir-relative base; each user gets `<base>/<userId>/documents/`.
|
|
14
|
+
* Empty string means "<dataDir>/users" at resolve time. */
|
|
15
|
+
rootBase: string;
|
|
16
|
+
};
|
|
17
|
+
packages: {
|
|
18
|
+
/** Max-age in seconds for `GET /packages/:id/client.js` responses.
|
|
19
|
+
* Clamped to [0, 31536000]. 0 emits `Cache-Control: no-store`.
|
|
20
|
+
* Any other value emits `public, max-age=N` (never `immutable`). */
|
|
21
|
+
cacheMaxAge: number;
|
|
22
|
+
};
|
|
12
23
|
}
|
|
13
24
|
export declare class SettingsStore {
|
|
14
25
|
#private;
|
|
@@ -18,5 +29,7 @@ export declare class SettingsStore {
|
|
|
18
29
|
/** Patch settings. Only provided fields are updated. */
|
|
19
30
|
update(patch: {
|
|
20
31
|
auth?: Partial<GlobalSettings['auth']>;
|
|
32
|
+
tenants?: Partial<GlobalSettings['tenants']>;
|
|
33
|
+
packages?: Partial<GlobalSettings['packages']>;
|
|
21
34
|
}): GlobalSettings;
|
|
22
35
|
}
|
package/dist/settings.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
6
6
|
import { dirname } from 'node:path';
|
|
7
|
+
const MAX_PACKAGE_CACHE_AGE = 31536000; // 1 year
|
|
7
8
|
const DEFAULTS = {
|
|
8
9
|
auth: {
|
|
9
10
|
required: true,
|
|
@@ -11,7 +12,24 @@ const DEFAULTS = {
|
|
|
11
12
|
sessionTTL: 24,
|
|
12
13
|
selfRegistration: false,
|
|
13
14
|
},
|
|
15
|
+
tenants: {
|
|
16
|
+
rootBase: '',
|
|
17
|
+
},
|
|
18
|
+
packages: {
|
|
19
|
+
cacheMaxAge: MAX_PACKAGE_CACHE_AGE,
|
|
20
|
+
},
|
|
14
21
|
};
|
|
22
|
+
function clampCacheMaxAge(value) {
|
|
23
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
24
|
+
return DEFAULTS.packages.cacheMaxAge;
|
|
25
|
+
}
|
|
26
|
+
const floored = Math.floor(value);
|
|
27
|
+
if (floored < 0)
|
|
28
|
+
return 0;
|
|
29
|
+
if (floored > MAX_PACKAGE_CACHE_AGE)
|
|
30
|
+
return MAX_PACKAGE_CACHE_AGE;
|
|
31
|
+
return floored;
|
|
32
|
+
}
|
|
15
33
|
export class SettingsStore {
|
|
16
34
|
#path;
|
|
17
35
|
#settings;
|
|
@@ -31,6 +49,12 @@ export class SettingsStore {
|
|
|
31
49
|
sessionTTL: raw.auth?.sessionTTL ?? DEFAULTS.auth.sessionTTL,
|
|
32
50
|
selfRegistration: raw.auth?.selfRegistration ?? DEFAULTS.auth.selfRegistration,
|
|
33
51
|
},
|
|
52
|
+
tenants: {
|
|
53
|
+
rootBase: raw.tenants?.rootBase ?? DEFAULTS.tenants.rootBase,
|
|
54
|
+
},
|
|
55
|
+
packages: {
|
|
56
|
+
cacheMaxAge: clampCacheMaxAge(raw.packages?.cacheMaxAge),
|
|
57
|
+
},
|
|
34
58
|
};
|
|
35
59
|
}
|
|
36
60
|
catch {
|
|
@@ -57,6 +81,15 @@ export class SettingsStore {
|
|
|
57
81
|
if (patch.auth.selfRegistration !== undefined)
|
|
58
82
|
this.#settings.auth.selfRegistration = patch.auth.selfRegistration;
|
|
59
83
|
}
|
|
84
|
+
if (patch.tenants) {
|
|
85
|
+
if (patch.tenants.rootBase !== undefined)
|
|
86
|
+
this.#settings.tenants.rootBase = patch.tenants.rootBase;
|
|
87
|
+
}
|
|
88
|
+
if (patch.packages) {
|
|
89
|
+
if (patch.packages.cacheMaxAge !== undefined) {
|
|
90
|
+
this.#settings.packages.cacheMaxAge = clampCacheMaxAge(patch.packages.cacheMaxAge);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
60
93
|
this.#save();
|
|
61
94
|
return this.get();
|
|
62
95
|
}
|
package/dist/shard-router.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface MountContext {
|
|
|
6
6
|
pkgDir: string;
|
|
7
7
|
keys: KeyStore;
|
|
8
8
|
settings: SettingsStore;
|
|
9
|
+
/** The server's document backend, for sync handle construction. */
|
|
10
|
+
documentBackend: import('sh3-core/server-sync').DocumentBackend;
|
|
9
11
|
/**
|
|
10
12
|
* Register a WebSocket upgrade handler on a path under this shard's
|
|
11
13
|
* route prefix. The returned value is a Hono middleware handler that
|
|
@@ -45,8 +47,13 @@ export interface MountContext {
|
|
|
45
47
|
*/
|
|
46
48
|
wsRegister(onConnect: (ws: any, c: any) => void): any;
|
|
47
49
|
}
|
|
48
|
-
/** Middleware
|
|
49
|
-
export declare function adminOnly(
|
|
50
|
+
/** Middleware requiring the caller's scope set to include admin:*. */
|
|
51
|
+
export declare function adminOnly(_keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
|
|
52
|
+
/**
|
|
53
|
+
* Build a ServerShardContext object for the given shard, wiring
|
|
54
|
+
* sync/syncRegistry based on the declared permissions.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildShardCtx(shardId: string, dataDir: string, permissions: string[], keys: KeyStore, settings: SettingsStore, wsRegister: MountContext['wsRegister'], documentBackend: MountContext['documentBackend']): Record<string, unknown>;
|
|
50
57
|
/**
|
|
51
58
|
* Dynamic shard route manager. Holds a Map of shard Hono sub-apps
|
|
52
59
|
* and delegates requests from a single wildcard route.
|
package/dist/shard-router.js
CHANGED
|
@@ -1,30 +1,51 @@
|
|
|
1
1
|
// packages/sh3-server/src/shard-router.ts
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { scopeRequired, tenantRequired } from './scope.js';
|
|
5
6
|
import { pathToFileURL } from 'node:url';
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import { getSyncBundle, createSyncHandle, createSyncRegistry } from 'sh3-core/server-sync';
|
|
8
|
+
/** Middleware requiring the caller's scope set to include admin:*. */
|
|
9
|
+
export function adminOnly(_keys, settings) {
|
|
8
10
|
return async (c, next) => {
|
|
9
|
-
|
|
10
|
-
if (!settings.get().auth.required) {
|
|
11
|
+
if (!settings.get().auth.required)
|
|
11
12
|
return next();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const session = c.get('session') ?? c.env?.session;
|
|
15
|
-
if (session?.role === 'admin') {
|
|
13
|
+
const caller = c.get('caller');
|
|
14
|
+
if (caller?.scopes.includes('admin:*'))
|
|
16
15
|
return next();
|
|
16
|
+
return c.json({ error: 'Admin privileges required' }, 403);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const PERMISSION_DOCUMENTS_SYNC = 'documents:sync';
|
|
20
|
+
/**
|
|
21
|
+
* Build a ServerShardContext object for the given shard, wiring
|
|
22
|
+
* sync/syncRegistry based on the declared permissions.
|
|
23
|
+
*/
|
|
24
|
+
export function buildShardCtx(shardId, dataDir, permissions, keys, settings, wsRegister, documentBackend) {
|
|
25
|
+
const hasSync = permissions.includes(PERMISSION_DOCUMENTS_SYNC);
|
|
26
|
+
const ctx = {
|
|
27
|
+
shardId,
|
|
28
|
+
dataDir,
|
|
29
|
+
permissions,
|
|
30
|
+
adminOnly: adminOnly(keys, settings),
|
|
31
|
+
scopeRequired,
|
|
32
|
+
tenantRequired,
|
|
33
|
+
wsRegister,
|
|
34
|
+
};
|
|
35
|
+
ctx.sync = async (tenantId, connectorId) => {
|
|
36
|
+
if (!hasSync) {
|
|
37
|
+
throw new Error(`Shard "${shardId}" cannot call ctx.sync — missing '${PERMISSION_DOCUMENTS_SYNC}' permission in manifest.`);
|
|
17
38
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
39
|
+
const { engine, registry } = await getSyncBundle(documentBackend, tenantId);
|
|
40
|
+
return createSyncHandle({ tenantId, connectorId, engine, registry });
|
|
41
|
+
};
|
|
42
|
+
ctx.syncRegistry = (tenantId) => {
|
|
43
|
+
if (!hasSync) {
|
|
44
|
+
throw new Error(`Shard "${shardId}" cannot call ctx.syncRegistry — missing '${PERMISSION_DOCUMENTS_SYNC}' permission in manifest.`);
|
|
25
45
|
}
|
|
26
|
-
return
|
|
46
|
+
return createSyncRegistry(documentBackend, tenantId);
|
|
27
47
|
};
|
|
48
|
+
return ctx;
|
|
28
49
|
}
|
|
29
50
|
/**
|
|
30
51
|
* Dynamic shard route manager. Holds a Map of shard Hono sub-apps
|
|
@@ -44,13 +65,17 @@ export class ShardRouter {
|
|
|
44
65
|
throw new Error(`${shardId}/server.js invalid export shape — expected { id, routes }`);
|
|
45
66
|
}
|
|
46
67
|
const shardDataDir = join(ctx.pkgDir, 'data');
|
|
68
|
+
const manifestPath = join(ctx.pkgDir, 'manifest.json');
|
|
69
|
+
let permissions = [];
|
|
70
|
+
try {
|
|
71
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
72
|
+
permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
|
|
76
|
+
}
|
|
47
77
|
const router = new Hono();
|
|
48
|
-
const shardCtx =
|
|
49
|
-
shardId: shard.id,
|
|
50
|
-
dataDir: shardDataDir,
|
|
51
|
-
adminOnly: adminOnly(ctx.keys, ctx.settings),
|
|
52
|
-
wsRegister: ctx.wsRegister,
|
|
53
|
-
};
|
|
78
|
+
const shardCtx = buildShardCtx(shard.id, shardDataDir, permissions, ctx.keys, ctx.settings, ctx.wsRegister, ctx.documentBackend);
|
|
54
79
|
await shard.routes(router, shardCtx);
|
|
55
80
|
// Create data dir only after routes() succeeds — so a failure
|
|
56
81
|
// doesn't leave behind a dir that prevents install rollback cleanup.
|
|
@@ -68,13 +93,17 @@ export class ShardRouter {
|
|
|
68
93
|
throw new Error(`${shardId} static mount — expected { id, routes }, got ${typeof mod}`);
|
|
69
94
|
}
|
|
70
95
|
const shardDataDir = join(ctx.pkgDir, 'data');
|
|
96
|
+
const manifestPath = join(ctx.pkgDir, 'manifest.json');
|
|
97
|
+
let permissions = [];
|
|
98
|
+
try {
|
|
99
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
100
|
+
permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Framework built-ins (mountStatic) may have no sibling manifest; default to empty.
|
|
104
|
+
}
|
|
71
105
|
const router = new Hono();
|
|
72
|
-
const shardCtx =
|
|
73
|
-
shardId: mod.id,
|
|
74
|
-
dataDir: shardDataDir,
|
|
75
|
-
adminOnly: adminOnly(ctx.keys, ctx.settings),
|
|
76
|
-
wsRegister: ctx.wsRegister,
|
|
77
|
-
};
|
|
106
|
+
const shardCtx = buildShardCtx(mod.id, shardDataDir, permissions, ctx.keys, ctx.settings, ctx.wsRegister, ctx.documentBackend);
|
|
78
107
|
await mod.routes(router, shardCtx);
|
|
79
108
|
mkdirSync(shardDataDir, { recursive: true });
|
|
80
109
|
this.shards.set(shardId, router);
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import type { Context } from 'hono';
|
|
2
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
3
3
|
import type { WsLike } from './session-manager.js';
|
|
4
4
|
export interface ShellServerContext {
|
|
5
5
|
shardId: string;
|
|
6
6
|
dataDir: string;
|
|
7
|
+
/** Base for per-user document roots; empty string → <dataDir>/users. */
|
|
8
|
+
tenantRootBase?: string;
|
|
7
9
|
adminOnly: any;
|
|
8
10
|
wsRegister: (onConnect: (ws: WsLike, c: Context) => void) => any;
|
|
11
|
+
permissions: string[];
|
|
12
|
+
scopeRequired: (scope: string) => MiddlewareHandler;
|
|
13
|
+
tenantRequired: MiddlewareHandler;
|
|
9
14
|
}
|
|
10
15
|
declare const _default: {
|
|
11
16
|
id: string;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { LocalRunner } from './runner.js';
|
|
11
11
|
import { SessionManager } from './session-manager.js';
|
|
12
12
|
import { handleClientMessage } from './ws.js';
|
|
13
|
+
import { shardDocumentsPath } from '../tenant-fs/paths.js';
|
|
13
14
|
function sessionUser(c) {
|
|
14
15
|
const session = c.get('session') ?? c.env?.session;
|
|
15
16
|
return session?.userId ?? 'admin';
|
|
@@ -20,7 +21,8 @@ export default {
|
|
|
20
21
|
// Default config — overridable via shard env state in a future pass.
|
|
21
22
|
const cfg = { ringSize: 500, historyMaxLines: 10_000, defaultCwd: '' };
|
|
22
23
|
const runner = new LocalRunner();
|
|
23
|
-
const
|
|
24
|
+
const userCwd = (userId) => shardDocumentsPath(ctx.dataDir, userId, 'shell', ctx.tenantRootBase ?? '');
|
|
25
|
+
const manager = new SessionManager(ctx.dataDir, runner, cfg, userCwd);
|
|
24
26
|
// NOTE: the JSON /history endpoint is convenience — the authoritative
|
|
25
27
|
// delivery of history is via the `history` server message sent on WS
|
|
26
28
|
// attach. Clients can skip the REST endpoint entirely. Kept for
|
|
@@ -58,6 +58,7 @@ export declare class SessionManager {
|
|
|
58
58
|
private readonly dataDir;
|
|
59
59
|
private readonly runner;
|
|
60
60
|
private readonly cfg;
|
|
61
|
-
|
|
61
|
+
private readonly userCwd;
|
|
62
|
+
constructor(dataDir: string, runner: Runner, cfg: SessionConfig, userCwd: (userId: string) => string);
|
|
62
63
|
getOrCreate(userId: string): ShellSession;
|
|
63
64
|
}
|
|
@@ -160,16 +160,29 @@ export class SessionManager {
|
|
|
160
160
|
dataDir;
|
|
161
161
|
runner;
|
|
162
162
|
cfg;
|
|
163
|
-
|
|
163
|
+
userCwd;
|
|
164
|
+
constructor(dataDir, runner, cfg, userCwd) {
|
|
164
165
|
this.dataDir = dataDir;
|
|
165
166
|
this.runner = runner;
|
|
166
167
|
this.cfg = cfg;
|
|
168
|
+
this.userCwd = userCwd;
|
|
167
169
|
}
|
|
168
170
|
getOrCreate(userId) {
|
|
169
171
|
let session = this.sessions.get(userId);
|
|
170
172
|
if (!session) {
|
|
171
173
|
const history = new HistoryStore(this.dataDir, userId, this.cfg.historyMaxLines);
|
|
172
|
-
|
|
174
|
+
// Resolve user cwd; on disk failure fall back to cfg.defaultCwd so
|
|
175
|
+
// a sick data dir doesn't block login. The warning surfaces in the
|
|
176
|
+
// server log so ops can notice.
|
|
177
|
+
let cwd = this.cfg.defaultCwd;
|
|
178
|
+
try {
|
|
179
|
+
cwd = this.userCwd(userId);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
console.warn(`[shell-shard] userCwd failed for ${userId}: ${err.message} — falling back to ${cwd || 'process.cwd()'}`);
|
|
183
|
+
}
|
|
184
|
+
const cfg = { ...this.cfg, defaultCwd: cwd };
|
|
185
|
+
session = new ShellSession(userId, this.runner, history, cfg);
|
|
173
186
|
this.sessions.set(userId, session);
|
|
174
187
|
}
|
|
175
188
|
return session;
|
package/dist/shell-shard/ws.js
CHANGED
|
@@ -15,21 +15,16 @@ export function handleClientMessage(session, ws, raw) {
|
|
|
15
15
|
msg = JSON.parse(raw);
|
|
16
16
|
}
|
|
17
17
|
catch {
|
|
18
|
-
// Malformed frame — ignore, do not crash the session.
|
|
19
18
|
return;
|
|
20
19
|
}
|
|
21
20
|
switch (msg.t) {
|
|
22
21
|
case 'hello':
|
|
23
|
-
// attach() already sent welcome+replay+history at connection time.
|
|
24
|
-
// The only meaningful thing here is a late hello with replayFrom,
|
|
25
|
-
// which v1 ignores — the client can resync by reconnecting.
|
|
26
22
|
return;
|
|
27
23
|
case 'submit': {
|
|
28
24
|
const trimmed = msg.line.trim();
|
|
29
25
|
if (trimmed.startsWith('cd ') || trimmed === 'cd') {
|
|
30
|
-
// Server-managed cd — don't spawn, update session cwd directly.
|
|
31
26
|
const target = trimmed === 'cd' ? '' : trimmed.slice(3).trim();
|
|
32
|
-
|
|
27
|
+
applyCwdChange(session, target, 'cd');
|
|
33
28
|
return;
|
|
34
29
|
}
|
|
35
30
|
void session.submit(msg.line, ws);
|
|
@@ -42,12 +37,12 @@ export function handleClientMessage(session, ws, raw) {
|
|
|
42
37
|
session.historyLog(msg.line);
|
|
43
38
|
return;
|
|
44
39
|
case 'cwd-query':
|
|
45
|
-
// Re-emit a cwd update message (reuses setCwd() broadcast path).
|
|
46
40
|
session.setCwd(session.cwd);
|
|
47
41
|
return;
|
|
42
|
+
case 'setCwd':
|
|
43
|
+
applyCwdChange(session, msg.path, 'setCwd');
|
|
44
|
+
return;
|
|
48
45
|
default: {
|
|
49
|
-
// Exhaustiveness check. If a new ClientMessage variant is added to
|
|
50
|
-
// the protocol without a handler here, TypeScript flags this line.
|
|
51
46
|
const _exhaustive = msg;
|
|
52
47
|
void _exhaustive;
|
|
53
48
|
return;
|
|
@@ -55,11 +50,16 @@ export function handleClientMessage(session, ws, raw) {
|
|
|
55
50
|
}
|
|
56
51
|
}
|
|
57
52
|
/**
|
|
58
|
-
*
|
|
59
|
-
* exists and is a directory, then update session.cwd via setCwd()
|
|
60
|
-
*
|
|
53
|
+
* Resolve a cwd-change request against the session's current cwd, validate
|
|
54
|
+
* it exists and is a directory, then update session.cwd via setCwd(). Used
|
|
55
|
+
* for both interactive `cd` (from submit) and programmatic `setCwd` (from
|
|
56
|
+
* the docs tree / file explorer).
|
|
57
|
+
*
|
|
58
|
+
* Stderr wording keeps the familiar `cd: no such directory` for shell users
|
|
59
|
+
* and `setCwd: no such directory` for programmatic callers, so the source
|
|
60
|
+
* of a bad path is obvious in the terminal log.
|
|
61
61
|
*/
|
|
62
|
-
function
|
|
62
|
+
function applyCwdChange(session, target, source) {
|
|
63
63
|
const dest = target === '' || target === '~'
|
|
64
64
|
? homedir()
|
|
65
65
|
: isAbsolute(target)
|
|
@@ -69,7 +69,7 @@ function handleCd(session, target) {
|
|
|
69
69
|
session.broadcast({
|
|
70
70
|
seq: 0,
|
|
71
71
|
kind: 'stderr',
|
|
72
|
-
data:
|
|
72
|
+
data: `${source}: no such directory: ${target}\n`,
|
|
73
73
|
ts: Date.now(),
|
|
74
74
|
});
|
|
75
75
|
return;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant FS HTTP API — mounts /api/fs/list, /api/fs/stat, /api/fs/read.
|
|
3
|
+
*
|
|
4
|
+
* Gated by sessionRequired; scope is the caller's own documentsRoot.
|
|
5
|
+
* Read-only. Writes are out of scope for this iteration.
|
|
6
|
+
*/
|
|
7
|
+
import type { Hono } from 'hono';
|
|
8
|
+
import type { SettingsStore } from '../settings.js';
|
|
9
|
+
export interface TenantFsRouteContext {
|
|
10
|
+
dataDir: string;
|
|
11
|
+
rootBase: string;
|
|
12
|
+
settings: SettingsStore;
|
|
13
|
+
maxReadBytes: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function registerTenantFsRoutes(app: Hono, ctx: TenantFsRouteContext): void;
|