sh3-server 0.13.0 → 0.13.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-B1K1agdD.css +1 -0
- package/app/assets/index-eXElR_9t.js +21 -0
- package/app/assets/index-eXElR_9t.js.map +1 -0
- package/app/assets/workspace-rekey-DRgvbNY-.js +2 -0
- package/app/assets/workspace-rekey-DRgvbNY-.js.map +1 -0
- package/app/index.html +2 -2
- package/dist/caller.d.ts +9 -2
- package/dist/caller.js +16 -6
- package/dist/doc-store/index.d.ts +11 -2
- package/dist/doc-store/index.js +4 -2
- package/dist/index.js +20 -11
- package/dist/keys.d.ts +14 -8
- package/dist/keys.js +66 -40
- package/dist/middleware/project-allowlist.d.ts +30 -0
- package/dist/middleware/project-allowlist.js +49 -0
- package/dist/packages.d.ts +13 -0
- package/dist/packages.js +28 -0
- package/dist/projects.d.ts +39 -0
- package/dist/projects.js +128 -0
- package/dist/routes/admin.js +1 -1
- package/dist/routes/docs.d.ts +23 -9
- package/dist/routes/docs.js +57 -48
- package/dist/routes/keys.d.ts +4 -4
- package/dist/routes/keys.js +12 -12
- package/dist/routes/projects.d.ts +13 -0
- package/dist/routes/projects.js +101 -0
- package/dist/scope.d.ts +9 -4
- package/dist/scope.js +17 -15
- package/dist/shard-router.js +2 -2
- package/package.json +1 -1
- package/app/assets/index-BPTrm0uN.js +0 -19
- package/app/assets/index-BPTrm0uN.js.map +0 -1
- package/app/assets/index-J_irM21j.css +0 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectStore — multi-member project scope identity primitive.
|
|
3
|
+
*
|
|
4
|
+
* Project ids derive from a slug of the project name plus a 4-char base36
|
|
5
|
+
* hash so collisions are rare and ids stay readable. Members and the app
|
|
6
|
+
* allowlist are stored alongside the project's metadata; document content
|
|
7
|
+
* lives under the same per-scope filesystem layout as personal scopes.
|
|
8
|
+
*/
|
|
9
|
+
export interface ProjectRecord {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
members: string[];
|
|
14
|
+
appAllowlist: string[];
|
|
15
|
+
createdBy: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class ProjectStore {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(dataDir: string);
|
|
22
|
+
list(): ProjectRecord[];
|
|
23
|
+
get(id: string): ProjectRecord | null;
|
|
24
|
+
listForUser(userId: string): ProjectRecord[];
|
|
25
|
+
isMember(projectId: string, userId: string): boolean;
|
|
26
|
+
create(input: {
|
|
27
|
+
name: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
members: string[];
|
|
30
|
+
appAllowlist: string[];
|
|
31
|
+
createdBy: string;
|
|
32
|
+
}): ProjectRecord;
|
|
33
|
+
update(id: string, patch: Partial<Pick<ProjectRecord, 'name' | 'description' | 'members' | 'appAllowlist'>>): ProjectRecord | null;
|
|
34
|
+
delete(id: string): boolean;
|
|
35
|
+
deleteWithData(id: string, dataDir: string): {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
wipedData: boolean;
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/projects.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectStore — multi-member project scope identity primitive.
|
|
3
|
+
*
|
|
4
|
+
* Project ids derive from a slug of the project name plus a 4-char base36
|
|
5
|
+
* hash so collisions are rare and ids stay readable. Members and the app
|
|
6
|
+
* allowlist are stored alongside the project's metadata; document content
|
|
7
|
+
* lives under the same per-scope filesystem layout as personal scopes.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
const ID_HASH_LEN = 4;
|
|
13
|
+
const SLUG_MAX_LEN = 32;
|
|
14
|
+
function slugify(name) {
|
|
15
|
+
return name
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '')
|
|
19
|
+
.slice(0, SLUG_MAX_LEN) || 'project';
|
|
20
|
+
}
|
|
21
|
+
function randomBase36(len) {
|
|
22
|
+
const bytes = randomBytes(len);
|
|
23
|
+
let s = '';
|
|
24
|
+
for (const b of bytes)
|
|
25
|
+
s += (b % 36).toString(36);
|
|
26
|
+
return s.slice(0, len);
|
|
27
|
+
}
|
|
28
|
+
export class ProjectStore {
|
|
29
|
+
#path;
|
|
30
|
+
#projects = [];
|
|
31
|
+
constructor(dataDir) {
|
|
32
|
+
this.#path = join(dataDir, 'projects.json');
|
|
33
|
+
this.#load();
|
|
34
|
+
}
|
|
35
|
+
#load() {
|
|
36
|
+
if (existsSync(this.#path)) {
|
|
37
|
+
try {
|
|
38
|
+
this.#projects = JSON.parse(readFileSync(this.#path, 'utf-8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
this.#projects = [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
#save() {
|
|
46
|
+
mkdirSync(dirname(this.#path), { recursive: true });
|
|
47
|
+
writeFileSync(this.#path, JSON.stringify(this.#projects, null, 2));
|
|
48
|
+
}
|
|
49
|
+
#generateId(name) {
|
|
50
|
+
const slug = slugify(name);
|
|
51
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
52
|
+
const candidate = `${slug}-${randomBase36(ID_HASH_LEN)}`;
|
|
53
|
+
if (!this.#projects.some(p => p.id === candidate))
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
throw new Error('Failed to generate unique project id after 10 attempts');
|
|
57
|
+
}
|
|
58
|
+
list() {
|
|
59
|
+
return [...this.#projects];
|
|
60
|
+
}
|
|
61
|
+
get(id) {
|
|
62
|
+
return this.#projects.find(p => p.id === id) ?? null;
|
|
63
|
+
}
|
|
64
|
+
listForUser(userId) {
|
|
65
|
+
return this.#projects.filter(p => p.members.includes(userId));
|
|
66
|
+
}
|
|
67
|
+
isMember(projectId, userId) {
|
|
68
|
+
const p = this.get(projectId);
|
|
69
|
+
return p ? p.members.includes(userId) : false;
|
|
70
|
+
}
|
|
71
|
+
create(input) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const project = {
|
|
74
|
+
id: this.#generateId(input.name),
|
|
75
|
+
name: input.name,
|
|
76
|
+
description: input.description,
|
|
77
|
+
members: [...input.members],
|
|
78
|
+
appAllowlist: [...input.appAllowlist],
|
|
79
|
+
createdBy: input.createdBy,
|
|
80
|
+
createdAt: now,
|
|
81
|
+
updatedAt: now,
|
|
82
|
+
};
|
|
83
|
+
this.#projects.push(project);
|
|
84
|
+
this.#save();
|
|
85
|
+
return project;
|
|
86
|
+
}
|
|
87
|
+
update(id, patch) {
|
|
88
|
+
const project = this.#projects.find(p => p.id === id);
|
|
89
|
+
if (!project)
|
|
90
|
+
return null;
|
|
91
|
+
if (patch.name !== undefined)
|
|
92
|
+
project.name = patch.name;
|
|
93
|
+
if (patch.description !== undefined)
|
|
94
|
+
project.description = patch.description;
|
|
95
|
+
if (patch.members !== undefined)
|
|
96
|
+
project.members = [...patch.members];
|
|
97
|
+
if (patch.appAllowlist !== undefined)
|
|
98
|
+
project.appAllowlist = [...patch.appAllowlist];
|
|
99
|
+
project.updatedAt = Date.now();
|
|
100
|
+
this.#save();
|
|
101
|
+
return project;
|
|
102
|
+
}
|
|
103
|
+
delete(id) {
|
|
104
|
+
const before = this.#projects.length;
|
|
105
|
+
this.#projects = this.#projects.filter(p => p.id !== id);
|
|
106
|
+
if (this.#projects.length < before) {
|
|
107
|
+
this.#save();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
deleteWithData(id, dataDir) {
|
|
113
|
+
if (!this.delete(id))
|
|
114
|
+
return { ok: false, wipedData: false };
|
|
115
|
+
const docsDir = join(dataDir, 'docs', id);
|
|
116
|
+
let wipedData = false;
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(docsDir)) {
|
|
119
|
+
rmSync(docsDir, { recursive: true, force: true });
|
|
120
|
+
wipedData = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.warn(`[sh3] project ${id}: failed to remove ${docsDir}:`, err);
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, wipedData };
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/routes/admin.js
CHANGED
|
@@ -117,7 +117,7 @@ export function createAdminRouter(users, settings, sessions, keys, dataDir) {
|
|
|
117
117
|
if (!label) {
|
|
118
118
|
return c.json({ error: 'Label required' }, 400);
|
|
119
119
|
}
|
|
120
|
-
const key = keys.generate({ label,
|
|
120
|
+
const key = keys.generate({ label, scopeId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
|
|
121
121
|
return c.json(key, 201);
|
|
122
122
|
});
|
|
123
123
|
router.delete('/keys/:id', (c) => {
|
package/dist/routes/docs.d.ts
CHANGED
|
@@ -6,15 +6,29 @@
|
|
|
6
6
|
* advance, conflict bucket). This router is intentionally thin: it validates
|
|
7
7
|
* auth and path shape, then calls the store.
|
|
8
8
|
*
|
|
9
|
-
* GET /api/docs/:
|
|
10
|
-
* GET /api/docs/:
|
|
11
|
-
* GET /api/docs/:
|
|
12
|
-
* GET /api/docs/:
|
|
13
|
-
* HEAD /api/docs/:
|
|
14
|
-
* PUT /api/docs/:
|
|
15
|
-
* DELETE /api/docs/:
|
|
9
|
+
* GET /api/docs/:scope/_shards → listAll (shard ids)
|
|
10
|
+
* GET /api/docs/:scope/_all → listAll
|
|
11
|
+
* GET /api/docs/:scope/:shard → list
|
|
12
|
+
* GET /api/docs/:scope/:shard/*path → read
|
|
13
|
+
* HEAD /api/docs/:scope/:shard/*path → exists
|
|
14
|
+
* PUT /api/docs/:scope/:shard/*path → write
|
|
15
|
+
* DELETE /api/docs/:scope/:shard/*path → delete
|
|
16
16
|
*/
|
|
17
17
|
import { Hono } from 'hono';
|
|
18
18
|
import type { SettingsStore } from '../settings.js';
|
|
19
|
-
import type {
|
|
20
|
-
|
|
19
|
+
import type { ScopedDocStore } from '../doc-store/index.js';
|
|
20
|
+
import type { ProjectStore } from '../projects.js';
|
|
21
|
+
interface AppRegistry {
|
|
22
|
+
get(id: string): {
|
|
23
|
+
manifest: {
|
|
24
|
+
requiredShards: string[];
|
|
25
|
+
};
|
|
26
|
+
} | null;
|
|
27
|
+
}
|
|
28
|
+
export interface DocsRouterOptions {
|
|
29
|
+
settings?: SettingsStore;
|
|
30
|
+
projectStore?: ProjectStore;
|
|
31
|
+
appRegistry?: AppRegistry;
|
|
32
|
+
}
|
|
33
|
+
export declare function createDocsRouter(store: ScopedDocStore, options?: DocsRouterOptions | SettingsStore): Hono;
|
|
34
|
+
export {};
|
package/dist/routes/docs.js
CHANGED
|
@@ -6,49 +6,58 @@
|
|
|
6
6
|
* advance, conflict bucket). This router is intentionally thin: it validates
|
|
7
7
|
* auth and path shape, then calls the store.
|
|
8
8
|
*
|
|
9
|
-
* GET /api/docs/:
|
|
10
|
-
* GET /api/docs/:
|
|
11
|
-
* GET /api/docs/:
|
|
12
|
-
* GET /api/docs/:
|
|
13
|
-
* HEAD /api/docs/:
|
|
14
|
-
* PUT /api/docs/:
|
|
15
|
-
* DELETE /api/docs/:
|
|
9
|
+
* GET /api/docs/:scope/_shards → listAll (shard ids)
|
|
10
|
+
* GET /api/docs/:scope/_all → listAll
|
|
11
|
+
* GET /api/docs/:scope/:shard → list
|
|
12
|
+
* GET /api/docs/:scope/:shard/*path → read
|
|
13
|
+
* HEAD /api/docs/:scope/:shard/*path → exists
|
|
14
|
+
* PUT /api/docs/:scope/:shard/*path → write
|
|
15
|
+
* DELETE /api/docs/:scope/:shard/*path → delete
|
|
16
16
|
*/
|
|
17
17
|
import { Hono } from 'hono';
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import { scopeAccessMatch } from '../scope.js';
|
|
19
|
+
import { projectAppAllowlist } from '../middleware/project-allowlist.js';
|
|
20
|
+
export function createDocsRouter(store, options = {}) {
|
|
21
|
+
// Backward-compat: callers used to pass the SettingsStore as the second
|
|
22
|
+
// positional arg. Detect that shape and adapt.
|
|
23
|
+
const opts = options && typeof options.get === 'function' && !('settings' in options)
|
|
24
|
+
? { settings: options }
|
|
25
|
+
: options;
|
|
20
26
|
const router = new Hono();
|
|
21
|
-
router.use('/:
|
|
22
|
-
router.use('/:
|
|
27
|
+
router.use('/:scope/*', scopeAccessMatch('scope', opts.settings));
|
|
28
|
+
router.use('/:scope', scopeAccessMatch('scope', opts.settings));
|
|
29
|
+
if (opts.projectStore && opts.appRegistry) {
|
|
30
|
+
router.use('/:scope/:shard/*', projectAppAllowlist({ projectStore: opts.projectStore, appRegistry: opts.appRegistry }));
|
|
31
|
+
}
|
|
23
32
|
function isReservedShardId(shard) {
|
|
24
33
|
return shard.startsWith('__');
|
|
25
34
|
}
|
|
26
35
|
// Shards list
|
|
27
|
-
router.get('/:
|
|
28
|
-
const {
|
|
29
|
-
const all = await store.listAll(
|
|
36
|
+
router.get('/:scope/_shards', async (c) => {
|
|
37
|
+
const { scope } = c.req.param();
|
|
38
|
+
const all = await store.listAll(scope);
|
|
30
39
|
const seen = new Set();
|
|
31
40
|
for (const e of all)
|
|
32
41
|
seen.add(e.shardId);
|
|
33
42
|
return c.json([...seen].filter((id) => !isReservedShardId(id)));
|
|
34
43
|
});
|
|
35
44
|
// All docs
|
|
36
|
-
router.get('/:
|
|
37
|
-
const {
|
|
38
|
-
return c.json(await store.listAll(
|
|
45
|
+
router.get('/:scope/_all', async (c) => {
|
|
46
|
+
const { scope } = c.req.param();
|
|
47
|
+
return c.json(await store.listAll(scope));
|
|
39
48
|
});
|
|
40
49
|
// Per-shard list
|
|
41
|
-
router.get('/:
|
|
42
|
-
const {
|
|
50
|
+
router.get('/:scope/:shard', async (c) => {
|
|
51
|
+
const { scope, shard } = c.req.param();
|
|
43
52
|
if (isReservedShardId(shard))
|
|
44
53
|
return c.notFound();
|
|
45
|
-
return c.json(await store.list(
|
|
54
|
+
return c.json(await store.list(scope, shard));
|
|
46
55
|
});
|
|
47
56
|
// Branch content read — conflict-branch by origin. Registered before the
|
|
48
57
|
// generic read handler so the `/branch` suffix isn't swallowed as a path.
|
|
49
|
-
router.get('/:
|
|
50
|
-
const {
|
|
51
|
-
const prefix = `/api/docs/${
|
|
58
|
+
router.get('/:scope/:shard/*', async (c, next) => {
|
|
59
|
+
const { scope, shard } = c.req.param();
|
|
60
|
+
const prefix = `/api/docs/${scope}/${shard}/`;
|
|
52
61
|
const rawPath = c.req.path.replace(prefix, '');
|
|
53
62
|
if (!rawPath.endsWith('/branch'))
|
|
54
63
|
return next();
|
|
@@ -60,26 +69,26 @@ export function createDocsRouter(store, settings) {
|
|
|
60
69
|
const origin = c.req.query('origin');
|
|
61
70
|
if (!origin)
|
|
62
71
|
return c.json({ error: 'Missing origin query param' }, 400);
|
|
63
|
-
const content = await store.readBranchContent(
|
|
72
|
+
const content = await store.readBranchContent(scope, shard, filePath, origin);
|
|
64
73
|
if (content === null)
|
|
65
74
|
return c.notFound();
|
|
66
75
|
return c.text(content);
|
|
67
76
|
});
|
|
68
77
|
// Read
|
|
69
|
-
router.get('/:
|
|
70
|
-
const {
|
|
78
|
+
router.get('/:scope/:shard/*', async (c) => {
|
|
79
|
+
const { scope, shard } = c.req.param();
|
|
71
80
|
if (isReservedShardId(shard))
|
|
72
81
|
return c.notFound();
|
|
73
|
-
const filePath = c.req.path.replace(`/api/docs/${
|
|
82
|
+
const filePath = c.req.path.replace(`/api/docs/${scope}/${shard}/`, '');
|
|
74
83
|
if (!filePath)
|
|
75
84
|
return c.json({ error: 'Missing file path' }, 400);
|
|
76
85
|
if (c.req.query('meta') === '1') {
|
|
77
|
-
const meta = await store.readMeta(
|
|
86
|
+
const meta = await store.readMeta(scope, shard, filePath);
|
|
78
87
|
if (!meta)
|
|
79
88
|
return c.json({ exists: false });
|
|
80
89
|
const payload = { exists: true, ...meta };
|
|
81
90
|
if (meta.syncState === 'conflict') {
|
|
82
|
-
const cf = await store.readConflict(
|
|
91
|
+
const cf = await store.readConflict(scope, shard, filePath);
|
|
83
92
|
if (cf) {
|
|
84
93
|
payload.branches = cf.branches.map((b) => ({
|
|
85
94
|
origin: b.origin,
|
|
@@ -90,28 +99,28 @@ export function createDocsRouter(store, settings) {
|
|
|
90
99
|
}
|
|
91
100
|
return c.json(payload);
|
|
92
101
|
}
|
|
93
|
-
const content = await store.read(
|
|
102
|
+
const content = await store.read(scope, shard, filePath);
|
|
94
103
|
if (content === null)
|
|
95
104
|
return c.notFound();
|
|
96
105
|
return c.text(content);
|
|
97
106
|
});
|
|
98
107
|
// Exists
|
|
99
|
-
router.on('HEAD', '/:
|
|
100
|
-
const {
|
|
108
|
+
router.on('HEAD', '/:scope/:shard/*', async (c) => {
|
|
109
|
+
const { scope, shard } = c.req.param();
|
|
101
110
|
if (isReservedShardId(shard))
|
|
102
111
|
return c.notFound();
|
|
103
|
-
const filePath = c.req.path.replace(`/api/docs/${
|
|
112
|
+
const filePath = c.req.path.replace(`/api/docs/${scope}/${shard}/`, '');
|
|
104
113
|
return new Response(null, {
|
|
105
|
-
status: (await store.exists(
|
|
114
|
+
status: (await store.exists(scope, shard, filePath)) ? 200 : 404,
|
|
106
115
|
});
|
|
107
116
|
});
|
|
108
117
|
// Conflict resolve and rename — must match before the generic PUT so the
|
|
109
118
|
// suffixes aren't captured as part of the file path.
|
|
110
|
-
router.post('/:
|
|
111
|
-
const {
|
|
119
|
+
router.post('/:scope/:shard/*', async (c) => {
|
|
120
|
+
const { scope, shard } = c.req.param();
|
|
112
121
|
if (isReservedShardId(shard))
|
|
113
122
|
return c.json({ error: 'Reserved shard id' }, 400);
|
|
114
|
-
const rawPath = c.req.path.replace(`/api/docs/${
|
|
123
|
+
const rawPath = c.req.path.replace(`/api/docs/${scope}/${shard}/`, '');
|
|
115
124
|
if (rawPath.endsWith('/resolve')) {
|
|
116
125
|
const filePath = rawPath.replace(/\/resolve$/, '');
|
|
117
126
|
if (!filePath)
|
|
@@ -120,7 +129,7 @@ export function createDocsRouter(store, settings) {
|
|
|
120
129
|
if (!body || typeof body.choice === 'undefined') {
|
|
121
130
|
return c.json({ error: 'Body must include { choice }' }, 400);
|
|
122
131
|
}
|
|
123
|
-
await store.resolveConflict(
|
|
132
|
+
await store.resolveConflict(scope, shard, filePath, body.choice);
|
|
124
133
|
return c.json({ ok: true });
|
|
125
134
|
}
|
|
126
135
|
if (rawPath.endsWith('/rename')) {
|
|
@@ -135,7 +144,7 @@ export function createDocsRouter(store, settings) {
|
|
|
135
144
|
return c.json({ error: 'Rename target must differ from source' }, 400);
|
|
136
145
|
}
|
|
137
146
|
try {
|
|
138
|
-
await store.rename(
|
|
147
|
+
await store.rename(scope, shard, oldPath, body.to);
|
|
139
148
|
}
|
|
140
149
|
catch (err) {
|
|
141
150
|
const msg = String(err?.message ?? err);
|
|
@@ -145,30 +154,30 @@ export function createDocsRouter(store, settings) {
|
|
|
145
154
|
return c.json({ error: msg }, 409);
|
|
146
155
|
throw err;
|
|
147
156
|
}
|
|
148
|
-
const meta = await store.readMeta(
|
|
157
|
+
const meta = await store.readMeta(scope, shard, body.to);
|
|
149
158
|
return c.json({ ok: true, version: meta?.version });
|
|
150
159
|
}
|
|
151
160
|
return c.json({ error: 'Unknown docs POST endpoint' }, 404);
|
|
152
161
|
});
|
|
153
162
|
// Write
|
|
154
|
-
router.put('/:
|
|
155
|
-
const {
|
|
163
|
+
router.put('/:scope/:shard/*', async (c) => {
|
|
164
|
+
const { scope, shard } = c.req.param();
|
|
156
165
|
if (isReservedShardId(shard))
|
|
157
166
|
return c.json({ error: 'Reserved shard id' }, 400);
|
|
158
|
-
const filePath = c.req.path.replace(`/api/docs/${
|
|
167
|
+
const filePath = c.req.path.replace(`/api/docs/${scope}/${shard}/`, '');
|
|
159
168
|
if (!filePath)
|
|
160
169
|
return c.json({ error: 'Missing file path' }, 400);
|
|
161
170
|
const body = await c.req.text();
|
|
162
|
-
const result = await store.write(
|
|
171
|
+
const result = await store.write(scope, shard, filePath, body);
|
|
163
172
|
return c.json({ ok: true, ...result });
|
|
164
173
|
});
|
|
165
174
|
// Delete
|
|
166
|
-
router.delete('/:
|
|
167
|
-
const {
|
|
175
|
+
router.delete('/:scope/:shard/*', async (c) => {
|
|
176
|
+
const { scope, shard } = c.req.param();
|
|
168
177
|
if (isReservedShardId(shard))
|
|
169
178
|
return c.json({ error: 'Reserved shard id' }, 400);
|
|
170
|
-
const filePath = c.req.path.replace(`/api/docs/${
|
|
171
|
-
await store.delete(
|
|
179
|
+
const filePath = c.req.path.replace(`/api/docs/${scope}/${shard}/`, '');
|
|
180
|
+
await store.delete(scope, shard, filePath);
|
|
172
181
|
return c.json({ ok: true });
|
|
173
182
|
});
|
|
174
183
|
return router;
|
package/dist/routes/keys.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* User-
|
|
2
|
+
* User-scope key management endpoints.
|
|
3
3
|
*
|
|
4
|
-
* Auth: uses `caller.
|
|
5
|
-
* see and revoke keys in their own
|
|
4
|
+
* Auth: uses `caller.scopeId` set by resolveCaller. Each caller can only
|
|
5
|
+
* see and revoke keys in their own scope.
|
|
6
6
|
*
|
|
7
7
|
* Mint flow:
|
|
8
8
|
* 1. Shell calls POST /consent (session-only) → receives a short-lived ticket.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { Hono } from 'hono';
|
|
14
14
|
import type { KeyStore, ApiKeyPublic } from '../keys.js';
|
|
15
15
|
export interface RevocationEvent {
|
|
16
|
-
|
|
16
|
+
scopeId: string;
|
|
17
17
|
id: string;
|
|
18
18
|
row: ApiKeyPublic;
|
|
19
19
|
}
|
package/dist/routes/keys.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* User-
|
|
2
|
+
* User-scope key management endpoints.
|
|
3
3
|
*
|
|
4
|
-
* Auth: uses `caller.
|
|
5
|
-
* see and revoke keys in their own
|
|
4
|
+
* Auth: uses `caller.scopeId` set by resolveCaller. Each caller can only
|
|
5
|
+
* see and revoke keys in their own scope.
|
|
6
6
|
*
|
|
7
7
|
* Mint flow:
|
|
8
8
|
* 1. Shell calls POST /consent (session-only) → receives a short-lived ticket.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { Hono } from 'hono';
|
|
14
14
|
import { randomBytes } from 'node:crypto';
|
|
15
|
-
import {
|
|
15
|
+
import { requireCallerScope } from '../scope.js';
|
|
16
16
|
const TICKET_TTL_MS = 60_000;
|
|
17
17
|
function sweepExpired(tickets) {
|
|
18
18
|
const now = Date.now();
|
|
@@ -31,11 +31,11 @@ export function createKeysRouter(keys, onRevoke) {
|
|
|
31
31
|
for (const fn of sseSubscribers)
|
|
32
32
|
fn(ev);
|
|
33
33
|
}
|
|
34
|
-
router.use('*',
|
|
34
|
+
router.use('*', requireCallerScope);
|
|
35
35
|
router.get('/', (c) => {
|
|
36
36
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
37
|
const caller = c.get('caller');
|
|
38
|
-
return c.json(keys.
|
|
38
|
+
return c.json(keys.listForScope(caller.scopeId));
|
|
39
39
|
});
|
|
40
40
|
/**
|
|
41
41
|
* POST /consent — shell-only endpoint that issues a single-use consent ticket.
|
|
@@ -65,7 +65,7 @@ export function createKeysRouter(keys, onRevoke) {
|
|
|
65
65
|
peerRole: body.peerRole,
|
|
66
66
|
peerId: body.peerId,
|
|
67
67
|
expiresIn: body.expiresIn,
|
|
68
|
-
|
|
68
|
+
scopeId: caller.scopeId,
|
|
69
69
|
userId: caller.userId,
|
|
70
70
|
issuedAt: Date.now(),
|
|
71
71
|
});
|
|
@@ -91,7 +91,7 @@ export function createKeysRouter(keys, onRevoke) {
|
|
|
91
91
|
if (Date.now() - entry.issuedAt > TICKET_TTL_MS) {
|
|
92
92
|
return c.json({ error: 'Ticket invalid or already used' }, 400);
|
|
93
93
|
}
|
|
94
|
-
if (entry.
|
|
94
|
+
if (entry.scopeId !== caller.scopeId) {
|
|
95
95
|
return c.json({ error: 'Ticket invalid or already used' }, 400);
|
|
96
96
|
}
|
|
97
97
|
const expiresAt = entry.expiresIn
|
|
@@ -99,7 +99,7 @@ export function createKeysRouter(keys, onRevoke) {
|
|
|
99
99
|
: undefined;
|
|
100
100
|
const row = keys.generate({
|
|
101
101
|
label: entry.label,
|
|
102
|
-
|
|
102
|
+
scopeId: entry.scopeId,
|
|
103
103
|
ownerUserId: entry.userId,
|
|
104
104
|
mintedByShardId: entry.shardId,
|
|
105
105
|
scopes: entry.scopes,
|
|
@@ -116,7 +116,7 @@ export function createKeysRouter(keys, onRevoke) {
|
|
|
116
116
|
* tenant is revoked from any source. The client-side revocation bus
|
|
117
117
|
* consumes this stream and dispatches `onKeyRevoked` on the owning shard.
|
|
118
118
|
*
|
|
119
|
-
* Auth:
|
|
119
|
+
* Auth: requireCallerScope (already applied via the `*` middleware above).
|
|
120
120
|
* Each connected tab gets its own stream. Connections are cleaned up
|
|
121
121
|
* automatically when the client disconnects (abort signal).
|
|
122
122
|
*/
|
|
@@ -154,10 +154,10 @@ export function createKeysRouter(keys, onRevoke) {
|
|
|
154
154
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
155
155
|
const caller = c.get('caller');
|
|
156
156
|
const id = c.req.param('id');
|
|
157
|
-
const removed = keys.revoke(caller.
|
|
157
|
+
const removed = keys.revoke(caller.scopeId, id);
|
|
158
158
|
if (!removed)
|
|
159
159
|
return c.json({ error: 'Key not found' }, 404);
|
|
160
|
-
await onRevoke({
|
|
160
|
+
await onRevoke({ scopeId: caller.scopeId, id, row: removed });
|
|
161
161
|
// Broadcast to SSE subscribers so other browser tabs learn of the revocation.
|
|
162
162
|
pushToBus({ id, shardId: removed.mintedByShardId ?? null });
|
|
163
163
|
return c.json({ ok: true });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* - GET / — list projects the caller is a member of
|
|
5
|
+
* - GET /all — admin: list every project
|
|
6
|
+
* - GET /:id — fetch one (member or admin)
|
|
7
|
+
* - POST / — admin: create a project
|
|
8
|
+
* - PATCH /:id — admin: mutate a project
|
|
9
|
+
* - DELETE /:id — admin: delete a project
|
|
10
|
+
*/
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import type { ProjectStore } from '../projects.js';
|
|
13
|
+
export declare function createProjectsRouter(store: ProjectStore, dataDir?: string): Hono;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* - GET / — list projects the caller is a member of
|
|
5
|
+
* - GET /all — admin: list every project
|
|
6
|
+
* - GET /:id — fetch one (member or admin)
|
|
7
|
+
* - POST / — admin: create a project
|
|
8
|
+
* - PATCH /:id — admin: mutate a project
|
|
9
|
+
* - DELETE /:id — admin: delete a project
|
|
10
|
+
*/
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
function isAdmin(caller) {
|
|
13
|
+
return !!caller?.scopes.includes('admin:*');
|
|
14
|
+
}
|
|
15
|
+
export function createProjectsRouter(store, dataDir) {
|
|
16
|
+
const router = new Hono();
|
|
17
|
+
router.get('/', (c) => {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
const caller = c.get('caller');
|
|
20
|
+
if (!caller?.userId)
|
|
21
|
+
return c.json({ error: 'Unauthenticated' }, 401);
|
|
22
|
+
return c.json({ projects: store.listForUser(caller.userId) });
|
|
23
|
+
});
|
|
24
|
+
router.get('/all', (c) => {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
const caller = c.get('caller');
|
|
27
|
+
if (!isAdmin(caller))
|
|
28
|
+
return c.json({ error: 'Admin required' }, 403);
|
|
29
|
+
return c.json({ projects: store.list() });
|
|
30
|
+
});
|
|
31
|
+
router.get('/:id', (c) => {
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
const caller = c.get('caller');
|
|
34
|
+
const project = store.get(c.req.param('id'));
|
|
35
|
+
if (!project)
|
|
36
|
+
return c.json({ error: 'Not found' }, 404);
|
|
37
|
+
const allowed = isAdmin(caller) || (caller?.userId !== null && caller?.userId !== undefined && project.members.includes(caller.userId));
|
|
38
|
+
if (!allowed)
|
|
39
|
+
return c.json({ error: 'Not a member' }, 403);
|
|
40
|
+
return c.json(project);
|
|
41
|
+
});
|
|
42
|
+
router.post('/', async (c) => {
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
const caller = c.get('caller');
|
|
45
|
+
if (!isAdmin(caller))
|
|
46
|
+
return c.json({ error: 'Admin required' }, 403);
|
|
47
|
+
const body = await c.req.json().catch(() => null);
|
|
48
|
+
if (!body ||
|
|
49
|
+
typeof body.name !== 'string' ||
|
|
50
|
+
!Array.isArray(body.members) ||
|
|
51
|
+
!Array.isArray(body.appAllowlist)) {
|
|
52
|
+
return c.json({ error: 'Body must include { name, members, appAllowlist }' }, 400);
|
|
53
|
+
}
|
|
54
|
+
const project = store.create({
|
|
55
|
+
name: body.name,
|
|
56
|
+
description: typeof body.description === 'string' ? body.description : undefined,
|
|
57
|
+
members: body.members,
|
|
58
|
+
appAllowlist: body.appAllowlist,
|
|
59
|
+
createdBy: caller.userId ?? 'admin-key',
|
|
60
|
+
});
|
|
61
|
+
return c.json(project, 201);
|
|
62
|
+
});
|
|
63
|
+
router.patch('/:id', async (c) => {
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
const caller = c.get('caller');
|
|
66
|
+
if (!isAdmin(caller))
|
|
67
|
+
return c.json({ error: 'Admin required' }, 403);
|
|
68
|
+
const body = await c.req.json().catch(() => null);
|
|
69
|
+
if (!body)
|
|
70
|
+
return c.json({ error: 'Body required' }, 400);
|
|
71
|
+
const updated = store.update(c.req.param('id'), {
|
|
72
|
+
name: body.name,
|
|
73
|
+
description: body.description,
|
|
74
|
+
members: body.members,
|
|
75
|
+
appAllowlist: body.appAllowlist,
|
|
76
|
+
});
|
|
77
|
+
if (!updated)
|
|
78
|
+
return c.json({ error: 'Not found' }, 404);
|
|
79
|
+
return c.json(updated);
|
|
80
|
+
});
|
|
81
|
+
router.delete('/:id', (c) => {
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
const caller = c.get('caller');
|
|
84
|
+
if (!isAdmin(caller))
|
|
85
|
+
return c.json({ error: 'Admin required' }, 403);
|
|
86
|
+
const id = c.req.param('id');
|
|
87
|
+
if (c.req.query('wipeData') === '1') {
|
|
88
|
+
if (!dataDir)
|
|
89
|
+
return c.json({ error: 'wipeData unsupported (no dataDir wired)' }, 500);
|
|
90
|
+
const result = store.deleteWithData(id, dataDir);
|
|
91
|
+
if (!result.ok)
|
|
92
|
+
return c.json({ error: 'Not found' }, 404);
|
|
93
|
+
return c.json(result);
|
|
94
|
+
}
|
|
95
|
+
const ok = store.delete(id);
|
|
96
|
+
if (!ok)
|
|
97
|
+
return c.json({ error: 'Not found' }, 404);
|
|
98
|
+
return c.json({ ok: true, wipedData: false });
|
|
99
|
+
});
|
|
100
|
+
return router;
|
|
101
|
+
}
|