@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 +3 -3
- package/src/process/bootstrap.ts +20 -3
- package/src/rpc/error-status.ts +2 -0
- package/src/rpc/ops.ts +32 -5
- package/src/rpc/server.ts +50 -6
- package/src/sqlite/focus.ts +91 -30
- package/src/sqlite/session-identity.ts +94 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "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.
|
|
29
|
-
"@danypops/vehicle-client": "^0.
|
|
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",
|
package/src/process/bootstrap.ts
CHANGED
|
@@ -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
|
}
|
package/src/rpc/error-status.ts
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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) => ({
|
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
|
+
}
|