@hiai-gg/docsmint 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/src/lib/lifecycle-service.ts +334 -219
- package/dist/backend/index.js +214234 -0
- package/dist/backend-launcher.d.ts +55 -0
- package/dist/backend-launcher.js +136 -0
- package/dist/frontend/extension.d.ts +2 -1
- package/dist/frontend/shared-document.js +89 -54
- package/dist/lifecycle-persistent.d.ts +31 -0
- package/dist/lifecycle-persistent.js +33 -0
- package/dist/server-only-browser-entry.d.ts +0 -0
- package/dist/server-only-browser-entry.js +3 -0
- package/dist/storage-quota.d.ts +92 -0
- package/dist/storage-quota.js +104 -0
- package/dist/types.d.ts +7 -0
- package/dist/workspace.d.ts +4 -1
- package/dist/workspace.js +29 -8
- package/package.json +14 -3
- package/packages/cli/src/index.ts +1 -1
- package/packages/db/src/index.ts +11 -2
- package/packages/db/src/schema.ts +30 -2
- package/packages/db/src/with-tenant.ts +38 -1
- package/packages/mcp-server/src/index.ts +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Server-only, persistence-agnostic storage quota contract. */
|
|
2
|
+
export class MissingAttachmentStorageQuotaAdmissionError extends Error {
|
|
3
|
+
code = "ATTACHMENT_STORAGE_QUOTA_ADMISSION_MISSING";
|
|
4
|
+
constructor() {
|
|
5
|
+
super("Attachment storage quota admission is required when workspace tenancy is enabled");
|
|
6
|
+
this.name = "MissingAttachmentStorageQuotaAdmissionError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function requireAttachmentStorageQuotaAdmission(admission) {
|
|
10
|
+
if (!admission)
|
|
11
|
+
throw new MissingAttachmentStorageQuotaAdmissionError();
|
|
12
|
+
return admission;
|
|
13
|
+
}
|
|
14
|
+
export class StorageQuotaExceededError extends Error {
|
|
15
|
+
code = "STORAGE_QUOTA_EXCEEDED";
|
|
16
|
+
usageBytes;
|
|
17
|
+
limitBytes;
|
|
18
|
+
requestedBytes;
|
|
19
|
+
constructor(rejection) {
|
|
20
|
+
super("Storage quota exceeded");
|
|
21
|
+
this.name = "StorageQuotaExceededError";
|
|
22
|
+
this.usageBytes = rejection.usageBytes;
|
|
23
|
+
this.limitBytes = rejection.limitBytes;
|
|
24
|
+
this.requestedBytes = rejection.requestedBytes;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function assertContext(context) {
|
|
28
|
+
for (const [name, value] of [
|
|
29
|
+
["actorUserId", context.actorUserId],
|
|
30
|
+
["requestId", context.requestId],
|
|
31
|
+
["idempotencyKey", context.idempotencyKey],
|
|
32
|
+
]) {
|
|
33
|
+
if (!value.trim())
|
|
34
|
+
throw new TypeError(`${name} must not be empty`);
|
|
35
|
+
}
|
|
36
|
+
if (context.signal?.aborted) {
|
|
37
|
+
throw new DOMException("Storage quota operation aborted", "AbortError");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function assertPositiveBytes(bytes, name) {
|
|
41
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0) {
|
|
42
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function assertNonnegativeBytes(bytes, name) {
|
|
46
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0) {
|
|
47
|
+
throw new TypeError(`${name} must be a non-negative safe integer`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function assertReservation(result) {
|
|
51
|
+
assertReservationId(result.reservationId);
|
|
52
|
+
assertPositiveBytes(result.reservedBytes, "reservedBytes");
|
|
53
|
+
assertNonnegativeBytes(result.usageBytes, "usageBytes");
|
|
54
|
+
assertNonnegativeBytes(result.limitBytes, "limitBytes");
|
|
55
|
+
if (Number.isNaN(Date.parse(result.expiresAt))) {
|
|
56
|
+
throw new TypeError("expiresAt must be an ISO-compatible timestamp");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function assertRejection(result) {
|
|
60
|
+
assertNonnegativeBytes(result.usageBytes, "usageBytes");
|
|
61
|
+
assertNonnegativeBytes(result.limitBytes, "limitBytes");
|
|
62
|
+
assertPositiveBytes(result.requestedBytes, "requestedBytes");
|
|
63
|
+
}
|
|
64
|
+
function assertReservationId(value) {
|
|
65
|
+
if (!value.trim())
|
|
66
|
+
throw new TypeError("reservationId must not be empty");
|
|
67
|
+
}
|
|
68
|
+
export function createStorageQuotaService(adapter) {
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
async reserve(request) {
|
|
71
|
+
assertContext(request);
|
|
72
|
+
assertPositiveBytes(request.bytes, "bytes");
|
|
73
|
+
const result = await adapter.reserve(Object.freeze({ ...request }));
|
|
74
|
+
if (result.status === "rejected") {
|
|
75
|
+
assertRejection(result);
|
|
76
|
+
throw new StorageQuotaExceededError(result);
|
|
77
|
+
}
|
|
78
|
+
assertReservation(result);
|
|
79
|
+
return Object.freeze({ ...result });
|
|
80
|
+
},
|
|
81
|
+
async commit(request) {
|
|
82
|
+
assertContext(request);
|
|
83
|
+
assertReservationId(request.reservationId);
|
|
84
|
+
assertPositiveBytes(request.actualBytes, "actualBytes");
|
|
85
|
+
const result = await adapter.commit(Object.freeze({ ...request }));
|
|
86
|
+
if (result.status !== "committed" &&
|
|
87
|
+
result.status !== "already_committed") {
|
|
88
|
+
throw new TypeError("Storage quota adapter returned an invalid commit status");
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze({ ...result });
|
|
91
|
+
},
|
|
92
|
+
async release(request) {
|
|
93
|
+
assertContext(request);
|
|
94
|
+
assertReservationId(request.reservationId);
|
|
95
|
+
const result = await adapter.release(Object.freeze({ ...request }));
|
|
96
|
+
if (result.status !== "released" &&
|
|
97
|
+
result.status !== "already_released" &&
|
|
98
|
+
result.status !== "not_found") {
|
|
99
|
+
throw new TypeError("Storage quota adapter returned an invalid release status");
|
|
100
|
+
}
|
|
101
|
+
return Object.freeze({ ...result });
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -294,11 +294,18 @@ export interface DocsAttachmentPresignInput {
|
|
|
294
294
|
export interface DocsAttachmentPresignResponse {
|
|
295
295
|
url: string;
|
|
296
296
|
key: string;
|
|
297
|
+
/**
|
|
298
|
+
* Opaque server-issued reservation. Present only when workspace storage
|
|
299
|
+
* enforcement is enabled and required verbatim by confirmAttachment.
|
|
300
|
+
*/
|
|
301
|
+
quotaReservationId?: string;
|
|
297
302
|
maxSize: number;
|
|
298
303
|
expiresIn: number;
|
|
299
304
|
}
|
|
300
305
|
export interface DocsAttachmentConfirmInput extends DocsAttachmentPresignInput {
|
|
301
306
|
key: string;
|
|
307
|
+
/** The opaque reservation returned by presignAttachment when present. */
|
|
308
|
+
quotaReservationId?: string;
|
|
302
309
|
}
|
|
303
310
|
export type DocsApiKeyScope = "global" | `category:${string}:${"read" | "edit" | "write"}`;
|
|
304
311
|
export interface DocsApiKeyCreated {
|
package/dist/workspace.d.ts
CHANGED
|
@@ -15,7 +15,10 @@ export type WorkspaceAssertionOptions = Readonly<{
|
|
|
15
15
|
issuer: string;
|
|
16
16
|
nowSeconds?: number;
|
|
17
17
|
clockSkewSeconds?: number;
|
|
18
|
-
|
|
18
|
+
/** @deprecated Assertion lifetime is fixed at 60 seconds. */
|
|
19
|
+
maxTtlSeconds?: never;
|
|
19
20
|
}>;
|
|
21
|
+
export declare const DOCSMINT_WORKSPACE_ASSERTION_TTL_SECONDS = 60;
|
|
22
|
+
export declare const DOCSMINT_WORKSPACE_ASSERTION_CLOCK_SKEW_SECONDS = 5;
|
|
20
23
|
export declare function createDocsmintWorkspaceAssertion(context: DocsmintWorkspaceContext, secret: string): Promise<string>;
|
|
21
24
|
export declare function verifyDocsmintWorkspaceAssertion(assertion: string, options: WorkspaceAssertionOptions): Promise<DocsmintWorkspaceContext>;
|
package/dist/workspace.js
CHANGED
|
@@ -4,17 +4,22 @@ export const DOCSMINT_WORKSPACE_CONTEXT_HEADER = "x-docsmint-workspace-context";
|
|
|
4
4
|
export const EXTERNAL_TENANT_CONTEXT_HEADER = "x-hiai-tenant-context";
|
|
5
5
|
const encoder = new TextEncoder();
|
|
6
6
|
const decoder = new TextDecoder();
|
|
7
|
-
const
|
|
7
|
+
export const DOCSMINT_WORKSPACE_ASSERTION_TTL_SECONDS = 60;
|
|
8
|
+
export const DOCSMINT_WORKSPACE_ASSERTION_CLOCK_SKEW_SECONDS = 5;
|
|
8
9
|
function toBase64Url(bytes) {
|
|
9
10
|
let binary = "";
|
|
10
11
|
for (const byte of bytes)
|
|
11
12
|
binary += String.fromCharCode(byte);
|
|
12
|
-
return btoa(binary)
|
|
13
|
+
return btoa(binary)
|
|
14
|
+
.replaceAll("+", "-")
|
|
15
|
+
.replaceAll("/", "_")
|
|
16
|
+
.replace(/=+$/, "");
|
|
13
17
|
}
|
|
14
18
|
function fromBase64Url(value) {
|
|
15
19
|
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
16
20
|
throw new Error("Invalid base64url");
|
|
17
|
-
const padded = value.replaceAll("-", "+").replaceAll("_", "/") +
|
|
21
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/") +
|
|
22
|
+
"=".repeat((4 - (value.length % 4)) % 4);
|
|
18
23
|
const binary = atob(padded);
|
|
19
24
|
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
20
25
|
}
|
|
@@ -26,13 +31,20 @@ function assertContext(value) {
|
|
|
26
31
|
if (!value || typeof value !== "object")
|
|
27
32
|
throw new Error("Invalid workspace assertion payload");
|
|
28
33
|
const context = value;
|
|
29
|
-
if (typeof context.actorUserId !== "string" ||
|
|
34
|
+
if (typeof context.actorUserId !== "string" ||
|
|
35
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(context.actorUserId))
|
|
30
36
|
throw new Error("Invalid actorUserId");
|
|
31
|
-
if (typeof context.workspaceId !== "string" ||
|
|
37
|
+
if (typeof context.workspaceId !== "string" ||
|
|
38
|
+
!context.workspaceId.trim() ||
|
|
39
|
+
context.workspaceId.trim() !== context.workspaceId ||
|
|
40
|
+
context.workspaceId.length > 128)
|
|
32
41
|
throw new Error("Invalid workspaceId");
|
|
33
42
|
if (!["owner", "admin", "editor", "viewer"].includes(context.actorRole))
|
|
34
43
|
throw new Error("Invalid actorRole");
|
|
35
|
-
if (!Number.isFinite(context.issuedAt) ||
|
|
44
|
+
if (!Number.isFinite(context.issuedAt) ||
|
|
45
|
+
!Number.isFinite(context.expiresAt) ||
|
|
46
|
+
typeof context.issuer !== "string" ||
|
|
47
|
+
!context.issuer)
|
|
36
48
|
throw new Error("Invalid workspace assertion timestamps or issuer");
|
|
37
49
|
}
|
|
38
50
|
export async function createDocsmintWorkspaceAssertion(context, secret) {
|
|
@@ -59,8 +71,17 @@ export async function verifyDocsmintWorkspaceAssertion(assertion, options) {
|
|
|
59
71
|
if (context.issuer !== options.issuer)
|
|
60
72
|
throw new Error("Invalid workspace assertion issuer");
|
|
61
73
|
const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
62
|
-
const skew = options.clockSkewSeconds ??
|
|
63
|
-
if (
|
|
74
|
+
const skew = options.clockSkewSeconds ?? DOCSMINT_WORKSPACE_ASSERTION_CLOCK_SKEW_SECONDS;
|
|
75
|
+
if (!Number.isSafeInteger(skew) ||
|
|
76
|
+
skew < 0 ||
|
|
77
|
+
skew > DOCSMINT_WORKSPACE_ASSERTION_CLOCK_SKEW_SECONDS) {
|
|
78
|
+
throw new Error("Invalid workspace assertion clock skew");
|
|
79
|
+
}
|
|
80
|
+
if (context.issuedAt > now + skew ||
|
|
81
|
+
context.expiresAt <= now - skew ||
|
|
82
|
+
context.expiresAt <= context.issuedAt ||
|
|
83
|
+
context.expiresAt - context.issuedAt >
|
|
84
|
+
DOCSMINT_WORKSPACE_ASSERTION_TTL_SECONDS)
|
|
64
85
|
throw new Error("Invalid workspace assertion lifetime");
|
|
65
86
|
return Object.freeze({ ...context });
|
|
66
87
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hiai-gg/docsmint",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"browser": {
|
|
6
|
+
"./dist/backend-launcher.js": false,
|
|
6
7
|
"./dist/lifecycle.js": false,
|
|
8
|
+
"./dist/lifecycle-persistent.js": false,
|
|
9
|
+
"./dist/storage-quota.js": false,
|
|
7
10
|
"./dist/workspace.js": false
|
|
8
11
|
},
|
|
9
12
|
"license": "Apache-2.0",
|
|
@@ -61,6 +64,16 @@
|
|
|
61
64
|
"import": "./dist/workspace.js",
|
|
62
65
|
"types": "./dist/workspace.d.ts"
|
|
63
66
|
},
|
|
67
|
+
"./backend/launcher": {
|
|
68
|
+
"browser": "./dist/server-only-browser-entry.js",
|
|
69
|
+
"import": "./dist/backend-launcher.js",
|
|
70
|
+
"types": "./dist/backend-launcher.d.ts"
|
|
71
|
+
},
|
|
72
|
+
"./storage-quota": {
|
|
73
|
+
"browser": "./dist/server-only-browser-entry.js",
|
|
74
|
+
"import": "./dist/storage-quota.js",
|
|
75
|
+
"types": "./dist/storage-quota.d.ts"
|
|
76
|
+
},
|
|
64
77
|
"./frontend/dashboard": {
|
|
65
78
|
"import": "./dist/frontend/dashboard.js",
|
|
66
79
|
"types": "./dist/frontend/dashboard.d.ts"
|
|
@@ -154,12 +167,10 @@
|
|
|
154
167
|
"packages/db/src/client.ts",
|
|
155
168
|
"packages/db/src/schema.ts",
|
|
156
169
|
"packages/db/src/with-tenant.ts",
|
|
157
|
-
"packages/cli/src/index.ts",
|
|
158
170
|
"packages/cli/src/client.ts",
|
|
159
171
|
"packages/cli/src/config.ts",
|
|
160
172
|
"packages/cli/src/format.ts",
|
|
161
173
|
"packages/cli/src/commands",
|
|
162
|
-
"packages/mcp-server/src/index.ts",
|
|
163
174
|
"packages/mcp-server/src/client.ts",
|
|
164
175
|
"packages/mcp-server/src/types.ts",
|
|
165
176
|
"packages/mcp-server/src/tools",
|
|
@@ -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.3.
|
|
27
|
+
const VERSION = "0.3.6";
|
|
28
28
|
|
|
29
29
|
const program = new Command();
|
|
30
30
|
program
|
package/packages/db/src/index.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
export * from "./schema";
|
|
2
2
|
export { db, client } from "./client";
|
|
3
3
|
export type { Database } from "./client";
|
|
4
|
-
export {
|
|
5
|
-
|
|
4
|
+
export {
|
|
5
|
+
withTenant,
|
|
6
|
+
adminTenantContext,
|
|
7
|
+
shareGuestTenantContext,
|
|
8
|
+
createActorScopedTransactionExecutor,
|
|
9
|
+
} from "./with-tenant";
|
|
10
|
+
export type {
|
|
11
|
+
ActorScopedTransactionExecutor,
|
|
12
|
+
TenantContext,
|
|
13
|
+
TenantTransaction,
|
|
14
|
+
} from "./with-tenant";
|
|
@@ -698,7 +698,8 @@ export const lifecycleOperations = pgTable(
|
|
|
698
698
|
"lifecycle_operations",
|
|
699
699
|
{
|
|
700
700
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
701
|
-
|
|
701
|
+
actorUserId: uuid("actor_user_id").references(() => users.id, { onDelete: "set null" }),
|
|
702
|
+
actorSubjectHash: text("actor_subject_hash").notNull(),
|
|
702
703
|
idempotencyKey: text("idempotency_key").notNull(),
|
|
703
704
|
operationKind: lifecycleOperationKindEnum("operation_kind").notNull(),
|
|
704
705
|
status: lifecycleOperationStatusEnum("status").notNull().default("pending"),
|
|
@@ -717,7 +718,34 @@ export const lifecycleOperations = pgTable(
|
|
|
717
718
|
uniqueIndex("lifecycle_operations_actor_idempotency_idx").on(table.actorUserId, table.idempotencyKey),
|
|
718
719
|
index("lifecycle_operations_status_lease_idx").on(table.status, table.leaseExpiresAt),
|
|
719
720
|
index("lifecycle_operations_actor_idx").on(table.actorUserId),
|
|
720
|
-
|
|
721
|
+
index("lifecycle_operations_retryable_idx").on(table.status).where(sql`${table.status} = 'retryable'`),
|
|
722
|
+
check("lifecycle_operations_actor_subject_hash", sql`${table.actorSubjectHash} ~ '^[a-f0-9]{64}$'`),
|
|
723
|
+
],
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
// Atomically committed alongside a document and its initial version when an
|
|
727
|
+
// authenticated client supplies Idempotency-Key on POST /api/documents.
|
|
728
|
+
export const documentCreateOperations = pgTable(
|
|
729
|
+
"document_create_operations",
|
|
730
|
+
{
|
|
731
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
732
|
+
workspaceId: text("workspace_id").notNull(),
|
|
733
|
+
actorUserId: uuid("actor_user_id")
|
|
734
|
+
.notNull()
|
|
735
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
736
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
737
|
+
documentId: uuid("document_id")
|
|
738
|
+
.notNull()
|
|
739
|
+
.references(() => documents.id, { onDelete: "cascade" }),
|
|
740
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
741
|
+
},
|
|
742
|
+
(table) => [
|
|
743
|
+
uniqueIndex("document_create_operations_workspace_actor_key_idx").on(
|
|
744
|
+
table.workspaceId,
|
|
745
|
+
table.actorUserId,
|
|
746
|
+
table.idempotencyKey,
|
|
747
|
+
),
|
|
748
|
+
uniqueIndex("document_create_operations_document_idx").on(table.documentId),
|
|
721
749
|
],
|
|
722
750
|
);
|
|
723
751
|
|
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { db } from "./client";
|
|
2
|
+
import { db, type Database } from "./client";
|
|
3
|
+
|
|
4
|
+
/** A transaction carrying the same query surface as the tenant-scoped DB. */
|
|
5
|
+
export type TenantTransaction = Parameters<
|
|
6
|
+
Parameters<typeof db.transaction>[0]
|
|
7
|
+
>[0];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Server-side adapter for packages that must run short actor-scoped database
|
|
11
|
+
* transactions without importing this module's process-global client.
|
|
12
|
+
*/
|
|
13
|
+
export interface ActorScopedTransactionExecutor {
|
|
14
|
+
withActorTransaction<T>(
|
|
15
|
+
actorUserId: string,
|
|
16
|
+
operation: (tx: TenantTransaction) => Promise<T>,
|
|
17
|
+
): Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createActorScopedTransactionExecutor(
|
|
21
|
+
database: Database,
|
|
22
|
+
): ActorScopedTransactionExecutor {
|
|
23
|
+
return {
|
|
24
|
+
withActorTransaction(actorUserId, operation) {
|
|
25
|
+
return database.transaction(async (tx) => {
|
|
26
|
+
await tx.execute(
|
|
27
|
+
sql`SELECT set_config('app.current_user_id', ${actorUserId}, true)`,
|
|
28
|
+
);
|
|
29
|
+
await tx.execute(
|
|
30
|
+
sql`SELECT set_config('app.current_user_role', 'user', true)`,
|
|
31
|
+
);
|
|
32
|
+
await tx.execute(
|
|
33
|
+
sql`SELECT set_config('app.current_workspace_id', '', true)`,
|
|
34
|
+
);
|
|
35
|
+
return operation(tx);
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
3
40
|
|
|
4
41
|
export interface TenantContext {
|
|
5
42
|
userId: string;
|