@hiai-gg/docsmint 0.4.7 → 0.4.9
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.9",
|
|
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: {
|
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ../db/src/schema.ts
|
|
18
|
+
var exports_schema = {};
|
|
19
|
+
__export(exports_schema, {
|
|
20
|
+
versions: () => versions,
|
|
21
|
+
versionRelations: () => versionRelations,
|
|
22
|
+
verifications: () => verifications,
|
|
23
|
+
users: () => users,
|
|
24
|
+
tags: () => tags,
|
|
25
|
+
tagRelations: () => tagRelations,
|
|
26
|
+
shareRoleEnum: () => shareRoleEnum,
|
|
27
|
+
shareLinks: () => shareLinks,
|
|
28
|
+
shareLinkRelations: () => shareLinkRelations,
|
|
29
|
+
sessions: () => sessions,
|
|
30
|
+
pipelineStatusEnum: () => pipelineStatusEnum,
|
|
31
|
+
pipelineStageEnum: () => pipelineStageEnum,
|
|
32
|
+
lifecycleOperations: () => lifecycleOperations,
|
|
33
|
+
lifecycleOperationStatusEnum: () => lifecycleOperationStatusEnum,
|
|
34
|
+
lifecycleOperationKindEnum: () => lifecycleOperationKindEnum,
|
|
35
|
+
guestAccessRelations: () => guestAccessRelations,
|
|
36
|
+
guestAccess: () => guestAccess,
|
|
37
|
+
folders: () => folders,
|
|
38
|
+
folderRelations: () => folderRelations,
|
|
39
|
+
embeddingStatusEnum: () => embeddingStatusEnum,
|
|
40
|
+
documents: () => documents,
|
|
41
|
+
documentVisibilityEnum: () => documentVisibilityEnum,
|
|
42
|
+
documentTags: () => documentTags,
|
|
43
|
+
documentTagRelations: () => documentTagRelations,
|
|
44
|
+
documentRelations: () => documentRelations,
|
|
45
|
+
documentPipelineRuns: () => documentPipelineRuns,
|
|
46
|
+
documentPipelineBatches: () => documentPipelineBatches,
|
|
47
|
+
documentEmbeddings: () => documentEmbeddings,
|
|
48
|
+
documentEmbeddingRelations: () => documentEmbeddingRelations,
|
|
49
|
+
documentCreateOperations: () => documentCreateOperations,
|
|
50
|
+
categoryRelations: () => categoryRelations,
|
|
51
|
+
categories: () => categories,
|
|
52
|
+
auditLog: () => auditLog,
|
|
53
|
+
attachments: () => attachments,
|
|
54
|
+
attachmentRelations: () => attachmentRelations,
|
|
55
|
+
apiKeys: () => apiKeys,
|
|
56
|
+
apiKeyRelations: () => apiKeyRelations,
|
|
57
|
+
accounts: () => accounts
|
|
58
|
+
});
|
|
59
|
+
import { pgTable, uuid, text, timestamp, bigint, jsonb, index, uniqueIndex, customType, boolean, check, integer, pgEnum } from "drizzle-orm/pg-core";
|
|
60
|
+
import { relations, sql } from "drizzle-orm";
|
|
61
|
+
var vector = customType({
|
|
62
|
+
dataType(config) {
|
|
63
|
+
return `vector(${config.dimensions})`;
|
|
64
|
+
},
|
|
65
|
+
toDriver(value) {
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
},
|
|
68
|
+
fromDriver(value) {
|
|
69
|
+
if (typeof value === "string")
|
|
70
|
+
return JSON.parse(value);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
var tsvector = customType({
|
|
75
|
+
dataType() {
|
|
76
|
+
return "tsvector";
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
var documentVisibilityEnum = pgEnum("document_visibility", ["private", "shared", "public"]);
|
|
80
|
+
var shareRoleEnum = pgEnum("share_role", ["viewer", "commenter", "editor"]);
|
|
81
|
+
var embeddingStatusEnum = pgEnum("embedding_status", [
|
|
82
|
+
"pending",
|
|
83
|
+
"processing",
|
|
84
|
+
"ready",
|
|
85
|
+
"failed",
|
|
86
|
+
"stale"
|
|
87
|
+
]);
|
|
88
|
+
var pipelineStageEnum = pgEnum("pipeline_stage", [
|
|
89
|
+
"prepare",
|
|
90
|
+
"embed",
|
|
91
|
+
"graph",
|
|
92
|
+
"summarize",
|
|
93
|
+
"finalize"
|
|
94
|
+
]);
|
|
95
|
+
var pipelineStatusEnum = pgEnum("pipeline_status", [
|
|
96
|
+
"pending",
|
|
97
|
+
"processing",
|
|
98
|
+
"ready",
|
|
99
|
+
"retrying",
|
|
100
|
+
"failed",
|
|
101
|
+
"ready_with_warnings",
|
|
102
|
+
"skipped",
|
|
103
|
+
"cancelled"
|
|
104
|
+
]);
|
|
105
|
+
var lifecycleOperationKindEnum = pgEnum("lifecycle_operation_kind", [
|
|
106
|
+
"export",
|
|
107
|
+
"purge"
|
|
108
|
+
]);
|
|
109
|
+
var lifecycleOperationStatusEnum = pgEnum("lifecycle_operation_status", [
|
|
110
|
+
"pending",
|
|
111
|
+
"running",
|
|
112
|
+
"retryable",
|
|
113
|
+
"completed",
|
|
114
|
+
"rejected"
|
|
115
|
+
]);
|
|
116
|
+
var users = pgTable("users", {
|
|
117
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
118
|
+
email: text("email").notNull().unique(),
|
|
119
|
+
name: text("name"),
|
|
120
|
+
emailVerified: boolean("email_verified").default(false),
|
|
121
|
+
image: text("image"),
|
|
122
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
123
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
124
|
+
});
|
|
125
|
+
var sessions = pgTable("sessions", {
|
|
126
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
127
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
128
|
+
token: text("token").notNull().unique(),
|
|
129
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
130
|
+
ipAddress: text("ip_address"),
|
|
131
|
+
userAgent: text("user_agent"),
|
|
132
|
+
revokedAt: timestamp("revoked_at"),
|
|
133
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
134
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
135
|
+
}, (table) => [
|
|
136
|
+
index("sessions_user_id_idx").on(table.userId),
|
|
137
|
+
index("sessions_revoked_at_idx").on(table.revokedAt).where(sql`${table.revokedAt} IS NOT NULL`)
|
|
138
|
+
]);
|
|
139
|
+
var accounts = pgTable("accounts", {
|
|
140
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
141
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
142
|
+
accountId: text("account_id").notNull(),
|
|
143
|
+
providerId: text("provider_id").notNull(),
|
|
144
|
+
accessToken: text("access_token"),
|
|
145
|
+
refreshToken: text("refresh_token"),
|
|
146
|
+
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
|
147
|
+
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
|
148
|
+
scope: text("scope"),
|
|
149
|
+
password: text("password"),
|
|
150
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
151
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
152
|
+
}, (table) => [
|
|
153
|
+
index("accounts_user_id_idx").on(table.userId),
|
|
154
|
+
uniqueIndex("accounts_provider_account_idx").on(table.providerId, table.accountId)
|
|
155
|
+
]);
|
|
156
|
+
var verifications = pgTable("verifications", {
|
|
157
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
158
|
+
identifier: text("identifier").notNull(),
|
|
159
|
+
value: text("value").notNull(),
|
|
160
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
161
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
162
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
163
|
+
}, (table) => [
|
|
164
|
+
index("verifications_identifier_idx").on(table.identifier)
|
|
165
|
+
]);
|
|
166
|
+
var folders = pgTable("folders", {
|
|
167
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
168
|
+
ownerId: uuid("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
169
|
+
workspaceId: text("workspace_id"),
|
|
170
|
+
parentId: uuid("parent_id").references(() => folders.id, {
|
|
171
|
+
onDelete: "set null"
|
|
172
|
+
}),
|
|
173
|
+
categoryId: uuid("category_id").references(() => categories.id, {
|
|
174
|
+
onDelete: "set null"
|
|
175
|
+
}),
|
|
176
|
+
name: text("name").notNull(),
|
|
177
|
+
order: integer("order").notNull().default(0),
|
|
178
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
179
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
180
|
+
}, (table) => [
|
|
181
|
+
index("folders_owner_id_idx").on(table.ownerId),
|
|
182
|
+
index("folders_parent_id_idx").on(table.parentId),
|
|
183
|
+
index("folders_category_id_idx").on(table.categoryId)
|
|
184
|
+
]);
|
|
185
|
+
var folderRelations = relations(folders, ({ one, many }) => ({
|
|
186
|
+
owner: one(users, { fields: [folders.ownerId], references: [users.id] }),
|
|
187
|
+
parent: one(folders, {
|
|
188
|
+
fields: [folders.parentId],
|
|
189
|
+
references: [folders.id],
|
|
190
|
+
relationName: "folderParent"
|
|
191
|
+
}),
|
|
192
|
+
category: one(categories, {
|
|
193
|
+
fields: [folders.categoryId],
|
|
194
|
+
references: [categories.id]
|
|
195
|
+
}),
|
|
196
|
+
children: many(folders, { relationName: "folderParent" }),
|
|
197
|
+
documents: many(documents)
|
|
198
|
+
}));
|
|
199
|
+
var documents = pgTable("documents", {
|
|
200
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
201
|
+
ownerId: uuid("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
202
|
+
workspaceId: text("workspace_id"),
|
|
203
|
+
folderId: uuid("folder_id").references(() => folders.id, {
|
|
204
|
+
onDelete: "set null"
|
|
205
|
+
}),
|
|
206
|
+
categoryId: uuid("category_id").references(() => categories.id, {
|
|
207
|
+
onDelete: "set null"
|
|
208
|
+
}),
|
|
209
|
+
title: text("title").notNull().default("Untitled"),
|
|
210
|
+
content: text("content").default(""),
|
|
211
|
+
contentJson: jsonb("content_json"),
|
|
212
|
+
metadata: jsonb("metadata"),
|
|
213
|
+
visibility: documentVisibilityEnum("visibility").notNull().default("private"),
|
|
214
|
+
contentHash: text("content_hash"),
|
|
215
|
+
lastSignificantHash: text("last_significant_hash"),
|
|
216
|
+
lastSignificantUpdateAt: timestamp("last_significant_update_at"),
|
|
217
|
+
pendingMinorChanges: boolean("pending_minor_changes").default(false).notNull(),
|
|
218
|
+
metadataChangedAt: timestamp("metadata_changed_at"),
|
|
219
|
+
searchVector: tsvector("search_vector").generatedAlwaysAs(sql`to_tsvector('english', left(COALESCE(title, '') || ' ' || regexp_replace(COALESCE(content, ''), 'data:[^[:space:])>]+', ' ', 'g'), 200000))`),
|
|
220
|
+
searchVectorSimple: tsvector("search_vector_simple").generatedAlwaysAs(sql`to_tsvector('simple', left(COALESCE(title, '') || ' ' || regexp_replace(COALESCE(content, ''), 'data:[^[:space:])>]+', ' ', 'g'), 200000))`),
|
|
221
|
+
embeddingStatus: embeddingStatusEnum("embedding_status").notNull().default("pending"),
|
|
222
|
+
activeEmbeddingGeneration: uuid("active_embedding_generation"),
|
|
223
|
+
pendingEmbeddingGeneration: uuid("pending_embedding_generation"),
|
|
224
|
+
embeddingProfile: text("embedding_profile"),
|
|
225
|
+
embeddingErrorCode: text("embedding_error_code"),
|
|
226
|
+
embeddingUpdatedAt: timestamp("embedding_updated_at"),
|
|
227
|
+
deletedAt: timestamp("deleted_at"),
|
|
228
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
229
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
230
|
+
}, (table) => [
|
|
231
|
+
index("documents_owner_id_idx").on(table.ownerId),
|
|
232
|
+
index("documents_folder_id_idx").on(table.folderId),
|
|
233
|
+
index("documents_category_id_idx").on(table.categoryId),
|
|
234
|
+
index("documents_created_at_idx").on(table.createdAt),
|
|
235
|
+
index("idx_documents_search_vector").using("gin", table.searchVector),
|
|
236
|
+
index("idx_documents_search_vector_simple").using("gin", table.searchVectorSimple),
|
|
237
|
+
index("documents_embedding_status_idx").on(table.embeddingStatus),
|
|
238
|
+
index("documents_workspace_deleted_at_idx").on(table.workspaceId, table.deletedAt),
|
|
239
|
+
index("idx_documents_title_trgm").using("gin", sql`${table.title} gin_trgm_ops`)
|
|
240
|
+
]);
|
|
241
|
+
var documentRelations = relations(documents, ({ one, many }) => ({
|
|
242
|
+
owner: one(users, { fields: [documents.ownerId], references: [users.id] }),
|
|
243
|
+
folder: one(folders, {
|
|
244
|
+
fields: [documents.folderId],
|
|
245
|
+
references: [folders.id]
|
|
246
|
+
}),
|
|
247
|
+
category: one(categories, {
|
|
248
|
+
fields: [documents.categoryId],
|
|
249
|
+
references: [categories.id]
|
|
250
|
+
}),
|
|
251
|
+
tags: many(documentTags),
|
|
252
|
+
attachments: many(attachments),
|
|
253
|
+
versions: many(versions)
|
|
254
|
+
}));
|
|
255
|
+
var documentPipelineRuns = pgTable("document_pipeline_runs", {
|
|
256
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
257
|
+
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
|
258
|
+
ownerId: uuid("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
259
|
+
workspaceId: text("workspace_id"),
|
|
260
|
+
generationId: uuid("generation_id").notNull(),
|
|
261
|
+
revision: text("revision").notNull(),
|
|
262
|
+
source: text("source").notNull(),
|
|
263
|
+
status: pipelineStatusEnum("status").notNull().default("pending"),
|
|
264
|
+
prepareStatus: pipelineStatusEnum("prepare_status").notNull().default("pending"),
|
|
265
|
+
embedStatus: pipelineStatusEnum("embed_status").notNull().default("pending"),
|
|
266
|
+
graphStatus: pipelineStatusEnum("graph_status").notNull().default("pending"),
|
|
267
|
+
summarizeStatus: pipelineStatusEnum("summarize_status").notNull().default("pending"),
|
|
268
|
+
finalizeStatus: pipelineStatusEnum("finalize_status").notNull().default("pending"),
|
|
269
|
+
totalBatches: integer("total_batches").notNull().default(0),
|
|
270
|
+
completedBatches: integer("completed_batches").notNull().default(0),
|
|
271
|
+
failedBatches: integer("failed_batches").notNull().default(0),
|
|
272
|
+
errorCode: text("error_code"),
|
|
273
|
+
attempts: integer("attempts").notNull().default(0),
|
|
274
|
+
requestedAt: timestamp("requested_at").defaultNow().notNull(),
|
|
275
|
+
startedAt: timestamp("started_at"),
|
|
276
|
+
completedAt: timestamp("completed_at"),
|
|
277
|
+
heartbeatAt: timestamp("heartbeat_at"),
|
|
278
|
+
availableAt: timestamp("available_at"),
|
|
279
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
280
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
281
|
+
}, (table) => [
|
|
282
|
+
uniqueIndex("document_pipeline_runs_document_generation_idx").on(table.documentId, table.generationId),
|
|
283
|
+
index("document_pipeline_runs_owner_status_updated_idx").on(table.ownerId, table.status, table.updatedAt)
|
|
284
|
+
]);
|
|
285
|
+
var documentPipelineBatches = pgTable("document_pipeline_batches", {
|
|
286
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
287
|
+
workspaceId: text("workspace_id"),
|
|
288
|
+
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
|
289
|
+
generationId: uuid("generation_id").notNull(),
|
|
290
|
+
batchIndex: integer("batch_index").notNull(),
|
|
291
|
+
stage: pipelineStageEnum("stage").notNull().default("embed"),
|
|
292
|
+
chunkStart: integer("chunk_start").notNull(),
|
|
293
|
+
chunkEnd: integer("chunk_end").notNull(),
|
|
294
|
+
status: pipelineStatusEnum("status").notNull().default("pending"),
|
|
295
|
+
attempts: integer("attempts").notNull().default(0),
|
|
296
|
+
embeddingProfile: text("embedding_profile"),
|
|
297
|
+
errorCode: text("error_code"),
|
|
298
|
+
availableAt: timestamp("available_at"),
|
|
299
|
+
startedAt: timestamp("started_at"),
|
|
300
|
+
completedAt: timestamp("completed_at"),
|
|
301
|
+
heartbeatAt: timestamp("heartbeat_at"),
|
|
302
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
303
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
304
|
+
}, (table) => [
|
|
305
|
+
uniqueIndex("document_pipeline_batches_generation_index_idx").on(table.generationId, table.batchIndex),
|
|
306
|
+
index("document_pipeline_batches_stage_status_available_idx").on(table.stage, table.status, table.availableAt),
|
|
307
|
+
index("document_pipeline_batches_document_id_idx").on(table.documentId)
|
|
308
|
+
]);
|
|
309
|
+
var tags = pgTable("tags", {
|
|
310
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
311
|
+
ownerId: uuid("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
312
|
+
workspaceId: text("workspace_id"),
|
|
313
|
+
name: text("name").notNull(),
|
|
314
|
+
color: text("color"),
|
|
315
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
316
|
+
}, (table) => [
|
|
317
|
+
index("tags_owner_id_idx").on(table.ownerId),
|
|
318
|
+
uniqueIndex("tags_owner_name_idx").on(table.ownerId, table.name)
|
|
319
|
+
]);
|
|
320
|
+
var tagRelations = relations(tags, ({ many }) => ({
|
|
321
|
+
documents: many(documentTags)
|
|
322
|
+
}));
|
|
323
|
+
var categories = pgTable("categories", {
|
|
324
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
325
|
+
ownerId: uuid("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
326
|
+
workspaceId: text("workspace_id"),
|
|
327
|
+
name: text("name").notNull(),
|
|
328
|
+
order: integer("order").notNull().default(0),
|
|
329
|
+
apiMode: text("api_mode").notNull().default("unavailable"),
|
|
330
|
+
apiPermissionRead: boolean("api_permission_read").notNull().default(false),
|
|
331
|
+
apiPermissionEdit: boolean("api_permission_edit").notNull().default(false),
|
|
332
|
+
apiPermissionWrite: boolean("api_permission_write").notNull().default(false),
|
|
333
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
334
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
335
|
+
}, (table) => [
|
|
336
|
+
index("categories_owner_id_idx").on(table.ownerId),
|
|
337
|
+
index("categories_api_mode_idx").on(table.apiMode)
|
|
338
|
+
]);
|
|
339
|
+
var categoryRelations = relations(categories, ({ one, many }) => ({
|
|
340
|
+
owner: one(users, { fields: [categories.ownerId], references: [users.id] }),
|
|
341
|
+
folders: many(folders),
|
|
342
|
+
documents: many(documents)
|
|
343
|
+
}));
|
|
344
|
+
var documentTags = pgTable("document_tags", {
|
|
345
|
+
workspaceId: text("workspace_id"),
|
|
346
|
+
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
|
347
|
+
tagId: uuid("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" })
|
|
348
|
+
}, (table) => [
|
|
349
|
+
uniqueIndex("document_tags_unique_idx").on(table.documentId, table.tagId)
|
|
350
|
+
]);
|
|
351
|
+
var documentTagRelations = relations(documentTags, ({ one }) => ({
|
|
352
|
+
document: one(documents, {
|
|
353
|
+
fields: [documentTags.documentId],
|
|
354
|
+
references: [documents.id]
|
|
355
|
+
}),
|
|
356
|
+
tag: one(tags, { fields: [documentTags.tagId], references: [tags.id] })
|
|
357
|
+
}));
|
|
358
|
+
var shareLinks = pgTable("share_links", {
|
|
359
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
360
|
+
documentId: uuid("document_id").references(() => documents.id, {
|
|
361
|
+
onDelete: "cascade"
|
|
362
|
+
}),
|
|
363
|
+
folderId: uuid("folder_id").references(() => folders.id, {
|
|
364
|
+
onDelete: "cascade"
|
|
365
|
+
}),
|
|
366
|
+
categoryId: uuid("category_id").references(() => categories.id, {
|
|
367
|
+
onDelete: "cascade"
|
|
368
|
+
}),
|
|
369
|
+
token: text("token").notNull().unique(),
|
|
370
|
+
passwordHash: text("password_hash"),
|
|
371
|
+
role: shareRoleEnum("role").notNull().default("viewer"),
|
|
372
|
+
expiresAt: timestamp("expires_at"),
|
|
373
|
+
createdBy: uuid("created_by").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
374
|
+
workspaceId: text("workspace_id"),
|
|
375
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
376
|
+
}, (table) => [
|
|
377
|
+
index("share_links_token_idx").on(table.token),
|
|
378
|
+
index("share_links_document_id_idx").on(table.documentId),
|
|
379
|
+
index("share_links_folder_id_idx").on(table.folderId),
|
|
380
|
+
index("share_links_category_id_idx").on(table.categoryId),
|
|
381
|
+
check("share_links_exactly_one_target_check", sql`num_nonnulls(${table.documentId}, ${table.folderId}, ${table.categoryId}) = 1`)
|
|
382
|
+
]);
|
|
383
|
+
var shareLinkRelations = relations(shareLinks, ({ one, many }) => ({
|
|
384
|
+
document: one(documents, {
|
|
385
|
+
fields: [shareLinks.documentId],
|
|
386
|
+
references: [documents.id]
|
|
387
|
+
}),
|
|
388
|
+
folder: one(folders, {
|
|
389
|
+
fields: [shareLinks.folderId],
|
|
390
|
+
references: [folders.id]
|
|
391
|
+
}),
|
|
392
|
+
category: one(categories, {
|
|
393
|
+
fields: [shareLinks.categoryId],
|
|
394
|
+
references: [categories.id]
|
|
395
|
+
}),
|
|
396
|
+
creator: one(users, {
|
|
397
|
+
fields: [shareLinks.createdBy],
|
|
398
|
+
references: [users.id]
|
|
399
|
+
}),
|
|
400
|
+
guestAccess: many(guestAccess)
|
|
401
|
+
}));
|
|
402
|
+
var guestAccess = pgTable("guest_access", {
|
|
403
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
404
|
+
shareLinkId: uuid("share_link_id").notNull().references(() => shareLinks.id, { onDelete: "cascade" }),
|
|
405
|
+
workspaceId: text("workspace_id"),
|
|
406
|
+
guestEmail: text("guest_email").notNull(),
|
|
407
|
+
grantedAt: timestamp("granted_at").defaultNow().notNull()
|
|
408
|
+
}, (table) => [index("guest_access_share_link_idx").on(table.shareLinkId)]);
|
|
409
|
+
var guestAccessRelations = relations(guestAccess, ({ one }) => ({
|
|
410
|
+
shareLink: one(shareLinks, {
|
|
411
|
+
fields: [guestAccess.shareLinkId],
|
|
412
|
+
references: [shareLinks.id]
|
|
413
|
+
})
|
|
414
|
+
}));
|
|
415
|
+
var attachments = pgTable("attachments", {
|
|
416
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
417
|
+
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
|
418
|
+
workspaceId: text("workspace_id"),
|
|
419
|
+
filename: text("filename").notNull(),
|
|
420
|
+
mimeType: text("mime_type").notNull(),
|
|
421
|
+
size: bigint("size", { mode: "number" }).notNull(),
|
|
422
|
+
storageKey: text("storage_key").notNull(),
|
|
423
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
424
|
+
}, (table) => [index("attachments_document_id_idx").on(table.documentId)]);
|
|
425
|
+
var attachmentRelations = relations(attachments, ({ one }) => ({
|
|
426
|
+
document: one(documents, {
|
|
427
|
+
fields: [attachments.documentId],
|
|
428
|
+
references: [documents.id]
|
|
429
|
+
})
|
|
430
|
+
}));
|
|
431
|
+
var versions = pgTable("versions", {
|
|
432
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
433
|
+
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
|
434
|
+
workspaceId: text("workspace_id"),
|
|
435
|
+
content: text("content").notNull(),
|
|
436
|
+
contentJson: jsonb("content_json"),
|
|
437
|
+
createdBy: uuid("created_by").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
438
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
439
|
+
label: text("label"),
|
|
440
|
+
description: text("description"),
|
|
441
|
+
isSnapshot: boolean("is_snapshot").default(false),
|
|
442
|
+
restoredFrom: uuid("restored_from")
|
|
443
|
+
}, (table) => [
|
|
444
|
+
index("versions_document_id_idx").on(table.documentId),
|
|
445
|
+
index("versions_created_at_idx").on(table.createdAt),
|
|
446
|
+
index("versions_is_snapshot_idx").on(table.isSnapshot)
|
|
447
|
+
]);
|
|
448
|
+
var documentEmbeddings = pgTable("document_embeddings", {
|
|
449
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
450
|
+
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
|
451
|
+
workspaceId: text("workspace_id"),
|
|
452
|
+
chunkIndex: bigint("chunk_index", { mode: "number" }).notNull(),
|
|
453
|
+
chunkText: text("chunk_text").notNull(),
|
|
454
|
+
chunkHash: text("chunk_hash"),
|
|
455
|
+
charStart: integer("char_start").notNull().default(0),
|
|
456
|
+
charEnd: integer("char_end").notNull().default(0),
|
|
457
|
+
embedding: vector("embedding", { dimensions: 1024 }),
|
|
458
|
+
embeddingModel: text("embedding_model").notNull().default(""),
|
|
459
|
+
generationId: uuid("generation_id").notNull(),
|
|
460
|
+
embeddingDimensions: integer("embedding_dimensions").notNull().default(1024),
|
|
461
|
+
embeddingProfile: text("embedding_profile").notNull().default("legacy"),
|
|
462
|
+
isValid: boolean("is_valid").notNull().default(false),
|
|
463
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
464
|
+
}, (table) => [
|
|
465
|
+
index("document_embeddings_doc_id_idx").on(table.documentId),
|
|
466
|
+
uniqueIndex("document_embeddings_doc_chunk_idx").on(table.documentId, table.generationId, table.chunkIndex),
|
|
467
|
+
index("document_embeddings_generation_valid_idx").on(table.documentId, table.generationId, table.isValid),
|
|
468
|
+
index("idx_document_embeddings_embedding_model").on(table.embeddingModel),
|
|
469
|
+
index("idx_document_embeddings_hnsw").using("hnsw", sql`${table.embedding} vector_cosine_ops`),
|
|
470
|
+
index("idx_document_embeddings_diskann").using("diskann", sql`${table.embedding} vector_cosine_ops`)
|
|
471
|
+
]);
|
|
472
|
+
var documentEmbeddingRelations = relations(documentEmbeddings, ({ one }) => ({
|
|
473
|
+
document: one(documents, {
|
|
474
|
+
fields: [documentEmbeddings.documentId],
|
|
475
|
+
references: [documents.id]
|
|
476
|
+
})
|
|
477
|
+
}));
|
|
478
|
+
var apiKeys = pgTable("api_keys", {
|
|
479
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
480
|
+
ownerId: uuid("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
481
|
+
workspaceId: text("workspace_id"),
|
|
482
|
+
name: text("name").notNull(),
|
|
483
|
+
keyHash: text("key_hash").notNull().unique(),
|
|
484
|
+
prefix: text("prefix").notNull(),
|
|
485
|
+
encryptedKey: text("encrypted_key"),
|
|
486
|
+
scopes: jsonb("scopes").notNull().default("[]"),
|
|
487
|
+
lastUsedAt: timestamp("last_used_at"),
|
|
488
|
+
expiresAt: timestamp("expires_at"),
|
|
489
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
490
|
+
}, (table) => [
|
|
491
|
+
index("idx_api_keys_owner").on(table.ownerId),
|
|
492
|
+
index("idx_api_keys_prefix").on(table.prefix)
|
|
493
|
+
]);
|
|
494
|
+
var apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
|
495
|
+
owner: one(users, { fields: [apiKeys.ownerId], references: [users.id] })
|
|
496
|
+
}));
|
|
497
|
+
var auditLog = pgTable("audit_log", {
|
|
498
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
499
|
+
actorId: uuid("actor_id").notNull(),
|
|
500
|
+
workspaceId: text("workspace_id"),
|
|
501
|
+
action: text("action").notNull(),
|
|
502
|
+
resourceType: text("resource_type").notNull(),
|
|
503
|
+
resourceId: uuid("resource_id"),
|
|
504
|
+
details: jsonb("details").notNull().default("{}"),
|
|
505
|
+
ipAddress: text("ip_address"),
|
|
506
|
+
userAgent: text("user_agent"),
|
|
507
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
508
|
+
}, (table) => [
|
|
509
|
+
index("idx_audit_log_actor").on(table.actorId),
|
|
510
|
+
index("idx_audit_log_resource").on(table.resourceType, table.resourceId),
|
|
511
|
+
index("idx_audit_log_created").on(table.createdAt)
|
|
512
|
+
]);
|
|
513
|
+
var lifecycleOperations = pgTable("lifecycle_operations", {
|
|
514
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
515
|
+
actorUserId: uuid("actor_user_id").references(() => users.id, { onDelete: "set null" }),
|
|
516
|
+
actorSubjectHash: text("actor_subject_hash").notNull(),
|
|
517
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
518
|
+
operationKind: lifecycleOperationKindEnum("operation_kind").notNull(),
|
|
519
|
+
status: lifecycleOperationStatusEnum("status").notNull().default("pending"),
|
|
520
|
+
leaseOwner: text("lease_owner"),
|
|
521
|
+
leaseExpiresAt: timestamp("lease_expires_at"),
|
|
522
|
+
fenceTokenHash: text("fence_token_hash"),
|
|
523
|
+
completedSteps: jsonb("completed_steps").notNull().default("[]"),
|
|
524
|
+
terminalResult: jsonb("terminal_result"),
|
|
525
|
+
safeErrorCode: text("safe_error_code"),
|
|
526
|
+
attemptCount: integer("attempt_count").notNull().default(0),
|
|
527
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
528
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
529
|
+
completedAt: timestamp("completed_at")
|
|
530
|
+
}, (table) => [
|
|
531
|
+
uniqueIndex("lifecycle_operations_actor_idempotency_idx").on(table.actorUserId, table.idempotencyKey),
|
|
532
|
+
index("lifecycle_operations_status_lease_idx").on(table.status, table.leaseExpiresAt),
|
|
533
|
+
index("lifecycle_operations_actor_idx").on(table.actorUserId),
|
|
534
|
+
index("lifecycle_operations_retryable_idx").on(table.status).where(sql`${table.status} = 'retryable'`),
|
|
535
|
+
check("lifecycle_operations_actor_subject_hash", sql`${table.actorSubjectHash} ~ '^[a-f0-9]{64}$'`)
|
|
536
|
+
]);
|
|
537
|
+
var documentCreateOperations = pgTable("document_create_operations", {
|
|
538
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
539
|
+
workspaceId: text("workspace_id").notNull(),
|
|
540
|
+
actorUserId: uuid("actor_user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
541
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
542
|
+
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
|
543
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
544
|
+
}, (table) => [
|
|
545
|
+
uniqueIndex("document_create_operations_workspace_actor_key_idx").on(table.workspaceId, table.actorUserId, table.idempotencyKey),
|
|
546
|
+
uniqueIndex("document_create_operations_document_idx").on(table.documentId)
|
|
547
|
+
]);
|
|
548
|
+
var versionRelations = relations(versions, ({ one }) => ({
|
|
549
|
+
document: one(documents, {
|
|
550
|
+
fields: [versions.documentId],
|
|
551
|
+
references: [documents.id]
|
|
552
|
+
}),
|
|
553
|
+
creator: one(users, {
|
|
554
|
+
fields: [versions.createdBy],
|
|
555
|
+
references: [users.id]
|
|
556
|
+
})
|
|
557
|
+
}));
|
|
558
|
+
|
|
559
|
+
// ../db/src/with-tenant.ts
|
|
560
|
+
import { sql as sql2 } from "drizzle-orm";
|
|
561
|
+
|
|
562
|
+
// ../db/src/client.ts
|
|
563
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
564
|
+
import postgres from "postgres";
|
|
565
|
+
var databaseUrl = process.env.DATABASE_URL || "postgresql://hiai_app:changeme@localhost:5437/hiai_docs";
|
|
566
|
+
var client = postgres(databaseUrl, {
|
|
567
|
+
max: 20,
|
|
568
|
+
idle_timeout: 30,
|
|
569
|
+
connect_timeout: 10
|
|
570
|
+
});
|
|
571
|
+
var db = drizzle(client, { schema: exports_schema });
|
|
572
|
+
|
|
573
|
+
// ../db/src/with-tenant.ts
|
|
574
|
+
var ZERO_UUID = "00000000-0000-0000-0000-000000000000";
|
|
575
|
+
function adminTenantContext(ownerId) {
|
|
576
|
+
const resolved = ownerId ?? process.env.OWNER_ID;
|
|
577
|
+
if (!resolved) {
|
|
578
|
+
console.warn("[hiai-docs/db] adminTenantContext: ownerId not provided and OWNER_ID env not set, using empty string");
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
userId: resolved ?? "",
|
|
582
|
+
role: "admin"
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
async function withTenant(ctx, fn) {
|
|
586
|
+
return db.transaction(async (tx) => {
|
|
587
|
+
await tx.execute(sql2`SELECT set_config('app.current_user_id', ${ctx.userId}, true)`);
|
|
588
|
+
await tx.execute(sql2`SELECT set_config('app.current_user_role', ${ctx.role}, true)`);
|
|
589
|
+
await tx.execute(sql2`SELECT set_config('app.current_workspace_id', ${ctx.workspaceId ?? ""}, true)`);
|
|
590
|
+
return fn(tx);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ../../backend/src/lib/api-keys.ts
|
|
595
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
596
|
+
|
|
597
|
+
// ../../backend/src/lib/api-key-encryption.ts
|
|
598
|
+
var VERSION = "v1";
|
|
599
|
+
var AAD = new TextEncoder().encode("hiai-docs:category-api-key:v1");
|
|
600
|
+
function bytesToBase64(bytes) {
|
|
601
|
+
let binary = "";
|
|
602
|
+
for (const byte of bytes)
|
|
603
|
+
binary += String.fromCharCode(byte);
|
|
604
|
+
return btoa(binary);
|
|
605
|
+
}
|
|
606
|
+
function base64ToBytes(value) {
|
|
607
|
+
const binary = atob(value);
|
|
608
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
609
|
+
for (let index2 = 0;index2 < binary.length; index2 += 1) {
|
|
610
|
+
bytes[index2] = binary.charCodeAt(index2);
|
|
611
|
+
}
|
|
612
|
+
return bytes;
|
|
613
|
+
}
|
|
614
|
+
async function importEncryptionKey(secret) {
|
|
615
|
+
if (secret.length < 32) {
|
|
616
|
+
throw new Error("API_KEY_ENCRYPTION_SECRET must be at least 32 characters");
|
|
617
|
+
}
|
|
618
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
619
|
+
return crypto.subtle.importKey("raw", digest, "AES-GCM", false, [
|
|
620
|
+
"encrypt",
|
|
621
|
+
"decrypt"
|
|
622
|
+
]);
|
|
623
|
+
}
|
|
624
|
+
async function encryptApiKey(rawKey, secret) {
|
|
625
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
626
|
+
const key = await importEncryptionKey(secret);
|
|
627
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: AAD }, key, new TextEncoder().encode(rawKey));
|
|
628
|
+
return `${VERSION}.${bytesToBase64(iv)}.${bytesToBase64(new Uint8Array(ciphertext))}`;
|
|
629
|
+
}
|
|
630
|
+
async function decryptApiKey(payload, secret) {
|
|
631
|
+
const [version, encodedIv, encodedCiphertext] = payload.split(".");
|
|
632
|
+
if (version !== VERSION || !encodedIv || !encodedCiphertext) {
|
|
633
|
+
throw new Error("Unsupported encrypted API key payload");
|
|
634
|
+
}
|
|
635
|
+
const key = await importEncryptionKey(secret);
|
|
636
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
637
|
+
name: "AES-GCM",
|
|
638
|
+
iv: base64ToBytes(encodedIv),
|
|
639
|
+
additionalData: AAD
|
|
640
|
+
}, key, base64ToBytes(encodedCiphertext));
|
|
641
|
+
return new TextDecoder().decode(plaintext);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ../../backend/src/lib/api-keys.ts
|
|
645
|
+
var API_KEY_ADMIN_TENANT = adminTenantContext(ZERO_UUID);
|
|
646
|
+
var GLOBAL_API_SCOPE = "global";
|
|
647
|
+
var CATEGORY_SCOPE_PATTERN = /^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)$/;
|
|
648
|
+
function parseApiKeyScopes(value) {
|
|
649
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
650
|
+
return null;
|
|
651
|
+
const scopes = [];
|
|
652
|
+
const seen = new Set;
|
|
653
|
+
for (const candidate of value) {
|
|
654
|
+
if (typeof candidate !== "string" || seen.has(candidate))
|
|
655
|
+
return null;
|
|
656
|
+
if (candidate !== GLOBAL_API_SCOPE && !CATEGORY_SCOPE_PATTERN.test(candidate)) {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
seen.add(candidate);
|
|
660
|
+
scopes.push(candidate);
|
|
661
|
+
}
|
|
662
|
+
return scopes;
|
|
663
|
+
}
|
|
664
|
+
function buildCategoryApiKeyScopes(categoryId, permissions) {
|
|
665
|
+
return ["read", "edit", "write"].filter((permission) => permissions[permission]).map((permission) => `category:${categoryId}:${permission}`);
|
|
666
|
+
}
|
|
667
|
+
function categoryIdFromApiKeyScopes(scopes) {
|
|
668
|
+
const parsed = parseApiKeyScopes(scopes);
|
|
669
|
+
if (!parsed)
|
|
670
|
+
return null;
|
|
671
|
+
for (const scope of parsed) {
|
|
672
|
+
const match = CATEGORY_SCOPE_PATTERN.exec(scope);
|
|
673
|
+
if (match?.[1])
|
|
674
|
+
return match[1];
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
function hashKey(key) {
|
|
679
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
680
|
+
hasher.update(key);
|
|
681
|
+
return hasher.digest("hex");
|
|
682
|
+
}
|
|
683
|
+
async function createApiKey(ownerId, name, scopes, expiresAt, options) {
|
|
684
|
+
const rawKey = crypto.randomUUID();
|
|
685
|
+
const keyHash = hashKey(rawKey);
|
|
686
|
+
const prefix = rawKey.slice(0, 8);
|
|
687
|
+
const encryptedKey = options?.encryptionSecret ? await encryptApiKey(rawKey, options.encryptionSecret) : null;
|
|
688
|
+
const [row] = await withTenant({ userId: ownerId, role: "user" }, (tx) => tx.insert(apiKeys).values({
|
|
689
|
+
ownerId,
|
|
690
|
+
name,
|
|
691
|
+
keyHash,
|
|
692
|
+
prefix,
|
|
693
|
+
scopes,
|
|
694
|
+
expiresAt: expiresAt ?? null,
|
|
695
|
+
encryptedKey
|
|
696
|
+
}).returning({ id: apiKeys.id }));
|
|
697
|
+
if (!row) {
|
|
698
|
+
throw new Error("Failed to create API key");
|
|
699
|
+
}
|
|
700
|
+
return { key: rawKey, prefix, id: row.id };
|
|
701
|
+
}
|
|
702
|
+
async function revealCategoryApiKey(id, ownerId, encryptionSecret) {
|
|
703
|
+
const [row] = await withTenant({ userId: ownerId, role: "user" }, (tx) => tx.select({
|
|
704
|
+
encryptedKey: apiKeys.encryptedKey,
|
|
705
|
+
scopes: apiKeys.scopes
|
|
706
|
+
}).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.ownerId, ownerId))).limit(1));
|
|
707
|
+
if (!row?.encryptedKey)
|
|
708
|
+
return null;
|
|
709
|
+
const scopes = row.scopes ?? [];
|
|
710
|
+
if (!categoryIdFromApiKeyScopes(scopes))
|
|
711
|
+
return null;
|
|
712
|
+
return decryptApiKey(row.encryptedKey, encryptionSecret);
|
|
713
|
+
}
|
|
714
|
+
async function revokeApiKey(id, ownerId) {
|
|
715
|
+
const deleted = await withTenant({ userId: ownerId, role: "user" }, (tx) => tx.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.ownerId, ownerId))).returning({ id: apiKeys.id }));
|
|
716
|
+
return deleted.length > 0;
|
|
717
|
+
}
|
|
718
|
+
async function validateApiKey(key) {
|
|
719
|
+
const keyHash = hashKey(key);
|
|
720
|
+
const [row] = await withTenant(API_KEY_ADMIN_TENANT, (tx) => tx.select({
|
|
721
|
+
id: apiKeys.id,
|
|
722
|
+
ownerId: apiKeys.ownerId,
|
|
723
|
+
scopes: apiKeys.scopes,
|
|
724
|
+
expiresAt: apiKeys.expiresAt
|
|
725
|
+
}).from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).limit(1));
|
|
726
|
+
if (!row) {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
if (row.expiresAt && new Date(row.expiresAt) < new Date) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
const scopes = parseApiKeyScopes(row.scopes ?? []);
|
|
733
|
+
if (!scopes)
|
|
734
|
+
return null;
|
|
735
|
+
await withTenant(API_KEY_ADMIN_TENANT, (tx) => tx.update(apiKeys).set({ lastUsedAt: new Date }).where(eq(apiKeys.id, row.id)));
|
|
736
|
+
return {
|
|
737
|
+
id: row.id,
|
|
738
|
+
ownerId: row.ownerId,
|
|
739
|
+
scopes
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
export {
|
|
743
|
+
validateApiKey as verifyApiKey,
|
|
744
|
+
revokeApiKey,
|
|
745
|
+
revealCategoryApiKey,
|
|
746
|
+
createApiKey as issueApiKey,
|
|
747
|
+
buildCategoryApiKeyScopes,
|
|
748
|
+
GLOBAL_API_SCOPE
|
|
749
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hiai-gg/docsmint",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"browser": {
|
|
6
6
|
"./dist/backend-launcher.js": false,
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"types": "./dist/storage-quota.d.ts"
|
|
90
90
|
},
|
|
91
91
|
"./backend/lib/api-key-facade": {
|
|
92
|
-
"import": "./backend
|
|
92
|
+
"import": "./dist/backend-api-key-facade.js",
|
|
93
93
|
"types": "./backend/src/lib/api-key-facade.ts"
|
|
94
94
|
},
|
|
95
95
|
"./frontend/app-shell": {
|
|
@@ -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.9";
|
|
28
28
|
|
|
29
29
|
const program = new Command();
|
|
30
30
|
program
|