@hiai-gg/docsmint 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +20 -4
  2. package/backend/src/lib/api-keys.ts +14 -6
  3. package/backend/src/lib/lifecycle-service.ts +397 -73
  4. package/dist/backend/index.js +13038 -10864
  5. package/dist/backend-account-runtime-cleanup.js +152 -42
  6. package/dist/backend-api-key-facade.js +207 -61
  7. package/dist/backend-pipeline-cancellation.js +144 -42
  8. package/dist/client.d.ts +12 -2
  9. package/dist/client.js +83 -30
  10. package/dist/frontend/{CategoryDialog-CbXBMBZB.js → CategoryDialog-C67_TXf0.js} +4 -4
  11. package/dist/frontend/{ConfirmDialog-DgMTQBq4.js → ConfirmDialog-_LlrA--f.js} +3 -3
  12. package/dist/frontend/{CreateSnapshotDialog-FUq-ZfBH.js → CreateSnapshotDialog-BmCRpOMv.js} +3 -3
  13. package/dist/frontend/{DatePicker-D6E-EEtZ.js → DatePicker-DsoepNdb.js} +8 -8
  14. package/dist/frontend/{DeleteDialog-8k6JYWCx.js → DeleteDialog-L1A3FhMM.js} +1 -1
  15. package/dist/frontend/{FolderNode-DAAn_T8_.js → FolderNode-CNwi0ySr.js} +1 -1
  16. package/dist/frontend/{HiAiEditor-DSVG3GNd.js → HiAiEditor-B1EDGsnA.js} +6 -6
  17. package/dist/frontend/{SettingsDialog-BDNgf5rv.js → SettingsDialog-AP42xOGg.js} +12 -12
  18. package/dist/frontend/ShareDialog-wGTXuXFE.js +281 -0
  19. package/dist/frontend/{Sidebar-DX08utdV.js → Sidebar-lKQT3dEf.js} +17 -16
  20. package/dist/frontend/{TagCreateDialog-Bb0-Vtzh.js → TagCreateDialog-su8BhLzS.js} +3 -3
  21. package/dist/frontend/api/attachments.js +1 -1
  22. package/dist/frontend/api/settings.js +1 -1
  23. package/dist/frontend/app-shell.js +2 -2
  24. package/dist/frontend/{attachments-B8AuDjno.js → attachments-B2aH3hXF.js} +10 -9
  25. package/dist/frontend/{auth-client-Cygmp1I2.js → auth-client-CnDZwPfp.js} +11 -11
  26. package/dist/frontend/components/category-dialog.js +1 -1
  27. package/dist/frontend/components/create-snapshot-dialog.js +1 -1
  28. package/dist/frontend/components/delete-dialog.js +1 -1
  29. package/dist/frontend/components/editor/compact-editor.js +1 -1
  30. package/dist/frontend/components/editor/document-editor.js +1 -1
  31. package/dist/frontend/components/folder-node.js +1 -1
  32. package/dist/frontend/components/save-as-dialog.js +3 -3
  33. package/dist/frontend/components/settings.js +1 -1
  34. package/dist/frontend/components/share-dialog.d.ts +2 -1
  35. package/dist/frontend/components/share-dialog.js +1 -1
  36. package/dist/frontend/components/sidebar.js +1 -1
  37. package/dist/frontend/components/tag-create-dialog.js +1 -1
  38. package/dist/frontend/components/version-history.js +2 -2
  39. package/dist/frontend/{confirm-dialog-CzmoZw3a.js → confirm-dialog-BG8y2Pum.js} +4 -4
  40. package/dist/frontend/dashboard.js +1008 -998
  41. package/dist/frontend/{db-ByrlYfaI.js → db-Bo1t_76T.js} +1 -1
  42. package/dist/frontend/{dialog-Ce79C9fd.js → dialog-C2iKObEu.js} +7 -7
  43. package/dist/frontend/{dropdown-menu-CJ_7V3N0.js → dropdown-menu-BXIl2E26.js} +12 -12
  44. package/dist/frontend/{floating-layer-anchor-S5RSefGN.js → floating-layer-anchor-BGIndLY-.js} +63 -63
  45. package/dist/frontend/{identity-DyWeNzu_.js → identity-Ynrglr5i.js} +1 -1
  46. package/dist/frontend/{input-BWlOML_m.js → input-eHFb2qiL.js} +2 -2
  47. package/dist/frontend/{label-C2HfJNQ2.js → label-_I1iiaNU.js} +2 -2
  48. package/dist/frontend/offline/identity.js +1 -1
  49. package/dist/frontend/search.js +2 -2
  50. package/dist/frontend/{select-value-JCAp2PmU.js → select-value-CtMyrvDJ.js} +18 -18
  51. package/dist/frontend/{settings-DSrUl4hW.js → settings-BR48ihB4.js} +6 -23
  52. package/dist/frontend/{textarea-DOAE-h42.js → textarea-2eslfANY.js} +2 -2
  53. package/dist/frontend/{utils-BPDRytUU.js → utils-CDBqTgoV.js} +1 -1
  54. package/dist/frontend-ssr/api/attachments.js +1 -1
  55. package/dist/frontend-ssr/api/settings.js +1 -1
  56. package/dist/frontend-ssr/app-shell.js +2 -2
  57. package/dist/frontend-ssr/assets/{CategoryDialog-QHEv_9N4.js → CategoryDialog-BGuHTb3K.js} +4 -4
  58. package/dist/frontend-ssr/assets/{ConfirmDialog-BhAelejz.js → ConfirmDialog-D1BDXXll.js} +3 -3
  59. package/dist/frontend-ssr/assets/{CreateSnapshotDialog-CI8vXXcN.js → CreateSnapshotDialog-BZ45odvJ.js} +3 -3
  60. package/dist/frontend-ssr/assets/{DatePicker-D84cJ-Eq.js → DatePicker-CPtl6W5E.js} +3 -3
  61. package/dist/frontend-ssr/assets/{DeleteDialog-DG9Lm38J.js → DeleteDialog-oHT_p_vG.js} +1 -1
  62. package/dist/frontend-ssr/assets/{FolderNode-kmilhDjO.js → FolderNode-D9kgJG9d.js} +1 -1
  63. package/dist/frontend-ssr/assets/{HiAiEditor-CUOJmKzT.js → HiAiEditor-DOd2MXjD.js} +5 -5
  64. package/dist/frontend-ssr/assets/{SettingsDialog-BjJIiVLc.js → SettingsDialog-CIVbMKmf.js} +12 -12
  65. package/dist/frontend-ssr/assets/{ShareDialog-C9C-XTm4.js → ShareDialog-D2UNUbF7.js} +19 -6
  66. package/dist/frontend-ssr/assets/{Sidebar-C2sUfGNk.js → Sidebar-Bb-k2R-Z.js} +13 -12
  67. package/dist/frontend-ssr/assets/{TagCreateDialog-DUPMitlL.js → TagCreateDialog-6Gk7L3D-.js} +3 -3
  68. package/dist/frontend-ssr/assets/{attachments-Cj3fQyUp.js → attachments-BdkOp8ha.js} +5 -4
  69. package/dist/frontend-ssr/assets/{auth-client-x26kqY8Z.js → auth-client-CojKIzqy.js} +13 -13
  70. package/dist/frontend-ssr/assets/{confirm-dialog-XuyaL8sr.js → confirm-dialog-B9MVS_bF.js} +4 -4
  71. package/dist/frontend-ssr/assets/{db-CnxeO66n.js → db-BhwJPEkn.js} +1 -1
  72. package/dist/frontend-ssr/assets/{dialog-BOV1u6zP.js → dialog-1SB5neow.js} +8 -8
  73. package/dist/frontend-ssr/assets/{dropdown-menu-Dn_vQq9b.js → dropdown-menu-DqoOkdqB.js} +12 -12
  74. package/dist/frontend-ssr/assets/{floating-layer-anchor-BIAu-252.js → floating-layer-anchor-BoiiIEiK.js} +71 -71
  75. package/dist/frontend-ssr/assets/{identity-B3uHoDtf.js → identity-DcMMBKWg.js} +1 -1
  76. package/dist/frontend-ssr/assets/{input-DezGsU3y.js → input-Df-QP3Km.js} +2 -2
  77. package/dist/frontend-ssr/assets/{label-Bj8-TfFN.js → label-CbUOUz6L.js} +2 -2
  78. package/dist/frontend-ssr/assets/{select-value-DBhbkNcc.js → select-value-CXEDWrZg.js} +18 -18
  79. package/dist/frontend-ssr/assets/{settings-Bk5Hk9Vq.js → settings-Mp8EuFSn.js} +3 -21
  80. package/dist/frontend-ssr/assets/{textarea-Dev-qSEc.js → textarea-CHh8NuSS.js} +2 -2
  81. package/dist/frontend-ssr/assets/{utils-DtlPPXpJ.js → utils-DtX0ta7y.js} +1 -1
  82. package/dist/frontend-ssr/components/category-dialog.js +1 -1
  83. package/dist/frontend-ssr/components/create-snapshot-dialog.js +1 -1
  84. package/dist/frontend-ssr/components/delete-dialog.js +1 -1
  85. package/dist/frontend-ssr/components/editor/compact-editor.js +1 -1
  86. package/dist/frontend-ssr/components/editor/document-editor.js +1 -1
  87. package/dist/frontend-ssr/components/folder-node.js +1 -1
  88. package/dist/frontend-ssr/components/save-as-dialog.js +3 -3
  89. package/dist/frontend-ssr/components/settings.js +1 -1
  90. package/dist/frontend-ssr/components/share-dialog.js +1 -1
  91. package/dist/frontend-ssr/components/sidebar.js +1 -1
  92. package/dist/frontend-ssr/components/tag-create-dialog.js +1 -1
  93. package/dist/frontend-ssr/components/version-history.js +2 -2
  94. package/dist/frontend-ssr/dashboard.js +31 -29
  95. package/dist/frontend-ssr/offline/identity.js +1 -1
  96. package/dist/frontend-ssr/search.js +2 -2
  97. package/dist/index.d.ts +5 -5
  98. package/dist/index.js +2 -2
  99. package/dist/lifecycle-persistent.d.ts +1 -1
  100. package/dist/lifecycle-runtime.js +52659 -338
  101. package/dist/mcp-cli.js +1056 -0
  102. package/dist/mcp-server.d.ts +39 -0
  103. package/dist/mcp-server.js +1053 -0
  104. package/dist/storage-quota.d.ts +5 -1
  105. package/dist/types.d.ts +7 -0
  106. package/dist/workspace.d.ts +11 -2
  107. package/dist/workspace.js +82 -10
  108. package/package.json +7 -13
  109. package/packages/cli/src/index.ts +1 -1
  110. package/packages/db/src/client.ts +56 -5
  111. package/packages/db/src/schema.ts +192 -1
  112. package/packages/db/src/with-tenant.ts +20 -2
  113. package/server.json +2 -2
  114. package/dist/frontend/ShareDialog-2Mty7hm4.js +0 -258
  115. package/packages/mcp-server/src/capabilities.ts +0 -189
  116. package/packages/mcp-server/src/client.ts +0 -225
  117. package/packages/mcp-server/src/index.ts +0 -8
  118. package/packages/mcp-server/src/server.ts +0 -87
  119. package/packages/mcp-server/src/tools/create-document.ts +0 -34
  120. package/packages/mcp-server/src/tools/create-folder.ts +0 -31
  121. package/packages/mcp-server/src/tools/create-snapshot.ts +0 -27
  122. package/packages/mcp-server/src/tools/export-document.ts +0 -22
  123. package/packages/mcp-server/src/tools/get-document.ts +0 -22
  124. package/packages/mcp-server/src/tools/list-documents.ts +0 -34
  125. package/packages/mcp-server/src/tools/list-folders.ts +0 -26
  126. package/packages/mcp-server/src/tools/search.ts +0 -39
  127. package/packages/mcp-server/src/tools/update-document.ts +0 -38
  128. package/packages/mcp-server/src/tools/version-history.ts +0 -27
  129. package/packages/mcp-server/src/types.ts +0 -126
@@ -67,7 +67,11 @@ export type AttachmentStorageQuotaFinalization = Readonly<{
67
67
  }>;
68
68
  /**
69
69
  * OSS invokes this only after it has verified tenant context and document
70
- * access. A host provider is never exposed to HTTP or browser code.
70
+ * access. A host provider is never exposed to HTTP or browser code. Every
71
+ * method MUST be idempotent for `context.idempotencyKey`: repeated reserve
72
+ * returns the same logical reservation, repeated finalize has one charge, and
73
+ * repeated release is a successful no-op after the first release. The OSS
74
+ * durable upload/cleanup saga relies on this contract at crash boundaries.
71
75
  */
72
76
  export type AttachmentStorageQuotaAdmission = Readonly<{
73
77
  reserve(context: AttachmentStorageQuotaContext): Promise<Readonly<{
package/dist/types.d.ts CHANGED
@@ -304,6 +304,8 @@ export interface DocsAttachmentPresignInput {
304
304
  export interface DocsAttachmentPresignResponse {
305
305
  url: string;
306
306
  key: string;
307
+ /** Signed, exact admission identity required by confirmAttachment. */
308
+ uploadToken: string;
307
309
  /**
308
310
  * Opaque server-issued reservation. Present only when workspace storage
309
311
  * enforcement is enabled and required verbatim by confirmAttachment.
@@ -314,6 +316,7 @@ export interface DocsAttachmentPresignResponse {
314
316
  }
315
317
  export interface DocsAttachmentConfirmInput extends DocsAttachmentPresignInput {
316
318
  key: string;
319
+ uploadToken: string;
317
320
  /** The opaque reservation returned by presignAttachment when present. */
318
321
  quotaReservationId?: string;
319
322
  }
@@ -434,4 +437,8 @@ export interface DocsRequestContext {
434
437
  workspaceAssertion?: string;
435
438
  headers?: HeadersInit;
436
439
  signal?: AbortSignal;
440
+ /** Per-request timeout in milliseconds, overriding the client default. */
441
+ timeoutMs?: number;
442
+ /** Idempotency key forwarded as the standard Idempotency-Key header. */
443
+ idempotencyKey?: string;
437
444
  }
@@ -1,13 +1,22 @@
1
1
  /** Public, server-only workspace assertion contract. */
2
2
  export declare const DOCSMINT_WORKSPACE_CONTEXT_HEADER = "x-docsmint-workspace-context";
3
- export type DocsmintWorkspaceContext = Readonly<{
3
+ export type WorkspaceRole = "owner" | "admin" | "editor" | "viewer";
4
+ export type WorkspaceResourcePermission = "read" | "edit" | "write";
5
+ export type WorkspaceResourceScope = Readonly<{
6
+ kind: "category";
7
+ categoryId: string;
8
+ permissions: readonly WorkspaceResourcePermission[];
9
+ }>;
10
+ export type WorkspaceAssertionPayload = Readonly<{
4
11
  actorUserId: string;
5
12
  workspaceId: string;
6
- actorRole: "owner" | "admin" | "editor" | "viewer";
13
+ actorRole: WorkspaceRole;
14
+ resourceScope?: WorkspaceResourceScope;
7
15
  issuedAt: number;
8
16
  expiresAt: number;
9
17
  issuer: string;
10
18
  }>;
19
+ export type DocsmintWorkspaceContext = WorkspaceAssertionPayload;
11
20
  export type WorkspaceAssertionOptions = Readonly<{
12
21
  secret: string;
13
22
  issuer: string;
package/dist/workspace.js CHANGED
@@ -4,6 +4,22 @@ const encoder = new TextEncoder();
4
4
  const decoder = new TextDecoder();
5
5
  export const DOCSMINT_WORKSPACE_ASSERTION_TTL_SECONDS = 60;
6
6
  export const DOCSMINT_WORKSPACE_ASSERTION_CLOCK_SKEW_SECONDS = 5;
7
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8
+ const CONTEXT_FIELDS = new Set([
9
+ "actorUserId",
10
+ "workspaceId",
11
+ "actorRole",
12
+ "resourceScope",
13
+ "issuedAt",
14
+ "expiresAt",
15
+ "issuer",
16
+ ]);
17
+ const RESOURCE_SCOPE_FIELDS = new Set(["kind", "categoryId", "permissions"]);
18
+ const RESOURCE_PERMISSIONS = new Set([
19
+ "read",
20
+ "edit",
21
+ "write",
22
+ ]);
7
23
  function toBase64Url(bytes) {
8
24
  let binary = "";
9
25
  for (const byte of bytes)
@@ -26,11 +42,14 @@ async function sign(payload, secret) {
26
42
  return toBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload))));
27
43
  }
28
44
  function assertContext(value) {
29
- if (!value || typeof value !== "object")
45
+ if (!value || typeof value !== "object" || Array.isArray(value))
30
46
  throw new Error("Invalid workspace assertion payload");
31
47
  const context = value;
48
+ if (Object.keys(context).some((field) => !CONTEXT_FIELDS.has(field))) {
49
+ throw new Error("Invalid workspace assertion payload fields");
50
+ }
32
51
  if (typeof context.actorUserId !== "string" ||
33
- !/^[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))
52
+ !UUID_PATTERN.test(context.actorUserId))
34
53
  throw new Error("Invalid actorUserId");
35
54
  if (typeof context.workspaceId !== "string" ||
36
55
  !context.workspaceId.trim() ||
@@ -39,15 +58,69 @@ function assertContext(value) {
39
58
  throw new Error("Invalid workspaceId");
40
59
  if (!["owner", "admin", "editor", "viewer"].includes(context.actorRole))
41
60
  throw new Error("Invalid actorRole");
61
+ if (context.resourceScope !== undefined) {
62
+ assertResourceScope(context.resourceScope);
63
+ }
42
64
  if (!Number.isFinite(context.issuedAt) ||
43
65
  !Number.isFinite(context.expiresAt) ||
44
66
  typeof context.issuer !== "string" ||
45
67
  !context.issuer)
46
68
  throw new Error("Invalid workspace assertion timestamps or issuer");
47
69
  }
70
+ function assertResourceScope(value) {
71
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
72
+ throw new Error("Invalid workspace resource scope");
73
+ }
74
+ const scope = value;
75
+ const fields = Object.keys(scope);
76
+ if (fields.length !== RESOURCE_SCOPE_FIELDS.size ||
77
+ fields.some((field) => !RESOURCE_SCOPE_FIELDS.has(field))) {
78
+ throw new Error("Invalid workspace resource scope fields");
79
+ }
80
+ if (scope.kind !== "category") {
81
+ throw new Error("Invalid workspace resource scope kind");
82
+ }
83
+ if (typeof scope.categoryId !== "string" ||
84
+ !UUID_PATTERN.test(scope.categoryId)) {
85
+ throw new Error("Invalid workspace resource categoryId");
86
+ }
87
+ if (!Array.isArray(scope.permissions) ||
88
+ Array.from(scope.permissions).some((permission) => typeof permission !== "string" ||
89
+ !RESOURCE_PERMISSIONS.has(permission))) {
90
+ throw new Error("Invalid workspace resource permissions");
91
+ }
92
+ }
93
+ function assertStaticLifetime(context) {
94
+ if (context.expiresAt <= context.issuedAt) {
95
+ throw new Error("Invalid workspace assertion lifetime");
96
+ }
97
+ if (context.expiresAt - context.issuedAt >
98
+ DOCSMINT_WORKSPACE_ASSERTION_TTL_SECONDS) {
99
+ throw new Error("Workspace assertion lifetime exceeds maximum TTL");
100
+ }
101
+ }
102
+ function immutableContext(context) {
103
+ const resourceScope = context.resourceScope
104
+ ? Object.freeze({
105
+ kind: context.resourceScope.kind,
106
+ categoryId: context.resourceScope.categoryId,
107
+ permissions: Object.freeze([...context.resourceScope.permissions]),
108
+ })
109
+ : undefined;
110
+ return Object.freeze({
111
+ actorUserId: context.actorUserId,
112
+ workspaceId: context.workspaceId,
113
+ actorRole: context.actorRole,
114
+ ...(resourceScope ? { resourceScope } : {}),
115
+ issuedAt: context.issuedAt,
116
+ expiresAt: context.expiresAt,
117
+ issuer: context.issuer,
118
+ });
119
+ }
48
120
  export async function createDocsmintWorkspaceAssertion(context, secret) {
49
121
  assertContext(context);
50
- const payload = toBase64Url(encoder.encode(JSON.stringify(context)));
122
+ assertStaticLifetime(context);
123
+ const payload = toBase64Url(encoder.encode(JSON.stringify(immutableContext(context))));
51
124
  return `${payload}.${await sign(payload, secret)}`;
52
125
  }
53
126
  export async function verifyDocsmintWorkspaceAssertion(assertion, options) {
@@ -66,6 +139,7 @@ export async function verifyDocsmintWorkspaceAssertion(assertion, options) {
66
139
  throw new Error("Invalid workspace assertion payload");
67
140
  }
68
141
  assertContext(context);
142
+ assertStaticLifetime(context);
69
143
  if (context.issuer !== options.issuer)
70
144
  throw new Error("Invalid workspace assertion issuer");
71
145
  const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
@@ -75,11 +149,9 @@ export async function verifyDocsmintWorkspaceAssertion(assertion, options) {
75
149
  skew > DOCSMINT_WORKSPACE_ASSERTION_CLOCK_SKEW_SECONDS) {
76
150
  throw new Error("Invalid workspace assertion clock skew");
77
151
  }
78
- if (context.issuedAt > now + skew ||
79
- context.expiresAt <= now - skew ||
80
- context.expiresAt <= context.issuedAt ||
81
- context.expiresAt - context.issuedAt >
82
- DOCSMINT_WORKSPACE_ASSERTION_TTL_SECONDS)
83
- throw new Error("Invalid workspace assertion lifetime");
84
- return Object.freeze({ ...context });
152
+ if (context.issuedAt > now + skew)
153
+ throw new Error("Workspace assertion is not yet valid");
154
+ if (context.expiresAt <= now - skew)
155
+ throw new Error("Workspace assertion is expired");
156
+ return immutableContext(context);
85
157
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hiai-gg/docsmint",
3
3
  "mcpName": "io.github.HiAi-gg/docsmint",
4
- "version": "0.6.8",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "browser": {
7
7
  "./dist/backend-launcher.js": false,
@@ -59,8 +59,8 @@
59
59
  "types": "./dist/index.d.ts"
60
60
  },
61
61
  "./mcp": {
62
- "import": "./packages/mcp-server/src/server.ts",
63
- "types": "./packages/mcp-server/src/server.ts"
62
+ "import": "./dist/mcp-server.js",
63
+ "types": "./dist/mcp-server.d.ts"
64
64
  },
65
65
  "./lifecycle": {
66
66
  "browser": "./dist/server-only-browser-entry.js",
@@ -339,9 +339,9 @@
339
339
  },
340
340
  "bin": {
341
341
  "docsmint": "./packages/cli/src/index.ts",
342
- "docsmint-mcp": "./packages/mcp-server/src/index.ts",
342
+ "docsmint-mcp": "./dist/mcp-cli.js",
343
343
  "hiai-docs": "./packages/cli/src/index.ts",
344
- "hiai-docs-mcp": "./packages/mcp-server/src/index.ts"
344
+ "hiai-docs-mcp": "./dist/mcp-cli.js"
345
345
  },
346
346
  "files": [
347
347
  "dist",
@@ -353,12 +353,6 @@
353
353
  "packages/cli/src/config.ts",
354
354
  "packages/cli/src/format.ts",
355
355
  "packages/cli/src/commands",
356
- "packages/mcp-server/src/client.ts",
357
- "packages/mcp-server/src/index.ts",
358
- "packages/mcp-server/src/server.ts",
359
- "packages/mcp-server/src/capabilities.ts",
360
- "packages/mcp-server/src/types.ts",
361
- "packages/mcp-server/src/tools",
362
356
  "skills",
363
357
  "backend/src/lib/redis-factory.ts",
364
358
  "backend/src/lib/storage-factory.ts",
@@ -397,7 +391,7 @@
397
391
  "postgres": "^3.4.9",
398
392
  "@aws-sdk/client-s3": "^3.0.0",
399
393
  "pino": "^10.0.0",
400
- "@sveltejs/kit": "^2.68.0",
394
+ "@sveltejs/kit": "^2.70.3",
401
395
  "svelte": "^5.56.4"
402
396
  },
403
397
  "peerDependenciesMeta": {
@@ -414,4 +408,4 @@
414
408
  "optional": true
415
409
  }
416
410
  }
417
- }
411
+ }
@@ -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.6.8';
27
+ const VERSION = '0.7.0';
28
28
 
29
29
  const program = new Command();
30
30
  program.name('hiai-docs').description('CLI for the hiai-docs knowledge base').version(VERSION);
@@ -5,16 +5,67 @@ import postgres from "postgres";
5
5
  export type Schema = typeof schema;
6
6
  export type Database = PostgresJsDatabase<Schema>;
7
7
 
8
+ export type DatabaseQueryObservation = Readonly<{
9
+ query: string;
10
+ }>;
11
+
12
+ export type DatabaseQueryObserver = (
13
+ observation: DatabaseQueryObservation,
14
+ ) => void;
15
+
16
+ function observeDatabaseQuery(
17
+ observer: DatabaseQueryObserver | undefined,
18
+ query: string,
19
+ ): void {
20
+ if (!observer) return;
21
+ try {
22
+ observer(Object.freeze({ query }));
23
+ } catch {
24
+ // Query observation is diagnostic only. Observer bugs must never affect the
25
+ // database protocol, application result, or error surface.
26
+ }
27
+ }
28
+
29
+ export type DatabaseClientOptions = Readonly<{
30
+ max?: number;
31
+ idleTimeout?: number;
32
+ connectTimeout?: number;
33
+ /** Explicit client-local test hook. Omitted clients install no debug callback. */
34
+ queryObserver?: DatabaseQueryObserver;
35
+ }>;
36
+
37
+ export function createDatabaseClient(
38
+ databaseUrl: string,
39
+ options: DatabaseClientOptions = {},
40
+ ) {
41
+ const sqlClient = postgres(databaseUrl, {
42
+ max: options.max ?? 20,
43
+ idle_timeout: options.idleTimeout ?? 30,
44
+ connect_timeout: options.connectTimeout ?? 10,
45
+ ...(options.queryObserver
46
+ ? {
47
+ debug: (_connection: number, query: string) => {
48
+ observeDatabaseQuery(options.queryObserver, query);
49
+ },
50
+ }
51
+ : {}),
52
+ });
53
+ return {
54
+ client: sqlClient,
55
+ db: drizzle(sqlClient, { schema }) as Database,
56
+ };
57
+ }
58
+
8
59
  const databaseUrl =
9
60
  process.env.DATABASE_URL ||
10
61
  "postgresql://hiai_app:changeme@localhost:5437/hiai_docs";
11
62
 
12
- const client = postgres(databaseUrl, {
13
- max: 20,
14
- idle_timeout: 30,
15
- connect_timeout: 10,
63
+ const database = createDatabaseClient(databaseUrl, {
64
+ // Production deliberately owns no query observer. Contract tests create an
65
+ // explicit client-local observer and close that client after each capture.
16
66
  });
17
67
 
18
- export const db: Database = drizzle(client, { schema });
68
+ const client = database.client;
69
+ export const db: Database = database.db;
19
70
 
20
71
  export { client };
@@ -1,4 +1,4 @@
1
- import { pgTable, uuid, text, timestamp, bigint, jsonb, index, uniqueIndex, customType, boolean, check, integer, pgEnum, type AnyPgColumn } from "drizzle-orm/pg-core";
1
+ import { pgTable, uuid, text, timestamp, bigint, jsonb, index, uniqueIndex, customType, boolean, check, integer, pgEnum, primaryKey, type AnyPgColumn } from "drizzle-orm/pg-core";
2
2
 
3
3
  // pgvector vector type — maps to PostgreSQL vector(n) column
4
4
  const vector = customType<{
@@ -226,6 +226,7 @@ export const documents = pgTable(
226
226
  activeEmbeddingGeneration: uuid("active_embedding_generation"),
227
227
  pendingEmbeddingGeneration: uuid("pending_embedding_generation"),
228
228
  embeddingProfile: text("embedding_profile"),
229
+ embeddingContextHash: text("embedding_context_hash"),
229
230
  embeddingErrorCode: text("embedding_error_code"),
230
231
  embeddingUpdatedAt: timestamp("embedding_updated_at"),
231
232
  /** Soft-delete marker. Trashed documents remain tenant-scoped until purge. */
@@ -281,6 +282,8 @@ export const documentPipelineRuns = pgTable(
281
282
  generationId: uuid("generation_id").notNull(),
282
283
  revision: text("revision").notNull(),
283
284
  source: text("source").notNull(),
285
+ embeddingContextHash: text("embedding_context_hash"),
286
+ refreshMode: text("refresh_mode").notNull().default("full"),
284
287
  status: pipelineStatusEnum("status").notNull().default("pending"),
285
288
  prepareStatus: pipelineStatusEnum("prepare_status").notNull().default("pending"),
286
289
  embedStatus: pipelineStatusEnum("embed_status").notNull().default("pending"),
@@ -310,6 +313,10 @@ export const documentPipelineRuns = pgTable(
310
313
  table.status,
311
314
  table.updatedAt,
312
315
  ),
316
+ check(
317
+ "document_pipeline_runs_refresh_mode_check",
318
+ sql`${table.refreshMode} in ('incremental', 'full')`,
319
+ ),
313
320
  ],
314
321
  );
315
322
 
@@ -354,6 +361,41 @@ export const documentPipelineBatches = pgTable(
354
361
  ],
355
362
  );
356
363
 
364
+ // ============================================
365
+ // metadata_reembed_outbox -- exact metadata-impact snapshots
366
+ // ============================================
367
+ export const metadataReembedOutbox = pgTable(
368
+ "metadata_reembed_outbox",
369
+ {
370
+ // The outbox row id is also the deterministic pipeline generation id.
371
+ id: uuid("id").primaryKey(),
372
+ operationId: uuid("operation_id").notNull(),
373
+ documentId: uuid("document_id")
374
+ .notNull()
375
+ .references(() => documents.id, { onDelete: "cascade" }),
376
+ ownerId: uuid("owner_id")
377
+ .notNull()
378
+ .references(() => users.id, { onDelete: "cascade" }),
379
+ workspaceId: text("workspace_id"),
380
+ revision: text("revision").notNull(),
381
+ createdAt: timestamp("created_at").defaultNow().notNull(),
382
+ },
383
+ (table) => [
384
+ uniqueIndex("metadata_reembed_outbox_operation_document_idx").on(
385
+ table.operationId,
386
+ table.documentId,
387
+ ),
388
+ index("metadata_reembed_outbox_operation_id_idx").on(
389
+ table.operationId,
390
+ table.id,
391
+ ),
392
+ index("metadata_reembed_outbox_created_id_idx").on(
393
+ table.createdAt,
394
+ table.id,
395
+ ),
396
+ ],
397
+ );
398
+
357
399
  // ============================================
358
400
  // document_knowledge_summaries — generation-scoped retrieval metadata
359
401
  // ============================================
@@ -575,6 +617,9 @@ export const attachments = pgTable(
575
617
  .notNull()
576
618
  .references(() => documents.id, { onDelete: "cascade" }),
577
619
  workspaceId: text("workspace_id"),
620
+ uploadedBy: uuid("uploaded_by")
621
+ .notNull()
622
+ .references(() => users.id, { onDelete: "restrict" }),
578
623
  filename: text("filename").notNull(),
579
624
  mimeType: text("mime_type").notNull(),
580
625
  size: bigint("size", { mode: "number" }).notNull(),
@@ -583,10 +628,156 @@ export const attachments = pgTable(
583
628
  },
584
629
  (table) => [
585
630
  index("attachments_document_id_idx").on(table.documentId),
631
+ index("attachments_uploaded_by_idx").on(table.uploadedBy),
586
632
  uniqueIndex("attachments_storage_key_unique").on(table.storageKey),
587
633
  ]
588
634
  );
589
635
 
636
+ // A direct upload URL can outlive the request that created it. Keeping the
637
+ // exact actor/key/expiry admission durable lets confirmation authenticate the
638
+ // key after session/document disappearance and lets account purge wait through
639
+ // the unrevocable URL lifetime before deleting the exact object.
640
+ export const pendingAttachmentUploads = pgTable(
641
+ "pending_attachment_uploads",
642
+ {
643
+ id: uuid("id").primaryKey().defaultRandom(),
644
+ documentId: uuid("document_id")
645
+ .notNull()
646
+ .references(() => documents.id, { onDelete: "restrict" }),
647
+ actorUserId: uuid("actor_user_id")
648
+ .notNull()
649
+ .references(() => users.id, { onDelete: "restrict" }),
650
+ workspaceId: text("workspace_id"),
651
+ storageKey: text("storage_key").notNull(),
652
+ tokenHash: text("token_hash").notNull(),
653
+ filename: text("filename").notNull(),
654
+ mimeType: text("mime_type").notNull(),
655
+ declaredSize: bigint("declared_size", { mode: "number" }).notNull(),
656
+ quotaReservationId: text("quota_reservation_id"),
657
+ quotaOperationKey: text("quota_operation_key").notNull(),
658
+ quotaState: text("quota_state").notNull(),
659
+ actualSize: bigint("actual_size", { mode: "number" }),
660
+ urlIssuedAt: timestamp("url_issued_at"),
661
+ confirmingAt: timestamp("confirming_at"),
662
+ leaseOwner: text("lease_owner"),
663
+ leaseExpiresAt: timestamp("lease_expires_at"),
664
+ attemptCount: integer("attempt_count").default(0).notNull(),
665
+ lastError: text("last_error"),
666
+ expiresAt: timestamp("expires_at").notNull(),
667
+ createdAt: timestamp("created_at").defaultNow().notNull(),
668
+ },
669
+ (table) => [
670
+ uniqueIndex("pending_attachment_uploads_storage_key_unique").on(table.storageKey),
671
+ uniqueIndex("pending_attachment_uploads_token_hash_unique").on(table.tokenHash),
672
+ index("pending_attachment_uploads_actor_expiry_idx").on(
673
+ table.actorUserId,
674
+ table.expiresAt,
675
+ ),
676
+ index("pending_attachment_uploads_document_idx").on(table.documentId),
677
+ index("pending_attachment_uploads_cleanup_lease_idx").on(
678
+ table.expiresAt,
679
+ table.leaseExpiresAt,
680
+ table.id,
681
+ ),
682
+ check(
683
+ "pending_attachment_uploads_quota_state_check",
684
+ sql`${table.quotaState} IN ('not_required', 'reserve_pending', 'reserved', 'finalize_pending', 'committed')`,
685
+ ),
686
+ ],
687
+ );
688
+
689
+ // DB-authoritative intents for object removal and quota release. Source rows
690
+ // are deleted in the same transaction that inserts an intent; S3 and quota
691
+ // effects happen only after commit and remain recoverable across crashes.
692
+ export const attachmentStorageCleanupOutbox = pgTable(
693
+ "attachment_storage_cleanup_outbox",
694
+ {
695
+ id: uuid("id").primaryKey().defaultRandom(),
696
+ sourceKind: text("source_kind").notNull(),
697
+ sourceId: uuid("source_id").notNull(),
698
+ storageKey: text("storage_key").notNull(),
699
+ documentId: uuid("document_id").notNull(),
700
+ actorUserId: uuid("actor_user_id").notNull(),
701
+ ownerUserId: uuid("owner_user_id").notNull(),
702
+ requestedByUserId: uuid("requested_by_user_id").notNull(),
703
+ workspaceId: text("workspace_id"),
704
+ size: bigint("size", { mode: "number" }).notNull(),
705
+ quotaOperationKey: text("quota_operation_key").notNull(),
706
+ quotaReleaseKind: text("quota_release_kind").default("none").notNull(),
707
+ quotaReservationId: text("quota_reservation_id"),
708
+ notBefore: timestamp("not_before").defaultNow().notNull(),
709
+ retainUntil: timestamp("retain_until"),
710
+ objectDeletedAt: timestamp("object_deleted_at"),
711
+ leaseOwner: text("lease_owner"),
712
+ leaseExpiresAt: timestamp("lease_expires_at"),
713
+ attemptCount: integer("attempt_count").default(0).notNull(),
714
+ lastError: text("last_error"),
715
+ createdAt: timestamp("created_at").defaultNow().notNull(),
716
+ },
717
+ (table) => [
718
+ uniqueIndex("attachment_storage_cleanup_outbox_source_unique").on(
719
+ table.sourceKind,
720
+ table.sourceId,
721
+ ),
722
+ uniqueIndex("attachment_storage_cleanup_outbox_storage_key_unique").on(
723
+ table.storageKey,
724
+ ),
725
+ index("attachment_storage_cleanup_outbox_ready_idx").on(
726
+ table.notBefore,
727
+ table.leaseExpiresAt,
728
+ table.createdAt,
729
+ table.id,
730
+ ),
731
+ index("attachment_storage_cleanup_outbox_actor_idx").on(
732
+ table.actorUserId,
733
+ table.id,
734
+ ),
735
+ index("attachment_storage_cleanup_outbox_owner_idx").on(
736
+ table.ownerUserId,
737
+ table.id,
738
+ ),
739
+ index("attachment_storage_cleanup_outbox_requester_idx").on(
740
+ table.requestedByUserId,
741
+ table.id,
742
+ ),
743
+ check(
744
+ "attachment_storage_cleanup_outbox_source_kind_check",
745
+ sql`${table.sourceKind} IN ('attachment', 'pending_upload', 'uncommitted_upload')`,
746
+ ),
747
+ check(
748
+ "attachment_storage_cleanup_outbox_quota_kind_check",
749
+ sql`${table.quotaReleaseKind} IN ('none', 'reserve_pending', 'reservation', 'finalize_pending', 'committed')`,
750
+ ),
751
+ check("attachment_storage_cleanup_outbox_size_check", sql`${table.size} >= 0`),
752
+ check(
753
+ "attachment_storage_cleanup_outbox_reservation_check",
754
+ sql`${table.quotaReleaseKind} NOT IN ('reservation', 'finalize_pending') OR ${table.quotaReservationId} IS NOT NULL`,
755
+ ),
756
+ ],
757
+ );
758
+
759
+ // Internal transaction-bound proof used only by the narrow signed-admission
760
+ // abandonment SECURITY DEFINER function. The runtime role has no table grants.
761
+ export const pendingAttachmentCleanupAuthorizations = pgTable(
762
+ "pending_attachment_cleanup_authorizations",
763
+ {
764
+ backendPid: integer("backend_pid").notNull(),
765
+ transactionId: bigint("transaction_id", { mode: "bigint" }).notNull(),
766
+ admissionId: uuid("admission_id").notNull(),
767
+ documentId: uuid("document_id").notNull(),
768
+ actorUserId: uuid("actor_user_id").notNull(),
769
+ ownerUserId: uuid("owner_user_id").notNull(),
770
+ workspaceId: text("workspace_id"),
771
+ storageKey: text("storage_key").notNull(),
772
+ tokenHash: text("token_hash").notNull(),
773
+ },
774
+ (table) => [
775
+ primaryKey({
776
+ columns: [table.backendPid, table.transactionId, table.admissionId],
777
+ }),
778
+ ],
779
+ );
780
+
590
781
  export const attachmentRelations = relations(attachments, ({ one }) => ({
591
782
  document: one(documents, {
592
783
  fields: [attachments.documentId],
@@ -1,5 +1,12 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { db, type Database } from "./client";
2
+ import { type Database, db } from "./client";
3
+
4
+ export type WorkspaceResourcePermission = "read" | "edit" | "write";
5
+ export type WorkspaceResourceScope = Readonly<{
6
+ kind: "category";
7
+ categoryId: string;
8
+ permissions: readonly WorkspaceResourcePermission[];
9
+ }>;
3
10
 
4
11
  /** A transaction carrying the same query surface as the tenant-scoped DB. */
5
12
  export type TenantTransaction = Parameters<
@@ -44,6 +51,8 @@ export interface TenantContext {
44
51
  workspaceId?: string;
45
52
  source?: "personal" | "external";
46
53
  actorRole?: "owner" | "admin" | "editor" | "viewer";
54
+ /** Resource restriction copied from a verified external assertion. */
55
+ resourceScope?: WorkspaceResourceScope;
47
56
  /** Unix timestamp from a verified external workspace assertion. */
48
57
  assertionExpiresAt?: number;
49
58
  }
@@ -99,7 +108,16 @@ export async function withTenant<T>(
99
108
  ctx: TenantContext,
100
109
  fn: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) => Promise<T>,
101
110
  ): Promise<T> {
102
- return db.transaction(async (tx) => {
111
+ return withTenantDatabase(db, ctx, fn);
112
+ }
113
+
114
+ /** Execute the canonical tenant transaction against an explicitly owned client. */
115
+ export async function withTenantDatabase<T>(
116
+ database: Database,
117
+ ctx: TenantContext,
118
+ fn: (tx: TenantTransaction) => Promise<T>,
119
+ ): Promise<T> {
120
+ return database.transaction(async (tx) => {
103
121
  await tx.execute(
104
122
  sql`SELECT set_config('app.current_user_id', ${ctx.userId}, true)`,
105
123
  );
package/server.json CHANGED
@@ -8,12 +8,12 @@
8
8
  "source": "github",
9
9
  "id": "1249550690"
10
10
  },
11
- "version": "0.6.8",
11
+ "version": "0.7.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "@hiai-gg/docsmint",
16
- "version": "0.6.8",
16
+ "version": "0.7.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },