@danypops/tickets 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,8 +25,8 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@danypops/vehicle-core": "^0.15.0",
28
- "@danypops/vehicle-server": "^0.21.0",
29
- "@danypops/vehicle-client": "^0.7.1",
28
+ "@danypops/vehicle-server": "^0.21.1",
29
+ "@danypops/vehicle-client": "^0.8.1",
30
30
  "@danypops/enigma-client": "^0.6.1",
31
31
  "@gitbeaker/rest": "^43.8.0",
32
32
  "commander": "^12.1.0",
@@ -16,9 +16,10 @@ import type { IssueRepository } from "../issue/repository.js";
16
16
  import { TicketService } from "../issue/service.js";
17
17
  import { TICKETS_DAEMON_NAMES } from "../rpc/ops.js";
18
18
  import { buildApp, type TicketsAppDeps } from "../rpc/server.js";
19
- import { FOCUS_MIGRATIONS, FocusStore } from "../sqlite/focus.js";
19
+ import { FOCUS_MIGRATIONS, FOCUS_STALE_AFTER_MS, FocusStore } from "../sqlite/focus.js";
20
20
  import { LEDGER_MIGRATIONS, Ledger } from "../sqlite/ledger.js";
21
21
  import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "../sqlite/saved-queries.js";
22
+ import { SESSION_IDENTITY_MIGRATIONS, SqliteSessionIdentityStore } from "../sqlite/session-identity.js";
22
23
  import { WATCH_MIGRATIONS, WatchStore } from "../sqlite/watches.js";
23
24
  import { StageStore } from "../stage/store.js";
24
25
  import { createSyncTask } from "./poller.js";
@@ -45,6 +46,8 @@ export interface BootstrapOptions {
45
46
  issueWatchIntervalMs?: number;
46
47
  /** How often every subscribed saved query is re-run and diffed. Defaults to DEFAULT_QUERY_WATCH_INTERVAL_MS. */
47
48
  queryWatchIntervalMs?: number;
49
+ /** How often stale (untouched for FOCUS_STALE_AFTER_MS) Focus scopes are reaped. Defaults to DEFAULT_FOCUS_REAP_INTERVAL_MS. */
50
+ focusReapIntervalMs?: number;
48
51
  /**
49
52
  * Overrides the daemon.shutdown op's effect. Defaults to sending this
50
53
  * process SIGTERM, which vehicle-server's runDaemonProcess already handles
@@ -61,6 +64,7 @@ export interface BootstrappedDaemon {
61
64
  queries: SavedQueryStore;
62
65
  stageStore: StageStore;
63
66
  watches: WatchStore;
67
+ sessionIdentity: SqliteSessionIdentityStore;
64
68
  service: TicketService;
65
69
  options: StartDaemonOptions;
66
70
  }
@@ -73,18 +77,21 @@ const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
73
77
  * so polling that fast would only waste API quota against GitHub/GitLab/Jira's own rate limits. */
74
78
  const DEFAULT_ISSUE_WATCH_INTERVAL_MS = 60_000;
75
79
  const DEFAULT_QUERY_WATCH_INTERVAL_MS = 60_000;
80
+ /** Focus scopes are session-lifetime pointers, not hot state -- reaping once an hour is plenty prompt against FOCUS_STALE_AFTER_MS's own 30-day window. */
81
+ const DEFAULT_FOCUS_REAP_INTERVAL_MS = 60 * 60_000;
76
82
 
77
83
  export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
78
84
  const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
79
85
  const token = ensureAuthToken(paths.token, "Tickets");
80
86
  const db = openSqliteWithPragmas(paths.database, {
81
- migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS, ...WATCH_MIGRATIONS],
87
+ migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS, ...WATCH_MIGRATIONS, ...SESSION_IDENTITY_MIGRATIONS],
82
88
  });
83
89
  const ledger = new Ledger(db);
84
90
  const focusStore = new FocusStore(db);
85
91
  const queries = new SavedQueryStore(db);
86
92
  const stageStore = new StageStore();
87
93
  const watches = new WatchStore(db);
94
+ const sessionIdentity = new SqliteSessionIdentityStore(db);
88
95
  const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
89
96
  const config = opts.config ?? loadConfig();
90
97
  const buildRepos = opts.buildRepositories ?? buildRepositories;
@@ -104,6 +111,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
104
111
  queries,
105
112
  stageStore,
106
113
  watches,
114
+ sessionIdentity,
107
115
  token,
108
116
  version,
109
117
  logger,
@@ -123,6 +131,14 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
123
131
  intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
124
132
  run: () => checkpoint(db),
125
133
  },
134
+ {
135
+ name: "focus-reap-stale",
136
+ intervalMs: opts.focusReapIntervalMs ?? DEFAULT_FOCUS_REAP_INTERVAL_MS,
137
+ run: () => {
138
+ const removed = focusStore.reapStale(new Date(Date.now() - FOCUS_STALE_AFTER_MS).toISOString());
139
+ if (removed > 0) logger.debug("reaped stale focus scopes", { removed });
140
+ },
141
+ },
126
142
  // Only when repos came from real config/env/Enigma resolution -- an
127
143
  // injected test fixture (opts.repos) has no config to re-resolve from.
128
144
  ...(opts.repos === undefined
@@ -146,6 +162,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
146
162
  queries,
147
163
  stageStore,
148
164
  watches,
165
+ sessionIdentity,
149
166
  token,
150
167
  version,
151
168
  logger,
@@ -157,5 +174,5 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
157
174
  },
158
175
  };
159
176
 
160
- return { db, ledger, focusStore, queries, stageStore, watches, service, options };
177
+ return { db, ledger, focusStore, queries, stageStore, watches, sessionIdentity, service, options };
161
178
  }
@@ -2,6 +2,7 @@ import { AuthRequiredError, IssueNotFoundError } from "../issue/errors.js";
2
2
  import { NotSupportedError, UnknownBackendError } from "../issue/service.js";
3
3
  import { FocusError } from "../sqlite/focus.js";
4
4
  import { SavedQueryNotFoundError } from "../sqlite/saved-queries.js";
5
+ import { SessionAuthError } from "../sqlite/session-identity.js";
5
6
  import { StagedItemNotFoundError } from "../stage/store.js";
6
7
 
7
8
  /** Returns the legacy HTTP status only for reviewed business errors; unknown failures stay unclassified. */
@@ -10,5 +11,6 @@ export function statusForKnownTicketError(error: unknown): number | undefined {
10
11
  return 404;
11
12
  if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
12
13
  if (error instanceof AuthRequiredError) return 422;
14
+ if (error instanceof SessionAuthError) return 401;
13
15
  return undefined;
14
16
  }
package/src/rpc/ops.ts CHANGED
@@ -32,6 +32,8 @@ export type TicketOperation =
32
32
  | "focus.pause"
33
33
  | "focus.unpause"
34
34
  | "focus.clear"
35
+ | "session.register"
36
+ | "session.release"
35
37
  | "discover.fields"
36
38
  | "discover.statuses"
37
39
  | "discover.template"
@@ -71,11 +73,31 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
71
73
  "issue.merge": { ref: string; method?: "merge" | "squash" | "rebase" };
72
74
  "ledger.search": { query: string; limit?: number; backend?: string };
73
75
  "ledger.stats": Record<string, never>;
74
- "focus.set": { ref: string };
75
- "focus.get": Record<string, never>;
76
- "focus.pause": { reason?: string };
77
- "focus.unpause": Record<string, never>;
78
- "focus.clear": Record<string, never>;
76
+ /**
77
+ * sessionId: optional explicit scope override (see sqlite/focus.ts's own normalizeFocusScope) --
78
+ * defaults to callContext?.callerSessionId, then "global", the same input-wins-over-callContext
79
+ * precedence issue.subscribe/query.subscribe already established. sessionSecret authorizes an
80
+ * EXPLICIT sessionId claim against session.register's own identity store (see
81
+ * sqlite/session-identity.ts) -- never required, and never even read, for the implicit
82
+ * callContext.callerSessionId default, since a Vehicle-projected tool call's own
83
+ * callerSessionId is host-derived, not model-settable.
84
+ */
85
+ "focus.set": { ref: string; sessionId?: string; sessionSecret?: string };
86
+ "focus.get": { sessionId?: string };
87
+ "focus.pause": { reason?: string; sessionId?: string; sessionSecret?: string };
88
+ "focus.unpause": { sessionId?: string; sessionSecret?: string };
89
+ "focus.clear": { sessionId?: string; sessionSecret?: string };
90
+ /**
91
+ * No CLI command, and excluded from Vehicle tool projection (see agent-tools/tickets-vehicle.ts's
92
+ * own OWNER/OPERATIONS list) -- a pure client<->daemon handshake pi-tickets' own extension code
93
+ * calls directly (see pi-tickets' tui.ts), never a human- or model-meaningful action the way
94
+ * every other operation here is. daemon.shutdown is the one other operation with no Pi tool for
95
+ * a similar reason, but it at least keeps a human-facing CLI command (`daemon stop`); these two
96
+ * have no comparable human verb at all.
97
+ */
98
+ "session.register": { sessionId: string };
99
+ "session.release": { sessionId: string; sessionSecret?: string };
100
+
79
101
  "discover.fields": { backend: string };
80
102
  "discover.statuses": { backend: string };
81
103
  "discover.template": { backend: string; project: string; issueType: string; sampleSize?: number };
@@ -124,6 +146,9 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
124
146
  "focus.pause": { focus: TicketFocusState };
125
147
  "focus.unpause": { focus: TicketFocusState };
126
148
  "focus.clear": { cleared: boolean };
149
+ /** secret: shown once, plaintext, on register -- never persisted or logged by the client, mirroring vehicle-server/session-identity's own contract. */
150
+ "session.register": { sessionId: string; secret: string };
151
+ "session.release": { released: true };
127
152
  "discover.fields": { mappings: Record<string, string> };
128
153
  "discover.statuses": { mappings: Record<string, string> };
129
154
  "discover.template": { template: Template | null };
@@ -169,6 +194,8 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
169
194
  "focus.pause",
170
195
  "focus.unpause",
171
196
  "focus.clear",
197
+ "session.register",
198
+ "session.release",
172
199
  "discover.fields",
173
200
  "discover.statuses",
174
201
  "discover.template",
package/src/rpc/server.ts CHANGED
@@ -11,11 +11,18 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
11
11
  import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
12
12
  import type { Logger } from "@danypops/vehicle-server/logging";
13
13
  import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
14
+ import {
15
+ isSessionRegistered,
16
+ registerSessionIdentity,
17
+ releaseSessionIdentity,
18
+ verifySessionSecret,
19
+ } from "@danypops/vehicle-server/session-identity";
14
20
  import { parseRef } from "../issue/issue.js";
15
21
  import type { TicketService } from "../issue/service.js";
16
22
  import { FocusError, type FocusStore } from "../sqlite/focus.js";
17
23
  import type { Ledger } from "../sqlite/ledger.js";
18
24
  import { SavedQueryNotFoundError, type SavedQueryStore } from "../sqlite/saved-queries.js";
25
+ import { SessionAuthError, type SqliteSessionIdentityStore } from "../sqlite/session-identity.js";
19
26
  import type { WatchStore } from "../sqlite/watches.js";
20
27
  import type { StagePayload, StageStore } from "../stage/store.js";
21
28
  import { statusForKnownTicketError } from "./error-status.js";
@@ -26,6 +33,7 @@ export interface TicketsAppDeps {
26
33
  ledger: Ledger;
27
34
  focusStore: FocusStore;
28
35
  queries: SavedQueryStore;
36
+ sessionIdentity: SqliteSessionIdentityStore;
29
37
  token: string;
30
38
  version: string;
31
39
  logger?: Logger;
@@ -77,6 +85,28 @@ export type Handler<Op extends TicketOperation> = (
77
85
  * VehicleRegistry projection -- never reimplemented a second time for the
78
86
  * newer surface.
79
87
  */
88
+ /**
89
+ * Resolves which Focus scope a focus.* call actually targets, and enforces the one place a
90
+ * caller-supplied session id is behavior-affecting in this daemon (see sqlite/session-identity.ts's
91
+ * own doc comment): an EXPLICIT input.sessionId must present the matching sessionSecret if that
92
+ * session id is registered; an unregistered one (or the implicit callContext.callerSessionId
93
+ * default -- never model-settable, since it's not part of any operation's declared input schema)
94
+ * passes through unarmored. input.sessionId always wins over callContext.callerSessionId, the
95
+ * same precedence issue.subscribe/query.subscribe already established for subscriberId.
96
+ */
97
+ function resolveFocusScope(
98
+ deps: Pick<TicketsAppDeps, "sessionIdentity">,
99
+ input: { sessionId?: string; sessionSecret?: string },
100
+ callContext: HandlerCallContext | undefined,
101
+ ): string | undefined {
102
+ const explicit = input.sessionId;
103
+ if (explicit === undefined) return callContext?.callerSessionId;
104
+ if (isSessionRegistered(deps.sessionIdentity, explicit) && !verifySessionSecret(deps.sessionIdentity, explicit, input.sessionSecret)) {
105
+ throw new SessionAuthError(`session "${explicit}" is registered but the given secret does not match`);
106
+ }
107
+ return explicit;
108
+ }
109
+
80
110
  export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
81
111
  "backends.list": async (deps) => ({ backends: deps.service.backendCapabilities() }),
82
112
  "issue.list": async (deps, input) => ({ issues: await deps.service.list(input.backend, input.filter) }),
@@ -92,7 +122,8 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
92
122
  "issue.merge": async (deps, input) => ({ issue: await deps.service.merge(input.ref, input.method) }),
93
123
  "ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit, input.backend) }),
94
124
  "ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
95
- "focus.set": async (deps, input) => {
125
+ "focus.set": async (deps, input, callContext) => {
126
+ const scope = resolveFocusScope(deps, input, callContext);
96
127
  // Ledger-first: focusing a ticket already pooled locally needs no live
97
128
  // backend call. Otherwise fall back to a live get (also validates the
98
129
  // ref actually exists) and opportunistically warm the ledger with it,
@@ -102,12 +133,25 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
102
133
  const issue = cached ?? (await deps.service.get(input.ref));
103
134
  if (!cached) deps.ledger.upsert(parseRef(input.ref).backend, issue);
104
135
  if (!issue.url) throw new FocusError(`issue "${input.ref}" has no URL from its backend; cannot focus without a full link`);
105
- return { focus: deps.focusStore.set(input.ref, issue.title, issue.url) };
136
+ return { focus: deps.focusStore.set(input.ref, issue.title, issue.url, scope) };
137
+ },
138
+ "focus.get": async (deps, input, callContext) => ({
139
+ focus: deps.focusStore.get(resolveFocusScope(deps, input, callContext)) ?? null,
140
+ }),
141
+ "focus.pause": async (deps, input, callContext) => ({
142
+ focus: deps.focusStore.pause(input.reason, resolveFocusScope(deps, input, callContext)),
143
+ }),
144
+ "focus.unpause": async (deps, input, callContext) => ({
145
+ focus: deps.focusStore.unpause(resolveFocusScope(deps, input, callContext)),
146
+ }),
147
+ "focus.clear": async (deps, input, callContext) => ({
148
+ cleared: deps.focusStore.clear(resolveFocusScope(deps, input, callContext)),
149
+ }),
150
+ "session.register": async (deps, input) => registerSessionIdentity(deps.sessionIdentity, input.sessionId),
151
+ "session.release": async (deps, input) => {
152
+ releaseSessionIdentity(deps.sessionIdentity, input.sessionId, input.sessionSecret);
153
+ return { released: true };
106
154
  },
107
- "focus.get": async (deps) => ({ focus: deps.focusStore.get() ?? null }),
108
- "focus.pause": async (deps, input) => ({ focus: deps.focusStore.pause(input.reason) }),
109
- "focus.unpause": async (deps) => ({ focus: deps.focusStore.unpause() }),
110
- "focus.clear": async (deps) => ({ cleared: deps.focusStore.clear() }),
111
155
  "discover.fields": async (deps, input) => ({ mappings: await deps.service.discoverFields(input.backend) }),
112
156
  "discover.statuses": async (deps, input) => ({ mappings: await deps.service.discoverStatuses(input.backend) }),
113
157
  "discover.template": async (deps, input) => ({
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Focus — the single ticket currently being worked on, independent of any
3
- * one CLI invocation or tool call. Unlike the Ledger (a cache of every issue
4
- * the daemon has ever seen), Focus is a pointer: one ref, its resolved title
2
+ * Focus — the ticket currently being worked on, independent of any one CLI
3
+ * invocation or tool call. Unlike the Ledger (a cache of every issue the
4
+ * daemon has ever seen), Focus is a pointer: one ref, its resolved title
5
5
  * and full web URL, and whether work on it is active or paused. Persisted
6
- * so it survives daemon restarts. A singleton by design — there is never
7
- * more than one ticket in focus at a time, so setting focus always replaces
8
- * whatever was there, the same way switching your attention to a different
9
- * ticket in real life replaces what you were just looking at.
6
+ * so it survives daemon restarts.
7
+ *
8
+ * One Focus per *scope*, not a single global singleton -- mirrors Papyrus's
9
+ * own Task Focus (stores/task-focus-store.ts / stores/sqlite-task-focus-store.ts)
10
+ * one domain over: a scope defaults to "global" for a caller that doesn't
11
+ * supply one (the bare CLI, legacy behavior, exactly today's pre-scoping
12
+ * shape), but is normally the requesting Pi session's own id, so two
13
+ * concurrent agents/terminals each get their own Focus instead of
14
+ * clobbering a shared one.
10
15
  */
11
16
  import type { Database } from "bun:sqlite";
12
17
  import type { Migration } from "@danypops/vehicle-server/storage";
@@ -28,6 +33,28 @@ export const FOCUS_MIGRATIONS: Migration[] = [
28
33
  `);
29
34
  },
30
35
  },
36
+ {
37
+ // Re-keys ticket_focus from a hardcoded id=1 singleton to one row per scope. Focus is a
38
+ // pointer, not historical data -- dropping and recreating (rather than an in-place
39
+ // ALTER TABLE + backfill) is a deliberate, acceptable loss of whatever was focused before
40
+ // this migration runs, the same way Papyrus's own equivalent migration didn't attempt to
41
+ // carry a pre-scoping global focus forward into some arbitrary scope.
42
+ version: 5,
43
+ up: (db) => {
44
+ db.exec("DROP TABLE IF EXISTS ticket_focus;");
45
+ db.exec(`
46
+ CREATE TABLE ticket_focus (
47
+ scope TEXT PRIMARY KEY,
48
+ ref TEXT NOT NULL,
49
+ title TEXT NOT NULL,
50
+ url TEXT NOT NULL,
51
+ status TEXT NOT NULL,
52
+ pause_reason TEXT,
53
+ updated_at TEXT NOT NULL
54
+ );
55
+ `);
56
+ },
57
+ },
31
58
  ];
32
59
 
33
60
  export type FocusStatus = "active" | "paused";
@@ -49,7 +76,24 @@ export class FocusError extends Error {
49
76
  }
50
77
  }
51
78
 
79
+ export const FOCUS_DEFAULT_SCOPE = "global";
80
+ export const FOCUS_SCOPE_MAX_LENGTH = 128;
81
+ /** Bounds distinct concurrent focus scopes (sessions); the least-recently-updated scope is evicted beyond this, mirroring Papyrus's TASK_FOCUS_MAX_SCOPES. */
82
+ export const FOCUS_MAX_SCOPES = 200;
83
+ /** A scope untouched this long is eligible for time-based reaping (see FocusStore.reapStale), independent of FOCUS_MAX_SCOPES's own eviction. 30 days, matching Papyrus's TASK_FOCUS_STALE_AFTER_MS convention. */
84
+ export const FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
85
+
86
+ /** An absent/empty scope defaults to "global" -- the bare CLI / legacy single-focus behavior; a real scope (normally a Pi session id) passes through unchanged. */
87
+ export function normalizeFocusScope(scope: string | undefined): string {
88
+ const value = scope && scope.length > 0 ? scope : FOCUS_DEFAULT_SCOPE;
89
+ if (value.length > FOCUS_SCOPE_MAX_LENGTH) {
90
+ throw new FocusError(`focus scope must be at most ${FOCUS_SCOPE_MAX_LENGTH} characters`);
91
+ }
92
+ return value;
93
+ }
94
+
52
95
  interface FocusRow {
96
+ scope: string;
53
97
  ref: string;
54
98
  title: string;
55
99
  url: string;
@@ -72,55 +116,72 @@ function rowToState(row: FocusRow): TicketFocusState {
72
116
  export class FocusStore {
73
117
  constructor(private readonly db: Database) {}
74
118
 
75
- get(): TicketFocusState | undefined {
119
+ get(scope?: string): TicketFocusState | undefined {
76
120
  const row = this.db
77
- .query("SELECT ref, title, url, status, pause_reason, updated_at FROM ticket_focus WHERE id = 1")
78
- .get() as FocusRow | null;
121
+ .query("SELECT scope, ref, title, url, status, pause_reason, updated_at FROM ticket_focus WHERE scope = $scope")
122
+ .get({ $scope: normalizeFocusScope(scope) }) as FocusRow | null;
79
123
  return row ? rowToState(row) : undefined;
80
124
  }
81
125
 
82
126
  /** Always lands "active" and drops any prior pause reason: switching focus onto a different ticket is not the same as resuming a pause on the old one. */
83
- set(ref: string, title: string, url: string): TicketFocusState {
127
+ set(ref: string, title: string, url: string, scope?: string): TicketFocusState {
128
+ const key = normalizeFocusScope(scope);
84
129
  const updatedAt = new Date().toISOString();
130
+ this.evictOldestBeyondCap(key);
85
131
  this.db
86
132
  .query(
87
- `INSERT INTO ticket_focus (id, ref, title, url, status, pause_reason, updated_at)
88
- VALUES (1, $ref, $title, $url, 'active', NULL, $updatedAt)
89
- ON CONFLICT(id) DO UPDATE SET
133
+ `INSERT INTO ticket_focus (scope, ref, title, url, status, pause_reason, updated_at)
134
+ VALUES ($scope, $ref, $title, $url, 'active', NULL, $updatedAt)
135
+ ON CONFLICT(scope) DO UPDATE SET
90
136
  ref = excluded.ref, title = excluded.title, url = excluded.url,
91
137
  status = 'active', pause_reason = NULL, updated_at = excluded.updated_at`,
92
138
  )
93
- .run({ $ref: ref, $title: title, $url: url, $updatedAt: updatedAt });
139
+ .run({ $scope: key, $ref: ref, $title: title, $url: url, $updatedAt: updatedAt });
94
140
  return { ref, title, url, status: "active", updatedAt };
95
141
  }
96
142
 
97
- pause(reason?: string): TicketFocusState {
98
- const current = this.get();
143
+ pause(reason?: string, scope?: string): TicketFocusState {
144
+ const current = this.get(scope);
99
145
  if (!current) throw new FocusError("no ticket is currently focused");
100
146
  if (current.status === "paused") throw new FocusError(`focus on "${current.ref}" is already paused`);
101
- return this.transition("paused", reason);
147
+ return this.transition("paused", reason, scope);
102
148
  }
103
149
 
104
- unpause(): TicketFocusState {
105
- const current = this.get();
150
+ unpause(scope?: string): TicketFocusState {
151
+ const current = this.get(scope);
106
152
  if (!current) throw new FocusError("no ticket is currently focused");
107
153
  if (current.status === "active") throw new FocusError(`focus on "${current.ref}" is already active`);
108
- return this.transition("active", undefined);
154
+ return this.transition("active", undefined, scope);
109
155
  }
110
156
 
111
- /** Returns whether a focus existed to clear (idempotent either way). */
112
- clear(): boolean {
113
- const existed = this.get() !== undefined;
114
- this.db.exec("DELETE FROM ticket_focus WHERE id = 1");
157
+ /** Returns whether a focus existed in this scope to clear (idempotent either way). */
158
+ clear(scope?: string): boolean {
159
+ const existed = this.get(scope) !== undefined;
160
+ this.db.query("DELETE FROM ticket_focus WHERE scope = $scope").run({ $scope: normalizeFocusScope(scope) });
115
161
  return existed;
116
162
  }
117
163
 
118
- private transition(status: FocusStatus, reason: string | undefined): TicketFocusState {
164
+ /** Deletes every scope's row whose updatedAt is strictly before olderThanIso (see FOCUS_STALE_AFTER_MS). Returns how many rows were removed. */
165
+ reapStale(olderThanIso: string): number {
166
+ return this.db.query("DELETE FROM ticket_focus WHERE updated_at < $cutoff").run({ $cutoff: olderThanIso }).changes;
167
+ }
168
+
169
+ private transition(status: FocusStatus, reason: string | undefined, scope: string | undefined): TicketFocusState {
170
+ const key = normalizeFocusScope(scope);
119
171
  const updatedAt = new Date().toISOString();
120
172
  this.db
121
- .query("UPDATE ticket_focus SET status = $status, pause_reason = $reason, updated_at = $updatedAt WHERE id = 1")
122
- .run({ $status: status, $reason: reason ?? null, $updatedAt: updatedAt });
123
- // Non-null: transition() is only ever called right after get() confirmed a row exists.
124
- return this.get()!;
173
+ .query("UPDATE ticket_focus SET status = $status, pause_reason = $reason, updated_at = $updatedAt WHERE scope = $scope")
174
+ .run({ $scope: key, $status: status, $reason: reason ?? null, $updatedAt: updatedAt });
175
+ // Non-null: transition() is only ever called right after get() confirmed a row exists for this scope.
176
+ return this.get(key)!;
177
+ }
178
+
179
+ /** Evicts the least-recently-updated scope once a brand-new scope would push the total beyond FOCUS_MAX_SCOPES. A no-op for a scope that already has a row (set() on an existing scope never counts as growth). */
180
+ private evictOldestBeyondCap(key: string): void {
181
+ const exists = this.db.query("SELECT 1 FROM ticket_focus WHERE scope = $scope").get({ $scope: key });
182
+ if (exists) return;
183
+ const count = (this.db.query("SELECT COUNT(*) AS count FROM ticket_focus").get() as { count: number }).count;
184
+ if (count < FOCUS_MAX_SCOPES) return;
185
+ this.db.exec("DELETE FROM ticket_focus WHERE scope = (SELECT scope FROM ticket_focus ORDER BY updated_at ASC LIMIT 1)");
125
186
  }
126
187
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Sqlite-backed SessionIdentityStore adapter for @danypops/vehicle-server's own
3
+ * generic session-identity primitive (secret generation/hashing/constant-time
4
+ * verify) -- this file only wires that primitive to sqlite, the same split
5
+ * Papyrus's own stores/sqlite-session-identity-store.ts makes.
6
+ *
7
+ * Hardens the one place a caller-supplied session id becomes BEHAVIOR-affecting
8
+ * in this daemon: focus.set/pause/unpause/clear, once a caller passes an EXPLICIT
9
+ * sessionId (see rpc/server.ts's own TICKET_OP_HANDLERS) -- this daemon, like
10
+ * Papyrus's, authenticates every client with one shared bearer token, so a bare
11
+ * session id alone is not a credential once it can redirect/pause/clear someone
12
+ * else's live Focus. Opt-in armor: a session id that was never registered
13
+ * (the implicit callContext.callerSessionId default from a real Vehicle tool
14
+ * call, or a bare CLI caller) passes through unarmored, exactly as today.
15
+ */
16
+ import type { Database } from "bun:sqlite";
17
+ import type { SessionIdentityRecord, SessionIdentityStore } from "@danypops/vehicle-server/session-identity";
18
+ import type { Migration } from "@danypops/vehicle-server/storage";
19
+
20
+ /** An explicit sessionId claim (see rpc/server.ts's resolveFocusScope) against a REGISTERED session id, with a missing or wrong sessionSecret. Maps to HTTP 401, not 400 -- this is an authorization failure, not a validation one. */
21
+ export class SessionAuthError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = "SessionAuthError";
25
+ }
26
+ }
27
+
28
+ export const SESSION_IDENTITY_MIGRATIONS: Migration[] = [
29
+ {
30
+ version: 6,
31
+ up: (db) => {
32
+ db.exec(`
33
+ CREATE TABLE session_identities (
34
+ session_id TEXT PRIMARY KEY,
35
+ secret_hash TEXT NOT NULL,
36
+ registered_at TEXT NOT NULL,
37
+ last_seen_at TEXT NOT NULL
38
+ );
39
+ `);
40
+ },
41
+ },
42
+ ];
43
+
44
+ interface SessionIdentityRow {
45
+ session_id: string;
46
+ secret_hash: string;
47
+ registered_at: string;
48
+ last_seen_at: string;
49
+ }
50
+
51
+ function rowToRecord(row: SessionIdentityRow): SessionIdentityRecord {
52
+ return { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at };
53
+ }
54
+
55
+ export class SqliteSessionIdentityStore implements SessionIdentityStore {
56
+ constructor(private readonly db: Database) {}
57
+
58
+ find(sessionId: string): SessionIdentityRecord | undefined {
59
+ const row = this.db
60
+ .query("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = $sessionId")
61
+ .get({ $sessionId: sessionId }) as SessionIdentityRow | null;
62
+ return row ? rowToRecord(row) : undefined;
63
+ }
64
+
65
+ upsert(record: SessionIdentityRecord): void {
66
+ this.db
67
+ .query(
68
+ `INSERT INTO session_identities (session_id, secret_hash, registered_at, last_seen_at)
69
+ VALUES ($sessionId, $secretHash, $registeredAt, $lastSeenAt)
70
+ ON CONFLICT(session_id) DO UPDATE SET
71
+ secret_hash = excluded.secret_hash, registered_at = excluded.registered_at, last_seen_at = excluded.last_seen_at`,
72
+ )
73
+ .run({
74
+ $sessionId: record.sessionId,
75
+ $secretHash: record.secretHash,
76
+ $registeredAt: record.registeredAt,
77
+ $lastSeenAt: record.lastSeenAt,
78
+ });
79
+ }
80
+
81
+ remove(sessionId: string): void {
82
+ this.db.query("DELETE FROM session_identities WHERE session_id = $sessionId").run({ $sessionId: sessionId });
83
+ }
84
+
85
+ touch(sessionId: string, lastSeenAt: string): void {
86
+ this.db
87
+ .query("UPDATE session_identities SET last_seen_at = $lastSeenAt WHERE session_id = $sessionId")
88
+ .run({ $sessionId: sessionId, $lastSeenAt: lastSeenAt });
89
+ }
90
+
91
+ count(): number {
92
+ return (this.db.query("SELECT COUNT(*) AS count FROM session_identities").get() as { count: number }).count;
93
+ }
94
+ }