@jdscript/opencode-notify 0.0.0-stage → 0.1.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/README.md +209 -2
- package/VERIFICATION.md +59 -0
- package/dist/crypto.d.ts +8 -0
- package/dist/crypto.js +63 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +96 -0
- package/dist/rpc.d.ts +199 -0
- package/dist/rpc.js +60 -0
- package/dist/sender.d.ts +73 -0
- package/dist/sender.js +847 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +1 -0
- package/index.js +2 -0
- package/package.json +48 -4
package/README.md
CHANGED
|
@@ -1,3 +1,210 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenCode encrypted mobile notifications
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An independently installable **OpenCode v2 plugin**. It observes semantic events
|
|
4
|
+
inside OpenCode and sends end-to-end encrypted alerts and Live Activity updates to
|
|
5
|
+
the [Rust relay](../notification-service/README.md). It uses standard plugin RPC,
|
|
6
|
+
storage and event APIs; no OpenCode fork, external connector process or Apple key
|
|
7
|
+
is required on the OpenCode instance.
|
|
8
|
+
|
|
9
|
+
Validated with the **published, unmodified `@opencode/sdk` 2.0.26**, using normal
|
|
10
|
+
`plugins` configuration and Bun 1.4.0. Earlier v2 versions have not been validated;
|
|
11
|
+
the app negotiates `@jdscript/opencode-notify/mobile-push` application protocol v1 before handing over keys.
|
|
12
|
+
|
|
13
|
+
## Install from this checkout
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
cd notification-plugin
|
|
17
|
+
bun install --frozen-lockfile
|
|
18
|
+
bun run build
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Add the built plugin directory to the OpenCode server user's **global**
|
|
22
|
+
`~/.config/opencode/opencode.json` (or the corresponding XDG config directory):
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"plugins": ["/absolute/path/to/OpenCode-iOS/notification-plugin"]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Global configuration makes the sender available regardless of which project the
|
|
31
|
+
mobile app opens. Directory-local configuration limits when the plugin loads and
|
|
32
|
+
is not the intended server-wide enrollment setup. The root `index.js` supports
|
|
33
|
+
OpenCode's local-directory entry discovery; build `dist/` before loading it.
|
|
34
|
+
|
|
35
|
+
The package is prepared as **`@jdscript/opencode-notify`**, but this checkout does
|
|
36
|
+
not publish it to npm. Once published, the equivalent global configuration is:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{ "plugins": ["@jdscript/opencode-notify"] }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The same package can be bundled into a fork as a default plugin. That is optional;
|
|
43
|
+
the implementation and RPC protocol are identical for upstream installations.
|
|
44
|
+
|
|
45
|
+
## Pair and subscribe
|
|
46
|
+
|
|
47
|
+
1. Deploy the relay with Apple credentials for the iOS app's signed bundle/environment.
|
|
48
|
+
2. In the app's Settings → Notifications → Encrypted push, pair the relay using an
|
|
49
|
+
operator-issued one-use enrollment ticket. The app registers its APNs token.
|
|
50
|
+
3. Enable a saved OpenCode server. The app negotiates the plugin, creates a distinct
|
|
51
|
+
relay subscription, and transfers its **send-only capability**, recipient public
|
|
52
|
+
key and authentication secret over its existing authenticated OpenCode connection.
|
|
53
|
+
4. Ongoing work automatically gets one cross-server Live Activity. The app registers
|
|
54
|
+
independently authorized source routes for its single ActivityKit token, then
|
|
55
|
+
attaches each source with a bounded initial session/shell inventory.
|
|
56
|
+
|
|
57
|
+
The iOS default gateway URL is **`https://opencode-mps-prod.jdscript.app`**. Users
|
|
58
|
+
can enter their own HTTPS gateway and reset the selection with **Use default**.
|
|
59
|
+
The app persists this preference, while an existing pairing stays bound to its
|
|
60
|
+
original gateway. Changing a paired gateway requires unpairing and enrolling at the
|
|
61
|
+
new one. `subscribe.relayURL` is taken from the **paired device credential's origin**,
|
|
62
|
+
not an unsaved text-field value. The plugin sends to that origin's `/v1/push`;
|
|
63
|
+
it has no hardcoded default gateway and receives no relay management credential.
|
|
64
|
+
|
|
65
|
+
An editable URL does not grant APNs authority. A direct self-hosted sender needs
|
|
66
|
+
Apple credentials authorized for the installed app's bundle/team. Developers signing
|
|
67
|
+
their own app can use their own keys. Stock-app users cannot directly replace the
|
|
68
|
+
publisher's APNs sender merely by deploying this relay. A forwarding adapter to a
|
|
69
|
+
publisher-owned delivery service would be a separate integration, not present here.
|
|
70
|
+
|
|
71
|
+
OpenCode needs no global identity/account at the relay. Its durable plugin storage
|
|
72
|
+
contains subscription configuration and keys authorized by that device. Relay/device
|
|
73
|
+
management tokens and Apple device/activity tokens never enter this plugin.
|
|
74
|
+
HTTPS is required for remote relay origins; loopback HTTP is supported for local
|
|
75
|
+
development. The app also requires confidential transport for its key handoff to OpenCode.
|
|
76
|
+
|
|
77
|
+
## RPC contract
|
|
78
|
+
|
|
79
|
+
The exported `./rpc` definition is a portable Standard Schema definition. Generic
|
|
80
|
+
The RPC ID is `@jdscript/opencode-notify/mobile-push`. HTTP calls use
|
|
81
|
+
`POST /api/rpc/{rpcID}/{method}`, `{ "input": ... }`, and the normal OpenCode
|
|
82
|
+
authentication/location context. The entire RPC ID is **one percent-encoded path
|
|
83
|
+
segment**, for example:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
POST /api/rpc/%40jdscript%2Fopencode-notify%2Fmobile-push/info
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Both the OpenCode SDK and iOS client encode the parameter. Reverse proxies must
|
|
90
|
+
preserve encoded slashes when forwarding it. Responses use `{ "output": ... }`.
|
|
91
|
+
|
|
92
|
+
| Method | Input | Output |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `info` | `{}` | `{version:1, encoding:"aes128gcm", applicationVersion:1, activityScopes:["session","server","workspace"]}` |
|
|
95
|
+
| `subscribe` | `subscriptionID`, positive `revision`, `relayURL`, `sendToken`, `expiresAt`, `serverID`, `installationID`, `recipientPublicKey`, `authSecret`, `preferences` | Safe subscription status |
|
|
96
|
+
| `status` | `{subscriptionID}` | `{subscriptionID, revision, expiresAt, state}` |
|
|
97
|
+
| `unsubscribe` | `{subscriptionID}` | `{ok:true}` |
|
|
98
|
+
| `attach` | `{subscriptionID, activityID, sessionID, expiresAt, scope?, serverName?, seedComplete?, seeds?, shells?}` | `{ok:true}` |
|
|
99
|
+
| `detach` | `{subscriptionID, activityID}` | `{ok:true}` |
|
|
100
|
+
|
|
101
|
+
IDs are lowercase UUIDs except OpenCode's session ID. Keys use unpadded base64url:
|
|
102
|
+
65-byte X9.63 P-256 public key and independent 16-byte RFC 8291 authentication
|
|
103
|
+
secret. `preferences` is `{completed:boolean, requests:boolean, failures:boolean}`.
|
|
104
|
+
Dates are Unix seconds; subscription expiry is at most 90 days, activity expiry at
|
|
105
|
+
most eight hours. Receiver keys/binding cannot change under an existing subscription
|
|
106
|
+
ID; generate a fresh subscription for a new binding. Sender credential/preferences
|
|
107
|
+
changes use increasing revisions; identical current-revision retries are idempotent.
|
|
108
|
+
|
|
109
|
+
Server overviews use `scope: "server"` and the binding sentinel `sessionID: "ses_workspace"`.
|
|
110
|
+
Optional `seeds` contain at most 512 `{sessionID, running, waiting: requestID[]}`
|
|
111
|
+
entries; `shells` contains at most 512 `{id, sessionID?}` entries. These seed current
|
|
112
|
+
observations; later server events take precedence. Session routes remain supported
|
|
113
|
+
for existing clients, and a route cannot change scope after attachment. Summaries
|
|
114
|
+
include at most three attention-first root-session rows and an optional total count.
|
|
115
|
+
An incomplete seed or restarted observer omits the exact count and does not claim completion.
|
|
116
|
+
|
|
117
|
+
Unified activities use `scope: "workspace"`. Their push requests carry `event:
|
|
118
|
+
"update"` and `settled: boolean`; a source must never end the shared system activity
|
|
119
|
+
on its own. The gateway groups source routes using device-owner registration,
|
|
120
|
+
serializes their APNs timestamps, and emits the system end only after all expected
|
|
121
|
+
sources settle. The widget caches separately authenticated encrypted fragments, so
|
|
122
|
+
one source does not replace another's view. This requires the gateway's advertised
|
|
123
|
+
`workspace_activities` capability and migration `0003_workspace_activities.sql`.
|
|
124
|
+
|
|
125
|
+
Expected RPC failures have type `invalid` and a safe `data.code`, such as
|
|
126
|
+
`subscription_not_found`, `subscription_revoked`, `stale_revision`, `revision_conflict`,
|
|
127
|
+
`invalid_relay_origin`, `activity_ended` or `content_too_large`. Status never returns
|
|
128
|
+
keys, sender tokens, relay credentials or notification content.
|
|
129
|
+
|
|
130
|
+
## Event and delivery behavior
|
|
131
|
+
|
|
132
|
+
- Observes native execution/status, permission/form and shell events. No token/text
|
|
133
|
+
deltas, model calls, transcript copies, tool arguments or output are sent.
|
|
134
|
+
- Primary completion/failure alerts wait for observed descendant/background work to
|
|
135
|
+
settle; permission/form requests notify promptly. Counts describe observed activity.
|
|
136
|
+
- After a new load, a session without an observed execution/status is "Awaiting
|
|
137
|
+
activity", not inferred complete from a previous run's historical outcome. Existing
|
|
138
|
+
work that predates plugin activation needs subsequent events to establish live state.
|
|
139
|
+
- A host's Location plugin copies share a runtime, preventing duplicate notifications
|
|
140
|
+
when multiple projects are open. Separate embedded hosts have independent state.
|
|
141
|
+
- Progress changes coalesce to at most one update per five seconds; attention/end
|
|
142
|
+
wait only for a strictly newer timestamp if occurring in the same second. A tracked
|
|
143
|
+
activity gets a 30-second freshness update. Rendering a timer needs no per-second push.
|
|
144
|
+
- Outbox records persist already-encrypted bodies and event UUIDs through server
|
|
145
|
+
restart. Retries reuse exact ciphertext, respect `Retry-After` and stop at content
|
|
146
|
+
expiry/12 attempts. Live updates replace superseded pending updates. No exactly-once
|
|
147
|
+
delivery guarantee is implied; the relay and receiver also deduplicate.
|
|
148
|
+
- Revocation removes pending work. Already-dispatched requests can complete; central
|
|
149
|
+
relay revocation is the authority for rejecting later sends. Invalid credentials
|
|
150
|
+
pause sending until device setup is repaired. Ended route IDs cannot be reused.
|
|
151
|
+
- Relay POSTs follow no redirects, have a 15-second deadline and contain only the
|
|
152
|
+
send capability, ciphertext and necessary activity lifecycle controls.
|
|
153
|
+
- Billing rejection (HTTP 402) drops denied work and briefly suppresses further
|
|
154
|
+
paid updates without treating the sender credential as invalid. Authorized
|
|
155
|
+
activity ends remain possible; later activity retries can observe renewed access.
|
|
156
|
+
|
|
157
|
+
The plugin caps subscriptions at 128 and pending outbox entries at 512 per host.
|
|
158
|
+
Completed alert source IDs retain deduplication receipts for one day. Device subscription
|
|
159
|
+
management remains in the app/relay; this plugin does not create accounts or host an
|
|
160
|
+
additional network listener.
|
|
161
|
+
|
|
162
|
+
## Encryption and application payload
|
|
163
|
+
|
|
164
|
+
RFC 8291 `aes128gcm` is implemented using Node-compatible P-256 ECDH, HKDF-SHA256
|
|
165
|
+
and AES-128-GCM. Fresh sender keys and salt are generated for each new message.
|
|
166
|
+
The app's `NotificationShared/NotificationCrypto.swift` uses the same RFC record.
|
|
167
|
+
Both implementations reproduce the published RFC vector byte-for-byte.
|
|
168
|
+
|
|
169
|
+
Authenticated application JSON contains:
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
version, subscriptionID, eventID, installationID, serverID,
|
|
173
|
+
kind (alert | live_activity), producer (server), issuedAt, expiresAt,
|
|
174
|
+
sessionID, directory, content
|
|
175
|
+
+ activityID, event (update | end), timestamp for Live Activities
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`content` contains bounded `title`, `project`, `phase`, `detail`, `agents`, `shells`,
|
|
179
|
+
`waiting`, optional `cost` / `startedAt`, and `updatedAt`. Freshness is at most one
|
|
180
|
+
hour with 30 seconds allowed future skew. The serialized plaintext has a 2400-byte
|
|
181
|
+
budget; directories are preserved exactly (max 1024 UTF-8 bytes), never truncated
|
|
182
|
+
into another binding. No server URL is needed in the payload.
|
|
183
|
+
|
|
184
|
+
The relay cannot decrypt this content. Lifecycle fields needed by ActivityKit
|
|
185
|
+
remain visible to APNs/the relay. Ordinary NSE and encrypted Widget implementations
|
|
186
|
+
are in this repository; real APNs and locked-device behavior still need signed
|
|
187
|
+
hardware validation. Remote push-to-start is outside v1; activities start in the app.
|
|
188
|
+
|
|
189
|
+
## Verify
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
bun run typecheck
|
|
193
|
+
bun test
|
|
194
|
+
bun run build
|
|
195
|
+
python3 scripts/check-upstream.py
|
|
196
|
+
# macOS + Rust/Xcode: unmodified upstream → plugin → real Rust relay → production Swift receiver
|
|
197
|
+
python3 scripts/check-upstream.py --rust-relay --swift
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The upstream check creates isolated HOME/config/data/database directories. It loads
|
|
201
|
+
the built plugin through global config, creates disposable sessions/forms without
|
|
202
|
+
any model prompts, tests multiple locations, restarts the host and revokes enrollment.
|
|
203
|
+
The optional Rust target uses the production relay router, storage and APNs payload
|
|
204
|
+
construction with captured APNs output. Swift then decrypts and validates those exact
|
|
205
|
+
envelopes using the production shared receiver. No Apple credentials or existing
|
|
206
|
+
OpenCode sessions are involved. The Rust capture executable is a development example,
|
|
207
|
+
not a mode in the deployed relay binary.
|
|
208
|
+
|
|
209
|
+
The recorded verification scope and remaining hardware checks are in
|
|
210
|
+
[`VERIFICATION.md`](VERIFICATION.md).
|
package/VERIFICATION.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Verification — initial plugin / app integration
|
|
2
|
+
|
|
3
|
+
## Passed
|
|
4
|
+
|
|
5
|
+
- TypeScript typecheck and distributable build.
|
|
6
|
+
- Thirteen Bun tests (75 assertions), rechecked on 2026-11-01: published RFC 8291 encryption/decryption,
|
|
7
|
+
tamper/key rejection, source/device binding, persistent byte-identical retry,
|
|
8
|
+
revocation/credential revision, parent/child terminal aggregation, activity end,
|
|
9
|
+
child-session activity completion while its parent continues, relay origin validation,
|
|
10
|
+
and payment-expiry suppression with terminal cleanup.
|
|
11
|
+
- `npm pack --dry-run`: publishable JS/declaration/README entrypoints only; no
|
|
12
|
+
development keys, fixtures, dependencies, databases or app data bundled.
|
|
13
|
+
- Installed through ordinary `plugins` configuration on **published upstream
|
|
14
|
+
`@opencode/sdk@2.0.26` / Bun 1.4.0**, without fork changes. The test uses the
|
|
15
|
+
upstream HTTP router, plugin loader, RPC registry, storage and form event.
|
|
16
|
+
- The scoped RPC ID `@jdscript/opencode-notify/mobile-push` also passed the complete
|
|
17
|
+
upstream/Rust/Swift pipeline with the SDK's encoded single-segment routing.
|
|
18
|
+
- Two project Locations produce one alert each. Separate simultaneous SDK hosts
|
|
19
|
+
cannot see each other's subscription/key state. Restart restores the subscription,
|
|
20
|
+
and unsubscribe changes its state to revoked.
|
|
21
|
+
- The same upstream test connected to the Rust relay's production HTTP router,
|
|
22
|
+
SQLite and APNs payload constructor. The current pass captured **six encrypted
|
|
23
|
+
envelopes**, including automatic workspace aggregation. Production Swift receiver code
|
|
24
|
+
decrypted all six and verified subscription/server/installation/session/activity
|
|
25
|
+
binding, alert replay handling and repeat Widget rendering.
|
|
26
|
+
- The iOS app + Notification Service Extension + Live Activity extension compiled
|
|
27
|
+
in a signed Debug simulator build. A second build from an isolated notification-only
|
|
28
|
+
candidate also passed, establishing that prior uncommitted composer/inbox changes
|
|
29
|
+
are not build dependencies. Effective Keychain/App Group entitlements and signatures
|
|
30
|
+
were checked. Swift tests cover the `development` entitlement → `sandbox` relay
|
|
31
|
+
environment mapping.
|
|
32
|
+
|
|
33
|
+
The upstream pipeline uses fresh temporary HOME/config/data/database directories,
|
|
34
|
+
disposable sessions and public form creation. **Zero model prompts.** Temporary
|
|
35
|
+
hosts and capture processes are closed by the driver.
|
|
36
|
+
|
|
37
|
+
## Run
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
bun install --frozen-lockfile
|
|
41
|
+
bun run build
|
|
42
|
+
bun run typecheck
|
|
43
|
+
bun test
|
|
44
|
+
python3 scripts/check-upstream.py --rust-relay --swift
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The final command needs Rust and Xcode's macOS Swift compiler. APNs output is captured
|
|
48
|
+
by a development-only executable; the actual Apple transport is separately covered
|
|
49
|
+
by the relay's HTTP/2/signature test. No Apple credentials are needed for this check.
|
|
50
|
+
|
|
51
|
+
## Not established by these checks
|
|
52
|
+
|
|
53
|
+
Real Apple delivery, iOS APNs token registration against a provisioned app, NSE
|
|
54
|
+
invocation while locked, remote Widget rendering while suspended/terminated, and
|
|
55
|
+
real device unpair/recovery fault injection remain hardware integration work.
|
|
56
|
+
Device builds need matching capabilities/provisioning for all three targets.
|
|
57
|
+
Installing this package does not enroll a device or configure sender credentials;
|
|
58
|
+
Kiteyard performs that handoff through the authenticated server connection.
|
|
59
|
+
No user's running OpenCode instance is used as a test fixture.
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** RFC 8291 + RFC 8188 single-record aes128gcm. All keys stay outside the relay. */
|
|
2
|
+
export declare function encrypt(plaintext: Uint8Array, publicKey: string, authSecret: string, fixture?: {
|
|
3
|
+
privateKey: Uint8Array;
|
|
4
|
+
salt: Uint8Array;
|
|
5
|
+
}): string;
|
|
6
|
+
/** Also used by independent interoperability fixtures; not invoked by the sender at runtime. */
|
|
7
|
+
export declare function decrypt(ciphertext: string, privateKey: string, authSecret: string): Buffer<ArrayBuffer>;
|
|
8
|
+
export declare function decode(value: string, length?: number): Buffer<ArrayBuffer>;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createECDH, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
/** RFC 8291 + RFC 8188 single-record aes128gcm. All keys stay outside the relay. */
|
|
3
|
+
export function encrypt(plaintext, publicKey, authSecret, fixture) {
|
|
4
|
+
const recipient = decode(publicKey, 65);
|
|
5
|
+
const auth = decode(authSecret, 16);
|
|
6
|
+
if (recipient[0] !== 4)
|
|
7
|
+
throw new Error("invalid_recipient_key");
|
|
8
|
+
const sender = createECDH("prime256v1");
|
|
9
|
+
if (fixture)
|
|
10
|
+
sender.setPrivateKey(fixture.privateKey);
|
|
11
|
+
else
|
|
12
|
+
sender.generateKeys();
|
|
13
|
+
const pub = sender.getPublicKey();
|
|
14
|
+
const salt = fixture ? Buffer.from(fixture.salt) : randomBytes(16);
|
|
15
|
+
if (salt.length !== 16)
|
|
16
|
+
throw new Error("invalid_salt");
|
|
17
|
+
const keys = derive(sender.computeSecret(recipient), auth, recipient, pub, salt);
|
|
18
|
+
const cipher = createCipheriv("aes-128-gcm", keys.key, keys.nonce);
|
|
19
|
+
const encrypted = Buffer.concat([
|
|
20
|
+
cipher.update(Buffer.concat([plaintext, Buffer.from([2])])),
|
|
21
|
+
cipher.final(),
|
|
22
|
+
cipher.getAuthTag(),
|
|
23
|
+
]);
|
|
24
|
+
const header = Buffer.alloc(21);
|
|
25
|
+
salt.copy(header);
|
|
26
|
+
header.writeUInt32BE(Math.max(4096, encrypted.length + 1), 16);
|
|
27
|
+
header[20] = pub.length;
|
|
28
|
+
return Buffer.concat([header, pub, encrypted]).toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
/** Also used by independent interoperability fixtures; not invoked by the sender at runtime. */
|
|
31
|
+
export function decrypt(ciphertext, privateKey, authSecret) {
|
|
32
|
+
const record = decode(ciphertext);
|
|
33
|
+
if (record.length < 103 || record.length > 4096 || record[20] !== 65 || record[21] !== 4)
|
|
34
|
+
throw new Error("invalid_record");
|
|
35
|
+
const receiver = createECDH("prime256v1");
|
|
36
|
+
receiver.setPrivateKey(decode(privateKey, 32));
|
|
37
|
+
const encrypted = record.subarray(86);
|
|
38
|
+
if (record.readUInt32BE(16) <= encrypted.length)
|
|
39
|
+
throw new Error("invalid_record_size");
|
|
40
|
+
const keys = derive(receiver.computeSecret(record.subarray(21, 86)), decode(authSecret, 16), receiver.getPublicKey(), record.subarray(21, 86), record.subarray(0, 16));
|
|
41
|
+
const decipher = createDecipheriv("aes-128-gcm", keys.key, keys.nonce);
|
|
42
|
+
decipher.setAuthTag(encrypted.subarray(-16));
|
|
43
|
+
const result = Buffer.concat([decipher.update(encrypted.subarray(0, -16)), decipher.final()]);
|
|
44
|
+
const delimiter = result.findLastIndex((byte) => byte !== 0);
|
|
45
|
+
if (delimiter < 0 || result[delimiter] !== 2)
|
|
46
|
+
throw new Error("invalid_record_delimiter");
|
|
47
|
+
return result.subarray(0, delimiter);
|
|
48
|
+
}
|
|
49
|
+
export function decode(value, length) {
|
|
50
|
+
if (!/^[a-zA-Z0-9_-]+$/u.test(value))
|
|
51
|
+
throw new Error("invalid_base64url");
|
|
52
|
+
const bytes = Buffer.from(value, "base64url");
|
|
53
|
+
if (bytes.toString("base64url") !== value || (length !== undefined && bytes.length !== length))
|
|
54
|
+
throw new Error("invalid_key_length");
|
|
55
|
+
return bytes;
|
|
56
|
+
}
|
|
57
|
+
function derive(secret, auth, receiver, sender, salt) {
|
|
58
|
+
const ikm = Buffer.from(hkdfSync("sha256", secret, auth, Buffer.concat([Buffer.from("WebPush: info\0"), receiver, sender]), 32));
|
|
59
|
+
return {
|
|
60
|
+
key: Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)),
|
|
61
|
+
nonce: Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12)),
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { Definition } from "./rpc.js";
|
|
3
|
+
import { Sender, SenderError } from "./sender.js";
|
|
4
|
+
const registryKey = Symbol.for("@jdscript/opencode-notify/runtime/v1");
|
|
5
|
+
// The host can import a fresh module copy for each Location. Module-local state alone
|
|
6
|
+
// duplicates pushes. App metadata is shared within one host; distinct SDK hosts stay isolated.
|
|
7
|
+
const registry = globalThis;
|
|
8
|
+
const runtimes = (registry[registryKey] ??= new WeakMap());
|
|
9
|
+
/** Location plugin instances share one stream consumer and sender per host. */
|
|
10
|
+
function makePlugin() {
|
|
11
|
+
return Plugin.define({
|
|
12
|
+
id: "opencode.mobile-push",
|
|
13
|
+
async setup(ctx) {
|
|
14
|
+
if (typeof ctx.rpc?.register !== "function" || typeof ctx.storage?.scan !== "function") {
|
|
15
|
+
throw new Error("Mobile notifications require OpenCode v2 plugin RPC and storage APIs (tested on 2.0.26).");
|
|
16
|
+
}
|
|
17
|
+
const shared = runtimes.get(ctx.app) ?? { contexts: new Set() };
|
|
18
|
+
runtimes.set(ctx.app, shared);
|
|
19
|
+
const contexts = shared.contexts;
|
|
20
|
+
contexts.add(ctx);
|
|
21
|
+
shared.sender ??= (async () => {
|
|
22
|
+
const runtime = new Sender(ctx.storage, (sessionID) => {
|
|
23
|
+
const current = contexts.values().next().value;
|
|
24
|
+
if (!current)
|
|
25
|
+
return Promise.reject(new SenderError("plugin_stopped"));
|
|
26
|
+
return current.session.get({ sessionID });
|
|
27
|
+
}, (sessionID) => {
|
|
28
|
+
const current = contexts.values().next().value;
|
|
29
|
+
return current ? current.session.wait({ sessionID }) : Promise.reject(new SenderError("plugin_stopped"));
|
|
30
|
+
});
|
|
31
|
+
await runtime.start();
|
|
32
|
+
return runtime;
|
|
33
|
+
})();
|
|
34
|
+
const runtime = await shared.sender;
|
|
35
|
+
const registration = await ctx.rpc.register(Definition, {
|
|
36
|
+
info: async () => ({ version: 1, encoding: "aes128gcm", applicationVersion: 1, activityScopes: ["session", "server", "workspace"] }),
|
|
37
|
+
subscribe: async (input, context) => runtime
|
|
38
|
+
.subscribe(input)
|
|
39
|
+
.catch((error) => context.error("invalid", "Notification setup failed", { code: code(error) })),
|
|
40
|
+
status: async (input, context) => {
|
|
41
|
+
try {
|
|
42
|
+
return runtime.status(input.subscriptionID);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return context.error("invalid", "Notification subscription unavailable", { code: code(error) });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
unsubscribe: async (input, context) => runtime
|
|
49
|
+
.unsubscribe(input.subscriptionID)
|
|
50
|
+
.catch((error) => context.error("invalid", "Notification removal failed", { code: code(error) })),
|
|
51
|
+
attach: async (input, context) => runtime
|
|
52
|
+
.attach(input)
|
|
53
|
+
.catch((error) => context.error("invalid", "Activity attachment failed", { code: code(error) })),
|
|
54
|
+
detach: async (input, context) => runtime
|
|
55
|
+
.detach(input.subscriptionID, input.activityID)
|
|
56
|
+
.catch((error) => context.error("invalid", "Activity removal failed", { code: code(error) })),
|
|
57
|
+
});
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const events = (async () => {
|
|
60
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
61
|
+
if (controller.signal.aborted)
|
|
62
|
+
return;
|
|
63
|
+
if (contexts.values().next().value !== ctx)
|
|
64
|
+
continue;
|
|
65
|
+
await runtime.onEvent(event).catch(() => {
|
|
66
|
+
runtime.observationUnavailable();
|
|
67
|
+
console.warn("[mobile-push] activity observation unavailable");
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (!controller.signal.aborted)
|
|
71
|
+
runtime.observationUnavailable();
|
|
72
|
+
})().catch(() => {
|
|
73
|
+
if (!controller.signal.aborted) {
|
|
74
|
+
runtime.observationUnavailable();
|
|
75
|
+
console.warn("[mobile-push] event stream ended");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return async () => {
|
|
79
|
+
controller.abort();
|
|
80
|
+
contexts.delete(ctx);
|
|
81
|
+
const stopping = contexts.size === 0 ? runtime.stop() : Promise.resolve();
|
|
82
|
+
if (contexts.size === 0)
|
|
83
|
+
runtimes.delete(ctx.app);
|
|
84
|
+
await registration.dispose();
|
|
85
|
+
await events;
|
|
86
|
+
await stopping;
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function code(error) {
|
|
92
|
+
return error instanceof Error && error.name === "MobilePushError" && "code" in error && typeof error.code === "string"
|
|
93
|
+
? error.code
|
|
94
|
+
: "sender_unavailable";
|
|
95
|
+
}
|
|
96
|
+
export default makePlugin();
|
package/dist/rpc.d.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const UUID: z.ZodString;
|
|
3
|
+
export declare const Subscribe: z.ZodObject<{
|
|
4
|
+
subscriptionID: z.ZodString;
|
|
5
|
+
revision: z.ZodNumber;
|
|
6
|
+
relayURL: z.ZodString;
|
|
7
|
+
sendToken: z.ZodString;
|
|
8
|
+
expiresAt: z.ZodNumber;
|
|
9
|
+
serverID: z.ZodString;
|
|
10
|
+
installationID: z.ZodString;
|
|
11
|
+
recipientPublicKey: z.ZodString;
|
|
12
|
+
authSecret: z.ZodString;
|
|
13
|
+
preferences: z.ZodObject<{
|
|
14
|
+
completed: z.ZodBoolean;
|
|
15
|
+
requests: z.ZodBoolean;
|
|
16
|
+
failures: z.ZodBoolean;
|
|
17
|
+
}, z.core.$strict>;
|
|
18
|
+
}, z.core.$strict>;
|
|
19
|
+
export type Subscribe = z.infer<typeof Subscribe>;
|
|
20
|
+
export declare const Reference: z.ZodObject<{
|
|
21
|
+
subscriptionID: z.ZodString;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
export declare const Route: z.ZodObject<{
|
|
24
|
+
subscriptionID: z.ZodString;
|
|
25
|
+
activityID: z.ZodString;
|
|
26
|
+
}, z.core.$strict>;
|
|
27
|
+
export declare const Attach: z.ZodObject<{
|
|
28
|
+
subscriptionID: z.ZodString;
|
|
29
|
+
activityID: z.ZodString;
|
|
30
|
+
sessionID: z.ZodString;
|
|
31
|
+
expiresAt: z.ZodNumber;
|
|
32
|
+
scope: z.ZodOptional<z.ZodEnum<{
|
|
33
|
+
session: "session";
|
|
34
|
+
server: "server";
|
|
35
|
+
workspace: "workspace";
|
|
36
|
+
}>>;
|
|
37
|
+
serverName: z.ZodOptional<z.ZodString>;
|
|
38
|
+
seedComplete: z.ZodOptional<z.ZodBoolean>;
|
|
39
|
+
seeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
40
|
+
sessionID: z.ZodString;
|
|
41
|
+
running: z.ZodBoolean;
|
|
42
|
+
waiting: z.ZodArray<z.ZodString>;
|
|
43
|
+
}, z.core.$strict>>>;
|
|
44
|
+
shells: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
45
|
+
id: z.ZodString;
|
|
46
|
+
sessionID: z.ZodOptional<z.ZodString>;
|
|
47
|
+
}, z.core.$strict>>>;
|
|
48
|
+
}, z.core.$strict>;
|
|
49
|
+
export type Attach = z.infer<typeof Attach>;
|
|
50
|
+
export declare const Status: z.ZodObject<{
|
|
51
|
+
subscriptionID: z.ZodString;
|
|
52
|
+
revision: z.ZodNumber;
|
|
53
|
+
expiresAt: z.ZodNumber;
|
|
54
|
+
state: z.ZodEnum<{
|
|
55
|
+
active: "active";
|
|
56
|
+
revoked: "revoked";
|
|
57
|
+
expired: "expired";
|
|
58
|
+
unavailable: "unavailable";
|
|
59
|
+
}>;
|
|
60
|
+
}, z.core.$strict>;
|
|
61
|
+
export declare const RPC_ID = "@jdscript/opencode-notify/mobile-push";
|
|
62
|
+
export declare const Definition: {
|
|
63
|
+
readonly id: "@jdscript/opencode-notify/mobile-push";
|
|
64
|
+
readonly events: {};
|
|
65
|
+
readonly methods: {
|
|
66
|
+
readonly info: {
|
|
67
|
+
readonly input: z.ZodObject<{}, z.core.$strict>;
|
|
68
|
+
readonly output: z.ZodObject<{
|
|
69
|
+
version: z.ZodLiteral<1>;
|
|
70
|
+
encoding: z.ZodLiteral<"aes128gcm">;
|
|
71
|
+
applicationVersion: z.ZodLiteral<1>;
|
|
72
|
+
activityScopes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
73
|
+
session: "session";
|
|
74
|
+
server: "server";
|
|
75
|
+
workspace: "workspace";
|
|
76
|
+
}>>>;
|
|
77
|
+
}, z.core.$strict>;
|
|
78
|
+
readonly errors: {
|
|
79
|
+
invalid: z.ZodObject<{
|
|
80
|
+
code: z.ZodString;
|
|
81
|
+
}, z.core.$strict>;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
readonly subscribe: {
|
|
85
|
+
readonly input: z.ZodObject<{
|
|
86
|
+
subscriptionID: z.ZodString;
|
|
87
|
+
revision: z.ZodNumber;
|
|
88
|
+
relayURL: z.ZodString;
|
|
89
|
+
sendToken: z.ZodString;
|
|
90
|
+
expiresAt: z.ZodNumber;
|
|
91
|
+
serverID: z.ZodString;
|
|
92
|
+
installationID: z.ZodString;
|
|
93
|
+
recipientPublicKey: z.ZodString;
|
|
94
|
+
authSecret: z.ZodString;
|
|
95
|
+
preferences: z.ZodObject<{
|
|
96
|
+
completed: z.ZodBoolean;
|
|
97
|
+
requests: z.ZodBoolean;
|
|
98
|
+
failures: z.ZodBoolean;
|
|
99
|
+
}, z.core.$strict>;
|
|
100
|
+
}, z.core.$strict>;
|
|
101
|
+
readonly output: z.ZodObject<{
|
|
102
|
+
subscriptionID: z.ZodString;
|
|
103
|
+
revision: z.ZodNumber;
|
|
104
|
+
expiresAt: z.ZodNumber;
|
|
105
|
+
state: z.ZodEnum<{
|
|
106
|
+
active: "active";
|
|
107
|
+
revoked: "revoked";
|
|
108
|
+
expired: "expired";
|
|
109
|
+
unavailable: "unavailable";
|
|
110
|
+
}>;
|
|
111
|
+
}, z.core.$strict>;
|
|
112
|
+
readonly errors: {
|
|
113
|
+
invalid: z.ZodObject<{
|
|
114
|
+
code: z.ZodString;
|
|
115
|
+
}, z.core.$strict>;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
readonly unsubscribe: {
|
|
119
|
+
readonly input: z.ZodObject<{
|
|
120
|
+
subscriptionID: z.ZodString;
|
|
121
|
+
}, z.core.$strict>;
|
|
122
|
+
readonly output: z.ZodObject<{
|
|
123
|
+
ok: z.ZodLiteral<true>;
|
|
124
|
+
}, z.core.$strict>;
|
|
125
|
+
readonly errors: {
|
|
126
|
+
invalid: z.ZodObject<{
|
|
127
|
+
code: z.ZodString;
|
|
128
|
+
}, z.core.$strict>;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
readonly status: {
|
|
132
|
+
readonly input: z.ZodObject<{
|
|
133
|
+
subscriptionID: z.ZodString;
|
|
134
|
+
}, z.core.$strict>;
|
|
135
|
+
readonly output: z.ZodObject<{
|
|
136
|
+
subscriptionID: z.ZodString;
|
|
137
|
+
revision: z.ZodNumber;
|
|
138
|
+
expiresAt: z.ZodNumber;
|
|
139
|
+
state: z.ZodEnum<{
|
|
140
|
+
active: "active";
|
|
141
|
+
revoked: "revoked";
|
|
142
|
+
expired: "expired";
|
|
143
|
+
unavailable: "unavailable";
|
|
144
|
+
}>;
|
|
145
|
+
}, z.core.$strict>;
|
|
146
|
+
readonly errors: {
|
|
147
|
+
invalid: z.ZodObject<{
|
|
148
|
+
code: z.ZodString;
|
|
149
|
+
}, z.core.$strict>;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
readonly attach: {
|
|
153
|
+
readonly input: z.ZodObject<{
|
|
154
|
+
subscriptionID: z.ZodString;
|
|
155
|
+
activityID: z.ZodString;
|
|
156
|
+
sessionID: z.ZodString;
|
|
157
|
+
expiresAt: z.ZodNumber;
|
|
158
|
+
scope: z.ZodOptional<z.ZodEnum<{
|
|
159
|
+
session: "session";
|
|
160
|
+
server: "server";
|
|
161
|
+
workspace: "workspace";
|
|
162
|
+
}>>;
|
|
163
|
+
serverName: z.ZodOptional<z.ZodString>;
|
|
164
|
+
seedComplete: z.ZodOptional<z.ZodBoolean>;
|
|
165
|
+
seeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
166
|
+
sessionID: z.ZodString;
|
|
167
|
+
running: z.ZodBoolean;
|
|
168
|
+
waiting: z.ZodArray<z.ZodString>;
|
|
169
|
+
}, z.core.$strict>>>;
|
|
170
|
+
shells: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
171
|
+
id: z.ZodString;
|
|
172
|
+
sessionID: z.ZodOptional<z.ZodString>;
|
|
173
|
+
}, z.core.$strict>>>;
|
|
174
|
+
}, z.core.$strict>;
|
|
175
|
+
readonly output: z.ZodObject<{
|
|
176
|
+
ok: z.ZodLiteral<true>;
|
|
177
|
+
}, z.core.$strict>;
|
|
178
|
+
readonly errors: {
|
|
179
|
+
invalid: z.ZodObject<{
|
|
180
|
+
code: z.ZodString;
|
|
181
|
+
}, z.core.$strict>;
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
readonly detach: {
|
|
185
|
+
readonly input: z.ZodObject<{
|
|
186
|
+
subscriptionID: z.ZodString;
|
|
187
|
+
activityID: z.ZodString;
|
|
188
|
+
}, z.core.$strict>;
|
|
189
|
+
readonly output: z.ZodObject<{
|
|
190
|
+
ok: z.ZodLiteral<true>;
|
|
191
|
+
}, z.core.$strict>;
|
|
192
|
+
readonly errors: {
|
|
193
|
+
invalid: z.ZodObject<{
|
|
194
|
+
code: z.ZodString;
|
|
195
|
+
}, z.core.$strict>;
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
};
|