@hiai-gg/docsmint 0.4.7 → 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.
|
@@ -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,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
|
+
}
|
package/dist/backend/index.js
CHANGED
|
@@ -214361,7 +214361,7 @@ var swaggerConfig = {
|
|
|
214361
214361
|
documentation: {
|
|
214362
214362
|
info: {
|
|
214363
214363
|
title: "DocsMint API",
|
|
214364
|
-
version: "0.4.
|
|
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: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hiai-gg/docsmint",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"browser": {
|
|
6
6
|
"./dist/backend-launcher.js": false,
|
|
@@ -341,6 +341,8 @@
|
|
|
341
341
|
"backend/src/lib/storage-factory.ts",
|
|
342
342
|
"backend/src/lib/logger.ts",
|
|
343
343
|
"backend/src/lib/api-key-facade.ts",
|
|
344
|
+
"backend/src/lib/api-keys.ts",
|
|
345
|
+
"backend/src/lib/api-key-encryption.ts",
|
|
344
346
|
"backend/src/lib/lifecycle-service.ts",
|
|
345
347
|
"README.md",
|
|
346
348
|
"LICENSE"
|
|
@@ -24,7 +24,7 @@ import { registerSearch } from "./commands/search.js";
|
|
|
24
24
|
import { registerSnapshot } from "./commands/snapshot.js";
|
|
25
25
|
import { registerUpdate } from "./commands/update.js";
|
|
26
26
|
|
|
27
|
-
const VERSION = "0.4.
|
|
27
|
+
const VERSION = "0.4.8";
|
|
28
28
|
|
|
29
29
|
const program = new Command();
|
|
30
30
|
program
|