sh3-server 0.13.1 → 0.13.3

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.
@@ -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
+ }
@@ -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
+ }
@@ -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, tenantId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
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) => {
@@ -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/:tenant/_shards → listAll (shard ids)
10
- * GET /api/docs/:tenant/_all → listAll
11
- * GET /api/docs/:tenant/:shard → list
12
- * GET /api/docs/:tenant/:shard/*path → read
13
- * HEAD /api/docs/:tenant/:shard/*path → exists
14
- * PUT /api/docs/:tenant/:shard/*path → write
15
- * DELETE /api/docs/:tenant/:shard/*path → delete
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 { TenantDocStore } from '../doc-store/index.js';
20
- export declare function createDocsRouter(store: TenantDocStore, settings?: SettingsStore): Hono;
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 {};
@@ -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/:tenant/_shards → listAll (shard ids)
10
- * GET /api/docs/:tenant/_all → listAll
11
- * GET /api/docs/:tenant/:shard → list
12
- * GET /api/docs/:tenant/:shard/*path → read
13
- * HEAD /api/docs/:tenant/:shard/*path → exists
14
- * PUT /api/docs/:tenant/:shard/*path → write
15
- * DELETE /api/docs/:tenant/:shard/*path → delete
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 { tenantParamMatch } from '../scope.js';
19
- export function createDocsRouter(store, settings) {
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('/:tenant/*', tenantParamMatch('tenant', settings));
22
- router.use('/:tenant', tenantParamMatch('tenant', settings));
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('/:tenant/_shards', async (c) => {
28
- const { tenant } = c.req.param();
29
- const all = await store.listAll(tenant);
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('/:tenant/_all', async (c) => {
37
- const { tenant } = c.req.param();
38
- return c.json(await store.listAll(tenant));
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('/:tenant/:shard', async (c) => {
42
- const { tenant, shard } = c.req.param();
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(tenant, shard));
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('/:tenant/:shard/*', async (c, next) => {
50
- const { tenant, shard } = c.req.param();
51
- const prefix = `/api/docs/${tenant}/${shard}/`;
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(tenant, shard, filePath, origin);
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('/:tenant/:shard/*', async (c) => {
70
- const { tenant, shard } = c.req.param();
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/${tenant}/${shard}/`, '');
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(tenant, shard, filePath);
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(tenant, shard, filePath);
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(tenant, shard, filePath);
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', '/:tenant/:shard/*', async (c) => {
100
- const { tenant, shard } = c.req.param();
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/${tenant}/${shard}/`, '');
112
+ const filePath = c.req.path.replace(`/api/docs/${scope}/${shard}/`, '');
104
113
  return new Response(null, {
105
- status: (await store.exists(tenant, shard, filePath)) ? 200 : 404,
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('/:tenant/:shard/*', async (c) => {
111
- const { tenant, shard } = c.req.param();
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/${tenant}/${shard}/`, '');
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(tenant, shard, filePath, body.choice);
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(tenant, shard, oldPath, body.to);
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(tenant, shard, body.to);
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('/:tenant/:shard/*', async (c) => {
155
- const { tenant, shard } = c.req.param();
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/${tenant}/${shard}/`, '');
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(tenant, shard, filePath, body);
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('/:tenant/:shard/*', async (c) => {
167
- const { tenant, shard } = c.req.param();
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/${tenant}/${shard}/`, '');
171
- await store.delete(tenant, shard, filePath);
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;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * User-tenant key management endpoints.
2
+ * User-scope key management endpoints.
3
3
  *
4
- * Auth: uses `caller.tenantId` set by resolveCaller. Each caller can only
5
- * see and revoke keys in their own tenant.
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
- tenantId: string;
16
+ scopeId: string;
17
17
  id: string;
18
18
  row: ApiKeyPublic;
19
19
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * User-tenant key management endpoints.
2
+ * User-scope key management endpoints.
3
3
  *
4
- * Auth: uses `caller.tenantId` set by resolveCaller. Each caller can only
5
- * see and revoke keys in their own tenant.
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 { tenantRequired } from '../scope.js';
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('*', tenantRequired);
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.listForTenant(caller.tenantId));
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
- tenantId: caller.tenantId,
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.tenantId !== caller.tenantId) {
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
- tenantId: entry.tenantId,
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: tenantRequired (already applied via the `*` middleware above).
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.tenantId, id);
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({ tenantId: caller.tenantId, id, row: removed });
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
+ }