diff --git a/Makefile b/Makefile index 8edf36e4..b94b5946 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ integration-test: nix run .#star-integration-tests test-emacs: - $(EMACS) -Q --batch -L . -l client-test.el -f ert-run-tests-batch-and-exit + $(EMACS) -Q --batch -L . -l client-test.el -l starintel-workbench-test.el -f ert-run-tests-batch-and-exit images: nix build .#star-server-image .#couchdb-image .#clouseau-image .#rabbitmq-image diff --git a/client.el b/client.el index 921c9e88..07352d5a 100644 --- a/client.el +++ b/client.el @@ -219,11 +219,15 @@ scrub error text." (defun starintel-api-error-message (err) "Return the redacted message of a starintel-api error ERR. ERR is the condition data as captured by `condition-case' or -`should-error': (SYMBOL . DATA)." +`should-error': (SYMBOL . DATA). Plain string data, as signaled by +ordinary `user-error' calls at the UI boundary, is redacted too." (let ((data (cdr err))) (cond - ((and (listp data) data (plist-member (car data) :message)) + ((and (listp data) data (listp (car data)) + (plist-member (car data) :message)) (plist-get (car data) :message)) + ((and (listp data) (stringp (car data))) + (starintel-api--redact (car data))) ((stringp data) (starintel-api--redact data)) (t (starintel-api--redact (format "%S" data)))))) diff --git a/docs/emacs-workbench-inventory.org b/docs/emacs-workbench-inventory.org new file mode 100644 index 00000000..2d4e8f85 --- /dev/null +++ b/docs/emacs-workbench-inventory.org @@ -0,0 +1,88 @@ +#+title: Emacs OSINT workbench: implementation inventory +#+options: toc:2 + +This inventory records what the StarIntel Emacs client implements today, what +the current server (v0.9 family) exposes, and where the workbench gaps are. +It was written by inspecting =client.el=, =client-test.el=, +=docs/http-api-docs.org=, =source/frontends/http-capabilities.lisp= and +=source/frontends/http-contract-routes.lisp= at commit +=e99aadd= (master, 2026-08). + +* Current client implementation + +** Modern contract layer (=starintel-api-*=, =client.el=) + +- Async-capable transport boundary with a pluggable + =starintel-api-transport-function=; default is a bounded url.el transport. +- Typed error taxonomy (=starintel-api-timeout-error=, + =starintel-api-http-error=, ...) with secret redaction. +- Correlation IDs and =X-Request-Timeout-Ms= deadlines on every request. +- Capability discovery against =GET /api/v1/capabilities= with cache and + =legacy_routes= compatibility gating. +- Contract operations: =starintel-api-health=, =starintel-api-server-info=, + =starintel-api-search=, =starintel-api-get-document=. + +** Legacy layer (requires =request=, soft dependency) + +- Callback-style wrappers for server info, health, documents, targets, + documents creation, and the CouchDB view routes + (=starintel-hosts-by-ip=, =starintel-messages-by-user=, ...). +- Document construction through the =starintel-doc= package. + +** Presentation layer (=starintel-ui-*=) + +- One shared =special-mode= buffer =*StarIntel*= with static text rendering. +- =starintel-status= renders server info, health and capabilities. +- =starintel-search= renders search rows as plain lines (not actionable). +- =starintel-document= renders the raw JSON document. + +** Existing modes + +- No major modes. The shared buffer is =special-mode= only. + +* Server surface available today (v0.9 family) + +| Surface | Route | Status | +|---------+-------+--------| +| Capabilities | =GET /api/v1/capabilities= | Active, public | +| Server info | =GET /= | Active: =server=, =version=, =doc_spec_version=, =default-dataset=, =event_log=, =openapi=, =client_manifest= | +| Health | =GET /health= | Active | +| Stats | =GET /api/v1/stats= | Active: =documents.total=, =documents.by_dtype=, =targets.total= | +| Search | =GET /api/v1/search= and legacy =GET /search= | Active: =q= (<=512), =limit= (1..50), =bookmark=; CouchDB FTS =rows= + =bookmark= | +| OpenAPI / manifest | =GET /openapi.json=, =GET /client-manifest.json= | Active | +| Auth | =POST /auth/login=, =/auth/bootstrap=, =/auth/context=, user/credential lifecycle | Active (registry operations) | +| Documents | =POST /new/document/:dtype=, =POST /documents/bulk=, =GET/PUT/DELETE /document/:id= | Active (legacy-marked), strict 0.9 validation | +| Targets | =POST /new/target/:actor=, =GET /targets/:actor= | Active (legacy-marked, non-strict adapter) | +| View queries | =/documents/.../by-*= bounded pagination | Active | +| Target leases | =features.target_leases= | =false=: not advertised; client must treat as unavailable | +| Streams/events | =features.streams= | =false=: no event stream yet | +| Actors/jobs | (none) | Not exposed over HTTP; local Sento actors and Rabbit only | +| Graph/path queries | (none) | Not exposed; relations are documents of dtype =relation= | + +* Gap analysis: workbench features missing from the client + +1. Server profiles and current-server abstraction (multiple deployments, + =auth-source=, server identity in references). +2. Stable object identity and =star://= URIs; no link handling, no Org + integration. +3. Generic object buffer (typed rendering, actions) - today only raw JSON. +4. Actionable search results buffer (tabulated list, marking, pagination). +5. Person/organization/target dedicated views (render 0.9 =data= fields). +6. Relation/graph traversal (read from =relation= documents). +7. Ingest commands over the validated =POST /new/document/:dtype= boundary. +8. Query workbench, saved queries, history. +9. Timeline assembled from object timestamps (=dateAdded=, =dateUpdated=, + extension timestamps). +10. Provenance surfacing from =extensions.star_server= (exists in schema). +11. Actor/job workbench: server exposes nothing yet; must render as + "under development" without breaking the workbench. +12. Live updates: =features.streams= is false; nothing to integrate yet. +13. Bookmarks/recent objects, investigation context. + +* Principles honored going forward + +- Server-authoritative; client stores references only. +- Capability-gated: missing features render as unavailable, never crash. +- No scraping/actor logic in Emacs; the workbench is a cockpit. +- No heavy frameworks: tabulated-list, buttons, text properties, soft + =transient= and =org= dependencies only. diff --git a/docs/emacs-workbench-plan.org b/docs/emacs-workbench-plan.org new file mode 100644 index 00000000..97f6774d --- /dev/null +++ b/docs/emacs-workbench-plan.org @@ -0,0 +1,101 @@ +#+title: Emacs OSINT workbench: execution plan +#+options: toc:2 + +Long-horizon plan for evolving =client.el= into a native Emacs OSINT +workbench. The workbench is a cockpit over the StarIntel server; the server +stays authoritative. Update the status table in the same commit as behavior +changes. + +* Dependency order + +1. transport/auth/server profiles <- foundation slice (this PR) +2. object/URI model <- foundation slice (this PR) +3. generic object views <- foundation slice (this PR) +4. search + documents + entities <- foundation slice (this PR) +5. targets +6. investigations + Org integration +7. graph (relation traversal) +8. actors/jobs (blocked: server exposes nothing) +9. ingest +10. query/timeline/provenance +11. dashboard/workbench polish + +* Module layout (additive; client.el keeps transport + legacy + compat UI) + +| File | Responsibility | +|------+----------------| +| =starintel-server.el= | Server profiles, current server, =auth-source=, switch/status | +| =starintel-uri.el= | =star://= parse/format/open, Org link registration | +| =starintel-object.el= | Object identity, dtype field maps, titles, object buffer mode | +| =starintel-search.el= | Search results tabulated-list buffer, marking, pagination | +| =starintel-ui.el= | Workbench package =starintel-ui=, entry =M-x starintel=, soft transient menu | + +* star:// URI grammar + +: star://SERVER/KIND/ID + +- =SERVER=: server profile name; empty means the current server. +- =KIND=: =document=, =person=, =org=, =target=, =relation=, =search=, ... + open set; unknown kinds still parse and open generically. +- =ID=: remainder of the string (IDs may contain =/=); percent-decoded. + +Examples: =star://local/document/01JABC...=, =star:///person/01J...=, +=star://remote/search/alice example.com=. + +* Server profile model + +Profiles live in =starintel-servers= as plists: + +#+begin_src elisp +(setq starintel-servers + '((local :url "http://127.0.0.1:5000" + :auth-source (:host "starintel-local" :user "api")))) +#+end_src + +Activating a profile sets the API layer base URL and token resolution, +clears the capability cache, and stamps every object reference with the +profile name. + +* Secret handling + +- Preferred: =:auth-source= in a profile. Credentials resolve per + request through =auth-source-search=; nothing is cached or written + by the client. Use an encrypted =~/.authinfo.gpg=: + + #+begin_src text + machine starintel-remote login api password star_sk_v1_... + #+end_src + +- =:auth-source= accepts a host string (login defaults to =api=) or + =(:host HOST :user USER)=. +- A session =:token= keeps the secret in memory for the session only + and is never persisted through Customize. +- Bearer tokens never appear in URLs; error text and messages are + redacted by the API layer against the active token. Secrets come from =auth-source=, never plain config. + +* Status + +| Slice | Status | Evidence | +|-------+--------+----------| +| 1. inventory + plan | done | this file + emacs-workbench-inventory.org | +| 2. profiles | done | starintel-server.el + ERT | +| 3. URI model | done | starintel-uri.el + ERT | +| 4. object identity + buffer | done | starintel-object.el + ERT | +| 5. search buffer | done | starintel-search.el + ERT | +| 6. targets workbench | pending | view routes + =/targets/:actor= exist server-side | +| 7. investigations/org | pending | after object model proven in use | +| 8. graph traversal | pending | relation documents | +| 9. actors/jobs | blocked | server feature absent; render unavailable | +| 10. ingest | pending | POST /new/document/:dtype | +| 11. query/timeline/provenance | pending | | +| 12. dashboard polish | pending | after real features land | + +Live validation against the development deployment (127.0.0.1:5000): +capabilities discovery, search round-trip, stats, and typed timeout +handling verified. Authenticated document fetch and target routes are +pending validation with a local API credential. + +* TDD gate + +=make test-emacs= runs all ERT suites (client + workbench) with the fake +transport. New behavior lands with red/green evidence in commit history. diff --git a/flake.nix b/flake.nix index f6cc9d25..49467a41 100644 --- a/flake.nix +++ b/flake.nix @@ -497,6 +497,33 @@ PY starServer = star-server-bin; }; + # Native Emacs OSINT workbench (package name starintel-ui, entry + # command M-x starintel). Only runtime elisp is compiled: test + # files stay out of the installed site-lisp. Autoloads are + # generated so M-x can discover the interactive commands. + starintel-ui-src = pkgs.runCommand "starintel-ui-src" { } '' + mkdir -p $out + cp ${./client.el} $out/client.el + cp ${./starintel-server.el} $out/starintel-server.el + cp ${./starintel-uri.el} $out/starintel-uri.el + cp ${./starintel-object.el} $out/starintel-object.el + cp ${./starintel-search.el} $out/starintel-search.el + cp ${./starintel-ui.el} $out/starintel-ui.el + ''; + + starintel-ui = pkgs.emacsPackages.trivialBuild { + pname = "starintel-ui"; + version = "2.0.0"; + src = starintel-ui-src; + postInstall = '' + cd "$out/share/emacs/site-lisp" + ${pkgs.emacs}/bin/emacs --batch \ + --eval '(progn + (require (quote package)) + (package-generate-autoloads "starintel-ui" "."))' + ''; + }; + in { packages.${system} = { default = star-server-bin; @@ -512,6 +539,8 @@ PY container-images = containerImages.allImages; load-images = containerImages.loadImages; + starintel-ui = starintel-ui; + star-cli = pkgs.stdenv.mkDerivation { pname = "star-cli"; version = "0.1.0"; diff --git a/starintel-object.el b/starintel-object.el new file mode 100644 index 00000000..7eb580da --- /dev/null +++ b/starintel-object.el @@ -0,0 +1,315 @@ +;;; starintel-object.el --- StarIntel object identity and views -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 + +;; Author: nsaspy +;; Version: 2.0.0 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: tools, processes + +;; Commentary: + +;; Canonical StarIntel object identity and the generic object buffer. +;; +;; A `starintel-object' is a reference to one server-side document: +;; the owning server profile, the dtype, the document ID, and the +;; derived `star://' URI. It never copies authoritative state; the +;; raw document is kept only to render the view. +;; +;; The generic object buffer renders dtype-known titles and data +;; fields (rendered dynamically from the document's `data' section; +;; no fields are invented), server-owned fields, and provenance from +;; `extensions.star_server' when present. Raw JSON stays one key +;; away with `$'. Features the server does not provide yet (graph +;; rendering, actor runs) are simply absent instead of emulated. + +;;; Code: + +(require 'cl-lib) +(require 'button) +(require 'subr-x) +(require 'client) +(require 'starintel-server) +(require 'starintel-uri) + +(declare-function starintel-ui--report-error "client" (condition plist)) + +(defvar starintel-object--current) + +(defgroup starintel-object nil + "StarIntel object identity and views." + :group 'starintel + :prefix "starintel-object-") + +(cl-defstruct starintel-object + "Reference to one authoritative StarIntel document. +SERVER is the server profile name used in URIs, DTYPE the document +type, ID the document identity, DATASET the logical corpus, URI the +derived star:// URI, and DOC the raw document alist for rendering." + server dtype id dataset uri doc) + +(defvar starintel-object-recent-uris nil + "Recent object URIs opened this session (client-side convenience).") + +(defun starintel-object--doc-field (doc field) + "Return FIELD from DOC, checking the nested 0.9 `data' section +before legacy flat top-level keys." + (let ((data (cdr (assq 'data doc)))) + (or (and data (cdr (assq field data))) + (cdr (assq field doc))))) + +(defun starintel-object--title-for-dtype (dtype doc) + "Return a human title for a DTYPE document DOC. +Only fields the StarIntel schema actually provides are used." + (cl-case (intern dtype) + (person + (let ((parts nil)) + (dolist (field '(fname mname lname)) + (let ((v (starintel-object--doc-field doc field))) + (when (and (stringp v) (not (string= v ""))) + (push v parts)))) + (let ((name (mapconcat #'identity (nreverse parts) " "))) + (and (not (string= name "")) name)))) + (org (starintel-object--doc-field doc 'name)) + (domain (starintel-object--doc-field doc 'record)) + (host (let ((hostname (starintel-object--doc-field doc 'hostname)) + (ip (starintel-object--doc-field doc 'ip))) + (cond ((and hostname ip) (format "%s (%s)" hostname ip)) + (hostname hostname)))) + (url (starintel-object--doc-field doc 'url)) + (user (let ((name (starintel-object--doc-field doc 'name)) + (platform (starintel-object--doc-field doc 'platform))) + (and name (if platform (format "@%s (%s)" name platform) name)))) + (email (let ((user (starintel-object--doc-field doc 'user)) + (domain (starintel-object--doc-field doc 'domain))) + (and user domain (format "%s@%s" user domain)))) + (message (let ((content (starintel-object--doc-field doc 'content))) + (and content (substring content 0 (min 60 (length content)))))) + (socialmpost (or (starintel-object--doc-field doc 'title) + (starintel-object--doc-field doc 'content))) + (phone (starintel-object--doc-field doc 'number)) + (breach (starintel-object--doc-field doc 'description)) + (target (let ((value (starintel-object--doc-field doc 'target)) + (actor (starintel-object--doc-field doc 'actor))) + (and value (if actor (format "%s @ %s" value actor) value)))) + (relation + (let ((source (starintel-object--doc-field doc 'source)) + (target-id (starintel-object--doc-field doc 'target)) + (predicate (starintel-object--doc-field doc 'predicate))) + (and source target-id predicate + (format "%s -%s-> %s" source predicate target-id)))) + (address (or (starintel-object--doc-field doc 'street) + (starintel-object--doc-field doc 'city))) + (t nil))) + +(defun starintel-object-title (obj) + "Return a human-readable title for OBJ. +Falls back to \"dtype short-id\" when no schema field matches." + (let ((title (starintel-object--title-for-dtype + (starintel-object-dtype obj) (starintel-object-doc obj)))) + (or (and (stringp title) (not (string= title "")) title) + (format "%s %s" + (starintel-object-dtype obj) + (substring (starintel-object-id obj) + 0 (min 8 (length (starintel-object-id obj)))))))) + +(defun starintel-object--kind (dtype) + "Map a document DTYPE to its star:// kind. +Known entity dtypes get specific kinds; everything else opens as a +plain document." + (cl-case (intern dtype) + (person "person") + (org "org") + (target "target") + (relation "relation") + (t "document"))) + +(defun starintel-object-from-doc (doc &optional server) + "Build a `starintel-object' from the document alist DOC. +SERVER defaults to the current server profile name." + (let* ((dtype (format "%s" (or (cdr (assq 'dtype doc)) "document"))) + (id (format "%s" (cdr (assq '_id doc)))) + (server (or server (starintel-server-uri-name))) + (kind (starintel-object--kind dtype))) + (make-starintel-object + :server server :dtype dtype :id id + :dataset (cdr (assq 'dataset doc)) + :uri (starintel-uri-format server kind id) + :doc doc))) + +(defun starintel-object--buffer-name (obj) + "Return the stable buffer name for OBJ." + (format "*StarIntel: %s*" (starintel-object-title obj))) + +;;; Generic object buffer + +(defgroup starintel-object-view nil + "Generic StarIntel object buffers." + :group 'starintel-object + :prefix "starintel-object-") + +(defface starintel-object-heading-face + '((t :inherit font-lock-keyword-face :weight bold)) + "Face for object buffer headings." + :group 'starintel-object-view) + +(defvar starintel-object-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map special-mode-map) + (define-key map "g" #'starintel-object-refresh) + (define-key map "w" #'starintel-object-copy-id) + (define-key map "u" #'starintel-object-copy-uri) + (define-key map "$" #'starintel-object-show-raw) + map) + "Keymap for `starintel-object-mode'.") + +(define-derived-mode starintel-object-mode special-mode "StarIntel-Object" + "Major mode for generic StarIntel object views. +\\{starintel-object-mode-map}") + +(defun starintel-object--buffer (obj) + "Return the clean object buffer for OBJ." + (let ((buffer (get-buffer-create (starintel-object--buffer-name obj)))) + (with-current-buffer buffer + (starintel-object-mode) + (setq buffer-read-only nil) + (erase-buffer) + (setq-local starintel-object--current obj)) + buffer)) + +(defun starintel-object--insert-field (label value) + "Insert LABEL/VALUE pair on one line." + (insert (format "%-22s %s\n" label (or value "-")))) + +(defun starintel-object--insert-heading (title) + "Insert TITLE as a section heading." + (insert "\n" (propertize title 'face 'starintel-object-heading-face) "\n")) + +(defun starintel-object--insert-id-button (id uri) + "Insert ID as a button opening URI when URI is non-nil." + (if uri + (insert-button id + 'starintel-uri uri + 'action (lambda (_button) + (starintel-uri-open uri)) + 'follow-link t) + (insert id))) + +(defun starintel-object--insert-section-data (doc) + "Render the document's `data' section from DOC." + (let ((data (cdr (assq 'data doc)))) + (when data + (starintel-object--insert-heading "Data") + (dolist (field data) + (starintel-object--insert-field + (car field) (starintel-object--format-value (cdr field))))))) + +(defun starintel-object--format-value (value) + "Render VALUE readably; lists are joined, nil renders as \"-\"." + (cond + ((null value) "-") + ((eq value :json-false) "false") + ((eq value t) "true") + ((listp value) (mapconcat #'starintel-object--format-value value ", ")) + (t (format "%s" value)))) + +(defun starintel-object--insert-section-provenance (doc) + "Render provenance from `extensions.star_server' in DOC." + (let* ((extensions (cdr (assq 'extensions doc))) + (star (cdr (assq 'star_server extensions)))) + (when star + (starintel-object--insert-heading "Provenance") + (dolist (field star) + (starintel-object--insert-field + (car field) (starintel-object--format-value (cdr field))))))) + +(defun starintel-object--render (obj) + "Render OBJ into its buffer." + (let* ((doc (starintel-object-doc obj)) + (sources (cdr (assq 'sources doc)))) + (with-current-buffer (starintel-object--buffer obj) + (let ((inhibit-read-only t)) + (insert (propertize (starintel-object-title obj) + 'face 'starintel-object-heading-face) + "\n") + (starintel-object--insert-heading "Identity") + (starintel-object--insert-field "dtype" (starintel-object-dtype obj)) + (starintel-object--insert-field "dataset" (starintel-object-dataset obj)) + (starintel-object--insert-field "server" (starintel-object-server obj)) + (insert "id ") + (starintel-object--insert-id-button (starintel-object-id obj) nil) + (insert "\nuri ") + (starintel-object--insert-id-button (starintel-object-uri obj) + (starintel-object-uri obj)) + (insert "\n") + (when sources + (starintel-object--insert-heading "Sources") + (starintel-object--insert-field + "sources" (starintel-object--format-value sources))) + (starintel-object--insert-section-data doc) + (starintel-object--insert-section-provenance doc) + (goto-char (point-min)) + (setq buffer-read-only t)) + (pop-to-buffer (current-buffer))))) + +;;; Public operations + +(defun starintel-object-open (_kind id) + "Fetch and render the StarIntel document with ID. +KIND is the star:// kind that led here; the rendered object uses the +dtype the server actually returns. The exchange is asynchronous; +typed errors surface through the usual StarIntel error reporting." + (starintel-api-get-document + id + :on-success + (lambda (doc) + (let ((obj (starintel-object-from-doc doc))) + (add-to-list 'starintel-object-recent-uris (starintel-object-uri obj)) + (starintel-object--render obj))) + :on-error #'starintel-ui--report-error)) + +;;;###autoload +(defun starintel-object-refresh () + "Refresh the object buffer at point from the server." + (interactive) + (if-let ((uri (and (boundp 'starintel-object--current) + (starintel-object-uri starintel-object--current)))) + (progn + (starintel-uri-open uri) + (message "StarIntel: refreshed %s" uri)) + (user-error "StarIntel: buffer does not reference a StarIntel object"))) + +;;;###autoload +(defun starintel-object-copy-id () + "Copy the current object's ID." + (interactive) + (let ((obj starintel-object--current)) + (kill-new (starintel-object-id obj)) + (message "StarIntel: copied %s" (starintel-object-id obj)))) + +;;;###autoload +(defun starintel-object-copy-uri () + "Copy the current object's star:// URI." + (interactive) + (let ((obj starintel-object--current)) + (kill-new (starintel-object-uri obj)) + (message "StarIntel: copied %s" (starintel-object-uri obj)))) + +;;;###autoload +(defun starintel-object-show-raw () + "Show the raw JSON document of the current object." + (interactive) + (let* ((obj starintel-object--current) + (name (format "*StarIntel raw: %s*" (starintel-object-id obj))) + (buffer (get-buffer-create name))) + (with-current-buffer buffer + (special-mode) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (json-encode (starintel-object-doc obj))) + (ignore-errors (json-pretty-print (point-min) (point-max))) + (goto-char (point-min)))) + (pop-to-buffer buffer))) + +(provide 'starintel-object) +;;; starintel-object.el ends here diff --git a/starintel-search.el b/starintel-search.el new file mode 100644 index 00000000..e955b1f2 --- /dev/null +++ b/starintel-search.el @@ -0,0 +1,222 @@ +;;; starintel-search.el --- StarIntel search results workbench -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 + +;; Author: nsaspy +;; Version: 2.0.0 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: tools, processes + +;; Commentary: + +;; First-class search over the capability-resolved StarIntel search +;; endpoint. Results render in a `tabulated-list-mode' buffer with +;; actionable rows: open the object, copy its star:// URI, mark rows +;; for bulk actions, and page forward with the server bookmark. +;; +;; The server stays authoritative: this buffer only renders rows the +;; server returned and keeps no local database. + +;;; Code: + +(require 'tabulated-list) +(require 'cl-lib) +(require 'client) +(require 'starintel-server) +(require 'starintel-uri) +(require 'starintel-object) + +(defgroup starintel-search nil + "StarIntel search results buffers." + :group 'starintel + :prefix "starintel-search-") + +(defcustom starintel-search-limit 25 + "Default number of results per search page." + :type 'integer + :group 'starintel-search) + +(defcustom starintel-search-buffer-name "*StarIntel Search*" + "Name of the search results buffer." + :type 'string + :group 'starintel-search) + +(defface starintel-search-heading-face + '((t :inherit font-lock-keyword-face :weight bold)) + "Face for search buffer headings." + :group 'starintel-search) + +(defvar starintel-search-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map tabulated-list-mode-map) + (define-key map "g" #'starintel-search-refresh) + (define-key map "n" #'starintel-search-next-page) + (define-key map "w" #'starintel-search-copy-uri) + (define-key map "m" #'starintel-search-toggle-mark) + (define-key map "a" #'starintel-search-add-marked-to-investigation) + map) + "Keymap for `starintel-search-mode'.") + +(defvar-local starintel-search--query nil + "Query currently rendered in this search buffer.") + +(defvar-local starintel-search--bookmark nil + "Server bookmark for the next page, or nil.") + +(defvar-local starintel-search--docs nil + "Alist of (DOCUMENT-ID . DOC-ALIST) for the rendered rows.") + +(defvar-local starintel-search--marked nil + "Alist of marked document IDs, keyed by ID string.") + +(define-derived-mode starintel-search-mode tabulated-list-mode "StarIntel-Search" + "Major mode for StarIntel search result buffers. +\\{starintel-search-mode-map}" + ;; Reserve a column for mark tags (required by `tabulated-list-put-tag'). + (setq tabulated-list-padding 1)) + +(defun starintel-search--object-from-doc (doc) + "Build a `starintel-object' reference from a search result DOC." + (require 'starintel-object) + (starintel-object-from-doc doc)) + +(defun starintel-search--entry (row) + "Build one tabulated-list entry from a search ROW. +ROW carries `doc' and optionally a score." + (let* ((doc (cdr (assq 'doc row))) + (id (or (and doc (cdr (assq '_id doc))) + (cdr (assq 'id row)) + "?")) + (obj (and doc (starintel-search--object-from-doc doc))) + (title (if obj + (starintel-object-title obj) + id)) + (dtype (and doc (cdr (assq 'dtype doc)))) + (dataset (and doc (cdr (assq 'dataset doc)))) + (date-added (and doc (cdr (assq 'dateAdded doc)))) + (date (if (numberp date-added) + (format-time-string "%Y-%m-%d" (seconds-to-time date-added)) + ""))) + (list id + (vector (or (format "%s" dtype) "?") + title + (or dataset "") + date + (propertize id 'starintel-id id + 'starintel-uri (and obj (starintel-object-uri obj))))))) + +(defun starintel-search--render (query data) + "Render the search document DATA for QUERY." + (let* ((rows (cdr (assq 'rows data))) + (bookmark (cdr (assq 'bookmark data))) + (entries (mapcar #'starintel-search--entry (append rows nil))) + (docs (mapcar (lambda (row) + (let ((doc (cdr (assq 'doc row)))) + (cons (cdr (assq '_id doc)) doc))) + (append rows nil)))) + (with-current-buffer (get-buffer-create starintel-search-buffer-name) + (starintel-search-mode) + (setq starintel-search--query query + starintel-search--bookmark bookmark + starintel-search--docs docs + starintel-search--marked nil) + (setq tabulated-list-entries entries) + (setq tabulated-list-format + [("Type" 14 t) + ("Title" 40 t) + ("Dataset" 16 t) + ("Date" 10 t) + ("ID" 30 t)]) + (tabulated-list-init-header) + (tabulated-list-print) + (goto-char (point-min)) + (pop-to-buffer (current-buffer)) + (message "StarIntel: %d result(s) for %S%s" + (length entries) query + (if bookmark " [more pages available: n]" ""))))) + +;;;###autoload +(cl-defun starintel-search-open (query &key limit bookmark) + "Search StarIntel for QUERY and render actionable results. +LIMIT bounds the page size; BOOKMARK continues a previous page. The +exchange is asynchronous against the capability-resolved search +endpoint. Unavailable search capabilities surface as a clean typed +error instead of breaking the workbench." + (interactive "sStarIntel search: ") + (starintel-api-search + query + :limit (or limit starintel-search-limit) + :bookmark bookmark + :on-success (lambda (data) (starintel-search--render query data)) + :on-error #'starintel-ui--report-error)) + +;;;###autoload +(defun starintel-search-refresh () + "Re-run the current query." + (interactive) + (if starintel-search--query + (starintel-search-open starintel-search--query) + (user-error "StarIntel: no query in this search buffer"))) + +;;;###autoload +(defun starintel-search-next-page () + "Render the next page using the server bookmark." + (interactive) + (if starintel-search--bookmark + (starintel-search-open starintel-search--query + :bookmark starintel-search--bookmark) + (user-error "StarIntel: no further pages"))) + +(defun starintel-search--entry-object () + "Return the `starintel-object' reference for the entry at point." + (let* ((entry (tabulated-list-get-entry)) + (id (and entry (vectorp entry) + (>= (length entry) 5) + (get-text-property 0 'starintel-id (aref entry 4))))) + (and id + (let ((doc (cdr (assoc id starintel-search--docs)))) + (and doc (starintel-search--object-from-doc doc)))))) + +;;;###autoload +(defun starintel-search-copy-uri () + "Copy the star:// URI of the entry at point." + (interactive) + (let ((obj (starintel-search--entry-object))) + (if obj + (progn + (kill-new (starintel-object-uri obj)) + (message "StarIntel: copied %s" (starintel-object-uri obj))) + (user-error "StarIntel: no StarIntel object at point")))) + +;;;###autoload +(defun starintel-search-toggle-mark () + "Toggle the mark on the entry at point." + (interactive) + (save-excursion + (beginning-of-line) + (let ((id (tabulated-list-get-id))) + (when id + (if (assoc id starintel-search--marked) + (progn + (setq starintel-search--marked + (assq-delete-all id starintel-search--marked)) + (tabulated-list-put-tag " " nil)) + (push (cons id t) starintel-search--marked) + (tabulated-list-put-tag "*" nil)))))) + +(defun starintel-search-add-marked-to-investigation () + "Add marked (or current) entries to the current Org investigation. +Investigation workbenches are not implemented yet; this command says +so instead of silently doing nothing." + (interactive) + (user-error "StarIntel: investigation integration is not implemented yet")) + +(defun starintel-search--uri-open (_server _kind query) + "star:// handler for the search kind: open QUERY as a search." + (starintel-search-open query)) + +(add-to-list 'starintel-uri-kind-handlers + (cons "search" #'starintel-search--uri-open)) + +(provide 'starintel-search) +;;; starintel-search.el ends here diff --git a/starintel-server.el b/starintel-server.el new file mode 100644 index 00000000..1c59efec --- /dev/null +++ b/starintel-server.el @@ -0,0 +1,151 @@ +;;; starintel-server.el --- StarIntel server profiles -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 + +;; Author: nsaspy +;; Version: 2.0.0 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: tools, processes + +;; Commentary: + +;; Named server profiles over the StarIntel API layer. +;; +;; A profile gives one StarIntel deployment a stable identity used in +;; `star://' URIs, so references never resolve an ID against the wrong +;; server. Activating a profile configures the transport layer base +;; URL and credential resolution and clears the capability cache. +;; +;; Secrets never live in plain configuration: prefer :auth-source, +;; either a host string (user defaults to \"api\") or a plist +;; (:host HOST :user USER). Credentials resolve per request through +;; `auth-source-search' against your authinfo file -- put the entry in +;; ~/.authinfo.gpg (encrypted) rather than plain ~/.authinfo: +;; +;; machine starintel-remote login api password star_sk_v1_... +;; +;; A session :token is accepted for transient use and lives only in +;; memory; it is never persisted through Customize. Error text and +;; messages are redacted against the token by the API layer. + +;;; Code: + +(require 'cl-lib) +(require 'client) + +(defgroup starintel-server nil + "StarIntel server profiles." + :group 'starintel + :prefix "starintel-server-") + +(defvar starintel-server-current-name nil + "Name (symbol) of the active server profile, or nil. +When nil the un-profiled API layer settings are in effect and URIs +use the synthetic server name \"default\".") + +(defcustom starintel-servers nil + "Named StarIntel server profiles. +An alist of (NAME . PLIST). PLIST keys: + :url base URL of the deployment (required) + :token session bearer token (transient; prefer :auth-source) + :auth-source plist (:host HOST :user USER) for `auth-source-search' +Example: + (setq starintel-servers + (quote ((local :url \"http://127.0.0.1:5000\") + (remote :url \"https://si.example.com\" + :auth-source (:host \"starintel-remote\" :user \"api\")))))" + :type '(repeat (cons symbol plist)) + :group 'starintel-server) + +(defun starintel-server-profile-names () + "Return the profile names, as symbols, in `starintel-servers'." + (mapcar #'car starintel-servers)) + +(defun starintel-server--spec (name) + "Return the cons spec for profile NAME, or signal a user error." + (or (assq name starintel-servers) + (user-error "StarIntel: no server profile named `%s'" name))) + +(defun starintel-server--plist (name) + "Return the plist of profile NAME." + (cdr (starintel-server--spec name))) + +(defun starintel-server--auth-source-spec (spec) + "Normalize an :auth-source SPEC to (HOST . USER). +SPEC is either a host string or a plist (:host HOST :user USER). +The user defaults to \"api\"." + (cond + ((stringp spec) (cons spec "api")) + ((listp spec) + (cons (plist-get spec :host) (or (plist-get spec :user) "api"))) + (t nil))) + +(defun starintel-server--activate-token (name) + "Configure credential resolution for profile NAME. +Tokens from :auth-source are resolved per request through +`auth-source-search' against the user's authinfo file (prefer +~/.authinfo.gpg); the secret is never cached in configuration or +written to disk. A plain :token value is kept in the session +variable only and never persisted through Customize." + (let ((plist (starintel-server--plist name))) + (if (plist-get plist :auth-source) + (let* ((spec (starintel-server--auth-source-spec + (plist-get plist :auth-source))) + (host (car spec)) + (user (cdr spec))) + (setq starintel-api-token nil) + (setq starintel-api-token-function + (lambda () + (let ((entry (car (ignore-errors + (auth-source-search + :max 1 :host host :user user + :require '(:secret)))))) + (when entry + (let ((secret (plist-get entry :secret))) + (cond + ((functionp secret) (funcall secret)) + (secret secret)))))))) + (setq starintel-api-token-function nil) + (setq starintel-api-token (plist-get plist :token))))) + +(defun starintel-server-activate (name) + "Activate the server profile NAME. +Sets the API layer base URL and credential resolution, clears the +capability cache, and records NAME as the current server. Returns +NAME." + (let ((url (plist-get (starintel-server--plist name) :url))) + (unless url + (user-error "StarIntel: profile `%s' has no :url" name)) + (setq starintel-api-base-url url) + (starintel-server--activate-token name) + (starintel-api-clear-capabilities) + (setq starintel-server-current-name name) + name)) + +(defun starintel-server-uri-name () + "Return the server identity used in `star://' URIs. +The active profile name when one is active, otherwise \"default\"." + (if starintel-server-current-name + (symbol-name starintel-server-current-name) + "default")) + +;;;###autoload +(defun starintel-server-switch (&optional name) + "Switch to the StarIntel server profile NAME. +Uses completion when called interactively. Activates the profile and +shows the status buffer." + (interactive) + (let* ((names (mapcar #'symbol-name (starintel-server-profile-names))) + (choice (or name + (completing-read "StarIntel server: " names nil t)))) + (starintel-server-activate (intern choice)) + (starintel-status))) + +;;;###autoload +(defun starintel-server-status () + "Show the status of the current StarIntel server." + (interactive) + (starintel-status)) + +(provide 'starintel-server) +;;; starintel-server.el ends here diff --git a/starintel-ui.el b/starintel-ui.el new file mode 100644 index 00000000..99f634f0 --- /dev/null +++ b/starintel-ui.el @@ -0,0 +1,57 @@ +;;; starintel.el --- StarIntel OSINT workbench for Emacs -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 + +;; Author: nsaspy +;; Version: 2.0.0 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: tools, processes + +;; Commentary: + +;; The StarIntel workbench entry point: `M-x starintel'. +;; +;; This is the cockpit command set over the authoritative StarIntel +;; server. Modules: +;; +;; `starintel-server' - named server profiles and identity +;; `starintel-uri' - star:// object URIs +;; `starintel-object' - generic object buffers +;; `starintel-search' - actionable search results +;; +;; When `transient' is available (bundled with Emacs 28+) the entry +;; command opens a discoverable menu; otherwise it opens the server +;; status buffer. Workbench areas that the connected deployment does +;; not provide render as clearly unavailable rather than crashing. + +;;; Code: + +(require 'client) +(require 'starintel-server) +(require 'starintel-uri) +(require 'starintel-object) +(require 'starintel-search) + +(declare-function transient-define-prefix "ext:transient" (&rest _args)) + +(defun starintel () + "Open the StarIntel OSINT workbench." + (interactive) + (if (and (featurep 'transient) (fboundp 'starintel-workbench)) + (funcall 'starintel-workbench) + (starintel-status))) + +(when (featurep 'transient) + (transient-define-prefix starintel-workbench () + "StarIntel OSINT workbench menu." + [["Search and objects" + ("s" "Search" starintel-search-open) + ("d" "Open document by ID" starintel-document) + ("u" "Open star:// URI" starintel-uri-open)] + ["Server" + ("S" "Server status" starintel-status) + ("P" "Switch server profile" starintel-server-switch) + ("R" "Refresh capabilities" starintel-api-clear-capabilities)]])) + +(provide 'starintel) +;;; starintel.el ends here diff --git a/starintel-uri.el b/starintel-uri.el new file mode 100644 index 00000000..c36d4ada --- /dev/null +++ b/starintel-uri.el @@ -0,0 +1,140 @@ +;;; starintel-uri.el --- star:// URIs for StarIntel objects -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 + +;; Author: nsaspy +;; Version: 2.0.0 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: tools, processes + +;; Commentary: + +;; The `star://' URI scheme gives every remote StarIntel object a +;; stable, copyable identity: +;; +;; star://SERVER/KIND/ID +;; +;; SERVER is a `starintel-servers' profile name (empty means the +;; current server). KIND is an open vocabulary (document, person, +;; org, target, relation, search, ...). ID is the remainder of the +;; URI; IDs may themselves contain slashes, and space and percent +;; characters are percent-encoded for round-tripping. +;; +;; URIs open through `starintel-uri-open', which dispatches on KIND +;; and is also registered as the follower of the Org \"star\" link +;; when Org is available. Handlers can be extended by adding to +;; `starintel-uri-kind-handlers'. + +;;; Code: + +(require 'url-util) +(require 'cl-lib) +(require 'client) +(require 'starintel-server) + +(declare-function org-link-set-parameters "org" (type &rest parameters)) +(declare-function org-link-store-props "org" (&rest properties)) +(declare-function starintel-object-open "starintel-object" (kind id)) + +;; Declared in starintel-object.el; resolved when the Org completion +;; handler loads that module. +(defvar starintel-object-recent-uris) + +(defgroup starintel-uri nil + "star:// URIs for StarIntel objects." + :group 'starintel + :prefix "starintel-uri-") + +(defconst starintel-uri-scheme "star" + "URI scheme for StarIntel object references.") + +(defvar starintel-uri-kind-handlers nil + "Alist of (KIND . FUNCTION) for opening star:// URIs. +FUNCTION receives (SERVER KIND ID); the default handler fetches the +document with ID and renders a generic object buffer. Register +handlers for kinds that need specialized behavior, such as search.") + +(defun starintel-uri-parse (uri) + "Parse a star:// URI into a plist (:server :kind :id). +SERVER is nil when the URI omits it. Returns nil when URI is not a +parseable star URI. Accepts the Org link path form (\"//S/K/I\")." + (when (stringp uri) + (when (string-prefix-p "//" uri) + (setq uri (concat starintel-uri-scheme ":" uri))) + (when (string-match + (concat "\\`" starintel-uri-scheme + "://\\([^/]*\\)/\\([^/]+\\)\\(?:/\\(.*\\)\\)?\\'") uri) + (list :server (let ((name (match-string 1 uri))) + (and (not (string= name "")) name)) + :kind (downcase (match-string 2 uri)) + :id (url-unhex-string (or (match-string 3 uri) "")))))) + +(defun starintel-uri--encode (value) + "Encode VALUE for safe embedding in a star:// URI. +Percent characters are encoded first so the space encoding survives." + (replace-regexp-in-string + " " "%20" + (replace-regexp-in-string "%" "%25" (format "%s" value)))) + +(defun starintel-uri-format (server kind id) + "Format a star:// URI. +SERVER is a profile name or nil for the current server; KIND and ID +form the object path." + (concat starintel-uri-scheme + "://" + (or server (starintel-server-uri-name)) + "/" (format "%s" kind) + "/" (starintel-uri--encode id))) + +;;;###autoload +(defun starintel-uri-open (uri) + "Open the StarIntel object referenced by URI. +Dispatches on the URI kind. When the URI names a server profile +other than the active one, that profile is activated first." + (interactive "sOpen star:// URI: ") + (let ((parsed (starintel-uri-parse uri))) + (unless parsed + (user-error "StarIntel: %S is not a %s:// URI" uri starintel-uri-scheme)) + (let* ((server (plist-get parsed :server)) + (kind (plist-get parsed :kind)) + (id (plist-get parsed :id))) + (when (and server + (not (string= server (starintel-server-uri-name)))) + (starintel-server-activate (intern server))) + (let ((handler (or (cdr (assoc-string kind starintel-uri-kind-handlers)) + #'starintel-uri--open-document))) + (funcall handler server kind id))))) + +(defun starintel-uri--open-document (_server kind id) + "Default star:// handler: fetch the document with ID. +KIND only labels the expectation; the rendered object uses the dtype +the server returns. Kinds without an ID cannot name a document." + (if (or (null id) (string= id "")) + (user-error "StarIntel: cannot open star:// URI kind `%s' with no id" kind) + (starintel-object-open kind id))) + +;; Org integration: the "star" link type follows and stores URIs. +(with-eval-after-load 'org + (org-link-set-parameters + starintel-uri-scheme + :follow #'starintel-uri-open + :store #'starintel-uri--org-store + :complete #'starintel-uri--org-complete)) + +(defun starintel-uri--org-store () + "Store the current object as an Org \"star\" link, when applicable." + (let ((uri (get-text-property (point) 'starintel-uri))) + (when uri + (org-link-store-props :type starintel-uri-scheme :link uri)))) + +(defun starintel-uri--org-complete () + "Complete a star:// URI from the recent-objects history." + (require 'starintel-object) + (let ((uri (completing-read + "StarIntel object URI: " + (delete-dups (append starintel-object-recent-uris nil)) + nil nil))) + (concat starintel-uri-scheme ":" uri))) + +(provide 'starintel-uri) +;;; starintel-uri.el ends here diff --git a/starintel-workbench-test.el b/starintel-workbench-test.el new file mode 100644 index 00000000..de3c5c6e --- /dev/null +++ b/starintel-workbench-test.el @@ -0,0 +1,593 @@ +;;; starintel-workbench-test.el --- Hermetic ERT tests for the OSINT workbench -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 + +;; Author: nsaspy +;; Version: 2.0.0 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: tools, processes + +;; Commentary: + +;; Hermetic ERT tests for the StarIntel workbench foundation: +;; server profiles, star:// URIs, the generic object buffer, and the +;; search results buffer. All tests run against fake HTTP transports; +;; no live StarIntel server is required. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'json) + +(let ((dir (file-name-directory (or load-file-name buffer-file-name)))) + (add-to-list 'load-path dir)) + +(require 'client) +(require 'starintel-server) +(require 'starintel-uri) +(require 'starintel-object) +(require 'starintel-search) +(require 'starintel-ui) + +;;; ------------------------------------------------------------------ +;;; Fake transport harness (same contract as client-test.el) +;;; ------------------------------------------------------------------ + +(defvar starintel-wb-test--requests nil) +(defvar starintel-wb-test--responses nil) +(defvar starintel-wb-test--deferred nil) + +(defun starintel-wb-test-fake-transport (_method url headers _body timeout-ms callback) + "Synchronous fake transport recording every call." + (push (list :method 'req :url url :headers headers :timeout-ms timeout-ms) + starintel-wb-test--requests) + (funcall callback + (if (null starintel-wb-test--responses) + (error "fake transport: unexpected request to %s" url) + (pop starintel-wb-test--responses)))) + +(defun starintel-wb-test-deferred-transport (_method url _headers _body _timeout-ms callback) + "Fake async transport parking CALLBACK until released." + (setq starintel-wb-test--deferred + (append starintel-wb-test--deferred + (list (list :url url :callback callback))))) + +(defun starintel-wb-test--release (response) + "Complete the oldest deferred request with RESPONSE." + (let* ((entry (car starintel-wb-test--deferred))) + (setq starintel-wb-test--deferred (cdr starintel-wb-test--deferred)) + (funcall (plist-get entry :callback) response) + entry)) + +(defun starintel-wb-test-last-request () + (car starintel-wb-test--requests)) + +(defun starintel-wb-test--ok (body) + "Build an HTTP 200 response plist with JSON BODY." + (list :status 200 :headers '() :body (json-encode body))) + +(defconst starintel-wb-test--capabilities + `((status . "ok") + (data . ((build . ((service . "starintel-gserver") (version . "0.9.4"))) + (schema_revisions . ((api . "v1") (document . "0.9.0"))) + (authentication . ((modes . ["api-key"]))) + (features . ((documents . t) (search . t) (stats . t) + (target_leases . :json-false) (streams . :json-false))) + (endpoints . ,(vconcat + (list + `((id . "public_search") (method . "GET") + (path . "/api/v1/search") (legacy . :json-false) + (authority . "public")) + `((id . "document_read") (method . "GET") + (path . "/document/:id") (legacy . t) + (authority . "authenticated")) + `((id . "stats") (method . "GET") + (path . "/api/v1/stats") (legacy . :json-false) + (authority . "public"))))) + (compatibility . ((legacy_routes . t))))))) + +(defconst starintel-wb-test--person-doc + '((dtype . "person") + (schema_version . "0.9.0") + (_id . "01JPERSON0000000000000000") + (_rev . "1-abc") + (dataset . "investigation-a") + (sources . ["manual"]) + (version . 1) + (dateAdded . 1735689600) + (data . ((fname . "Ada") (lname . "Lovelace") (bio . "first programmer"))) + (extensions . ((star_server . ((trace_id . "01JTRACE"))))))) + +(defconst starintel-wb-test--relation-doc + '((dtype . "relation") + (_id . "01JRELATION000000000000000") + (dataset . "investigation-a") + (data . ((source . "01JPERSON0000000000000000") + (target . "01JORG000000000000000000000") + (predicate . "employed-by") + (note . "public filings"))))) + +(defconst starintel-wb-test--search-response + '((status . "ok") + (rows . [((id . "01JPERSON0000000000000000") + (doc . ((dtype . "person") + (_id . "01JPERSON0000000000000000") + (dataset . "investigation-a") + (dateAdded . 1735689600) + (data . ((fname . "Ada") (lname . "Lovelace")))))) + ((id . "01JDOMAIN000000000000000000") + (doc . ((dtype . "domain") + (_id . "01JDOMAIN000000000000000000") + (dataset . "investigation-a") + (data . ((record . "example.com"))))))]) + (bookmark . "g1AAAABbe"))) + +(defmacro starintel-wb-test-with-client (&rest body) + "Run BODY against a synchronous fake transport and clean state." + `(let ((starintel-wb-test--requests nil) + (starintel-wb-test--responses nil) + (starintel-wb-test--deferred nil) + (starintel-api-base-url "http://starintel.test:5000") + (starintel-api-token "star_sk_v1_secret-token-1234") + (starintel-api-transport-function #'starintel-wb-test-fake-transport) + (starintel-api--capabilities-cache nil) + (starintel-server-current-name nil) + (starintel-servers nil)) + ,@body)) + +(defmacro starintel-wb-test-with-deferred (&rest body) + "Run BODY against a deferred fake transport." + `(let ((starintel-wb-test--requests nil) + (starintel-wb-test--responses nil) + (starintel-wb-test--deferred nil) + (starintel-api-base-url "http://starintel.test:5000") + (starintel-api-token "star_sk_v1_secret-token-1234") + (starintel-api-transport-function #'starintel-wb-test-deferred-transport) + (starintel-api--capabilities-cache nil) + (starintel-server-current-name nil) + (starintel-servers nil)) + ,@body)) + +;;; ------------------------------------------------------------------ +;;; star:// URI model +;;; ------------------------------------------------------------------ + +(ert-deftest starintel-uri-parse-full () + (let ((parsed (starintel-uri-parse "star://local/document/01JABC"))) + (should (equal "local" (plist-get parsed :server))) + (should (equal "document" (plist-get parsed :kind))) + (should (equal "01JABC" (plist-get parsed :id))))) + +(ert-deftest starintel-uri-parse-empty-server () + (let ((parsed (starintel-uri-parse "star:///person/01JABC"))) + (should (equal nil (plist-get parsed :server))) + (should (equal "person" (plist-get parsed :kind))) + (should (equal "01JABC" (plist-get parsed :id))))) + +(ert-deftest starintel-uri-parse-slash-containing-id () + (let ((parsed (starintel-uri-parse "star://remote/search/alice example.com/x"))) + (should (equal "remote" (plist-get parsed :server))) + (should (equal "search" (plist-get parsed :kind))) + (should (equal "alice example.com/x" (plist-get parsed :id))))) + +(ert-deftest starintel-uri-parse-percent-decodes-id () + (let ((parsed (starintel-uri-parse "star://local/search/alice%20smith"))) + (should (equal "alice smith" (plist-get parsed :id))))) + +(ert-deftest starintel-uri-parse-rejects-garbage () + (should (null (starintel-uri-parse "http://example.com/thing"))) + (should (null (starintel-uri-parse "star://"))) + (should (null (starintel-uri-parse "star://just-a-server")))) + +(ert-deftest starintel-uri-format-round-trip () + (let* ((uri (starintel-uri-format "local" "search" "ada lovelace 100%")) + (parsed (starintel-uri-parse uri))) + (should (equal "local" (plist-get parsed :server))) + (should (equal "search" (plist-get parsed :kind))) + (should (equal "ada lovelace 100%" (plist-get parsed :id))))) + +(ert-deftest starintel-uri-format-keeps-plain-ids-readable () + (should (equal "star://local/document/01JABC" + (starintel-uri-format "local" "document" "01JABC")))) + +(ert-deftest starintel-uri-format-default-server () + (let ((starintel-server-current-name 'remote)) + (should (equal "star://remote/document/01JABC" + (starintel-uri-format nil "document" "01JABC"))))) + +;;; ------------------------------------------------------------------ +;;; Server profiles +;;; ------------------------------------------------------------------ + +(ert-deftest starintel-server-profile-names () + (let ((starintel-servers '((local :url "http://127.0.0.1:5000") + (remote :url "https://si.example.com")))) + (should (equal '(local remote) + (starintel-server-profile-names))))) + +(ert-deftest starintel-server-activate-sets-url-and-name () + (let ((starintel-servers '((remote :url "https://si.example.com")))) + (starintel-server-activate 'remote) + (should (eq 'remote starintel-server-current-name)) + (should (equal "https://si.example.com" starintel-api-base-url)))) + +(ert-deftest starintel-server-activate-clears-capability-cache () + (let ((starintel-servers '((remote :url "https://si.example.com"))) + (starintel-api--capabilities-cache '((stale . t)))) + (starintel-server-activate 'remote) + (should (null starintel-api--capabilities-cache)))) + +(ert-deftest starintel-server-activate-with-token () + (let ((starintel-servers '((remote :url "https://si.example.com" + :token "star_sk_v1_remote")))) + (starintel-server-activate 'remote) + (should (equal "star_sk_v1_remote" starintel-api-token)))) + +(ert-deftest starintel-server-activate-with-auth-source () + (let* ((starintel-servers '((remote :url "https://si.example.com" + :auth-source (:host "starintel-remote" + :user "api")))) + (lookups nil)) + (cl-letf (((symbol-function 'auth-source-search) + (lambda (&rest keys) + (push keys lookups) + (list (list :secret (lambda () "star_sk_v1_from-auth-source")))))) + (starintel-server-activate 'remote) + (should (null starintel-api-token)) + (should (functionp starintel-api-token-function)) + (should (equal "star_sk_v1_from-auth-source" + (funcall starintel-api-token-function))) + (should (equal "starintel-remote" (plist-get (car lookups) :host))) + (should (equal "api" (plist-get (car lookups) :user)))))) + +(ert-deftest starintel-server-activate-with-auth-source-shorthand () + (let* ((starintel-servers '((remote :url "https://si.example.com" + :auth-source "starintel-remote"))) + (lookups nil)) + (cl-letf (((symbol-function 'auth-source-search) + (lambda (&rest keys) + (push keys lookups) + (list (list :secret (lambda () "star_sk_v1_shorthand")))))) + (starintel-server-activate 'remote) + (should (null starintel-api-token)) + (should (equal "star_sk_v1_shorthand" + (funcall starintel-api-token-function))) + (should (equal "starintel-remote" (plist-get (car lookups) :host))) + (should (equal "api" (plist-get (car lookups) :user)))))) + +(ert-deftest starintel-server-activate-unknown-profile-signals () + (should-error (starintel-server-activate 'does-not-exist))) + +(ert-deftest starintel-server-uri-name () + (let ((starintel-server-current-name nil)) + (should (equal "default" (starintel-server-uri-name)))) + (let ((starintel-server-current-name 'remote)) + (should (equal "remote" (starintel-server-uri-name))))) + +(ert-deftest starintel-server-switch-completes-and-activates () + (let ((starintel-servers '((local :url "http://127.0.0.1:5000") + (remote :url "https://si.example.com")))) + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _) "remote")) + ((symbol-function 'starintel-status) (lambda (&rest _) 'status-called))) + (should (eq 'status-called (starintel-server-switch))) + (should (eq 'remote starintel-server-current-name))))) + +;;; ------------------------------------------------------------------ +;;; Object identity and titles +;;; ------------------------------------------------------------------ + +(ert-deftest starintel-object-from-doc () + (let ((obj (starintel-object-from-doc starintel-wb-test--person-doc "local"))) + (should (equal "01JPERSON0000000000000000" (starintel-object-id obj))) + (should (equal "person" (starintel-object-dtype obj))) + (should (equal "investigation-a" (starintel-object-dataset obj))) + (should (equal "local" (starintel-object-server obj))) + (should (equal "star://local/person/01JPERSON0000000000000000" + (starintel-object-uri obj))))) + +(ert-deftest starintel-object-from-doc-default-server () + (let ((starintel-server-current-name 'workbench)) + (should (equal "workbench" + (starintel-object-server + (starintel-object-from-doc starintel-wb-test--person-doc)))))) + +(ert-deftest starintel-object-title-person () + (should (equal "Ada Lovelace" + (starintel-object-title + (starintel-object-from-doc starintel-wb-test--person-doc))))) + +(ert-deftest starintel-object-title-org () + (should (equal "Example Corp" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "org") (_id . "A") + (data . ((name . "Example Corp"))))))))) + +(ert-deftest starintel-object-title-domain () + (should (equal "example.com" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "domain") (_id . "A") + (data . ((record . "example.com"))))))))) + +(ert-deftest starintel-object-title-host () + (should (equal "host01.example.com (10.0.0.1)" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "host") (_id . "A") + (data . ((hostname . "host01.example.com") + (ip . "10.0.0.1"))))))))) + +(ert-deftest starintel-object-title-url () + (should (equal "https://example.com/a" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "url") (_id . "A") + (data . ((url . "https://example.com/a"))))))))) + +(ert-deftest starintel-object-title-user () + (should (equal "@ada (mastodon)" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "user") (_id . "A") + (data . ((name . "ada") (platform . "mastodon"))))))))) + +(ert-deftest starintel-object-title-email () + (should (equal "ada@example.com" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "email") (_id . "A") + (data . ((user . "ada") (domain . "example.com"))))))))) + +(ert-deftest starintel-object-title-relation () + (should (equal "01JPERSON0000000000000000 -employed-by-> 01JORG000000000000000000000" + (starintel-object-title + (starintel-object-from-doc starintel-wb-test--relation-doc))))) + +(ert-deftest starintel-object-title-target () + (should (equal "example.com @ httpx" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "target") (_id . "A") + (data . ((target . "example.com") (actor . "httpx"))))))))) + +(ert-deftest starintel-object-title-fallback () + (should (equal "blob 01JSHORT" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "blob") (_id . "01JSHORTORLONGER"))))))) + +(ert-deftest starintel-object-title-legacy-flat-fields () + (should (equal "flat.example.com" + (starintel-object-title + (starintel-object-from-doc + '((dtype . "domain") (_id . "A") (record . "flat.example.com"))))))) + +;;; ------------------------------------------------------------------ +;;; star:// dispatch +;;; ------------------------------------------------------------------ + +(ert-deftest starintel-uri-open-document-fetches-and-renders () + (starintel-wb-test-with-deferred + (starintel-uri-open "star://default/document/01JPERSON0000000000000000") + (let ((entry (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--capabilities)))) + (should (string-match-p "/api/v1/capabilities" (plist-get entry :url)))) + (let ((entry (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--person-doc)))) + (should (string-match-p "/document/01JPERSON0000000000000000" + (plist-get entry :url)))) + (let ((buffer (get-buffer "*StarIntel: Ada Lovelace*"))) + (should buffer) + (with-current-buffer buffer + (should (derived-mode-p 'starintel-object-mode)) + (should (string-match-p "01JPERSON0000000000000000" (buffer-string))) + (should (string-match-p "star://default/person/01JPERSON0000000000000000" + (buffer-string))))))) + +(ert-deftest starintel-uri-open-search-runs-search () + (starintel-wb-test-with-client + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok starintel-wb-test--capabilities) + (starintel-wb-test--ok starintel-wb-test--search-response))) + (starintel-uri-open "star:///search/ada") + (should (string-match-p "/api/v1/search" + (plist-get (starintel-wb-test-last-request) :url))) + (let ((buffer (get-buffer "*StarIntel Search*"))) + (should buffer) + (with-current-buffer buffer + (should (derived-mode-p 'starintel-search-mode)) + (should (= 2 (length starintel-search--docs))))))) + +(ert-deftest starintel-uri-open-unknown-kind-signals () + (starintel-wb-test-with-client + (let ((msg (condition-case err + (progn (starintel-uri-open "star:///vobject") nil) + (user-error (starintel-api-error-message err))))) + (should (string-match-p "cannot open" msg)) + (should (string-match-p "vobject" msg)) + (should (string-match-p "with no id" msg))))) + +(ert-deftest starintel-uri-open-other-server-switches-profile () + (starintel-wb-test-with-deferred + (let ((starintel-servers '((remote :url "https://remote.test:5000")))) + (starintel-uri-open "star://remote/document/01JPERSON0000000000000000") + (should (eq 'remote starintel-server-current-name)) + (should (equal "https://remote.test:5000" starintel-api-base-url)) + (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--capabilities)) + (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--person-doc))))) + +;;; ------------------------------------------------------------------ +;;; Generic object buffer +;;; ------------------------------------------------------------------ + +(ert-deftest starintel-object-open-renders-typed-fields () + (starintel-wb-test-with-deferred + (starintel-object-open "person" "01JPERSON0000000000000000") + (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--capabilities)) + (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--person-doc)) + (with-current-buffer "*StarIntel: Ada Lovelace*" + (let ((text (buffer-string))) + (should (string-match-p "dtype +person" text)) + (should (string-match-p "dataset +investigation-a" text)) + (should (string-match-p "fname +Ada" text)) + (should (string-match-p "lname +Lovelace" text)) + (should (string-match-p "Provenance" text)) + (should (string-match-p "trace_id +01JTRACE" text)))))) + +(ert-deftest starintel-object-copy-id () + (starintel-wb-test-with-client + (let ((obj (starintel-object-from-doc starintel-wb-test--person-doc))) + (with-current-buffer (starintel-object--buffer obj) + (starintel-object-copy-id) + (should (equal "01JPERSON0000000000000000" (car kill-ring))))))) + +(ert-deftest starintel-object-copy-uri () + (starintel-wb-test-with-client + (let ((obj (starintel-object-from-doc starintel-wb-test--person-doc))) + (with-current-buffer (starintel-object--buffer obj) + (starintel-object-copy-uri) + (should (equal "star://default/person/01JPERSON0000000000000000" + (car kill-ring))))))) + +(ert-deftest starintel-object-refresh-refetches () + (starintel-wb-test-with-deferred + (starintel-object-open "person" "01JPERSON0000000000000000") + (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--capabilities)) + (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--person-doc)) + (let ((buffer (get-buffer "*StarIntel: Ada Lovelace*"))) + (should buffer) + (with-current-buffer buffer + (let ((requests-before (length starintel-wb-test--deferred))) + (starintel-object-refresh) + ;; Capabilities are already cached: refresh issues exactly one + ;; document fetch. + (should (= (1+ requests-before) (length starintel-wb-test--deferred))) + (starintel-wb-test--release + (starintel-wb-test--ok starintel-wb-test--person-doc))))))) + +(ert-deftest starintel-object-unavailable-capability-is-clean-error () + (starintel-wb-test-with-client + ;; Capabilities advertise no document_read and no legacy compatibility. + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok + '((status . "ok") + (data . ((features . ((documents . t))) + (endpoints . []) + (compatibility . ((legacy_routes . :json-false))))))))) + (let ((err (condition-case e + (progn (starintel-object-open "person" "01JX") nil) + (user-error e)))) + (should err) + (should (string-match-p "not advertised" + (starintel-api-error-message err)))))) + +;;; ------------------------------------------------------------------ +;;; Search results buffer +;;; ------------------------------------------------------------------ + +(ert-deftest starintel-search-open-renders-tabulated-results () + (starintel-wb-test-with-client + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok starintel-wb-test--capabilities) + (starintel-wb-test--ok starintel-wb-test--search-response))) + (starintel-search-open "ada") + (let ((buffer (get-buffer "*StarIntel Search*"))) + (should buffer) + (with-current-buffer buffer + (should (derived-mode-p 'starintel-search-mode)) + (should (equal "ada" starintel-search--query)) + (should (= 2 (length starintel-search--docs))) + (should (equal "g1AAAABbe" starintel-search--bookmark)) + (goto-char (point-min)) + (search-forward "01JPERSON0000000000000000") + (should (equal "01JPERSON0000000000000000" + (aref (tabulated-list-get-entry) 4))))))) + +(ert-deftest starintel-search-sends-bookmark-on-next-page () + (starintel-wb-test-with-client + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok starintel-wb-test--capabilities) + (starintel-wb-test--ok starintel-wb-test--search-response))) + (starintel-search-open "ada") + (with-current-buffer "*StarIntel Search*" + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok starintel-wb-test--search-response))) + (starintel-search-next-page)) + (let ((url (plist-get (starintel-wb-test-last-request) :url))) + (should (string-match-p "bookmark=g1AAAABbe" url))))) + +(ert-deftest starintel-search-open-entry-opens-object () + (starintel-wb-test-with-deferred + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok starintel-wb-test--capabilities) + (starintel-wb-test--ok starintel-wb-test--search-response))) + (starintel-search-open "ada") + (let ((buffer (get-buffer "*StarIntel Search*"))) + (with-current-buffer buffer + (goto-char (point-min)) + (search-forward "01JPERSON0000000000000000") + (let ((object (starintel-search--entry-object))) + (should (equal "person" (starintel-object-dtype object))) + (should (equal "01JPERSON0000000000000000" (starintel-object-id object)))))))) + +(ert-deftest starintel-search-copy-uri () + (starintel-wb-test-with-client + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok starintel-wb-test--capabilities) + (starintel-wb-test--ok starintel-wb-test--search-response))) + (starintel-search-open "ada") + (with-current-buffer "*StarIntel Search*" + (goto-char (point-min)) + (search-forward "01JPERSON0000000000000000") + (starintel-search-copy-uri) + (should (equal "star://default/person/01JPERSON0000000000000000" + (car kill-ring)))))) + +(ert-deftest starintel-search-marks () + (starintel-wb-test-with-client + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok starintel-wb-test--capabilities) + (starintel-wb-test--ok starintel-wb-test--search-response))) + (starintel-search-open "ada") + (with-current-buffer "*StarIntel Search*" + (goto-char (point-min)) + (search-forward "01JPERSON0000000000000000") + (starintel-search-toggle-mark) + (should (assoc "01JPERSON0000000000000000" starintel-search--marked))))) + +(ert-deftest starintel-search-unavailable-capability-is-clean-error () + (starintel-wb-test-with-client + (setq starintel-wb-test--responses + (list (starintel-wb-test--ok + '((status . "ok") + (data . ((features . ((documents . t))) + (endpoints . []) + (compatibility . ((legacy_routes . :json-false))))))))) + (let ((err (condition-case e + (progn (starintel-search-open "ada") nil) + (user-error e)))) + (should err) + (should (string-match-p "not advertised" + (starintel-api-error-message err)))))) + +;;; ------------------------------------------------------------------ +;;; Workbench entry point +;;; ------------------------------------------------------------------ + +(ert-deftest starintel-entry-opens-status () + (starintel-wb-test-with-client + (cl-letf (((symbol-function 'starintel-status) + (lambda (&rest _) 'status-shown))) + (should (eq 'status-shown (starintel)))))) + +(provide 'starintel-workbench-test) +;;; starintel-workbench-test.el ends here