@danypops/tickets 0.12.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 +4 -4
- package/src/agent-tools/tickets-vehicle.ts +67 -2
- package/src/cli/index.ts +53 -0
- package/src/process/bootstrap.ts +39 -3
- package/src/process/watch-sync.ts +204 -0
- package/src/rpc/error-status.ts +2 -0
- package/src/rpc/ops.ts +61 -5
- package/src/rpc/server.ts +97 -6
- package/src/sqlite/focus.ts +91 -30
- package/src/sqlite/session-identity.ts +94 -0
- package/src/sqlite/watches.ts +355 -0
package/src/rpc/server.ts
CHANGED
|
@@ -11,11 +11,19 @@ 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";
|
|
26
|
+
import type { WatchStore } from "../sqlite/watches.js";
|
|
19
27
|
import type { StagePayload, StageStore } from "../stage/store.js";
|
|
20
28
|
import { statusForKnownTicketError } from "./error-status.js";
|
|
21
29
|
import { type StagePushResult, TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
|
|
@@ -25,6 +33,7 @@ export interface TicketsAppDeps {
|
|
|
25
33
|
ledger: Ledger;
|
|
26
34
|
focusStore: FocusStore;
|
|
27
35
|
queries: SavedQueryStore;
|
|
36
|
+
sessionIdentity: SqliteSessionIdentityStore;
|
|
28
37
|
token: string;
|
|
29
38
|
version: string;
|
|
30
39
|
logger?: Logger;
|
|
@@ -45,6 +54,20 @@ export interface TicketsAppDeps {
|
|
|
45
54
|
*/
|
|
46
55
|
vehicleRegistry: VehicleRegistry;
|
|
47
56
|
stageStore: StageStore;
|
|
57
|
+
watches: WatchStore;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The subset of a Vehicle call's own identity a handful of handlers read for a sensible default --
|
|
62
|
+
* see issue.subscribe/query.subscribe below, which default subscriberId/projectRoot from these
|
|
63
|
+
* when the caller doesn't pass them explicitly, mirroring @danypops/pipes' own ci.subscribe.
|
|
64
|
+
* Always undefined-safe: absent entirely for the raw HTTP /api/v1/ops dispatch (which has no Pi
|
|
65
|
+
* session behind it), present when the call came through agent-tools/tickets-vehicle.ts's
|
|
66
|
+
* Vehicle registration (see its own VehicleOperationContext.callerSessionId/callerProjectRoot).
|
|
67
|
+
*/
|
|
68
|
+
export interface HandlerCallContext {
|
|
69
|
+
callerSessionId?: string;
|
|
70
|
+
callerProjectRoot?: string;
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
// Narrower than TicketsAppDeps on purpose: no real handler reads
|
|
@@ -53,6 +76,7 @@ export interface TicketsAppDeps {
|
|
|
53
76
|
export type Handler<Op extends TicketOperation> = (
|
|
54
77
|
deps: Omit<TicketsAppDeps, "vehicleRegistry">,
|
|
55
78
|
input: TicketOpInputs[Op],
|
|
79
|
+
callContext?: HandlerCallContext,
|
|
56
80
|
) => Promise<TicketOpOutputs[Op]>;
|
|
57
81
|
|
|
58
82
|
/**
|
|
@@ -61,6 +85,28 @@ export type Handler<Op extends TicketOperation> = (
|
|
|
61
85
|
* VehicleRegistry projection -- never reimplemented a second time for the
|
|
62
86
|
* newer surface.
|
|
63
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
|
+
|
|
64
110
|
export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
65
111
|
"backends.list": async (deps) => ({ backends: deps.service.backendCapabilities() }),
|
|
66
112
|
"issue.list": async (deps, input) => ({ issues: await deps.service.list(input.backend, input.filter) }),
|
|
@@ -76,7 +122,8 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
76
122
|
"issue.merge": async (deps, input) => ({ issue: await deps.service.merge(input.ref, input.method) }),
|
|
77
123
|
"ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit, input.backend) }),
|
|
78
124
|
"ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
|
|
79
|
-
"focus.set": async (deps, input) => {
|
|
125
|
+
"focus.set": async (deps, input, callContext) => {
|
|
126
|
+
const scope = resolveFocusScope(deps, input, callContext);
|
|
80
127
|
// Ledger-first: focusing a ticket already pooled locally needs no live
|
|
81
128
|
// backend call. Otherwise fall back to a live get (also validates the
|
|
82
129
|
// ref actually exists) and opportunistically warm the ledger with it,
|
|
@@ -86,12 +133,25 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
86
133
|
const issue = cached ?? (await deps.service.get(input.ref));
|
|
87
134
|
if (!cached) deps.ledger.upsert(parseRef(input.ref).backend, issue);
|
|
88
135
|
if (!issue.url) throw new FocusError(`issue "${input.ref}" has no URL from its backend; cannot focus without a full link`);
|
|
89
|
-
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 };
|
|
90
154
|
},
|
|
91
|
-
"focus.get": async (deps) => ({ focus: deps.focusStore.get() ?? null }),
|
|
92
|
-
"focus.pause": async (deps, input) => ({ focus: deps.focusStore.pause(input.reason) }),
|
|
93
|
-
"focus.unpause": async (deps) => ({ focus: deps.focusStore.unpause() }),
|
|
94
|
-
"focus.clear": async (deps) => ({ cleared: deps.focusStore.clear() }),
|
|
95
155
|
"discover.fields": async (deps, input) => ({ mappings: await deps.service.discoverFields(input.backend) }),
|
|
96
156
|
"discover.statuses": async (deps, input) => ({ mappings: await deps.service.discoverStatuses(input.backend) }),
|
|
97
157
|
"discover.template": async (deps, input) => ({
|
|
@@ -109,6 +169,37 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
109
169
|
if (!saved) throw new SavedQueryNotFoundError(input.name);
|
|
110
170
|
return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
|
|
111
171
|
},
|
|
172
|
+
"issue.subscribe": async (deps, input, callContext) => {
|
|
173
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
174
|
+
const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
|
|
175
|
+
deps.watches.subscribeIssue(input.ref, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
|
|
176
|
+
return { subscribed: true };
|
|
177
|
+
},
|
|
178
|
+
"issue.unsubscribe": async (deps, input, callContext) => {
|
|
179
|
+
deps.watches.unsubscribeIssue(input.ref, input.subscriberId ?? callContext?.callerSessionId ?? "");
|
|
180
|
+
return { unsubscribed: true };
|
|
181
|
+
},
|
|
182
|
+
"issue.subscribed": async (deps, input, callContext) => ({
|
|
183
|
+
watches: deps.watches.issueSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
|
|
184
|
+
}),
|
|
185
|
+
"query.subscribe": async (deps, input, callContext) => {
|
|
186
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
187
|
+
const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
|
|
188
|
+
deps.watches.subscribeQuery(input.name, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
|
|
189
|
+
return { subscribed: true };
|
|
190
|
+
},
|
|
191
|
+
"query.unsubscribe": async (deps, input, callContext) => {
|
|
192
|
+
deps.watches.unsubscribeQuery(input.name, input.subscriberId ?? callContext?.callerSessionId ?? "");
|
|
193
|
+
return { unsubscribed: true };
|
|
194
|
+
},
|
|
195
|
+
"query.subscribed": async (deps, input, callContext) => ({
|
|
196
|
+
watches: deps.watches.queryWatchSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
|
|
197
|
+
}),
|
|
198
|
+
"watch.events": async (deps, input, callContext) => {
|
|
199
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
200
|
+
const events = deps.watches.eventsSince(subscriberId, input.sinceId ?? 0, input.limit);
|
|
201
|
+
return { events, lastId: events.at(-1)?.id ?? input.sinceId ?? 0 };
|
|
202
|
+
},
|
|
112
203
|
"stage.add": async (deps, input) => ({ item: deps.stageStore.add(input.payload) }),
|
|
113
204
|
"stage.list": async (deps) => ({ items: deps.stageStore.list() }),
|
|
114
205
|
"stage.show": async (deps, input) => ({ item: deps.stageStore.show(input.id) }),
|
package/src/sqlite/focus.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Focus — the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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 (
|
|
88
|
-
VALUES (
|
|
89
|
-
ON CONFLICT(
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|