@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.
Files changed (46) hide show
  1. package/README.md +40 -1
  2. package/app/api/devices/me/push/route.test.ts +209 -0
  3. package/app/api/devices/me/push/route.ts +115 -0
  4. package/app/api/devices/me/route.ts +12 -0
  5. package/app/api/devices/route.ts +2 -0
  6. package/app/api/push/test/route.test.ts +112 -0
  7. package/app/api/push/test/route.ts +38 -0
  8. package/components/Devices/DeviceRow.tsx +52 -3
  9. package/components/Devices/DevicesPanel.tsx +13 -0
  10. package/data/devices/queries.ts +30 -2
  11. package/lib/away/run.ts +15 -3
  12. package/lib/chat/registry.ts +2 -0
  13. package/lib/chat/runner.ts +3 -0
  14. package/lib/connect/control-api.ts +38 -0
  15. package/lib/connect/serve.ts +107 -31
  16. package/lib/connect/supervise.ts +110 -11
  17. package/lib/db/migrations.ts +59 -0
  18. package/lib/done/index.ts +5 -1
  19. package/lib/load/alert.ts +5 -1
  20. package/lib/notify/copy.ts +190 -0
  21. package/lib/notify/index.ts +46 -7
  22. package/lib/notify/limiter.ts +6 -1
  23. package/lib/notify/push/encode.ts +42 -0
  24. package/lib/notify/push/events.ts +72 -0
  25. package/lib/notify/push/index.ts +183 -0
  26. package/lib/notify/push/outbox.ts +238 -0
  27. package/lib/notify/push/policy.ts +33 -0
  28. package/lib/notify/push/presence.ts +45 -0
  29. package/lib/notify/push/relay.ts +50 -0
  30. package/lib/notify/push/sender.ts +271 -0
  31. package/lib/notify/push/testing.ts +30 -0
  32. package/lib/notify/push/waiting.ts +29 -0
  33. package/lib/notify/push-types.ts +79 -0
  34. package/lib/notify/redact.ts +68 -0
  35. package/lib/orchestrator/asks.ts +5 -1
  36. package/lib/orchestrator/review.ts +3 -0
  37. package/lib/program-status/tap.ts +11 -1
  38. package/lib/schedules/start.ts +11 -2
  39. package/lib/security/demo.ts +1 -0
  40. package/lib/security/device-me.ts +12 -0
  41. package/lib/security/devices.ts +213 -5
  42. package/lib/security/net.ts +47 -11
  43. package/lib/tasks/finish.ts +2 -0
  44. package/lib/tasks/session.ts +26 -2
  45. package/package.json +1 -1
  46. 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
+ }
@@ -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 { Device } from "@/lib/security/devices";
6
- import { useRevokeDevice, useSetCanApprove } from "@/data/devices";
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: 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&apos;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" />