sh3-server 0.8.1 → 0.9.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/app/assets/index-DKuJNK2S.js +17 -0
- package/app/assets/index-DKuJNK2S.js.map +1 -0
- package/app/assets/index-DkC3EpjJ.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 +17 -0
- package/dist/caller.js +55 -0
- package/dist/doc-store/conflicts.d.ts +19 -0
- package/dist/doc-store/conflicts.js +79 -0
- package/dist/doc-store/index.d.ts +11 -0
- package/dist/doc-store/index.js +22 -0
- package/dist/doc-store/meta.d.ts +11 -0
- package/dist/doc-store/meta.js +37 -0
- package/dist/doc-store/policy.d.ts +15 -0
- package/dist/doc-store/policy.js +85 -0
- package/dist/doc-store/reserved.d.ts +7 -0
- package/dist/doc-store/reserved.js +26 -0
- package/dist/doc-store/roles.d.ts +12 -0
- package/dist/doc-store/roles.js +15 -0
- package/dist/doc-store/store.d.ts +71 -0
- package/dist/doc-store/store.js +336 -0
- package/dist/doc-store/tick.d.ts +13 -0
- package/dist/doc-store/tick.js +52 -0
- package/dist/fs-backend.d.ts +10 -0
- package/dist/fs-backend.js +105 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +32 -5
- package/dist/keys.d.ts +35 -19
- package/dist/keys.js +187 -49
- package/dist/migrations/sync-grants.d.ts +7 -0
- package/dist/migrations/sync-grants.js +27 -0
- package/dist/packages.d.ts +3 -2
- package/dist/packages.js +5 -5
- package/dist/routes/admin.js +7 -3
- package/dist/routes/docs.d.ts +11 -7
- package/dist/routes/docs.js +88 -122
- package/dist/routes/keys.d.ts +21 -0
- package/dist/routes/keys.js +166 -0
- package/dist/scope.d.ts +11 -0
- package/dist/scope.js +45 -0
- package/dist/shard-router.d.ts +10 -4
- package/dist/shard-router.js +130 -49
- package/dist/shell-shard/index.d.ts +4 -1
- package/package.json +1 -1
- package/app/assets/index-C3rCTpjL.js +0 -17
- package/app/assets/index-C3rCTpjL.js.map +0 -1
- package/app/assets/index-GfhVhkjD.css +0 -1
package/dist/index.js
CHANGED
|
@@ -19,13 +19,18 @@ import { UserStore } from './users.js';
|
|
|
19
19
|
import { SessionStore } from './sessions.js';
|
|
20
20
|
import { SettingsStore } from './settings.js';
|
|
21
21
|
import { sessionAuth, adminAuth } from './auth.js';
|
|
22
|
+
import { resolveCaller } from './caller.js';
|
|
22
23
|
import { createAuthRouter } from './routes/auth.js';
|
|
23
24
|
import { createBootRouter } from './routes/boot.js';
|
|
24
25
|
import { createAdminRouter } from './routes/admin.js';
|
|
25
26
|
import { createDocsRouter } from './routes/docs.js';
|
|
27
|
+
import { createTenantDocStore } from './doc-store/index.js';
|
|
26
28
|
import { createEnvStateRouter } from './routes/env-state.js';
|
|
29
|
+
import { createKeysRouter } from './routes/keys.js';
|
|
27
30
|
import { loadPackages, scanPackages, servePackageBundles, createPackageManagementRoutes } from './packages.js';
|
|
31
|
+
import { removeLegacyGrants } from './migrations/sync-grants.js';
|
|
28
32
|
import { ShardRouter, adminOnly } from './shard-router.js';
|
|
33
|
+
import { scopeRequired, tenantRequired } from './scope.js';
|
|
29
34
|
import { registerTenantFsRoutes } from './tenant-fs/index.js';
|
|
30
35
|
import shellShardServer from './shell-shard/index.js';
|
|
31
36
|
export async function createServer(options = {}) {
|
|
@@ -109,12 +114,21 @@ export async function createServer(options = {}) {
|
|
|
109
114
|
app.route('/api/boot', createBootRouter(sessions, users, settings));
|
|
110
115
|
// Auth endpoints (login, logout, register, verify)
|
|
111
116
|
app.route('/api/auth', createAuthRouter(keys, users, sessions, settings));
|
|
112
|
-
// Document backend API
|
|
113
|
-
app.route('/api/docs', createDocsRouter(dataDir));
|
|
114
117
|
// --- Session-gated routes ---
|
|
115
118
|
app.use('/api/*', sessionAuth(sessions, settings));
|
|
119
|
+
app.use('/api/*', resolveCaller(keys));
|
|
120
|
+
// Document backend API — gated by sessionAuth + resolveCaller (mounted above).
|
|
121
|
+
// The router itself enforces tenantParamMatch and the __-prefix reservation.
|
|
122
|
+
// Settings is threaded so tenantParamMatch respects open / no-auth mode.
|
|
123
|
+
const docStore = createTenantDocStore(dataDir);
|
|
124
|
+
app.route('/api/docs', createDocsRouter(docStore, settings));
|
|
116
125
|
// Environment state API (per-shard server-backed config)
|
|
117
126
|
app.route('/api/env-state', createEnvStateRouter(dataDir));
|
|
127
|
+
// User-tenant key management (list/revoke). Mint lives at /api/shards-keys.
|
|
128
|
+
app.route('/api/keys', createKeysRouter(keys, async (event) => {
|
|
129
|
+
// Broadcast will be wired in Task 10 once the shell has a consumer.
|
|
130
|
+
console.log(`[sh3] key revoked: tenant=${event.tenantId} id=${event.id}`);
|
|
131
|
+
}));
|
|
118
132
|
// Package listing (public read, writes need admin — handled by admin middleware on management routes)
|
|
119
133
|
app.get('/api/packages', (c) => {
|
|
120
134
|
const packages = scanPackages(dataDir);
|
|
@@ -168,7 +182,7 @@ export async function createServer(options = {}) {
|
|
|
168
182
|
app.use('/api/packages/install', adminAuth(sessions, keys, settings));
|
|
169
183
|
app.use('/api/packages/uninstall', adminAuth(sessions, keys, settings));
|
|
170
184
|
const frameworkShardIds = ['__sh3core__', 'shell', 'sh3-store', 'sh3-admin'];
|
|
171
|
-
app.route('/api/packages', createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, frameworkShardIds));
|
|
185
|
+
app.route('/api/packages', createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, docStore, frameworkShardIds));
|
|
172
186
|
// Serve client bundles from discovered packages
|
|
173
187
|
servePackageBundles(app, dataDir, settings);
|
|
174
188
|
// Framework built-in: shell-shard server routes.
|
|
@@ -190,7 +204,10 @@ export async function createServer(options = {}) {
|
|
|
190
204
|
await shellShardServer.routes(shellSubApp, {
|
|
191
205
|
shardId: 'shell',
|
|
192
206
|
dataDir: shellDataDir,
|
|
207
|
+
permissions: [],
|
|
193
208
|
adminOnly: adminOnly(keys, settings),
|
|
209
|
+
scopeRequired,
|
|
210
|
+
tenantRequired,
|
|
194
211
|
wsRegister,
|
|
195
212
|
});
|
|
196
213
|
app.route('/api/shell', shellSubApp);
|
|
@@ -215,10 +232,12 @@ export async function createServer(options = {}) {
|
|
|
215
232
|
users,
|
|
216
233
|
sessions,
|
|
217
234
|
settings,
|
|
235
|
+
docStore,
|
|
218
236
|
async start() {
|
|
237
|
+
removeLegacyGrants(dataDir);
|
|
219
238
|
// First-boot: generate admin key + admin user
|
|
220
239
|
if (keys.isEmpty()) {
|
|
221
|
-
const initial = keys.generate('Initial admin key');
|
|
240
|
+
const initial = keys.generate({ label: 'Initial admin key', tenantId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
|
|
222
241
|
const tempPassword = UserStore.generatePassword();
|
|
223
242
|
await users.create({
|
|
224
243
|
username: 'admin',
|
|
@@ -246,7 +265,7 @@ export async function createServer(options = {}) {
|
|
|
246
265
|
console.log(`[sh3] Seeded official registry: ${registryUrl}`);
|
|
247
266
|
}
|
|
248
267
|
}
|
|
249
|
-
await loadPackages(shardRouter, dataDir, keys, settings, wsRegister);
|
|
268
|
+
await loadPackages(shardRouter, dataDir, keys, settings, wsRegister, docStore);
|
|
250
269
|
// Periodic session cleanup (every 15 minutes)
|
|
251
270
|
setInterval(() => sessions.cleanup(), 15 * 60 * 1000);
|
|
252
271
|
// API 404
|
|
@@ -269,6 +288,14 @@ export async function createServer(options = {}) {
|
|
|
269
288
|
console.log(`sh3-server listening on http://localhost:${port}`);
|
|
270
289
|
});
|
|
271
290
|
injectWebSocket(server);
|
|
291
|
+
const shutdown = async (sig) => {
|
|
292
|
+
console.log(`[sh3] received ${sig}, tearing down shards...`);
|
|
293
|
+
await shardRouter.unmountAll();
|
|
294
|
+
server.close();
|
|
295
|
+
process.exit(0);
|
|
296
|
+
};
|
|
297
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
298
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
272
299
|
},
|
|
273
300
|
};
|
|
274
301
|
}
|
package/dist/keys.d.ts
CHANGED
|
@@ -1,33 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* API key
|
|
2
|
+
* Unified API key store — admin, user-tenant, and connector-bound keys.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Layout on disk:
|
|
5
|
+
* <dataDir>/admin-keys.json — tenantId: null rows only
|
|
6
|
+
* <dataDir>/users/<tenantId>/__system__/keys.json — user-tenant rows
|
|
7
|
+
*
|
|
8
|
+
* Legacy migration: a pre-existing <dataDir>/keys.json is treated as admin
|
|
9
|
+
* keys and moved to admin-keys.json on first load, with the original renamed
|
|
10
|
+
* to keys.json.legacy.
|
|
7
11
|
*/
|
|
8
12
|
export interface ApiKey {
|
|
9
|
-
/** Short id for display and revocation. */
|
|
10
13
|
id: string;
|
|
11
|
-
/** The full bearer token value. */
|
|
12
14
|
key: string;
|
|
13
|
-
/** Human-readable label. */
|
|
14
15
|
label: string;
|
|
15
|
-
|
|
16
|
+
tenantId: string | null;
|
|
17
|
+
ownerUserId: string | null;
|
|
18
|
+
mintedByShardId: string | null;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
peerRole?: 'primary' | 'replica';
|
|
21
|
+
peerId?: string;
|
|
16
22
|
createdAt: string;
|
|
23
|
+
expiresAt?: string;
|
|
24
|
+
}
|
|
25
|
+
export type ApiKeyPublic = Omit<ApiKey, 'key'>;
|
|
26
|
+
export interface GenerateInput {
|
|
27
|
+
label: string;
|
|
28
|
+
tenantId: string | null;
|
|
29
|
+
ownerUserId: string | null;
|
|
30
|
+
mintedByShardId: string | null;
|
|
31
|
+
scopes: string[];
|
|
32
|
+
peerRole?: 'primary' | 'replica';
|
|
33
|
+
peerId?: string;
|
|
34
|
+
expiresAt?: string;
|
|
17
35
|
}
|
|
18
36
|
export declare class KeyStore {
|
|
19
37
|
#private;
|
|
20
38
|
constructor(dataDir: string);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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). */
|
|
39
|
+
generate(input: GenerateInput): ApiKey;
|
|
40
|
+
resolve(token: string): ApiKey | null;
|
|
41
|
+
listForTenant(tenantId: string): ApiKeyPublic[];
|
|
42
|
+
listForShard(tenantId: string, shardId: string): ApiKeyPublic[];
|
|
43
|
+
listAdmin(): ApiKeyPublic[];
|
|
44
|
+
listAll(): ApiKeyPublic[];
|
|
45
|
+
revoke(tenantId: string | null, id: string): ApiKeyPublic | null;
|
|
32
46
|
isEmpty(): boolean;
|
|
47
|
+
/** @deprecated use resolve() — kept until all callers migrate. */
|
|
48
|
+
validate(token: string): boolean;
|
|
33
49
|
}
|
package/dist/keys.js
CHANGED
|
@@ -1,68 +1,206 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* API key
|
|
2
|
+
* Unified API key store — admin, user-tenant, and connector-bound keys.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Layout on disk:
|
|
5
|
+
* <dataDir>/admin-keys.json — tenantId: null rows only
|
|
6
|
+
* <dataDir>/users/<tenantId>/__system__/keys.json — user-tenant rows
|
|
7
|
+
*
|
|
8
|
+
* Legacy migration: a pre-existing <dataDir>/keys.json is treated as admin
|
|
9
|
+
* keys and moved to admin-keys.json on first load, with the original renamed
|
|
10
|
+
* to keys.json.legacy.
|
|
7
11
|
*/
|
|
8
|
-
import { readFileSync, writeFileSync, mkdirSync,
|
|
9
|
-
import {
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, renameSync, } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
10
14
|
import { randomBytes } from 'node:crypto';
|
|
15
|
+
function strip(k) {
|
|
16
|
+
const { key: _drop, ...rest } = k;
|
|
17
|
+
return rest;
|
|
18
|
+
}
|
|
11
19
|
export class KeyStore {
|
|
12
|
-
#
|
|
13
|
-
#
|
|
20
|
+
#dataDir;
|
|
21
|
+
#admin = [];
|
|
22
|
+
#byTenant = new Map();
|
|
14
23
|
constructor(dataDir) {
|
|
15
|
-
this.#
|
|
16
|
-
this.#
|
|
24
|
+
this.#dataDir = dataDir;
|
|
25
|
+
this.#migrateLegacy();
|
|
26
|
+
this.#loadAdmin();
|
|
27
|
+
this.#loadAllTenants();
|
|
28
|
+
}
|
|
29
|
+
// ---------- public API ----------
|
|
30
|
+
generate(input) {
|
|
31
|
+
const id = randomBytes(4).toString('hex');
|
|
32
|
+
const key = `sh3_${randomBytes(32).toString('hex')}`;
|
|
33
|
+
const row = {
|
|
34
|
+
id,
|
|
35
|
+
key,
|
|
36
|
+
label: input.label,
|
|
37
|
+
tenantId: input.tenantId,
|
|
38
|
+
ownerUserId: input.ownerUserId,
|
|
39
|
+
mintedByShardId: input.mintedByShardId,
|
|
40
|
+
scopes: [...input.scopes],
|
|
41
|
+
peerRole: input.peerRole,
|
|
42
|
+
peerId: input.peerId,
|
|
43
|
+
createdAt: new Date().toISOString(),
|
|
44
|
+
expiresAt: input.expiresAt,
|
|
45
|
+
};
|
|
46
|
+
if (row.tenantId === null) {
|
|
47
|
+
this.#admin.push(row);
|
|
48
|
+
this.#saveAdmin();
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const bucket = this.#byTenant.get(row.tenantId) ?? [];
|
|
52
|
+
bucket.push(row);
|
|
53
|
+
this.#byTenant.set(row.tenantId, bucket);
|
|
54
|
+
this.#saveTenant(row.tenantId);
|
|
55
|
+
}
|
|
56
|
+
return { ...row, scopes: [...row.scopes] };
|
|
57
|
+
}
|
|
58
|
+
resolve(token) {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const match = (row) => {
|
|
61
|
+
if (row.key !== token)
|
|
62
|
+
return false;
|
|
63
|
+
if (row.expiresAt && Date.parse(row.expiresAt) <= now)
|
|
64
|
+
return false;
|
|
65
|
+
return true;
|
|
66
|
+
};
|
|
67
|
+
const adminHit = this.#admin.find(match);
|
|
68
|
+
if (adminHit)
|
|
69
|
+
return adminHit;
|
|
70
|
+
for (const bucket of this.#byTenant.values()) {
|
|
71
|
+
const hit = bucket.find(match);
|
|
72
|
+
if (hit)
|
|
73
|
+
return hit;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
listForTenant(tenantId) {
|
|
78
|
+
return (this.#byTenant.get(tenantId) ?? []).map(strip);
|
|
79
|
+
}
|
|
80
|
+
listForShard(tenantId, shardId) {
|
|
81
|
+
return this.listForTenant(tenantId).filter((k) => k.mintedByShardId === shardId);
|
|
82
|
+
}
|
|
83
|
+
listAdmin() {
|
|
84
|
+
return this.#admin.map(strip);
|
|
85
|
+
}
|
|
86
|
+
listAll() {
|
|
87
|
+
const out = this.#admin.map(strip);
|
|
88
|
+
for (const bucket of this.#byTenant.values())
|
|
89
|
+
out.push(...bucket.map(strip));
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
revoke(tenantId, id) {
|
|
93
|
+
if (tenantId === null) {
|
|
94
|
+
const idx = this.#admin.findIndex((k) => k.id === id);
|
|
95
|
+
if (idx < 0)
|
|
96
|
+
return null;
|
|
97
|
+
const [removed] = this.#admin.splice(idx, 1);
|
|
98
|
+
this.#saveAdmin();
|
|
99
|
+
return strip(removed);
|
|
100
|
+
}
|
|
101
|
+
const bucket = this.#byTenant.get(tenantId);
|
|
102
|
+
if (!bucket)
|
|
103
|
+
return null;
|
|
104
|
+
const idx = bucket.findIndex((k) => k.id === id);
|
|
105
|
+
if (idx < 0)
|
|
106
|
+
return null;
|
|
107
|
+
const [removed] = bucket.splice(idx, 1);
|
|
108
|
+
this.#saveTenant(tenantId);
|
|
109
|
+
return strip(removed);
|
|
17
110
|
}
|
|
18
|
-
|
|
19
|
-
|
|
111
|
+
isEmpty() {
|
|
112
|
+
return this.#admin.length === 0;
|
|
113
|
+
}
|
|
114
|
+
// ---------- persistence ----------
|
|
115
|
+
#adminPath() {
|
|
116
|
+
return join(this.#dataDir, 'admin-keys.json');
|
|
117
|
+
}
|
|
118
|
+
#tenantPath(tenantId) {
|
|
119
|
+
return join(this.#dataDir, 'users', tenantId, '__system__', 'keys.json');
|
|
120
|
+
}
|
|
121
|
+
#loadAdmin() {
|
|
122
|
+
const p = this.#adminPath();
|
|
123
|
+
if (!existsSync(p))
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
const raw = JSON.parse(readFileSync(p, 'utf-8'));
|
|
127
|
+
const dirty = raw.some((row) => 'connectorId' in row);
|
|
128
|
+
const cleaned = raw.map((row) => {
|
|
129
|
+
const { connectorId: _drop, ...rest } = row;
|
|
130
|
+
return rest;
|
|
131
|
+
});
|
|
132
|
+
this.#admin.push(...cleaned);
|
|
133
|
+
if (dirty)
|
|
134
|
+
this.#saveAdmin();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Corrupt admin file — leave empty; the next generate() overwrites.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
#loadAllTenants() {
|
|
141
|
+
const usersDir = join(this.#dataDir, 'users');
|
|
142
|
+
if (!existsSync(usersDir))
|
|
143
|
+
return;
|
|
144
|
+
for (const entry of readdirSync(usersDir, { withFileTypes: true })) {
|
|
145
|
+
if (!entry.isDirectory())
|
|
146
|
+
continue;
|
|
147
|
+
const tenantId = entry.name;
|
|
148
|
+
const file = this.#tenantPath(tenantId);
|
|
149
|
+
if (!existsSync(file))
|
|
150
|
+
continue;
|
|
20
151
|
try {
|
|
21
|
-
|
|
152
|
+
const raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
153
|
+
const dirty = raw.some((row) => 'connectorId' in row);
|
|
154
|
+
const cleaned = raw.map((row) => {
|
|
155
|
+
const { connectorId: _drop, ...rest } = row;
|
|
156
|
+
return rest;
|
|
157
|
+
});
|
|
158
|
+
this.#byTenant.set(tenantId, cleaned);
|
|
159
|
+
if (dirty)
|
|
160
|
+
this.#saveTenant(tenantId);
|
|
22
161
|
}
|
|
23
162
|
catch {
|
|
24
|
-
this.#
|
|
163
|
+
this.#byTenant.set(tenantId, []);
|
|
25
164
|
}
|
|
26
165
|
}
|
|
27
166
|
}
|
|
28
|
-
#
|
|
29
|
-
const
|
|
30
|
-
mkdirSync(
|
|
31
|
-
writeFileSync(
|
|
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 }));
|
|
167
|
+
#saveAdmin() {
|
|
168
|
+
const p = this.#adminPath();
|
|
169
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
170
|
+
writeFileSync(p, JSON.stringify(this.#admin, null, 2));
|
|
40
171
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
172
|
+
#saveTenant(tenantId) {
|
|
173
|
+
const bucket = this.#byTenant.get(tenantId) ?? [];
|
|
174
|
+
const p = this.#tenantPath(tenantId);
|
|
175
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
176
|
+
writeFileSync(p, JSON.stringify(bucket, null, 2));
|
|
44
177
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
178
|
+
#migrateLegacy() {
|
|
179
|
+
const legacyPath = join(this.#dataDir, 'keys.json');
|
|
180
|
+
const adminPath = this.#adminPath();
|
|
181
|
+
if (!existsSync(legacyPath) || existsSync(adminPath))
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'));
|
|
185
|
+
const migrated = legacy.map((row) => ({
|
|
186
|
+
...row,
|
|
187
|
+
tenantId: null,
|
|
188
|
+
ownerUserId: null,
|
|
189
|
+
mintedByShardId: null,
|
|
190
|
+
scopes: ['admin:*'],
|
|
191
|
+
}));
|
|
192
|
+
mkdirSync(dirname(adminPath), { recursive: true });
|
|
193
|
+
writeFileSync(adminPath, JSON.stringify(migrated, null, 2));
|
|
194
|
+
renameSync(legacyPath, legacyPath + '.legacy');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// If migration fails, leave everything alone; admin can investigate.
|
|
61
198
|
}
|
|
62
|
-
return false;
|
|
63
199
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
200
|
+
// ---------- legacy compat ----------
|
|
201
|
+
/** @deprecated use resolve() — kept until all callers migrate. */
|
|
202
|
+
validate(token) {
|
|
203
|
+
const hit = this.resolve(token);
|
|
204
|
+
return !!hit && hit.scopes.includes('admin:*');
|
|
67
205
|
}
|
|
68
206
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-019 §11.2: retire the legacy `grants/` namespace under the reserved
|
|
3
|
+
* `__sync__` shard id for every tenant. Idempotent; safe to run on every
|
|
4
|
+
* boot. Other `__sync__/` subdirectories are out of scope here — they
|
|
5
|
+
* disappear when the sync runtime is rebuilt in sh3-server.
|
|
6
|
+
*/
|
|
7
|
+
export declare function removeLegacyGrants(dataDir: string): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-019 §11.2: retire the legacy `grants/` namespace under the reserved
|
|
3
|
+
* `__sync__` shard id for every tenant. Idempotent; safe to run on every
|
|
4
|
+
* boot. Other `__sync__/` subdirectories are out of scope here — they
|
|
5
|
+
* disappear when the sync runtime is rebuilt in sh3-server.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
export function removeLegacyGrants(dataDir) {
|
|
10
|
+
const docsDir = join(dataDir, 'docs');
|
|
11
|
+
if (!existsSync(docsDir))
|
|
12
|
+
return;
|
|
13
|
+
const tenants = readdirSync(docsDir, { withFileTypes: true })
|
|
14
|
+
.filter((e) => e.isDirectory())
|
|
15
|
+
.map((e) => e.name);
|
|
16
|
+
let removed = 0;
|
|
17
|
+
for (const tenant of tenants) {
|
|
18
|
+
const grants = join(docsDir, tenant, '__sync__', 'grants');
|
|
19
|
+
if (existsSync(grants)) {
|
|
20
|
+
rmSync(grants, { recursive: true, force: true });
|
|
21
|
+
removed += 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (removed > 0) {
|
|
25
|
+
console.log(`[sh3] migration: removed legacy __sync__/grants/ for ${removed} tenant(s)`);
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/packages.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { KeyStore } from './keys.js';
|
|
|
3
3
|
import type { SettingsStore } from './settings.js';
|
|
4
4
|
import { ShardRouter } from './shard-router.js';
|
|
5
5
|
import type { MountContext } from './shard-router.js';
|
|
6
|
+
import type { TenantDocStore } from './doc-store/index.js';
|
|
6
7
|
/** Type of the `wsRegister` factory field from MountContext. */
|
|
7
8
|
type WsRegister = MountContext['wsRegister'];
|
|
8
9
|
export interface DiscoveredPackage {
|
|
@@ -19,7 +20,7 @@ export interface DiscoveredPackage {
|
|
|
19
20
|
* For each valid package, mount server routes (if server.js exists) and
|
|
20
21
|
* return the full list of discovered packages.
|
|
21
22
|
*/
|
|
22
|
-
export declare function loadPackages(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister): Promise<DiscoveredPackage[]>;
|
|
23
|
+
export declare function loadPackages(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, docStore: TenantDocStore): Promise<DiscoveredPackage[]>;
|
|
23
24
|
/**
|
|
24
25
|
* Re-scan `<dataDir>/packages/` and return metadata for all valid packages.
|
|
25
26
|
* Unlike `loadPackages`, this does NOT mount server routes — it only reads
|
|
@@ -52,5 +53,5 @@ export declare function validateRequiredShards(manifest: Record<string, unknown>
|
|
|
52
53
|
* Returns a Hono router with POST /install and POST /uninstall.
|
|
53
54
|
* Protected by the blanket `/api/*` auth middleware already applied upstream.
|
|
54
55
|
*/
|
|
55
|
-
export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, frameworkShardIds?: string[]): Hono;
|
|
56
|
+
export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, docStore: TenantDocStore, frameworkShardIds?: string[]): Hono;
|
|
56
57
|
export {};
|
package/dist/packages.js
CHANGED
|
@@ -18,7 +18,7 @@ function isValidId(id) {
|
|
|
18
18
|
* For each valid package, mount server routes (if server.js exists) and
|
|
19
19
|
* return the full list of discovered packages.
|
|
20
20
|
*/
|
|
21
|
-
export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegister) {
|
|
21
|
+
export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegister, docStore) {
|
|
22
22
|
const packagesDir = join(dataDir, 'packages');
|
|
23
23
|
if (!existsSync(packagesDir)) {
|
|
24
24
|
mkdirSync(packagesDir, { recursive: true });
|
|
@@ -68,7 +68,7 @@ export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegis
|
|
|
68
68
|
};
|
|
69
69
|
if (hasServer) {
|
|
70
70
|
try {
|
|
71
|
-
await shardRouter.mount(manifest.id, serverJs, { pkgDir, keys, settings, wsRegister });
|
|
71
|
+
await shardRouter.mount(manifest.id, serverJs, { pkgDir, keys, settings, wsRegister, docStore });
|
|
72
72
|
}
|
|
73
73
|
catch (err) {
|
|
74
74
|
console.warn(`[sh3] ${manifest.id}/server.js failed to load:`, err);
|
|
@@ -193,7 +193,7 @@ export function validateRequiredShards(manifest, knownShards) {
|
|
|
193
193
|
* Returns a Hono router with POST /install and POST /uninstall.
|
|
194
194
|
* Protected by the blanket `/api/*` auth middleware already applied upstream.
|
|
195
195
|
*/
|
|
196
|
-
export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, frameworkShardIds = []) {
|
|
196
|
+
export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, docStore, frameworkShardIds = []) {
|
|
197
197
|
const router = new Hono();
|
|
198
198
|
router.post('/install', async (c) => {
|
|
199
199
|
const form = await c.req.formData();
|
|
@@ -267,7 +267,7 @@ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settin
|
|
|
267
267
|
writeFileSync(join(pkgDir, 'server.js'), buf);
|
|
268
268
|
// Hot-mount server routes
|
|
269
269
|
try {
|
|
270
|
-
await shardRouter.mount(id, join(pkgDir, 'server.js'), { pkgDir, keys, settings, wsRegister });
|
|
270
|
+
await shardRouter.mount(id, join(pkgDir, 'server.js'), { pkgDir, keys, settings, wsRegister, docStore });
|
|
271
271
|
}
|
|
272
272
|
catch (err) {
|
|
273
273
|
// Roll back entire install — broken server bundle must not be half-installed
|
|
@@ -297,7 +297,7 @@ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settin
|
|
|
297
297
|
return c.json({ error: `Package "${id}" not found` }, 404);
|
|
298
298
|
}
|
|
299
299
|
// Unmount server routes immediately
|
|
300
|
-
shardRouter.unmount(id);
|
|
300
|
+
await shardRouter.unmount(id);
|
|
301
301
|
// Remove code files
|
|
302
302
|
rmSync(manifestPath, { force: true });
|
|
303
303
|
const clientPath = join(pkgDir, 'client.js');
|
package/dist/routes/admin.js
CHANGED
|
@@ -99,7 +99,11 @@ export function createAdminRouter(users, settings, sessions, keys, dataDir) {
|
|
|
99
99
|
});
|
|
100
100
|
// --- API Keys ---
|
|
101
101
|
router.get('/keys', (c) => {
|
|
102
|
-
return c.json(keys.
|
|
102
|
+
return c.json(keys.listAdmin());
|
|
103
|
+
});
|
|
104
|
+
/** Read-only audit endpoint — every key across all tenants and admin. */
|
|
105
|
+
router.get('/keys/all', (c) => {
|
|
106
|
+
return c.json(keys.listAll());
|
|
103
107
|
});
|
|
104
108
|
router.post('/keys', async (c) => {
|
|
105
109
|
let body;
|
|
@@ -113,12 +117,12 @@ export function createAdminRouter(users, settings, sessions, keys, dataDir) {
|
|
|
113
117
|
if (!label) {
|
|
114
118
|
return c.json({ error: 'Label required' }, 400);
|
|
115
119
|
}
|
|
116
|
-
const key = keys.generate(label);
|
|
120
|
+
const key = keys.generate({ label, tenantId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
|
|
117
121
|
return c.json(key, 201);
|
|
118
122
|
});
|
|
119
123
|
router.delete('/keys/:id', (c) => {
|
|
120
124
|
const id = c.req.param('id');
|
|
121
|
-
if (!keys.revoke(id))
|
|
125
|
+
if (!keys.revoke(null, id))
|
|
122
126
|
return c.json({ error: 'Key not found' }, 404);
|
|
123
127
|
return c.body(null, 204);
|
|
124
128
|
});
|
package/dist/routes/docs.d.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Document backend API routes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Delegates every read/write/list/delete to the TenantDocStore, which owns
|
|
5
|
+
* the metadata-sidecar write pipeline (version bump, syncMode cache, tick
|
|
6
|
+
* advance, conflict bucket). This router is intentionally thin: it validates
|
|
7
|
+
* auth and path shape, then calls the store.
|
|
6
8
|
*
|
|
7
|
-
* GET /api/docs/:tenant/_shards →
|
|
8
|
-
* GET /api/docs/:tenant/_all →
|
|
9
|
+
* GET /api/docs/:tenant/_shards → listAll (shard ids)
|
|
10
|
+
* GET /api/docs/:tenant/_all → listAll
|
|
9
11
|
* GET /api/docs/:tenant/:shard → list
|
|
10
12
|
* GET /api/docs/:tenant/:shard/*path → read
|
|
11
13
|
* HEAD /api/docs/:tenant/:shard/*path → exists
|
|
12
|
-
* PUT /api/docs/:tenant/:shard/*path → write
|
|
13
|
-
* DELETE /api/docs/:tenant/:shard/*path → delete
|
|
14
|
+
* PUT /api/docs/:tenant/:shard/*path → write
|
|
15
|
+
* DELETE /api/docs/:tenant/:shard/*path → delete
|
|
14
16
|
*/
|
|
15
17
|
import { Hono } from 'hono';
|
|
16
|
-
|
|
18
|
+
import type { SettingsStore } from '../settings.js';
|
|
19
|
+
import type { TenantDocStore } from '../doc-store/index.js';
|
|
20
|
+
export declare function createDocsRouter(store: TenantDocStore, settings?: SettingsStore): Hono;
|