Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Direct + full-screen Dashboard viewing with a one-time cross-tab handoff**
(#288, Dashboard v1 Phase 6 — the final phase of #280; also lands #302). Two
explicit viewing modes on the standalone `/dashboard` route (ADR-0003): an
**edit mode** opened from the new Workbench-header **`Dashboard →`** control
(bookmarkable `?ws=&dash=` current-workspace route that verifies BOTH the
workspace and dashboard ids — showing *Dashboard unavailable* rather than
silently opening a different one — and shares the IndexedDB workspace store so
reorder/relayout persist), and a read-only **view mode** opened from the
Dashboard page's own **File → "Open for viewing…"** that snapshots the current
dashboard into a detached copy in a **new tab**. The cross-tab *state* handoff
uses a **one-time IndexedDB token** (unguessable 256-bit token; the validated
bundle is written before the tab opens, atomically consumed exactly once, and
the token stripped from the URL via `history.replaceState`) — `sessionStorage`
alone is insufficient. The consumed handoff **materializes** into a persistent
detached-views store under its own id, so a view tab survives relogin/reload
while staying detached from later Workbench edits. The read-only viewer path
builds no Workbench/editor modules (boundary-tested). Closes #153.
- **Portable open/import/export + a transactional import planner** (#287, Dashboard
v1 Phase 5). The File menu's ambiguous `Append` is gone, replaced by
resource-oriented **Import queries / Import Dashboard / Replace workspace** and
Expand All @@ -31,6 +48,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
Library-only JSON is written.

### Changed
- **Dashboard operations moved out of the Workbench File menu** (#302). The
Workbench File menu now owns workspace + query-collection operations only (New
workspace / Import queries / Replace workspace / Export workspace / Download
Markdown+SQL / variable history). Dashboard navigation moved to a `Dashboard →`
control next to the workspace name (shown only when a Dashboard exists; opens
the standalone route in a new tab), and **Import Dashboard / Export Dashboard**
moved to the standalone Dashboard page's own resource-scoped **File** menu
(which also hosts the new **Open for viewing…**). Dashboard import still runs
the transactional planner and commits atomically; portable-bundle, schema, and
persistence semantics are unchanged.
- **The `StoredWorkspaceV1` aggregate is now the single source of truth for the
saved-query collection** (#287). All query CRUD (create/edit/rename/delete/star)
commits the whole workspace through the atomic `WorkspaceRepository`
Expand Down
133 changes: 133 additions & 0 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# ADR-0003: Dashboard viewing — edit vs view modes, and the one-time cross-tab state handoff

- **Status:** Accepted — 2026-07-18 (#288 + #302, Dashboard v1 phase 6, the final
phase of epic #280). Implemented on branch `feat/dashboard-viewing-288`.
- **Date:** 2026-07-18
- **Context tracking:** roadmap #68 (Dashboard track); epic #280; closes #153.
- **Related:** #149 (original standalone-dashboard route + postMessage credential
handoff — its *state*-transport decision is superseded here), #284 (IndexedDB
`StoredWorkspaceV1` persistence, whose adapter pattern this reuses), #287
(portable bundle codecs + transactional import planner this builds on).

## Context

Phases 1–5 of #280 made the Dashboard a first-class module: an explicit
`StoredWorkspaceV1` aggregate persisted atomically to IndexedDB (#284), an
independent read-only `DashboardViewerSession` + `flow@1` layout (#286), and a
canonical `PortableBundleV1` with a transactional import planner (#287). Phase 6
(#288) is the viewing surface: how a Dashboard is *opened* — bookmarkably for the
current workspace, and in a detached read-only tab — without the read-only path
dragging in Workbench or editor construction. In parallel, #302 asked to move
Dashboard navigation and Dashboard-scoped file operations out of the (overloaded)
Workbench File menu and next to the Dashboard resource itself.

The original #288 spec framed the second open kind as a *temporary, one-time,
non-bookmarkable* "session-bundle" for full-screen preview and external-bundle
viewing. During planning the owner refined this into a cleaner, more durable
product model, which this ADR records.

## Decision

### Two explicit viewing modes

| | **Edit mode** | **View mode** |
|---|---|---|
| Entry | Workbench header `Dashboard →` control | Dashboard header **File → "Open for viewing…"** |
| Tab | new tab | new tab |
| Open source | `current-workspace` (`?ws=&dash=`) | detached snapshot of the current dashboard |
| Storage | **shared** primary workspace store (`asb-workspace`) | its **own** detached store (`asb-dashboard-views`), fresh id |
| Editable | yes — drag reorder + layout preset persist to the shared aggregate | **read-only** |
| Auth | existing postMessage credential handoff (#149) | same |
| Survives relogin / reload | yes (shared store) | yes (own persisted record) |

`Dashboard →` opens a **new tab** (an owner override of #302's "current tab"
wording), keeping the Workbench tab available and reusing the existing new-tab
credential handoff.

### The `DashboardOpenSource` contract

```ts
type DashboardOpenSource =
| { kind: 'current-workspace'; workspaceId: string; dashboardId: string }
| { kind: 'session-bundle'; token: string; dashboardId: string };
```

Encoded as **query params on `/dashboard`** — never path segments — so the
pathname stays exactly `/dashboard` and `isDashboardRoute`/`configBase`/the OAuth
`redirect_uri` (all keyed on the `/dashboard` suffix) are untouched, and the
params survive `bootstrap`'s OAuth-param strip automatically. `?ws=&dash=` is
current-workspace; `?st=&dash=` is session-bundle.

> **Critical constraint:** the session token param is **`st`**, never `state`.
> `bootstrap` (`src/main.ts`) reads `?state` as the OAuth CSRF value; a token
> there would raise "OAuth state mismatch".

**Mode is discriminated by which store the `ws` id resolves in**, not by a
spoofable URL flag: the id present in the primary workspace store → edit mode;
present in the detached views store → view mode; in neither → a not-found panel
that executes nothing (never silently opens a different dashboard).

### The one-time cross-tab state handoff

"Open for viewing…" hands the dashboard *state* (not credentials) to the new tab
through a one-time IndexedDB token, then materializes a durable detached copy:

1. Snapshot the current dashboard's dependency closure into a `PortableBundleV1`
(`buildDashboardExportBundle`).
2. Generate an unguessable 256-bit token (`crypto.getRandomValues`); write a
record `{ text: bundleJSON, dashboardId, detachedWorkspaceId, expiresAt }` to
a dedicated IndexedDB database (`asb-dashboard-handoff`) **before** opening the
tab.
3. `window.open('/dashboard?st=<token>&dash=<id>')` and grant the credential
handoff.
4. The new tab **atomically consumes + deletes** the record in one readwrite
transaction (`take` = get+delete, then reject if expired), and strips `st`
from the URL via `history.replaceState`.
5. It **materializes** the bundle into the detached store under
`detachedWorkspaceId`, rewrites the URL to `?ws=<detachedWorkspaceId>&dash=`,
and renders read-only.

Auth is orthogonal and unchanged: the new tab still restores credentials via the
#149 postMessage handoff, falling back to a normal OAuth relogin (which the
detached `?ws=` URL survives).

## Consequences / deliberate evolutions of #288

- **"session-bundle is not bookmarkable" is refined, not violated.** The `?st=`
token URL is genuinely one-time and dead after consumption — not bookmarkable.
The *detached view it produces* (`?ws=<detachedId>`) is persistent and
bookmarkable/relogin-surviving. This is the intended product behavior: a
view-mode tab you can leave open, reload, and re-authenticate into.
- **External-file *viewing* + the untrusted-bundle "Run Dashboard on `<host>`"
trust preflight are descoped.** External `.json` files go through #302's
transactional **Import Dashboard…** (validate + commit via the planner).
"Open for viewing…" detaches the workspace's *own*, already-trusted dashboard,
so there is no untrusted external SQL to gate. The viewer's existing safety
limits (row/byte caps, bounded concurrency, per-tile cancellation, stale-wave
protection, no Setup execution) still apply.
- **A new IndexedDB store family.** Two dedicated databases join `asb-workspace`:
`asb-dashboard-handoff` (one-time tokens) and `asb-dashboard-views`
(multi-record detached snapshots, keyed by workspace id, with a small retention
cap so abandoned views don't grow unbounded). Both reuse the #284 adapter
pattern (lazy cached open, single readwrite-txn atomicity) behind injected
seams, so the pure/seam logic stays 100%-covered and tests use in-memory fakes.
- **Read-only path stays clean.** The viewer path constructs no Workbench/editor
modules; the `check:arch` boundary guard + `dashboard-boundaries.test.js` are
extended to the new `dashboard/application` + `workspace` modules.
- **File-menu ownership clarified (#302).** The Workbench File menu owns
workspace/query operations only; Dashboard navigation moves to a Workbench
header `Dashboard →` control, and Dashboard import/export + "Open for viewing…"
move to a resource-scoped File menu on the Dashboard header.

## Alternatives considered

- **Same-tab navigation for `Dashboard →`** (#302 as literally written): rejected
by the owner in favor of a new tab, to keep the Workbench visible.
- **Ephemeral one-time view (strict original #288):** the view would not survive
relogin/close. Rejected — the owner wants a durable, detached view surface;
the token remains the *transport*, persistence is layered on top.
- **Path-segment routes (`/dashboard/ws/<id>`):** rejected — breaks
`isDashboardRoute`/`configBase`/OAuth-redirect, all keyed on the `/dashboard`
suffix.
- **A `?view=1` mode flag:** rejected in favor of store-membership discrimination
(not spoofable, and naturally yields the not-found case).
26 changes: 26 additions & 0 deletions src/core/handoff-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Pure token generation for the one-time dashboard view handoff (#288/#302).
// No DOM, no globals — `crypto` is injected exactly like the OAuth PKCE
// verifier/state generation in `src/net/oauth.ts`, so this stays testable
// without a real `crypto` global.

const HEX = '0123456789abcdef';

/**
* An unguessable 256-bit token, hex-encoded (64 lowercase hex chars). Backs
* the one-time `?st=` handoff URL param: the opener writes a `HandoffRecord`
* keyed by this token, the new tab consumes+deletes it exactly once.
*
* `crypto` is injected (the caller passes `env.crypto` or the real
* `globalThis.crypto`) so tests can supply a deterministic fake. Reads exactly
* 32 bytes via `getRandomValues(new Uint8Array(32))`.
*/
export function randomHandoffToken(crypto: Pick<Crypto, 'getRandomValues'>): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
let hex = '';
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i];
hex += HEX[(byte >> 4) & 0xf] + HEX[byte & 0xf];
}
return hex;
}
57 changes: 57 additions & 0 deletions src/dashboard/application/dashboard-open-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Pure parsing/building of the standalone Dashboard route's open-source
// contract (#288/#302). No DOM, no globals — the caller supplies the raw URL
// search string (e.g. `location.search`) and gets back a discriminated union
// telling it which store to read the dashboard from.
//
// Two ways a dashboard tab can be opened (see the coordinator's two-mode
// model): EDIT mode resolves `?ws=<workspaceId>&dash=<dashboardId>` against the
// shared primary workspace store; VIEW mode's one-time handoff resolves
// `?st=<token>&dash=<dashboardId>` against the dedicated handoff store, then
// rewrites the URL to a persistent `?ws=<detachedId>` once materialized. `st`
// takes precedence over `ws` when both are present (a stale `ws` left over
// from history should never shadow a fresh handoff token).

/** Which store a standalone Dashboard tab should resolve its dashboard from. */
export type DashboardOpenSource =
| { kind: 'current-workspace'; workspaceId: string; dashboardId: string }
| { kind: 'session-bundle'; token: string; dashboardId: string };

/**
* Parse a URL search string (e.g. `location.search`, with or without a leading
* `?`) into a `DashboardOpenSource`. Precedence: a non-empty `st` param wins as
* `session-bundle`; else a non-empty `ws` param is `current-workspace`; else
* `null` (the legacy bare `/dashboard` open, or garbage input). A missing
* `dash` param becomes `''` rather than failing parse — the discriminator only
* needs to know WHICH store to look in; a missing dashboard id inside that
* store is the caller's not-found case to handle.
*
* `state` is deliberately never read here — it is main.ts's OAuth CSRF param,
* and this contract does not use that name for its token (see the coordinator
* spec's URL param naming rule).
*/
export function parseDashboardOpenSource(search: string): DashboardOpenSource | null {
const params = new URLSearchParams(search);
const dashboardId = params.get('dash') ?? '';
const token = params.get('st');
if (token) return { kind: 'session-bundle', token, dashboardId };
const workspaceId = params.get('ws');
if (workspaceId) return { kind: 'current-workspace', workspaceId, dashboardId };
return null;
}

/**
* Build the `?`-prefixed search string for a `DashboardOpenSource`, the
* inverse of `parseDashboardOpenSource`. `current-workspace` → `?ws=..&dash=..`;
* `session-bundle` → `?st=..&dash=..`. Values are URL-encoded by
* `URLSearchParams`.
*/
export function buildDashboardSearch(source: DashboardOpenSource): string {
const params = new URLSearchParams();
if (source.kind === 'session-bundle') {
params.set('st', source.token);
} else {
params.set('ws', source.workspaceId);
}
params.set('dash', source.dashboardId);
return '?' + params.toString();
}
121 changes: 121 additions & 0 deletions src/dashboard/application/session-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Pure application glue for the standalone Dashboard route's view-mode
// session-bundle handoff (#288). Ties together the open-source contract
// (dashboard-open-source.ts), the one-time handoff record shape
// (workspace/handoff-store.types.ts), and the portable-bundle export/codec
// pair (dashboard-export.ts / portable-bundle-codec.ts) into three pure
// functions the coordinator (app.ts/dashboard.ts) calls around its impure
// IndexedDB/crypto/clock seams.
//
// NO DOM, NO globals, NO IndexedDB — those live in the coordinator. This
// module obeys the same import boundary as every other `dashboard/application`
// file: only `src/dashboard/**`, `src/workspace/**` types, `src/core/**`, and
// `src/generated/**`.

import { buildDashboardExportBundle } from '../model/dashboard-export.js';
import { decodePortableBundleJson, encodePortableBundleJson } from '../model/portable-bundle-codec.js';
import { diagnostic } from '../model/workspace-diagnostics.js';
import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js';
import type { HandoffRecord } from '../../workspace/handoff-store.types.js';
import type { DashboardOpenSource } from './dashboard-open-source.js';
import type {
DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV1,
} from '../../generated/json-schema.types.js';

export type BuildViewHandoffRecordResult =
| { ok: true; record: HandoffRecord }
| { ok: false; diagnostics: WorkspaceDiagnostic[] };

/**
* Build the one-time handoff record for opening a Dashboard in view mode:
* snapshot `dashboard` plus its dependency closure (`buildDashboardExportBundle`)
* into a portable bundle, canonically encode it, and assemble the record the
* coordinator writes to the handoff store. `nowISO`/`expiresAt`/
* `detachedWorkspaceId` are all supplied by the caller — every impure source
* (clock, id generator) lives in app.ts, never here.
*/
export function buildViewHandoffRecord(
dashboard: DashboardDocumentV1,
queries: readonly SavedQueryV2[],
opts: { detachedWorkspaceId: string; expiresAt: number; nowISO: string },
): BuildViewHandoffRecordResult {
const bundle = buildDashboardExportBundle(dashboard, queries, opts.nowISO);
const encoded = encodePortableBundleJson({
queries: bundle.queries, dashboards: bundle.dashboards, nowISO: bundle.exportedAt,
});
if (!encoded.ok) return { ok: false, diagnostics: encoded.diagnostics };
return {
ok: true,
record: {
text: encoded.value,
dashboardId: dashboard.id,
detachedWorkspaceId: opts.detachedWorkspaceId,
expiresAt: opts.expiresAt,
},
};
}

export type MaterializeDetachedWorkspaceResult =
| { ok: true; workspace: StoredWorkspaceV1 }
| { ok: false; diagnostics: WorkspaceDiagnostic[] };

/**
* Materialize a consumed handoff record's bundle text into a detached,
* read-only `StoredWorkspaceV1`. Decodes and validates `text`, selects the
* Dashboard matching `dashboardId` out of the bundle (failing with a single
* diagnostic when absent), and assembles the detached workspace under
* `detachedWorkspaceId`. `name` is the selected Dashboard's title, falling
* back to `'Dashboard'` when the title is empty.
*/
export function materializeDetachedWorkspace(
text: string, dashboardId: string, detachedWorkspaceId: string,
): MaterializeDetachedWorkspaceResult {
const decoded = decodePortableBundleJson(text);
if (!decoded.ok) return { ok: false, diagnostics: decoded.diagnostics };
const bundle: PortableBundleV1 = decoded.value;
const selected = bundle.dashboards.find((candidate) => candidate.id === dashboardId);
if (!selected) {
return {
ok: false,
diagnostics: [diagnostic(['dashboards'], 'dashboard-not-found', `Dashboard ${dashboardId} not found in bundle`)],
};
}
return {
ok: true,
workspace: {
storageVersion: 1,
id: detachedWorkspaceId,
name: selected.title || 'Dashboard',
queries: bundle.queries,
dashboard: selected,
},
};
}

/** Pure mode resolution result for a parsed `current-workspace` open source. */
export type DashboardModeResolution =
| { mode: 'edit'; workspace: StoredWorkspaceV1 }
| { mode: 'view'; workspace: StoredWorkspaceV1 }
| { mode: 'not-found' };

const matches = (
workspace: StoredWorkspaceV1 | null, source: Extract<DashboardOpenSource, { kind: 'current-workspace' }>,
): workspace is StoredWorkspaceV1 =>
!!workspace && workspace.id === source.workspaceId && workspace.dashboard?.id === source.dashboardId;

/**
* Resolve a parsed `current-workspace` `DashboardOpenSource` against the two
* candidate workspaces already loaded by the coordinator: `primary` (from the
* shared `asb-workspace` store) and `detached` (from the `asb-dashboard-views`
* detached-views store). Both the workspace id AND the Dashboard id must
* match — a workspace id match with a differing (or missing) Dashboard id is
* `not-found`, never a silent fall-through to edit mode.
*/
export function resolveDashboardMode(
source: Extract<DashboardOpenSource, { kind: 'current-workspace' }>,
primary: StoredWorkspaceV1 | null,
detached: StoredWorkspaceV1 | null,
): DashboardModeResolution {
if (matches(primary, source)) return { mode: 'edit', workspace: primary };
if (matches(detached, source)) return { mode: 'view', workspace: detached };
return { mode: 'not-found' };
}
Loading
Loading