@frockbot/machine-protocol 0.0.0 → 0.1.1
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 +22 -6
- package/src/index.ts +4 -0
- package/src/messages.test.ts +200 -0
- package/src/protocol.test.ts +406 -0
- package/src/protocol.ts +1540 -0
- package/src/quota.test.ts +156 -0
- package/src/quota.ts +152 -0
- package/src/routes.test.ts +105 -0
- package/src/routes.ts +161 -0
- package/src/token.test.ts +149 -0
- package/src/token.ts +241 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MACHINE_QUOTA_DEFAULTS_V1,
|
|
4
|
+
checkMachineQuotaV1,
|
|
5
|
+
decodeMachineQuotaConfigV1,
|
|
6
|
+
machineQuotaRefusalV1,
|
|
7
|
+
type MachineQuotaOutcomeV1,
|
|
8
|
+
} from "./quota.ts";
|
|
9
|
+
import {
|
|
10
|
+
MACHINE_COMMANDS_PER_DAY,
|
|
11
|
+
MACHINE_MAX_PER_USER,
|
|
12
|
+
MACHINE_MAX_QUEUE,
|
|
13
|
+
} from "./protocol.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `plugin-audit/src/bot.ts`'s `outcomeFor`, copied verbatim rather than
|
|
17
|
+
* imported: a protocol package takes no dependency on a Package. The copy is
|
|
18
|
+
* the point of the assertion — if the classifier's wording ever moves, this
|
|
19
|
+
* test is where the machine refusals stop reading as `refused`.
|
|
20
|
+
*/
|
|
21
|
+
const AUDIT_REFUSAL = /\brefus|not allowed|denied|blocked while\b/i;
|
|
22
|
+
|
|
23
|
+
const refused = (outcome: MachineQuotaOutcomeV1) => {
|
|
24
|
+
expect(outcome.status).toBe("refused");
|
|
25
|
+
return outcome as Extract<MachineQuotaOutcomeV1, { status: "refused" }>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("machine quota", () => {
|
|
29
|
+
test("admits up to each limit and refuses at it", () => {
|
|
30
|
+
expect(
|
|
31
|
+
checkMachineQuotaV1({
|
|
32
|
+
kind: "register",
|
|
33
|
+
registeredMachines: MACHINE_MAX_PER_USER - 1,
|
|
34
|
+
}),
|
|
35
|
+
).toEqual({ status: "within" });
|
|
36
|
+
expect(
|
|
37
|
+
refused(
|
|
38
|
+
checkMachineQuotaV1({
|
|
39
|
+
kind: "register",
|
|
40
|
+
registeredMachines: MACHINE_MAX_PER_USER,
|
|
41
|
+
}),
|
|
42
|
+
).limitName,
|
|
43
|
+
).toBe("machine-count");
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
checkMachineQuotaV1({
|
|
47
|
+
kind: "dispatch",
|
|
48
|
+
queuedCommands: MACHINE_MAX_QUEUE - 1,
|
|
49
|
+
commandsToday: 0,
|
|
50
|
+
}),
|
|
51
|
+
).toEqual({ status: "within" });
|
|
52
|
+
expect(
|
|
53
|
+
refused(
|
|
54
|
+
checkMachineQuotaV1({
|
|
55
|
+
kind: "dispatch",
|
|
56
|
+
queuedCommands: MACHINE_MAX_QUEUE,
|
|
57
|
+
commandsToday: 0,
|
|
58
|
+
}),
|
|
59
|
+
).limitName,
|
|
60
|
+
).toBe("queue-depth");
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
refused(
|
|
64
|
+
checkMachineQuotaV1({
|
|
65
|
+
kind: "dispatch",
|
|
66
|
+
queuedCommands: 0,
|
|
67
|
+
commandsToday: MACHINE_COMMANDS_PER_DAY,
|
|
68
|
+
}),
|
|
69
|
+
).limitName,
|
|
70
|
+
).toBe("commands-per-day");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a breach is an outcome, never a throw, and names its numbers", () => {
|
|
74
|
+
const outcome = refused(
|
|
75
|
+
checkMachineQuotaV1({
|
|
76
|
+
kind: "dispatch",
|
|
77
|
+
queuedCommands: MACHINE_MAX_QUEUE + 4,
|
|
78
|
+
commandsToday: 0,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(outcome.used).toBe(MACHINE_MAX_QUEUE + 4);
|
|
82
|
+
expect(outcome.limit).toBe(MACHINE_MAX_QUEUE);
|
|
83
|
+
expect(outcome.reason).toContain(String(MACHINE_MAX_QUEUE));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("every refusal reads as `refused` to the audit classifier", () => {
|
|
87
|
+
const outcomes = [
|
|
88
|
+
checkMachineQuotaV1({
|
|
89
|
+
kind: "register",
|
|
90
|
+
registeredMachines: MACHINE_MAX_PER_USER,
|
|
91
|
+
}),
|
|
92
|
+
checkMachineQuotaV1({
|
|
93
|
+
kind: "dispatch",
|
|
94
|
+
queuedCommands: MACHINE_MAX_QUEUE,
|
|
95
|
+
commandsToday: 0,
|
|
96
|
+
}),
|
|
97
|
+
checkMachineQuotaV1({
|
|
98
|
+
kind: "dispatch",
|
|
99
|
+
queuedCommands: 0,
|
|
100
|
+
commandsToday: MACHINE_COMMANDS_PER_DAY,
|
|
101
|
+
}),
|
|
102
|
+
];
|
|
103
|
+
for (const outcome of outcomes) {
|
|
104
|
+
const text = machineQuotaRefusalV1(refused(outcome));
|
|
105
|
+
expect(text.startsWith("Refused: ")).toBe(true);
|
|
106
|
+
expect(AUDIT_REFUSAL.test(text)).toBe(true);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a configured quota overrides the defaults", () => {
|
|
111
|
+
const config = {
|
|
112
|
+
...MACHINE_QUOTA_DEFAULTS_V1,
|
|
113
|
+
maxQueuedCommands: 2,
|
|
114
|
+
};
|
|
115
|
+
expect(
|
|
116
|
+
checkMachineQuotaV1(
|
|
117
|
+
{ kind: "dispatch", queuedCommands: 1, commandsToday: 0 },
|
|
118
|
+
config,
|
|
119
|
+
),
|
|
120
|
+
).toEqual({ status: "within" });
|
|
121
|
+
expect(
|
|
122
|
+
checkMachineQuotaV1(
|
|
123
|
+
{ kind: "dispatch", queuedCommands: 2, commandsToday: 0 },
|
|
124
|
+
config,
|
|
125
|
+
).status,
|
|
126
|
+
).toBe("refused");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("the defaults are the protocol's declared limits", () => {
|
|
130
|
+
expect(MACHINE_QUOTA_DEFAULTS_V1).toEqual({
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
maxMachinesPerUser: MACHINE_MAX_PER_USER,
|
|
133
|
+
maxQueuedCommands: MACHINE_MAX_QUEUE,
|
|
134
|
+
maxCommandsPerDay: MACHINE_COMMANDS_PER_DAY,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("the config decoder is exact-key and bounded", () => {
|
|
139
|
+
expect(decodeMachineQuotaConfigV1(undefined)).toEqual(
|
|
140
|
+
MACHINE_QUOTA_DEFAULTS_V1,
|
|
141
|
+
);
|
|
142
|
+
expect(
|
|
143
|
+
decodeMachineQuotaConfigV1({ ...MACHINE_QUOTA_DEFAULTS_V1 }),
|
|
144
|
+
).toEqual(MACHINE_QUOTA_DEFAULTS_V1);
|
|
145
|
+
for (const bad of [
|
|
146
|
+
{ ...MACHINE_QUOTA_DEFAULTS_V1, extra: 1 },
|
|
147
|
+
{ ...MACHINE_QUOTA_DEFAULTS_V1, schemaVersion: 2 },
|
|
148
|
+
{ ...MACHINE_QUOTA_DEFAULTS_V1, maxMachinesPerUser: 0 },
|
|
149
|
+
{ ...MACHINE_QUOTA_DEFAULTS_V1, maxQueuedCommands: 100_000 },
|
|
150
|
+
{ ...MACHINE_QUOTA_DEFAULTS_V1, maxCommandsPerDay: 1.5 },
|
|
151
|
+
[MACHINE_QUOTA_DEFAULTS_V1],
|
|
152
|
+
]) {
|
|
153
|
+
expect(() => decodeMachineQuotaConfigV1(bad)).toThrow();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/quota.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The bounded per-User machine quota.
|
|
2
|
+
//
|
|
3
|
+
// "Quotas refuse visibly": exceeding one refuses the operation and records a
|
|
4
|
+
// visible failure, so this module never throws for a breach. It returns an
|
|
5
|
+
// outcome, exactly as `plugin-skills/src/quota.ts` does, and the caller turns
|
|
6
|
+
// that outcome into an `isError` tool result whose text `plugin-audit`
|
|
7
|
+
// classifies as `refused` rather than `error`.
|
|
8
|
+
//
|
|
9
|
+
// Three things are bounded, and each bounds a different loss:
|
|
10
|
+
//
|
|
11
|
+
// - **machines per User** — the registry is a projection the settings section
|
|
12
|
+
// renders and a tool lists; unbounded, it is an unbounded response.
|
|
13
|
+
// - **commands queued for one machine** — the queue lives in the User Durable
|
|
14
|
+
// Object and is drained by a laptop that may be asleep for a week.
|
|
15
|
+
// - **commands per User per day** — the one bound on a Bot that has learned
|
|
16
|
+
// to ask for approval often. It is a rate, so it needs a durable counter
|
|
17
|
+
// the User Durable Object keeps; this module only says whether the number
|
|
18
|
+
// it is handed is over.
|
|
19
|
+
|
|
20
|
+
import { MACHINE_LIMITS_V1 } from "./protocol.js";
|
|
21
|
+
|
|
22
|
+
export interface MachineQuotaConfigV1 {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
/** Machines one User may hold registered at once. */
|
|
25
|
+
maxMachinesPerUser: number;
|
|
26
|
+
/** Commands one machine may hold queued at once. */
|
|
27
|
+
maxQueuedCommands: number;
|
|
28
|
+
/** Commands one User may dispatch across all machines in a day. */
|
|
29
|
+
maxCommandsPerDay: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MACHINE_QUOTA_DEFAULTS_V1: MachineQuotaConfigV1 = {
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
maxMachinesPerUser: MACHINE_LIMITS_V1.maxMachinesPerUser,
|
|
35
|
+
maxQueuedCommands: MACHINE_LIMITS_V1.maxQueue,
|
|
36
|
+
maxCommandsPerDay: MACHINE_LIMITS_V1.commandsPerDay,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type MachineQuotaLimitV1 =
|
|
40
|
+
"machine-count" | "queue-depth" | "commands-per-day";
|
|
41
|
+
|
|
42
|
+
export type MachineQuotaRequestV1 =
|
|
43
|
+
| { kind: "register"; registeredMachines: number }
|
|
44
|
+
| { kind: "dispatch"; queuedCommands: number; commandsToday: number };
|
|
45
|
+
|
|
46
|
+
export type MachineQuotaOutcomeV1 =
|
|
47
|
+
| { status: "within" }
|
|
48
|
+
| {
|
|
49
|
+
status: "refused";
|
|
50
|
+
limitName: MachineQuotaLimitV1;
|
|
51
|
+
reason: string;
|
|
52
|
+
used: number;
|
|
53
|
+
limit: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks one registration or one dispatch against the quota. Never throws for
|
|
58
|
+
* a breach: a quota breach is an observable outcome the tool result reports.
|
|
59
|
+
*/
|
|
60
|
+
export function checkMachineQuotaV1(
|
|
61
|
+
request: MachineQuotaRequestV1,
|
|
62
|
+
config: MachineQuotaConfigV1 = MACHINE_QUOTA_DEFAULTS_V1,
|
|
63
|
+
): MachineQuotaOutcomeV1 {
|
|
64
|
+
if (request.kind === "register") {
|
|
65
|
+
if (request.registeredMachines >= config.maxMachinesPerUser) {
|
|
66
|
+
return {
|
|
67
|
+
status: "refused",
|
|
68
|
+
limitName: "machine-count",
|
|
69
|
+
reason: `this account holds ${request.registeredMachines} registered machines; the quota allows ${config.maxMachinesPerUser}`,
|
|
70
|
+
used: request.registeredMachines,
|
|
71
|
+
limit: config.maxMachinesPerUser,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { status: "within" };
|
|
75
|
+
}
|
|
76
|
+
if (request.queuedCommands >= config.maxQueuedCommands) {
|
|
77
|
+
return {
|
|
78
|
+
status: "refused",
|
|
79
|
+
limitName: "queue-depth",
|
|
80
|
+
reason: `this machine already has ${request.queuedCommands} commands waiting; the quota allows ${config.maxQueuedCommands}`,
|
|
81
|
+
used: request.queuedCommands,
|
|
82
|
+
limit: config.maxQueuedCommands,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (request.commandsToday >= config.maxCommandsPerDay) {
|
|
86
|
+
return {
|
|
87
|
+
status: "refused",
|
|
88
|
+
limitName: "commands-per-day",
|
|
89
|
+
reason: `this account has sent ${request.commandsToday} machine commands today; the quota allows ${config.maxCommandsPerDay}`,
|
|
90
|
+
used: request.commandsToday,
|
|
91
|
+
limit: config.maxCommandsPerDay,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { status: "within" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The refusal a tool result carries.
|
|
99
|
+
*
|
|
100
|
+
* The leading "Refused:" is not decoration. `plugin-audit`'s `outcomeFor`
|
|
101
|
+
* classifies an `isError` result by its text
|
|
102
|
+
* (`/\brefus|not allowed|denied|blocked while\b/i`, `plugin-audit/src/bot.ts`),
|
|
103
|
+
* so a quota breach that does not say it refused would be audited as an effect
|
|
104
|
+
* that ran and failed — which is a materially different fact about somebody's
|
|
105
|
+
* laptop.
|
|
106
|
+
*/
|
|
107
|
+
export function machineQuotaRefusalV1(
|
|
108
|
+
outcome: Extract<MachineQuotaOutcomeV1, { status: "refused" }>,
|
|
109
|
+
): string {
|
|
110
|
+
return `Refused: ${outcome.reason}.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function decodeMachineQuotaConfigV1(
|
|
114
|
+
input: unknown,
|
|
115
|
+
label = "machine quota configuration",
|
|
116
|
+
): MachineQuotaConfigV1 {
|
|
117
|
+
if (input === undefined) return { ...MACHINE_QUOTA_DEFAULTS_V1 };
|
|
118
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
119
|
+
throw new Error(`${label} must be an object`);
|
|
120
|
+
}
|
|
121
|
+
const value = input as Record<string, unknown>;
|
|
122
|
+
const keys = [
|
|
123
|
+
"schemaVersion",
|
|
124
|
+
"maxMachinesPerUser",
|
|
125
|
+
"maxQueuedCommands",
|
|
126
|
+
"maxCommandsPerDay",
|
|
127
|
+
];
|
|
128
|
+
if (
|
|
129
|
+
value.schemaVersion !== 1 ||
|
|
130
|
+
Object.keys(value).length !== keys.length ||
|
|
131
|
+
!keys.every((key) => Object.hasOwn(value, key))
|
|
132
|
+
) {
|
|
133
|
+
throw new Error(`${label} is invalid`);
|
|
134
|
+
}
|
|
135
|
+
const bounded = (name: string, maximum: number): number => {
|
|
136
|
+
const candidate = value[name];
|
|
137
|
+
if (
|
|
138
|
+
!Number.isSafeInteger(candidate) ||
|
|
139
|
+
(candidate as number) < 1 ||
|
|
140
|
+
(candidate as number) > maximum
|
|
141
|
+
) {
|
|
142
|
+
throw new Error(`${label}.${name} is out of range`);
|
|
143
|
+
}
|
|
144
|
+
return candidate as number;
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
schemaVersion: 1,
|
|
148
|
+
maxMachinesPerUser: bounded("maxMachinesPerUser", 64),
|
|
149
|
+
maxQueuedCommands: bounded("maxQueuedCommands", 256),
|
|
150
|
+
maxCommandsPerDay: bounded("maxCommandsPerDay", 10_000),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MACHINE_POLL_WAIT_PARAM_V1,
|
|
4
|
+
MACHINE_ROUTES_V1,
|
|
5
|
+
MACHINE_ROUTE_NAMES_V1,
|
|
6
|
+
MACHINE_ROUTE_PREFIX_V1,
|
|
7
|
+
decodeMachinePollWaitV1,
|
|
8
|
+
machineRoutePathV1,
|
|
9
|
+
} from "./routes.ts";
|
|
10
|
+
import { MACHINE_LIMITS_V1, MachineDecodeError } from "./protocol.ts";
|
|
11
|
+
|
|
12
|
+
const MACHINE_ID = "994dc2ee-3f42-4a4d-9f2a-0a3f6f0d1b77";
|
|
13
|
+
|
|
14
|
+
describe("machine route table", () => {
|
|
15
|
+
test("every machine-addressed route is public, and no browser route is", () => {
|
|
16
|
+
const publicRoutes = MACHINE_ROUTE_NAMES_V1.filter(
|
|
17
|
+
(name) => MACHINE_ROUTES_V1[name].publicRoute,
|
|
18
|
+
);
|
|
19
|
+
expect(publicRoutes).toEqual(["enroll", "poll", "claim", "result"]);
|
|
20
|
+
// Public means "no session", never "no authority": every public route is
|
|
21
|
+
// addressed by the machine, which presents a token instead.
|
|
22
|
+
for (const name of publicRoutes) {
|
|
23
|
+
expect(MACHINE_ROUTES_V1[name].audience).toBe("machine");
|
|
24
|
+
}
|
|
25
|
+
for (const name of MACHINE_ROUTE_NAMES_V1) {
|
|
26
|
+
const route = MACHINE_ROUTES_V1[name];
|
|
27
|
+
if (route.audience === "browser") expect(route.publicRoute).toBe(false);
|
|
28
|
+
expect(route.template.startsWith(MACHINE_ROUTE_PREFIX_V1)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("builds each concrete path", () => {
|
|
33
|
+
expect(machineRoutePathV1("pair")).toBe("/api/machines/pair");
|
|
34
|
+
expect(machineRoutePathV1("enroll")).toBe("/api/machines/enroll");
|
|
35
|
+
expect(machineRoutePathV1("list")).toBe("/api/machines");
|
|
36
|
+
expect(machineRoutePathV1("poll", { machineId: MACHINE_ID })).toBe(
|
|
37
|
+
`/api/machines/${MACHINE_ID}/poll`,
|
|
38
|
+
);
|
|
39
|
+
expect(
|
|
40
|
+
machineRoutePathV1("poll", { machineId: MACHINE_ID, waitSeconds: 25 }),
|
|
41
|
+
).toBe(`/api/machines/${MACHINE_ID}/poll?${MACHINE_POLL_WAIT_PARAM_V1}=25`);
|
|
42
|
+
expect(machineRoutePathV1("revoke", { machineId: MACHINE_ID })).toBe(
|
|
43
|
+
`/api/machines/${MACHINE_ID}/revoke`,
|
|
44
|
+
);
|
|
45
|
+
expect(
|
|
46
|
+
machineRoutePathV1("claim", {
|
|
47
|
+
machineId: MACHINE_ID,
|
|
48
|
+
commandId: "tool:3:1:0",
|
|
49
|
+
}),
|
|
50
|
+
).toBe(`/api/machines/${MACHINE_ID}/commands/tool%3A3%3A1%3A0/claim`);
|
|
51
|
+
expect(
|
|
52
|
+
machineRoutePathV1("result", {
|
|
53
|
+
machineId: MACHINE_ID,
|
|
54
|
+
commandId: "tool:3:1:0",
|
|
55
|
+
}),
|
|
56
|
+
).toBe(`/api/machines/${MACHINE_ID}/commands/tool%3A3%3A1%3A0/result`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("refuses a missing or unsafe segment rather than emitting one", () => {
|
|
60
|
+
expect(() => machineRoutePathV1("poll")).toThrow(/needs a valid machineId/);
|
|
61
|
+
expect(() =>
|
|
62
|
+
machineRoutePathV1("claim", { machineId: MACHINE_ID }),
|
|
63
|
+
).toThrow(/needs a valid commandId/);
|
|
64
|
+
for (const bad of ["../../admin", "a b", "", "-x"]) {
|
|
65
|
+
expect(() => machineRoutePathV1("poll", { machineId: bad })).toThrow(
|
|
66
|
+
MachineDecodeError,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("no template leaves an unsubstituted parameter behind", () => {
|
|
72
|
+
for (const name of MACHINE_ROUTE_NAMES_V1) {
|
|
73
|
+
const path = machineRoutePathV1(name, {
|
|
74
|
+
machineId: MACHINE_ID,
|
|
75
|
+
commandId: "tool:3:1:0",
|
|
76
|
+
});
|
|
77
|
+
expect(path).not.toContain(":machineId");
|
|
78
|
+
expect(path).not.toContain(":commandId");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("poll wait", () => {
|
|
84
|
+
test("accepts the ceiling from a number or a query string", () => {
|
|
85
|
+
expect(decodeMachinePollWaitV1(0)).toBe(0);
|
|
86
|
+
expect(decodeMachinePollWaitV1("25")).toBe(25);
|
|
87
|
+
expect(decodeMachinePollWaitV1(MACHINE_LIMITS_V1.pollMaxWaitSeconds)).toBe(
|
|
88
|
+
MACHINE_LIMITS_V1.pollMaxWaitSeconds,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("refuses a longer hold rather than silently clamping it", () => {
|
|
93
|
+
// A silently clamped wait is a backoff the agent believes it does not need.
|
|
94
|
+
for (const bad of [
|
|
95
|
+
MACHINE_LIMITS_V1.pollMaxWaitSeconds + 1,
|
|
96
|
+
"26",
|
|
97
|
+
-1,
|
|
98
|
+
1.5,
|
|
99
|
+
"twenty",
|
|
100
|
+
null,
|
|
101
|
+
]) {
|
|
102
|
+
expect(() => decodeMachinePollWaitV1(bad)).toThrow(MachineDecodeError);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The route table, declared once.
|
|
2
|
+
//
|
|
3
|
+
// Three parties address these paths — the browser, the gateway that registers
|
|
4
|
+
// them, and the device agent that dials them — and each would otherwise hold
|
|
5
|
+
// its own string. One table means the agent cannot poll a path the gateway
|
|
6
|
+
// does not serve, and a test cannot pass against a route nobody registered.
|
|
7
|
+
//
|
|
8
|
+
// `audience` and `publicRoute` are the load-bearing columns. `poll`, `claim`
|
|
9
|
+
// and `result` are declared public because they carry a *machine token*, not a
|
|
10
|
+
// session: they run at the seam in `apps/cloudflare/src/gateway.ts` that
|
|
11
|
+
// executes before session authentication, which `plugin-routines`' webhook
|
|
12
|
+
// already uses. Public here means "no session", never "no authority" — the
|
|
13
|
+
// token is verified at the edge and re-checked against the machine record's
|
|
14
|
+
// digest inside the User Durable Object.
|
|
15
|
+
|
|
16
|
+
import { MACHINE_LIMITS_V1, MachineDecodeError } from "./protocol.js";
|
|
17
|
+
|
|
18
|
+
export const MACHINE_ROUTE_PREFIX_V1 = "/api/machines";
|
|
19
|
+
|
|
20
|
+
export type MachineRouteNameV1 =
|
|
21
|
+
"pair" | "enroll" | "poll" | "claim" | "result" | "list" | "revoke";
|
|
22
|
+
|
|
23
|
+
export interface MachineRouteV1 {
|
|
24
|
+
method: "GET" | "POST";
|
|
25
|
+
/** The template the gateway registers, with `:machineId` / `:commandId`. */
|
|
26
|
+
template: string;
|
|
27
|
+
/** Who presents at this route. */
|
|
28
|
+
audience: "browser" | "machine";
|
|
29
|
+
/** Declared `publicRoute` on the gateway contribution: bearer, not session. */
|
|
30
|
+
publicRoute: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const MACHINE_ROUTES_V1: Readonly<
|
|
34
|
+
Record<MachineRouteNameV1, MachineRouteV1>
|
|
35
|
+
> = {
|
|
36
|
+
pair: {
|
|
37
|
+
method: "POST",
|
|
38
|
+
template: `${MACHINE_ROUTE_PREFIX_V1}/pair`,
|
|
39
|
+
audience: "browser",
|
|
40
|
+
publicRoute: false,
|
|
41
|
+
},
|
|
42
|
+
enroll: {
|
|
43
|
+
method: "POST",
|
|
44
|
+
template: `${MACHINE_ROUTE_PREFIX_V1}/enroll`,
|
|
45
|
+
audience: "machine",
|
|
46
|
+
publicRoute: true,
|
|
47
|
+
},
|
|
48
|
+
poll: {
|
|
49
|
+
method: "GET",
|
|
50
|
+
template: `${MACHINE_ROUTE_PREFIX_V1}/:machineId/poll`,
|
|
51
|
+
audience: "machine",
|
|
52
|
+
publicRoute: true,
|
|
53
|
+
},
|
|
54
|
+
claim: {
|
|
55
|
+
method: "POST",
|
|
56
|
+
template: `${MACHINE_ROUTE_PREFIX_V1}/:machineId/commands/:commandId/claim`,
|
|
57
|
+
audience: "machine",
|
|
58
|
+
publicRoute: true,
|
|
59
|
+
},
|
|
60
|
+
result: {
|
|
61
|
+
method: "POST",
|
|
62
|
+
template: `${MACHINE_ROUTE_PREFIX_V1}/:machineId/commands/:commandId/result`,
|
|
63
|
+
audience: "machine",
|
|
64
|
+
publicRoute: true,
|
|
65
|
+
},
|
|
66
|
+
list: {
|
|
67
|
+
method: "GET",
|
|
68
|
+
template: MACHINE_ROUTE_PREFIX_V1,
|
|
69
|
+
audience: "browser",
|
|
70
|
+
publicRoute: false,
|
|
71
|
+
},
|
|
72
|
+
revoke: {
|
|
73
|
+
method: "POST",
|
|
74
|
+
template: `${MACHINE_ROUTE_PREFIX_V1}/:machineId/revoke`,
|
|
75
|
+
audience: "browser",
|
|
76
|
+
publicRoute: false,
|
|
77
|
+
},
|
|
78
|
+
} as const;
|
|
79
|
+
|
|
80
|
+
export const MACHINE_ROUTE_NAMES_V1 = Object.keys(
|
|
81
|
+
MACHINE_ROUTES_V1,
|
|
82
|
+
) as MachineRouteNameV1[];
|
|
83
|
+
|
|
84
|
+
/** The query parameter a long poll asks its hold with. */
|
|
85
|
+
export const MACHINE_POLL_WAIT_PARAM_V1 = "wait";
|
|
86
|
+
|
|
87
|
+
export interface MachineRouteParamsV1 {
|
|
88
|
+
machineId?: string;
|
|
89
|
+
commandId?: string;
|
|
90
|
+
/** `poll` only: seconds to hold, bounded by `pollMaxWaitSeconds`. */
|
|
91
|
+
waitSeconds?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SEGMENT_SAFE = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The concrete path for one route.
|
|
98
|
+
*
|
|
99
|
+
* Every substituted segment is checked against the same identifier rule the
|
|
100
|
+
* decoders use and then percent-encoded: a `commandId` is an `effectId` and
|
|
101
|
+
* carries colons, which are legal in a path segment, but nothing here trusts
|
|
102
|
+
* that an id it was handed is one it minted.
|
|
103
|
+
*/
|
|
104
|
+
export function machineRoutePathV1(
|
|
105
|
+
name: MachineRouteNameV1,
|
|
106
|
+
params: MachineRouteParamsV1 = {},
|
|
107
|
+
): string {
|
|
108
|
+
const route = MACHINE_ROUTES_V1[name];
|
|
109
|
+
if (!route) {
|
|
110
|
+
throw new MachineDecodeError(`unknown machine route: ${String(name)}`);
|
|
111
|
+
}
|
|
112
|
+
const path = route.template.replace(
|
|
113
|
+
/:(machineId|commandId)/g,
|
|
114
|
+
(_match, key: "machineId" | "commandId") => {
|
|
115
|
+
const value = params[key];
|
|
116
|
+
if (
|
|
117
|
+
typeof value !== "string" ||
|
|
118
|
+
value.length === 0 ||
|
|
119
|
+
value.length > MACHINE_LIMITS_V1.identifier ||
|
|
120
|
+
!SEGMENT_SAFE.test(value)
|
|
121
|
+
) {
|
|
122
|
+
throw new MachineDecodeError(
|
|
123
|
+
`machine route ${name} needs a valid ${key}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return encodeURIComponent(value);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
if (name !== "poll" || params.waitSeconds === undefined) return path;
|
|
130
|
+
const wait = decodeMachinePollWaitV1(params.waitSeconds);
|
|
131
|
+
return `${path}?${MACHINE_POLL_WAIT_PARAM_V1}=${wait}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How long a poll may be held, from a number or from the raw query string.
|
|
136
|
+
*
|
|
137
|
+
* The ceiling is the protocol's, not the caller's: a machine that asks to be
|
|
138
|
+
* held for an hour is asking a Worker to hold a request past every limit it
|
|
139
|
+
* has, so the value is refused rather than silently clamped — a silently
|
|
140
|
+
* clamped wait is a backoff the agent thinks it does not need.
|
|
141
|
+
*/
|
|
142
|
+
export function decodeMachinePollWaitV1(
|
|
143
|
+
input: unknown,
|
|
144
|
+
label = "machine poll wait",
|
|
145
|
+
): number {
|
|
146
|
+
const value =
|
|
147
|
+
typeof input === "string" && /^[0-9]{1,4}$/.test(input)
|
|
148
|
+
? Number.parseInt(input, 10)
|
|
149
|
+
: input;
|
|
150
|
+
if (!Number.isSafeInteger(value)) {
|
|
151
|
+
throw new MachineDecodeError(`${label} must be an integer`);
|
|
152
|
+
}
|
|
153
|
+
const seconds = value as number;
|
|
154
|
+
if (seconds < 0 || seconds > MACHINE_LIMITS_V1.pollMaxWaitSeconds) {
|
|
155
|
+
throw new MachineDecodeError(
|
|
156
|
+
`${label} must be between 0 and ${MACHINE_LIMITS_V1.pollMaxWaitSeconds}`,
|
|
157
|
+
"limit-exceeded",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return seconds;
|
|
161
|
+
}
|