@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.
@@ -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 {
@@ -15,7 +15,10 @@ export type WorkspaceAssertionOptions = Readonly<{
15
15
  issuer: string;
16
16
  nowSeconds?: number;
17
17
  clockSkewSeconds?: number;
18
- maxTtlSeconds?: number;
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 MAX_TTL_SECONDS = 300;
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).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
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("_", "/") + "=".repeat((4 - (value.length % 4)) % 4);
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" || !/^[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))
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" || !context.workspaceId.trim() || context.workspaceId.trim() !== context.workspaceId || context.workspaceId.length > 128)
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) || !Number.isFinite(context.expiresAt) || typeof context.issuer !== "string" || !context.issuer)
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 ?? 5;
63
- if (context.issuedAt > now + skew || context.expiresAt <= now - skew || context.expiresAt <= context.issuedAt || context.expiresAt - context.issuedAt > (options.maxTtlSeconds ?? MAX_TTL_SECONDS))
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.4",
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.4";
27
+ const VERSION = "0.3.6";
28
28
 
29
29
  const program = new Command();
30
30
  program
@@ -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 { withTenant, adminTenantContext, shareGuestTenantContext } from "./with-tenant";
5
- export type { TenantContext } from "./with-tenant";
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
- actorUserId: uuid("actor_user_id").notNull().references(() => users.id, { onDelete: "restrict" }),
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
- index("lifecycle_operations_retryable_idx").on(table.status).where(sql`${table.status} = 'retryable'`),
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;
@@ -35,7 +35,7 @@ interface McpToolResult {
35
35
 
36
36
  const server = new McpServer({
37
37
  name: "hiai-docs",
38
- version: "0.3.4",
38
+ version: "0.3.6",
39
39
  });
40
40
 
41
41
  /**