@hiai-gg/docsmint 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/backend/src/lib/api-key-encryption.ts +66 -0
  2. package/backend/src/lib/api-key-facade.ts +15 -0
  3. package/backend/src/lib/api-keys.ts +243 -0
  4. package/dist/backend/index.js +1 -1
  5. package/dist/frontend/{HiaiDocsExtensionProvider-DHWLzRUu.js → HiaiDocsExtensionProvider-CMiTXi2J.js} +1 -1
  6. package/dist/frontend/{SettingsDialog-LyZUxxku.js → SettingsDialog-BB7h7GWw.js} +1 -1
  7. package/dist/frontend/{Sidebar-BsJsD1mJ.js → Sidebar-fbtYC3qs.js} +241 -225
  8. package/dist/frontend/app-shell.js +4 -4
  9. package/dist/frontend/components/settings.js +1 -1
  10. package/dist/frontend/components/sidebar.js +1 -1
  11. package/dist/frontend/{context-BMD6XTPe.js → context-KLiM3QQ0.js} +1 -0
  12. package/dist/frontend/dashboard.js +1 -1
  13. package/dist/frontend/extension.d.ts +2 -1
  14. package/dist/frontend/extension.js +1 -1
  15. package/dist/frontend/search.js +1 -1
  16. package/dist/frontend/shared-document.js +1 -1
  17. package/dist/frontend-ssr/app-shell.js +4 -4
  18. package/dist/frontend-ssr/assets/{HiaiDocsExtensionProvider-DAM-lX4R.js → HiaiDocsExtensionProvider-B_MrN08c.js} +1 -1
  19. package/dist/frontend-ssr/assets/{SettingsDialog-Dg97vv83.js → SettingsDialog-C2SMWJL8.js} +1 -1
  20. package/dist/frontend-ssr/assets/{Sidebar-CrDk_HYY.js → Sidebar-DKqmwreh.js} +38 -11
  21. package/dist/frontend-ssr/assets/{context-jz1GGEUi.js → context-BtCMqdVe.js} +1 -0
  22. package/dist/frontend-ssr/components/settings.js +1 -1
  23. package/dist/frontend-ssr/components/sidebar.js +1 -1
  24. package/dist/frontend-ssr/dashboard.js +1 -1
  25. package/dist/frontend-ssr/extension.js +1 -1
  26. package/dist/frontend-ssr/search.js +1 -1
  27. package/dist/frontend-ssr/shared-document.js +1 -1
  28. package/package.json +8 -1
  29. package/packages/cli/src/index.ts +1 -1
  30. package/packages/mcp-server/src/index.ts +1 -1
@@ -0,0 +1,66 @@
1
+ const VERSION = "v1";
2
+ const AAD = new TextEncoder().encode("hiai-docs:category-api-key:v1");
3
+
4
+ function bytesToBase64(bytes: Uint8Array): string {
5
+ let binary = "";
6
+ for (const byte of bytes) binary += String.fromCharCode(byte);
7
+ return btoa(binary);
8
+ }
9
+
10
+ function base64ToBytes(value: string): Uint8Array<ArrayBuffer> {
11
+ const binary = atob(value);
12
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
13
+ for (let index = 0; index < binary.length; index += 1) {
14
+ bytes[index] = binary.charCodeAt(index);
15
+ }
16
+ return bytes;
17
+ }
18
+
19
+ async function importEncryptionKey(secret: string): Promise<CryptoKey> {
20
+ if (secret.length < 32) {
21
+ throw new Error("API_KEY_ENCRYPTION_SECRET must be at least 32 characters");
22
+ }
23
+ const digest = await crypto.subtle.digest(
24
+ "SHA-256",
25
+ new TextEncoder().encode(secret),
26
+ );
27
+ return crypto.subtle.importKey("raw", digest, "AES-GCM", false, [
28
+ "encrypt",
29
+ "decrypt",
30
+ ]);
31
+ }
32
+
33
+ export async function encryptApiKey(
34
+ rawKey: string,
35
+ secret: string,
36
+ ): Promise<string> {
37
+ const iv = crypto.getRandomValues(new Uint8Array(12));
38
+ const key = await importEncryptionKey(secret);
39
+ const ciphertext = await crypto.subtle.encrypt(
40
+ { name: "AES-GCM", iv, additionalData: AAD },
41
+ key,
42
+ new TextEncoder().encode(rawKey),
43
+ );
44
+ return `${VERSION}.${bytesToBase64(iv)}.${bytesToBase64(new Uint8Array(ciphertext))}`;
45
+ }
46
+
47
+ export async function decryptApiKey(
48
+ payload: string,
49
+ secret: string,
50
+ ): Promise<string> {
51
+ const [version, encodedIv, encodedCiphertext] = payload.split(".");
52
+ if (version !== VERSION || !encodedIv || !encodedCiphertext) {
53
+ throw new Error("Unsupported encrypted API key payload");
54
+ }
55
+ const key = await importEncryptionKey(secret);
56
+ const plaintext = await crypto.subtle.decrypt(
57
+ {
58
+ name: "AES-GCM",
59
+ iv: base64ToBytes(encodedIv),
60
+ additionalData: AAD,
61
+ },
62
+ key,
63
+ base64ToBytes(encodedCiphertext),
64
+ );
65
+ return new TextDecoder().decode(plaintext);
66
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Server-only credential lifecycle facade for product hosts.
3
+ *
4
+ * DocsMint OSS remains the sole owner of key generation, hashing, encrypted
5
+ * category secrets, revocation, and verification. Downstream hosts may only
6
+ * add their own target overlay after calling these functions.
7
+ */
8
+ export {
9
+ buildCategoryApiKeyScopes,
10
+ createApiKey as issueApiKey,
11
+ GLOBAL_API_SCOPE,
12
+ revealCategoryApiKey,
13
+ revokeApiKey,
14
+ validateApiKey as verifyApiKey,
15
+ } from "./api-keys";
@@ -0,0 +1,243 @@
1
+ import { apiKeys } from "@hiai-docs/db/schema";
2
+ import {
3
+ adminTenantContext,
4
+ withTenant,
5
+ ZERO_UUID,
6
+ } from "@hiai-docs/db/with-tenant";
7
+ import { and, desc, eq } from "drizzle-orm";
8
+ import { decryptApiKey, encryptApiKey } from "./api-key-encryption";
9
+
10
+ const API_KEY_ADMIN_TENANT = adminTenantContext(ZERO_UUID);
11
+
12
+ export const GLOBAL_API_SCOPE = "global";
13
+ export const CATEGORY_API_PERMISSIONS = ["read", "edit", "write"] as const;
14
+ export type CategoryApiPermission = (typeof CATEGORY_API_PERMISSIONS)[number];
15
+ export type CategoryApiScope = `category:${string}:${CategoryApiPermission}`;
16
+ export type ApiKeyScope = typeof GLOBAL_API_SCOPE | CategoryApiScope;
17
+
18
+ const CATEGORY_SCOPE_PATTERN =
19
+ /^category:([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}):(read|edit|write)$/;
20
+
21
+ /** Reject unknown, malformed, and duplicate persisted scopes. */
22
+ export function parseApiKeyScopes(value: unknown): ApiKeyScope[] | null {
23
+ if (!Array.isArray(value) || value.length === 0) return null;
24
+ const scopes: ApiKeyScope[] = [];
25
+ const seen = new Set<string>();
26
+ for (const candidate of value) {
27
+ if (typeof candidate !== "string" || seen.has(candidate)) return null;
28
+ if (
29
+ candidate !== GLOBAL_API_SCOPE &&
30
+ !CATEGORY_SCOPE_PATTERN.test(candidate)
31
+ ) {
32
+ return null;
33
+ }
34
+ seen.add(candidate);
35
+ scopes.push(candidate as ApiKeyScope);
36
+ }
37
+ return scopes;
38
+ }
39
+
40
+ export function buildCategoryApiKeyScopes(
41
+ categoryId: string,
42
+ permissions: { read: boolean; edit: boolean; write: boolean },
43
+ ): ApiKeyScope[] {
44
+ return (["read", "edit", "write"] as const)
45
+ .filter((permission) => permissions[permission])
46
+ .map((permission) => `category:${categoryId}:${permission}` as ApiKeyScope);
47
+ }
48
+
49
+ export function categoryIdFromApiKeyScopes(
50
+ scopes: readonly string[],
51
+ ): string | null {
52
+ const parsed = parseApiKeyScopes(scopes);
53
+ if (!parsed) return null;
54
+ for (const scope of parsed) {
55
+ const match = CATEGORY_SCOPE_PATTERN.exec(scope);
56
+ if (match?.[1]) return match[1];
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Hash a raw API key with SHA-256.
63
+ */
64
+ function hashKey(key: string): string {
65
+ const hasher = new Bun.CryptoHasher("sha256");
66
+ hasher.update(key);
67
+ return hasher.digest("hex");
68
+ }
69
+
70
+ /**
71
+ * Create a new API key for a user.
72
+ *
73
+ * Returns the raw key (only time it is ever exposed), the prefix, and the DB id.
74
+ */
75
+ export async function createApiKey(
76
+ ownerId: string,
77
+ name: string,
78
+ scopes: ApiKeyScope[],
79
+ expiresAt?: Date,
80
+ options?: { encryptionSecret?: string },
81
+ ): Promise<{ key: string; prefix: string; id: string }> {
82
+ const rawKey = crypto.randomUUID();
83
+ const keyHash = hashKey(rawKey);
84
+ const prefix = rawKey.slice(0, 8);
85
+ const encryptedKey = options?.encryptionSecret
86
+ ? await encryptApiKey(rawKey, options.encryptionSecret)
87
+ : null;
88
+
89
+ const [row] = await withTenant({ userId: ownerId, role: "user" }, (tx) =>
90
+ tx
91
+ .insert(apiKeys)
92
+ .values({
93
+ ownerId,
94
+ name,
95
+ keyHash,
96
+ prefix,
97
+ scopes,
98
+ expiresAt: expiresAt ?? null,
99
+ encryptedKey,
100
+ })
101
+ .returning({ id: apiKeys.id }),
102
+ );
103
+
104
+ if (!row) {
105
+ throw new Error("Failed to create API key");
106
+ }
107
+
108
+ return { key: rawKey, prefix, id: row.id };
109
+ }
110
+
111
+ export async function revealCategoryApiKey(
112
+ id: string,
113
+ ownerId: string,
114
+ encryptionSecret: string,
115
+ ): Promise<string | null> {
116
+ const [row] = await withTenant({ userId: ownerId, role: "user" }, (tx) =>
117
+ tx
118
+ .select({
119
+ encryptedKey: apiKeys.encryptedKey,
120
+ scopes: apiKeys.scopes,
121
+ })
122
+ .from(apiKeys)
123
+ .where(and(eq(apiKeys.id, id), eq(apiKeys.ownerId, ownerId)))
124
+ .limit(1),
125
+ );
126
+ if (!row?.encryptedKey) return null;
127
+ const scopes = (row.scopes ?? []) as string[];
128
+ if (!categoryIdFromApiKeyScopes(scopes)) return null;
129
+ return decryptApiKey(row.encryptedKey, encryptionSecret);
130
+ }
131
+
132
+ /**
133
+ * List all API keys for a user (excludes the key hash).
134
+ */
135
+ export async function listApiKeys(ownerId: string): Promise<
136
+ Array<{
137
+ id: string;
138
+ name: string;
139
+ prefix: string;
140
+ scopes: string[];
141
+ lastUsedAt: Date | null;
142
+ expiresAt: Date | null;
143
+ createdAt: Date;
144
+ recoverable: boolean;
145
+ }>
146
+ > {
147
+ const rows = await withTenant({ userId: ownerId, role: "user" }, (tx) =>
148
+ tx
149
+ .select({
150
+ id: apiKeys.id,
151
+ name: apiKeys.name,
152
+ prefix: apiKeys.prefix,
153
+ scopes: apiKeys.scopes,
154
+ lastUsedAt: apiKeys.lastUsedAt,
155
+ expiresAt: apiKeys.expiresAt,
156
+ createdAt: apiKeys.createdAt,
157
+ encryptedKey: apiKeys.encryptedKey,
158
+ })
159
+ .from(apiKeys)
160
+ .where(eq(apiKeys.ownerId, ownerId))
161
+ .orderBy(desc(apiKeys.createdAt)),
162
+ );
163
+
164
+ return rows.map((r) => ({
165
+ id: r.id,
166
+ name: r.name,
167
+ prefix: r.prefix,
168
+ lastUsedAt: r.lastUsedAt,
169
+ expiresAt: r.expiresAt,
170
+ createdAt: r.createdAt,
171
+ recoverable: r.encryptedKey !== null,
172
+ scopes: (r.scopes ?? []) as string[],
173
+ }));
174
+ }
175
+
176
+ /**
177
+ * Revoke (delete) an API key by id and ownerId.
178
+ * Returns true if a key was deleted.
179
+ */
180
+ export async function revokeApiKey(
181
+ id: string,
182
+ ownerId: string,
183
+ ): Promise<boolean> {
184
+ const deleted = await withTenant({ userId: ownerId, role: "user" }, (tx) =>
185
+ tx
186
+ .delete(apiKeys)
187
+ .where(and(eq(apiKeys.id, id), eq(apiKeys.ownerId, ownerId)))
188
+ .returning({ id: apiKeys.id }),
189
+ );
190
+
191
+ return deleted.length > 0;
192
+ }
193
+
194
+ /**
195
+ * Validate an API key.
196
+ * Returns { ownerId, scopes } if valid, or null if not found / expired.
197
+ * Updates lastUsedAt on successful validation.
198
+ */
199
+ export async function validateApiKey(key: string): Promise<{
200
+ id: string;
201
+ ownerId: string;
202
+ scopes: ApiKeyScope[];
203
+ } | null> {
204
+ const keyHash = hashKey(key);
205
+
206
+ const [row] = await withTenant(API_KEY_ADMIN_TENANT, (tx) =>
207
+ tx
208
+ .select({
209
+ id: apiKeys.id,
210
+ ownerId: apiKeys.ownerId,
211
+ scopes: apiKeys.scopes,
212
+ expiresAt: apiKeys.expiresAt,
213
+ })
214
+ .from(apiKeys)
215
+ .where(eq(apiKeys.keyHash, keyHash))
216
+ .limit(1),
217
+ );
218
+
219
+ if (!row) {
220
+ return null;
221
+ }
222
+
223
+ // Check expiration
224
+ if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
225
+ return null;
226
+ }
227
+ const scopes = parseApiKeyScopes(row.scopes ?? []);
228
+ if (!scopes) return null;
229
+
230
+ // Update last_used_at
231
+ await withTenant(API_KEY_ADMIN_TENANT, (tx) =>
232
+ tx
233
+ .update(apiKeys)
234
+ .set({ lastUsedAt: new Date() })
235
+ .where(eq(apiKeys.id, row.id)),
236
+ );
237
+
238
+ return {
239
+ id: row.id,
240
+ ownerId: row.ownerId,
241
+ scopes,
242
+ };
243
+ }
@@ -214361,7 +214361,7 @@ var swaggerConfig = {
214361
214361
  documentation: {
214362
214362
  info: {
214363
214363
  title: "DocsMint API",
214364
- version: "0.4.6",
214364
+ version: "0.4.8",
214365
214365
  description: "Self-hosted AI-first documentation platform. Full-text + semantic search, version history, sharing, and folder organization.",
214366
214366
  contact: { name: "HiAi-gg", url: "https://github.com/HiAi-gg/docsmint" },
214367
214367
  license: {
@@ -1,4 +1,4 @@
1
- import { n as e } from "./context-BMD6XTPe.js";
1
+ import { n as e } from "./context-KLiM3QQ0.js";
2
2
  import "svelte/internal/disclose-version";
3
3
  import * as t from "svelte/internal/client";
4
4
  //#region src/lib/hosts/HiaiDocsExtensionProvider.svelte
@@ -10,7 +10,7 @@ import { r as ce, t as le } from "./auth-client-Cygmp1I2.js";
10
10
  import { a as ue, r as de } from "./settings-DSrUl4hW.js";
11
11
  import { a as k, i as A, n as j, o as M, s as N, t as P } from "./api-keys-CpkrA8rD.js";
12
12
  import { i as F } from "./categories-Drnns7N6.js";
13
- import { t as fe } from "./context-BMD6XTPe.js";
13
+ import { t as fe } from "./context-KLiM3QQ0.js";
14
14
  import { t as pe } from "./resolve-BG4HgzpN.js";
15
15
  import { n as I, t as L } from "./db-ByrlYfaI.js";
16
16
  import { a as R, t as z } from "./identity-DyWeNzu_.js";