@anchrd/intel 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { ARCHIVE_COLUMN_ID, BoardColumn } from "../../contract/board.js";
|
|
2
|
+
import { grantInForce, subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
3
|
+
function parseList(raw, of) {
|
|
4
|
+
// The column is `NOT NULL DEFAULT '[]'`, but it is JSON written by this repository and read back
|
|
5
|
+
// by it — a value that cannot be parsed is a defect, not a caller's mistake. It must still not
|
|
6
|
+
// take the whole board down: one unreadable row would make the board unopenable, and there would
|
|
7
|
+
// be no way in to repair it.
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
void of;
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function mapTask(row, boardId) {
|
|
18
|
+
return {
|
|
19
|
+
id: row.id,
|
|
20
|
+
title: row.title,
|
|
21
|
+
/**
|
|
22
|
+
* ⚠️ **An archived card reports the ARCHIVE column, not the one it came from** — that is what
|
|
23
|
+
* makes the column real instead of a label. `board_tasks.status` keeps naming the working
|
|
24
|
+
* column underneath, untouched, and that is what a card pulled back out returns to.
|
|
25
|
+
*
|
|
26
|
+
* A card archived over `node_archive`, without anybody dragging it, therefore appears here too.
|
|
27
|
+
* That is the test which tells this design apart from a second stored status (D68).
|
|
28
|
+
*/
|
|
29
|
+
status: row.archived_at === null ? row.status : ARCHIVE_COLUMN_ID,
|
|
30
|
+
assigneeId: row.assignee_id,
|
|
31
|
+
labels: parseList(row.labels_json, "labels").filter((entry) => typeof entry === "string"),
|
|
32
|
+
startDate: row.start_date,
|
|
33
|
+
dueDate: row.due_date,
|
|
34
|
+
dependsOn: row.depends_on,
|
|
35
|
+
// ⚠️ The board itself is not a parent TASK. A card filed directly on the board has
|
|
36
|
+
// `parent_id = boardId`, and handing that back as `parentTaskId` would make every top-level
|
|
37
|
+
// card claim to be a subtask of the board — a tree one level too deep, in every view.
|
|
38
|
+
parentTaskId: row.parent_id === boardId ? null : row.parent_id,
|
|
39
|
+
position: row.position,
|
|
40
|
+
archivedAt: row.archived_at,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function createBoardRepository(deps) {
|
|
44
|
+
/**
|
|
45
|
+
* ⚠️ **The VERB is a parameter, and every caller has to name it.** It was hard-wired to `"read"`
|
|
46
|
+
* once, and the consequence was not a missing check but the WRONG one: `columnsOf` and `taskRow`
|
|
47
|
+
* are the only gates the board service passes before it writes, so a board shared read-only could
|
|
48
|
+
* have its columns replaced and its cards moved. The refusal was there, it simply asked whether
|
|
49
|
+
* the actor may LOOK (ADR-0004 §2).
|
|
50
|
+
*/
|
|
51
|
+
const walkBindings = (actor, verb) => subtreeBindings(actor, verb, deps.now().toISOString());
|
|
52
|
+
return {
|
|
53
|
+
/**
|
|
54
|
+
* The whole board in ONE answer: the node, its columns, and every card the actor may see.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **`JOIN allowed` on the TASK, not only on the board.** Reaching the board is not the same
|
|
57
|
+
* question as reaching a card in it — a grant can sit on a single task, and one on the board
|
|
58
|
+
* does not make its tasks readable by a different rule than the tree uses. Asking once at the
|
|
59
|
+
* top would be a second authorization model beside `allowed`, and the two would drift.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ **Every filter is a WHERE, never a pass over the result.** A filter applied after reading
|
|
62
|
+
* has already carried the rows over the wire — which for `assigneeId` means titles of other
|
|
63
|
+
* people's cards in an answer that was supposed to exclude them.
|
|
64
|
+
*/
|
|
65
|
+
async read(actor, input) {
|
|
66
|
+
const board = await deps.db
|
|
67
|
+
.prepare(`${subtreeCte}
|
|
68
|
+
SELECT n.id, n.title, COALESCE(b.statuses_json, '[]') AS statuses_json,
|
|
69
|
+
COALESCE(b.archive_visible, 1) AS archive_visible
|
|
70
|
+
FROM nodes n
|
|
71
|
+
JOIN allowed ON allowed.id = n.id
|
|
72
|
+
LEFT JOIN boards b ON b.node_id = n.id
|
|
73
|
+
WHERE n.id = ? AND n.kind = 'board'`)
|
|
74
|
+
.bind(...walkBindings(actor, "read"), input.boardId)
|
|
75
|
+
.first();
|
|
76
|
+
if (board === null)
|
|
77
|
+
return null;
|
|
78
|
+
const conditions = ["t.board_id = ?"];
|
|
79
|
+
const bindings = [input.boardId];
|
|
80
|
+
if (input.status === ARCHIVE_COLUMN_ID) {
|
|
81
|
+
// ⚠️ The archive column is not a stored status — `board_tasks.status` keeps naming the
|
|
82
|
+
// working column underneath (D68). Matched against `t.status` the filter would answer
|
|
83
|
+
// "nothing here" on a board full of visibly archived cards, and the column would look
|
|
84
|
+
// broken rather than empty.
|
|
85
|
+
conditions.push("n.archived_at IS NOT NULL");
|
|
86
|
+
}
|
|
87
|
+
else if (input.status !== undefined) {
|
|
88
|
+
conditions.push("t.status = ?");
|
|
89
|
+
bindings.push(input.status);
|
|
90
|
+
}
|
|
91
|
+
if (input.assigneeId !== undefined) {
|
|
92
|
+
conditions.push("t.assignee_id = ?");
|
|
93
|
+
bindings.push(input.assigneeId);
|
|
94
|
+
}
|
|
95
|
+
if (input.dueBefore !== undefined) {
|
|
96
|
+
// ⚠️ `t.due_date IS NOT NULL` is not redundant beside `<`: in SQLite `NULL < 'x'` is NULL,
|
|
97
|
+
// which is not true, so the rows would be dropped anyway — but writing it out says that
|
|
98
|
+
// dropping them is the DECISION. A task with no due date is not "due before" anything.
|
|
99
|
+
conditions.push("t.due_date IS NOT NULL AND t.due_date < ?");
|
|
100
|
+
bindings.push(input.dueBefore);
|
|
101
|
+
}
|
|
102
|
+
if (input.dependsOn !== undefined) {
|
|
103
|
+
conditions.push("t.depends_on = ?");
|
|
104
|
+
bindings.push(input.dependsOn);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* ⚠️ **The archive column IS the archived cards** (D68, #674). While it is drawn they belong
|
|
108
|
+
* in the answer, or the column would always be empty and nobody could pull anything back out
|
|
109
|
+
* of it. `includeArchived` stays for the other direction: a caller — an agent, a report —
|
|
110
|
+
* that wants them even on a board where the column is hidden.
|
|
111
|
+
*/
|
|
112
|
+
const archiveVisible = board.archive_visible !== 0;
|
|
113
|
+
if (!input.includeArchived && !archiveVisible)
|
|
114
|
+
conditions.push("n.archived_at IS NULL");
|
|
115
|
+
const tasks = await deps.db
|
|
116
|
+
.prepare(`${subtreeCte}
|
|
117
|
+
SELECT n.id, n.title, n.parent_id, t.status, t.assignee_id, t.labels_json,
|
|
118
|
+
t.start_date, t.due_date, t.depends_on, t.position, n.archived_at
|
|
119
|
+
FROM board_tasks t
|
|
120
|
+
JOIN nodes n ON n.id = t.node_id
|
|
121
|
+
JOIN allowed ON allowed.id = n.id
|
|
122
|
+
WHERE ${conditions.join(" AND ")}
|
|
123
|
+
ORDER BY t.status, t.position, n.id`)
|
|
124
|
+
.bind(...walkBindings(actor, "read"), ...bindings)
|
|
125
|
+
.all();
|
|
126
|
+
// ⚠️ The CONFIGURED columns only. The archive column is appended by the service, after the
|
|
127
|
+
// fallback for an unconfigured board has been applied — appended here it would make every
|
|
128
|
+
// board look configured, and a board with no columns of its own would answer with the
|
|
129
|
+
// archive and nothing else.
|
|
130
|
+
const columns = parseList(board.statuses_json, "columns")
|
|
131
|
+
.map((entry) => BoardColumn.safeParse(entry))
|
|
132
|
+
.filter((parsed) => parsed.success)
|
|
133
|
+
.map((parsed) => parsed.data);
|
|
134
|
+
return {
|
|
135
|
+
boardId: board.id,
|
|
136
|
+
title: board.title,
|
|
137
|
+
archiveVisible,
|
|
138
|
+
columns,
|
|
139
|
+
tasks: (tasks.results ?? []).map((row) => mapTask(row, board.id)),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
async columnsOf(actor, boardId, verb) {
|
|
143
|
+
const row = await deps.db
|
|
144
|
+
.prepare(`${subtreeCte}
|
|
145
|
+
SELECT COALESCE(b.statuses_json, '[]') AS statuses_json
|
|
146
|
+
FROM nodes n
|
|
147
|
+
JOIN allowed ON allowed.id = n.id
|
|
148
|
+
LEFT JOIN boards b ON b.node_id = n.id
|
|
149
|
+
WHERE n.id = ? AND n.kind = 'board'`)
|
|
150
|
+
.bind(...walkBindings(actor, verb), boardId)
|
|
151
|
+
.first();
|
|
152
|
+
if (row === null)
|
|
153
|
+
return null;
|
|
154
|
+
return parseList(row.statuses_json, "columns")
|
|
155
|
+
.map((entry) => BoardColumn.safeParse(entry))
|
|
156
|
+
.filter((parsed) => parsed.success)
|
|
157
|
+
.map((parsed) => parsed.data.id);
|
|
158
|
+
},
|
|
159
|
+
async setColumns(boardId, columnsJson, occurredAt, archiveVisible) {
|
|
160
|
+
// ⚠️ `COALESCE(?, archive_visible)` rather than a second statement: an absent flag means
|
|
161
|
+
// "leave it", and writing a default here would un-hide an archive somebody put away every
|
|
162
|
+
// time the columns are reordered.
|
|
163
|
+
await deps.db
|
|
164
|
+
.prepare(`INSERT INTO boards (node_id, statuses_json, archive_visible, created_at, updated_at)
|
|
165
|
+
VALUES (?, ?, COALESCE(?, 1), ?, ?)
|
|
166
|
+
ON CONFLICT(node_id) DO UPDATE SET statuses_json = excluded.statuses_json,
|
|
167
|
+
archive_visible = COALESCE(?, boards.archive_visible),
|
|
168
|
+
updated_at = excluded.updated_at`)
|
|
169
|
+
.bind(boardId, columnsJson, archiveVisible === undefined ? null : Number(archiveVisible), occurredAt, occurredAt, archiveVisible === undefined ? null : Number(archiveVisible))
|
|
170
|
+
.run();
|
|
171
|
+
},
|
|
172
|
+
/**
|
|
173
|
+
* The row that makes a node a card.
|
|
174
|
+
*
|
|
175
|
+
* ⚠️ `INSERT … ON CONFLICT DO NOTHING`, because this runs on a path that may already have run:
|
|
176
|
+
* `nodes.create` under a board writes it, and so does `board_task_create`. Two rows are
|
|
177
|
+
* impossible (the primary key), and a second call must not overwrite a status somebody has
|
|
178
|
+
* since changed.
|
|
179
|
+
*/
|
|
180
|
+
async attach(write) {
|
|
181
|
+
await deps.db
|
|
182
|
+
.prepare(`INSERT INTO board_tasks (
|
|
183
|
+
node_id, board_id, status, assignee_id, labels_json,
|
|
184
|
+
start_date, due_date, depends_on, position, created_at, updated_at
|
|
185
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
186
|
+
ON CONFLICT(node_id) DO NOTHING`)
|
|
187
|
+
.bind(write.nodeId, write.boardId, write.status, write.assigneeId, JSON.stringify(write.labels), write.startDate, write.dueDate, write.dependsOn, write.position, write.occurredAt, write.occurredAt)
|
|
188
|
+
.run();
|
|
189
|
+
},
|
|
190
|
+
async taskRow(actor, taskId, verb) {
|
|
191
|
+
const row = await deps.db
|
|
192
|
+
.prepare(`${subtreeCte}
|
|
193
|
+
SELECT t.node_id, t.board_id, t.status, t.position, n.archived_at, n.updated_at
|
|
194
|
+
FROM board_tasks t
|
|
195
|
+
JOIN nodes n ON n.id = t.node_id
|
|
196
|
+
JOIN allowed ON allowed.id = n.id
|
|
197
|
+
WHERE t.node_id = ?`)
|
|
198
|
+
.bind(...walkBindings(actor, verb), taskId)
|
|
199
|
+
.first();
|
|
200
|
+
if (row === null)
|
|
201
|
+
return null;
|
|
202
|
+
return {
|
|
203
|
+
nodeId: row.node_id,
|
|
204
|
+
boardId: row.board_id,
|
|
205
|
+
status: row.status,
|
|
206
|
+
position: row.position,
|
|
207
|
+
archivedAt: row.archived_at,
|
|
208
|
+
updatedAt: row.updated_at,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
/**
|
|
212
|
+
* ⚠️ Only the named columns are written, and the statement is built from the fields that
|
|
213
|
+
* arrived. A full-row `UPDATE` would turn "move this card" into "write every field I happen to
|
|
214
|
+
* be holding" — and two people editing two different fields of one task would overwrite each
|
|
215
|
+
* other with values neither of them touched.
|
|
216
|
+
*/
|
|
217
|
+
async updateTask(taskId, fields, occurredAt) {
|
|
218
|
+
const sets = ["updated_at = ?"];
|
|
219
|
+
const bindings = [occurredAt];
|
|
220
|
+
for (const [column, value] of Object.entries(fields)) {
|
|
221
|
+
sets.push(`${column} = ?`);
|
|
222
|
+
bindings.push(value);
|
|
223
|
+
}
|
|
224
|
+
await deps.db
|
|
225
|
+
.prepare(`UPDATE board_tasks SET ${sets.join(", ")} WHERE node_id = ?`)
|
|
226
|
+
.bind(...bindings, taskId)
|
|
227
|
+
.run();
|
|
228
|
+
},
|
|
229
|
+
async nextPosition(boardId, status) {
|
|
230
|
+
const row = await deps.db
|
|
231
|
+
.prepare("SELECT MAX(position) AS top FROM board_tasks WHERE board_id = ? AND status = ?")
|
|
232
|
+
.bind(boardId, status)
|
|
233
|
+
.first();
|
|
234
|
+
// A whole number apart, so the first drop between two cards has room for a midpoint without
|
|
235
|
+
// needing fractions immediately.
|
|
236
|
+
return (row?.top ?? 0) + 1;
|
|
237
|
+
},
|
|
238
|
+
/**
|
|
239
|
+
* Who reaches this board once inheritance is resolved (#700, D70).
|
|
240
|
+
*
|
|
241
|
+
* ⚠️ ONE statement, hence one D1 snapshot, for the reason `listEffectiveAccess` gives at the
|
|
242
|
+
* node repository: two parallel SELECTs are not atomic, and a grant mutation between their
|
|
243
|
+
* snapshots produces a set of people that never existed as a whole.
|
|
244
|
+
*
|
|
245
|
+
* ⚠️ It answers the THREE things the caller needs, not a list of grants. A service that
|
|
246
|
+
* received grant rows here would be one careless `return` away from handing a board's
|
|
247
|
+
* membership to anyone who can read it; what leaves this method cannot be turned back into who
|
|
248
|
+
* granted what to whom.
|
|
249
|
+
*
|
|
250
|
+
* ⚠️ **A grant can name an EMAIL instead of a user id, and both are real access** (#757). The
|
|
251
|
+
* first version of this counted only `principal_type = 'user'` and therefore showed nobody on a
|
|
252
|
+
* board shared by address. Measured on the installation on 2026-08-23: four of nine grants were
|
|
253
|
+
* `email` ones.
|
|
254
|
+
*
|
|
255
|
+
* ⚠️ **Not lowercased here, and that is measured rather than assumed.** The write path already
|
|
256
|
+
* normalises an address twice (`nodes.ts` before the write, `db-grants.ts` at the boundary), and
|
|
257
|
+
* the installation confirms it: every stored `email` grant is lowercase. A `lower()` here would
|
|
258
|
+
* be defence against data that cannot exist, and no test could tell it from a no-op. The side
|
|
259
|
+
* that DOES need folding is the directory hit, because gate stores what somebody typed at
|
|
260
|
+
* signup and its casing is not this repository's to decide.
|
|
261
|
+
*
|
|
262
|
+
* ⚠️ `verb = 'read'`, and that is not a detail. The verbs here are granted INDEPENDENTLY
|
|
263
|
+
* (ADR-0004 §2): `write` does not follow from `read` and `read` does not follow from `write`.
|
|
264
|
+
* Counting any verb would offer somebody who holds only `write` or `share` as an assignee for a
|
|
265
|
+
* board they cannot open — the exact opposite of what D70 decided, arrived at by asking a
|
|
266
|
+
* slightly different question than `columnsOrRefuse` asks two lines earlier.
|
|
267
|
+
*
|
|
268
|
+
* ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` has to end the recursion rather than the
|
|
269
|
+
* database, the same reason every other walk of this tree gives.
|
|
270
|
+
*/
|
|
271
|
+
async effectiveAccess(boardId) {
|
|
272
|
+
const row = await deps.db
|
|
273
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
|
|
274
|
+
SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
|
|
275
|
+
UNION
|
|
276
|
+
SELECT parent.id, parent.parent_id, parent.owner_id
|
|
277
|
+
FROM nodes parent
|
|
278
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
279
|
+
)
|
|
280
|
+
SELECT
|
|
281
|
+
(SELECT json_group_array(owner_id)
|
|
282
|
+
FROM (SELECT DISTINCT owner_id FROM ancestors WHERE owner_id IS NOT NULL))
|
|
283
|
+
AS owner_ids_json,
|
|
284
|
+
(SELECT json_group_array(principal_id)
|
|
285
|
+
FROM (
|
|
286
|
+
SELECT DISTINCT grant_row.principal_id
|
|
287
|
+
FROM node_grants grant_row
|
|
288
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
289
|
+
WHERE grant_row.principal_type = 'user'
|
|
290
|
+
AND grant_row.principal_id IS NOT NULL
|
|
291
|
+
AND grant_row.verb = 'read'
|
|
292
|
+
AND ${grantInForce}
|
|
293
|
+
)) AS principal_ids_json,
|
|
294
|
+
(SELECT json_group_array(principal_id)
|
|
295
|
+
FROM (
|
|
296
|
+
SELECT DISTINCT grant_row.principal_id
|
|
297
|
+
FROM node_grants grant_row
|
|
298
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
299
|
+
WHERE grant_row.principal_type = 'email'
|
|
300
|
+
AND grant_row.principal_id IS NOT NULL
|
|
301
|
+
AND grant_row.verb = 'read'
|
|
302
|
+
AND ${grantInForce}
|
|
303
|
+
)) AS principal_emails_json,
|
|
304
|
+
(SELECT COUNT(*)
|
|
305
|
+
FROM node_grants grant_row
|
|
306
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
307
|
+
WHERE grant_row.principal_type = 'organization'
|
|
308
|
+
AND grant_row.verb = 'read'
|
|
309
|
+
AND ${grantInForce}) AS organization_grants`)
|
|
310
|
+
// ⚠️ ONE `now` per `grantInForce`, and there are three of them since #757: the user
|
|
311
|
+
// grants, the email grants and the organization count. A missing binding is not a wrong
|
|
312
|
+
// answer here, it is `D1_ERROR: Wrong number of parameter bindings` — loud, which is the
|
|
313
|
+
// good case. Read the count off the statement, never off memory.
|
|
314
|
+
.bind(boardId, deps.now().toISOString(), deps.now().toISOString(), deps.now().toISOString())
|
|
315
|
+
.first();
|
|
316
|
+
return {
|
|
317
|
+
ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
|
|
318
|
+
principalIds: JSON.parse(row?.principal_ids_json ?? "[]"),
|
|
319
|
+
principalEmails: JSON.parse(row?.principal_emails_json ?? "[]"),
|
|
320
|
+
organizationWide: (row?.organization_grants ?? 0) > 0,
|
|
321
|
+
};
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FeedRepository } from "../../feed/feed.types.js";
|
|
2
|
+
import type { D1Database } from "./db.types.js";
|
|
3
|
+
export declare const feedPageQuery: string;
|
|
4
|
+
/**
|
|
5
|
+
* The folders above each of a page's nodes, and only the ones the actor may open.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **The walk upwards is filtered too, and leaving that out would be a leak.** Grants inherit
|
|
8
|
+
* DOWNWARDS: a grant handed out on a deep node says nothing about the folders above it. Without the
|
|
9
|
+
* join against `allowed`, a breadcrumb would spell out the names of folders the reader may not
|
|
10
|
+
* open — on a surface whose entire premise is that it shows nothing one is not entitled to. A
|
|
11
|
+
* segment that fails the check drops out, and the trail is simply shorter.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ `UNION` and not `UNION ALL`, and no depth column. A cycle in `parent_id` can reach this table,
|
|
14
|
+
* and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
|
|
15
|
+
* and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
|
|
16
|
+
* `parent_id`, where a visited-set makes the cycle harmless.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
|
|
19
|
+
* its parent works only while the node exists, and the one event that most needs a trail is the one
|
|
20
|
+
* whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
|
|
21
|
+
* where the walk went anyway — and it is the only anchor a deletion has left.
|
|
22
|
+
*
|
|
23
|
+
* There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
|
|
24
|
+
* on the page, and each trail is rebuilt from its own `parentId` through that set.
|
|
25
|
+
*/
|
|
26
|
+
export declare const feedAncestorsQuery: (seeds: number) => string;
|
|
27
|
+
export declare function createFeedRepository(deps: {
|
|
28
|
+
db: D1Database;
|
|
29
|
+
now(): Date;
|
|
30
|
+
}): FeedRepository;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { FeedAction, feedResourceTypeOf, } from "../../contract/feed.js";
|
|
2
|
+
import { flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
3
|
+
// The three kinds of resource the feed reads, split out of the one action list so the SQL can name
|
|
4
|
+
// each side by itself. Derived from the contract rather than written again here: one list that has
|
|
5
|
+
// to agree with another is one list too many.
|
|
6
|
+
const NODE_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "node");
|
|
7
|
+
const FLOW_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "flow");
|
|
8
|
+
// ⚠️ The third kind (#767). These are cards ABOUT a flow — `feedKindOf` says `flow` — written as
|
|
9
|
+
// rows about a RUN, so they are found under their own `resource_type` and nowhere near the two
|
|
10
|
+
// lists above. Splitting on `feedResourceTypeOf` rather than on `feedKindOf` is what keeps them out
|
|
11
|
+
// of the flow branch, where `flow.id = e.resource_id` would never match a run id and every one of
|
|
12
|
+
// them would vanish without a word.
|
|
13
|
+
const RUN_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "flow-run");
|
|
14
|
+
const NODE_PLACEHOLDERS = NODE_ACTIONS.map(() => "?").join(", ");
|
|
15
|
+
const FLOW_PLACEHOLDERS = FLOW_ACTIONS.map(() => "?").join(", ");
|
|
16
|
+
const RUN_PLACEHOLDERS = RUN_ACTIONS.map(() => "?").join(", ");
|
|
17
|
+
function parseMetadata(raw) {
|
|
18
|
+
// The column is `NOT NULL DEFAULT '{}'` but it is written by eleven call sites over
|
|
19
|
+
// `JSON.stringify` on whatever each had at hand. A row that cannot be parsed must not take the
|
|
20
|
+
// page down with it: the reader would be stuck at that position with no way past. An unreadable
|
|
21
|
+
// payload becomes `{}` and the event still arrives — that something happened is the part a feed
|
|
22
|
+
// cannot afford to lose.
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* One page of the journal, newest first, cut down to the rows this actor may see.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ **The visibility check is the same JOIN against `allowed` that `audit_list` uses**, which is
|
|
38
|
+
* the same tree walk `nodes` reads through (`db-grants.ts`). An event about a node is visible
|
|
39
|
+
* exactly when the node is. A second, similar-looking query beside the first is the drift that
|
|
40
|
+
* `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **Two events have no row left to check, and that is why the join reads `COALESCE`** (#645 for
|
|
43
|
+
* `node.purge`, #763 for `flows.purge`).
|
|
44
|
+
* After `node.purge` the row is gone, so `JOIN nodes` dropped the event for everybody — creation,
|
|
45
|
+
* change and archiving arrived, final deletion never did. It now falls back to the `parentId` the
|
|
46
|
+
* purge wrote into its own metadata, and the check runs against that surviving FOLDER.
|
|
47
|
+
*
|
|
48
|
+
* The fallback is deliberately narrow: `COALESCE(n.id, …)` prefers the node whenever it exists, so
|
|
49
|
+
* for every other event nothing changes at all. It is not "or the parent is allowed" — that would
|
|
50
|
+
* hand out every event in a readable folder about nodes inside it that are NOT readable.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ **The title comes from the metadata for the same reason** — there is no row to read it from —
|
|
53
|
+
* and events written before #645 carry no `parentId`, so they stay unreachable. That cannot be
|
|
54
|
+
* repaired: at a deleted row there is nothing left to look up where it hung.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
|
|
57
|
+
* in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
|
|
58
|
+
* alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
|
|
59
|
+
* tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ **Both columns descend, and that is what lets an index carry this.** SQLite reads an index
|
|
62
|
+
* backwards only when every sort column is reversed together. Reversing one would cost a sort over
|
|
63
|
+
* the whole table, and that is invisible on the first pages and only hurts far down.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ **The index that carries it is `audit_events_time_idx`, NOT `audit_events_feed_idx`** (#763).
|
|
66
|
+
* The feed reads all three kinds at once (#767), and the older index leads with `resource_type`;
|
|
67
|
+
* under `IN ('node', 'flow', 'flow-run')` — or, as written here, under three branches joined by
|
|
68
|
+
* `OR` — that column stops being an equality prefix, and the ordering falls off it. Measured before
|
|
69
|
+
* `0026` existed:
|
|
70
|
+
* `SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)` followed by
|
|
71
|
+
* `USE TEMP B-TREE FOR ORDER BY`. The older index stays for `audit_list`, which names one kind.
|
|
72
|
+
*
|
|
73
|
+
* ⚠️ **The unary `+` in front of each `resource_type` is load-bearing, not a typo.** It tells SQLite
|
|
74
|
+
* the term may not drive an index. Without it the planner sees two indexable `OR` branches, picks
|
|
75
|
+
* `MULTI-INDEX OR` over the OLD index — one search per branch, both on `resource_type=?` — and buys
|
|
76
|
+
* the filtering back at the price of the ordering: `USE TEMP B-TREE FOR ORDER BY` returns, over the
|
|
77
|
+
* whole table. Measured both ways. Filtering on kind is cheap here; sorting the journal is not.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ **The three branches ask three DIFFERENT authorization questions, and none may stand in for
|
|
80
|
+
* another.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
|
|
81
|
+
* when it carries a grant of its own — flows hang in the tree but have nothing beneath them, so
|
|
82
|
+
* `flowInSubtree` is the rule and `allowed.id = flow.id` would answer about a node that does not
|
|
83
|
+
* exist. A RUN is visible when the flow it belongs to is, which takes one join more to reach
|
|
84
|
+
* (#767). The first two fall back on the metadata `parentId` for the one event whose row is gone;
|
|
85
|
+
* the third deliberately does not — see `RUN_PARENT`.
|
|
86
|
+
*/
|
|
87
|
+
const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.parentId'))";
|
|
88
|
+
// ⚠️ A card whose title is NULL names nothing, and `FeedEvent.resourceTitle` refuses it — the reader
|
|
89
|
+
// gets a validation error instead of a feed. It is a real state and not a theoretical one (#763,
|
|
90
|
+
// found against the running installation): an ADMIN passes `flowInSubtree` on its first branch
|
|
91
|
+
// alone, so events about a flow whose row is gone reach this far, and only `flows.purge` carries a
|
|
92
|
+
// title in its metadata. `flows.create` and `flows.archive` carry none, and there is nothing left
|
|
93
|
+
// to read one from.
|
|
94
|
+
//
|
|
95
|
+
// ⚠️ The node half cannot reach this state, and that asymmetry is the whole reason it was missed:
|
|
96
|
+
// there the check is `COALESCE(n.id, …) IN (SELECT id FROM allowed)`, a join against real rows with
|
|
97
|
+
// no admin short-circuit in front of it, so a deleted node's events drop out on their own.
|
|
98
|
+
const HAS_TITLE = "COALESCE(n.title, flow.title, run_flow.title, json_extract(e.metadata_json, '$.title')) IS NOT NULL";
|
|
99
|
+
const NODE_PARENT = "COALESCE(n.id, json_extract(e.metadata_json, '$.parentId'))";
|
|
100
|
+
/**
|
|
101
|
+
* The third walk (#767): the run's flow, and that flow's folder.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ There is **no metadata fallback here**, and that is the decision rather than an omission. The
|
|
104
|
+
* other two kinds fall back on a `parentId` the purge wrote, because a purged node or flow still
|
|
105
|
+
* has events worth reading. A run has no purge of its own — it disappears when its flow is purged,
|
|
106
|
+
* and `flows.purge` already carries that news as its own card. An event whose run row is gone
|
|
107
|
+
* therefore drops out on `HAS_TITLE`, which is the safe direction: the alternative is a card naming
|
|
108
|
+
* a flow nobody can check the reader was allowed to see.
|
|
109
|
+
*/
|
|
110
|
+
const RUN_PARENT = "run_flow.parent_id";
|
|
111
|
+
/**
|
|
112
|
+
* What a run card POINTS at: the flow, never the run (#767).
|
|
113
|
+
*
|
|
114
|
+
* Everything else about such a card already names the flow — its title, its path — and the surface
|
|
115
|
+
* turns `resourceId` into a link through `feedKindOf`, which answers `flow` for these two actions.
|
|
116
|
+
* Left as the run id it would navigate to `/flows?select=<run id>`, where nothing matches: an empty
|
|
117
|
+
* pane with no error, which `feed.tsx` names the worst of the three possible outcomes. There is no
|
|
118
|
+
* route onto a single run, so the flow is the honest target rather than a second-best one.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ `COALESCE` and not a `CASE` on the resource type: `run` is only joined for `flow-run` rows, so
|
|
121
|
+
* it is NULL everywhere else and every other card keeps its own id untouched.
|
|
122
|
+
*/
|
|
123
|
+
const RUN_TARGET = "COALESCE(run.flow_id, e.resource_id)";
|
|
124
|
+
export const feedPageQuery = `${subtreeCte}
|
|
125
|
+
SELECT e.id, e.actor_id, e.action,
|
|
126
|
+
${RUN_TARGET} AS resource_id,
|
|
127
|
+
COALESCE(n.title, flow.title, run_flow.title,
|
|
128
|
+
json_extract(e.metadata_json, '$.title')) AS resource_title,
|
|
129
|
+
COALESCE(n.parent_id, flow.parent_id, run_flow.parent_id,
|
|
130
|
+
json_extract(e.metadata_json, '$.parentId'))
|
|
131
|
+
AS resource_parent_id,
|
|
132
|
+
e.metadata_json, e.occurred_at
|
|
133
|
+
FROM audit_events e
|
|
134
|
+
LEFT JOIN nodes n ON n.id = e.resource_id AND e.resource_type = 'node'
|
|
135
|
+
LEFT JOIN flows flow ON flow.id = e.resource_id AND e.resource_type = 'flow'
|
|
136
|
+
LEFT JOIN flow_runs run ON run.id = e.resource_id AND e.resource_type = 'flow-run'
|
|
137
|
+
LEFT JOIN flows run_flow ON run_flow.id = run.flow_id
|
|
138
|
+
WHERE (
|
|
139
|
+
(
|
|
140
|
+
+e.resource_type = 'node'
|
|
141
|
+
AND e.action IN (${NODE_PLACEHOLDERS})
|
|
142
|
+
AND ${NODE_PARENT} IN (SELECT id FROM allowed)
|
|
143
|
+
)
|
|
144
|
+
OR (
|
|
145
|
+
+e.resource_type = 'flow'
|
|
146
|
+
AND e.action IN (${FLOW_PLACEHOLDERS})
|
|
147
|
+
AND ${flowInSubtreeOver(FLOW_PARENT)}
|
|
148
|
+
)
|
|
149
|
+
OR (
|
|
150
|
+
+e.resource_type = 'flow-run'
|
|
151
|
+
AND e.action IN (${RUN_PLACEHOLDERS})
|
|
152
|
+
AND ${flowInSubtreeOver(RUN_PARENT, "run_flow")}
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
AND ${HAS_TITLE}
|
|
156
|
+
AND (? IS NULL OR e.actor_id = ?)
|
|
157
|
+
AND (
|
|
158
|
+
? IS NULL
|
|
159
|
+
OR e.occurred_at < ?
|
|
160
|
+
OR (e.occurred_at = ? AND e.id < ?)
|
|
161
|
+
)
|
|
162
|
+
ORDER BY e.occurred_at DESC, e.id DESC
|
|
163
|
+
LIMIT ?`;
|
|
164
|
+
/**
|
|
165
|
+
* The folders above each of a page's nodes, and only the ones the actor may open.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ **The walk upwards is filtered too, and leaving that out would be a leak.** Grants inherit
|
|
168
|
+
* DOWNWARDS: a grant handed out on a deep node says nothing about the folders above it. Without the
|
|
169
|
+
* join against `allowed`, a breadcrumb would spell out the names of folders the reader may not
|
|
170
|
+
* open — on a surface whose entire premise is that it shows nothing one is not entitled to. A
|
|
171
|
+
* segment that fails the check drops out, and the trail is simply shorter.
|
|
172
|
+
*
|
|
173
|
+
* ⚠️ `UNION` and not `UNION ALL`, and no depth column. A cycle in `parent_id` can reach this table,
|
|
174
|
+
* and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
|
|
175
|
+
* and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
|
|
176
|
+
* `parent_id`, where a visited-set makes the cycle harmless.
|
|
177
|
+
*
|
|
178
|
+
* ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
|
|
179
|
+
* its parent works only while the node exists, and the one event that most needs a trail is the one
|
|
180
|
+
* whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
|
|
181
|
+
* where the walk went anyway — and it is the only anchor a deletion has left.
|
|
182
|
+
*
|
|
183
|
+
* There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
|
|
184
|
+
* on the page, and each trail is rebuilt from its own `parentId` through that set.
|
|
185
|
+
*/
|
|
186
|
+
export const feedAncestorsQuery = (seeds) => `${subtreeCte},
|
|
187
|
+
ancestry(id, parent_id, title) AS (
|
|
188
|
+
SELECT parent.id, parent.parent_id, parent.title
|
|
189
|
+
FROM nodes parent
|
|
190
|
+
WHERE parent.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
|
|
191
|
+
UNION
|
|
192
|
+
SELECT grandparent.id, grandparent.parent_id, grandparent.title
|
|
193
|
+
FROM nodes grandparent
|
|
194
|
+
JOIN ancestry a ON a.parent_id = grandparent.id
|
|
195
|
+
)
|
|
196
|
+
SELECT a.id, a.parent_id, a.title
|
|
197
|
+
FROM ancestry a
|
|
198
|
+
JOIN allowed ON allowed.id = a.id`;
|
|
199
|
+
/**
|
|
200
|
+
* The chain from the root down to (but not including) the node itself.
|
|
201
|
+
*
|
|
202
|
+
* Built here rather than in SQL because ordering it there would need a depth counter, and a depth
|
|
203
|
+
* counter is what turns a cycle into a hang. `seen` is the same guard on this side.
|
|
204
|
+
*
|
|
205
|
+
* ⚠️ It starts at the node's OWN `parent_id` and not at "some row with this seed": every ancestor
|
|
206
|
+
* of a node carries the same seed, and the order rows come back in is not promised. Picking the
|
|
207
|
+
* first one would put a random grandparent at the near end of the trail.
|
|
208
|
+
*
|
|
209
|
+
* ⚠️ The chain stops where visibility stops. If the actor may read a node but not the folder above
|
|
210
|
+
* it — grants inherit downwards, so a deep grant says nothing about the folders over it — that
|
|
211
|
+
* folder is absent from `rows`, the walk ends, and the trail is short. Short is the honest answer;
|
|
212
|
+
* skipping the gap and carrying on with the grandparent would spell out a place the reader cannot
|
|
213
|
+
* reach.
|
|
214
|
+
*/
|
|
215
|
+
function trailFor(parentId, rows) {
|
|
216
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
217
|
+
const trail = [];
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
let current = parentId === null ? undefined : byId.get(parentId);
|
|
220
|
+
while (current !== undefined && !seen.has(current.id)) {
|
|
221
|
+
seen.add(current.id);
|
|
222
|
+
trail.push({ id: current.id, title: current.title });
|
|
223
|
+
current = current.parent_id === null ? undefined : byId.get(current.parent_id);
|
|
224
|
+
}
|
|
225
|
+
return trail.reverse();
|
|
226
|
+
}
|
|
227
|
+
export function createFeedRepository(deps) {
|
|
228
|
+
return {
|
|
229
|
+
async listEvents(actor, query) {
|
|
230
|
+
const before = query.before ?? null;
|
|
231
|
+
// One reading of the clock for the whole call. Both guards measure grant expiry against it,
|
|
232
|
+
// and two `now()` calls could straddle the moment a grant runs out — a page whose flow half
|
|
233
|
+
// answers about a permission its node half no longer has.
|
|
234
|
+
const now = deps.now().toISOString();
|
|
235
|
+
const page = await deps.db
|
|
236
|
+
.prepare(feedPageQuery)
|
|
237
|
+
.bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now), ...RUN_ACTIONS,
|
|
238
|
+
// ⚠️ A SECOND set of bindings for the same guard: `flowInSubtreeOver` writes its
|
|
239
|
+
// placeholders out again for the run branch, and positional binding counts every one of
|
|
240
|
+
// them. Reusing the first set would shift every parameter after it by the width of the
|
|
241
|
+
// guard — which is not a type error and not a syntax error, only wrong answers.
|
|
242
|
+
...flowInSubtreeBindings(actor, "read", now), query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
|
|
243
|
+
.all();
|
|
244
|
+
const rows = page.results ?? [];
|
|
245
|
+
if (rows.length === 0)
|
|
246
|
+
return { events: [] };
|
|
247
|
+
// One walk for the whole page rather than one per row. The set is at most the page size, and
|
|
248
|
+
// it is deduplicated because a busy morning on one document is exactly the case the feed
|
|
249
|
+
// shows unsummarised today. The trail is keyed by EVENT rather than by node: two events about
|
|
250
|
+
// one node share a trail, but a deleted node and a live one can carry the same parent.
|
|
251
|
+
const parentIds = [
|
|
252
|
+
...new Set(rows.map((row) => row.resource_parent_id).filter((id) => id !== null)),
|
|
253
|
+
];
|
|
254
|
+
const ancestorRows = parentIds.length === 0
|
|
255
|
+
? []
|
|
256
|
+
: ((await deps.db
|
|
257
|
+
.prepare(feedAncestorsQuery(parentIds.length))
|
|
258
|
+
.bind(...subtreeBindings(actor, "read", now), ...parentIds)
|
|
259
|
+
.all()).results ?? []);
|
|
260
|
+
const trails = new Map(rows.map((row) => [row.id, trailFor(row.resource_parent_id, ancestorRows)]));
|
|
261
|
+
const events = rows.map((row) => ({
|
|
262
|
+
id: row.id,
|
|
263
|
+
actorId: row.actor_id,
|
|
264
|
+
// Narrowed at the door by the SQL `IN`, so the cast names what the query already
|
|
265
|
+
// guarantees rather than trusting the column.
|
|
266
|
+
action: row.action,
|
|
267
|
+
resourceId: row.resource_id,
|
|
268
|
+
resourceTitle: row.resource_title,
|
|
269
|
+
path: trails.get(row.id) ?? [],
|
|
270
|
+
metadata: parseMetadata(row.metadata_json),
|
|
271
|
+
occurredAt: row.occurred_at,
|
|
272
|
+
}));
|
|
273
|
+
return { events };
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|