@danypops/vehicle-core 0.7.0 → 0.9.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/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/vehicle-approvals.d.ts +50 -0
- package/dist/vehicle-approvals.js +75 -0
- package/dist/vehicle-watchers.d.ts +60 -0
- package/dist/vehicle-watchers.js +85 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/vehicle-approvals.ts +130 -0
- package/src/vehicle-watchers.ts +103 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level shapes for the Approval Gate: a pending human/authority decision
|
|
3
|
+
* that stands between a gated-effect invoke() and its handler actually
|
|
4
|
+
* running. Kept in vehicle-core (runtime-neutral, zero dependencies) --
|
|
5
|
+
* the actual signing/verification authority (HmacApprovalAuthority) needs
|
|
6
|
+
* node:crypto and lives in vehicle-server instead, the same split
|
|
7
|
+
* atomic-json.ts already uses for fs access.
|
|
8
|
+
*/
|
|
9
|
+
import type { VehicleEffect, VehiclePrincipal } from "./vehicle-contract.js";
|
|
10
|
+
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
11
|
+
export declare const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[];
|
|
12
|
+
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
13
|
+
export declare const DEFAULT_APPROVAL_TIMEOUT_MS: number;
|
|
14
|
+
/** Emitted (as a Vehicle Event) the moment a gated-effect invoke() has no valid capability -- durable-first, before any interactive prompt is attempted. */
|
|
15
|
+
export interface VehicleApprovalRequest {
|
|
16
|
+
readonly requestId: string;
|
|
17
|
+
readonly operationName: string;
|
|
18
|
+
readonly operationVersion: number;
|
|
19
|
+
readonly effect: VehicleEffect;
|
|
20
|
+
readonly principal?: VehiclePrincipal;
|
|
21
|
+
readonly requestedAt: number;
|
|
22
|
+
readonly expiresAt: number;
|
|
23
|
+
/** sha256 hex of the exact input the gated invoke() attempted -- a minted capability is scoped to this input, not just the operation. */
|
|
24
|
+
readonly inputHash: string;
|
|
25
|
+
}
|
|
26
|
+
export type VehicleApprovalDecision = "granted" | "denied";
|
|
27
|
+
/** Emitted once vehicle.approval.resolve settles a request, whichever way. */
|
|
28
|
+
export interface VehicleApprovalOutcome {
|
|
29
|
+
readonly requestId: string;
|
|
30
|
+
readonly decision: VehicleApprovalDecision;
|
|
31
|
+
readonly decidedAt: number;
|
|
32
|
+
readonly decidedBy?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The real authority behind an approvalCapability -- replaces today's
|
|
36
|
+
* "any non-empty string satisfies it" rubber stamp. mint() is called only
|
|
37
|
+
* from inside vehicle.approval.resolve once a decision is actually made;
|
|
38
|
+
* verify() is called from invoke() itself against whatever capability the
|
|
39
|
+
* caller presents. A capability is scoped to the exact operation+input it
|
|
40
|
+
* was minted for and expires with its originating request -- presenting a
|
|
41
|
+
* capability minted for a different operation, a different input, or one
|
|
42
|
+
* already consumed (single-use) must fail verify().
|
|
43
|
+
*/
|
|
44
|
+
export interface VehicleApprovalAuthority {
|
|
45
|
+
mint(request: Pick<VehicleApprovalRequest, "requestId" | "operationName" | "operationVersion" | "expiresAt" | "inputHash">): string;
|
|
46
|
+
verify(capability: string, operationName: string, operationVersion: number, inputHash: string): boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Built into every VehicleRegistry once configureApprovals() is called -- never registered unconditionally, so a Vehicle that never opts in has zero manifest/shape change. */
|
|
49
|
+
export declare const vehicleApprovalRequestedEvent: import("./vehicle-contract.js").VehicleEvent<VehicleApprovalRequest>;
|
|
50
|
+
export declare const vehicleApprovalResolvedEvent: import("./vehicle-contract.js").VehicleEvent<VehicleApprovalOutcome>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
2
|
+
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
3
|
+
export const DEFAULT_APPROVAL_EFFECTS = ["destructive", "open-world"];
|
|
4
|
+
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
5
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
6
|
+
const requestedPayloadSchema = defineVehicleSchema({
|
|
7
|
+
jsonSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
requestId: { type: "string" },
|
|
11
|
+
operationName: { type: "string" },
|
|
12
|
+
operationVersion: { type: "number" },
|
|
13
|
+
effect: { type: "string" },
|
|
14
|
+
requestedAt: { type: "number" },
|
|
15
|
+
expiresAt: { type: "number" },
|
|
16
|
+
inputHash: { type: "string" },
|
|
17
|
+
},
|
|
18
|
+
required: ["requestId", "operationName", "operationVersion", "effect", "requestedAt", "expiresAt", "inputHash"],
|
|
19
|
+
additionalProperties: true,
|
|
20
|
+
},
|
|
21
|
+
safeParse(value) {
|
|
22
|
+
if (typeof value !== "object" || value === null)
|
|
23
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
24
|
+
const row = value;
|
|
25
|
+
if (typeof row.requestId !== "string" ||
|
|
26
|
+
typeof row.operationName !== "string" ||
|
|
27
|
+
typeof row.operationVersion !== "number" ||
|
|
28
|
+
typeof row.effect !== "string" ||
|
|
29
|
+
typeof row.requestedAt !== "number" ||
|
|
30
|
+
typeof row.expiresAt !== "number" ||
|
|
31
|
+
typeof row.inputHash !== "string") {
|
|
32
|
+
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
33
|
+
}
|
|
34
|
+
return { success: true, value: row };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const resolvedPayloadSchema = defineVehicleSchema({
|
|
38
|
+
jsonSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
requestId: { type: "string" },
|
|
42
|
+
decision: { type: "string", enum: ["granted", "denied"] },
|
|
43
|
+
decidedAt: { type: "number" },
|
|
44
|
+
decidedBy: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
required: ["requestId", "decision", "decidedAt"],
|
|
47
|
+
additionalProperties: true,
|
|
48
|
+
},
|
|
49
|
+
safeParse(value) {
|
|
50
|
+
if (typeof value !== "object" || value === null)
|
|
51
|
+
return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
52
|
+
const row = value;
|
|
53
|
+
if (typeof row.requestId !== "string" ||
|
|
54
|
+
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
55
|
+
typeof row.decidedAt !== "number") {
|
|
56
|
+
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
57
|
+
}
|
|
58
|
+
return { success: true, value: row };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
/** Built into every VehicleRegistry once configureApprovals() is called -- never registered unconditionally, so a Vehicle that never opts in has zero manifest/shape change. */
|
|
62
|
+
export const vehicleApprovalRequestedEvent = defineVehicleEvent({
|
|
63
|
+
name: "vehicle.approval.requested",
|
|
64
|
+
version: 1,
|
|
65
|
+
description: "A gated-effect operation was invoked without a valid approval capability.",
|
|
66
|
+
payload: requestedPayloadSchema,
|
|
67
|
+
maxPayloadBytes: 16_384,
|
|
68
|
+
});
|
|
69
|
+
export const vehicleApprovalResolvedEvent = defineVehicleEvent({
|
|
70
|
+
name: "vehicle.approval.resolved",
|
|
71
|
+
version: 1,
|
|
72
|
+
description: "A pending approval request was granted or denied.",
|
|
73
|
+
payload: resolvedPayloadSchema,
|
|
74
|
+
maxPayloadBytes: 4_096,
|
|
75
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Watch a changing resource, get notified" -- lifted near-verbatim from
|
|
3
|
+
* Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
|
|
4
|
+
* generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
|
|
5
|
+
* -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
|
|
6
|
+
* Confirmed independently reinvented three-plus times across this house's
|
|
7
|
+
* own ecosystem before this existed: Lector's own registry, Lector's CLI
|
|
8
|
+
* (a second, non-resilient reimplementation of the same watch/subscribe
|
|
9
|
+
* shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
|
|
10
|
+
* ensurePushChannel()+poll dance independently).
|
|
11
|
+
*
|
|
12
|
+
* Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
|
|
13
|
+
* I/O here. Matching pattern/resource against a real changed resource is a
|
|
14
|
+
* provider's own job, not this registry's; this only tracks which topic a
|
|
15
|
+
* given (scope, resource) pair publishes under, and bounds how many watches
|
|
16
|
+
* one scope can accumulate.
|
|
17
|
+
*/
|
|
18
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
19
|
+
export declare const DEFAULT_MAX_WATCHES_PER_SCOPE = 32;
|
|
20
|
+
export interface WatchRegistration {
|
|
21
|
+
readonly watchId: string;
|
|
22
|
+
readonly scope: string;
|
|
23
|
+
readonly resource: string;
|
|
24
|
+
readonly topic: string;
|
|
25
|
+
}
|
|
26
|
+
/** Raised when a scope already has its configured maximum of registrations -- fails closed, the same bounded-resource discipline every other Vehicle capability already applies, rather than letting one scope accumulate unbounded watch state. */
|
|
27
|
+
export declare class WatchLimitExceeded extends Error {
|
|
28
|
+
readonly scope: string;
|
|
29
|
+
readonly max: number;
|
|
30
|
+
constructor(scope: string, max: number);
|
|
31
|
+
}
|
|
32
|
+
export interface WatchRegistryOptions {
|
|
33
|
+
/** Defaults to DEFAULT_MAX_WATCHES_PER_SCOPE. */
|
|
34
|
+
readonly maxWatchesPerScope?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare class WatchRegistry {
|
|
37
|
+
private readonly byId;
|
|
38
|
+
private readonly byScope;
|
|
39
|
+
private readonly maxWatchesPerScope;
|
|
40
|
+
constructor(options?: WatchRegistryOptions);
|
|
41
|
+
add(scope: string, resource: string, watchId: string, topic: string): WatchRegistration;
|
|
42
|
+
/** The removed registration, or undefined if watchId was already unknown -- idempotent, like the rest of Vehicle's own unregister-shaped operations. Returns the registration itself (not just a boolean) so a caller can tell which scope lost its last watch without a separate lookup. */
|
|
43
|
+
remove(watchId: string): WatchRegistration | undefined;
|
|
44
|
+
/** False once a scope has zero remaining registrations -- a provider's own signal to release whatever underlying watch/subscription resource that scope was backing. */
|
|
45
|
+
hasAnyFor(scope: string): boolean;
|
|
46
|
+
registrationsFor(scope: string): readonly WatchRegistration[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The wire topic name a watch's changes publish under -- one shared naming
|
|
50
|
+
* function so a provider's publish() call and a subscriber's connectPushChannel()
|
|
51
|
+
* topic can never drift apart, the same role vehicleEventTopic() plays for
|
|
52
|
+
* Vehicle Events' own declared, fixed-schema event types. Deliberately a
|
|
53
|
+
* separate function/namespace from vehicleEventTopic(): a watch's topic is
|
|
54
|
+
* per-watch-instance-dynamic (one new topic per watchId), not a small fixed
|
|
55
|
+
* set of declared event types, so it doesn't fit Vehicle Events' own
|
|
56
|
+
* name@version schema-declaration model -- it reuses the same PushChannel
|
|
57
|
+
* transport substrate Vehicle Events made available generically, not the
|
|
58
|
+
* declared-event-type layer itself.
|
|
59
|
+
*/
|
|
60
|
+
export declare function vehicleWatchTopic(watchId: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Watch a changing resource, get notified" -- lifted near-verbatim from
|
|
3
|
+
* Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
|
|
4
|
+
* generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
|
|
5
|
+
* -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
|
|
6
|
+
* Confirmed independently reinvented three-plus times across this house's
|
|
7
|
+
* own ecosystem before this existed: Lector's own registry, Lector's CLI
|
|
8
|
+
* (a second, non-resilient reimplementation of the same watch/subscribe
|
|
9
|
+
* shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
|
|
10
|
+
* ensurePushChannel()+poll dance independently).
|
|
11
|
+
*
|
|
12
|
+
* Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
|
|
13
|
+
* I/O here. Matching pattern/resource against a real changed resource is a
|
|
14
|
+
* provider's own job, not this registry's; this only tracks which topic a
|
|
15
|
+
* given (scope, resource) pair publishes under, and bounds how many watches
|
|
16
|
+
* one scope can accumulate.
|
|
17
|
+
*/
|
|
18
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
19
|
+
export const DEFAULT_MAX_WATCHES_PER_SCOPE = 32;
|
|
20
|
+
/** Raised when a scope already has its configured maximum of registrations -- fails closed, the same bounded-resource discipline every other Vehicle capability already applies, rather than letting one scope accumulate unbounded watch state. */
|
|
21
|
+
export class WatchLimitExceeded extends Error {
|
|
22
|
+
scope;
|
|
23
|
+
max;
|
|
24
|
+
constructor(scope, max) {
|
|
25
|
+
super(`scope "${scope}" already has ${max} active watches -- unwatch one before adding another`);
|
|
26
|
+
this.scope = scope;
|
|
27
|
+
this.max = max;
|
|
28
|
+
this.name = "WatchLimitExceeded";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class WatchRegistry {
|
|
32
|
+
byId = new Map();
|
|
33
|
+
byScope = new Map();
|
|
34
|
+
maxWatchesPerScope;
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.maxWatchesPerScope = options.maxWatchesPerScope ?? DEFAULT_MAX_WATCHES_PER_SCOPE;
|
|
37
|
+
}
|
|
38
|
+
add(scope, resource, watchId, topic) {
|
|
39
|
+
const existing = this.byScope.get(scope) ?? new Set();
|
|
40
|
+
if (existing.size >= this.maxWatchesPerScope)
|
|
41
|
+
throw new WatchLimitExceeded(scope, this.maxWatchesPerScope);
|
|
42
|
+
const registration = { watchId, scope, resource, topic };
|
|
43
|
+
existing.add(watchId);
|
|
44
|
+
this.byScope.set(scope, existing);
|
|
45
|
+
this.byId.set(watchId, registration);
|
|
46
|
+
return registration;
|
|
47
|
+
}
|
|
48
|
+
/** The removed registration, or undefined if watchId was already unknown -- idempotent, like the rest of Vehicle's own unregister-shaped operations. Returns the registration itself (not just a boolean) so a caller can tell which scope lost its last watch without a separate lookup. */
|
|
49
|
+
remove(watchId) {
|
|
50
|
+
const registration = this.byId.get(watchId);
|
|
51
|
+
if (!registration)
|
|
52
|
+
return undefined;
|
|
53
|
+
this.byId.delete(watchId);
|
|
54
|
+
const scopeWatches = this.byScope.get(registration.scope);
|
|
55
|
+
scopeWatches?.delete(watchId);
|
|
56
|
+
if (scopeWatches?.size === 0)
|
|
57
|
+
this.byScope.delete(registration.scope);
|
|
58
|
+
return registration;
|
|
59
|
+
}
|
|
60
|
+
/** False once a scope has zero remaining registrations -- a provider's own signal to release whatever underlying watch/subscription resource that scope was backing. */
|
|
61
|
+
hasAnyFor(scope) {
|
|
62
|
+
return (this.byScope.get(scope)?.size ?? 0) > 0;
|
|
63
|
+
}
|
|
64
|
+
registrationsFor(scope) {
|
|
65
|
+
const ids = this.byScope.get(scope);
|
|
66
|
+
if (!ids)
|
|
67
|
+
return [];
|
|
68
|
+
return Array.from(ids, (id) => this.byId.get(id)).filter((registration) => registration !== undefined);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The wire topic name a watch's changes publish under -- one shared naming
|
|
73
|
+
* function so a provider's publish() call and a subscriber's connectPushChannel()
|
|
74
|
+
* topic can never drift apart, the same role vehicleEventTopic() plays for
|
|
75
|
+
* Vehicle Events' own declared, fixed-schema event types. Deliberately a
|
|
76
|
+
* separate function/namespace from vehicleEventTopic(): a watch's topic is
|
|
77
|
+
* per-watch-instance-dynamic (one new topic per watchId), not a small fixed
|
|
78
|
+
* set of declared event types, so it doesn't fit Vehicle Events' own
|
|
79
|
+
* name@version schema-declaration model -- it reuses the same PushChannel
|
|
80
|
+
* transport substrate Vehicle Events made available generically, not the
|
|
81
|
+
* declared-event-type layer itself.
|
|
82
|
+
*/
|
|
83
|
+
export function vehicleWatchTopic(watchId) {
|
|
84
|
+
return `vehicle-watch:${watchId}`;
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Vehicle's runtime-neutral wire contract: operation descriptors, schema codecs, failure shapes. Zero runtime dependencies, zero Bun-specific code -- the one thing every Vehicle client and server package depends on.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level shapes for the Approval Gate: a pending human/authority decision
|
|
3
|
+
* that stands between a gated-effect invoke() and its handler actually
|
|
4
|
+
* running. Kept in vehicle-core (runtime-neutral, zero dependencies) --
|
|
5
|
+
* the actual signing/verification authority (HmacApprovalAuthority) needs
|
|
6
|
+
* node:crypto and lives in vehicle-server instead, the same split
|
|
7
|
+
* atomic-json.ts already uses for fs access.
|
|
8
|
+
*/
|
|
9
|
+
import type { VehicleEffect, VehiclePrincipal } from "./vehicle-contract.js";
|
|
10
|
+
import { defineVehicleEvent, defineVehicleSchema } from "./vehicle-contract.js";
|
|
11
|
+
|
|
12
|
+
/** Set once at registry-configuration time (VehicleRegistry.configureApprovals()); never on a per-invoke basis. */
|
|
13
|
+
export const DEFAULT_APPROVAL_EFFECTS: readonly VehicleEffect[] = ["destructive", "open-world"];
|
|
14
|
+
|
|
15
|
+
/** How long a request stays resolvable before it lapses and must be re-requested. */
|
|
16
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
17
|
+
|
|
18
|
+
/** Emitted (as a Vehicle Event) the moment a gated-effect invoke() has no valid capability -- durable-first, before any interactive prompt is attempted. */
|
|
19
|
+
export interface VehicleApprovalRequest {
|
|
20
|
+
readonly requestId: string;
|
|
21
|
+
readonly operationName: string;
|
|
22
|
+
readonly operationVersion: number;
|
|
23
|
+
readonly effect: VehicleEffect;
|
|
24
|
+
readonly principal?: VehiclePrincipal;
|
|
25
|
+
readonly requestedAt: number;
|
|
26
|
+
readonly expiresAt: number;
|
|
27
|
+
/** sha256 hex of the exact input the gated invoke() attempted -- a minted capability is scoped to this input, not just the operation. */
|
|
28
|
+
readonly inputHash: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type VehicleApprovalDecision = "granted" | "denied";
|
|
32
|
+
|
|
33
|
+
/** Emitted once vehicle.approval.resolve settles a request, whichever way. */
|
|
34
|
+
export interface VehicleApprovalOutcome {
|
|
35
|
+
readonly requestId: string;
|
|
36
|
+
readonly decision: VehicleApprovalDecision;
|
|
37
|
+
readonly decidedAt: number;
|
|
38
|
+
readonly decidedBy?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The real authority behind an approvalCapability -- replaces today's
|
|
43
|
+
* "any non-empty string satisfies it" rubber stamp. mint() is called only
|
|
44
|
+
* from inside vehicle.approval.resolve once a decision is actually made;
|
|
45
|
+
* verify() is called from invoke() itself against whatever capability the
|
|
46
|
+
* caller presents. A capability is scoped to the exact operation+input it
|
|
47
|
+
* was minted for and expires with its originating request -- presenting a
|
|
48
|
+
* capability minted for a different operation, a different input, or one
|
|
49
|
+
* already consumed (single-use) must fail verify().
|
|
50
|
+
*/
|
|
51
|
+
export interface VehicleApprovalAuthority {
|
|
52
|
+
mint(request: Pick<VehicleApprovalRequest, "requestId" | "operationName" | "operationVersion" | "expiresAt" | "inputHash">): string;
|
|
53
|
+
verify(capability: string, operationName: string, operationVersion: number, inputHash: string): boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const requestedPayloadSchema = defineVehicleSchema<VehicleApprovalRequest>({
|
|
57
|
+
jsonSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
requestId: { type: "string" },
|
|
61
|
+
operationName: { type: "string" },
|
|
62
|
+
operationVersion: { type: "number" },
|
|
63
|
+
effect: { type: "string" },
|
|
64
|
+
requestedAt: { type: "number" },
|
|
65
|
+
expiresAt: { type: "number" },
|
|
66
|
+
inputHash: { type: "string" },
|
|
67
|
+
},
|
|
68
|
+
required: ["requestId", "operationName", "operationVersion", "effect", "requestedAt", "expiresAt", "inputHash"],
|
|
69
|
+
additionalProperties: true,
|
|
70
|
+
},
|
|
71
|
+
safeParse(value) {
|
|
72
|
+
if (typeof value !== "object" || value === null) return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
73
|
+
const row = value as Record<string, unknown>;
|
|
74
|
+
if (
|
|
75
|
+
typeof row.requestId !== "string" ||
|
|
76
|
+
typeof row.operationName !== "string" ||
|
|
77
|
+
typeof row.operationVersion !== "number" ||
|
|
78
|
+
typeof row.effect !== "string" ||
|
|
79
|
+
typeof row.requestedAt !== "number" ||
|
|
80
|
+
typeof row.expiresAt !== "number" ||
|
|
81
|
+
typeof row.inputHash !== "string"
|
|
82
|
+
) {
|
|
83
|
+
return { success: false, issues: [{ path: [], message: "invalid approval request payload" }] };
|
|
84
|
+
}
|
|
85
|
+
return { success: true, value: row as unknown as VehicleApprovalRequest };
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const resolvedPayloadSchema = defineVehicleSchema<VehicleApprovalOutcome>({
|
|
90
|
+
jsonSchema: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
requestId: { type: "string" },
|
|
94
|
+
decision: { type: "string", enum: ["granted", "denied"] },
|
|
95
|
+
decidedAt: { type: "number" },
|
|
96
|
+
decidedBy: { type: "string" },
|
|
97
|
+
},
|
|
98
|
+
required: ["requestId", "decision", "decidedAt"],
|
|
99
|
+
additionalProperties: true,
|
|
100
|
+
},
|
|
101
|
+
safeParse(value) {
|
|
102
|
+
if (typeof value !== "object" || value === null) return { success: false, issues: [{ path: [], message: "input must be an object" }] };
|
|
103
|
+
const row = value as Record<string, unknown>;
|
|
104
|
+
if (
|
|
105
|
+
typeof row.requestId !== "string" ||
|
|
106
|
+
(row.decision !== "granted" && row.decision !== "denied") ||
|
|
107
|
+
typeof row.decidedAt !== "number"
|
|
108
|
+
) {
|
|
109
|
+
return { success: false, issues: [{ path: [], message: "invalid approval outcome payload" }] };
|
|
110
|
+
}
|
|
111
|
+
return { success: true, value: row as unknown as VehicleApprovalOutcome };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** Built into every VehicleRegistry once configureApprovals() is called -- never registered unconditionally, so a Vehicle that never opts in has zero manifest/shape change. */
|
|
116
|
+
export const vehicleApprovalRequestedEvent = defineVehicleEvent<VehicleApprovalRequest>({
|
|
117
|
+
name: "vehicle.approval.requested",
|
|
118
|
+
version: 1,
|
|
119
|
+
description: "A gated-effect operation was invoked without a valid approval capability.",
|
|
120
|
+
payload: requestedPayloadSchema,
|
|
121
|
+
maxPayloadBytes: 16_384,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const vehicleApprovalResolvedEvent = defineVehicleEvent<VehicleApprovalOutcome>({
|
|
125
|
+
name: "vehicle.approval.resolved",
|
|
126
|
+
version: 1,
|
|
127
|
+
description: "A pending approval request was granted or denied.",
|
|
128
|
+
payload: resolvedPayloadSchema,
|
|
129
|
+
maxPayloadBytes: 4_096,
|
|
130
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Watch a changing resource, get notified" -- lifted near-verbatim from
|
|
3
|
+
* Lector's own `WatchRegistry` (packages/lector/src/domain/watch-registry.ts),
|
|
4
|
+
* generalized past Lector's own workspace/pattern vocabulary (`workspaceId`
|
|
5
|
+
* -> `scope`, `pattern` -> `resource`) into a shared Vehicle primitive.
|
|
6
|
+
* Confirmed independently reinvented three-plus times across this house's
|
|
7
|
+
* own ecosystem before this existed: Lector's own registry, Lector's CLI
|
|
8
|
+
* (a second, non-resilient reimplementation of the same watch/subscribe
|
|
9
|
+
* shape), and Papyrus's TaskOverlay/NoteOverlay (each hand-rolling the same
|
|
10
|
+
* ensurePushChannel()+poll dance independently).
|
|
11
|
+
*
|
|
12
|
+
* Pure, in-memory bookkeeping only -- no filesystem, network, or PushChannel
|
|
13
|
+
* I/O here. Matching pattern/resource against a real changed resource is a
|
|
14
|
+
* provider's own job, not this registry's; this only tracks which topic a
|
|
15
|
+
* given (scope, resource) pair publishes under, and bounds how many watches
|
|
16
|
+
* one scope can accumulate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Matches WatchRegistry's own historical default (Lector's MAX_WATCHES_PER_WORKSPACE). */
|
|
20
|
+
export const DEFAULT_MAX_WATCHES_PER_SCOPE = 32;
|
|
21
|
+
|
|
22
|
+
export interface WatchRegistration {
|
|
23
|
+
readonly watchId: string;
|
|
24
|
+
readonly scope: string;
|
|
25
|
+
readonly resource: string;
|
|
26
|
+
readonly topic: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Raised when a scope already has its configured maximum of registrations -- fails closed, the same bounded-resource discipline every other Vehicle capability already applies, rather than letting one scope accumulate unbounded watch state. */
|
|
30
|
+
export class WatchLimitExceeded extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly scope: string,
|
|
33
|
+
readonly max: number,
|
|
34
|
+
) {
|
|
35
|
+
super(`scope "${scope}" already has ${max} active watches -- unwatch one before adding another`);
|
|
36
|
+
this.name = "WatchLimitExceeded";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WatchRegistryOptions {
|
|
41
|
+
/** Defaults to DEFAULT_MAX_WATCHES_PER_SCOPE. */
|
|
42
|
+
readonly maxWatchesPerScope?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class WatchRegistry {
|
|
46
|
+
private readonly byId = new Map<string, WatchRegistration>();
|
|
47
|
+
private readonly byScope = new Map<string, Set<string>>();
|
|
48
|
+
private readonly maxWatchesPerScope: number;
|
|
49
|
+
|
|
50
|
+
constructor(options: WatchRegistryOptions = {}) {
|
|
51
|
+
this.maxWatchesPerScope = options.maxWatchesPerScope ?? DEFAULT_MAX_WATCHES_PER_SCOPE;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
add(scope: string, resource: string, watchId: string, topic: string): WatchRegistration {
|
|
55
|
+
const existing = this.byScope.get(scope) ?? new Set();
|
|
56
|
+
if (existing.size >= this.maxWatchesPerScope) throw new WatchLimitExceeded(scope, this.maxWatchesPerScope);
|
|
57
|
+
const registration: WatchRegistration = { watchId, scope, resource, topic };
|
|
58
|
+
existing.add(watchId);
|
|
59
|
+
this.byScope.set(scope, existing);
|
|
60
|
+
this.byId.set(watchId, registration);
|
|
61
|
+
return registration;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The removed registration, or undefined if watchId was already unknown -- idempotent, like the rest of Vehicle's own unregister-shaped operations. Returns the registration itself (not just a boolean) so a caller can tell which scope lost its last watch without a separate lookup. */
|
|
65
|
+
remove(watchId: string): WatchRegistration | undefined {
|
|
66
|
+
const registration = this.byId.get(watchId);
|
|
67
|
+
if (!registration) return undefined;
|
|
68
|
+
this.byId.delete(watchId);
|
|
69
|
+
const scopeWatches = this.byScope.get(registration.scope);
|
|
70
|
+
scopeWatches?.delete(watchId);
|
|
71
|
+
if (scopeWatches?.size === 0) this.byScope.delete(registration.scope);
|
|
72
|
+
return registration;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** False once a scope has zero remaining registrations -- a provider's own signal to release whatever underlying watch/subscription resource that scope was backing. */
|
|
76
|
+
hasAnyFor(scope: string): boolean {
|
|
77
|
+
return (this.byScope.get(scope)?.size ?? 0) > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
registrationsFor(scope: string): readonly WatchRegistration[] {
|
|
81
|
+
const ids = this.byScope.get(scope);
|
|
82
|
+
if (!ids) return [];
|
|
83
|
+
return Array.from(ids, (id) => this.byId.get(id)).filter(
|
|
84
|
+
(registration): registration is WatchRegistration => registration !== undefined,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The wire topic name a watch's changes publish under -- one shared naming
|
|
91
|
+
* function so a provider's publish() call and a subscriber's connectPushChannel()
|
|
92
|
+
* topic can never drift apart, the same role vehicleEventTopic() plays for
|
|
93
|
+
* Vehicle Events' own declared, fixed-schema event types. Deliberately a
|
|
94
|
+
* separate function/namespace from vehicleEventTopic(): a watch's topic is
|
|
95
|
+
* per-watch-instance-dynamic (one new topic per watchId), not a small fixed
|
|
96
|
+
* set of declared event types, so it doesn't fit Vehicle Events' own
|
|
97
|
+
* name@version schema-declaration model -- it reuses the same PushChannel
|
|
98
|
+
* transport substrate Vehicle Events made available generically, not the
|
|
99
|
+
* declared-event-type layer itself.
|
|
100
|
+
*/
|
|
101
|
+
export function vehicleWatchTopic(watchId: string): string {
|
|
102
|
+
return `vehicle-watch:${watchId}`;
|
|
103
|
+
}
|