@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,447 @@
|
|
|
1
|
+
import { ARCHIVE_COLUMN_ID, DEFAULT_COLUMN_COLOR } from "../contract/board.js";
|
|
2
|
+
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a board with no configuration answers with. A board is usable the moment it is created, and
|
|
5
|
+
* nothing asks whoever made it to define columns before filing the first card.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **This is the FALLBACK, not what a board made through the application gets.** Columns are
|
|
8
|
+
* shared data: two people on one board must read the same names, and the settings dialog edits one
|
|
9
|
+
* list. So the application writes them at creation, in the language of whoever created it (#675),
|
|
10
|
+
* and from then on they are data anybody can rename. This list only answers for a board nobody
|
|
11
|
+
* configured — one made over MCP, or one whose seeding did not land.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* ⚠️ **The four start coloured, and the colours mean something** (Jack's decision 2026-08-22): grey
|
|
15
|
+
* for what is only lying there, yellow for what is ready, orange for what is being worked on, green
|
|
16
|
+
* for what is done. That order is the one people already read without a legend, which is the whole
|
|
17
|
+
* reason a default is worth having — a board that starts grey teaches nothing, and one that starts
|
|
18
|
+
* in arbitrary colours teaches something false.
|
|
19
|
+
*
|
|
20
|
+
* They are a suggestion, not a setting: every one of them can be overwritten, and a board that has
|
|
21
|
+
* been recoloured keeps its own values.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_COLUMNS = [
|
|
24
|
+
{ id: "backlog", title: "Backlog", terminal: false, color: DEFAULT_COLUMN_COLOR },
|
|
25
|
+
{ id: "todo", title: "Offen", terminal: false, color: "#eab308" },
|
|
26
|
+
{ id: "doing", title: "Läuft", terminal: false, color: "#f97316" },
|
|
27
|
+
{ id: "done", title: "Fertig", terminal: true, color: "#22c55e" },
|
|
28
|
+
];
|
|
29
|
+
// The column a card lands in when nobody said and the board has none configured. One export rather
|
|
30
|
+
// than the literal `"todo"` in three places: the fallback exists FOR the unconfigured board, so a
|
|
31
|
+
// copy of it would drift exactly where it is the only thing deciding.
|
|
32
|
+
export const FIRST_DEFAULT_COLUMN = DEFAULT_COLUMNS[0].id;
|
|
33
|
+
// Below this a query answers empty rather than with everybody. A single letter is not a search, it
|
|
34
|
+
// is a listing under a different name — the same floor gate holds at its own door, stated here so
|
|
35
|
+
// this surface keeps its own promise (#700).
|
|
36
|
+
const MINIMUM_QUERY = 2;
|
|
37
|
+
// How many people a picker offers before anybody types. Three is a suggestion, not a directory: a
|
|
38
|
+
// longer list is one somebody reads instead of typing the name they already know (#757).
|
|
39
|
+
const SUGGESTIONS = 3;
|
|
40
|
+
/**
|
|
41
|
+
* Whether one person from the directory reaches this board (#700, #757).
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ **Two ways in, and the second one cost a release to notice.** A grant names either a user id or
|
|
44
|
+
* an EMAIL, and both are real access. Counting only ids showed nobody on a board shared by address,
|
|
45
|
+
* which is how four of the nine grants on the reference installation are written.
|
|
46
|
+
*
|
|
47
|
+
* The address is compared lowercased on both sides: a grant written `Anton@…` and a directory hit
|
|
48
|
+
* that says `anton@…` are the same person, and a case-sensitive compare would quietly disagree.
|
|
49
|
+
*/
|
|
50
|
+
function reachedBy(access, person) {
|
|
51
|
+
if (access.ownerIds.includes(person.id))
|
|
52
|
+
return true;
|
|
53
|
+
if (access.principalIds.includes(person.id))
|
|
54
|
+
return true;
|
|
55
|
+
return access.principalEmails.includes(person.email.toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
export function createBoards(deps) {
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ The one place "which board, and may this actor DO THIS to it" is answered — and the verb is
|
|
60
|
+
* the whole question. It asked `read` for every caller once, including the three that were about
|
|
61
|
+
* to write: a board shared read-only could have its columns replaced and its cards moved. The
|
|
62
|
+
* refusal existed and asked the wrong thing, which is the harder failure to see.
|
|
63
|
+
*
|
|
64
|
+
* A `404` rather than a `403`, on purpose: "this board exists but you may not touch it" is itself
|
|
65
|
+
* information about somebody else's tree.
|
|
66
|
+
*/
|
|
67
|
+
const columnsOrRefuse = async (actor, boardId, verb) => {
|
|
68
|
+
const columns = await deps.boards.columnsOf(actor, boardId, verb);
|
|
69
|
+
if (columns === null) {
|
|
70
|
+
throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
|
|
71
|
+
}
|
|
72
|
+
return columns.length > 0 ? columns : DEFAULT_COLUMNS.map((column) => column.id);
|
|
73
|
+
};
|
|
74
|
+
const viewOrRefuse = async (actor, input) => {
|
|
75
|
+
const view = await deps.boards.read(actor, input);
|
|
76
|
+
if (view === null) {
|
|
77
|
+
throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
|
|
78
|
+
}
|
|
79
|
+
// A board that was never configured answers with the default columns rather than none. An empty
|
|
80
|
+
// column list would render as a board with no columns at all — a screen with nowhere to put a
|
|
81
|
+
// card, which looks broken rather than new.
|
|
82
|
+
/**
|
|
83
|
+
* ⚠️ **A stored column may not claim the archive id, and the migration is not the only guard.**
|
|
84
|
+
* `0024` renames the ones that existed before the id was reserved, and `update` refuses new
|
|
85
|
+
* ones — but a row written straight into D1, or a migration that did not reach an installation,
|
|
86
|
+
* would still put two columns with one id in front of a reader. Dropping it here means the
|
|
87
|
+
* derived one always wins, which is the answer that cannot produce two.
|
|
88
|
+
*/
|
|
89
|
+
const configured = (view.columns.length > 0 ? view.columns : [...DEFAULT_COLUMNS]).filter((column) => column.id !== ARCHIVE_COLUMN_ID);
|
|
90
|
+
/**
|
|
91
|
+
* ⚠️ **The archive column is appended here, after the fallback, and it is never stored.** A
|
|
92
|
+
* card is in it because `nodes.archived_at` is set; `board_tasks.status` keeps naming the
|
|
93
|
+
* working column underneath, which is what a card pulled back out returns to (D68).
|
|
94
|
+
*
|
|
95
|
+
* Always last, and not part of the list `board_update` writes: the archive is the same place on
|
|
96
|
+
* every board. A shelf that could be dragged between the working columns would be a shelf
|
|
97
|
+
* pretending to be a stage.
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ **The title here is a fallback, not the label a reader sees.** Configured columns are
|
|
100
|
+
* shared data and are therefore written once in the creator's language (#675); this one is
|
|
101
|
+
* derived and identical on every board, so the browser says it in the reader's own words
|
|
102
|
+
* (`useBoard`). What travels is English, for everything without a catalog — MCP, an export, a
|
|
103
|
+
* report.
|
|
104
|
+
*/
|
|
105
|
+
return {
|
|
106
|
+
...view,
|
|
107
|
+
columns: view.archiveVisible
|
|
108
|
+
? [...configured, { id: ARCHIVE_COLUMN_ID, title: "Archive", terminal: true }]
|
|
109
|
+
: configured,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const requireColumn = (columns, status) => {
|
|
113
|
+
if (columns.includes(status))
|
|
114
|
+
return;
|
|
115
|
+
throw new IntelError(400, "unknown_column", `This board has no column \`${status}\`. Its columns are: ${columns.join(", ")}.`);
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* The people this board can be handed to, without a search term (#757).
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ **Named, not enumerated.** Gate's directory answers searches, not listings, so what can be
|
|
121
|
+
* offered here is exactly the set this repository already knows by name: the owners, the user
|
|
122
|
+
* grants, and the addresses of the email grants. An organization-wide board therefore suggests
|
|
123
|
+
* whatever explicit grants it also has and nothing more — "everybody" is not a list anyone can
|
|
124
|
+
* produce, and pretending otherwise would mean asking gate to hand over its directory.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ **The cap is visible.** `more` says how many are left over, so the picker can say "and 4
|
|
127
|
+
* more" instead of quietly showing three and looking complete.
|
|
128
|
+
*/
|
|
129
|
+
const suggest = async (boardId) => {
|
|
130
|
+
if (!deps.directory)
|
|
131
|
+
return { items: [], more: 0 };
|
|
132
|
+
const access = await deps.boards.effectiveAccess(boardId);
|
|
133
|
+
const ids = [...new Set([...access.ownerIds, ...access.principalIds])];
|
|
134
|
+
// One call for every id at once. The addresses need one lookup each, because gate resolves ids
|
|
135
|
+
// and searches text and there is no third door — so only as many as it takes to FILL the cap.
|
|
136
|
+
const byId = ids.length === 0 ? [] : await deps.directory.resolve(ids);
|
|
137
|
+
const knownEmails = new Set(byId.map((person) => person.email.toLowerCase()));
|
|
138
|
+
/**
|
|
139
|
+
* ⚠️ **The count and the list are two different things, and the count comes FIRST.**
|
|
140
|
+
*
|
|
141
|
+
* An earlier version stopped looking after the cap and then reported the leftovers it happened
|
|
142
|
+
* to have — so a board with ten address grants said "and 1 more" instead of "and 7 more". The
|
|
143
|
+
* cap was visible and its number was wrong, which is worse than no number: it is a number
|
|
144
|
+
* somebody believes.
|
|
145
|
+
*
|
|
146
|
+
* Counted here without naming anybody: every address that is not already one of the resolved
|
|
147
|
+
* people is one more person who reaches this board. Whether gate can name them does not change
|
|
148
|
+
* how many there are.
|
|
149
|
+
*/
|
|
150
|
+
const unnamedAddresses = access.principalEmails.filter((address) => !knownEmails.has(address));
|
|
151
|
+
const total = byId.length + unnamedAddresses.length;
|
|
152
|
+
/**
|
|
153
|
+
* ⚠️ **The cap counts LOOKUPS, not names found**, and the difference is a real hole. An address
|
|
154
|
+
* gate cannot name never grows `named`, so a bound on the names would never trigger: a board
|
|
155
|
+
* with a hundred address grants and no matching accounts would fire a hundred sequential calls
|
|
156
|
+
* to gate to open one menu. The test that sets six unnameable addresses walks exactly that path.
|
|
157
|
+
*/
|
|
158
|
+
const named = [...byId];
|
|
159
|
+
let lookups = 0;
|
|
160
|
+
for (const address of unnamedAddresses) {
|
|
161
|
+
if (named.length >= SUGGESTIONS || lookups >= SUGGESTIONS)
|
|
162
|
+
break;
|
|
163
|
+
lookups += 1;
|
|
164
|
+
const found = await deps.directory.search(address);
|
|
165
|
+
const exact = found.find((person) => person.email.toLowerCase() === address);
|
|
166
|
+
if (exact !== undefined)
|
|
167
|
+
named.push(exact);
|
|
168
|
+
}
|
|
169
|
+
const items = named.slice(0, SUGGESTIONS);
|
|
170
|
+
return { items, more: Math.max(0, total - items.length) };
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
async get(actor, input) {
|
|
174
|
+
return await viewOrRefuse(actor, input);
|
|
175
|
+
},
|
|
176
|
+
async update(actor, input) {
|
|
177
|
+
await columnsOrRefuse(actor, input.boardId, "write");
|
|
178
|
+
// ⚠️ Duplicate ids are refused rather than deduplicated. Two columns with one id means every
|
|
179
|
+
// card in them lands in whichever the view draws first, and silently dropping one would
|
|
180
|
+
// discard a column somebody just wrote.
|
|
181
|
+
const ids = input.columns.map((column) => column.id);
|
|
182
|
+
const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
|
|
183
|
+
if (duplicate !== undefined) {
|
|
184
|
+
throw new IntelError(400, "duplicate_column", `The column id \`${duplicate}\` appears more than once.`);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* ⚠️ **The archive id cannot be claimed by a configured column.** It is derived — a card is in
|
|
188
|
+
* it because `archived_at` is set — so a stored column of the same id would give a board two
|
|
189
|
+
* columns with one id: every card in them lands in whichever the view draws first, and one of
|
|
190
|
+
* the two can never receive a card at all.
|
|
191
|
+
*/
|
|
192
|
+
/**
|
|
193
|
+
* ⚠️ **Duplicate NAMES are refused here, not only in the dialog.** An id nobody sees may
|
|
194
|
+
* repeat without confusing anybody; a name is what a person reads, and two columns called the
|
|
195
|
+
* same thing cannot be told apart on the board, in the filter, or in the settings. Checked in
|
|
196
|
+
* the service because an agent calling `board_update` reaches no dialog — root `CLAUDE.md`:
|
|
197
|
+
* validation does not vary by surface.
|
|
198
|
+
*/
|
|
199
|
+
const titles = input.columns.map((column) => column.title.trim());
|
|
200
|
+
const duplicateTitle = titles.find((title, index) => titles.indexOf(title) !== index);
|
|
201
|
+
if (duplicateTitle !== undefined) {
|
|
202
|
+
throw new IntelError(400, "duplicate_column_name", `Two columns are called \`${duplicateTitle}\`. A name is what tells them apart.`);
|
|
203
|
+
}
|
|
204
|
+
if (ids.includes(ARCHIVE_COLUMN_ID)) {
|
|
205
|
+
throw new IntelError(400, "reserved_column", `\`${ARCHIVE_COLUMN_ID}\` is the archive column, which every board has. Show or hide it instead of configuring it.`);
|
|
206
|
+
}
|
|
207
|
+
await deps.boards.setColumns(input.boardId, JSON.stringify(input.columns), deps.now().toISOString(), input.archiveVisible);
|
|
208
|
+
return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
|
|
209
|
+
},
|
|
210
|
+
async createTask(actor, input) {
|
|
211
|
+
const columns = await columnsOrRefuse(actor, input.boardId, "write");
|
|
212
|
+
// Absent means the first column — a caller who does not care where a task starts should not
|
|
213
|
+
// have to read the board first. A named one is checked, because a typo would otherwise create
|
|
214
|
+
// a card in a column no view draws.
|
|
215
|
+
const status = input.status ?? columns[0] ?? DEFAULT_COLUMNS[0].id;
|
|
216
|
+
if (input.status !== undefined)
|
|
217
|
+
requireColumn(columns, status);
|
|
218
|
+
/**
|
|
219
|
+
* ⚠️ **The parent's board must BE the board that was asked for**, checked here rather than
|
|
220
|
+
* left to the node path. `nodes.create` resolves the destination board from the parent, and
|
|
221
|
+
* on creation there is no previous board to compare it with — so a call naming board B with a
|
|
222
|
+
* parent on board A files the card on **A**, answers with the view of **B**, and the caller
|
|
223
|
+
* gets neither an error nor their card. The position would have been counted against B too.
|
|
224
|
+
*/
|
|
225
|
+
if (input.parentTaskId !== null) {
|
|
226
|
+
// ⚠️ `"write"` for the same reason `attachToBoard` uses it — filing a card here is a write
|
|
227
|
+
// to that board. It is defence in depth rather than the only guard: `nodes.create` checks
|
|
228
|
+
// `write` on the parent node itself and answers 403 first, which is why a behaviour test
|
|
229
|
+
// cannot tell `"read"` from `"write"` here. Named so the next reader does not go looking
|
|
230
|
+
// for the test that proves it.
|
|
231
|
+
const parentRow = await deps.boards.taskRow(actor, input.parentTaskId, "write");
|
|
232
|
+
/**
|
|
233
|
+
* ⚠️ **Only the wrong BOARD is refused here.** `taskRow` answers `null` for three different
|
|
234
|
+
* things — the node does not exist, it is not a task, or this actor may not write it — and
|
|
235
|
+
* each of those already has a precise answer further in: `404 node_not_found` from
|
|
236
|
+
* `requireVisible`, `task_belongs_to_a_board` from `refuseWrongParent`, `403` from the
|
|
237
|
+
* write check. Catching them here would answer "that parent card is not on this board" for
|
|
238
|
+
* a card that does not exist, which claims it does.
|
|
239
|
+
*/
|
|
240
|
+
if (parentRow !== null && parentRow.boardId !== input.boardId) {
|
|
241
|
+
throw new IntelError(409, "task_cannot_change_board", "That parent card is not on this board. A subtask belongs to the same board as the card it hangs under.");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// ⚠️ Through the node service, not a second insert. The task gets the same authorization
|
|
245
|
+
// check, the same audit row and the same version chain every other node gets — and that audit
|
|
246
|
+
// row is what `anchrd/signals` reads (#620).
|
|
247
|
+
const node = await deps.nodes.create(actor, {
|
|
248
|
+
// ⚠️ A subtask's parent is the TASK, not the board — the hierarchy is the node tree (#669).
|
|
249
|
+
// The node service resolves which board that lands on and files the row there, so nothing
|
|
250
|
+
// here has to know: naming the board as parent would flatten every subtask on creation.
|
|
251
|
+
parentId: input.parentTaskId ?? input.boardId,
|
|
252
|
+
kind: "task",
|
|
253
|
+
title: input.title,
|
|
254
|
+
description: null,
|
|
255
|
+
idempotencyKey: input.idempotencyKey,
|
|
256
|
+
});
|
|
257
|
+
/**
|
|
258
|
+
* ⚠️ **`update`, not `attach` — the row already exists by the time this line runs**, and
|
|
259
|
+
* getting that wrong is silent. `nodes.create` files every new task through `attachToBoard`,
|
|
260
|
+
* with the board's first column and no fields; a second `INSERT` here is refused by the
|
|
261
|
+
* primary key, `ON CONFLICT DO NOTHING` swallows the refusal, and the caller's `status`,
|
|
262
|
+
* assignee and dates are **discarded without an error**. The card lands in the wrong column
|
|
263
|
+
* and nothing anywhere says why.
|
|
264
|
+
*
|
|
265
|
+
* It was written as an insert first, and three tests caught it: a card created in `doing`
|
|
266
|
+
* came back in `todo`. The lesson is the shape rather than the line — one row, one writer,
|
|
267
|
+
* and the writer is the node path.
|
|
268
|
+
*/
|
|
269
|
+
/**
|
|
270
|
+
* ⚠️ **The position is computed only when the card is not already in that column**, and that
|
|
271
|
+
* is what makes a retry safe. `deps.nodes.create` honours the idempotency key and answers with
|
|
272
|
+
* the SAME node on a replay; recomputing here would push the card one place further on every
|
|
273
|
+
* attempt — a client retrying a timeout would watch its card walk down the column, with every
|
|
274
|
+
* call succeeding.
|
|
275
|
+
*
|
|
276
|
+
* The other fields are rewritten with the same values, which is idempotent by construction.
|
|
277
|
+
*/
|
|
278
|
+
const filed = await deps.boards.taskRow(actor, node.id, "write");
|
|
279
|
+
await deps.boards.updateTask(node.id, {
|
|
280
|
+
status,
|
|
281
|
+
assignee_id: input.assigneeId,
|
|
282
|
+
labels_json: JSON.stringify(input.labels),
|
|
283
|
+
start_date: input.startDate,
|
|
284
|
+
due_date: input.dueDate,
|
|
285
|
+
depends_on: input.dependsOn,
|
|
286
|
+
...(filed?.status === status
|
|
287
|
+
? {}
|
|
288
|
+
: { position: await deps.boards.nextPosition(input.boardId, status) }),
|
|
289
|
+
}, deps.now().toISOString());
|
|
290
|
+
return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
|
|
291
|
+
},
|
|
292
|
+
async updateTask(actor, input) {
|
|
293
|
+
const row = await deps.boards.taskRow(actor, input.taskId, "write");
|
|
294
|
+
if (row === null) {
|
|
295
|
+
throw new IntelError(404, "task_not_found", "No task with this id is readable for you.");
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* ⚠️ **Dropping a card on the archive column archives it; pulling it out restores it** (D68).
|
|
299
|
+
* Neither writes `board_tasks.status`: the working column underneath is what a restored card
|
|
300
|
+
* returns to, and overwriting it with `archived` would be the second stored truth this
|
|
301
|
+
* design exists against.
|
|
302
|
+
*
|
|
303
|
+
* `nodes.archive` is the same door `node_archive` uses — one path, one audit row, one set of
|
|
304
|
+
* refusals. ⚠️ Since D71 (#733) that door CASCADES: dropping a card with subtasks on the
|
|
305
|
+
* archive column takes them with it, where #669 used to refuse the drop. Pulling the card back
|
|
306
|
+
* out brings exactly those back, and pulling a SUBTASK out while its parent card is archived
|
|
307
|
+
* is refused instead — a live card under an archived one is what the purge reaches.
|
|
308
|
+
*/
|
|
309
|
+
const intoArchive = input.status === ARCHIVE_COLUMN_ID;
|
|
310
|
+
const outOfArchive = input.status !== undefined && !intoArchive && row.archivedAt !== null;
|
|
311
|
+
if (intoArchive || outOfArchive) {
|
|
312
|
+
await deps.nodes.archive(actor, {
|
|
313
|
+
nodeId: input.taskId,
|
|
314
|
+
baseUpdatedAt: row.updatedAt,
|
|
315
|
+
archived: intoArchive,
|
|
316
|
+
idempotencyKey: `${input.idempotencyKey}:archive`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (input.status !== undefined && !intoArchive) {
|
|
320
|
+
requireColumn(await columnsOrRefuse(actor, row.boardId, "write"), input.status);
|
|
321
|
+
}
|
|
322
|
+
// Only what arrived. An absent field means "leave it"; `null` where the column is nullable
|
|
323
|
+
// means "clear it", which is why the check is against `undefined` and not against falsiness —
|
|
324
|
+
// `dueDate: null` and `position: 0` both have to get through.
|
|
325
|
+
const fields = {};
|
|
326
|
+
if (input.title !== undefined) {
|
|
327
|
+
// ⚠️ The title lives on the NODE, not here. `board_tasks` has no title column, and adding
|
|
328
|
+
// one would make two rows disagree about what a card is called.
|
|
329
|
+
throw new IntelError(400, "title_belongs_to_the_node", "Rename a task with `node_update`; the board row carries no title.");
|
|
330
|
+
}
|
|
331
|
+
// ⚠️ Not for a drop into the archive: the working column stays as it is, so the card knows
|
|
332
|
+
// where to go back to.
|
|
333
|
+
if (input.status !== undefined && !intoArchive)
|
|
334
|
+
fields.status = input.status;
|
|
335
|
+
// ⚠️ Not for a drop into the archive either. The kanban sends a position with EVERY drop,
|
|
336
|
+
// computed from the neighbours inside the column it was dropped on — and inside the archive
|
|
337
|
+
// those are cards from all over the board. Taken, it would overwrite the card's place in the
|
|
338
|
+
// working column with a number that means nothing there. The status is guarded one line up;
|
|
339
|
+
// without this line the guard only covered half the write.
|
|
340
|
+
if (input.position !== undefined && !intoArchive)
|
|
341
|
+
fields.position = input.position;
|
|
342
|
+
if (input.assigneeId !== undefined)
|
|
343
|
+
fields.assignee_id = input.assigneeId;
|
|
344
|
+
if (input.labels !== undefined)
|
|
345
|
+
fields.labels_json = JSON.stringify(input.labels);
|
|
346
|
+
if (input.startDate !== undefined)
|
|
347
|
+
fields.start_date = input.startDate;
|
|
348
|
+
if (input.dueDate !== undefined)
|
|
349
|
+
fields.due_date = input.dueDate;
|
|
350
|
+
if (input.dependsOn !== undefined)
|
|
351
|
+
fields.depends_on = input.dependsOn;
|
|
352
|
+
/**
|
|
353
|
+
* A move that names a new column but no position lands at the end of it — otherwise the card
|
|
354
|
+
* would keep the number it had in its old column and appear at an arbitrary place.
|
|
355
|
+
*
|
|
356
|
+
* ⚠️ **Only when the column actually CHANGES.** Asking again for the column a card is already
|
|
357
|
+
* in is what a retry looks like, and recomputing there would move the card one place further
|
|
358
|
+
* on every attempt while every call reported success. That is the idempotency the tool
|
|
359
|
+
* promises with `idempotentHint: true`, kept by construction rather than by a key lookup.
|
|
360
|
+
*/
|
|
361
|
+
/**
|
|
362
|
+
* ⚠️ **Never for a drop into the archive**, and the reason is the same one that keeps
|
|
363
|
+
* `fields.status` unset there: `board_tasks.status` never holds `archived`, so
|
|
364
|
+
* `nextPosition(board, "archived")` counts zero rows and answers `1` — every time. The card
|
|
365
|
+
* keeps its working column but silently loses its place IN it, landing first among cards it
|
|
366
|
+
* used to sit behind. Nothing reports that, and the reader only sees it after restoring.
|
|
367
|
+
*/
|
|
368
|
+
if (input.status !== undefined &&
|
|
369
|
+
input.position === undefined &&
|
|
370
|
+
row.status !== input.status &&
|
|
371
|
+
!intoArchive) {
|
|
372
|
+
fields.position = await deps.boards.nextPosition(row.boardId, input.status);
|
|
373
|
+
}
|
|
374
|
+
if (Object.keys(fields).length > 0) {
|
|
375
|
+
await deps.boards.updateTask(input.taskId, fields, deps.now().toISOString());
|
|
376
|
+
}
|
|
377
|
+
return await viewOrRefuse(actor, { boardId: row.boardId, includeArchived: false });
|
|
378
|
+
},
|
|
379
|
+
/**
|
|
380
|
+
* Who this card may be given to (D70, #700).
|
|
381
|
+
*
|
|
382
|
+
* ⚠️ THE ANSWER IS NARROWER THAN GATE'S, NEVER WIDER. gate is asked first and its hits are then
|
|
383
|
+
* dropped down to the people who can actually open this board. That order is what makes the
|
|
384
|
+
* door safe to offer to anyone who can read the board: whatever survives the filter, gate would
|
|
385
|
+
* have handed the same person anyway at its own `POST /directory/search`.
|
|
386
|
+
*
|
|
387
|
+
* The reverse order would have been the leak: asking the board first and then naming the
|
|
388
|
+
* principals would hand out a membership list for a board to anyone who can see it.
|
|
389
|
+
*/
|
|
390
|
+
async searchAssignees(actor, input) {
|
|
391
|
+
// `read`, not `share`: offering an assignee is something everyone who can open the board
|
|
392
|
+
// does, while managing grants is not. The 404 from here is the same one every other board
|
|
393
|
+
// read gives, so a board one may not see stays indistinguishable from one that is not there.
|
|
394
|
+
await columnsOrRefuse(actor, input.boardId, "read");
|
|
395
|
+
if (!deps.directory)
|
|
396
|
+
return { items: [], more: 0 };
|
|
397
|
+
/**
|
|
398
|
+
* ⚠️ ENFORCED HERE, not only described. The contract's `query` says a single character
|
|
399
|
+
* answers empty, and until this line that sentence was true only because gate happens to
|
|
400
|
+
* hold the same floor — a promise this repository made and a different service kept.
|
|
401
|
+
*
|
|
402
|
+
* It is also the cheaper answer: a letter that can only ever produce a listing does not
|
|
403
|
+
* need to travel to gate first to be refused there.
|
|
404
|
+
*/
|
|
405
|
+
const query = input.query.trim();
|
|
406
|
+
/**
|
|
407
|
+
* ⚠️ **Nothing typed is a QUESTION, not a refusal** (#757). The set of people who reach this
|
|
408
|
+
* board is short and known, so it can be named without a search term — and a picker that
|
|
409
|
+
* opens empty teaches the reader that there is nobody, which is the opposite of what it
|
|
410
|
+
* means.
|
|
411
|
+
*
|
|
412
|
+
* A single character stays refused, and the reason is unchanged: a letter is not a search, it
|
|
413
|
+
* is a listing under a different name. The two cases differ because one names a bounded set
|
|
414
|
+
* and the other asks the directory to filter one.
|
|
415
|
+
*/
|
|
416
|
+
if (query.length === 0)
|
|
417
|
+
return await suggest(input.boardId);
|
|
418
|
+
if (query.length < MINIMUM_QUERY)
|
|
419
|
+
return { items: [], more: 0 };
|
|
420
|
+
const hits = await deps.directory.search(query);
|
|
421
|
+
if (hits.length === 0)
|
|
422
|
+
return { items: [], more: 0 };
|
|
423
|
+
const access = await deps.boards.effectiveAccess(input.boardId);
|
|
424
|
+
// A grant to the organization means everybody reaches the board, so there is nothing left to
|
|
425
|
+
// narrow — and narrowing anyway would produce an empty picker on exactly the boards that are
|
|
426
|
+
// shared with everyone, which is most of them.
|
|
427
|
+
if (access.organizationWide)
|
|
428
|
+
return { items: hits, more: 0 };
|
|
429
|
+
return { items: hits.filter((hit) => reachedBy(access, hit)), more: 0 };
|
|
430
|
+
},
|
|
431
|
+
/**
|
|
432
|
+
* What the people already recorded on cards are called (#258, #700).
|
|
433
|
+
*
|
|
434
|
+
* ⚠️ NOT narrowed by the board, and that is the difference from the search above. Someone whose
|
|
435
|
+
* grant was withdrawn, or whose account was switched off, is still the person this card was
|
|
436
|
+
* given to. Filtering them out would leave the card reading as unassigned, which is a worse
|
|
437
|
+
* answer than the truth — and it would not protect anything, because the caller already holds
|
|
438
|
+
* the id and got it from a card they may read.
|
|
439
|
+
*/
|
|
440
|
+
async resolveNames(actor, input) {
|
|
441
|
+
void actor;
|
|
442
|
+
if (!deps.directory)
|
|
443
|
+
return { items: [] };
|
|
444
|
+
return { items: await deps.directory.resolve(input.ids) };
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { BoardAssigneeSearchInput, BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, BoardView, DirectoryPerson, DirectoryResolveInput } from "../contract/board.js";
|
|
2
|
+
import type { Node } from "../contract/node.js";
|
|
3
|
+
import type { Actor } from "../nodes/nodes.types.js";
|
|
4
|
+
export type { BoardView };
|
|
5
|
+
export interface BoardTaskRow {
|
|
6
|
+
nodeId: string;
|
|
7
|
+
boardId: string;
|
|
8
|
+
status: string;
|
|
9
|
+
position: number;
|
|
10
|
+
/**
|
|
11
|
+
* From the NODE, not from `board_tasks` (D68, #674).
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ Whether a card is archived is `nodes.archived_at` and nothing else. The row carries it so
|
|
14
|
+
* that a move can tell "into the archive" from "out of it" without a second read — and so that
|
|
15
|
+
* nobody is tempted to store the answer here, which would be the two truths D68 exists against.
|
|
16
|
+
*/
|
|
17
|
+
archivedAt: string | null;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
export interface BoardTaskWrite {
|
|
21
|
+
nodeId: string;
|
|
22
|
+
boardId: string;
|
|
23
|
+
status: string;
|
|
24
|
+
assigneeId: string | null;
|
|
25
|
+
labels: string[];
|
|
26
|
+
startDate: string | null;
|
|
27
|
+
dueDate: string | null;
|
|
28
|
+
dependsOn: string | null;
|
|
29
|
+
position: number;
|
|
30
|
+
occurredAt: string;
|
|
31
|
+
}
|
|
32
|
+
export interface BoardRepository {
|
|
33
|
+
read(actor: Actor, input: BoardGetInput): Promise<BoardView | null>;
|
|
34
|
+
columnsOf(actor: Actor, boardId: string, verb: "read" | "write"): Promise<string[] | null>;
|
|
35
|
+
/**
|
|
36
|
+
* ⚠️ `archiveVisible` is `undefined` for "leave it as it is" — the dialog that writes the columns
|
|
37
|
+
* and the switch that hides the archive are the same call, and a caller who only reorders columns
|
|
38
|
+
* must not silently un-hide an archive somebody put away.
|
|
39
|
+
*/
|
|
40
|
+
setColumns(boardId: string, columnsJson: string, occurredAt: string, archiveVisible?: boolean): Promise<void>;
|
|
41
|
+
attach(write: BoardTaskWrite): Promise<void>;
|
|
42
|
+
taskRow(actor: Actor, taskId: string, verb: "read" | "write"): Promise<BoardTaskRow | null>;
|
|
43
|
+
updateTask(taskId: string, fields: Record<string, string | number | null>, occurredAt: string): Promise<void>;
|
|
44
|
+
nextPosition(boardId: string, status: string): Promise<number>;
|
|
45
|
+
/**
|
|
46
|
+
* Who reaches this node once inheritance is resolved: every grant along the folders above plus
|
|
47
|
+
* the owners. The same answer `listEffectiveAccess` gives, reached through the SAME repository
|
|
48
|
+
* method, so the two can never disagree about who has access.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ Read here WITHOUT the `share` permission the application-level `listEffectiveAccess`
|
|
51
|
+
* demands, and that is deliberate rather than an oversight. The two callers ask for different
|
|
52
|
+
* reasons: managing sharing is an owner's job, while naming the people a card may be given to is
|
|
53
|
+
* something anyone who can open the board has to be able to do. The answer never leaves this
|
|
54
|
+
* service as a list of grants; it is only ever used to NARROW what gate already offers.
|
|
55
|
+
*/
|
|
56
|
+
effectiveAccess(boardId: string): Promise<{
|
|
57
|
+
ownerIds: string[];
|
|
58
|
+
principalIds: string[];
|
|
59
|
+
principalEmails: string[];
|
|
60
|
+
organizationWide: boolean;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The one seam to gate's directory, built PER REQUEST around the caller's bearer.
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ The bearer never reaches this service, and it must not: `Actor` is the resolved identity, not
|
|
67
|
+
* the credential that produced it. The edge already holds the token because it just authorized with
|
|
68
|
+
* it, so it closes over it here and hands in something that answers questions instead.
|
|
69
|
+
*/
|
|
70
|
+
export interface DirectoryPort {
|
|
71
|
+
search(query: string): Promise<DirectoryPerson[]>;
|
|
72
|
+
resolve(ids: string[]): Promise<DirectoryPerson[]>;
|
|
73
|
+
}
|
|
74
|
+
export interface BoardNodePort {
|
|
75
|
+
create(actor: Actor, input: {
|
|
76
|
+
parentId: string;
|
|
77
|
+
kind: "task";
|
|
78
|
+
title: string;
|
|
79
|
+
description: null;
|
|
80
|
+
idempotencyKey: string;
|
|
81
|
+
}): Promise<Node>;
|
|
82
|
+
/**
|
|
83
|
+
* ⚠️ The same door `node_archive` uses. Dropping a card on the archive column must not become a
|
|
84
|
+
* second way to archive: one path means one audit row, one set of refusals — a card with live
|
|
85
|
+
* subtasks stays refused (#669) — and one answer to "what does archived mean".
|
|
86
|
+
*/
|
|
87
|
+
archive(actor: Actor, input: {
|
|
88
|
+
nodeId: string;
|
|
89
|
+
baseUpdatedAt: string;
|
|
90
|
+
archived: boolean;
|
|
91
|
+
idempotencyKey: string;
|
|
92
|
+
}): Promise<Node>;
|
|
93
|
+
}
|
|
94
|
+
export interface BoardDeps {
|
|
95
|
+
boards: BoardRepository;
|
|
96
|
+
nodes: BoardNodePort;
|
|
97
|
+
now(): Date;
|
|
98
|
+
directory?: DirectoryPort;
|
|
99
|
+
}
|
|
100
|
+
export interface BoardService {
|
|
101
|
+
get(actor: Actor, input: BoardGetInput): Promise<BoardView>;
|
|
102
|
+
update(actor: Actor, input: BoardUpdateInput): Promise<BoardView>;
|
|
103
|
+
createTask(actor: Actor, input: BoardTaskCreateInput): Promise<BoardView>;
|
|
104
|
+
updateTask(actor: Actor, input: BoardTaskUpdateInput): Promise<BoardView>;
|
|
105
|
+
/**
|
|
106
|
+
* ⚠️ `more` says how many people reach this board beyond the ones named, so a capped list can say
|
|
107
|
+
* so instead of looking complete (#757). It is `0` on a search answer, where the cap belongs to
|
|
108
|
+
* gate and not to this door.
|
|
109
|
+
*/
|
|
110
|
+
searchAssignees(actor: Actor, input: BoardAssigneeSearchInput): Promise<{
|
|
111
|
+
items: DirectoryPerson[];
|
|
112
|
+
more: number;
|
|
113
|
+
}>;
|
|
114
|
+
resolveNames(actor: Actor, input: DirectoryResolveInput): Promise<{
|
|
115
|
+
items: DirectoryPerson[];
|
|
116
|
+
}>;
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|