Skip to content

fix: save ASK Chat chats and artifacts per user on the server - #91

Merged
alberthflores95 merged 8 commits into
mainfrom
fix/persist-chats-artifacts
Oct 27, 2026
Merged

alberthflores95 merged 8 commits into
mainfrom
fix/persist-chats-artifacts

Conversation

@alberthflores95

Copy link
Copy Markdown
Collaborator

Problem

ASK Chat kept chats and artifacts in the browser's localStorage:

  • an artifact or chat made in Chrome did not exist in Edge for the same user;
  • the storage keys carried no user and signing out did not clear them, so the next person to sign in on the same browser saw the previous user's chats and artifacts, result rows included.

Change

The browser still builds chats and artifacts as before; the orchestrator now keeps them per user.

  • Store (ask_orchestrator/user_store.py): OpenSearch indices ask-chats-v1 and ask-artifacts-v1, one document per item, owned by <issuer>:<sub> of the validated token. Messages, documents and rows are Fernet-encrypted with ONIBEX_ENCRYPTION_KEY in a payload field mapped enabled: false (result columns never become index fields); the mapping is dynamic: strict. Only what lists need stays in cleartext.
  • Routes (routers/saved_items.py): GET/PUT/DELETE /v1/chats[/{id}], GET/PUT /v1/artifacts[/{id}]. Someone else's id answers 404 like a missing one, PUT included. Token without sub: 401. Payload over 20 MB: 413.
  • Key rotation: scripts/rotate_master_key.py re-encrypts both indices.
  • ask-chat: the artifact gallery and the chat store load from and save to these routes. Chats are saved after each exchange and after the title, never while the answer is revealed; saves and deletes of one chat run in a queue. An empty "New Chat" is never sent. persist keeps only browser preferences (version 1; its migrate drops the chats version 0 stored). The old onibex_artifacts_v1 key is cleared.
  • Docs: chat, artifacts, installation and secret-scanning pages.

/v1/query and /v1/artifact are unchanged. Nothing is migrated: existing chats and artifacts in browsers are dropped (agreed).

Out of scope

Regenerate still uses the selector's env/workspace (as before), no artifact delete, no sharing between users.

Tests

  • tests/unit/test_saved_items.py: 25 tests (owner isolation, encryption at rest, lists without payload, timestamps, size limit, unreadable payload, key rotation). Checked against mutants without the owner filter and without encryption: they fail.
  • ask-chat has no test runner: typecheck, lint and build pass; the real chat store was exercised against a fake server (ordering of saves, delete during an in-flight save, migration from version 0).
  • Browser verification (Chrome and Edge, two users) on the GONAC PoC server is pending.

🤖 Generated with Claude Code

alberthflores95 and others added 7 commits October 26, 2026 11:00
ASK Chat kept chats and artifacts in the browser's localStorage. A user
who made one in Chrome could not see it in Edge, and because the storage
key carried no user and signing out did not clear it, the next person to
sign in on the same browser saw the previous user's chats and artifacts,
result rows included.

Add a per-user store in OpenSearch (ask-chats-v1, ask-artifacts-v1) and
the routes the SPA will use instead of localStorage:

  GET/PUT/DELETE /v1/chats[/{id}]
  GET/PUT        /v1/artifacts[/{id}]

Every item is owned by <issuer>:<sub> of the validated token. An id that
belongs to someone else answers 404, like one that does not exist, PUT
included. Messages, documents and rows are Fernet-encrypted with
ONIBEX_ENCRYPTION_KEY in a payload field mapped enabled:false, so result
columns never become index fields; only what the lists need stays in
cleartext. /v1/query and /v1/artifact are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saved chats and artifacts are encrypted with ONIBEX_ENCRYPTION_KEY, so a
key change that skipped them would leave every one unreadable.
rotate_master_key.py now re-encrypts the payload of every document in
ask-chats-v1 and ask-artifacts-v1, leaving updated_at alone.

The script names the two indices instead of importing them, because it
also runs in the admin-api image, which does not install the
orchestrator; a test keeps both lists equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The artifact gallery read and wrote localStorage, so an artifact made in
one browser did not exist in another, and the next user on the same
browser saw the previous user's documents.

The gallery now lists the user's artifacts from GET /v1/artifacts, opens
one with GET /v1/artifacts/{id}, and saves every created, regenerated or
edited artifact with PUT /v1/artifacts/{id}. Generation is unchanged. A
failed save keeps the artifact open with a sticky toast that retries.
The old onibex_artifacts_v1 key is cleared; nothing is migrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chats lived in localStorage (zustand persist), so they did not exist in
another browser, and the next user on the same browser saw them.

The store now keeps chats in memory and syncs them with /v1/chats:
the list (titles only) loads once per page load, a chat's messages are
fetched when it is opened, and the chat is saved after each exchange
and after its title arrives, never while the answer is being revealed.
Saves and the delete of one chat run in a queue, so a slow save cannot
overwrite a later one. An empty "New Chat" is never sent.

persist keeps only the browser preferences; version 1 with a migrate
drops the chats version 0 stored. Nothing is migrated to the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The manual said artifacts are stored in the browser's localStorage and
are browser-specific. Chats and artifacts are now saved per user on the
server, visible from any browser the user signs in on and to nobody
else, and encrypted at rest. The ONIBEX_ENCRYPTION_KEY pages list them
among what the key decrypts and what a lost key makes unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generic-api-key fires on LEGACY_ARTIFACTS_KEY in ArtifactsPage.tsx: a
constant whose name ends in KEY holding a quoted identifier. The value
is the name of a localStorage key, the same one already recorded as a
false positive under its previous constant name. Nothing to rotate.

The line now carries `gitleaks:allow`, which travels with it; the
fingerprint in .gitleaksignore covers commit 52cbe47, which the pull
request scan reads as written, before the marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and env

The gallery listed every artifact the user had, so nothing said which
workspace or which environment an artifact belonged to, and dev (QAS)
and prod (PRD) hold different figures.

GET /v1/artifacts takes workspace_id and env, and the gallery asks for
the ones the sidebar selects, like the chat does per workspace. The
page names both under its title and in the viewer, reloads when either
changes and closes any open artifact. A late list or save from a
previous selection is dropped instead of shown. With no workspace the
gallery is empty and New artifact is disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was no way to delete an artifact, before or after artifacts moved
to the server; chats already had one.

DELETE /v1/artifacts/{id} removes it for its owner, with the same 404
for someone else's id as the other routes. The viewer has a Delete
button after Regenerate that asks for confirmation, removes the artifact
from the gallery and returns to it. A 404 counts as done: the artifact
was never saved, or was deleted from another browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alberthflores95 added a commit that referenced this pull request Oct 27, 2026
Brings the artifact delete of PR #91 into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alberthflores95
alberthflores95 merged commit 684ae21 into main Oct 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant