studiograph 1.3.48-next.21 → 1.3.48-next.210
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/README.md +26 -16
- package/dist/agent/agent-pool.d.ts +87 -3
- package/dist/agent/agent-pool.js +188 -15
- package/dist/agent/agent-pool.js.map +1 -1
- package/dist/agent/followups.d.ts +34 -0
- package/dist/agent/followups.js +52 -0
- package/dist/agent/followups.js.map +1 -0
- package/dist/agent/orchestrator.d.ts +101 -4
- package/dist/agent/orchestrator.js +464 -66
- package/dist/agent/orchestrator.js.map +1 -1
- package/dist/agent/prompts/entity-types-section.d.ts +34 -0
- package/dist/agent/prompts/entity-types-section.js +76 -0
- package/dist/agent/prompts/entity-types-section.js.map +1 -0
- package/dist/agent/prompts/system.md +112 -54
- package/dist/agent/skill-loader.d.ts +30 -17
- package/dist/agent/skill-loader.js +63 -30
- package/dist/agent/skill-loader.js.map +1 -1
- package/dist/agent/skills/artifact-canvas/skill.md +152 -0
- package/dist/agent/skills/entity-schema.md +75 -431
- package/dist/agent/skills/interview/entity-templates.md +38 -37
- package/dist/agent/skills/interview/question-domains.md +54 -49
- package/dist/agent/skills/interview/skill.md +84 -16
- package/dist/agent/skills/schema-rules.md +28 -28
- package/dist/agent/tools/artifact-tools.d.ts +124 -0
- package/dist/agent/tools/artifact-tools.js +207 -0
- package/dist/agent/tools/artifact-tools.js.map +1 -0
- package/dist/agent/tools/capture-tools.d.ts +31 -0
- package/dist/agent/tools/capture-tools.js +40 -0
- package/dist/agent/tools/capture-tools.js.map +1 -0
- package/dist/agent/tools/folder-tools.d.ts +47 -0
- package/dist/agent/tools/folder-tools.js +249 -0
- package/dist/agent/tools/folder-tools.js.map +1 -0
- package/dist/agent/tools/fs-tools.d.ts +6 -5
- package/dist/agent/tools/fs-tools.js +5 -5
- package/dist/agent/tools/fs-tools.js.map +1 -1
- package/dist/agent/tools/graph-tools.d.ts +48 -72
- package/dist/agent/tools/graph-tools.js +852 -329
- package/dist/agent/tools/graph-tools.js.map +1 -1
- package/dist/agent/tools/history-tools.d.ts +95 -0
- package/dist/agent/tools/history-tools.js +461 -0
- package/dist/agent/tools/history-tools.js.map +1 -0
- package/dist/agent/tools/load-skill.d.ts +6 -5
- package/dist/agent/tools/load-skill.js +3 -3
- package/dist/agent/tools/load-skill.js.map +1 -1
- package/dist/agent/tools/ops-tools.d.ts +5 -4
- package/dist/agent/tools/ops-tools.js +130 -236
- package/dist/agent/tools/ops-tools.js.map +1 -1
- package/dist/agent/tools/permission-tools.d.ts +87 -17
- package/dist/agent/tools/permission-tools.js +233 -43
- package/dist/agent/tools/permission-tools.js.map +1 -1
- package/dist/agent/tools/tool-loader.js +5 -4
- package/dist/agent/tools/tool-loader.js.map +1 -1
- package/dist/agent/tools/web-tools.js +58 -9
- package/dist/agent/tools/web-tools.js.map +1 -1
- package/dist/cli/commands/about.d.ts +1 -0
- package/dist/cli/commands/about.js +55 -3
- package/dist/cli/commands/about.js.map +1 -1
- package/dist/cli/commands/admin/audit-workspace.d.ts +23 -0
- package/dist/cli/commands/admin/audit-workspace.js +133 -0
- package/dist/cli/commands/admin/audit-workspace.js.map +1 -0
- package/dist/cli/commands/admin/audit.d.ts +21 -0
- package/dist/cli/commands/admin/audit.js +174 -0
- package/dist/cli/commands/admin/audit.js.map +1 -0
- package/dist/cli/commands/admin/backup.d.ts +54 -0
- package/dist/cli/commands/admin/backup.js +207 -0
- package/dist/cli/commands/admin/backup.js.map +1 -0
- package/dist/cli/commands/admin/folder.d.ts +11 -0
- package/dist/cli/commands/{collection.js → admin/folder.js} +33 -32
- package/dist/cli/commands/admin/folder.js.map +1 -0
- package/dist/cli/commands/admin/index.d.ts +16 -0
- package/dist/cli/commands/admin/index.js +46 -0
- package/dist/cli/commands/admin/index.js.map +1 -0
- package/dist/cli/commands/admin/migrate-assets.d.ts +53 -0
- package/dist/cli/commands/admin/migrate-assets.js +184 -0
- package/dist/cli/commands/admin/migrate-assets.js.map +1 -0
- package/dist/cli/commands/admin/migrate.d.ts +56 -0
- package/dist/cli/commands/admin/migrate.js +263 -0
- package/dist/cli/commands/admin/migrate.js.map +1 -0
- package/dist/cli/commands/admin/restore.d.ts +24 -0
- package/dist/cli/commands/admin/restore.js +228 -0
- package/dist/cli/commands/admin/restore.js.map +1 -0
- package/dist/cli/commands/admin/secrets.d.ts +23 -0
- package/dist/cli/commands/admin/secrets.js +256 -0
- package/dist/cli/commands/admin/secrets.js.map +1 -0
- package/dist/cli/commands/admin/settings.d.ts +28 -0
- package/dist/cli/commands/admin/settings.js +284 -0
- package/dist/cli/commands/admin/settings.js.map +1 -0
- package/dist/cli/commands/admin/token.d.ts +42 -0
- package/dist/cli/commands/admin/token.js +126 -0
- package/dist/cli/commands/admin/token.js.map +1 -0
- package/dist/cli/commands/admin/transfer-ownership.d.ts +15 -0
- package/dist/cli/commands/admin/transfer-ownership.js +106 -0
- package/dist/cli/commands/admin/transfer-ownership.js.map +1 -0
- package/dist/cli/commands/admin/user.d.ts +11 -0
- package/dist/cli/commands/admin/user.js +187 -0
- package/dist/cli/commands/admin/user.js.map +1 -0
- package/dist/cli/commands/clone.d.ts +25 -3
- package/dist/cli/commands/clone.js +142 -36
- package/dist/cli/commands/clone.js.map +1 -1
- package/dist/cli/commands/config.d.ts +23 -107
- package/dist/cli/commands/config.js +52 -260
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/connector.d.ts +10 -13
- package/dist/cli/commands/connector.js +16 -58
- package/dist/cli/commands/connector.js.map +1 -1
- package/dist/cli/commands/deploy.js +215 -68
- package/dist/cli/commands/deploy.js.map +1 -1
- package/dist/cli/commands/index.js +5 -1
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/init.js +10 -30
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/mcp.js +54 -6
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/r2.d.ts +3 -0
- package/dist/cli/commands/r2.js +223 -204
- package/dist/cli/commands/r2.js.map +1 -1
- package/dist/cli/commands/redeploy.js +65 -12
- package/dist/cli/commands/redeploy.js.map +1 -1
- package/dist/cli/commands/reset.d.ts +22 -6
- package/dist/cli/commands/reset.js +132 -34
- package/dist/cli/commands/reset.js.map +1 -1
- package/dist/cli/commands/serve.js +231 -26
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/sync.d.ts +1 -1
- package/dist/cli/commands/sync.js +237 -227
- package/dist/cli/commands/sync.js.map +1 -1
- package/dist/cli/crypto-bundle.d.ts +39 -0
- package/dist/cli/crypto-bundle.js +94 -0
- package/dist/cli/crypto-bundle.js.map +1 -0
- package/dist/cli/index.js +24 -26
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/railway-pin.d.ts +68 -0
- package/dist/cli/railway-pin.js +96 -0
- package/dist/cli/railway-pin.js.map +1 -0
- package/dist/cli/railway-provisioning.d.ts +53 -0
- package/dist/cli/railway-provisioning.js +85 -0
- package/dist/cli/railway-provisioning.js.map +1 -0
- package/dist/cli/setup-wizard.d.ts +30 -49
- package/dist/cli/setup-wizard.js +39 -40
- package/dist/cli/setup-wizard.js.map +1 -1
- package/dist/core/accent-palette.d.ts +22 -0
- package/dist/core/accent-palette.js +47 -0
- package/dist/core/accent-palette.js.map +1 -0
- package/dist/core/artifact-asset-urls.d.ts +51 -0
- package/dist/core/artifact-asset-urls.js +79 -0
- package/dist/core/artifact-asset-urls.js.map +1 -0
- package/dist/core/artifact-bundle.d.ts +69 -0
- package/dist/core/artifact-bundle.js +305 -0
- package/dist/core/artifact-bundle.js.map +1 -0
- package/dist/core/artifact-escape.d.ts +5 -0
- package/dist/core/artifact-escape.js +26 -0
- package/dist/core/artifact-escape.js.map +1 -0
- package/dist/core/artifact-export.d.ts +17 -0
- package/dist/core/artifact-export.js +529 -0
- package/dist/core/artifact-export.js.map +1 -0
- package/dist/core/cell-anchor.d.ts +29 -0
- package/dist/core/cell-anchor.js +53 -0
- package/dist/core/cell-anchor.js.map +1 -0
- package/dist/core/comment-anchor.d.ts +41 -0
- package/dist/core/comment-anchor.js +147 -0
- package/dist/core/comment-anchor.js.map +1 -0
- package/dist/core/commit-messages.d.ts +57 -0
- package/dist/core/commit-messages.js +85 -0
- package/dist/core/commit-messages.js.map +1 -0
- package/dist/core/embeds.d.ts +96 -0
- package/dist/core/embeds.js +187 -0
- package/dist/core/embeds.js.map +1 -0
- package/dist/core/entity-body-templates.d.ts +21 -0
- package/dist/core/entity-body-templates.js +67 -0
- package/dist/core/entity-body-templates.js.map +1 -0
- package/dist/core/entity-types-catalog.d.ts +65 -0
- package/dist/core/entity-types-catalog.js +142 -0
- package/dist/core/entity-types-catalog.js.map +1 -0
- package/dist/core/field-library.d.ts +62 -0
- package/dist/core/field-library.js +129 -0
- package/dist/core/field-library.js.map +1 -0
- package/dist/core/folder-paths.d.ts +41 -0
- package/dist/core/folder-paths.js +95 -0
- package/dist/core/folder-paths.js.map +1 -0
- package/dist/core/folder-sidecar.d.ts +36 -0
- package/dist/core/folder-sidecar.js +91 -0
- package/dist/core/folder-sidecar.js.map +1 -0
- package/dist/core/format-registry.d.ts +170 -0
- package/dist/core/format-registry.js +412 -0
- package/dist/core/format-registry.js.map +1 -0
- package/dist/core/graph.d.ts +773 -14
- package/dist/core/graph.js +2269 -308
- package/dist/core/graph.js.map +1 -1
- package/dist/core/history-diff.d.ts +35 -0
- package/dist/core/history-diff.js +114 -0
- package/dist/core/history-diff.js.map +1 -0
- package/dist/core/refs.d.ts +41 -0
- package/dist/core/refs.js +80 -0
- package/dist/core/refs.js.map +1 -0
- package/dist/core/schema-registry.d.ts +66 -0
- package/dist/core/schema-registry.js +198 -37
- package/dist/core/schema-registry.js.map +1 -1
- package/dist/core/schemas/connector.d.ts +67 -0
- package/dist/core/schemas/connector.js +100 -0
- package/dist/core/schemas/connector.js.map +1 -0
- package/dist/core/schemas/workspace.d.ts +122 -0
- package/dist/core/schemas/workspace.js +211 -0
- package/dist/core/schemas/workspace.js.map +1 -0
- package/dist/core/secrets/SecretStore.d.ts +77 -0
- package/dist/core/secrets/SecretStore.js +13 -0
- package/dist/core/secrets/SecretStore.js.map +1 -0
- package/dist/core/secrets/SettingsResolver.d.ts +54 -0
- package/dist/core/secrets/SettingsResolver.js +80 -0
- package/dist/core/secrets/SettingsResolver.js.map +1 -0
- package/dist/core/secrets/SettingsStore.d.ts +30 -0
- package/dist/core/secrets/SettingsStore.js +9 -0
- package/dist/core/secrets/SettingsStore.js.map +1 -0
- package/dist/core/secrets/SqliteEncryptedStore.d.ts +90 -0
- package/dist/core/secrets/SqliteEncryptedStore.js +598 -0
- package/dist/core/secrets/SqliteEncryptedStore.js.map +1 -0
- package/dist/core/secrets/audit.d.ts +36 -0
- package/dist/core/secrets/audit.js +60 -0
- package/dist/core/secrets/audit.js.map +1 -0
- package/dist/core/secrets/auth-db-migration.d.ts +129 -0
- package/dist/core/secrets/auth-db-migration.js +653 -0
- package/dist/core/secrets/auth-db-migration.js.map +1 -0
- package/dist/core/secrets/bundle.d.ts +141 -0
- package/dist/core/secrets/bundle.js +435 -0
- package/dist/core/secrets/bundle.js.map +1 -0
- package/dist/core/secrets/dual-write.d.ts +81 -0
- package/dist/core/secrets/dual-write.js +247 -0
- package/dist/core/secrets/dual-write.js.map +1 -0
- package/dist/core/secrets/encryption.d.ts +44 -0
- package/dist/core/secrets/encryption.js +97 -0
- package/dist/core/secrets/encryption.js.map +1 -0
- package/dist/core/secrets/index.d.ts +49 -0
- package/dist/core/secrets/index.js +74 -0
- package/dist/core/secrets/index.js.map +1 -0
- package/dist/core/secrets/master-key.d.ts +38 -0
- package/dist/core/secrets/master-key.js +122 -0
- package/dist/core/secrets/master-key.js.map +1 -0
- package/dist/core/secrets/probes/anthropic.d.ts +98 -0
- package/dist/core/secrets/probes/anthropic.js +300 -0
- package/dist/core/secrets/probes/anthropic.js.map +1 -0
- package/dist/core/secrets/probes/brave.d.ts +9 -0
- package/dist/core/secrets/probes/brave.js +15 -0
- package/dist/core/secrets/probes/brave.js.map +1 -0
- package/dist/core/secrets/probes/r2.d.ts +15 -0
- package/dist/core/secrets/probes/r2.js +14 -0
- package/dist/core/secrets/probes/r2.js.map +1 -0
- package/dist/core/secrets/probes/tavily.d.ts +18 -0
- package/dist/core/secrets/probes/tavily.js +58 -0
- package/dist/core/secrets/probes/tavily.js.map +1 -0
- package/dist/core/secrets/probes/voyage.d.ts +13 -0
- package/dist/core/secrets/probes/voyage.js +52 -0
- package/dist/core/secrets/probes/voyage.js.map +1 -0
- package/dist/core/secrets/r2-structural-migration.d.ts +39 -0
- package/dist/core/secrets/r2-structural-migration.js +109 -0
- package/dist/core/secrets/r2-structural-migration.js.map +1 -0
- package/dist/core/secrets/read-flip.d.ts +59 -0
- package/dist/core/secrets/read-flip.js +87 -0
- package/dist/core/secrets/read-flip.js.map +1 -0
- package/dist/core/secrets/registry.d.ts +188 -0
- package/dist/core/secrets/registry.js +616 -0
- package/dist/core/secrets/registry.js.map +1 -0
- package/dist/core/types.d.ts +77 -483
- package/dist/core/types.js +25 -3
- package/dist/core/types.js.map +1 -1
- package/dist/core/user-config.d.ts +75 -12
- package/dist/core/user-config.js +155 -14
- package/dist/core/user-config.js.map +1 -1
- package/dist/core/validation.d.ts +786 -1373
- package/dist/core/validation.js +458 -147
- package/dist/core/validation.js.map +1 -1
- package/dist/core/workspace-manager.d.ts +87 -14
- package/dist/core/workspace-manager.js +222 -94
- package/dist/core/workspace-manager.js.map +1 -1
- package/dist/core/workspace.d.ts +19 -5
- package/dist/core/workspace.js +67 -39
- package/dist/core/workspace.js.map +1 -1
- package/dist/mcp/comment-virtual-entity.d.ts +36 -0
- package/dist/mcp/comment-virtual-entity.js +71 -0
- package/dist/mcp/comment-virtual-entity.js.map +1 -0
- package/dist/mcp/connector-manager.d.ts +72 -2
- package/dist/mcp/connector-manager.js +211 -32
- package/dist/mcp/connector-manager.js.map +1 -1
- package/dist/mcp/oauth-provider.d.ts +0 -5
- package/dist/mcp/oauth-provider.js +10 -22
- package/dist/mcp/oauth-provider.js.map +1 -1
- package/dist/mcp/oauth-registry.d.ts +46 -8
- package/dist/mcp/oauth-registry.js +52 -13
- package/dist/mcp/oauth-registry.js.map +1 -1
- package/dist/mcp/server-discovery.d.ts +73 -0
- package/dist/mcp/server-discovery.js +164 -0
- package/dist/mcp/server-discovery.js.map +1 -0
- package/dist/mcp/server.d.ts +24 -3
- package/dist/mcp/server.js +53 -10
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/state-signer.d.ts +62 -0
- package/dist/mcp/state-signer.js +205 -0
- package/dist/mcp/state-signer.js.map +1 -0
- package/dist/mcp/stdio-proxy.d.ts +67 -0
- package/dist/mcp/stdio-proxy.js +149 -0
- package/dist/mcp/stdio-proxy.js.map +1 -0
- package/dist/mcp/tools.d.ts +65 -2
- package/dist/mcp/tools.js +1772 -124
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/__tests__/helpers/graph-api.d.ts +16 -0
- package/dist/server/__tests__/helpers/graph-api.js +16 -0
- package/dist/server/__tests__/helpers/graph-api.js.map +1 -0
- package/dist/server/artifact-asset-mint.d.ts +41 -0
- package/dist/server/artifact-asset-mint.js +59 -0
- package/dist/server/artifact-asset-mint.js.map +1 -0
- package/dist/server/asset-index-queue.d.ts +98 -0
- package/dist/server/asset-index-queue.js +193 -0
- package/dist/server/asset-index-queue.js.map +1 -0
- package/dist/server/body-write-coordinator.d.ts +161 -0
- package/dist/server/body-write-coordinator.js +182 -0
- package/dist/server/body-write-coordinator.js.map +1 -0
- package/dist/server/cloud-billing-flow.d.ts +32 -0
- package/dist/server/cloud-billing-flow.js +75 -0
- package/dist/server/cloud-billing-flow.js.map +1 -0
- package/dist/server/commit-audit.d.ts +26 -0
- package/dist/server/commit-audit.js +30 -0
- package/dist/server/commit-audit.js.map +1 -0
- package/dist/server/index.d.ts +29 -3
- package/dist/server/index.js +657 -269
- package/dist/server/index.js.map +1 -1
- package/dist/server/middleware/sanitize.d.ts +46 -0
- package/dist/server/middleware/sanitize.js +182 -0
- package/dist/server/middleware/sanitize.js.map +1 -0
- package/dist/server/routes/artifact-export-api.d.ts +11 -0
- package/dist/server/routes/artifact-export-api.js +159 -0
- package/dist/server/routes/artifact-export-api.js.map +1 -0
- package/dist/server/routes/asset-api.d.ts +76 -1
- package/dist/server/routes/asset-api.js +761 -61
- package/dist/server/routes/asset-api.js.map +1 -1
- package/dist/server/routes/audit-api.d.ts +24 -0
- package/dist/server/routes/audit-api.js +117 -0
- package/dist/server/routes/audit-api.js.map +1 -0
- package/dist/server/routes/auth-api.js +532 -63
- package/dist/server/routes/auth-api.js.map +1 -1
- package/dist/server/routes/capture-api.d.ts +10 -3
- package/dist/server/routes/capture-api.js +213 -25
- package/dist/server/routes/capture-api.js.map +1 -1
- package/dist/server/routes/chat-sessions-api.d.ts +13 -0
- package/dist/server/routes/chat-sessions-api.js +455 -0
- package/dist/server/routes/chat-sessions-api.js.map +1 -0
- package/dist/server/routes/chat.d.ts +35 -1
- package/dist/server/routes/chat.js +699 -54
- package/dist/server/routes/chat.js.map +1 -1
- package/dist/server/routes/comments-api.d.ts +15 -0
- package/dist/server/routes/comments-api.js +444 -0
- package/dist/server/routes/comments-api.js.map +1 -0
- package/dist/server/routes/config-api.d.ts +3 -2
- package/dist/server/routes/config-api.js +179 -49
- package/dist/server/routes/config-api.js.map +1 -1
- package/dist/server/routes/connectors-api.js +177 -42
- package/dist/server/routes/connectors-api.js.map +1 -1
- package/dist/server/routes/event-ingest-api.d.ts +15 -0
- package/dist/server/routes/{meeting-ingest-api.js → event-ingest-api.js} +35 -20
- package/dist/server/routes/event-ingest-api.js.map +1 -0
- package/dist/server/routes/field-library-api.d.ts +28 -0
- package/dist/server/routes/field-library-api.js +126 -0
- package/dist/server/routes/field-library-api.js.map +1 -0
- package/dist/server/routes/git-http.d.ts +2 -2
- package/dist/server/routes/git-http.js +86 -39
- package/dist/server/routes/git-http.js.map +1 -1
- package/dist/server/routes/graph-api-access.d.ts +57 -0
- package/dist/server/routes/graph-api-access.js +112 -0
- package/dist/server/routes/graph-api-access.js.map +1 -0
- package/dist/server/routes/graph-api.d.ts +1 -1
- package/dist/server/routes/graph-api.js +1455 -325
- package/dist/server/routes/graph-api.js.map +1 -1
- package/dist/server/routes/health.d.ts +18 -0
- package/dist/server/routes/health.js +74 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/import-batch.d.ts +24 -0
- package/dist/server/routes/import-batch.js +33 -0
- package/dist/server/routes/import-batch.js.map +1 -0
- package/dist/server/routes/insights-api.d.ts +9 -2
- package/dist/server/routes/insights-api.js +355 -61
- package/dist/server/routes/insights-api.js.map +1 -1
- package/dist/server/routes/mcp.d.ts +7 -3
- package/dist/server/routes/mcp.js +24 -8
- package/dist/server/routes/mcp.js.map +1 -1
- package/dist/server/routes/oauth-api.d.ts +67 -0
- package/dist/server/routes/oauth-api.js +421 -0
- package/dist/server/routes/oauth-api.js.map +1 -0
- package/dist/server/routes/permissions-api.d.ts +9 -2
- package/dist/server/routes/permissions-api.js +87 -31
- package/dist/server/routes/permissions-api.js.map +1 -1
- package/dist/server/routes/r2-api.d.ts +25 -0
- package/dist/server/routes/r2-api.js +276 -0
- package/dist/server/routes/r2-api.js.map +1 -0
- package/dist/server/routes/secrets-api.d.ts +36 -0
- package/dist/server/routes/secrets-api.js +308 -0
- package/dist/server/routes/secrets-api.js.map +1 -0
- package/dist/server/routes/settings-api.d.ts +29 -0
- package/dist/server/routes/settings-api.js +180 -0
- package/dist/server/routes/settings-api.js.map +1 -0
- package/dist/server/routes/share-api.d.ts +32 -0
- package/dist/server/routes/share-api.js +234 -0
- package/dist/server/routes/share-api.js.map +1 -0
- package/dist/server/routes/views-api.js +51 -0
- package/dist/server/routes/views-api.js.map +1 -1
- package/dist/server/routes/workspace-api.d.ts +2 -1
- package/dist/server/routes/workspace-api.js +142 -23
- package/dist/server/routes/workspace-api.js.map +1 -1
- package/dist/server/routes/ws.d.ts +3 -2
- package/dist/server/routes/ws.js +68 -17
- package/dist/server/routes/ws.js.map +1 -1
- package/dist/server/session-manager.d.ts +48 -5
- package/dist/server/session-manager.js +182 -14
- package/dist/server/session-manager.js.map +1 -1
- package/dist/server/ws-hub.d.ts +124 -37
- package/dist/server/ws-hub.js +0 -0
- package/dist/server/ws-hub.js.map +1 -1
- package/dist/server/yjs-manager.d.ts +278 -7
- package/dist/server/yjs-manager.js +830 -38
- package/dist/server/yjs-manager.js.map +1 -1
- package/dist/server/yjs-persistence.d.ts +165 -0
- package/dist/server/yjs-persistence.js +557 -0
- package/dist/server/yjs-persistence.js.map +1 -0
- package/dist/server/yjs-text-diff.d.ts +32 -0
- package/dist/server/yjs-text-diff.js +61 -0
- package/dist/server/yjs-text-diff.js.map +1 -0
- package/dist/services/access-control.d.ts +73 -0
- package/dist/services/access-control.js +165 -0
- package/dist/services/access-control.js.map +1 -0
- package/dist/services/artifact-asset-token.d.ts +69 -0
- package/dist/services/artifact-asset-token.js +94 -0
- package/dist/services/artifact-asset-token.js.map +1 -0
- package/dist/services/artifact-library-sources.d.ts +3 -0
- package/dist/services/artifact-library-sources.js +44 -0
- package/dist/services/artifact-library-sources.js.map +1 -0
- package/dist/services/artifact-render-export.d.ts +97 -0
- package/dist/services/artifact-render-export.js +467 -0
- package/dist/services/artifact-render-export.js.map +1 -0
- package/dist/services/asset-caption-service.d.ts +103 -0
- package/dist/services/asset-caption-service.js +186 -0
- package/dist/services/asset-caption-service.js.map +1 -0
- package/dist/services/asset-delete-authz.d.ts +31 -0
- package/dist/services/asset-delete-authz.js +61 -0
- package/dist/services/asset-delete-authz.js.map +1 -0
- package/dist/services/asset-finalize-service.d.ts +58 -0
- package/dist/services/asset-finalize-service.js +216 -0
- package/dist/services/asset-finalize-service.js.map +1 -0
- package/dist/services/asset-import-service.d.ts +111 -0
- package/dist/services/asset-import-service.js +394 -0
- package/dist/services/asset-import-service.js.map +1 -0
- package/dist/services/asset-index-sweep.d.ts +95 -0
- package/dist/services/asset-index-sweep.js +249 -0
- package/dist/services/asset-index-sweep.js.map +1 -0
- package/dist/services/asset-lifecycle-check.d.ts +22 -0
- package/dist/services/asset-lifecycle-check.js +80 -0
- package/dist/services/asset-lifecycle-check.js.map +1 -0
- package/dist/services/asset-listing.d.ts +72 -0
- package/dist/services/asset-listing.js +321 -0
- package/dist/services/asset-listing.js.map +1 -0
- package/dist/services/asset-presign-service.d.ts +68 -0
- package/dist/services/asset-presign-service.js +124 -0
- package/dist/services/asset-presign-service.js.map +1 -0
- package/dist/services/asset-sidecar-service.d.ts +28 -0
- package/dist/services/asset-sidecar-service.js +79 -0
- package/dist/services/asset-sidecar-service.js.map +1 -0
- package/dist/services/asset-upload-errors.d.ts +41 -0
- package/dist/services/asset-upload-errors.js +45 -0
- package/dist/services/asset-upload-errors.js.map +1 -0
- package/dist/services/asset-upload-service.d.ts +56 -0
- package/dist/services/asset-upload-service.js +200 -0
- package/dist/services/asset-upload-service.js.map +1 -0
- package/dist/services/asset-upload-token.d.ts +58 -0
- package/dist/services/asset-upload-token.js +75 -0
- package/dist/services/asset-upload-token.js.map +1 -0
- package/dist/services/assets/asset-index-service.d.ts +127 -0
- package/dist/services/assets/asset-index-service.js +227 -0
- package/dist/services/assets/asset-index-service.js.map +1 -0
- package/dist/services/assets/base.d.ts +128 -2
- package/dist/services/assets/base.js +98 -1
- package/dist/services/assets/base.js.map +1 -1
- package/dist/services/assets/index.d.ts +114 -1
- package/dist/services/assets/index.js +221 -0
- package/dist/services/assets/index.js.map +1 -1
- package/dist/services/assets/local.d.ts +16 -1
- package/dist/services/assets/local.js +110 -1
- package/dist/services/assets/local.js.map +1 -1
- package/dist/services/assets/r2-sync.d.ts +88 -0
- package/dist/services/assets/r2-sync.js +412 -0
- package/dist/services/assets/r2-sync.js.map +1 -0
- package/dist/services/assets/r2.d.ts +87 -1
- package/dist/services/assets/r2.js +203 -1
- package/dist/services/assets/r2.js.map +1 -1
- package/dist/services/auth-service.d.ts +312 -45
- package/dist/services/auth-service.js +1011 -195
- package/dist/services/auth-service.js.map +1 -1
- package/dist/services/chat-session-service.d.ts +188 -0
- package/dist/services/chat-session-service.js +512 -0
- package/dist/services/chat-session-service.js.map +1 -0
- package/dist/services/cloud-inference-billing.d.ts +64 -0
- package/dist/services/cloud-inference-billing.js +313 -0
- package/dist/services/cloud-inference-billing.js.map +1 -0
- package/dist/services/comment-service.d.ts +97 -0
- package/dist/services/comment-service.js +341 -0
- package/dist/services/comment-service.js.map +1 -0
- package/dist/services/comment-virtual-entity.d.ts +36 -0
- package/dist/services/comment-virtual-entity.js +71 -0
- package/dist/services/comment-virtual-entity.js.map +1 -0
- package/dist/services/convert-service.d.ts +64 -0
- package/dist/services/convert-service.js +68 -0
- package/dist/services/convert-service.js.map +1 -0
- package/dist/services/csv-service.d.ts +22 -17
- package/dist/services/csv-service.js +55 -92
- package/dist/services/csv-service.js.map +1 -1
- package/dist/services/entity-mutations.d.ts +213 -0
- package/dist/services/entity-mutations.js +380 -0
- package/dist/services/entity-mutations.js.map +1 -0
- package/dist/services/{meeting-processor.d.ts → event-processor.d.ts} +15 -8
- package/dist/services/{meeting-processor.js → event-processor.js} +124 -65
- package/dist/services/event-processor.js.map +1 -0
- package/dist/services/extract/docx.d.ts +1 -0
- package/dist/services/extract/docx.js +233 -0
- package/dist/services/extract/docx.js.map +1 -0
- package/dist/services/extract/index.d.ts +9 -0
- package/dist/services/extract/index.js +10 -0
- package/dist/services/extract/index.js.map +1 -0
- package/dist/services/extract/pdf.d.ts +13 -0
- package/dist/services/extract/pdf.js +69 -0
- package/dist/services/extract/pdf.js.map +1 -0
- package/dist/services/folder-creation.d.ts +33 -0
- package/dist/services/folder-creation.js +103 -0
- package/dist/services/folder-creation.js.map +1 -0
- package/dist/services/git.d.ts +58 -0
- package/dist/services/git.js +194 -55
- package/dist/services/git.js.map +1 -1
- package/dist/services/graph-maintenance.js +4 -4
- package/dist/services/graph-maintenance.js.map +1 -1
- package/dist/services/import-service.d.ts +51 -2
- package/dist/services/import-service.js +254 -107
- package/dist/services/import-service.js.map +1 -1
- package/dist/services/lint-service.js +10 -17
- package/dist/services/lint-service.js.map +1 -1
- package/dist/services/markdown.d.ts +12 -1
- package/dist/services/markdown.js +77 -9
- package/dist/services/markdown.js.map +1 -1
- package/dist/services/mention-detector.d.ts +23 -0
- package/dist/services/mention-detector.js +47 -0
- package/dist/services/mention-detector.js.map +1 -0
- package/dist/services/model-capabilities.d.ts +41 -0
- package/dist/services/model-capabilities.js +107 -0
- package/dist/services/model-capabilities.js.map +1 -0
- package/dist/services/notification-broadcast.d.ts +32 -0
- package/dist/services/notification-broadcast.js +38 -0
- package/dist/services/notification-broadcast.js.map +1 -0
- package/dist/services/notification-service.d.ts +41 -0
- package/dist/services/notification-service.js +100 -0
- package/dist/services/notification-service.js.map +1 -0
- package/dist/services/oauth-server-store.d.ts +147 -0
- package/dist/services/oauth-server-store.js +319 -0
- package/dist/services/oauth-server-store.js.map +1 -0
- package/dist/services/orphan-service.js +2 -2
- package/dist/services/orphan-service.js.map +1 -1
- package/dist/services/personal-folder.d.ts +40 -0
- package/dist/services/personal-folder.js +101 -0
- package/dist/services/personal-folder.js.map +1 -0
- package/dist/services/scratch-asset-promote.d.ts +57 -0
- package/dist/services/scratch-asset-promote.js +95 -0
- package/dist/services/scratch-asset-promote.js.map +1 -0
- package/dist/services/scratch-attachment-service.d.ts +196 -0
- package/dist/services/scratch-attachment-service.js +347 -0
- package/dist/services/scratch-attachment-service.js.map +1 -0
- package/dist/services/share-link-service.d.ts +57 -0
- package/dist/services/share-link-service.js +142 -0
- package/dist/services/share-link-service.js.map +1 -0
- package/dist/services/user-person-bridge.d.ts +136 -0
- package/dist/services/user-person-bridge.js +340 -0
- package/dist/services/user-person-bridge.js.map +1 -0
- package/dist/services/vector-service.d.ts +282 -5
- package/dist/services/vector-service.js +585 -29
- package/dist/services/vector-service.js.map +1 -1
- package/dist/services/workspace-access.d.ts +108 -0
- package/dist/services/workspace-access.js +149 -0
- package/dist/services/workspace-access.js.map +1 -0
- package/dist/services/workspace-assets-folder.d.ts +46 -0
- package/dist/services/workspace-assets-folder.js +75 -0
- package/dist/services/workspace-assets-folder.js.map +1 -0
- package/dist/utils/git.d.ts +17 -2
- package/dist/utils/git.js +23 -7
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/log.d.ts +42 -0
- package/dist/utils/log.js +137 -0
- package/dist/utils/log.js.map +1 -0
- package/dist/utils/preflight.js +2 -2
- package/dist/utils/preflight.js.map +1 -1
- package/dist/utils/version-checker.d.ts +12 -19
- package/dist/utils/version-checker.js +14 -104
- package/dist/utils/version-checker.js.map +1 -1
- package/dist/web/_app/immutable/assets/0.Bs_z3Z86.css +2 -0
- package/dist/web/_app/immutable/assets/12.DlIf1mKh.css +1 -0
- package/dist/web/_app/immutable/assets/14.7d-Q37wc.css +1 -0
- package/dist/web/_app/immutable/assets/15.BwBFdc1D.css +1 -0
- package/dist/web/_app/immutable/assets/16.CmLrbzZp.css +1 -0
- package/dist/web/_app/immutable/assets/17.DcuK5ZVm.css +1 -0
- package/dist/web/_app/immutable/assets/2.DATnR31c.css +1 -0
- package/dist/web/_app/immutable/assets/3.Cz4H8n_O.css +1 -0
- package/dist/web/_app/immutable/assets/4.aDHteTDy.css +1 -0
- package/dist/web/_app/immutable/assets/6.CF1i5Bon.css +1 -0
- package/dist/web/_app/immutable/assets/ArtifactEntityView.C4ooklFD.css +1 -0
- package/dist/web/_app/immutable/assets/AssetLibraryPicker.BOFW0MvF.css +1 -0
- package/dist/web/_app/immutable/assets/BulkActionsBar.cmKvHzRK.css +1 -0
- package/dist/web/_app/immutable/assets/CalendarView.lBCvS3X5.css +1 -0
- package/dist/web/_app/immutable/assets/CodeMirrorEditor.D6hf2pNJ.css +1 -0
- package/dist/web/_app/immutable/assets/CopyField.DVGZtw4U.css +1 -0
- package/dist/web/_app/immutable/assets/FolderMembersPanel.kiYnDFHu.css +1 -0
- package/dist/web/_app/immutable/assets/FolderMutationDialogs.BMRF6Z3z.css +1 -0
- package/dist/web/_app/immutable/assets/FolderPicker.Cp46GHe2.css +1 -0
- package/dist/web/_app/immutable/assets/GalleryView.WBGxxUQc.css +1 -0
- package/dist/web/_app/immutable/assets/GraphView.BJtGL9py.css +1 -0
- package/dist/web/_app/immutable/assets/KanbanView.BOaI5KFL.css +1 -0
- package/dist/web/_app/immutable/assets/Link.vV0ne105.css +1 -0
- package/dist/web/_app/immutable/assets/Markdown.BZnsSOSf.css +1 -0
- package/dist/web/_app/immutable/assets/Rail.ilusFgD4.css +1 -0
- package/dist/web/_app/immutable/assets/SettingsDialog.D7-BO6S_.css +1 -0
- package/dist/web/_app/immutable/assets/Spinner.UBfl0pab.css +1 -0
- package/dist/web/_app/immutable/assets/StopFilledAlt.fNlDN65D.css +1 -0
- package/dist/web/_app/immutable/assets/TimelineView.xAZBJhHw.css +1 -0
- package/dist/web/_app/immutable/assets/WorkspaceView.C8ErUJAY.css +1 -0
- package/dist/web/_app/immutable/assets/button.BlLm4Dg1.css +1 -0
- package/dist/web/_app/immutable/assets/history-summary.LpmHXasl.css +1 -0
- package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.CvHOgSBP.woff +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2 +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.CB9ihrfo.woff +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2 +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CZTNEAuW.woff2 +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-italic.CsGl1sm0.woff +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CDDApCn2.woff2 +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-400-normal.CYLoc0-x.woff +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.BNK2_mGO.woff2 +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-italic.DpEwFAQM.woff +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.6ng42L7E.woff2 +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-500-normal.BgVn5rGT.woff +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.Cu4Hd6ag.woff +0 -0
- package/dist/web/_app/immutable/assets/ibm-plex-sans-latin-600-normal.CuJfVYMP.woff2 +0 -0
- package/dist/web/_app/immutable/assets/inline-list.cqga56xT.css +1 -0
- package/dist/web/_app/immutable/assets/list-columns.BgnafZ4K.css +1 -0
- package/dist/web/_app/immutable/assets/realtime.DDCWxn3B.css +1 -0
- package/dist/web/_app/immutable/assets/select.B90KUqR4.css +1 -0
- package/dist/web/_app/immutable/assets/sheet.RF9RBmml.css +1 -0
- package/dist/web/_app/immutable/chunks/22qYtvr82.js +1 -0
- package/dist/web/_app/immutable/chunks/3anKjXOb.js +1 -0
- package/dist/web/_app/immutable/chunks/6S9WOky52.js +1 -0
- package/dist/web/_app/immutable/chunks/7xJ91z7d.js +2 -0
- package/dist/web/_app/immutable/chunks/9K8VREGP.js +1 -0
- package/dist/web/_app/immutable/chunks/B-TGZnCt2.js +1 -0
- package/dist/web/_app/immutable/chunks/B-n5q6ku.js +1 -0
- package/dist/web/_app/immutable/chunks/B0p2fSEV2.js +1 -0
- package/dist/web/_app/immutable/chunks/B2oi87jG.js +2 -0
- package/dist/web/_app/immutable/chunks/B4DG5QOL.js +1 -0
- package/dist/web/_app/immutable/chunks/B6Lt-qaJ.js +1 -0
- package/dist/web/_app/immutable/chunks/B6qeIQZz.js +2 -0
- package/dist/web/_app/immutable/chunks/B9KfEsQw2.js +1 -0
- package/dist/web/_app/immutable/chunks/B9VRF5aH2.js +1 -0
- package/dist/web/_app/immutable/chunks/BCQQzpbj.js +1 -0
- package/dist/web/_app/immutable/chunks/BF0vIqH9.js +1 -0
- package/dist/web/_app/immutable/chunks/BFp1K70l.js +8 -0
- package/dist/web/_app/immutable/chunks/BFr7Funh.js +1 -0
- package/dist/web/_app/immutable/chunks/BFx32D20.js +2 -0
- package/dist/web/_app/immutable/chunks/BLs1h0Rh2.js +1 -0
- package/dist/web/_app/immutable/chunks/BPzV_xOS2.js +1 -0
- package/dist/web/_app/immutable/chunks/BRvMjAzW.js +2 -0
- package/dist/web/_app/immutable/chunks/BV-iCKqa.js +1 -0
- package/dist/web/_app/immutable/chunks/BZi3uKF4.js +1 -0
- package/dist/web/_app/immutable/chunks/B_PyL87x.js +2 -0
- package/dist/web/_app/immutable/chunks/BbfHGU9n2.js +1 -0
- package/dist/web/_app/immutable/chunks/Bbqu2-5G2.js +1 -0
- package/dist/web/_app/immutable/chunks/BdWZJpmv.js +2 -0
- package/dist/web/_app/immutable/chunks/BfnFtsiY.js +1 -0
- package/dist/web/_app/immutable/chunks/BlgjVmFM2.js +14 -0
- package/dist/web/_app/immutable/chunks/BmgXvmIG.js +5 -0
- package/dist/web/_app/immutable/chunks/BpCG9m962.js +1 -0
- package/dist/web/_app/immutable/chunks/BrveeylZ.js +1 -0
- package/dist/web/_app/immutable/chunks/BsW72fyR2.js +1 -0
- package/dist/web/_app/immutable/chunks/Bti_XKwx.js +2692 -0
- package/dist/web/_app/immutable/chunks/BuhW5Fr0.js +689 -0
- package/dist/web/_app/immutable/chunks/Bx74EEyn2.js +1 -0
- package/dist/web/_app/immutable/chunks/BxPN0T61.js +1 -0
- package/dist/web/_app/immutable/chunks/Bxk27YWG.js +1 -0
- package/dist/web/_app/immutable/chunks/Bz9lrgGs.js +1 -0
- package/dist/web/_app/immutable/chunks/BzaxZ6j12.js +1 -0
- package/dist/web/_app/immutable/chunks/C1B4xDs0.js +1 -0
- package/dist/web/_app/immutable/chunks/C1trcC8M.js +7 -0
- package/dist/web/_app/immutable/chunks/C3F_NMTk.js +3 -0
- package/dist/web/_app/immutable/chunks/C4PZmGUQ.js +2 -0
- package/dist/web/_app/immutable/chunks/C6wYiSz8.js +1 -0
- package/dist/web/_app/immutable/chunks/C70saxIl.js +1 -0
- package/dist/web/_app/immutable/chunks/C9tvkk7Y2.js +22 -0
- package/dist/web/_app/immutable/chunks/CE1XZ5DE.js +1 -0
- package/dist/web/_app/immutable/chunks/CEahbtnP.js +1 -0
- package/dist/web/_app/immutable/chunks/CGkd0a-c2.js +2 -0
- package/dist/web/_app/immutable/chunks/CIoNEHQ82.js +184 -0
- package/dist/web/_app/immutable/chunks/CK4z74w5.js +1 -0
- package/dist/web/_app/immutable/chunks/COo3HgCZ.js +1 -0
- package/dist/web/_app/immutable/chunks/CP8_U450.js +1 -0
- package/dist/web/_app/immutable/chunks/CQ1eQ93t2.js +6 -0
- package/dist/web/_app/immutable/chunks/CQyBDccA.js +1 -0
- package/dist/web/_app/immutable/chunks/CUFQMEBn2.js +1 -0
- package/dist/web/_app/immutable/chunks/CV5OSCJc.js +1 -0
- package/dist/web/_app/immutable/chunks/CYEYDNwO2.js +1 -0
- package/dist/web/_app/immutable/chunks/CZgFaimi2.js +83 -0
- package/dist/web/_app/immutable/chunks/CZqvCTCv.js +1 -0
- package/dist/web/_app/immutable/chunks/CcWaWbrI2.js +1 -0
- package/dist/web/_app/immutable/chunks/CdtzRVZK.js +1 -0
- package/dist/web/_app/immutable/chunks/CfYG72Hc.js +1 -0
- package/dist/web/_app/immutable/chunks/Cg-ND0cl.js +1 -0
- package/dist/web/_app/immutable/chunks/Ch1kmm3J2.js +1 -0
- package/dist/web/_app/immutable/chunks/CjiMLTwX2.js +1 -0
- package/dist/web/_app/immutable/chunks/CkoeSJ8K.js +6 -0
- package/dist/web/_app/immutable/chunks/Cp87MaaW.js +1 -0
- package/dist/web/_app/immutable/chunks/Cqm5wAin.js +1 -0
- package/dist/web/_app/immutable/chunks/Ct1uesdW.js +2 -0
- package/dist/web/_app/immutable/chunks/CuYPXpWU.js +1 -0
- package/dist/web/_app/immutable/chunks/Cuq8J2Td.js +1 -0
- package/dist/web/_app/immutable/chunks/CvSEodTA.js +1 -0
- package/dist/web/_app/immutable/chunks/CvqqrTrj.js +1 -0
- package/dist/web/_app/immutable/chunks/CzXdOgVl2.js +7 -0
- package/dist/web/_app/immutable/chunks/D0W7c6Sg.js +185 -0
- package/dist/web/_app/immutable/chunks/D0rBhnqB2.js +1 -0
- package/dist/web/_app/immutable/chunks/D1-GZSN1.js +1 -0
- package/dist/web/_app/immutable/chunks/D1BLcNJ7.js +1 -0
- package/dist/web/_app/immutable/chunks/D1yvtriI.js +1 -0
- package/dist/web/_app/immutable/chunks/D25NMRpl.js +1 -0
- package/dist/web/_app/immutable/chunks/D3F675fu2.js +2 -0
- package/dist/web/_app/immutable/chunks/D4UrtGC1.js +1 -0
- package/dist/web/_app/immutable/chunks/D8wZ2xul.js +4 -0
- package/dist/web/_app/immutable/chunks/DA3HEX3X.js +1 -0
- package/dist/web/_app/immutable/chunks/DE2TCESD2.js +1 -0
- package/dist/web/_app/immutable/chunks/DN-gIRdd2.js +1 -0
- package/dist/web/_app/immutable/chunks/DPUVu0T0.js +1 -0
- package/dist/web/_app/immutable/chunks/DPcejqKZ.js +1 -0
- package/dist/web/_app/immutable/chunks/DQMWa2t7.js +1 -0
- package/dist/web/_app/immutable/chunks/DRPMJ-3M.js +2 -0
- package/dist/web/_app/immutable/chunks/DRPnwQAm.js +1 -0
- package/dist/web/_app/immutable/chunks/DXmfl4Hq2.js +1 -0
- package/dist/web/_app/immutable/chunks/DXsFrDer2.js +1 -0
- package/dist/web/_app/immutable/chunks/DYc5KW3F.js +1 -0
- package/dist/web/_app/immutable/chunks/DcPrzUMJ.js +1 -0
- package/dist/web/_app/immutable/chunks/DdQVGKgM.js +1 -0
- package/dist/web/_app/immutable/chunks/DiFfW--R2.js +2 -0
- package/dist/web/_app/immutable/chunks/DjIqa1_y.js +2 -0
- package/dist/web/_app/immutable/chunks/Dl63IUnH2.js +224 -0
- package/dist/web/_app/immutable/chunks/DmFdD6zT.js +1 -0
- package/dist/web/_app/immutable/chunks/Dn3R8S-U.js +1 -0
- package/dist/web/_app/immutable/chunks/DpNr1GQ2.js +1 -0
- package/dist/web/_app/immutable/chunks/DsFEd8vL2.js +1 -0
- package/dist/web/_app/immutable/chunks/DuxziNnw.js +1 -0
- package/dist/web/_app/immutable/chunks/FuiJnZG0.js +1 -0
- package/dist/web/_app/immutable/chunks/Izkulod7.js +1 -0
- package/dist/web/_app/immutable/chunks/LYL8znYR2.js +23 -0
- package/dist/web/_app/immutable/chunks/ON7Gta0d.js +1 -0
- package/dist/web/_app/immutable/chunks/PhN6tXuJ2.js +1 -0
- package/dist/web/_app/immutable/chunks/PyLyCpuL.js +1 -0
- package/dist/web/_app/immutable/chunks/RG7IUVLn2.js +2 -0
- package/dist/web/_app/immutable/chunks/RK4gCWN0.js +2 -0
- package/dist/web/_app/immutable/chunks/SnzlL4ZV.js +1 -0
- package/dist/web/_app/immutable/chunks/XcWejFm_.js +5 -0
- package/dist/web/_app/immutable/chunks/Xkp5hSA1.js +1 -0
- package/dist/web/_app/immutable/chunks/al_nidE9.js +1 -0
- package/dist/web/_app/immutable/chunks/e50-izZO2.js +1 -0
- package/dist/web/_app/immutable/chunks/golz20La2.js +2 -0
- package/dist/web/_app/immutable/chunks/hG-QCarB.js +1 -0
- package/dist/web/_app/immutable/chunks/kNaey6uv.js +1 -0
- package/dist/web/_app/immutable/chunks/nXReuy-5.js +3 -0
- package/dist/web/_app/immutable/chunks/o61aZZFZ2.js +66 -0
- package/dist/web/_app/immutable/chunks/qwr5boV8.js +1 -0
- package/dist/web/_app/immutable/chunks/rjjyuGhb.js +1 -0
- package/dist/web/_app/immutable/chunks/uBgoVlsx.js +3 -0
- package/dist/web/_app/immutable/chunks/xY0c-UfG.js +3 -0
- package/dist/web/_app/immutable/chunks/xeNvx4Bl2.js +1 -0
- package/dist/web/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/web/_app/immutable/entry/app.dqA31fjy.js +2 -0
- package/dist/web/_app/immutable/entry/start.BB1XrAea.js +1 -0
- package/dist/web/_app/immutable/nodes/0.dALEQAnu.js +2 -0
- package/dist/web/_app/immutable/nodes/1.aFU32Ukj.js +1 -0
- package/dist/web/_app/immutable/nodes/10.C2wB-OnW.js +1 -0
- package/dist/web/_app/immutable/nodes/11.CI3a8Rj5.js +1 -0
- package/dist/web/_app/immutable/nodes/12.CsTvsKzB.js +1 -0
- package/dist/web/_app/immutable/nodes/13.CYrHXCA8.js +1 -0
- package/dist/web/_app/immutable/nodes/14.B31HrQ26.js +1 -0
- package/dist/web/_app/immutable/nodes/15.DxgnZ61h.js +1 -0
- package/dist/web/_app/immutable/nodes/16.DAP8cwVb.js +1 -0
- package/dist/web/_app/immutable/nodes/17.CmwXtCDY.js +77 -0
- package/dist/web/_app/immutable/nodes/2.BuGXEIlN.js +122 -0
- package/dist/web/_app/immutable/nodes/3.CmhZJWHE.js +6 -0
- package/dist/web/_app/immutable/nodes/4.BRfoPTm-.js +11 -0
- package/dist/web/_app/immutable/nodes/5.C7gPuZM_.js +1 -0
- package/dist/web/_app/immutable/nodes/6.qKODVbya.js +6 -0
- package/dist/web/_app/immutable/nodes/7.BZO37zAJ.js +1 -0
- package/dist/web/_app/immutable/nodes/8.xF_Dd1BO.js +1 -0
- package/dist/web/_app/immutable/nodes/9.Bow4UyKV.js +1 -0
- package/dist/web/_app/version.json +1 -1
- package/dist/web/favicon-16.png +0 -0
- package/dist/web/favicon-180.png +0 -0
- package/dist/web/favicon-32.png +0 -0
- package/dist/web/favicon-48.png +0 -0
- package/dist/web/favicon-512.png +0 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +8 -0
- package/dist/web/index.html +56 -21
- package/dist/web/studiograph-logomark.svg +29 -0
- package/package.json +35 -14
- package/dist/agent/skills/enrich-entities.md +0 -136
- package/dist/agent/skills/sync-configuration.md +0 -119
- package/dist/agent/skills/sync-setup.md +0 -82
- package/dist/agent/tools/message-tools.d.ts +0 -42
- package/dist/agent/tools/message-tools.js +0 -106
- package/dist/agent/tools/message-tools.js.map +0 -1
- package/dist/cli/commands/app.d.ts +0 -7
- package/dist/cli/commands/app.js +0 -268
- package/dist/cli/commands/app.js.map +0 -1
- package/dist/cli/commands/clear.d.ts +0 -5
- package/dist/cli/commands/clear.js +0 -36
- package/dist/cli/commands/clear.js.map +0 -1
- package/dist/cli/commands/collection.d.ts +0 -10
- package/dist/cli/commands/collection.js.map +0 -1
- package/dist/cli/commands/join.d.ts +0 -9
- package/dist/cli/commands/join.js +0 -255
- package/dist/cli/commands/join.js.map +0 -1
- package/dist/cli/commands/start.d.ts +0 -7
- package/dist/cli/commands/start.js +0 -584
- package/dist/cli/commands/start.js.map +0 -1
- package/dist/cli/commands/update.d.ts +0 -8
- package/dist/cli/commands/update.js +0 -155
- package/dist/cli/commands/update.js.map +0 -1
- package/dist/cli/commands/user.d.ts +0 -7
- package/dist/cli/commands/user.js +0 -175
- package/dist/cli/commands/user.js.map +0 -1
- package/dist/cli/scaffolding.d.ts +0 -12
- package/dist/cli/scaffolding.js +0 -397
- package/dist/cli/scaffolding.js.map +0 -1
- package/dist/integrations/registry.d.ts +0 -8
- package/dist/integrations/registry.js +0 -7
- package/dist/integrations/registry.js.map +0 -1
- package/dist/integrations/types.d.ts +0 -43
- package/dist/integrations/types.js +0 -5
- package/dist/integrations/types.js.map +0 -1
- package/dist/lib/lib/utils.d.ts +0 -2
- package/dist/lib/lib/utils.js +0 -6
- package/dist/lib/lib/utils.js.map +0 -1
- package/dist/server/chrome/chrome.css +0 -691
- package/dist/server/chrome/chrome.js +0 -374
- package/dist/server/commit-scheduler.d.ts +0 -39
- package/dist/server/commit-scheduler.js +0 -113
- package/dist/server/commit-scheduler.js.map +0 -1
- package/dist/server/plugin-loader.d.ts +0 -53
- package/dist/server/plugin-loader.js +0 -155
- package/dist/server/plugin-loader.js.map +0 -1
- package/dist/server/routes/meeting-ingest-api.d.ts +0 -14
- package/dist/server/routes/meeting-ingest-api.js.map +0 -1
- package/dist/server/routes/messages-api.d.ts +0 -15
- package/dist/server/routes/messages-api.js +0 -385
- package/dist/server/routes/messages-api.js.map +0 -1
- package/dist/services/batch-processor.d.ts +0 -49
- package/dist/services/batch-processor.js +0 -166
- package/dist/services/batch-processor.js.map +0 -1
- package/dist/services/briefing.d.ts +0 -26
- package/dist/services/briefing.js +0 -230
- package/dist/services/briefing.js.map +0 -1
- package/dist/services/heartbeat.d.ts +0 -28
- package/dist/services/heartbeat.js +0 -183
- package/dist/services/heartbeat.js.map +0 -1
- package/dist/services/image-import-service.d.ts +0 -24
- package/dist/services/image-import-service.js +0 -108
- package/dist/services/image-import-service.js.map +0 -1
- package/dist/services/insights.d.ts +0 -38
- package/dist/services/insights.js +0 -269
- package/dist/services/insights.js.map +0 -1
- package/dist/services/meeting-processor.js.map +0 -1
- package/dist/services/message-service.d.ts +0 -68
- package/dist/services/message-service.js +0 -220
- package/dist/services/message-service.js.map +0 -1
- package/dist/utils/workspace-config.d.ts +0 -8
- package/dist/utils/workspace-config.js +0 -22
- package/dist/utils/workspace-config.js.map +0 -1
- package/dist/web/_app/immutable/assets/0.uPSqtsC5.css +0 -1
- package/dist/web/_app/immutable/assets/11.2jzI3cZY.css +0 -1
- package/dist/web/_app/immutable/assets/12.CT4xL4K3.css +0 -1
- package/dist/web/_app/immutable/assets/13.BUrHkYry.css +0 -1
- package/dist/web/_app/immutable/assets/2.DpQWr6q6.css +0 -1
- package/dist/web/_app/immutable/assets/3.BxdqT7zi.css +0 -1
- package/dist/web/_app/immutable/assets/4.DdNqjd0T.css +0 -1
- package/dist/web/_app/immutable/assets/5.DFeGxLYf.css +0 -1
- package/dist/web/_app/immutable/assets/6.DXZr_rI_.css +0 -1
- package/dist/web/_app/immutable/assets/GraphView.D9ePpZez.css +0 -1
- package/dist/web/_app/immutable/assets/Toaster.rN8r_Hzv.css +0 -1
- package/dist/web/_app/immutable/assets/ViewToolbar.BWE03S64.css +0 -1
- package/dist/web/_app/immutable/assets/WorkspaceView.CgGDi_Zb.css +0 -1
- package/dist/web/_app/immutable/assets/wikilinks.CzMBkUMB.css +0 -1
- package/dist/web/_app/immutable/chunks/-jG4Obyh.js +0 -4
- package/dist/web/_app/immutable/chunks/2nrc8f_O.js +0 -1
- package/dist/web/_app/immutable/chunks/7S2LGqoP.js +0 -2
- package/dist/web/_app/immutable/chunks/8Q3ZJIYu.js +0 -1
- package/dist/web/_app/immutable/chunks/B8ydT5xX.js +0 -2
- package/dist/web/_app/immutable/chunks/BB_5th5W.js +0 -3383
- package/dist/web/_app/immutable/chunks/BEn6N5c2.js +0 -1
- package/dist/web/_app/immutable/chunks/BFNNbCAM.js +0 -1
- package/dist/web/_app/immutable/chunks/BFZZEUd5.js +0 -1
- package/dist/web/_app/immutable/chunks/BGk8kfOE.js +0 -2
- package/dist/web/_app/immutable/chunks/BIBCr278.js +0 -1
- package/dist/web/_app/immutable/chunks/BPCkkwqe.js +0 -1
- package/dist/web/_app/immutable/chunks/BhlvzGQx.js +0 -1
- package/dist/web/_app/immutable/chunks/BlMknQeF.js +0 -1
- package/dist/web/_app/immutable/chunks/BpjqrlCg.js +0 -1
- package/dist/web/_app/immutable/chunks/BrebtJNG.js +0 -18
- package/dist/web/_app/immutable/chunks/ByBZ7JIT.js +0 -1
- package/dist/web/_app/immutable/chunks/Bz0ZjVQE.js +0 -2
- package/dist/web/_app/immutable/chunks/BzJTZOK2.js +0 -1
- package/dist/web/_app/immutable/chunks/C0LVZhvn.js +0 -1
- package/dist/web/_app/immutable/chunks/C3uBrOS5.js +0 -1
- package/dist/web/_app/immutable/chunks/C8R1sDpW.js +0 -1
- package/dist/web/_app/immutable/chunks/CDRHrs0g.js +0 -1
- package/dist/web/_app/immutable/chunks/CN5_py1I.js +0 -1
- package/dist/web/_app/immutable/chunks/CPne482a.js +0 -1
- package/dist/web/_app/immutable/chunks/CSVbGg_b.js +0 -5
- package/dist/web/_app/immutable/chunks/CWyU-seV.js +0 -1
- package/dist/web/_app/immutable/chunks/CX_61fY8.js +0 -5
- package/dist/web/_app/immutable/chunks/CYrVHOHA.js +0 -1
- package/dist/web/_app/immutable/chunks/ChsiIm-E.js +0 -1
- package/dist/web/_app/immutable/chunks/CojKppbh.js +0 -1
- package/dist/web/_app/immutable/chunks/CqkleIqs.js +0 -1
- package/dist/web/_app/immutable/chunks/CtYLtD7K.js +0 -1
- package/dist/web/_app/immutable/chunks/D-HDy5qS.js +0 -1
- package/dist/web/_app/immutable/chunks/D5aIYSkd.js +0 -1
- package/dist/web/_app/immutable/chunks/DBBXARVf.js +0 -1
- package/dist/web/_app/immutable/chunks/DFYOFE3o.js +0 -1
- package/dist/web/_app/immutable/chunks/DKaafS50.js +0 -1
- package/dist/web/_app/immutable/chunks/DUMOo1Pn.js +0 -1
- package/dist/web/_app/immutable/chunks/DWX9f6AF.js +0 -1
- package/dist/web/_app/immutable/chunks/DY8-EONP.js +0 -1
- package/dist/web/_app/immutable/chunks/DkoHPElM.js +0 -1
- package/dist/web/_app/immutable/chunks/DnL9f0lc.js +0 -7
- package/dist/web/_app/immutable/chunks/DsnmJJEf.js +0 -1
- package/dist/web/_app/immutable/chunks/DujahU3W.js +0 -1
- package/dist/web/_app/immutable/chunks/FBn-ES5k.js +0 -1
- package/dist/web/_app/immutable/chunks/HQpwSOb3.js +0 -1
- package/dist/web/_app/immutable/chunks/KyjzlWRN.js +0 -2
- package/dist/web/_app/immutable/chunks/PPVm8Dsz.js +0 -1
- package/dist/web/_app/immutable/chunks/UwYfmrPx.js +0 -2
- package/dist/web/_app/immutable/chunks/W25ZVI8L.js +0 -2
- package/dist/web/_app/immutable/chunks/WSUKABI_.js +0 -1
- package/dist/web/_app/immutable/chunks/XjsuEnNE.js +0 -5
- package/dist/web/_app/immutable/chunks/ZD6ARAtb.js +0 -1
- package/dist/web/_app/immutable/chunks/pBTf4stg.js +0 -2
- package/dist/web/_app/immutable/chunks/rGIarcnp.js +0 -1
- package/dist/web/_app/immutable/chunks/vxEtkL8z.js +0 -1
- package/dist/web/_app/immutable/chunks/wDIGUaoU.js +0 -3
- package/dist/web/_app/immutable/entry/app.ZHiTM8WS.js +0 -2
- package/dist/web/_app/immutable/entry/start.C9-FfAVc.js +0 -1
- package/dist/web/_app/immutable/nodes/0.D92Orz8k.js +0 -2
- package/dist/web/_app/immutable/nodes/1.BMdyglM_.js +0 -1
- package/dist/web/_app/immutable/nodes/10.BilVDHSL.js +0 -1
- package/dist/web/_app/immutable/nodes/11.CS9E0Hic.js +0 -1
- package/dist/web/_app/immutable/nodes/12.D3BOxYzG.js +0 -1
- package/dist/web/_app/immutable/nodes/13.CClMKYtN.js +0 -1
- package/dist/web/_app/immutable/nodes/2.C8KKElPL.js +0 -267
- package/dist/web/_app/immutable/nodes/3.C5n5uiWU.js +0 -5
- package/dist/web/_app/immutable/nodes/4.DcGVvgiL.js +0 -11
- package/dist/web/_app/immutable/nodes/5.DxIjnFQO.js +0 -4
- package/dist/web/_app/immutable/nodes/6.lkh6kKLj.js +0 -1
- package/dist/web/_app/immutable/nodes/7.Bg7SbfhA.js +0 -1
- package/dist/web/_app/immutable/nodes/8.DcXWCNBE.js +0 -1
- package/dist/web/_app/immutable/nodes/9.Cefi8Jfm.js +0 -1
|
@@ -2,132 +2,64 @@
|
|
|
2
2
|
* Graph API routes
|
|
3
3
|
*
|
|
4
4
|
* REST façade over WorkspaceManager.
|
|
5
|
-
* Enforces per-
|
|
5
|
+
* Enforces per-folder access when users are authenticated via JWT.
|
|
6
6
|
* API key / open mode → full access (no filtering).
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { isWorkspaceOwner } from '../../services/auth-service.js';
|
|
9
|
+
import { WorkspaceAccessService } from '../../services/workspace-access.js';
|
|
9
10
|
import { Workspace } from '../../core/workspace.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return !repo.owner_id; // API key / open mode — only if no owner set
|
|
25
|
-
if (!repo.owner_id)
|
|
26
|
-
return false; // Private without owner — hidden from all users
|
|
27
|
-
return user.id === repo.owner_id;
|
|
28
|
-
}
|
|
29
|
-
/** Get the list of repos the current user can access. */
|
|
30
|
-
function getAccessibleRepos(req, workspaceManager, authService) {
|
|
31
|
-
const allRepos = workspaceManager.getAllRepoConfigs();
|
|
32
|
-
const user = req.user;
|
|
33
|
-
// Filter out private repos owned by other users (applies to all roles)
|
|
34
|
-
const visible = allRepos.filter(r => canSeePrivateRepo(r, user));
|
|
35
|
-
if (!user)
|
|
36
|
-
return visible; // API key / open mode → full access
|
|
37
|
-
if (isAdminOrOwner(user.role))
|
|
38
|
-
return visible;
|
|
39
|
-
const allowed = new Set(authService.getUserCollections(user.id));
|
|
40
|
-
return visible.filter(r => r.private || allowed.has(r.name));
|
|
41
|
-
}
|
|
42
|
-
/** Check if the current user can access a specific repo. */
|
|
43
|
-
function hasRepoAccess(req, repoName, workspaceManager, authService) {
|
|
44
|
-
const user = req.user;
|
|
45
|
-
const repoConfig = workspaceManager.getRepoConfig(repoName);
|
|
46
|
-
if (repoConfig && !canSeePrivateRepo(repoConfig, user))
|
|
47
|
-
return false;
|
|
48
|
-
if (!user)
|
|
49
|
-
return true; // API key / open mode
|
|
50
|
-
if (repoConfig?.private && repoConfig?.owner_id === user.id)
|
|
51
|
-
return true; // Owner of private repo
|
|
52
|
-
if (isAdminOrOwner(user.role))
|
|
53
|
-
return true;
|
|
54
|
-
return authService.getUserCollections(user.id).includes(repoName);
|
|
55
|
-
}
|
|
56
|
-
/** Check if the current user is an admin. */
|
|
57
|
-
function isAdmin(req) {
|
|
58
|
-
const user = req.user;
|
|
59
|
-
if (!user)
|
|
60
|
-
return true; // API key / open mode → admin-equivalent
|
|
61
|
-
return isAdminOrOwner(user.role);
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Build a set of all entity IDs visible to the current user.
|
|
65
|
-
* Used to filter wikilinks so users don't see links to entities
|
|
66
|
-
* in collections they can't access.
|
|
67
|
-
*/
|
|
68
|
-
function getAccessibleEntityIds(req, workspaceManager, authService) {
|
|
69
|
-
const user = req.user;
|
|
70
|
-
if (!user)
|
|
71
|
-
return null; // API key / open mode → no filtering
|
|
72
|
-
if (isAdminOrOwner(user.role)) {
|
|
73
|
-
// Admins see everything except other users' private repos
|
|
74
|
-
const allRepos = workspaceManager.getAllRepoConfigs();
|
|
75
|
-
const hasHiddenPrivate = allRepos.some(r => r.private && r.owner_id && r.owner_id !== user.id);
|
|
76
|
-
if (!hasHiddenPrivate)
|
|
77
|
-
return null; // No filtering needed
|
|
78
|
-
}
|
|
79
|
-
const repos = getAccessibleRepos(req, workspaceManager, authService);
|
|
80
|
-
const ids = new Set();
|
|
81
|
-
for (const repo of repos) {
|
|
82
|
-
try {
|
|
83
|
-
const graph = workspaceManager.getGraph(repo.name);
|
|
84
|
-
for (const e of graph.search({})) {
|
|
85
|
-
ids.add(e.id);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch { /* skip */ }
|
|
89
|
-
}
|
|
90
|
-
return ids;
|
|
91
|
-
}
|
|
92
|
-
/** Filter wikilinks to only those pointing to accessible entities. */
|
|
93
|
-
function filterWikilinks(wikilinks, accessibleIds) {
|
|
94
|
-
if (!accessibleIds)
|
|
95
|
-
return wikilinks;
|
|
96
|
-
return wikilinks.filter(id => accessibleIds.has(id));
|
|
97
|
-
}
|
|
11
|
+
import { createSharedFolder, FolderCreationError } from '../../services/folder-creation.js';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
13
|
+
import { dirname, join } from 'path';
|
|
14
|
+
import { RESERVED_ENTITY_TYPES } from '../../core/validation.js';
|
|
15
|
+
import { validateFolderPath } from '../../core/folder-paths.js';
|
|
16
|
+
import { deriveAssetDisplayName, stripFrontmatter } from '../../core/embeds.js';
|
|
17
|
+
import { parseFrontmatterCacheFree } from '../../core/history-diff.js';
|
|
18
|
+
import { StaleBaseRevisionError } from '../../core/graph.js';
|
|
19
|
+
import { checkEagerSkillPermission, filterWikilinks, getAccessibleEntityIds, getAccessibleRepos, getContentRepos, graphForAdmin as resolveGraphForAdmin, graphForRead as resolveGraphForRead, graphForWrite as resolveGraphForWrite, isFolderAdminOrOwner, reqGitUser, reqUser, } from './graph-api-access.js';
|
|
20
|
+
import { BridgeError, assertCanChangeUserHandle, validateUserHandleInPayload } from '../../services/user-person-bridge.js';
|
|
21
|
+
import { assertEntityRefs, EntityRefError, UnknownUserRefError } from '../../services/entity-mutations.js';
|
|
22
|
+
import { assetDeleteDenial } from '../../services/asset-delete-authz.js';
|
|
23
|
+
import { clearLegacyBucketCache } from '../../services/asset-listing.js';
|
|
24
|
+
import { log } from '../../utils/log.js';
|
|
98
25
|
export async function registerGraphApiRoutes(fastify, workspaceManager, authService, wsHub, sessionManager) {
|
|
99
|
-
//
|
|
26
|
+
// ARCHITECTURAL BOUNDARY: every per-route graph resolution flows through
|
|
27
|
+
// this service so the act of getting a graph IS the access check. Do not
|
|
28
|
+
// call `workspaceManager.getGraph(repo)` from a request-scoped handler —
|
|
29
|
+
// use `accessService.graphFor*(reqUser(req), repo)` instead. A security
|
|
30
|
+
// audit (2026-05) flagged the previous opt-in pattern (route had to
|
|
31
|
+
// remember `hasRepoAccess()` before `getGraph()`) as an architectural
|
|
32
|
+
// risk; if you find yourself reaching for the raw WM here, that's the
|
|
33
|
+
// smell the audit was about.
|
|
34
|
+
const accessService = new WorkspaceAccessService(workspaceManager, authService);
|
|
35
|
+
const graphForRead = (req, reply, repoName) => resolveGraphForRead(accessService, req, reply, repoName);
|
|
36
|
+
const graphForWrite = (req, reply, repoName) => resolveGraphForWrite(accessService, req, reply, repoName);
|
|
37
|
+
const graphForAdmin = (req, reply, repoName) => resolveGraphForAdmin(accessService, req, reply, repoName);
|
|
38
|
+
// POST /api/repos — any authenticated user can create a folder.
|
|
39
|
+
// The creator is granted folder-admin access automatically.
|
|
40
|
+
// Shared logic lives in services/folder-creation.ts so the MCP tool
|
|
41
|
+
// create_repo behaves identically.
|
|
100
42
|
fastify.post('/api/repos', async (req, reply) => {
|
|
101
|
-
|
|
102
|
-
return reply.status(403).send({ error: 'Admin access required' });
|
|
103
|
-
}
|
|
43
|
+
const user = req.user;
|
|
104
44
|
const { name, display_name, description } = req.body || {};
|
|
105
|
-
if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
106
|
-
return reply.status(400).send({ error: 'Collection name must be kebab-case' });
|
|
107
|
-
}
|
|
108
45
|
try {
|
|
109
|
-
const
|
|
110
|
-
const repo = await workspace.registerRepo({
|
|
111
|
-
name,
|
|
112
|
-
display_name: display_name || undefined,
|
|
113
|
-
description,
|
|
114
|
-
});
|
|
115
|
-
workspaceManager.reloadConfig();
|
|
116
|
-
wsHub?.broadcast({
|
|
117
|
-
type: 'collection_created', repo: name,
|
|
118
|
-
actor: req.user?.displayName ?? 'API',
|
|
119
|
-
timestamp: new Date().toISOString(),
|
|
120
|
-
});
|
|
46
|
+
const repo = await createSharedFolder(workspaceManager, authService, { name, display_name, description }, user, wsHub);
|
|
121
47
|
return reply.status(201).send(repo);
|
|
122
48
|
}
|
|
123
49
|
catch (err) {
|
|
124
|
-
|
|
50
|
+
if (err instanceof FolderCreationError) {
|
|
51
|
+
const status = err.code === 'invalid_name' ? 400
|
|
52
|
+
: err.code === 'duplicate' ? 409
|
|
53
|
+
: 500;
|
|
54
|
+
return reply.status(status).send({ error: err.message });
|
|
55
|
+
}
|
|
56
|
+
return reply.status(500).send({ error: err?.message ?? String(err) });
|
|
125
57
|
}
|
|
126
58
|
});
|
|
127
|
-
// DELETE /api/repos/:repo —
|
|
59
|
+
// DELETE /api/repos/:repo — folder admin or workspace owner only.
|
|
128
60
|
fastify.delete('/api/repos/:repo', async (req, reply) => {
|
|
129
|
-
if (!
|
|
130
|
-
return reply.status(403).send({ error: '
|
|
61
|
+
if (!isFolderAdminOrOwner(req, req.params.repo, workspaceManager, authService)) {
|
|
62
|
+
return reply.status(403).send({ error: 'Folder admin access required' });
|
|
131
63
|
}
|
|
132
64
|
const { repo } = req.params;
|
|
133
65
|
try {
|
|
@@ -135,14 +67,19 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
135
67
|
const config = await workspace.loadConfig();
|
|
136
68
|
const repoIndex = config.repos.findIndex(r => r.name === repo);
|
|
137
69
|
if (repoIndex === -1) {
|
|
138
|
-
return reply.status(404).send({ error: `
|
|
70
|
+
return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
|
|
139
71
|
}
|
|
140
72
|
const repoConfig = config.repos[repoIndex];
|
|
73
|
+
// System folders (the reserved Workspace Assets store) are an app
|
|
74
|
+
// invariant — undeletable, so nothing needs to recreate them on boot.
|
|
75
|
+
if (repoConfig.system) {
|
|
76
|
+
return reply.status(403).send({ error: 'This is a system folder and cannot be deleted.' });
|
|
77
|
+
}
|
|
141
78
|
const repoPath = join(workspaceManager['workspacePath'], repoConfig.path);
|
|
142
79
|
// Remove from config
|
|
143
80
|
config.repos.splice(repoIndex, 1);
|
|
144
81
|
workspace.writeConfig(config);
|
|
145
|
-
workspace.pushWorkspaceConfig(`Remove
|
|
82
|
+
workspace.pushWorkspaceConfig(`Remove folder: ${repo}`);
|
|
146
83
|
// Delete files
|
|
147
84
|
const { rmSync } = await import('fs');
|
|
148
85
|
if (existsSync(repoPath)) {
|
|
@@ -150,7 +87,7 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
150
87
|
}
|
|
151
88
|
workspaceManager.reloadConfig();
|
|
152
89
|
wsHub?.broadcast({
|
|
153
|
-
type: '
|
|
90
|
+
type: 'folder_deleted', repo,
|
|
154
91
|
actor: req.user?.displayName ?? 'API',
|
|
155
92
|
timestamp: new Date().toISOString(),
|
|
156
93
|
});
|
|
@@ -160,16 +97,16 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
160
97
|
return reply.status(500).send({ error: err.message });
|
|
161
98
|
}
|
|
162
99
|
});
|
|
163
|
-
// PATCH /api/repos/:repo — update
|
|
100
|
+
// PATCH /api/repos/:repo — rename/update sync rules. Folder admin or workspace owner only.
|
|
164
101
|
fastify.patch('/api/repos/:repo', async (req, reply) => {
|
|
165
|
-
if (!
|
|
166
|
-
return reply.status(403).send({ error: '
|
|
102
|
+
if (!isFolderAdminOrOwner(req, req.params.repo, workspaceManager, authService)) {
|
|
103
|
+
return reply.status(403).send({ error: 'Folder admin access required' });
|
|
167
104
|
}
|
|
168
105
|
const { repo } = req.params;
|
|
169
106
|
const body = req.body || {};
|
|
170
107
|
const { name: newName, display_name: newDisplayName, sync: newSync, ...rest } = body;
|
|
171
|
-
if ('
|
|
172
|
-
return reply.status(400).send({ error: 'The
|
|
108
|
+
if ('personal' in rest) {
|
|
109
|
+
return reply.status(400).send({ error: 'The personal flag is immutable after creation' });
|
|
173
110
|
}
|
|
174
111
|
if (newName === undefined && newSync === undefined && newDisplayName === undefined) {
|
|
175
112
|
return reply.status(400).send({ error: 'Nothing to update' });
|
|
@@ -182,12 +119,17 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
182
119
|
const config = await workspace.loadConfig();
|
|
183
120
|
const repoConfig = config.repos.find(r => r.name === repo);
|
|
184
121
|
if (!repoConfig) {
|
|
185
|
-
return reply.status(404).send({ error: `
|
|
122
|
+
return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
|
|
123
|
+
}
|
|
124
|
+
// System folders (the reserved Workspace Assets store) have a reserved
|
|
125
|
+
// name + fixed display; renaming/reconfiguring them is not allowed.
|
|
126
|
+
if (repoConfig.system) {
|
|
127
|
+
return reply.status(403).send({ error: 'This is a system folder and cannot be renamed or reconfigured.' });
|
|
186
128
|
}
|
|
187
129
|
// Handle rename
|
|
188
130
|
if (newName !== undefined) {
|
|
189
131
|
if (config.repos.some(r => r.name === newName)) {
|
|
190
|
-
return reply.status(409).send({ error: `
|
|
132
|
+
return reply.status(409).send({ error: `Folder \u201C${newName}\u201D already exists` });
|
|
191
133
|
}
|
|
192
134
|
const wsPath = workspaceManager['workspacePath'];
|
|
193
135
|
const oldPath = join(wsPath, repoConfig.path);
|
|
@@ -206,8 +148,8 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
206
148
|
if (existsSync(markerPath)) {
|
|
207
149
|
writeFS(markerPath, JSON.stringify({ repo_name: newName }, null, 2), 'utf-8');
|
|
208
150
|
}
|
|
209
|
-
// Update
|
|
210
|
-
authService.
|
|
151
|
+
// Update folder_access to follow the rename
|
|
152
|
+
authService.renameFolder(repo, newName);
|
|
211
153
|
}
|
|
212
154
|
// Handle display name update (without rename)
|
|
213
155
|
if (newDisplayName !== undefined && newName === undefined) {
|
|
@@ -218,7 +160,7 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
218
160
|
repoConfig.sync = newSync.length > 0 ? newSync : undefined;
|
|
219
161
|
}
|
|
220
162
|
workspace.writeConfig(config);
|
|
221
|
-
workspace.pushWorkspaceConfig(newName !== undefined ? `Rename
|
|
163
|
+
workspace.pushWorkspaceConfig(newName !== undefined ? `Rename folder: ${repo} → ${newName}` : `Update folder: ${repo}`);
|
|
222
164
|
workspaceManager.reloadConfig();
|
|
223
165
|
return reply.send(repoConfig);
|
|
224
166
|
}
|
|
@@ -226,50 +168,128 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
226
168
|
return reply.status(500).send({ error: err.message });
|
|
227
169
|
}
|
|
228
170
|
});
|
|
229
|
-
// GET /api/repos — list accessible repos (re-reads config to pick up CLI changes)
|
|
171
|
+
// GET /api/repos — list accessible repos (re-reads config to pick up CLI changes).
|
|
172
|
+
// Augments each repo with the caller's folder role ('admin' | 'editor'),
|
|
173
|
+
// member_count, entity_types (distinct types present), and folders (the
|
|
174
|
+
// flat list of nested-folder paths in the repo, used by the sidebar to
|
|
175
|
+
// render the active-path expansion).
|
|
230
176
|
fastify.get('/api/repos', async (req, reply) => {
|
|
231
177
|
workspaceManager.reloadConfig();
|
|
232
|
-
|
|
233
|
-
|
|
178
|
+
// System folders (the reserved Workspace Assets store) are accessible but
|
|
179
|
+
// excluded from the sidebar — they're surfaced via the asset manager, not
|
|
180
|
+
// the folder list. They stay in getAccessibleRepos so authz + the asset
|
|
181
|
+
// manager (/api/workspace/assets) still see them.
|
|
182
|
+
const repos = getContentRepos(req, workspaceManager, authService);
|
|
183
|
+
const user = req.user;
|
|
184
|
+
const enriched = repos.map(r => {
|
|
185
|
+
const memberCount = authService.getFolderMembers(r.name).length;
|
|
186
|
+
const callerRole = user ? authService.getFolderRole(user.id, r.name) : null;
|
|
187
|
+
let entityTypes = [];
|
|
188
|
+
// `folders` carries the per-folder display_name override (when one
|
|
189
|
+
// exists in the folder's .studiograph-folder.json sidecar); the client
|
|
190
|
+
// falls back to toDisplayName(slug) when display_name is absent.
|
|
191
|
+
let folders = [];
|
|
192
|
+
try {
|
|
193
|
+
// Direct WM access OK here — `repos` came from getAccessibleRepos
|
|
194
|
+
// above, so every iteration is over a folder the caller can read.
|
|
195
|
+
const graph = workspaceManager.getGraph(r.name);
|
|
196
|
+
entityTypes = graph.listEntityTypes();
|
|
197
|
+
folders = graph.listFolders().map(f => {
|
|
198
|
+
const entry = { path: f.path };
|
|
199
|
+
if (f.display_name)
|
|
200
|
+
entry.display_name = f.display_name;
|
|
201
|
+
if (f.id)
|
|
202
|
+
entry.id = f.id;
|
|
203
|
+
return entry;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch { /* repo not mounted */ }
|
|
207
|
+
return {
|
|
208
|
+
...r,
|
|
209
|
+
caller_role: callerRole,
|
|
210
|
+
member_count: memberCount,
|
|
211
|
+
entity_types: entityTypes,
|
|
212
|
+
folders,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
return reply.send(enriched);
|
|
216
|
+
});
|
|
217
|
+
// GET /api/admin/folders — workspace-owner-only metadata list of every folder
|
|
218
|
+
// (name, display_name, creator, member count). No content access implied —
|
|
219
|
+
// owners still need to join a folder to read its entries.
|
|
220
|
+
fastify.get('/api/admin/folders', async (req, reply) => {
|
|
221
|
+
const user = req.user;
|
|
222
|
+
if (user && !isWorkspaceOwner(user.role)) {
|
|
223
|
+
return reply.status(403).send({ error: 'Workspace owner access required' });
|
|
224
|
+
}
|
|
225
|
+
workspaceManager.reloadConfig();
|
|
226
|
+
const all = workspaceManager.getAllRepoConfigs();
|
|
227
|
+
const result = all.map(r => {
|
|
228
|
+
const members = authService.getFolderMembers(r.name);
|
|
229
|
+
const admins = members.filter(m => m.role === 'admin');
|
|
230
|
+
const creator = r.owner_id
|
|
231
|
+
? members.find(m => m.user.id === r.owner_id)?.user
|
|
232
|
+
: admins[0]?.user;
|
|
233
|
+
let entryCount = 0;
|
|
234
|
+
// Direct WM access OK — this endpoint is owner-only (gated above) and
|
|
235
|
+
// returns metadata for every folder the workspace owner needs to see
|
|
236
|
+
// for admin purposes. No content is leaked: only entry counts.
|
|
237
|
+
try {
|
|
238
|
+
entryCount = workspaceManager.getGraph(r.name).search({}).length;
|
|
239
|
+
}
|
|
240
|
+
catch { /* skip */ }
|
|
241
|
+
return {
|
|
242
|
+
name: r.name,
|
|
243
|
+
display_name: r.display_name ?? r.name,
|
|
244
|
+
description: r.description,
|
|
245
|
+
member_count: members.length,
|
|
246
|
+
admin_count: admins.length,
|
|
247
|
+
entry_count: entryCount,
|
|
248
|
+
creator: creator ? { id: creator.id, displayName: creator.displayName, email: creator.email } : null,
|
|
249
|
+
caller_is_member: user ? members.some(m => m.user.id === user.id) : true,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
return reply.send(result);
|
|
234
253
|
});
|
|
254
|
+
// Build the lightweight summary objects used by /entities?summary=true.
|
|
255
|
+
// Extracted so the per-repo and bulk endpoints share the same shape.
|
|
256
|
+
const EXCLUDED_SUMMARY_FIELDS = new Set([
|
|
257
|
+
'entity_type', 'entity_id', 'created_by', 'updated_by',
|
|
258
|
+
'description', 'content', 'context', 'rationale', 'alternatives', 'consequences',
|
|
259
|
+
'steps', 'guidelines', 'bio', 'agenda', 'summary', 'decision', 'action_items',
|
|
260
|
+
]);
|
|
261
|
+
function buildSummaries(entities, accessibleIds) {
|
|
262
|
+
return entities.map(e => {
|
|
263
|
+
const extra = Object.fromEntries(Object.entries(e.data ?? {}).filter(([k, v]) => !EXCLUDED_SUMMARY_FIELDS.has(k) && k !== 'name' && k !== 'created_at' && k !== 'updated_at' &&
|
|
264
|
+
(typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v instanceof Date)));
|
|
265
|
+
return {
|
|
266
|
+
id: e.id,
|
|
267
|
+
entityType: e.entityType,
|
|
268
|
+
folder_path: e.folder_path ?? '',
|
|
269
|
+
data: {
|
|
270
|
+
name: e.data.name,
|
|
271
|
+
created_at: e.data.created_at,
|
|
272
|
+
updated_at: e.data.updated_at,
|
|
273
|
+
...extra,
|
|
274
|
+
},
|
|
275
|
+
document: { wikilinks: filterWikilinks(e.document?.wikilinks ?? [], accessibleIds) },
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
235
279
|
// GET /api/repos/:repo/entities — list all entities in a repo
|
|
236
280
|
// ?summary=true returns lightweight objects (no content, minimal data) for sidebar/graph
|
|
237
281
|
fastify.get('/api/repos/:repo/entities', async (req, reply) => {
|
|
238
282
|
const { repo } = req.params;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
catch (err) {
|
|
247
|
-
return reply.status(404).send({ error: err.message });
|
|
248
|
-
}
|
|
283
|
+
// Access enforced by graphForRead \u2014 do not call workspaceManager.getGraph
|
|
284
|
+
// directly here. The audit (2026-05) flagged direct WM access from routes
|
|
285
|
+
// as the primary architectural risk; the service is the gate.
|
|
286
|
+
const graph = graphForRead(req, reply, repo);
|
|
287
|
+
if (!graph)
|
|
288
|
+
return;
|
|
249
289
|
const entities = graph.search({});
|
|
250
290
|
const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
|
|
251
291
|
if (req.query.summary === 'true') {
|
|
252
|
-
|
|
253
|
-
'entity_type', 'entity_id', 'created_by', 'updated_by',
|
|
254
|
-
'description', 'content', 'context', 'rationale', 'alternatives', 'consequences',
|
|
255
|
-
'steps', 'guidelines', 'bio', 'agenda', 'summary', 'decision', 'action_items',
|
|
256
|
-
]);
|
|
257
|
-
const summaries = entities.map(e => {
|
|
258
|
-
const extra = Object.fromEntries(Object.entries(e.data ?? {}).filter(([k, v]) => !EXCLUDED_SUMMARY_FIELDS.has(k) && k !== 'name' && k !== 'created_at' && k !== 'updated_at' &&
|
|
259
|
-
(typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v instanceof Date)));
|
|
260
|
-
return {
|
|
261
|
-
id: e.id,
|
|
262
|
-
entityType: e.entityType,
|
|
263
|
-
data: {
|
|
264
|
-
name: e.data.name,
|
|
265
|
-
created_at: e.data.created_at,
|
|
266
|
-
updated_at: e.data.updated_at,
|
|
267
|
-
...extra,
|
|
268
|
-
},
|
|
269
|
-
document: { wikilinks: filterWikilinks(e.document?.wikilinks ?? [], accessibleIds) },
|
|
270
|
-
};
|
|
271
|
-
});
|
|
272
|
-
return reply.send(summaries);
|
|
292
|
+
return reply.send(buildSummaries(entities, accessibleIds));
|
|
273
293
|
}
|
|
274
294
|
// Full response — filter wikilinks in document
|
|
275
295
|
const filtered = entities.map(e => {
|
|
@@ -279,19 +299,57 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
279
299
|
});
|
|
280
300
|
return reply.send(filtered);
|
|
281
301
|
});
|
|
302
|
+
// GET /api/workspace/entities?summary=true — bulk entity summaries for ALL
|
|
303
|
+
// accessible repos in a single round-trip. Replaces 15+ parallel
|
|
304
|
+
// /api/repos/:repo/entities?summary=true calls. Important on dev where each
|
|
305
|
+
// request has Vite middleware overhead and the browser's HTTP/1.1 6-slot
|
|
306
|
+
// limit serializes them, blocking other navigation fetches.
|
|
307
|
+
// Response shape: `{ [repoName]: EntitySummary[] }`.
|
|
308
|
+
fastify.get('/api/workspace/entities', async (req, reply) => {
|
|
309
|
+
// Mirror the per-repo endpoint's contract: only summary=true is supported
|
|
310
|
+
// here. Returning full bodies for the whole workspace would be huge and
|
|
311
|
+
// isn't a use case we have today.
|
|
312
|
+
if (req.query.summary !== 'true') {
|
|
313
|
+
return reply.status(400).send({ error: 'summary=true is required for the bulk entities endpoint' });
|
|
314
|
+
}
|
|
315
|
+
const repos = getContentRepos(req, workspaceManager, authService);
|
|
316
|
+
// Single-pass: collect the per-repo entity arrays AND the accessible-ids
|
|
317
|
+
// set in one walk. The previous shape called graph.search({}) twice per
|
|
318
|
+
// repo (once via getAccessibleEntityIds, once in this loop), doubling
|
|
319
|
+
// disk I/O on the most-trafficked endpoint.
|
|
320
|
+
const user = reqUser(req);
|
|
321
|
+
const collectIds = user !== undefined; // unauthenticated → no wikilink filtering
|
|
322
|
+
const accessibleIds = collectIds ? new Set() : null;
|
|
323
|
+
const perRepoEntities = [];
|
|
324
|
+
for (const r of repos) {
|
|
325
|
+
let graph;
|
|
326
|
+
// Direct WM access OK — `repos` is the access-filtered list above.
|
|
327
|
+
try {
|
|
328
|
+
graph = workspaceManager.getGraph(r.name);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
continue;
|
|
332
|
+
} // skip unmounted repos rather than failing the whole request
|
|
333
|
+
const entities = graph.search({});
|
|
334
|
+
perRepoEntities.push({ name: r.name, entities });
|
|
335
|
+
if (accessibleIds) {
|
|
336
|
+
for (const e of entities)
|
|
337
|
+
accessibleIds.add(e.id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const result = {};
|
|
341
|
+
for (const { name, entities } of perRepoEntities) {
|
|
342
|
+
result[name] = buildSummaries(entities, accessibleIds);
|
|
343
|
+
}
|
|
344
|
+
return reply.send(result);
|
|
345
|
+
});
|
|
282
346
|
// GET /api/repos/:repo/entities/:type — list entities of a type
|
|
283
347
|
fastify.get('/api/repos/:repo/entities/:type', async (req, reply) => {
|
|
284
348
|
const { repo, type } = req.params;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
graph = workspaceManager.getGraph(repo);
|
|
291
|
-
}
|
|
292
|
-
catch (err) {
|
|
293
|
-
return reply.status(404).send({ error: err.message });
|
|
294
|
-
}
|
|
349
|
+
// Access enforced by graphForRead (WorkspaceAccessService).
|
|
350
|
+
const graph = graphForRead(req, reply, repo);
|
|
351
|
+
if (!graph)
|
|
352
|
+
return;
|
|
295
353
|
const entities = graph.search({ entityType: type });
|
|
296
354
|
const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
|
|
297
355
|
const filtered = entities.map(e => {
|
|
@@ -304,17 +362,20 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
304
362
|
// GET /api/repos/:repo/entities/:type/:id — get single entity
|
|
305
363
|
fastify.get('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
|
|
306
364
|
const { repo, type, id } = req.params;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
365
|
+
// Access enforced by graphForRead (WorkspaceAccessService).
|
|
366
|
+
const graph = graphForRead(req, reply, repo);
|
|
367
|
+
if (!graph)
|
|
368
|
+
return;
|
|
369
|
+
let entity;
|
|
311
370
|
try {
|
|
312
|
-
|
|
371
|
+
entity = graph.get(type, id);
|
|
313
372
|
}
|
|
314
373
|
catch (err) {
|
|
315
|
-
return reply.status(404).send({ error: err.message });
|
|
374
|
+
return reply.status(404).send({ error: err.message ?? `Entity not found: ${type}/${id}` });
|
|
316
375
|
}
|
|
317
|
-
|
|
376
|
+
// graph.get may also return null for a missing entity without throwing
|
|
377
|
+
// (depends on the backing graph implementation). Treat that as 404 so
|
|
378
|
+
// we don't blow up on the wikilinks access below.
|
|
318
379
|
if (!entity) {
|
|
319
380
|
return reply.status(404).send({ error: `Entity not found: ${type}/${id}` });
|
|
320
381
|
}
|
|
@@ -330,33 +391,151 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
330
391
|
// GET /api/repos/:repo/entities/:type/:id/history — entity commit history
|
|
331
392
|
fastify.get('/api/repos/:repo/entities/:type/:id/history', async (req, reply) => {
|
|
332
393
|
const { repo, type, id } = req.params;
|
|
333
|
-
|
|
334
|
-
|
|
394
|
+
// Access enforced by graphForRead (WorkspaceAccessService).
|
|
395
|
+
const graph = graphForRead(req, reply, repo);
|
|
396
|
+
if (!graph)
|
|
397
|
+
return;
|
|
398
|
+
const gitService = graph.getGitService();
|
|
399
|
+
if (!gitService) {
|
|
400
|
+
return reply.send([]);
|
|
335
401
|
}
|
|
336
|
-
|
|
402
|
+
const limit = Math.min(parseInt(req.query.limit ?? '20', 10) || 20, 50);
|
|
403
|
+
// Resolve the actual on-disk path via graph.get() — it walks nested
|
|
404
|
+
// folders to find the file. The previous hardcoded `${plural}/${id}.md`
|
|
405
|
+
// missed any entry not at the legacy flat path (entries created in a
|
|
406
|
+
// sub-folder, MCP entries that landed elsewhere, etc.) and silently
|
|
407
|
+
// returned an empty history. Convert absolute path → repo-relative
|
|
408
|
+
// for git log.
|
|
409
|
+
let filePath;
|
|
337
410
|
try {
|
|
338
|
-
|
|
411
|
+
const entity = graph.get(type, id);
|
|
412
|
+
const repoRoot = graph.getRepoPath();
|
|
413
|
+
filePath = entity.path.startsWith(repoRoot + '/')
|
|
414
|
+
? entity.path.slice(repoRoot.length + 1)
|
|
415
|
+
: entity.path;
|
|
339
416
|
}
|
|
340
417
|
catch (err) {
|
|
341
418
|
return reply.status(404).send({ error: err.message });
|
|
342
419
|
}
|
|
343
|
-
const gitService = graph.getGitService();
|
|
344
|
-
if (!gitService) {
|
|
345
|
-
return reply.send([]);
|
|
346
|
-
}
|
|
347
|
-
const limit = Math.min(parseInt(req.query.limit ?? '20', 10) || 20, 50);
|
|
348
|
-
const { PLURAL_TO_SINGULAR } = await import('../../core/validation.js');
|
|
349
|
-
// Reverse lookup: singular type → plural folder name
|
|
350
|
-
const pluralDir = Object.entries(PLURAL_TO_SINGULAR).find(([, v]) => v === type)?.[0] ?? `${type}s`;
|
|
351
|
-
const filePath = `${pluralDir}/${id}.md`;
|
|
352
420
|
try {
|
|
353
421
|
const history = gitService.getFileHistory(filePath, limit);
|
|
422
|
+
// Opt-in per-commit change summary for the History panel. Off by
|
|
423
|
+
// default so the eager gating fetch on every entity-page load stays
|
|
424
|
+
// cheap; the panel requests it only when expanded.
|
|
425
|
+
if (req.query.changes === 'true') {
|
|
426
|
+
return reply.send(history.map((c) => ({ ...c, changes: graph.getCommitChange(filePath, c.hash) })));
|
|
427
|
+
}
|
|
354
428
|
return reply.send(history);
|
|
355
429
|
}
|
|
356
430
|
catch {
|
|
357
431
|
return reply.send([]);
|
|
358
432
|
}
|
|
359
433
|
});
|
|
434
|
+
// GET /api/repos/:repo/entities/:type/:id/at-commit/:hash — full body content
|
|
435
|
+
// of an entry as it was at a past commit, for the History "version preview"
|
|
436
|
+
// (read-only Live Preview render). Same read gate as /history.
|
|
437
|
+
//
|
|
438
|
+
// Reuses graph.getEntityAtCommit, which walks the commit tree by IDENTITY
|
|
439
|
+
// (entityType, entityId) — so a rename/move between then and now resolves to
|
|
440
|
+
// the right historical file without the route duplicating path-slicing, and a
|
|
441
|
+
// commit where the entry didn't yet exist at this identity resolves to null.
|
|
442
|
+
// null → typed 404 VERSION_NOT_AT_PATH so the UI can show an honest "this
|
|
443
|
+
// version isn't viewable here" message instead of a misleading generic error.
|
|
444
|
+
fastify.get('/api/repos/:repo/entities/:type/:id/at-commit/:hash', async (req, reply) => {
|
|
445
|
+
const { repo, type, id, hash } = req.params;
|
|
446
|
+
// Defense-in-depth: the hash always comes from our own history list, but
|
|
447
|
+
// validate the shape before it reaches git.
|
|
448
|
+
if (!/^[0-9a-f]{7,40}$/.test(hash)) {
|
|
449
|
+
return reply.status(400).send({ error: 'Invalid commit hash' });
|
|
450
|
+
}
|
|
451
|
+
// Access enforced by graphForRead (WorkspaceAccessService).
|
|
452
|
+
const graph = graphForRead(req, reply, repo);
|
|
453
|
+
if (!graph)
|
|
454
|
+
return;
|
|
455
|
+
const hit = graph.getEntityAtCommit(type, id, hash);
|
|
456
|
+
if (hit === null) {
|
|
457
|
+
return reply.status(404).send({ error: 'VERSION_NOT_AT_PATH' });
|
|
458
|
+
}
|
|
459
|
+
// Editor content is body-only (frontmatter stripped) — match it so the
|
|
460
|
+
// preview renders exactly what the live editor would. The parsed
|
|
461
|
+
// frontmatter rides along so the preview can also show the title + fields
|
|
462
|
+
// as they were at this commit. parseFrontmatterCacheFree (NOT the shared
|
|
463
|
+
// gray-matter MarkdownService) is required for historical content — the
|
|
464
|
+
// shared parser's module-level cache returns stale values for past commits.
|
|
465
|
+
return reply.send({
|
|
466
|
+
body: stripFrontmatter(hit.content),
|
|
467
|
+
frontmatter: parseFrontmatterCacheFree(hit.content),
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
// POST /api/repos/:repo/entities/:type/:id/restore — restore an entry to
|
|
471
|
+
// its content at a past commit. Single-entity scope. Editor-or-admin role
|
|
472
|
+
// required (canWriteRepo). Returns 200 with the restore result, 404 if
|
|
473
|
+
// the entry doesn't exist at that commit, 409 PATH_CONFLICT if a different
|
|
474
|
+
// entry now occupies the target path, 403 if the caller lacks write access.
|
|
475
|
+
fastify.post('/api/repos/:repo/entities/:type/:id/restore', async (req, reply) => {
|
|
476
|
+
const { repo, type, id } = req.params;
|
|
477
|
+
const { commitHash } = req.body ?? {};
|
|
478
|
+
if (!commitHash || typeof commitHash !== 'string') {
|
|
479
|
+
return reply.status(400).send({ error: 'commitHash is required' });
|
|
480
|
+
}
|
|
481
|
+
// Access enforced by graphForWrite: 404 if no read access, 403 if no write.
|
|
482
|
+
const graph = graphForWrite(req, reply, repo);
|
|
483
|
+
if (!graph)
|
|
484
|
+
return;
|
|
485
|
+
// Flush any deferred autosave for this repo first — otherwise a restore
|
|
486
|
+
// could overwrite a pending (uncommitted) edit on disk and commit only
|
|
487
|
+
// the restored version, silently discarding the in-flight work. Matches
|
|
488
|
+
// rename/move/change-type, which all flush before mutating.
|
|
489
|
+
if (sessionManager)
|
|
490
|
+
await sessionManager.flush(repo);
|
|
491
|
+
try {
|
|
492
|
+
// restoreFile reconciles any live editor Y.Doc against the restored disk
|
|
493
|
+
// content internally (via the BodyWriteCoordinator's per-doc lock), so an
|
|
494
|
+
// open editor shows the restored version instead of syncing stale content
|
|
495
|
+
// back over it. That lives in graph.restoreFile so every restore caller
|
|
496
|
+
// (this route, the agent tool, the MCP tool) gets it.
|
|
497
|
+
const result = await graph.restoreFile(type, id, commitHash, reqGitUser(req));
|
|
498
|
+
wsHub?.broadcast({
|
|
499
|
+
type: 'entity_change',
|
|
500
|
+
action: result.action === 'recreate' ? 'created' : 'updated',
|
|
501
|
+
repo,
|
|
502
|
+
entityType: type,
|
|
503
|
+
entityId: id,
|
|
504
|
+
actor: reqUser(req)?.displayName ?? 'API',
|
|
505
|
+
source: 'api',
|
|
506
|
+
timestamp: new Date().toISOString(),
|
|
507
|
+
});
|
|
508
|
+
return reply.send({
|
|
509
|
+
success: true,
|
|
510
|
+
action: result.action,
|
|
511
|
+
path: result.path,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
// PathConflictError carries an error code distinct from generic
|
|
516
|
+
// failures so the UI can render an actionable conflict message.
|
|
517
|
+
if (err?.code === 'PATH_CONFLICT' && err?.occupiedBy) {
|
|
518
|
+
return reply.status(409).send({
|
|
519
|
+
error: 'PATH_CONFLICT',
|
|
520
|
+
path: err.path,
|
|
521
|
+
occupiedBy: err.occupiedBy,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
const message = err instanceof Error ? err.message : 'Restore failed';
|
|
525
|
+
// "not found at commit" / "did not exist at commit" → 404 so the
|
|
526
|
+
// client can show "that version isn't available" instead of a
|
|
527
|
+
// generic 500.
|
|
528
|
+
if (/not found at commit|did not exist at commit/i.test(message)) {
|
|
529
|
+
return reply.status(404).send({ error: message });
|
|
530
|
+
}
|
|
531
|
+
// Type-mismatch refusal (restoring would silently change entity_type)
|
|
532
|
+
// is a client-actionable conflict, not a server fault → 409.
|
|
533
|
+
if (/would change its type|was type '/i.test(message)) {
|
|
534
|
+
return reply.status(409).send({ error: message });
|
|
535
|
+
}
|
|
536
|
+
return reply.status(500).send({ error: message });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
360
539
|
// GET /api/schema/:entityType/fields — field metadata for an entity type
|
|
361
540
|
fastify.get('/api/schema/:entityType/fields', async (req, reply) => {
|
|
362
541
|
const { entityType } = req.params;
|
|
@@ -364,20 +543,25 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
364
543
|
const fields = registry.getFieldMetadata(entityType);
|
|
365
544
|
return reply.send(fields);
|
|
366
545
|
});
|
|
546
|
+
// GET /api/formats — the format registry, serialized for the client.
|
|
547
|
+
//
|
|
548
|
+
// Single source of truth for ingestion: the web client builds its accept
|
|
549
|
+
// lists, disposition matrix, and supported-types copy from this instead of
|
|
550
|
+
// maintaining parallel (and drifting) constants. Mirrors the
|
|
551
|
+
// GET /api/schema/:type/fields pattern. Workspace-invariant + auth-gated
|
|
552
|
+
// by the global middleware; no per-repo access needed.
|
|
553
|
+
fastify.get('/api/formats', async (_req, reply) => {
|
|
554
|
+
const { serializeForClient } = await import('../../core/format-registry.js');
|
|
555
|
+
return reply.send(serializeForClient());
|
|
556
|
+
});
|
|
367
557
|
// GET /api/repos/:repo/search?q=&type=&tags= — search within a repo
|
|
368
558
|
fastify.get('/api/repos/:repo/search', async (req, reply) => {
|
|
369
559
|
const { repo } = req.params;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
560
|
+
// Access enforced by graphForRead (WorkspaceAccessService).
|
|
561
|
+
const graph = graphForRead(req, reply, repo);
|
|
562
|
+
if (!graph)
|
|
563
|
+
return;
|
|
373
564
|
const { q, type, tags } = req.query;
|
|
374
|
-
let graph;
|
|
375
|
-
try {
|
|
376
|
-
graph = workspaceManager.getGraph(repo);
|
|
377
|
-
}
|
|
378
|
-
catch (err) {
|
|
379
|
-
return reply.status(404).send({ error: err.message });
|
|
380
|
-
}
|
|
381
565
|
const results = graph.search({
|
|
382
566
|
query: q,
|
|
383
567
|
entityType: type,
|
|
@@ -393,19 +577,18 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
393
577
|
});
|
|
394
578
|
// GET /api/graph — full workspace graph topology (nodes + edges)
|
|
395
579
|
fastify.get('/api/graph', async (req, reply) => {
|
|
396
|
-
const repos =
|
|
580
|
+
const repos = getContentRepos(req, workspaceManager, authService);
|
|
397
581
|
const nodes = [];
|
|
398
582
|
const edgeSet = new Set();
|
|
399
583
|
const edges = [];
|
|
400
584
|
const entityIds = new Set();
|
|
401
585
|
for (const repoConfig of repos) {
|
|
402
586
|
try {
|
|
587
|
+
// Direct WM access OK — `repos` came from getAccessibleRepos above.
|
|
403
588
|
const graph = workspaceManager.getGraph(repoConfig.name);
|
|
404
589
|
const entities = graph.search({});
|
|
405
590
|
for (const entity of entities) {
|
|
406
591
|
const nodeId = entity.id;
|
|
407
|
-
if (nodeId.toLowerCase().startsWith('readme'))
|
|
408
|
-
continue;
|
|
409
592
|
if (!entityIds.has(nodeId)) {
|
|
410
593
|
entityIds.add(nodeId);
|
|
411
594
|
nodes.push({
|
|
@@ -433,10 +616,10 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
433
616
|
const validEdges = edges.filter(e => entityIds.has(e.source) && entityIds.has(e.target));
|
|
434
617
|
return reply.send({ nodes, edges: validEdges });
|
|
435
618
|
});
|
|
436
|
-
// GET /api/search?q=&type=&tags=&repos= — cross-repo search
|
|
619
|
+
// GET /api/search?q=&type=&tags=&repos=&mode= — cross-repo search
|
|
437
620
|
fastify.get('/api/search', async (req, reply) => {
|
|
438
|
-
const { q, type, tags, repos } = req.query;
|
|
439
|
-
const accessibleNames = new Set(
|
|
621
|
+
const { q, type, tags, repos, mode } = req.query;
|
|
622
|
+
const accessibleNames = new Set(getContentRepos(req, workspaceManager, authService).map(r => r.name));
|
|
440
623
|
// Filter requested repos to only accessible ones
|
|
441
624
|
let repoNames = repos ? repos.split(',').map(r => r.trim()) : undefined;
|
|
442
625
|
if (repoNames) {
|
|
@@ -445,12 +628,35 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
445
628
|
else {
|
|
446
629
|
repoNames = [...accessibleNames];
|
|
447
630
|
}
|
|
631
|
+
const keywordMode = mode === 'keyword';
|
|
448
632
|
const results = await workspaceManager.search({
|
|
449
633
|
query: q,
|
|
450
634
|
entityType: type,
|
|
451
635
|
tags: tags ? tags.split(',').map(t => t.trim()) : undefined,
|
|
452
636
|
repoNames,
|
|
637
|
+
mode: keywordMode ? 'keyword' : undefined,
|
|
638
|
+
// Autosuggest dropdown caps at 20 visible rows; capping server-side
|
|
639
|
+
// avoids serializing a long tail the client throws away.
|
|
640
|
+
limit: keywordMode ? 20 : undefined,
|
|
453
641
|
});
|
|
642
|
+
// Autosuggest doesn't need the markdown body or wikilinks — the
|
|
643
|
+
// dropdown renders {name, type, repo} only. Returning a slim shape
|
|
644
|
+
// cuts response size from ~hundreds of KB to a few KB on big
|
|
645
|
+
// workspaces, and skips the per-entity wikilink visibility filter.
|
|
646
|
+
if (keywordMode) {
|
|
647
|
+
const slim = results.map(e => ({
|
|
648
|
+
id: e.id,
|
|
649
|
+
path: e.path,
|
|
650
|
+
folder_path: e.folder_path,
|
|
651
|
+
entityType: e.entityType,
|
|
652
|
+
repoName: e.repoName,
|
|
653
|
+
data: {
|
|
654
|
+
name: e.data?.name,
|
|
655
|
+
updated_at: e.data?.updated_at,
|
|
656
|
+
},
|
|
657
|
+
}));
|
|
658
|
+
return reply.send(slim);
|
|
659
|
+
}
|
|
454
660
|
const accessibleIds = getAccessibleEntityIds(req, workspaceManager, authService);
|
|
455
661
|
const filtered = results.map((e) => {
|
|
456
662
|
if (!e.document?.wikilinks?.length)
|
|
@@ -462,20 +668,29 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
462
668
|
// PUT /api/repos/:repo/entities/:type/:id — update entity (data and/or content)
|
|
463
669
|
fastify.put('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
|
|
464
670
|
const { repo, type, id } = req.params;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
catch (err) {
|
|
473
|
-
return reply.status(404).send({ error: err.message });
|
|
474
|
-
}
|
|
671
|
+
// Access enforced by graphForWrite \u2014 PUT updates an entity, write required.
|
|
672
|
+
const graph = graphForWrite(req, reply, repo);
|
|
673
|
+
if (!graph)
|
|
674
|
+
return;
|
|
675
|
+
const eagerError = checkEagerSkillPermission(req, type, req.body.data, repo, workspaceManager, authService);
|
|
676
|
+
if (eagerError)
|
|
677
|
+
return reply.status(403).send({ error: eagerError });
|
|
475
678
|
try {
|
|
476
|
-
// Optimistic concurrency
|
|
679
|
+
// Optimistic concurrency. Two preconditions:
|
|
680
|
+
// - `X-Studiograph-Base-Body-Hash` (Phase 3a): sha256 hex of the
|
|
681
|
+
// body the client last loaded. Enforced INSIDE the per-doc lock
|
|
682
|
+
// by graph.update via `baseRevision`, so it composes with
|
|
683
|
+
// serialization — two body-writing PUTs from the same baseline
|
|
684
|
+
// can't both pass and back-stomp each other.
|
|
685
|
+
// - `If-Unmodified-Since` (legacy): kept for non-web clients;
|
|
686
|
+
// RFC-7231 second-precision so aliases sub-millisecond writes,
|
|
687
|
+
// which is why the body-hash form is preferred.
|
|
688
|
+
// Body-hash header is IGNORED on metadata-only PUTs — a stale body
|
|
689
|
+
// baseline is irrelevant when the PUT doesn't touch the body, and
|
|
690
|
+
// enforcing it would cause spurious 412s after an agent body-write.
|
|
691
|
+
const baseBodyHash = req.headers['x-studiograph-base-body-hash'];
|
|
477
692
|
const ifUnmodifiedSince = req.headers['if-unmodified-since'];
|
|
478
|
-
if (ifUnmodifiedSince) {
|
|
693
|
+
if (ifUnmodifiedSince && !baseBodyHash) {
|
|
479
694
|
const current = graph.get(type, id);
|
|
480
695
|
const entityUpdatedAt = current?.data?.updated_at;
|
|
481
696
|
if (entityUpdatedAt && new Date(entityUpdatedAt) > new Date(ifUnmodifiedSince)) {
|
|
@@ -483,6 +698,40 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
483
698
|
}
|
|
484
699
|
}
|
|
485
700
|
const gitUser = reqGitUser(req);
|
|
701
|
+
// Workspace-wide uniqueness check for `user_handle` on person entities.
|
|
702
|
+
// Skip the entity being updated so we don't reject it for clashing
|
|
703
|
+
// with itself. Also gate WHO can change the bridge: only workspace
|
|
704
|
+
// owners may stamp/clear/rewrite user_handle. Other folder-writers
|
|
705
|
+
// see the field but can't edit it (defense against impersonation
|
|
706
|
+
// through the agent's Current User prompt).
|
|
707
|
+
if (type === 'person' && req.body.data) {
|
|
708
|
+
try {
|
|
709
|
+
validateUserHandleInPayload(workspaceManager, req.body.data, { repo, entityId: id });
|
|
710
|
+
if (Object.prototype.hasOwnProperty.call(req.body.data, 'user_handle')) {
|
|
711
|
+
const current = graph.get('person', id);
|
|
712
|
+
const currentHandle = (current?.data?.user_handle ?? '');
|
|
713
|
+
assertCanChangeUserHandle(reqUser(req), currentHandle, req.body.data.user_handle);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
if (err instanceof BridgeError) {
|
|
718
|
+
const status = err.code === 'OWNER_REQUIRED' ? 403 : 409;
|
|
719
|
+
return reply.status(status).send({ error: err.message, code: err.code, ...err.details });
|
|
720
|
+
}
|
|
721
|
+
throw err;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Scope ref fields (today: task.assignee → person). Rejects a wikilink
|
|
725
|
+
// that resolves to a non-person; forward references are allowed.
|
|
726
|
+
try {
|
|
727
|
+
assertEntityRefs(workspaceManager, type, req.body.data, { userExists: (id) => !!authService.findUserById(Number(id)) });
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
if (err instanceof EntityRefError || err instanceof UnknownUserRefError) {
|
|
731
|
+
return reply.status(400).send({ error: err.message, code: err.code, field: err.field });
|
|
732
|
+
}
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
486
735
|
const updated = await graph.update({
|
|
487
736
|
entityType: type,
|
|
488
737
|
entityId: id,
|
|
@@ -491,6 +740,12 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
491
740
|
commitMessage: `Update ${type}/${id} from web UI`,
|
|
492
741
|
user: gitUser,
|
|
493
742
|
skipCommit: !!sessionManager, // defer commit if scheduler is active
|
|
743
|
+
source: 'autosave',
|
|
744
|
+
// Forward the body-hash precondition into graph.update so the
|
|
745
|
+
// check runs INSIDE the per-doc lock — see Phase 3 P1 fix.
|
|
746
|
+
// Only meaningful for body writes; ignored when content is
|
|
747
|
+
// undefined (graph.update gates on content !== undefined too).
|
|
748
|
+
baseRevision: req.body.content !== undefined ? baseBodyHash : undefined,
|
|
494
749
|
});
|
|
495
750
|
if (sessionManager && gitUser) {
|
|
496
751
|
sessionManager.markDirty(repo, gitUser, type, id, updated.path);
|
|
@@ -503,6 +758,19 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
503
758
|
return reply.send(updated);
|
|
504
759
|
}
|
|
505
760
|
catch (err) {
|
|
761
|
+
// Translate the locked-section baseRevision mismatch into the
|
|
762
|
+
// same 412 shape the route used to emit pre-lock. Preserves the
|
|
763
|
+
// client-side contract (code: STALE_BODY_HASH + currentBodyHash
|
|
764
|
+
// + entity).
|
|
765
|
+
if (err instanceof StaleBaseRevisionError) {
|
|
766
|
+
const current = graph.get(type, id);
|
|
767
|
+
return reply.status(412).send({
|
|
768
|
+
error: 'Precondition Failed: body was modified',
|
|
769
|
+
code: 'STALE_BODY_HASH',
|
|
770
|
+
currentBodyHash: err.actual,
|
|
771
|
+
entity: current,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
506
774
|
return reply.status(400).send({ error: err.message });
|
|
507
775
|
}
|
|
508
776
|
});
|
|
@@ -513,16 +781,10 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
513
781
|
if (!type || !id || !newType) {
|
|
514
782
|
return reply.status(400).send({ error: 'entityType, entityId, and newType are required' });
|
|
515
783
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
try {
|
|
521
|
-
graph = workspaceManager.getGraph(repo);
|
|
522
|
-
}
|
|
523
|
-
catch (err) {
|
|
524
|
-
return reply.status(404).send({ error: err.message });
|
|
525
|
-
}
|
|
784
|
+
// Access enforced by graphForRead (WorkspaceAccessService).
|
|
785
|
+
const graph = graphForRead(req, reply, repo);
|
|
786
|
+
if (!graph)
|
|
787
|
+
return;
|
|
526
788
|
try {
|
|
527
789
|
// Flush any pending session saves before moving the file
|
|
528
790
|
if (sessionManager) {
|
|
@@ -547,45 +809,121 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
547
809
|
return reply.status(status).send({ error: err.message });
|
|
548
810
|
}
|
|
549
811
|
});
|
|
550
|
-
// POST /api/repos/:repo/
|
|
551
|
-
fastify.post('/api/repos/:repo/
|
|
552
|
-
const
|
|
553
|
-
const { entityType, entityId
|
|
554
|
-
if (!
|
|
555
|
-
return reply.status(400).send({ error: 'entityType, entityId,
|
|
812
|
+
// POST /api/repos/:repo/rename — rename an entity (changes its id/filename)
|
|
813
|
+
fastify.post('/api/repos/:repo/rename', async (req, reply) => {
|
|
814
|
+
const { repo } = req.params;
|
|
815
|
+
const { entityType: type, entityId: id, newId } = req.body ?? {};
|
|
816
|
+
if (!type || !id || !newId) {
|
|
817
|
+
return reply.status(400).send({ error: 'entityType, entityId, and newId are required' });
|
|
556
818
|
}
|
|
557
|
-
if (
|
|
558
|
-
return reply.status(400).send({ error: '
|
|
819
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(newId)) {
|
|
820
|
+
return reply.status(400).send({ error: 'New ID must be kebab-case (lowercase letters, numbers, hyphens, underscores)' });
|
|
559
821
|
}
|
|
560
|
-
if (
|
|
561
|
-
return reply.status(
|
|
822
|
+
if (id === newId) {
|
|
823
|
+
return reply.status(400).send({ error: 'New ID must differ from the current ID' });
|
|
562
824
|
}
|
|
563
|
-
|
|
564
|
-
|
|
825
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
826
|
+
const graph = graphForWrite(req, reply, repo);
|
|
827
|
+
if (!graph)
|
|
828
|
+
return;
|
|
829
|
+
// Workspace-wide uniqueness — ids must be unique across all folders
|
|
830
|
+
const conflict = workspaceManager.findEntityById(newId);
|
|
831
|
+
if (conflict) {
|
|
832
|
+
return reply.status(409).send({ error: `An entry with id "${newId}" already exists in folder "${conflict.repoName}".` });
|
|
565
833
|
}
|
|
566
|
-
let sourceGraph, targetGraph;
|
|
567
834
|
try {
|
|
568
|
-
|
|
569
|
-
|
|
835
|
+
// Flush pending session saves so we don't PUT to the old path after rename
|
|
836
|
+
if (sessionManager) {
|
|
837
|
+
await sessionManager.flush(repo);
|
|
838
|
+
}
|
|
839
|
+
const gitUser = reqGitUser(req);
|
|
840
|
+
const renamed = await graph.rename(type, id, newId, undefined, gitUser);
|
|
841
|
+
// Rewrite any [[wikilinks]] that still point at the old id, across
|
|
842
|
+
// every folder the caller can access. Per-repo — cross-folder links
|
|
843
|
+
// are rare but supported.
|
|
844
|
+
const refactorMsg = `Refactor wikilinks: ${id} \u2192 ${newId}`;
|
|
845
|
+
for (const other of getAccessibleRepos(req, workspaceManager, authService)) {
|
|
846
|
+
try {
|
|
847
|
+
// Direct WM access OK — iterating the access-filtered list.
|
|
848
|
+
const otherGraph = workspaceManager.getGraph(other.name);
|
|
849
|
+
await otherGraph.refactorWikilink(id, newId, refactorMsg);
|
|
850
|
+
}
|
|
851
|
+
catch { /* skip unloadable repo */ }
|
|
852
|
+
}
|
|
853
|
+
wsHub?.broadcast({
|
|
854
|
+
type: 'entity_change', action: 'deleted', repo, entityType: type, entityId: id,
|
|
855
|
+
actor: req.user?.displayName ?? 'API',
|
|
856
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
857
|
+
});
|
|
858
|
+
wsHub?.broadcast({
|
|
859
|
+
type: 'entity_change', action: 'created', repo, entityType: type, entityId: newId,
|
|
860
|
+
actor: req.user?.displayName ?? 'API',
|
|
861
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
862
|
+
});
|
|
863
|
+
return reply.send({ success: true, entity: renamed, newUrl: `/${repo}/${type}/${newId}` });
|
|
570
864
|
}
|
|
571
865
|
catch (err) {
|
|
572
|
-
|
|
866
|
+
const status = err.message?.includes('already exists') ? 409 : 400;
|
|
867
|
+
return reply.status(status).send({ error: err.message });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
// POST /api/repos/:repo/transfer — move or copy entity to another folder
|
|
871
|
+
fastify.post('/api/repos/:repo/transfer', async (req, reply) => {
|
|
872
|
+
const sourceRepo = req.params.repo;
|
|
873
|
+
const { entityType, entityId, targetRepo, mode, targetFolderPath } = req.body ?? {};
|
|
874
|
+
if (!entityType || !entityId || !targetRepo || !mode) {
|
|
875
|
+
return reply.status(400).send({ error: 'entityType, entityId, targetRepo, and mode are required' });
|
|
573
876
|
}
|
|
877
|
+
if (sourceRepo === targetRepo) {
|
|
878
|
+
return reply.status(400).send({ error: 'Source and target folders must be different' });
|
|
879
|
+
}
|
|
880
|
+
// Workspace Assets store ownership gate (codex P1 / D2). Moving an `asset`
|
|
881
|
+
// OUT of a workspace_wide store reassigns its index repo, after which the
|
|
882
|
+
// uploader/owner delete rule no longer applies — so the move itself must
|
|
883
|
+
// honor that rule. assetDeleteDenial is a no-op for non-workspace_wide
|
|
884
|
+
// sources, so normal folder→folder moves are unaffected.
|
|
885
|
+
if (entityType === 'asset') {
|
|
886
|
+
const denial = assetDeleteDenial(workspaceManager.getAssetIndex?.(), workspaceManager.getRepoConfig(sourceRepo), reqUser(req), sourceRepo, entityType, entityId);
|
|
887
|
+
if (denial)
|
|
888
|
+
return reply.status(403).send({ error: denial });
|
|
889
|
+
}
|
|
890
|
+
// Access enforced by graphForWrite — both source (delete on move)
|
|
891
|
+
// and target (create) require write access. Resolved up-front so a
|
|
892
|
+
// target-side denial never surfaces only after source has been
|
|
893
|
+
// mutated. Audit (2026-05) flagged the previous opt-in pattern.
|
|
894
|
+
const sourceGraph = graphForWrite(req, reply, sourceRepo);
|
|
895
|
+
if (!sourceGraph)
|
|
896
|
+
return;
|
|
897
|
+
const targetGraph = graphForWrite(req, reply, targetRepo);
|
|
898
|
+
if (!targetGraph)
|
|
899
|
+
return;
|
|
574
900
|
try {
|
|
575
|
-
//
|
|
576
|
-
|
|
901
|
+
// Phase 0 — prevalidate. Audit (2026-05) required this: previously
|
|
902
|
+
// the route created in target, broadcast `created`, and only then
|
|
903
|
+
// tried to delete from source. A delete failure left a duplicate
|
|
904
|
+
// entry the client had already been told about.
|
|
905
|
+
if (sessionManager) {
|
|
577
906
|
await sessionManager.flush(sourceRepo);
|
|
578
|
-
|
|
907
|
+
if (targetRepo !== sourceRepo)
|
|
908
|
+
await sessionManager.flush(targetRepo);
|
|
909
|
+
}
|
|
910
|
+
// Source must exist; target must not collide. Both checks run BEFORE
|
|
911
|
+
// any mutation so we never enter phase 2 unless phase 1 will succeed.
|
|
579
912
|
const source = sourceGraph.get(entityType, entityId);
|
|
580
|
-
|
|
913
|
+
const sourcePath = source.path;
|
|
914
|
+
const sourceRawMarkdown = readFileSync(sourcePath, 'utf-8');
|
|
581
915
|
try {
|
|
582
916
|
targetGraph.get(entityType, entityId);
|
|
583
917
|
return reply.status(409).send({ error: `"${entityId}" already exists in ${targetRepo}` });
|
|
584
918
|
}
|
|
585
919
|
catch { /* not found = good */ }
|
|
586
|
-
// Create in target
|
|
587
920
|
const gitUser = reqGitUser(req);
|
|
588
921
|
const { entity_type, entity_id, ...dataFields } = source.data;
|
|
922
|
+
// Phase 1 — create in target. No broadcast yet: a half-completed
|
|
923
|
+
// move (created in target, source still present) must NOT be
|
|
924
|
+
// observable to clients, or they will display two copies until the
|
|
925
|
+
// next refresh. `targetFolderPath` lets the caller drop the entry
|
|
926
|
+
// straight into a subfolder of the target repo.
|
|
589
927
|
const created = await targetGraph.create({
|
|
590
928
|
entityType: entityType,
|
|
591
929
|
entityId,
|
|
@@ -593,21 +931,123 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
593
931
|
content: source.document?.content ?? '',
|
|
594
932
|
commitMessage: `${mode === 'move' ? 'Move' : 'Copy'} ${entityType}: ${entityId} from ${sourceRepo}`,
|
|
595
933
|
user: gitUser,
|
|
934
|
+
folderPath: targetFolderPath,
|
|
596
935
|
});
|
|
936
|
+
// Phase 2 — for `move` only: delete from source. If this fails, run
|
|
937
|
+
// a compensating delete on target so we don't leave a duplicate.
|
|
938
|
+
// The compensating delete is best-effort; if it also fails, surface
|
|
939
|
+
// a 500 with both error messages so the residue is visible (the
|
|
940
|
+
// previous behavior silently succeeded and left orphans).
|
|
941
|
+
if (mode === 'move') {
|
|
942
|
+
try {
|
|
943
|
+
// keepAssets: the moved entity's immutable-keyed blobs are shared
|
|
944
|
+
// (same assetId in both repos' bodies during the window); the
|
|
945
|
+
// source delete must not destroy them.
|
|
946
|
+
await sourceGraph.delete(entityType, entityId, undefined, gitUser, { keepAssets: true });
|
|
947
|
+
// Re-point ownership to the target repo ONLY after the source
|
|
948
|
+
// delete succeeds. Doing it after means a failed source delete
|
|
949
|
+
// (handled below) leaves the index pointing at the source repo,
|
|
950
|
+
// which is correct for the entity we're about to restore there.
|
|
951
|
+
// Blobs/URLs are unchanged (immutable keys); only the index's
|
|
952
|
+
// repo column moves, which is what serve-time authz reads.
|
|
953
|
+
workspaceManager.getAssetIndex?.()?.reassignOwner(sourceRepo, entityType, entityId, { repo: targetRepo });
|
|
954
|
+
}
|
|
955
|
+
catch (deleteErr) {
|
|
956
|
+
const deleteMsg = deleteErr instanceof Error ? deleteErr.message : String(deleteErr);
|
|
957
|
+
let recoveryMsg = null;
|
|
958
|
+
try {
|
|
959
|
+
let restoreSource = !existsSync(sourcePath);
|
|
960
|
+
if (!restoreSource) {
|
|
961
|
+
try {
|
|
962
|
+
restoreSource = readFileSync(sourcePath, 'utf-8') !== sourceRawMarkdown;
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
restoreSource = true;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (restoreSource) {
|
|
969
|
+
mkdirSync(dirname(sourcePath), { recursive: true });
|
|
970
|
+
writeFileSync(sourcePath, sourceRawMarkdown, 'utf-8');
|
|
971
|
+
const recoveryPaths = [sourcePath];
|
|
972
|
+
const folderMarkerPath = join(dirname(sourcePath), '.gitkeep');
|
|
973
|
+
if (existsSync(folderMarkerPath)) {
|
|
974
|
+
rmSync(folderMarkerPath, { force: true });
|
|
975
|
+
recoveryPaths.push(folderMarkerPath);
|
|
976
|
+
}
|
|
977
|
+
const sourceAny = sourceGraph;
|
|
978
|
+
const recoveryGit = sourceAny.gitService;
|
|
979
|
+
const recoveryUser = gitUser ?? sourceAny.gitUser;
|
|
980
|
+
if (!recoveryGit || !recoveryUser) {
|
|
981
|
+
throw new Error('source graph git service or git user unavailable');
|
|
982
|
+
}
|
|
983
|
+
recoveryGit.commitFiles(recoveryPaths, `Recover ${entityType}: ${entityId} after failed move to ${targetRepo}`, recoveryUser);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch (recoveryErr) {
|
|
987
|
+
recoveryMsg = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
|
|
988
|
+
log.error('[critical] cross-repo move source recovery failed', {
|
|
989
|
+
sourceRepo,
|
|
990
|
+
targetRepo,
|
|
991
|
+
entityType,
|
|
992
|
+
entityId,
|
|
993
|
+
sourcePath,
|
|
994
|
+
deleteError: deleteMsg,
|
|
995
|
+
recoveryError: recoveryMsg,
|
|
996
|
+
capturedContent: sourceRawMarkdown,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
// keepAssets: ownership was NOT reassigned (that happens only
|
|
1001
|
+
// after a successful source delete), so the blobs still belong
|
|
1002
|
+
// to the source entity we just restored. Destroying them here
|
|
1003
|
+
// would leave the recovered source pointing at dead URLs.
|
|
1004
|
+
await targetGraph.delete(entityType, entityId, undefined, gitUser, { keepAssets: true });
|
|
1005
|
+
if (recoveryMsg) {
|
|
1006
|
+
return reply.status(500).send({
|
|
1007
|
+
error: `Move failed: source delete of ${entityType}/${entityId} failed (${deleteMsg}). Target rolled back, but source recovery commit failed (${recoveryMsg}); manual source repo recovery is required.`,
|
|
1008
|
+
code: 'MOVE_SOURCE_RECOVERY_FAILED',
|
|
1009
|
+
sourceRepo,
|
|
1010
|
+
targetRepo,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return reply.status(500).send({
|
|
1014
|
+
error: `Move failed: source delete of ${entityType}/${entityId} failed (${deleteMsg}). Target rolled back; folders are back to their pre-move state.`,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
catch (compErr) {
|
|
1018
|
+
const compMsg = compErr instanceof Error ? compErr.message : String(compErr);
|
|
1019
|
+
if (recoveryMsg) {
|
|
1020
|
+
return reply.status(500).send({
|
|
1021
|
+
error: `Move failed and could not be rolled back. Source delete failed (${deleteMsg}); source recovery commit failed (${recoveryMsg}); compensating target delete also failed (${compMsg}). Captured source content was logged for manual recovery.`,
|
|
1022
|
+
code: 'MOVE_RECOVERY_AND_ROLLBACK_FAILED',
|
|
1023
|
+
sourceRepo,
|
|
1024
|
+
targetRepo,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return reply.status(500).send({
|
|
1028
|
+
error: `Move failed and could not be rolled back. Source delete failed (${deleteMsg}); compensating target delete also failed (${compMsg}). Entry now exists in BOTH ${sourceRepo} and ${targetRepo} — manual cleanup required.`,
|
|
1029
|
+
code: 'MOVE_DUPLICATE',
|
|
1030
|
+
sourceRepo,
|
|
1031
|
+
targetRepo,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// Phase 3 — both phases succeeded; only now broadcast. Order:
|
|
1037
|
+
// source-delete first so subscribers don't briefly observe the
|
|
1038
|
+
// entity in both folders during the network race.
|
|
597
1039
|
const actor = req.user?.displayName ?? 'API';
|
|
598
1040
|
const ts = new Date().toISOString();
|
|
599
|
-
wsHub?.broadcast({
|
|
600
|
-
type: 'entity_change', action: 'created', repo: targetRepo, entityType, entityId,
|
|
601
|
-
actor, source: 'api', timestamp: ts,
|
|
602
|
-
});
|
|
603
|
-
// Delete from source if moving
|
|
604
1041
|
if (mode === 'move') {
|
|
605
|
-
await sourceGraph.delete(entityType, entityId, undefined, gitUser);
|
|
606
1042
|
wsHub?.broadcast({
|
|
607
1043
|
type: 'entity_change', action: 'deleted', repo: sourceRepo, entityType, entityId,
|
|
608
1044
|
actor, source: 'api', timestamp: ts,
|
|
609
1045
|
});
|
|
610
1046
|
}
|
|
1047
|
+
wsHub?.broadcast({
|
|
1048
|
+
type: 'entity_change', action: 'created', repo: targetRepo, entityType, entityId,
|
|
1049
|
+
actor, source: 'api', timestamp: ts,
|
|
1050
|
+
});
|
|
611
1051
|
return reply.send({
|
|
612
1052
|
success: true,
|
|
613
1053
|
entity: created,
|
|
@@ -618,27 +1058,208 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
618
1058
|
return reply.status(400).send({ error: err.message });
|
|
619
1059
|
}
|
|
620
1060
|
});
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1061
|
+
// POST /api/repos/:repo/transfer-entities — bulk move a list of entries
|
|
1062
|
+
// into a single target folder (in this or another repo). The web UI's
|
|
1063
|
+
// multi-select Move flow used to loop and POST per entry; this collapses
|
|
1064
|
+
// to one round-trip per source repo and one commit per repo touched.
|
|
1065
|
+
fastify.post('/api/repos/:repo/transfer-entities', async (req, reply) => {
|
|
1066
|
+
const sourceRepo = req.params.repo;
|
|
1067
|
+
const { entries, targetRepo, targetFolder } = req.body ?? {};
|
|
1068
|
+
if (!Array.isArray(entries) || !targetRepo || targetFolder === undefined) {
|
|
1069
|
+
return reply.status(400).send({ error: 'entries, targetRepo, and targetFolder are required' });
|
|
1070
|
+
}
|
|
1071
|
+
// Workspace Assets store ownership gate (codex P1 / D2): same rule as the
|
|
1072
|
+
// single /transfer route, applied per asset entry. No-op when the source
|
|
1073
|
+
// isn't workspace_wide.
|
|
1074
|
+
for (const e of entries) {
|
|
1075
|
+
if (e.entityType !== 'asset')
|
|
1076
|
+
continue;
|
|
1077
|
+
const denial = assetDeleteDenial(workspaceManager.getAssetIndex?.(), workspaceManager.getRepoConfig(sourceRepo), reqUser(req), sourceRepo, e.entityType, e.entityId);
|
|
1078
|
+
if (denial)
|
|
1079
|
+
return reply.status(403).send({ error: denial });
|
|
1080
|
+
}
|
|
1081
|
+
// Access enforced by graphForWrite — both source (mutate) and
|
|
1082
|
+
// target (write) require write access. Resolved up-front so a
|
|
1083
|
+
// target-side denial never surfaces only after source mutation.
|
|
1084
|
+
const sourceGraph = graphForWrite(req, reply, sourceRepo);
|
|
1085
|
+
if (!sourceGraph)
|
|
1086
|
+
return;
|
|
1087
|
+
const targetGraph = graphForWrite(req, reply, targetRepo);
|
|
1088
|
+
if (!targetGraph)
|
|
1089
|
+
return;
|
|
1090
|
+
try {
|
|
1091
|
+
if (sessionManager) {
|
|
1092
|
+
await sessionManager.flush(sourceRepo);
|
|
1093
|
+
if (targetRepo !== sourceRepo)
|
|
1094
|
+
await sessionManager.flush(targetRepo);
|
|
1095
|
+
}
|
|
1096
|
+
const result = await sourceGraph.moveEntitiesToRepo(entries.map(e => ({ entityType: e.entityType, entityId: e.entityId })), targetGraph, targetFolder, { user: reqGitUser(req) });
|
|
1097
|
+
const actor = req.user?.displayName ?? 'API';
|
|
1098
|
+
const ts = new Date().toISOString();
|
|
1099
|
+
wsHub?.broadcast({ type: 'repo_sync', repo: sourceRepo, source: 'api', actor, timestamp: ts });
|
|
1100
|
+
if (targetRepo !== sourceRepo) {
|
|
1101
|
+
wsHub?.broadcast({ type: 'repo_sync', repo: targetRepo, source: 'api', actor, timestamp: ts });
|
|
1102
|
+
}
|
|
1103
|
+
return reply.send({ success: true, ...result });
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
const status = err?.name === 'InvalidFolderPathError' ? 400
|
|
1107
|
+
: err.message?.includes('not found') ? 404
|
|
1108
|
+
: err.message?.includes('already occupied') ? 409
|
|
1109
|
+
: 400;
|
|
1110
|
+
return reply.status(status).send({ error: err.message });
|
|
626
1111
|
}
|
|
627
|
-
|
|
1112
|
+
});
|
|
1113
|
+
// POST /api/repos/:repo/transfer-folder — move an entire folder (with all
|
|
1114
|
+
// descendants) into another repo in one shot. Replaces the per-entry loop
|
|
1115
|
+
// the web UI used to run, which produced 2 git commits per entry; this
|
|
1116
|
+
// route performs a single FS-level rename and one commit per repo.
|
|
1117
|
+
fastify.post('/api/repos/:repo/transfer-folder', async (req, reply) => {
|
|
1118
|
+
const sourceRepo = req.params.repo;
|
|
1119
|
+
const { sourceFolder, targetRepo, targetFolder } = req.body ?? {};
|
|
1120
|
+
if (!sourceFolder || !targetRepo || targetFolder === undefined) {
|
|
1121
|
+
return reply.status(400).send({ error: 'sourceFolder, targetRepo, and targetFolder are required' });
|
|
1122
|
+
}
|
|
1123
|
+
if (sourceRepo === targetRepo && sourceFolder === targetFolder) {
|
|
1124
|
+
return reply.status(400).send({ error: 'Source and target are the same' });
|
|
1125
|
+
}
|
|
1126
|
+
// Workspace Assets store gate (codex P1 / D2). Moving a folder OUT of a
|
|
1127
|
+
// workspace_wide store relocates+reassigns every asset under it, escaping
|
|
1128
|
+
// the per-asset uploader/owner delete rule. A folder-level bulk move in
|
|
1129
|
+
// the shared store is an admin action — restrict it to a workspace owner.
|
|
1130
|
+
// (No-op for normal folders; the manager never folder-moves the store.)
|
|
1131
|
+
if (workspaceManager.getRepoConfig(sourceRepo)?.workspace_wide && !isWorkspaceOwner(reqUser(req)?.role ?? '')) {
|
|
1132
|
+
return reply.status(403).send({ error: 'Only a workspace owner can move folders out of the shared Assets store.' });
|
|
1133
|
+
}
|
|
1134
|
+
// Access enforced by graphForWrite — both source (mutate) and
|
|
1135
|
+
// target (write) require write access. Resolved up-front so a
|
|
1136
|
+
// target-side denial never surfaces only after source mutation.
|
|
1137
|
+
const sourceGraph = graphForWrite(req, reply, sourceRepo);
|
|
1138
|
+
if (!sourceGraph)
|
|
1139
|
+
return;
|
|
1140
|
+
const targetGraph = graphForWrite(req, reply, targetRepo);
|
|
1141
|
+
if (!targetGraph)
|
|
1142
|
+
return;
|
|
628
1143
|
try {
|
|
629
|
-
|
|
1144
|
+
// Flush any deferred saves on either side so we don't strand a
|
|
1145
|
+
// pending edit in the source folder mid-move.
|
|
1146
|
+
if (sessionManager) {
|
|
1147
|
+
await sessionManager.flush(sourceRepo);
|
|
1148
|
+
if (targetRepo !== sourceRepo)
|
|
1149
|
+
await sessionManager.flush(targetRepo);
|
|
1150
|
+
}
|
|
1151
|
+
const stats = await sourceGraph.moveFolderToRepo(sourceFolder, targetGraph, targetFolder, { user: reqGitUser(req) });
|
|
1152
|
+
const actor = req.user?.displayName ?? 'API';
|
|
1153
|
+
const ts = new Date().toISOString();
|
|
1154
|
+
// One refresh-trigger per repo. The receiving and sending lists
|
|
1155
|
+
// both need to update; using `repo_sync` keeps the realtime
|
|
1156
|
+
// handler simple — it already calls workspace.refreshEntities
|
|
1157
|
+
// on this event type. Avoids emitting 2× entries-moved entity_change
|
|
1158
|
+
// broadcasts (one created, one deleted) per file.
|
|
1159
|
+
wsHub?.broadcast({ type: 'repo_sync', repo: sourceRepo, source: 'api', actor, timestamp: ts });
|
|
1160
|
+
if (targetRepo !== sourceRepo) {
|
|
1161
|
+
wsHub?.broadcast({ type: 'repo_sync', repo: targetRepo, source: 'api', actor, timestamp: ts });
|
|
1162
|
+
}
|
|
1163
|
+
return reply.send({ success: true, ...stats });
|
|
630
1164
|
}
|
|
631
1165
|
catch (err) {
|
|
632
|
-
|
|
1166
|
+
const status = err?.name === 'InvalidFolderPathError' ? 400
|
|
1167
|
+
: err.message?.includes('not found') ? 404
|
|
1168
|
+
: err.message?.includes('not empty') ? 409
|
|
1169
|
+
: 400;
|
|
1170
|
+
return reply.status(status).send({ error: err.message });
|
|
633
1171
|
}
|
|
1172
|
+
});
|
|
1173
|
+
// Workspace Assets store delete gate (codex P1 / D2) — uploader/owner only.
|
|
1174
|
+
// Pure decision lives in services/asset-delete-authz.ts (unit-tested); this
|
|
1175
|
+
// closure binds it to the route's workspaceManager.
|
|
1176
|
+
const denyAssetDelete = (user, repo, entityType, entityId) => assetDeleteDenial(workspaceManager.getAssetIndex?.(), workspaceManager.getRepoConfig(repo), user, repo, entityType, entityId);
|
|
1177
|
+
// DELETE /api/repos/:repo/entities/:type/:id — delete an entity
|
|
1178
|
+
fastify.delete('/api/repos/:repo/entities/:type/:id', async (req, reply) => {
|
|
1179
|
+
const { repo, type, id } = req.params;
|
|
1180
|
+
const denial = denyAssetDelete(reqUser(req), repo, type, id);
|
|
1181
|
+
if (denial)
|
|
1182
|
+
return reply.status(403).send({ error: denial });
|
|
1183
|
+
// Access enforced by graphForRead (WorkspaceAccessService).
|
|
1184
|
+
const graph = graphForRead(req, reply, repo);
|
|
1185
|
+
if (!graph)
|
|
1186
|
+
return;
|
|
634
1187
|
try {
|
|
1188
|
+
// Soft bridge-awareness: deleting a person that's linked to an auth
|
|
1189
|
+
// account orphans that user (their "My Tasks"/@mentions stop
|
|
1190
|
+
// resolving). We don't BLOCK the delete — the link is a soft
|
|
1191
|
+
// relationship — but we surface a warning so the caller knows, and the
|
|
1192
|
+
// orphan is then caught by the Profile panel's unlinked affordance.
|
|
1193
|
+
let bridgeWarning;
|
|
1194
|
+
if (type === 'person') {
|
|
1195
|
+
try {
|
|
1196
|
+
const handle = graph.get('person', id)?.data?.user_handle;
|
|
1197
|
+
if (typeof handle === 'string' && handle.length > 0) {
|
|
1198
|
+
bridgeWarning = `This record was linked to the "${handle}" account. Deleting it unlinks that user — their "My Tasks", @mentions, and task assignments stop resolving until they link a new record (Settings → Profile → "Create & link").`;
|
|
1199
|
+
log.warn(`[bridge] deleting linked person ${repo}/${id} (was user ${handle})`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
catch { /* not readable / already gone — let graph.delete surface it */ }
|
|
1203
|
+
}
|
|
635
1204
|
await graph.delete(type, id, `Delete ${type}/${id} from web UI`, reqGitUser(req));
|
|
1205
|
+
// Deleting an asset entity removes its blob(s); drop the cached bucket
|
|
1206
|
+
// walk so a legacy twin doesn't linger in the manager (codex P2).
|
|
1207
|
+
if (type === 'asset')
|
|
1208
|
+
clearLegacyBucketCache();
|
|
636
1209
|
wsHub?.broadcast({
|
|
637
1210
|
type: 'entity_change', action: 'deleted', repo, entityType: type, entityId: id,
|
|
638
1211
|
actor: req.user?.displayName ?? 'API',
|
|
639
1212
|
source: 'api', timestamp: new Date().toISOString(),
|
|
640
1213
|
});
|
|
641
|
-
return reply.send({ ok: true });
|
|
1214
|
+
return reply.send({ ok: true, ...(bridgeWarning ? { warning: bridgeWarning } : {}) });
|
|
1215
|
+
}
|
|
1216
|
+
catch (err) {
|
|
1217
|
+
return reply.status(400).send({ error: err.message });
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
// POST /api/repos/:repo/entities/bulk-delete — delete many entities in
|
|
1221
|
+
// one git transaction + one WS broadcast. Replaces N parallel
|
|
1222
|
+
// DELETE /entities/:type/:id calls, which on a 500-entry bulk delete
|
|
1223
|
+
// produced 500 git commits + 500 entity_change broadcasts and froze
|
|
1224
|
+
// the UI for minutes.
|
|
1225
|
+
fastify.post('/api/repos/:repo/entities/bulk-delete', async (req, reply) => {
|
|
1226
|
+
const { repo } = req.params;
|
|
1227
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
1228
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1229
|
+
if (!graph)
|
|
1230
|
+
return;
|
|
1231
|
+
const body = req.body;
|
|
1232
|
+
if (!body?.entities || !Array.isArray(body.entities)) {
|
|
1233
|
+
return reply.status(400).send({ error: 'Expected { entities: [{ entityType, entityId }] }' });
|
|
1234
|
+
}
|
|
1235
|
+
// Apply the workspace-Assets delete gate per entity (codex P1 / D2):
|
|
1236
|
+
// uploader/owner-only. Denied entries are reported, not silently dropped.
|
|
1237
|
+
const deleteUser = reqUser(req);
|
|
1238
|
+
const denied = [];
|
|
1239
|
+
const allowed = body.entities.filter(e => {
|
|
1240
|
+
const d = denyAssetDelete(deleteUser, repo, e.entityType, e.entityId);
|
|
1241
|
+
if (d) {
|
|
1242
|
+
denied.push({ entityType: e.entityType, entityId: e.entityId, error: d });
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
return true;
|
|
1246
|
+
});
|
|
1247
|
+
try {
|
|
1248
|
+
const result = await graph.bulkDelete(allowed.map(e => ({ entityType: e.entityType, entityId: e.entityId })), {
|
|
1249
|
+
user: reqGitUser(req),
|
|
1250
|
+
commitMessage: `Bulk delete ${allowed.length} ${allowed.length === 1 ? 'entry' : 'entries'} from web UI`,
|
|
1251
|
+
});
|
|
1252
|
+
if (result.deleted.length > 0) {
|
|
1253
|
+
wsHub?.broadcast({
|
|
1254
|
+
type: 'repo_sync', repo,
|
|
1255
|
+
actor: req.user?.displayName ?? 'API',
|
|
1256
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
return reply.send({
|
|
1260
|
+
deleted: result.deleted.length,
|
|
1261
|
+
errors: [...result.errors, ...denied],
|
|
1262
|
+
});
|
|
642
1263
|
}
|
|
643
1264
|
catch (err) {
|
|
644
1265
|
return reply.status(400).send({ error: err.message });
|
|
@@ -647,78 +1268,418 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
647
1268
|
// POST /api/repos/:repo/entities — create a new entity
|
|
648
1269
|
fastify.post('/api/repos/:repo/entities', async (req, reply) => {
|
|
649
1270
|
const { repo } = req.params;
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1271
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
1272
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1273
|
+
if (!graph)
|
|
1274
|
+
return;
|
|
1275
|
+
const { entityType, entityId, data = {}, content = '', folder_path } = req.body;
|
|
654
1276
|
if (!entityType || !entityId) {
|
|
655
1277
|
return reply.status(400).send({ error: 'entityType and entityId are required' });
|
|
656
1278
|
}
|
|
657
|
-
|
|
1279
|
+
// Refuse to re-mint a retired type (`asset`, Phase F). Otherwise an
|
|
1280
|
+
// unknown entityType falls back to EntityBaseSchema and writes a
|
|
1281
|
+
// base-only stub of exactly the type asset-as-embed removed.
|
|
1282
|
+
if (RESERVED_ENTITY_TYPES.has(entityType)) {
|
|
1283
|
+
return reply.status(400).send({ error: `Entity type '${entityType}' was retired and can no longer be created.` });
|
|
1284
|
+
}
|
|
1285
|
+
const eagerError = checkEagerSkillPermission(req, entityType, data, repo, workspaceManager, authService);
|
|
1286
|
+
if (eagerError)
|
|
1287
|
+
return reply.status(403).send({ error: eagerError });
|
|
1288
|
+
// Person entities can carry a `user_handle` pointer to an auth account;
|
|
1289
|
+
// enforce workspace-wide uniqueness AND owner-only authz before
|
|
1290
|
+
// delegating to the per-repo create path. Stamping a handle on a new
|
|
1291
|
+
// entity is the same trust action as stamping it on an existing one,
|
|
1292
|
+
// so non-owners can't create a pre-linked person entity either.
|
|
1293
|
+
if (entityType === 'person') {
|
|
1294
|
+
try {
|
|
1295
|
+
validateUserHandleInPayload(workspaceManager, data);
|
|
1296
|
+
if (Object.prototype.hasOwnProperty.call(data, 'user_handle')) {
|
|
1297
|
+
assertCanChangeUserHandle(reqUser(req), '', data.user_handle);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
if (err instanceof BridgeError) {
|
|
1302
|
+
const status = err.code === 'OWNER_REQUIRED' ? 403 : 409;
|
|
1303
|
+
return reply.status(status).send({ error: err.message, code: err.code, ...err.details });
|
|
1304
|
+
}
|
|
1305
|
+
throw err;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
// Scope ref fields (today: task.assignee → person). Rejects a wikilink
|
|
1309
|
+
// that resolves to a non-person; forward references are allowed.
|
|
658
1310
|
try {
|
|
659
|
-
|
|
1311
|
+
assertEntityRefs(workspaceManager, entityType, data, { userExists: (id) => !!authService.findUserById(Number(id)) });
|
|
660
1312
|
}
|
|
661
1313
|
catch (err) {
|
|
662
|
-
|
|
1314
|
+
if (err instanceof EntityRefError || err instanceof UnknownUserRefError) {
|
|
1315
|
+
return reply.status(400).send({ error: err.message, code: err.code, field: err.field });
|
|
1316
|
+
}
|
|
1317
|
+
throw err;
|
|
663
1318
|
}
|
|
664
1319
|
try {
|
|
1320
|
+
// Detect implicit subfolder creation: if the entry lands in a folder
|
|
1321
|
+
// that didn't exist yet, the entity_change below only refreshes the
|
|
1322
|
+
// entry list — clients need a _folder event to refetch the folder tree
|
|
1323
|
+
// (sidebar + list/gallery folder rows). Mirrors the MCP create_entity
|
|
1324
|
+
// path; normalize the same way graph.create does so the existence
|
|
1325
|
+
// check matches the real on-disk path.
|
|
1326
|
+
const normFolder = folder_path ? validateFolderPath(folder_path) : '';
|
|
1327
|
+
const folderExisted = !normFolder || existsSync(join(graph.getRepoPath(), normFolder));
|
|
1328
|
+
const now = new Date().toISOString();
|
|
1329
|
+
const actorId = reqGitUser(req)?.id ?? 'web';
|
|
1330
|
+
const stampedData = {
|
|
1331
|
+
...data,
|
|
1332
|
+
entity_type: entityType,
|
|
1333
|
+
entity_id: entityId,
|
|
1334
|
+
created_at: data.created_at ?? now,
|
|
1335
|
+
updated_at: data.updated_at ?? now,
|
|
1336
|
+
created_by: data.created_by ?? actorId,
|
|
1337
|
+
updated_by: data.updated_by ?? actorId,
|
|
1338
|
+
};
|
|
665
1339
|
const entity = await graph.create({
|
|
666
1340
|
entityType: entityType,
|
|
667
1341
|
entityId,
|
|
668
|
-
data:
|
|
1342
|
+
data: stampedData,
|
|
669
1343
|
content,
|
|
1344
|
+
folderPath: folder_path,
|
|
670
1345
|
commitMessage: `Create ${entityType}/${entityId} from web UI`,
|
|
671
1346
|
user: reqGitUser(req),
|
|
672
1347
|
});
|
|
1348
|
+
const actor = req.user?.displayName ?? 'API';
|
|
1349
|
+
const ts = new Date().toISOString();
|
|
673
1350
|
wsHub?.broadcast({
|
|
674
1351
|
type: 'entity_change', action: 'created', repo, entityType, entityId,
|
|
1352
|
+
actor, source: 'api', timestamp: ts,
|
|
1353
|
+
});
|
|
1354
|
+
if (!folderExisted) {
|
|
1355
|
+
wsHub?.broadcast({
|
|
1356
|
+
type: 'entity_change', action: 'created', repo, entityType: '_folder', entityId: normFolder,
|
|
1357
|
+
actor, source: 'api', timestamp: ts,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
return reply.status(201).send(entity);
|
|
1361
|
+
}
|
|
1362
|
+
catch (err) {
|
|
1363
|
+
return reply.status(400).send({ error: err.message });
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
// POST /api/repos/:repo/move — move entry to a different folder within the
|
|
1367
|
+
// same repo. Distinct from /transfer (cross-repo) and /rename (id change).
|
|
1368
|
+
// The entry's type and id are unchanged; only its folder_path moves.
|
|
1369
|
+
fastify.post('/api/repos/:repo/move', async (req, reply) => {
|
|
1370
|
+
const { repo } = req.params;
|
|
1371
|
+
const { entityType, entityId, folder_path } = req.body ?? {};
|
|
1372
|
+
if (!entityType || !entityId || folder_path === undefined) {
|
|
1373
|
+
return reply.status(400).send({ error: 'entityType, entityId, and folder_path are required' });
|
|
1374
|
+
}
|
|
1375
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
1376
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1377
|
+
if (!graph)
|
|
1378
|
+
return;
|
|
1379
|
+
try {
|
|
1380
|
+
if (sessionManager)
|
|
1381
|
+
await sessionManager.flush(repo);
|
|
1382
|
+
// If the destination folder doesn't exist yet, the move creates it —
|
|
1383
|
+
// emit a _folder event (alongside the entry update) so clients refetch
|
|
1384
|
+
// the folder tree, not just the entry list. Mirrors MCP move_entity.
|
|
1385
|
+
const normTarget = folder_path ? validateFolderPath(folder_path) : '';
|
|
1386
|
+
const targetExisted = !normTarget || existsSync(join(graph.getRepoPath(), normTarget));
|
|
1387
|
+
const moved = await graph.move(entityType, entityId, folder_path, undefined, reqGitUser(req));
|
|
1388
|
+
const actor = req.user?.displayName ?? 'API';
|
|
1389
|
+
const ts = new Date().toISOString();
|
|
1390
|
+
wsHub?.broadcast({
|
|
1391
|
+
type: 'entity_change', action: 'updated', repo, entityType, entityId,
|
|
1392
|
+
actor, source: 'api', timestamp: ts,
|
|
1393
|
+
});
|
|
1394
|
+
if (!targetExisted) {
|
|
1395
|
+
wsHub?.broadcast({
|
|
1396
|
+
type: 'entity_change', action: 'created', repo, entityType: '_folder', entityId: normTarget,
|
|
1397
|
+
actor, source: 'api', timestamp: ts,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
return reply.send({ success: true, entity: moved });
|
|
1401
|
+
}
|
|
1402
|
+
catch (err) {
|
|
1403
|
+
const status = err?.name === 'InvalidFolderPathError' ? 400
|
|
1404
|
+
: err.message?.includes('already occupied') ? 409
|
|
1405
|
+
: err.message?.includes('not found') ? 404
|
|
1406
|
+
: 400;
|
|
1407
|
+
return reply.status(status).send({ error: err.message });
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
// POST /api/repos/:repo/folders — create a folder (with .gitkeep). Auto-creates parents.
|
|
1411
|
+
// Optional display_name is written to the folder's .studiograph-folder.json
|
|
1412
|
+
// sidecar when set; otherwise the client falls back to toDisplayName(slug).
|
|
1413
|
+
fastify.post('/api/repos/:repo/folders', async (req, reply) => {
|
|
1414
|
+
const { repo } = req.params;
|
|
1415
|
+
const { path, display_name } = req.body ?? {};
|
|
1416
|
+
if (!path || typeof path !== 'string') {
|
|
1417
|
+
return reply.status(400).send({ error: 'path is required' });
|
|
1418
|
+
}
|
|
1419
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
1420
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1421
|
+
if (!graph)
|
|
1422
|
+
return;
|
|
1423
|
+
try {
|
|
1424
|
+
await graph.createFolder(path, undefined, reqGitUser(req), {
|
|
1425
|
+
displayName: display_name,
|
|
1426
|
+
});
|
|
1427
|
+
// Emit the _folder sentinel so OTHER connected clients refetch the
|
|
1428
|
+
// folder tree (sidebar + list/gallery folder rows). The local creator
|
|
1429
|
+
// already updated optimistically via workspace.addFolder.
|
|
1430
|
+
wsHub?.broadcast({
|
|
1431
|
+
type: 'entity_change', action: 'created', repo, entityType: '_folder', entityId: validateFolderPath(path),
|
|
675
1432
|
actor: req.user?.displayName ?? 'API',
|
|
676
1433
|
source: 'api', timestamp: new Date().toISOString(),
|
|
677
1434
|
});
|
|
678
|
-
return reply.status(201).send(
|
|
1435
|
+
return reply.status(201).send({ success: true, path });
|
|
679
1436
|
}
|
|
680
1437
|
catch (err) {
|
|
681
1438
|
return reply.status(400).send({ error: err.message });
|
|
682
1439
|
}
|
|
683
1440
|
});
|
|
1441
|
+
// PATCH /api/repos/:repo/folders — rename / move a folder within the repo,
|
|
1442
|
+
// optionally updating its display_name in the same commit. Three shapes:
|
|
1443
|
+
// { from, to } — slug-only rename / move
|
|
1444
|
+
// { from, to, display_name } — slug change + display update
|
|
1445
|
+
// { from, to: from, display_name } — display-only update (no FS move)
|
|
1446
|
+
// `display_name: ""` (or null) clears the override and reverts to the slug
|
|
1447
|
+
// fallback.
|
|
1448
|
+
fastify.patch('/api/repos/:repo/folders', async (req, reply) => {
|
|
1449
|
+
const { repo } = req.params;
|
|
1450
|
+
const { from, to, display_name } = req.body ?? {};
|
|
1451
|
+
if (!from || !to) {
|
|
1452
|
+
return reply.status(400).send({ error: 'from and to are required' });
|
|
1453
|
+
}
|
|
1454
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
1455
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1456
|
+
if (!graph)
|
|
1457
|
+
return;
|
|
1458
|
+
try {
|
|
1459
|
+
if (sessionManager)
|
|
1460
|
+
await sessionManager.flush(repo);
|
|
1461
|
+
await graph.renameFolder(from, to, undefined, reqGitUser(req), {
|
|
1462
|
+
displayName: display_name === undefined ? undefined : display_name,
|
|
1463
|
+
});
|
|
1464
|
+
// Rename/move relocates entries on disk, so the _folder sentinel's
|
|
1465
|
+
// refreshRepoAfterFolderMutation (tree + entity summaries) is exactly
|
|
1466
|
+
// what other clients need. Carries the new path as the entityId.
|
|
1467
|
+
wsHub?.broadcast({
|
|
1468
|
+
type: 'entity_change', action: 'updated', repo, entityType: '_folder', entityId: validateFolderPath(to),
|
|
1469
|
+
actor: req.user?.displayName ?? 'API',
|
|
1470
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
1471
|
+
});
|
|
1472
|
+
return reply.send({ success: true, from, to });
|
|
1473
|
+
}
|
|
1474
|
+
catch (err) {
|
|
1475
|
+
const status = err?.name === 'InvalidFolderPathError' ? 400
|
|
1476
|
+
: err.message?.includes('not found') ? 404
|
|
1477
|
+
: err.message?.includes('already exists') ? 409
|
|
1478
|
+
: 400;
|
|
1479
|
+
return reply.status(status).send({ error: err.message });
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
// DELETE /api/repos/:repo/folders?path=X&recursive=true — delete a folder.
|
|
1483
|
+
// Without recursive=true, non-empty folders return 409 with blast-radius
|
|
1484
|
+
// counts so the UI can confirm before retrying.
|
|
1485
|
+
fastify.delete('/api/repos/:repo/folders', async (req, reply) => {
|
|
1486
|
+
const { repo } = req.params;
|
|
1487
|
+
const { path, recursive } = req.query;
|
|
1488
|
+
if (!path) {
|
|
1489
|
+
return reply.status(400).send({ error: 'path query parameter is required' });
|
|
1490
|
+
}
|
|
1491
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
1492
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1493
|
+
if (!graph)
|
|
1494
|
+
return;
|
|
1495
|
+
// Workspace Assets store gate (codex P1 / D2). A recursive delete in a
|
|
1496
|
+
// workspace_wide store destroys every asset under the folder, bypassing
|
|
1497
|
+
// the per-asset uploader/owner rule — restrict it to a workspace owner.
|
|
1498
|
+
// (Non-recursive delete is blocked on non-empty folders by FOLDER_NOT_EMPTY,
|
|
1499
|
+
// so it can't take assets with it.)
|
|
1500
|
+
if (recursive === 'true' && workspaceManager.getRepoConfig(repo)?.workspace_wide && !isWorkspaceOwner(reqUser(req)?.role ?? '')) {
|
|
1501
|
+
return reply.status(403).send({ error: 'Only a workspace owner can recursively delete folders in the shared Assets store.' });
|
|
1502
|
+
}
|
|
1503
|
+
try {
|
|
1504
|
+
if (sessionManager)
|
|
1505
|
+
await sessionManager.flush(repo);
|
|
1506
|
+
const result = await graph.deleteFolder(path, {
|
|
1507
|
+
recursive: recursive === 'true',
|
|
1508
|
+
user: reqGitUser(req),
|
|
1509
|
+
});
|
|
1510
|
+
// Emit the _folder sentinel so other clients drop the folder (and any
|
|
1511
|
+
// entries it held, via the entity-summary refresh) from their view.
|
|
1512
|
+
wsHub?.broadcast({
|
|
1513
|
+
type: 'entity_change', action: 'deleted', repo, entityType: '_folder', entityId: validateFolderPath(path),
|
|
1514
|
+
actor: req.user?.displayName ?? 'API',
|
|
1515
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
1516
|
+
});
|
|
1517
|
+
return reply.send({ success: true, ...result });
|
|
1518
|
+
}
|
|
1519
|
+
catch (err) {
|
|
1520
|
+
if (err?.code === 'FOLDER_NOT_EMPTY') {
|
|
1521
|
+
return reply.status(409).send({
|
|
1522
|
+
error: err.message,
|
|
1523
|
+
code: 'FOLDER_NOT_EMPTY',
|
|
1524
|
+
blast_radius: err.blastRadius,
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
const status = err?.name === 'InvalidFolderPathError' ? 400
|
|
1528
|
+
: err.message?.includes('not found') ? 404
|
|
1529
|
+
: 400;
|
|
1530
|
+
return reply.status(status).send({ error: err.message });
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
684
1533
|
// POST /api/repos/:repo/commit — flush deferred commits (navigate-away signal)
|
|
685
1534
|
fastify.post('/api/repos/:repo/commit', async (req, reply) => {
|
|
686
1535
|
if (!sessionManager) {
|
|
687
1536
|
return reply.send({ committed: false });
|
|
688
1537
|
}
|
|
689
1538
|
const { repo } = req.params;
|
|
690
|
-
|
|
691
|
-
|
|
1539
|
+
// Access enforced via accessService.canRead \u2014 flush operates on the
|
|
1540
|
+
// SessionManager (no graph instance needed), so a boolean check is
|
|
1541
|
+
// sufficient. Pending commits were already authored by an authenticated
|
|
1542
|
+
// writer; this endpoint just flushes them on navigate-away.
|
|
1543
|
+
if (!accessService.canRead(reqUser(req), repo)) {
|
|
1544
|
+
return reply.status(404).send({ error: `Folder \u201C${repo}\u201D not found` });
|
|
692
1545
|
}
|
|
693
1546
|
const committed = await sessionManager.flush(repo);
|
|
694
1547
|
return reply.send({ committed });
|
|
695
1548
|
});
|
|
696
|
-
// POST /api/repos/:repo/import — import files
|
|
1549
|
+
// POST /api/repos/:repo/import — import files into a folder.
|
|
1550
|
+
//
|
|
1551
|
+
// Routing by extension:
|
|
1552
|
+
// .md / .txt / .csv → direct write via importFile() — no LLM
|
|
1553
|
+
// round-trip, preserves the source faithfully. Markdown frontmatter
|
|
1554
|
+
// (entity_type, entity_id) is honored when present; otherwise
|
|
1555
|
+
// sensible defaults.
|
|
1556
|
+
// .pdf + opaque binaries → asset wrapper via uploadAsset() —
|
|
1557
|
+
// creates an `asset` entity, stores the binary in R2 or the local
|
|
1558
|
+
// volume, writes a PDF text sidecar for vector indexing.
|
|
1559
|
+
// .docx → text extraction + agent capture
|
|
1560
|
+
// pipeline (until the mammoth → markdown workstream lands).
|
|
697
1561
|
fastify.post('/api/repos/:repo/import', async (req, reply) => {
|
|
698
1562
|
const { repo } = req.params;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1563
|
+
// Access enforced by graphForWrite — import creates entities, so write
|
|
1564
|
+
// is required. Graph captured here is reused by the inner closures so
|
|
1565
|
+
// they don't need to re-resolve (which would also bypass the gate).
|
|
1566
|
+
const repoGraph = graphForWrite(req, reply, repo);
|
|
1567
|
+
if (!repoGraph)
|
|
1568
|
+
return;
|
|
702
1569
|
const body = req.body;
|
|
703
1570
|
if (!body?.files || !Array.isArray(body.files)) {
|
|
704
1571
|
return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
|
|
705
1572
|
}
|
|
1573
|
+
// Reject paths with traversal segments; importFile slugifies the rest.
|
|
1574
|
+
// Empty / undefined = repo root (existing behavior).
|
|
1575
|
+
const targetFolderPath = (body.targetFolderPath ?? '').trim();
|
|
1576
|
+
if (targetFolderPath.split('/').some(seg => seg === '..' || seg === '.')) {
|
|
1577
|
+
return reply.status(400).send({ error: 'Invalid targetFolderPath: contains traversal segments' });
|
|
1578
|
+
}
|
|
706
1579
|
const captureRepo = body.agentDecides ? undefined : repo;
|
|
1580
|
+
const gitUser = reqGitUser(req);
|
|
1581
|
+
// Cancel support (CANCEL): the web client aborts the fetch via an
|
|
1582
|
+
// AbortController. Listen on the RESPONSE stream's `close` — it fires when
|
|
1583
|
+
// the connection drops, and `writableEnded` is false iff we haven't
|
|
1584
|
+
// finished sending the response yet, i.e. a genuine mid-import client
|
|
1585
|
+
// abort (vs the normal post-response close, where writableEnded is true).
|
|
1586
|
+
// `reply.raw` is the ServerResponse, the canonical place to detect that
|
|
1587
|
+
// the peer went away while we're still working; `req.raw` (the parsed
|
|
1588
|
+
// request) is a less reliable signal for an abort during processing.
|
|
1589
|
+
// Best-effort: the batch runner only reads this between batches, so a
|
|
1590
|
+
// single-batch import still commits its completed prefix (additive +
|
|
1591
|
+
// collision-safe). The listener is on this request's per-request
|
|
1592
|
+
// ServerResponse, so it's GC'd with the request — no cross-request leak.
|
|
1593
|
+
let clientGone = false;
|
|
1594
|
+
reply.raw.on('close', () => {
|
|
1595
|
+
if (!reply.raw.writableEnded)
|
|
1596
|
+
clientGone = true;
|
|
1597
|
+
});
|
|
707
1598
|
try {
|
|
708
|
-
const { extractText,
|
|
1599
|
+
const { extractText, importFile } = await import('../../services/import-service.js');
|
|
1600
|
+
const { importAssetAsEntity } = await import('../../services/asset-import-service.js');
|
|
1601
|
+
const { resolveFormat, isIndexable, FALLBACK_ASSET_ENTRY } = await import('../../core/format-registry.js');
|
|
709
1602
|
const results = [];
|
|
710
1603
|
const CONCURRENCY = 3;
|
|
711
|
-
async function
|
|
712
|
-
|
|
713
|
-
|
|
1604
|
+
async function processDirect(file) {
|
|
1605
|
+
// Reuse the access-checked graph captured in the outer scope. Do
|
|
1606
|
+
// NOT re-resolve via workspaceManager.getGraph here — the audit's
|
|
1607
|
+
// boundary is the outer graphForWrite call.
|
|
1608
|
+
const graph = repoGraph;
|
|
1609
|
+
const result = await importFile(graph, file.filename, file.content, {
|
|
1610
|
+
user: gitUser,
|
|
1611
|
+
// Caller-chosen destination subfolder; importFile combines it
|
|
1612
|
+
// with each file's own relative directory.
|
|
1613
|
+
folderPath: targetFolderPath || undefined,
|
|
1614
|
+
// Defer commits — we batch them into one transaction at the
|
|
1615
|
+
// end of the request so a 500-file import is one git commit
|
|
1616
|
+
// instead of 500. Per-file commits dominated wall time
|
|
1617
|
+
// (~50-200ms each) before this; batching collapses that to
|
|
1618
|
+
// one commit for the whole batch.
|
|
1619
|
+
skipCommit: true,
|
|
1620
|
+
});
|
|
1621
|
+
if (result.status === 'created' && result.path) {
|
|
1622
|
+
return {
|
|
1623
|
+
filename: file.filename,
|
|
1624
|
+
status: 'created',
|
|
1625
|
+
entities: [{ entityId: result.entityId, entityType: result.entityType }],
|
|
1626
|
+
entry: {
|
|
1627
|
+
operation: 'create',
|
|
1628
|
+
entityType: result.entityType,
|
|
1629
|
+
entityId: result.entityId,
|
|
1630
|
+
filePath: result.path,
|
|
1631
|
+
},
|
|
1632
|
+
sidecarPaths: result.sidecarPaths,
|
|
1633
|
+
};
|
|
714
1634
|
}
|
|
1635
|
+
return { filename: file.filename, status: result.status, error: result.error };
|
|
1636
|
+
}
|
|
1637
|
+
async function processAsset(file) {
|
|
1638
|
+
// Reuse the outer access-checked graph; re-resolving would bypass
|
|
1639
|
+
// the architectural boundary even though the closure captures repo.
|
|
1640
|
+
const graph = repoGraph;
|
|
1641
|
+
try {
|
|
1642
|
+
const buffer = Buffer.from(file.content, 'base64');
|
|
1643
|
+
// Asset-as-embed (Phase B): store the binary folder-owned + create a
|
|
1644
|
+
// properly-TYPED entity (document / dataset×N / material) that embeds
|
|
1645
|
+
// it via asset_ref. No self-owning `asset` entity, no text sidecar
|
|
1646
|
+
// (the seeded body is indexed natively). xlsx fans out to N datasets,
|
|
1647
|
+
// so this returns 1..N entities — `commitEntries` carries them all.
|
|
1648
|
+
const result = await importAssetAsEntity({
|
|
1649
|
+
graph,
|
|
1650
|
+
filename: file.filename,
|
|
1651
|
+
buffer,
|
|
1652
|
+
folderPath: targetFolderPath || undefined,
|
|
1653
|
+
user: gitUser,
|
|
1654
|
+
uploaderUserId: reqUser(req)?.id != null ? String(reqUser(req).id) : null,
|
|
1655
|
+
// Defer commits — the dispatcher's batched commitPending call at
|
|
1656
|
+
// the bottom of the route covers every entry in one transaction.
|
|
1657
|
+
skipCommit: true,
|
|
1658
|
+
});
|
|
1659
|
+
return {
|
|
1660
|
+
filename: file.filename,
|
|
1661
|
+
status: 'created',
|
|
1662
|
+
entities: result.entities.map(e => ({ entityId: e.entityId, entityType: e.entityType })),
|
|
1663
|
+
commitEntries: result.entities.map(e => ({
|
|
1664
|
+
operation: 'create',
|
|
1665
|
+
entityType: e.entityType,
|
|
1666
|
+
entityId: e.entityId,
|
|
1667
|
+
filePath: e.filePath,
|
|
1668
|
+
})),
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
catch (err) {
|
|
1672
|
+
return { filename: file.filename, status: 'error', error: err.message ?? 'Asset import failed' };
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
async function processViaAgent(file) {
|
|
715
1676
|
try {
|
|
716
1677
|
const text = await extractText(file.filename, file.content);
|
|
717
1678
|
if (!text) {
|
|
718
|
-
|
|
1679
|
+
log.warn(`[import] No text extracted from ${file.filename}`);
|
|
719
1680
|
return { filename: file.filename, status: 'error', error: 'No text content extracted' };
|
|
720
1681
|
}
|
|
721
|
-
|
|
1682
|
+
log.info(`[import] Extracted ${text.length} chars from ${file.filename}`);
|
|
722
1683
|
const captureRes = await fastify.inject({
|
|
723
1684
|
method: 'POST',
|
|
724
1685
|
url: '/api/capture',
|
|
@@ -731,7 +1692,7 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
731
1692
|
});
|
|
732
1693
|
const captureBody = JSON.parse(captureRes.body);
|
|
733
1694
|
if (captureRes.statusCode !== 200 || !captureBody.success) {
|
|
734
|
-
|
|
1695
|
+
log.warn(`[import] Capture failed for ${file.filename}: ${captureRes.statusCode} ${captureBody.error ?? JSON.stringify(captureBody).slice(0, 200)}`);
|
|
735
1696
|
return { filename: file.filename, status: 'error', error: captureBody.error ?? 'Agent processing failed' };
|
|
736
1697
|
}
|
|
737
1698
|
return { filename: file.filename, status: 'created', entities: captureBody.entities };
|
|
@@ -740,21 +1701,156 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
740
1701
|
return { filename: file.filename, status: 'error', error: err.message ?? 'Processing failed' };
|
|
741
1702
|
}
|
|
742
1703
|
}
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1704
|
+
// Route a single file off the format registry — the one source of
|
|
1705
|
+
// truth for every ingestion surface. P0 is a parity refactor: the
|
|
1706
|
+
// routing below reproduces today's behavior exactly. The only
|
|
1707
|
+
// intentional follow-ups are flagged inline (docx/svg transitional
|
|
1708
|
+
// paths; the FALLBACK reject that P0b flips to lenient).
|
|
1709
|
+
async function routeByFormat(file, entry) {
|
|
1710
|
+
// Transitional formats keep the current agent-extraction path until
|
|
1711
|
+
// their real handler lands. After P2 this is svg only — it routes
|
|
1712
|
+
// here until sanitize-html is wired (P3); routing it to uploadAsset
|
|
1713
|
+
// before then would store unsanitized svg (stored-XSS). docx flipped
|
|
1714
|
+
// to the asset path in P2 (uploads as asset, converts on demand).
|
|
1715
|
+
if (entry.transitional === 'agent-extract') {
|
|
1716
|
+
return processViaAgent(file);
|
|
1717
|
+
}
|
|
1718
|
+
switch (entry.handler) {
|
|
1719
|
+
case 'importDirect':
|
|
1720
|
+
return processDirect(file);
|
|
1721
|
+
case 'uploadAsset':
|
|
1722
|
+
// P0 parity: genuinely-unknown types are still rejected. The
|
|
1723
|
+
// lenient generic-asset fallback (accept unknowns as assets) is
|
|
1724
|
+
// P0b — a deliberate behavior change made by removing exactly
|
|
1725
|
+
// this guard.
|
|
1726
|
+
if (entry === FALLBACK_ASSET_ENTRY) {
|
|
1727
|
+
return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
|
|
1728
|
+
}
|
|
1729
|
+
return processAsset(file);
|
|
1730
|
+
default:
|
|
1731
|
+
// importBookmarks is not routed through the dispatcher until
|
|
1732
|
+
// P8 (content-sniff); bookmark exports still use the dedicated
|
|
1733
|
+
// POST /api/repos/:repo/import-bookmarks endpoint, so a bare
|
|
1734
|
+
// .html lands here and is rejected — matching today.
|
|
1735
|
+
return { filename: file.filename, status: 'error', error: `Unsupported file: ${file.filename}` };
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
async function processFile(file) {
|
|
1739
|
+
if (!file.filename) {
|
|
1740
|
+
return { filename: file.filename, status: 'error', error: 'Missing filename' };
|
|
1741
|
+
}
|
|
1742
|
+
// Resolve the format once and thread it through routing.
|
|
1743
|
+
// `searchable` (OV3 server contract) reflects the registry's
|
|
1744
|
+
// DECLARED indexability for the file — the client (P5) renders the
|
|
1745
|
+
// honest "stored as-is / not searchable" callouts from it.
|
|
1746
|
+
// KNOWN LIMITATION: this is declared, not actual. A scanned/encrypted
|
|
1747
|
+
// PDF declares searchable:true (indexAs:'sidecar') but yields no text,
|
|
1748
|
+
// so it won't be flagged "stored as-is" even though its content is
|
|
1749
|
+
// effectively filename-only. Closing that gap needs the extractor to
|
|
1750
|
+
// report actual text-yield back into this flag (P3-indexing scope);
|
|
1751
|
+
// the negative claim stays accurate, the positive one is best-effort.
|
|
1752
|
+
const entry = resolveFormat(file.filename);
|
|
1753
|
+
const searchable = isIndexable(entry);
|
|
1754
|
+
const result = await routeByFormat(file, entry);
|
|
1755
|
+
return { ...result, searchable };
|
|
1756
|
+
}
|
|
1757
|
+
// Process files in cancellable batches of CONCURRENCY. The runner
|
|
1758
|
+
// checks the abort flag between batches: if the client disconnected
|
|
1759
|
+
// (web AbortController), it stops dispatching the remainder, lets the
|
|
1760
|
+
// in-flight batch settle, and returns the completed prefix — which the
|
|
1761
|
+
// commitPending step below commits in one transaction (no uncommitted
|
|
1762
|
+
// files left behind). Cancel is partial + honest: import is additive
|
|
1763
|
+
// and collision-safe.
|
|
1764
|
+
const { runImportBatches } = await import('./import-batch.js');
|
|
1765
|
+
const { results: batched, cancelled } = await runImportBatches(body.files, processFile, { concurrency: CONCURRENCY, isAborted: () => clientGone });
|
|
1766
|
+
results.push(...batched);
|
|
1767
|
+
// Direct-write path deferred git commits — flush them all in one
|
|
1768
|
+
// transaction now. Collapses N per-file commits into one batch
|
|
1769
|
+
// commit (~50ms vs N×50ms). Agent-path files committed inline via
|
|
1770
|
+
// /api/capture so they're not in this set.
|
|
1771
|
+
const pendingEntries = results.flatMap(r => {
|
|
1772
|
+
const pf = r;
|
|
1773
|
+
// Asset-import files carry commitEntries (1..N); direct-write files
|
|
1774
|
+
// carry a single entry. Prefer the plural, fall back to the singular.
|
|
1775
|
+
if (pf.commitEntries && pf.commitEntries.length > 0)
|
|
1776
|
+
return pf.commitEntries;
|
|
1777
|
+
return pf.entry ? [pf.entry] : [];
|
|
1778
|
+
});
|
|
1779
|
+
// Collect every sidecar path produced by the import — these stage in
|
|
1780
|
+
// the same commit as the entity files, so the folder display name
|
|
1781
|
+
// and the entries it labels land atomically. De-dup because two
|
|
1782
|
+
// imported files inside the same Schema_OS/widgets/ would otherwise
|
|
1783
|
+
// produce duplicate sidecar entries.
|
|
1784
|
+
const sidecarPaths = Array.from(new Set(results.flatMap(r => r.sidecarPaths ?? [])));
|
|
1785
|
+
// commitPending failure used to be swallowed and the route returned
|
|
1786
|
+
// success=true even though the files were never committed. The audit
|
|
1787
|
+
// (2026-05) flagged this: a 500-file import that failed mid-commit
|
|
1788
|
+
// looked successful to the client but left N untracked files. Now
|
|
1789
|
+
// the failure propagates as 500 with a partial-failure shape so the
|
|
1790
|
+
// caller knows which entries are stranded.
|
|
1791
|
+
let commitError = null;
|
|
1792
|
+
if (pendingEntries.length > 0) {
|
|
1793
|
+
try {
|
|
1794
|
+
await repoGraph.commitPending(pendingEntries, {
|
|
1795
|
+
commitMessage: `Import ${pendingEntries.length} ${pendingEntries.length === 1 ? 'entry' : 'entries'} from web UI`,
|
|
1796
|
+
user: gitUser,
|
|
1797
|
+
extraPaths: sidecarPaths.length > 0 ? sidecarPaths : undefined,
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
catch (err) {
|
|
1801
|
+
commitError = err instanceof Error ? err.message : String(err);
|
|
1802
|
+
log.warn(`[import] batch commit failed for ${repo}: ${commitError}`);
|
|
1803
|
+
}
|
|
748
1804
|
}
|
|
749
1805
|
const created = results.filter(r => r.status === 'created').length;
|
|
750
1806
|
const entityCount = results.reduce((sum, r) => sum + (r.entities?.length ?? 0), 0);
|
|
1807
|
+
// Single repo_sync broadcast for the whole import. The realtime
|
|
1808
|
+
// store's repo_sync handler refreshes the repo's entity list once
|
|
1809
|
+
// — vs. one entity_change per file, which would do N re-renders
|
|
1810
|
+
// on every connected client and freeze the UI for large imports.
|
|
1811
|
+
// Skip when commit failed: there's nothing for clients to refresh
|
|
1812
|
+
// beyond stranded uncommitted files, and the broadcast would lie
|
|
1813
|
+
// about persistence.
|
|
1814
|
+
if (created > 0 && !commitError) {
|
|
1815
|
+
wsHub?.broadcast({
|
|
1816
|
+
type: 'repo_sync', repo,
|
|
1817
|
+
actor: req.user?.displayName ?? 'API',
|
|
1818
|
+
source: 'api', timestamp: new Date().toISOString(),
|
|
1819
|
+
});
|
|
1820
|
+
// Asset caption/extract enqueue is no longer wired here — it fires
|
|
1821
|
+
// from the single VectorService.onAssetPending chokepoint when the
|
|
1822
|
+
// asset's index state is seeded (covers every upload path uniformly).
|
|
1823
|
+
}
|
|
1824
|
+
const errors = results.filter(r => r.status === 'error').map(r => r.error);
|
|
1825
|
+
if (commitError) {
|
|
1826
|
+
// Files are on disk but uncommitted — partial failure. Surface as
|
|
1827
|
+
// 500 with details so the client doesn't show "imported N entries"
|
|
1828
|
+
// for entries that won't survive a server restart.
|
|
1829
|
+
return reply.status(500).send({
|
|
1830
|
+
success: false,
|
|
1831
|
+
error: `Import wrote ${pendingEntries.length} entries to disk but the git commit failed: ${commitError}`,
|
|
1832
|
+
commitError,
|
|
1833
|
+
uncommittedEntries: pendingEntries.length,
|
|
1834
|
+
imported: created,
|
|
1835
|
+
entityCount,
|
|
1836
|
+
skipped: errors.length,
|
|
1837
|
+
errors,
|
|
1838
|
+
results,
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
751
1841
|
return {
|
|
752
1842
|
success: true,
|
|
753
1843
|
imported: created,
|
|
754
1844
|
entityCount,
|
|
755
|
-
skipped:
|
|
756
|
-
errors
|
|
1845
|
+
skipped: errors.length,
|
|
1846
|
+
errors,
|
|
757
1847
|
results,
|
|
1848
|
+
// Cancel summary (CANCEL): when the client disconnected mid-import we
|
|
1849
|
+
// committed the completed prefix above and report N of M honestly.
|
|
1850
|
+
// The aborting client typically won't read this (its fetch rejected),
|
|
1851
|
+
// but it keeps the contract honest for any caller that does.
|
|
1852
|
+
cancelled,
|
|
1853
|
+
total: body.files.length,
|
|
758
1854
|
};
|
|
759
1855
|
}
|
|
760
1856
|
catch (err) {
|
|
@@ -767,9 +1863,10 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
767
1863
|
// POST /api/repos/:repo/import-bookmarks — import browser bookmark exports as reference entities
|
|
768
1864
|
fastify.post('/api/repos/:repo/import-bookmarks', async (req, reply) => {
|
|
769
1865
|
const { repo } = req.params;
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1866
|
+
// Access enforced by graphForWrite — bookmark import creates entities.
|
|
1867
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1868
|
+
if (!graph)
|
|
1869
|
+
return;
|
|
773
1870
|
const body = req.body;
|
|
774
1871
|
if (!body?.content || typeof body.content !== 'string') {
|
|
775
1872
|
return reply.status(400).send({ error: 'Expected { content: string }' });
|
|
@@ -781,7 +1878,6 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
781
1878
|
error: "This doesn't appear to be a browser bookmark export. Export bookmarks from your browser's bookmark manager to generate the correct format.",
|
|
782
1879
|
});
|
|
783
1880
|
}
|
|
784
|
-
const graph = workspaceManager.getGraph(repo);
|
|
785
1881
|
const user = reqGitUser(req);
|
|
786
1882
|
const result = await importBookmarks(graph, body.content, user);
|
|
787
1883
|
// Single git commit for all created entities
|
|
@@ -806,41 +1902,75 @@ export async function registerGraphApiRoutes(fastify, workspaceManager, authServ
|
|
|
806
1902
|
});
|
|
807
1903
|
}
|
|
808
1904
|
});
|
|
809
|
-
// POST /api/repos/:repo/
|
|
810
|
-
|
|
1905
|
+
// POST /api/repos/:repo/assets — generic asset upload.
|
|
1906
|
+
//
|
|
1907
|
+
// Multipart `file` field. Asset-as-embed (B2): stores a FREE-STANDING,
|
|
1908
|
+
// folder-owned asset (assets.db row + immutable-key blob) — NO wrapper
|
|
1909
|
+
// entity. Returns { assetId, asset_url, ... } for the caller to embed.
|
|
1910
|
+
// For an IMAGE, graph.uploadAsset fires the caption/OCR trigger (assetId-
|
|
1911
|
+
// keyed), and the caption folds into any entry that embeds the asset. See
|
|
1912
|
+
// docs/asset-architecture.md.
|
|
1913
|
+
fastify.post('/api/repos/:repo/assets', async (req, reply) => {
|
|
811
1914
|
const { repo } = req.params;
|
|
812
|
-
|
|
813
|
-
|
|
1915
|
+
// Access enforced by graphForWrite (WorkspaceAccessService).
|
|
1916
|
+
const graph = graphForWrite(req, reply, repo);
|
|
1917
|
+
if (!graph)
|
|
1918
|
+
return;
|
|
1919
|
+
// Per-route fileSize override — the global multipart limit (10MB) is
|
|
1920
|
+
// tuned for inline image embeds; full asset uploads accept up to the
|
|
1921
|
+
// 100MB cap enforced inside AssetService.upload().
|
|
1922
|
+
let file;
|
|
1923
|
+
try {
|
|
1924
|
+
file = await req.file({ limits: { fileSize: 100 * 1024 * 1024 } });
|
|
814
1925
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
return reply.status(400).send({ error: 'Expected { files: [{ filename, content }] }' });
|
|
1926
|
+
catch (err) {
|
|
1927
|
+
return reply.status(400).send({ error: err?.message ?? 'Invalid multipart upload' });
|
|
818
1928
|
}
|
|
1929
|
+
if (!file)
|
|
1930
|
+
return reply.status(400).send({ error: 'No file uploaded' });
|
|
1931
|
+
const buffer = await file.toBuffer();
|
|
819
1932
|
try {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1933
|
+
// Asset-as-embed (B2): uploading to the Assets store produces a
|
|
1934
|
+
// FREE-STANDING, folder-owned asset — NOT an `asset` entity. The asset
|
|
1935
|
+
// manager lists it directly; the homepage capture embeds its URL into
|
|
1936
|
+
// the entry it creates and deletes it by assetId if unused. Folder-owned
|
|
1937
|
+
// assets are repo-scoped, so the `folder` subpath doesn't apply here.
|
|
1938
|
+
const displayName = req.query.name?.trim()
|
|
1939
|
+
|| deriveAssetDisplayName(file.filename)
|
|
1940
|
+
|| file.filename;
|
|
1941
|
+
const stored = await graph.uploadAsset('material', // ignored for ownership when `owner` is set
|
|
1942
|
+
'asset', // ignored
|
|
1943
|
+
file.filename, buffer, undefined, {
|
|
1944
|
+
owner: { type: 'folder', id: repo },
|
|
1945
|
+
uploaderUserId: reqUser(req)?.id != null ? String(reqUser(req).id) : null,
|
|
1946
|
+
name: displayName,
|
|
1947
|
+
});
|
|
1948
|
+
wsHub?.broadcast({
|
|
1949
|
+
type: 'repo_sync',
|
|
1950
|
+
repo,
|
|
1951
|
+
actor: req.user?.displayName ?? 'API',
|
|
1952
|
+
source: 'api',
|
|
1953
|
+
timestamp: new Date().toISOString(),
|
|
1954
|
+
});
|
|
1955
|
+
// NOTE: image caption/OCR indexing is NOT derived for this
|
|
1956
|
+
// free-standing asset — the caption sweep + live drain key on `asset`
|
|
1957
|
+
// ENTITIES, which this no longer creates. It stays filename-only in
|
|
1958
|
+
// vector search until the caption pipeline is rewired to be
|
|
1959
|
+
// assets.db-row-keyed (tracked follow-up).
|
|
1960
|
+
return reply.send({
|
|
833
1961
|
success: true,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1962
|
+
assetId: stored.assetId,
|
|
1963
|
+
asset_url: stored.publicUrl,
|
|
1964
|
+
asset_kind: stored.mediaType,
|
|
1965
|
+
filename: stored.filename,
|
|
1966
|
+
});
|
|
838
1967
|
}
|
|
839
1968
|
catch (err) {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1969
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1970
|
+
const status = msg.includes('not configured') ? 501
|
|
1971
|
+
: msg.includes('Unsupported asset extension') ? 415
|
|
1972
|
+
: 400;
|
|
1973
|
+
return reply.status(status).send({ success: false, error: msg });
|
|
844
1974
|
}
|
|
845
1975
|
});
|
|
846
1976
|
}
|