@saadnvd1/agent-os 0.3.514 → 0.3.517
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/README.md +40 -1
- package/app/api/devices/me/push/route.test.ts +209 -0
- package/app/api/devices/me/push/route.ts +115 -0
- package/app/api/devices/me/route.ts +12 -0
- package/app/api/devices/route.ts +2 -0
- package/app/api/push/test/route.test.ts +112 -0
- package/app/api/push/test/route.ts +38 -0
- package/components/Devices/DeviceRow.tsx +52 -3
- package/components/Devices/DevicesPanel.tsx +13 -0
- package/data/devices/queries.ts +30 -2
- package/lib/away/run.ts +15 -3
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/connect/control-api.ts +38 -0
- package/lib/connect/serve.ts +107 -31
- package/lib/connect/supervise.ts +110 -11
- package/lib/db/migrations.ts +59 -0
- package/lib/done/index.ts +5 -1
- package/lib/load/alert.ts +5 -1
- package/lib/notify/copy.ts +190 -0
- package/lib/notify/index.ts +46 -7
- package/lib/notify/limiter.ts +6 -1
- package/lib/notify/push/encode.ts +42 -0
- package/lib/notify/push/events.ts +72 -0
- package/lib/notify/push/index.ts +183 -0
- package/lib/notify/push/outbox.ts +238 -0
- package/lib/notify/push/policy.ts +33 -0
- package/lib/notify/push/presence.ts +45 -0
- package/lib/notify/push/relay.ts +50 -0
- package/lib/notify/push/sender.ts +271 -0
- package/lib/notify/push/testing.ts +30 -0
- package/lib/notify/push/waiting.ts +29 -0
- package/lib/notify/push-types.ts +79 -0
- package/lib/notify/redact.ts +68 -0
- package/lib/orchestrator/asks.ts +5 -1
- package/lib/orchestrator/review.ts +3 -0
- package/lib/program-status/tap.ts +11 -1
- package/lib/schedules/start.ts +11 -2
- package/lib/security/demo.ts +1 -0
- package/lib/security/device-me.ts +12 -0
- package/lib/security/devices.ts +213 -5
- package/lib/security/net.ts +47 -11
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/session.ts +26 -2
- package/package.json +1 -1
- package/server.ts +3 -0
package/README.md
CHANGED
|
@@ -914,6 +914,32 @@ line at the bottom of Schedules), one of:
|
|
|
914
914
|
- **Telegram**: a bot token and your chat id. The token is kept in the
|
|
915
915
|
database and never shown again, returned by the API or logged.
|
|
916
916
|
|
|
917
|
+
#### Push to the phone app
|
|
918
|
+
|
|
919
|
+
A machine linked to Connect sends to the paired phones that have registered
|
|
920
|
+
for push, instead of Telegram. A push is generic: the title "AgentOS", a
|
|
921
|
+
category word and a link to open, no session or task text (the payload is a
|
|
922
|
+
versioned envelope, so an encrypted body can replace it later). It goes
|
|
923
|
+
through the Connect relay, signed with the machine key; AgentOS keeps a
|
|
924
|
+
`push_outbox` that retries with backoff, forgets tokens the relay reports as
|
|
925
|
+
dead, and caps alerting pushes at 10 an hour per device (one summary after).
|
|
926
|
+
|
|
927
|
+
- **What pushes**: an ask raised, a session waiting on you for a minute,
|
|
928
|
+
a task ready for review, CI or a review failing, a plan ready to approve,
|
|
929
|
+
and the Away digest. Merged is off by default. Each device picks its
|
|
930
|
+
categories (`PATCH /api/devices/me/push`). A task mirrored from another
|
|
931
|
+
machine never pushes from here.
|
|
932
|
+
- **Quiet rules**: waiting, review, CI and merged don't push while you're at
|
|
933
|
+
this Mac (keyboard or mouse used in the last 3 minutes). While Away is on,
|
|
934
|
+
everything except asks folds into the 10-minute digest.
|
|
935
|
+
- **Fallback**: agent messages, the Away digest and machine alerts (load,
|
|
936
|
+
schedules) go to Telegram when push isn't configured or fails. The newer
|
|
937
|
+
events are push-only. If Connect says push isn't set up yet, AgentOS
|
|
938
|
+
stops asking for 10 minutes and uses Telegram meanwhile.
|
|
939
|
+
- **Check it**: `aos push test` sends a real push to each registered phone
|
|
940
|
+
and prints what came back; Settings → Devices shows each device's push
|
|
941
|
+
state, categories, last error and a Send test button.
|
|
942
|
+
|
|
917
943
|
## Agent network
|
|
918
944
|
|
|
919
945
|
Sessions started by AgentOS can find and talk to each other without you,
|
|
@@ -940,6 +966,7 @@ aos schedules # every schedule, its next run and last outcom
|
|
|
940
966
|
aos schedule run <name> # run a schedule now
|
|
941
967
|
aos schedule add --session <s> --every 30m "prompt" # check in on a session
|
|
942
968
|
aos notify "text" # push a message to your phone
|
|
969
|
+
aos push test # a real test push to the paired phones, and the result
|
|
943
970
|
aos heavy -- npm test # run it now; says first what else heavy is running
|
|
944
971
|
aos stop <session> # stop its turn now; the next message runs as a new turn
|
|
945
972
|
aos done <session> # finished: merge through the gates, archive
|
|
@@ -1585,7 +1612,13 @@ device can use AgentOS but can't add devices or change access; only this
|
|
|
1585
1612
|
machine and the tailnet can.
|
|
1586
1613
|
|
|
1587
1614
|
AgentOS also refuses requests addressed to any other host name (DNS rebinding),
|
|
1588
|
-
and API calls or terminal connections made by another website's page.
|
|
1615
|
+
and API calls or terminal connections made by another website's page. That
|
|
1616
|
+
Origin check holds whatever `AGENTOS_BIND` says: a page may write to the API
|
|
1617
|
+
or open a terminal, chat or status socket only from this machine's own
|
|
1618
|
+
addresses (loopback, the addresses it listens on, its tailnet name, its own
|
|
1619
|
+
Connect address and `AGENTOS_ALLOWED_HOSTS`). Another Connect machine's page
|
|
1620
|
+
is always refused. With `AGENTOS_BIND=0.0.0.0`, reaching it by a DNS name
|
|
1621
|
+
other than these needs that name in `AGENTOS_ALLOWED_HOSTS`.
|
|
1589
1622
|
|
|
1590
1623
|
| Variable | Effect |
|
|
1591
1624
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
@@ -1613,6 +1646,12 @@ While Connect is on, the production server:
|
|
|
1613
1646
|
- asks the Connect service for a fresh connector token on every start and hands
|
|
1614
1647
|
it to `cloudflared tunnel run` in `TUNNEL_TOKEN` only, never in argv, a file
|
|
1615
1648
|
or a log;
|
|
1649
|
+
- starts cloudflared only once AgentOS holds the tunnel port and has seen a
|
|
1650
|
+
connection to it arrive on its own listener, and stops it if the listener
|
|
1651
|
+
goes, so it never publishes something else on that port;
|
|
1652
|
+
- runs cloudflared in a process group of its own under a small watchdog that
|
|
1653
|
+
kills it the moment AgentOS exits, however it exits, and kills one a crash
|
|
1654
|
+
left behind (from `cloudflared.pid`) before starting another;
|
|
1616
1655
|
- restarts cloudflared with backoff if it exits, and stops it when Connect is
|
|
1617
1656
|
turned off. Devices shows its state.
|
|
1618
1657
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { DELETE, PATCH, PUT } from "./route";
|
|
4
|
+
import { DELETE as SELF_DELETE } from "../route";
|
|
5
|
+
import {
|
|
6
|
+
deviceForToken,
|
|
7
|
+
getDevicePush,
|
|
8
|
+
mintDevice,
|
|
9
|
+
revokeDevice,
|
|
10
|
+
} from "@/lib/security/devices";
|
|
11
|
+
import { DEVICE_HEADER } from "@/lib/security/auth";
|
|
12
|
+
import { getDb } from "@/lib/db";
|
|
13
|
+
import { defaultCategories } from "@/lib/notify/copy";
|
|
14
|
+
|
|
15
|
+
const call = (
|
|
16
|
+
fn: (r: NextRequest) => Promise<Response>,
|
|
17
|
+
method: string,
|
|
18
|
+
device: string | null,
|
|
19
|
+
body?: unknown
|
|
20
|
+
) =>
|
|
21
|
+
fn(
|
|
22
|
+
new NextRequest("http://x/api/devices/me/push", {
|
|
23
|
+
method,
|
|
24
|
+
headers: device ? { [DEVICE_HEADER]: device } : {},
|
|
25
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const tok = (n: number) => "apns" + String(n).padStart(8, "0") + "ab".repeat(8);
|
|
30
|
+
|
|
31
|
+
describe("/api/devices/me/push", () => {
|
|
32
|
+
it("refuses a request that isn't from a paired device", async () => {
|
|
33
|
+
for (const [fn, m] of [
|
|
34
|
+
[PUT, "PUT"],
|
|
35
|
+
[PATCH, "PATCH"],
|
|
36
|
+
[DELETE, "DELETE"],
|
|
37
|
+
] as const)
|
|
38
|
+
expect((await call(fn, m, null, { token: tok(1) })).status).toBe(403);
|
|
39
|
+
expect(
|
|
40
|
+
(
|
|
41
|
+
await SELF_DELETE(
|
|
42
|
+
new NextRequest("http://x/api/devices/me", { method: "DELETE" })
|
|
43
|
+
)
|
|
44
|
+
).status
|
|
45
|
+
).toBe(403);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("registers, defaults the categories, and is idempotent", async () => {
|
|
49
|
+
const { device } = mintDevice("phone");
|
|
50
|
+
const res = await call(PUT, "PUT", device.id, {
|
|
51
|
+
token: tok(2),
|
|
52
|
+
environment: "sandbox",
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(200);
|
|
55
|
+
expect(await res.json()).toMatchObject({
|
|
56
|
+
deviceId: device.id,
|
|
57
|
+
enabled: true,
|
|
58
|
+
platform: "ios",
|
|
59
|
+
environment: "sandbox",
|
|
60
|
+
categories: defaultCategories(),
|
|
61
|
+
});
|
|
62
|
+
const again = await call(PUT, "PUT", device.id, {
|
|
63
|
+
token: tok(2),
|
|
64
|
+
environment: "sandbox",
|
|
65
|
+
});
|
|
66
|
+
expect(again.status).toBe(200);
|
|
67
|
+
expect(getDevicePush(device.id)?.token).toBe(tok(2));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a new token replaces the old and keeps saved categories", async () => {
|
|
71
|
+
const { device } = mintDevice("phone");
|
|
72
|
+
await call(PUT, "PUT", device.id, { token: tok(3) });
|
|
73
|
+
await call(PATCH, "PATCH", device.id, { categories: ["asks", "merged"] });
|
|
74
|
+
await call(PUT, "PUT", device.id, { token: tok(4) });
|
|
75
|
+
expect(getDevicePush(device.id)).toMatchObject({
|
|
76
|
+
token: tok(4),
|
|
77
|
+
categories: ["asks", "merged"],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("moves a token from the device that held it", async () => {
|
|
82
|
+
const a = mintDevice("old pairing").device;
|
|
83
|
+
const b = mintDevice("new pairing").device;
|
|
84
|
+
await call(PUT, "PUT", a.id, { token: tok(5) });
|
|
85
|
+
await call(PUT, "PUT", b.id, { token: tok(5) });
|
|
86
|
+
expect(getDevicePush(a.id)).toBeNull();
|
|
87
|
+
expect(getDevicePush(b.id)?.token).toBe(tok(5));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects bad input", async () => {
|
|
91
|
+
const { device } = mintDevice("phone");
|
|
92
|
+
for (const b of [
|
|
93
|
+
{},
|
|
94
|
+
{ token: "short" },
|
|
95
|
+
{ token: "bad token with spaces!!" },
|
|
96
|
+
{ token: tok(6), platform: "palm" },
|
|
97
|
+
{ token: tok(6), environment: "staging" },
|
|
98
|
+
{ token: tok(6), categories: ["asks", "nope"] },
|
|
99
|
+
{ token: tok(6), categories: "asks" },
|
|
100
|
+
])
|
|
101
|
+
expect((await call(PUT, "PUT", device.id, b)).status).toBe(400);
|
|
102
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("PATCH sets categories, needs a registration, and validates", async () => {
|
|
106
|
+
const { device } = mintDevice("phone");
|
|
107
|
+
expect(
|
|
108
|
+
(await call(PATCH, "PATCH", device.id, { categories: ["asks"] })).status
|
|
109
|
+
).toBe(404);
|
|
110
|
+
await call(PUT, "PUT", device.id, { token: tok(7) });
|
|
111
|
+
expect(
|
|
112
|
+
(await call(PATCH, "PATCH", device.id, { categories: ["x"] })).status
|
|
113
|
+
).toBe(400);
|
|
114
|
+
expect((await call(PATCH, "PATCH", device.id, {})).status).toBe(400);
|
|
115
|
+
const res = await call(PATCH, "PATCH", device.id, {
|
|
116
|
+
categories: ["ci", "ci"],
|
|
117
|
+
});
|
|
118
|
+
expect((await res.json()).categories).toEqual(["ci"]);
|
|
119
|
+
await call(PATCH, "PATCH", device.id, { categories: [] });
|
|
120
|
+
expect(getDevicePush(device.id)?.categories).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("DELETE clears push for this device only", async () => {
|
|
124
|
+
const a = mintDevice("a").device;
|
|
125
|
+
const b = mintDevice("b").device;
|
|
126
|
+
await call(PUT, "PUT", a.id, { token: tok(8) });
|
|
127
|
+
await call(PUT, "PUT", b.id, { token: tok(9) });
|
|
128
|
+
const res = await call(DELETE, "DELETE", a.id);
|
|
129
|
+
expect(await res.json()).toMatchObject({ enabled: false });
|
|
130
|
+
expect(getDevicePush(a.id)).toBeNull();
|
|
131
|
+
expect(getDevicePush(b.id)).not.toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("refuses an oversized body", async () => {
|
|
135
|
+
const { device } = mintDevice("phone");
|
|
136
|
+
const res = await call(PUT, "PUT", device.id, {
|
|
137
|
+
token: tok(14),
|
|
138
|
+
pad: "x".repeat(5000),
|
|
139
|
+
});
|
|
140
|
+
expect(res.status).toBe(413);
|
|
141
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads a corrupt saved category list as the defaults", async () => {
|
|
145
|
+
const { device } = mintDevice("phone");
|
|
146
|
+
await call(PUT, "PUT", device.id, { token: tok(15) });
|
|
147
|
+
for (const junk of ["{not json", '"asks"', "7"]) {
|
|
148
|
+
getDb()
|
|
149
|
+
.prepare(`UPDATE devices SET push_categories = ? WHERE id = ?`)
|
|
150
|
+
.run(junk, device.id);
|
|
151
|
+
expect(getDevicePush(device.id)?.categories).toEqual(defaultCategories());
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("never returns the token", async () => {
|
|
156
|
+
const { device } = mintDevice("phone");
|
|
157
|
+
const res = await call(PUT, "PUT", device.id, { token: tok(10) });
|
|
158
|
+
expect(JSON.stringify(await res.json())).not.toContain(tok(10));
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("device revocation and push", () => {
|
|
163
|
+
it("revoking clears every push column", async () => {
|
|
164
|
+
const { device } = mintDevice("phone");
|
|
165
|
+
await call(PUT, "PUT", device.id, { token: tok(11), categories: ["asks"] });
|
|
166
|
+
expect(revokeDevice(device.id)).toBe(true);
|
|
167
|
+
const row = getDb()
|
|
168
|
+
.prepare(
|
|
169
|
+
`SELECT push_token, push_platform, push_environment, push_categories,
|
|
170
|
+
push_updated_at, push_last_error FROM devices WHERE id = ?`
|
|
171
|
+
)
|
|
172
|
+
.get(device.id);
|
|
173
|
+
expect(Object.values(row as object).every((v) => v === null)).toBe(true);
|
|
174
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("DELETE /api/devices/me revokes the caller and its push", async () => {
|
|
178
|
+
const { device, token } = mintDevice("phone");
|
|
179
|
+
await call(PUT, "PUT", device.id, { token: tok(12) });
|
|
180
|
+
const res = await SELF_DELETE(
|
|
181
|
+
new NextRequest("http://x/api/devices/me", {
|
|
182
|
+
method: "DELETE",
|
|
183
|
+
headers: { [DEVICE_HEADER]: device.id },
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
expect(deviceForToken(token)).toBeNull();
|
|
188
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
189
|
+
// A second sign-out finds nothing.
|
|
190
|
+
expect(
|
|
191
|
+
(
|
|
192
|
+
await SELF_DELETE(
|
|
193
|
+
new NextRequest("http://x/api/devices/me", {
|
|
194
|
+
method: "DELETE",
|
|
195
|
+
headers: { [DEVICE_HEADER]: device.id },
|
|
196
|
+
})
|
|
197
|
+
)
|
|
198
|
+
).status
|
|
199
|
+
).toBe(404);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("a revoked device can't register push", async () => {
|
|
203
|
+
const { device } = mintDevice("phone");
|
|
204
|
+
revokeDevice(device.id);
|
|
205
|
+
expect((await call(PUT, "PUT", device.id, { token: tok(13) })).status).toBe(
|
|
206
|
+
404
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
clearDevicePush,
|
|
4
|
+
getDevicePush,
|
|
5
|
+
setDevicePush,
|
|
6
|
+
setDevicePushCategories,
|
|
7
|
+
} from "@/lib/security/devices";
|
|
8
|
+
import { callingDevice, needsDevice } from "@/lib/security/device-me";
|
|
9
|
+
import { readCapped } from "@/lib/security/read-capped";
|
|
10
|
+
import { isPushCategory } from "@/lib/notify/copy";
|
|
11
|
+
import {
|
|
12
|
+
PUSH_ENVIRONMENTS,
|
|
13
|
+
PUSH_PLATFORMS,
|
|
14
|
+
type PushCategory,
|
|
15
|
+
type PushEnvironment,
|
|
16
|
+
type PushPlatform,
|
|
17
|
+
} from "@/lib/notify/push-types";
|
|
18
|
+
|
|
19
|
+
const bad = (error: string) => NextResponse.json({ error }, { status: 400 });
|
|
20
|
+
|
|
21
|
+
const MAX_BODY_BYTES = 4096;
|
|
22
|
+
|
|
23
|
+
/** The JSON body, or null when it's over the cap. Bad JSON reads as empty. */
|
|
24
|
+
async function body(request: NextRequest) {
|
|
25
|
+
const text = await readCapped(request, MAX_BODY_BYTES);
|
|
26
|
+
if (text === null) return null;
|
|
27
|
+
try {
|
|
28
|
+
const v = JSON.parse(text || "{}");
|
|
29
|
+
return (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tooBig = () => NextResponse.json({ error: "Too large" }, { status: 413 });
|
|
36
|
+
|
|
37
|
+
/** Undefined if absent, null if present but invalid. */
|
|
38
|
+
function categoriesOf(v: unknown): PushCategory[] | undefined | null {
|
|
39
|
+
if (v === undefined) return undefined;
|
|
40
|
+
if (!Array.isArray(v) || !v.every(isPushCategory)) return null;
|
|
41
|
+
return [...new Set(v)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const view = (id: string) => {
|
|
45
|
+
const p = getDevicePush(id);
|
|
46
|
+
return {
|
|
47
|
+
deviceId: id,
|
|
48
|
+
enabled: !!p,
|
|
49
|
+
categories: p?.categories ?? [],
|
|
50
|
+
environment: p?.environment ?? null,
|
|
51
|
+
platform: p?.platform ?? null,
|
|
52
|
+
lastError: p?.lastError ?? null,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// PUT /api/devices/me/push - register or refresh this device's push token
|
|
57
|
+
export async function PUT(request: NextRequest) {
|
|
58
|
+
const id = callingDevice(request);
|
|
59
|
+
if (!id) return needsDevice();
|
|
60
|
+
const b = await body(request);
|
|
61
|
+
if (!b) return tooBig();
|
|
62
|
+
const token = b.token;
|
|
63
|
+
if (
|
|
64
|
+
typeof token !== "string" ||
|
|
65
|
+
token.length < 8 ||
|
|
66
|
+
token.length > 512 ||
|
|
67
|
+
!/^[A-Za-z0-9:_.\-]+$/.test(token)
|
|
68
|
+
)
|
|
69
|
+
return bad("token must be a device push token");
|
|
70
|
+
const platform = b.platform ?? "ios";
|
|
71
|
+
const environment = b.environment ?? "production";
|
|
72
|
+
if (!PUSH_PLATFORMS.includes(platform as PushPlatform))
|
|
73
|
+
return bad("platform must be ios or android");
|
|
74
|
+
if (!PUSH_ENVIRONMENTS.includes(environment as PushEnvironment))
|
|
75
|
+
return bad("environment must be production or sandbox");
|
|
76
|
+
const categories = categoriesOf(b.categories);
|
|
77
|
+
if (categories === null) return bad("categories must be known names");
|
|
78
|
+
// Intended: a phone re-paired to this machine gets a new device row and the
|
|
79
|
+
// same APNs token, which must move to it so the old row stops getting sends.
|
|
80
|
+
// Only a holder of a valid device token can register one.
|
|
81
|
+
if (
|
|
82
|
+
!setDevicePush(id, {
|
|
83
|
+
token,
|
|
84
|
+
platform: platform as PushPlatform,
|
|
85
|
+
environment: environment as PushEnvironment,
|
|
86
|
+
categories,
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
90
|
+
return NextResponse.json(view(id));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// PATCH /api/devices/me/push - choose which categories to receive
|
|
94
|
+
export async function PATCH(request: NextRequest) {
|
|
95
|
+
const id = callingDevice(request);
|
|
96
|
+
if (!id) return needsDevice();
|
|
97
|
+
const b = await body(request);
|
|
98
|
+
if (!b) return tooBig();
|
|
99
|
+
const categories = categoriesOf(b.categories);
|
|
100
|
+
if (!categories) return bad("categories must be a list of known names");
|
|
101
|
+
if (!setDevicePushCategories(id, categories))
|
|
102
|
+
return NextResponse.json(
|
|
103
|
+
{ error: "Push isn't registered" },
|
|
104
|
+
{ status: 404 }
|
|
105
|
+
);
|
|
106
|
+
return NextResponse.json(view(id));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// DELETE /api/devices/me/push - stop pushing to this device
|
|
110
|
+
export async function DELETE(request: NextRequest) {
|
|
111
|
+
const id = callingDevice(request);
|
|
112
|
+
if (!id) return needsDevice();
|
|
113
|
+
clearDevicePush(id);
|
|
114
|
+
return NextResponse.json(view(id));
|
|
115
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { revokeDevice } from "@/lib/security/devices";
|
|
3
|
+
import { callingDevice, needsDevice } from "@/lib/security/device-me";
|
|
4
|
+
|
|
5
|
+
// DELETE /api/devices/me - the device signs itself out ("Forget machine")
|
|
6
|
+
export async function DELETE(request: NextRequest) {
|
|
7
|
+
const id = callingDevice(request);
|
|
8
|
+
if (!id) return needsDevice();
|
|
9
|
+
if (!revokeDevice(id))
|
|
10
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
11
|
+
return NextResponse.json({ ok: true });
|
|
12
|
+
}
|
package/app/api/devices/route.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { listDevices } from "@/lib/security/devices";
|
|
3
3
|
import { DEVICE_HEADER, TRUST_HEADER } from "@/lib/security/auth";
|
|
4
4
|
import { demoMode } from "@/lib/security/demo";
|
|
5
|
+
import { pushStatus } from "@/lib/notify/push";
|
|
5
6
|
|
|
6
7
|
// GET /api/devices - paired devices, and how this request was trusted
|
|
7
8
|
export async function GET(request: NextRequest) {
|
|
@@ -10,6 +11,7 @@ export async function GET(request: NextRequest) {
|
|
|
10
11
|
devices: demoMode()
|
|
11
12
|
? listDevices().map(({ last_address: _a, user_agent: _u, ...d }) => d)
|
|
12
13
|
: listDevices(),
|
|
14
|
+
push: pushStatus(),
|
|
13
15
|
current: {
|
|
14
16
|
deviceId: request.headers.get(DEVICE_HEADER),
|
|
15
17
|
via: request.headers.get(TRUST_HEADER),
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const relaySend = vi.fn();
|
|
5
|
+
vi.mock("@/lib/notify/push/relay", () => ({
|
|
6
|
+
linked: () => true,
|
|
7
|
+
NOT_LINKED: "this machine isn't linked to Connect",
|
|
8
|
+
relaySend: (...a: unknown[]) => relaySend(...a),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const { POST } = await import("./route");
|
|
12
|
+
const { GET } = await import("../../devices/route");
|
|
13
|
+
const { pairPhone, resetPush } = await import("@/lib/notify/push/testing");
|
|
14
|
+
const { db } = await import("@/lib/db");
|
|
15
|
+
|
|
16
|
+
const post = (body?: unknown) =>
|
|
17
|
+
POST(
|
|
18
|
+
new NextRequest("http://x/api/push/test", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
resetPush();
|
|
26
|
+
relaySend.mockReset();
|
|
27
|
+
relaySend.mockImplementation(async (b: { devices: unknown[] }) => ({
|
|
28
|
+
kind: "results",
|
|
29
|
+
results: b.devices.map(() => ({ status: "sent" })),
|
|
30
|
+
}));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("POST /api/push/test", () => {
|
|
34
|
+
it("409s with no phone registered", async () => {
|
|
35
|
+
expect((await post({})).status).toBe(409);
|
|
36
|
+
expect(relaySend).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("404s for a device with no push", async () => {
|
|
40
|
+
pairPhone();
|
|
41
|
+
expect((await post({ deviceId: "nope" })).status).toBe(404);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("sends to the named device only and says what happened", async () => {
|
|
45
|
+
const a = pairPhone();
|
|
46
|
+
pairPhone();
|
|
47
|
+
const res = await post({ deviceId: a });
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
expect(await res.json()).toEqual({
|
|
50
|
+
results: [{ deviceId: a, status: "sent", error: null }],
|
|
51
|
+
unavailable: null,
|
|
52
|
+
});
|
|
53
|
+
expect(relaySend).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("explains when push isn't set up", async () => {
|
|
57
|
+
pairPhone();
|
|
58
|
+
relaySend.mockResolvedValue({
|
|
59
|
+
kind: "unavailable",
|
|
60
|
+
reason: "push isn't set up on the Connect service yet",
|
|
61
|
+
});
|
|
62
|
+
const res = await post({});
|
|
63
|
+
const body = await res.json();
|
|
64
|
+
expect(body.results[0].status).toBe("failed");
|
|
65
|
+
expect(body.unavailable).toMatch(/isn't set up/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("GET /api/devices push status", () => {
|
|
70
|
+
it("lists each device's push state and categories, never the token", async () => {
|
|
71
|
+
const id = pairPhone(["asks", "review"]);
|
|
72
|
+
const res = await GET(new NextRequest("http://x/api/devices"));
|
|
73
|
+
const body = await res.json();
|
|
74
|
+
const device = body.devices.find((d: { id: string }) => d.id === id);
|
|
75
|
+
expect(device.push).toEqual({
|
|
76
|
+
enabled: true,
|
|
77
|
+
categories: ["asks", "review"],
|
|
78
|
+
lastError: null,
|
|
79
|
+
updatedAt: expect.any(String),
|
|
80
|
+
});
|
|
81
|
+
expect(body.push).toEqual({ linked: true, unavailable: null });
|
|
82
|
+
const token = (
|
|
83
|
+
db.prepare(`SELECT push_token FROM devices WHERE id = ?`).get(id) as {
|
|
84
|
+
push_token: string;
|
|
85
|
+
}
|
|
86
|
+
).push_token;
|
|
87
|
+
expect(JSON.stringify(body)).not.toContain(token);
|
|
88
|
+
expect(Object.keys(device)).not.toContain("push_token");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("shows a device with no push as off, and a pruned one with its error", async () => {
|
|
92
|
+
const { mintDevice, pruneDevicePush } =
|
|
93
|
+
await import("@/lib/security/devices");
|
|
94
|
+
const off = mintDevice("laptop").device.id;
|
|
95
|
+
const dead = pairPhone();
|
|
96
|
+
pruneDevicePush(dead, "token no longer valid (Unregistered)");
|
|
97
|
+
const body = await (
|
|
98
|
+
await GET(new NextRequest("http://x/api/devices"))
|
|
99
|
+
).json();
|
|
100
|
+
const by = (id: string) =>
|
|
101
|
+
body.devices.find((d: { id: string }) => d.id === id).push;
|
|
102
|
+
expect(by(off)).toMatchObject({
|
|
103
|
+
enabled: false,
|
|
104
|
+
categories: [],
|
|
105
|
+
lastError: null,
|
|
106
|
+
});
|
|
107
|
+
expect(by(dead)).toMatchObject({
|
|
108
|
+
enabled: false,
|
|
109
|
+
lastError: expect.stringMatching(/Unregistered/),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { sendTestPush } from "@/lib/notify/push";
|
|
3
|
+
import { listPushDevices } from "@/lib/security/devices";
|
|
4
|
+
import { readCapped } from "@/lib/security/read-capped";
|
|
5
|
+
|
|
6
|
+
// POST /api/push/test { deviceId? } - a real push to the registered phones
|
|
7
|
+
// now, ignoring the quiet rules, and what the relay said about each.
|
|
8
|
+
export async function POST(request: NextRequest) {
|
|
9
|
+
let deviceId: string | undefined;
|
|
10
|
+
try {
|
|
11
|
+
const text = await readCapped(request, 1024);
|
|
12
|
+
if (text === null)
|
|
13
|
+
return NextResponse.json({ error: "Too large" }, { status: 413 });
|
|
14
|
+
const v = JSON.parse(text || "{}");
|
|
15
|
+
if (typeof v?.deviceId === "string") deviceId = v.deviceId;
|
|
16
|
+
} catch {
|
|
17
|
+
return NextResponse.json({ error: "Bad JSON" }, { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
const devices = listPushDevices().filter(
|
|
20
|
+
(d) => !deviceId || d.id === deviceId
|
|
21
|
+
);
|
|
22
|
+
if (!devices.length)
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{
|
|
25
|
+
error: deviceId
|
|
26
|
+
? "That device has no push registered"
|
|
27
|
+
: "No phone has push registered yet",
|
|
28
|
+
},
|
|
29
|
+
{ status: deviceId ? 404 : 409 }
|
|
30
|
+
);
|
|
31
|
+
const { results, unavailable } = await sendTestPush(deviceId);
|
|
32
|
+
if (unavailable && !results.length)
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: `Push isn't available: ${unavailable}` },
|
|
35
|
+
{ status: 409 }
|
|
36
|
+
);
|
|
37
|
+
return NextResponse.json({ results, unavailable });
|
|
38
|
+
}
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { Laptop, Smartphone, Tablet, Trash2 } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
|
-
import type {
|
|
6
|
-
import {
|
|
5
|
+
import type { ListedDevice } from "@/lib/security/devices";
|
|
6
|
+
import { CATEGORIES } from "@/lib/notify/copy";
|
|
7
|
+
import {
|
|
8
|
+
useRevokeDevice,
|
|
9
|
+
useSendTestPush,
|
|
10
|
+
useSetCanApprove,
|
|
11
|
+
} from "@/data/devices";
|
|
7
12
|
import { Switch } from "@/components/ui/switch";
|
|
8
13
|
|
|
9
14
|
// SQLite's datetime('now') is UTC without a zone.
|
|
@@ -31,13 +36,22 @@ export function DeviceRow({
|
|
|
31
36
|
isCurrent,
|
|
32
37
|
canManage,
|
|
33
38
|
}: {
|
|
34
|
-
device:
|
|
39
|
+
device: ListedDevice;
|
|
35
40
|
isCurrent: boolean;
|
|
36
41
|
// Only this machine or the tailnet may let a device approve asks.
|
|
37
42
|
canManage: boolean;
|
|
38
43
|
}) {
|
|
39
44
|
const revoke = useRevokeDevice();
|
|
40
45
|
const setCanApprove = useSetCanApprove();
|
|
46
|
+
const sendTest = useSendTestPush();
|
|
47
|
+
const tested = sendTest.data?.results[0];
|
|
48
|
+
const testNote = sendTest.isError
|
|
49
|
+
? sendTest.error.message
|
|
50
|
+
: tested
|
|
51
|
+
? tested.status === "sent"
|
|
52
|
+
? "Test sent"
|
|
53
|
+
: `Test failed: ${tested.error}`
|
|
54
|
+
: null;
|
|
41
55
|
|
|
42
56
|
const onRevoke = () => {
|
|
43
57
|
if (
|
|
@@ -64,7 +78,42 @@ export function DeviceRow({
|
|
|
64
78
|
{lastSeen(device.last_seen_at)}
|
|
65
79
|
{device.last_address ? ` · ${device.last_address}` : ""}
|
|
66
80
|
</p>
|
|
81
|
+
{device.push.enabled && (
|
|
82
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
83
|
+
Push on ·{" "}
|
|
84
|
+
{device.push.categories
|
|
85
|
+
.map((c) => CATEGORIES[c].label)
|
|
86
|
+
.join(", ") || "nothing selected"}
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
{device.push.lastError && (
|
|
90
|
+
<p
|
|
91
|
+
className="text-destructive truncate text-xs"
|
|
92
|
+
title={device.push.lastError}
|
|
93
|
+
>
|
|
94
|
+
Push error: {device.push.lastError}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
{testNote && (
|
|
98
|
+
<p
|
|
99
|
+
className={`truncate text-xs ${tested?.status === "sent" ? "text-muted-foreground" : "text-destructive"}`}
|
|
100
|
+
role="status"
|
|
101
|
+
>
|
|
102
|
+
{testNote}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
67
105
|
</div>
|
|
106
|
+
{device.push.enabled && (
|
|
107
|
+
<Button
|
|
108
|
+
variant="outline"
|
|
109
|
+
size="sm"
|
|
110
|
+
className="h-11 shrink-0 sm:h-8"
|
|
111
|
+
onClick={() => sendTest.mutate(device.id)}
|
|
112
|
+
disabled={sendTest.isPending}
|
|
113
|
+
>
|
|
114
|
+
{sendTest.isPending ? "Sending…" : "Send test"}
|
|
115
|
+
</Button>
|
|
116
|
+
)}
|
|
68
117
|
<label className="text-muted-foreground flex min-h-11 shrink-0 items-center gap-2 text-xs sm:min-h-8">
|
|
69
118
|
Can approve
|
|
70
119
|
<Switch
|
|
@@ -43,6 +43,19 @@ export function DevicesPanel() {
|
|
|
43
43
|
)
|
|
44
44
|
)}
|
|
45
45
|
|
|
46
|
+
{data && !data.push.linked && (
|
|
47
|
+
<p className="text-muted-foreground text-sm">
|
|
48
|
+
Phone push needs this machine linked to Connect. Until then,
|
|
49
|
+
notifications go to Telegram if it's set up.
|
|
50
|
+
</p>
|
|
51
|
+
)}
|
|
52
|
+
{data?.push.unavailable && (
|
|
53
|
+
<p className="text-destructive text-sm">
|
|
54
|
+
Push is paused: {data.push.unavailable}. Notifications go to Telegram
|
|
55
|
+
meanwhile.
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
|
|
46
59
|
<div className="space-y-2">
|
|
47
60
|
{isPending && (
|
|
48
61
|
<div className="bg-muted/40 h-14 animate-pulse rounded-lg" />
|