opencody-notify 0.0.0-stage → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Will Software Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
1
+ # opencody-notify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OpenCode **V2** server plugin that sends end-to-end encrypted push notifications to the OpenCody iOS app when
4
+
5
+ - a session run finishes or fails (root sessions only; interruptions by the user are ignored),
6
+ - OpenCode asks for a permission (Allow once / Always / Deny right from the notification),
7
+ - the agent asks a question (form).
8
+
9
+ It also lets OpenCody send a test notification through the server (since 0.3.0), so the app can check the whole path: OpenCode server → relay → APNs → device.
10
+
11
+ The push goes through the OpenCody relay; the relay only sees ciphertext. No runtime dependencies.
12
+
13
+ ## 1. Get a pairing string
14
+
15
+ OpenCody → server settings → *Push notifications* → *Pair*. Copy the single-line pairing string. It is a credential: treat it like a password. One string per device (iPhone or iPad).
16
+
17
+ ## 2. Install the plugin (self-hosted V2 server)
18
+
19
+ ```sh
20
+ opencode plugin add opencody-notify
21
+ ```
22
+
23
+ This adds it to the global config (`~/.config/opencode/opencode.jsonc`, applies to every project). Equivalent by hand, globally or per project (`<project>/opencode.jsonc`):
24
+
25
+ ```jsonc
26
+ {
27
+ "plugins": [
28
+ {
29
+ "package": "opencody-notify",
30
+ "options": {
31
+ "label": "homelab", // shown in the notification; default: hostname
32
+ "pairings": ["<pairing string device 1>", "<pairing string device 2>"] // optional, see env below
33
+ }
34
+ }
35
+ ]
36
+ }
37
+ ```
38
+
39
+ ### Fallback: local directory
40
+
41
+ To keep a copy on disk (offline servers, baked into an image), unpack the package into a directory and point `package` at it:
42
+
43
+ ```sh
44
+ mkdir -p /opt/opencody-notify && cd /opt/opencody-notify
45
+ npm pack opencody-notify && tar -xzf opencody-notify-*.tgz --strip-components=1 && rm opencody-notify-*.tgz
46
+ ```
47
+
48
+ ```jsonc
49
+ { "plugins": [{ "package": "/opt/opencody-notify", "options": { "label": "homelab" } }] } // absolute path, "./relative" or file://
50
+ ```
51
+
52
+ OpenCode loads a local directory plugin from `<dir>/server.js` (or `<dir>/index.*`), not from `package.json` exports; `server.js` re-exports `dist/index.js`.
53
+
54
+ ## 3. Configuration via environment
55
+
56
+ | Variable | Meaning |
57
+ |---|---|
58
+ | `OPENCODY_PAIRING` | one or more pairing strings, separated by commas, spaces or newlines. Merged with `options.pairings`. |
59
+ | `OPENCODY_SERVER_LABEL` | label if `options.label` is not set |
60
+ | `OPENCODY_DEBUG=1` | log every received event type |
61
+
62
+ Without any valid pairing the plugin logs a warning and stays idle.
63
+
64
+ ### Docker / Coolify (OpenCode server in a container)
65
+
66
+ Add the plugin to the config of the user running OpenCode in the container and pass the pairing as a secret env var:
67
+
68
+ ```jsonc
69
+ // ~/.config/opencode/opencode.jsonc
70
+ { "plugins": [{ "package": "opencody-notify", "options": { "label": "coolify-opencode" } }] }
71
+ ```
72
+
73
+ To avoid a download at startup, bake it in with the local-directory fallback above (`RUN mkdir -p /opt/opencody-notify && cd /opt/opencody-notify && npm pack opencody-notify && tar …`) and use `"package": "/opt/opencody-notify"`.
74
+
75
+ Environment (Coolify → *Environment Variables*, mark as secret): `OPENCODY_PAIRING=<string1>,<string2>`.
76
+
77
+ The server must be able to reach `https://push.willsoftwaresolutions.de` (outbound HTTPS).
78
+
79
+ ## 4. Behaviour and safety
80
+
81
+ - Plugins are instantiated per OpenCode location (project directory); each instance only sees its own project's events, so there are no duplicate pushes across projects.
82
+ - Every event is handled in the background with a 5 s timeout for session lookup and for the relay request; errors are logged (`[opencody-notify] …` on stderr), never thrown. The event subscription is re-opened automatically if it ends.
83
+ - Duplicate events (same type, session, request) within 15 s produce one push.
84
+ - Each push carries a dedupe key (`<sessionID>@<idle time>` or `req:<requestID>`) inside the ciphertext and a SHA-256 prefix of it as APNs collapse id, so OpenCody shows one notification per event even when the app also notices it live.
85
+ - Relay answers `401`/`410` mean the pairing was removed: re-pair in OpenCody and replace the string.
86
+
87
+ ## 5. Test notification (OpenCody → server → device)
88
+
89
+ OpenCody's *Send Test Notification* calls the plugin through OpenCode's plugin RPC route (OpenCode 2.1 or later), behind the server's normal authentication:
90
+
91
+ ```
92
+ POST /api/rpc/opencody-notify/test {"input": {"deviceId": "<deviceId from the pairing string>"}}
93
+ → 200 {"output": {"server": "<label>"}} push sent, relay accepted it
94
+ → 400 {"type": "not_paired", "data": {"pairings": <n>}, …} no pairing for this device on this server
95
+ → 400 {"type": "relay_failed", "data": {"status": <relay status, 0 = unreachable>}, …}
96
+ → 400 {"type": "rpc.unavailable", …} plugin not loaded, or older than 0.3.0
97
+ ```
98
+
99
+ It sends one encrypted message of type `test` to that device only. No session, no LLM call, nothing stored on the server. The route is answered at the server's default location (its working directory), so install the plugin globally (`opencode plugin add`) for the test to find it.
100
+
101
+ ## Development
102
+
103
+ Protocol: `SPEC.md` in the source repository.
104
+
105
+ ```sh
106
+ npm test # node --test (unit tests incl. crypto test vectors)
107
+ npm run typecheck
108
+ swift test/cryptokit-verify.swift test/vectors.json # vectors vs. Apple CryptoKit (macOS)
109
+ node ../integration/e2e.ts # real OpenCode 2.1.4 + relay + fake APNs (needs npm run build)
110
+ ```
@@ -0,0 +1,132 @@
1
+ import { type Pairing } from "./protocol.ts";
2
+ export interface OpenCodeEvent {
3
+ id?: string;
4
+ type: string;
5
+ created?: number;
6
+ location?: {
7
+ directory?: string;
8
+ };
9
+ data?: any;
10
+ }
11
+ interface SessionInfo {
12
+ id: string;
13
+ parentID?: string;
14
+ title?: string;
15
+ location?: {
16
+ directory?: string;
17
+ };
18
+ time?: {
19
+ idle?: number;
20
+ };
21
+ }
22
+ interface RpcCallContext {
23
+ error(type: string, message: string, data?: unknown): unknown;
24
+ }
25
+ export interface PluginContext {
26
+ options?: Record<string, unknown>;
27
+ location?: {
28
+ directory?: string;
29
+ };
30
+ event: {
31
+ subscribe(options?: {
32
+ signal?: AbortSignal;
33
+ }): AsyncIterable<OpenCodeEvent>;
34
+ };
35
+ session: {
36
+ get(input: {
37
+ sessionID: string;
38
+ }): Promise<SessionInfo>;
39
+ };
40
+ /** OpenCode serves registered methods at `POST /api/rpc/<id>/<method>` (2.1+). */
41
+ rpc?: {
42
+ register(definition: object, handlers: Record<string, (input: any, context: RpcCallContext) => Promise<unknown>>): Promise<{
43
+ dispose(): Promise<void>;
44
+ }>;
45
+ };
46
+ }
47
+ /**
48
+ * `POST /api/rpc/opencody-notify/test {"input":{"deviceId":"…"}}` sends a test push to that
49
+ * device's pairing, so OpenCody can check the whole path server → relay → APNs → device.
50
+ */
51
+ export declare const RPC: {
52
+ readonly id: "opencody-notify";
53
+ readonly methods: {
54
+ readonly test: {
55
+ readonly input: {
56
+ readonly type: "object";
57
+ readonly properties: {
58
+ readonly deviceId: {
59
+ readonly type: "string";
60
+ };
61
+ };
62
+ readonly required: readonly ["deviceId"];
63
+ };
64
+ readonly output: {
65
+ readonly type: "object";
66
+ readonly properties: {
67
+ readonly server: {
68
+ readonly type: "string";
69
+ };
70
+ };
71
+ readonly required: readonly ["server"];
72
+ };
73
+ readonly errors: {
74
+ readonly not_paired: {
75
+ readonly type: "object";
76
+ readonly properties: {
77
+ readonly pairings: {
78
+ readonly type: "number";
79
+ };
80
+ };
81
+ readonly required: readonly ["pairings"];
82
+ };
83
+ readonly relay_failed: {
84
+ readonly type: "object";
85
+ readonly properties: {
86
+ readonly status: {
87
+ readonly type: "number";
88
+ };
89
+ };
90
+ readonly required: readonly ["status"];
91
+ };
92
+ };
93
+ };
94
+ };
95
+ readonly events: {};
96
+ };
97
+ export type TestResult = {
98
+ ok: true;
99
+ } | {
100
+ ok: false;
101
+ error: "not_paired";
102
+ pairings: number;
103
+ } | {
104
+ ok: false;
105
+ error: "relay_failed";
106
+ status: number;
107
+ message: string;
108
+ };
109
+ export interface NotifierOptions {
110
+ pairings: Pairing[];
111
+ label: string;
112
+ getSession: (sessionID: string) => Promise<SessionInfo | undefined>;
113
+ fetch?: typeof fetch;
114
+ log?: (message: string) => void;
115
+ timeoutMs?: number;
116
+ dedupeMs?: number;
117
+ now?: () => number;
118
+ }
119
+ export declare function createNotifier(options: NotifierOptions): {
120
+ onEvent(event: OpenCodeEvent): Promise<void>;
121
+ sendTest(deviceId: string): Promise<TestResult>;
122
+ };
123
+ export declare function loadConfig(options?: Record<string, unknown>, env?: Record<string, string | undefined>): {
124
+ pairings: Pairing[];
125
+ label: string;
126
+ errors: string[];
127
+ };
128
+ declare const _default: {
129
+ id: string;
130
+ setup(ctx: PluginContext): () => void;
131
+ };
132
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,244 @@
1
+ import { hostname } from "node:os";
2
+ import { CATEGORY, collapseId, encodeMessage, parsePairing, seal, splitPairings } from "./protocol.js";
3
+ /**
4
+ * `POST /api/rpc/opencody-notify/test {"input":{"deviceId":"…"}}` sends a test push to that
5
+ * device's pairing, so OpenCody can check the whole path server → relay → APNs → device.
6
+ */
7
+ export const RPC = {
8
+ id: "opencody-notify",
9
+ methods: {
10
+ test: {
11
+ input: { type: "object", properties: { deviceId: { type: "string" } }, required: ["deviceId"] },
12
+ output: { type: "object", properties: { server: { type: "string" } }, required: ["server"] },
13
+ errors: {
14
+ // Error data is required: OpenCode validates it against these schemas.
15
+ not_paired: { type: "object", properties: { pairings: { type: "number" } }, required: ["pairings"] },
16
+ relay_failed: { type: "object", properties: { status: { type: "number" } }, required: ["status"] },
17
+ },
18
+ },
19
+ },
20
+ events: {},
21
+ };
22
+ const SKIPPED_INTERRUPTS = new Set(["user", "superseded"]);
23
+ /**
24
+ * The session's `time.idle` for this run end, which OpenCody's catch-up keys on. OpenCode sets
25
+ * idle = max(created, previous idle + 1) when it projects the event; an older idle means the
26
+ * lookup ran before that, so the event time is the new value.
27
+ * ponytail: a previous run ending in the same millisecond, before projection, yields its key.
28
+ */
29
+ function runEnd(session, created) {
30
+ const idle = session?.time?.idle;
31
+ return idle !== undefined && idle >= created ? idle : created;
32
+ }
33
+ const withTimeout = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms).unref?.())]);
34
+ export function createNotifier(options) {
35
+ const { pairings, label, getSession, fetch: doFetch = fetch, log = () => { }, timeoutMs = 5000, dedupeMs = 15_000 } = options;
36
+ const now = options.now ?? Date.now;
37
+ const recent = new Map();
38
+ /** Walks up parentID links; undefined session info means the lookup failed. */
39
+ async function lineage(sessionID) {
40
+ let current = await getSession(sessionID).catch(() => undefined);
41
+ const own = current;
42
+ for (let hops = 0; current?.parentID && hops < 8; hops++) {
43
+ const parent = await getSession(current.parentID).catch(() => undefined);
44
+ if (!parent)
45
+ break;
46
+ current = parent;
47
+ }
48
+ return { own, root: current };
49
+ }
50
+ async function toMessage(event) {
51
+ const data = event.data ?? {};
52
+ let type;
53
+ let sessionID;
54
+ let text;
55
+ let requestID;
56
+ switch (event.type) {
57
+ case "session.execution.succeeded":
58
+ ;
59
+ [type, sessionID, text] = ["session.finished", data.sessionID, "Session finished"];
60
+ break;
61
+ case "session.execution.failed":
62
+ ;
63
+ [type, sessionID, text] = ["session.failed", data.sessionID, data.error?.message || "Session failed"];
64
+ break;
65
+ case "session.execution.interrupted":
66
+ if (SKIPPED_INTERRUPTS.has(data.reason))
67
+ return undefined;
68
+ [type, sessionID, text] = ["session.failed", data.sessionID, `Interrupted (${data.reason})`];
69
+ break;
70
+ case "permission.asked":
71
+ type = "permission.asked";
72
+ sessionID = data.sessionID;
73
+ requestID = data.id;
74
+ text = data.message || [data.action, (data.resources ?? []).join(", ")].filter(Boolean).join(": ");
75
+ break;
76
+ case "form.created":
77
+ type = "form.asked";
78
+ sessionID = data.form?.sessionID;
79
+ requestID = data.form?.id;
80
+ // The question tool titles every form "Questions"; the actual question is the field description.
81
+ text = data.form?.fields?.[0]?.description || data.form?.title || "Question";
82
+ if (data.form?.fields?.length > 1)
83
+ text += ` (+${data.form.fields.length - 1} more)`;
84
+ break;
85
+ default:
86
+ return undefined;
87
+ }
88
+ if (typeof sessionID !== "string")
89
+ return undefined;
90
+ const { own, root } = await lineage(sessionID);
91
+ // Subagent runs finish constantly; only root runs are worth a push. Permission and form
92
+ // requests from subagents still block the run, so they are always sent.
93
+ if (type.startsWith("session.") && own?.parentID)
94
+ return undefined;
95
+ const ts = event.created ?? now();
96
+ return {
97
+ v: 1,
98
+ type,
99
+ server: label,
100
+ // Durable session.execution.* events carry no location; the session record does.
101
+ directory: event.location?.directory ?? own?.location?.directory,
102
+ sessionID,
103
+ ...(root && root.id !== sessionID ? { rootSessionID: root.id } : {}),
104
+ ...(root?.title ? { title: root.title } : {}),
105
+ text,
106
+ ...(requestID ? { requestID } : {}),
107
+ key: requestID ? `req:${requestID}` : `${sessionID}@${Math.trunc(runEnd(own, ts))}`,
108
+ ts,
109
+ };
110
+ }
111
+ function duplicate(message) {
112
+ const t = now();
113
+ for (const [key, at] of recent)
114
+ if (t - at > dedupeMs)
115
+ recent.delete(key);
116
+ const key = `${message.type}:${message.sessionID}:${message.requestID ?? ""}`;
117
+ if (recent.has(key))
118
+ return true;
119
+ recent.set(key, t);
120
+ return false;
121
+ }
122
+ async function post(pairing, message) {
123
+ const ciphertext = seal(Buffer.from(pairing.key, "base64url"), pairing.deviceId, encodeMessage(message));
124
+ const res = await doFetch(`${pairing.relay}/v1/push`, {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json", authorization: `Bearer ${pairing.secret}` },
127
+ body: JSON.stringify({
128
+ deviceId: pairing.deviceId,
129
+ category: CATEGORY[message.type],
130
+ ciphertext,
131
+ ...(message.key ? { collapseId: collapseId(message.key) } : {}),
132
+ }),
133
+ signal: AbortSignal.timeout(timeoutMs),
134
+ });
135
+ if (!res.ok) {
136
+ const hint = res.status === 401 || res.status === 410 ? " (pairing revoked or invalid, re-pair in OpenCody)" : "";
137
+ log(`relay answered ${res.status} for device ${pairing.deviceId.slice(0, 6)}…${hint}`);
138
+ }
139
+ return res.status;
140
+ }
141
+ return {
142
+ async onEvent(event) {
143
+ try {
144
+ const message = await withTimeout(toMessage(event), timeoutMs);
145
+ if (!message || duplicate(message))
146
+ return;
147
+ const results = await Promise.allSettled(pairings.map((pairing) => post(pairing, message)));
148
+ for (const result of results)
149
+ if (result.status === "rejected")
150
+ log(`push failed: ${result.reason?.message}`);
151
+ }
152
+ catch (error) {
153
+ log(`event handling failed: ${error?.message}`);
154
+ }
155
+ },
156
+ async sendTest(deviceId) {
157
+ const pairing = pairings.find((p) => p.deviceId === deviceId);
158
+ if (!pairing)
159
+ return { ok: false, error: "not_paired", pairings: pairings.length };
160
+ const message = { v: 1, type: "test", server: label, sessionID: "", title: "OpenCody", text: "Test notification from your server. Push works.", ts: now() };
161
+ try {
162
+ const status = await post(pairing, message);
163
+ return status >= 200 && status < 300 ? { ok: true } : { ok: false, error: "relay_failed", status, message: `relay answered ${status}` };
164
+ }
165
+ catch (error) {
166
+ return { ok: false, error: "relay_failed", status: 0, message: `relay not reachable: ${error?.message}` };
167
+ }
168
+ },
169
+ };
170
+ }
171
+ export function loadConfig(options = {}, env = process.env) {
172
+ const raw = [...splitPairings(options.pairings ?? options.pairing), ...splitPairings(env.OPENCODY_PAIRING)];
173
+ const pairings = [];
174
+ const errors = [];
175
+ for (const value of new Set(raw)) {
176
+ try {
177
+ pairings.push(parsePairing(value));
178
+ }
179
+ catch (error) {
180
+ errors.push(error.message);
181
+ }
182
+ }
183
+ const label = (typeof options.label === "string" && options.label) || env.OPENCODY_SERVER_LABEL || hostname();
184
+ return { pairings, label, errors };
185
+ }
186
+ const log = (message) => console.error(`[opencody-notify] ${message}`);
187
+ export default {
188
+ id: "opencody-notify",
189
+ setup(ctx) {
190
+ const { pairings, label, errors } = loadConfig(ctx.options);
191
+ for (const error of errors)
192
+ log(`ignoring pairing: ${error}`);
193
+ const notifier = createNotifier({
194
+ pairings,
195
+ label,
196
+ log,
197
+ getSession: (sessionID) => ctx.session.get({ sessionID }),
198
+ });
199
+ // Registered even without pairings, so OpenCody can tell "plugin missing" from "this device's pairing missing".
200
+ const registration = ctx.rpc
201
+ ?.register(RPC, {
202
+ test: async ({ deviceId }, { error }) => {
203
+ const result = await notifier.sendTest(deviceId);
204
+ if (result.ok)
205
+ return { server: label };
206
+ if (result.error === "not_paired")
207
+ return error("not_paired", `no pairing for this device on "${label}" (${result.pairings} configured)`, { pairings: result.pairings });
208
+ return error("relay_failed", result.message, { status: result.status });
209
+ },
210
+ })
211
+ .catch((error) => log(`test endpoint unavailable: ${error?.message}`));
212
+ const unregister = () => void registration?.then((r) => r?.dispose()).catch(() => { });
213
+ if (!pairings.length) {
214
+ log("no valid pairing configured (OPENCODY_PAIRING or plugin option `pairings`); push disabled");
215
+ return unregister;
216
+ }
217
+ const debug = process.env.OPENCODY_DEBUG === "1";
218
+ const controller = new AbortController();
219
+ void (async () => {
220
+ if (debug)
221
+ log(`subscribing at ${JSON.stringify(ctx.location)}`);
222
+ while (!controller.signal.aborted) {
223
+ try {
224
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
225
+ if (debug)
226
+ log(`event ${event.type}`);
227
+ void notifier.onEvent(event);
228
+ }
229
+ }
230
+ catch (error) {
231
+ if (!controller.signal.aborted)
232
+ log(`event stream error: ${error?.message}`);
233
+ }
234
+ if (!controller.signal.aborted)
235
+ await new Promise((resolve) => setTimeout(resolve, 2000));
236
+ }
237
+ })();
238
+ log(`push enabled for ${pairings.length} device(s) as "${label}"`);
239
+ return () => {
240
+ controller.abort();
241
+ unregister();
242
+ };
243
+ },
244
+ };
@@ -0,0 +1,37 @@
1
+ export interface Pairing {
2
+ v: 1;
3
+ relay: string;
4
+ deviceId: string;
5
+ secret: string;
6
+ key: string;
7
+ }
8
+ export type PushType = "session.finished" | "session.failed" | "permission.asked" | "form.asked" | "test";
9
+ export type Category = "OC_SESSION" | "OC_PERMISSION" | "OC_FORM";
10
+ export interface Message {
11
+ v: 1;
12
+ type: PushType;
13
+ server: string;
14
+ directory?: string;
15
+ sessionID: string;
16
+ rootSessionID?: string;
17
+ title?: string;
18
+ text: string;
19
+ requestID?: string;
20
+ /** Dedupe key shared with OpenCody's local alerts: `<sessionID>@<time.idle>` or `req:<requestID>`. */
21
+ key?: string;
22
+ ts: number;
23
+ }
24
+ export declare const CATEGORY: Record<PushType, Category>;
25
+ export declare const MAX_PLAINTEXT = 2048;
26
+ /** APNs collapse id for a dedupe key: hashed, so the relay and Apple never see session or request IDs. */
27
+ export declare const collapseId: (key: string) => string;
28
+ export declare function parsePairing(input: string): Pairing;
29
+ export declare function encodePairing(pairing: Omit<Pairing, "v">): string;
30
+ /** Pairing strings in env/options may be separated by commas or whitespace. */
31
+ export declare function splitPairings(value: unknown): string[];
32
+ export declare const aad: (deviceId: string) => Buffer<ArrayBuffer>;
33
+ /** Output is base64(nonce[12] || ciphertext || tag[16]) = CryptoKit `AES.GCM.SealedBox.combined`. */
34
+ export declare function seal(key: Buffer, deviceId: string, plaintext: Buffer, nonce?: NonSharedBuffer): string;
35
+ export declare function open(key: Buffer, deviceId: string, combined: string): Buffer<ArrayBuffer>;
36
+ /** Serializes a message, shrinking free-text fields until it fits MAX_PLAINTEXT bytes. */
37
+ export declare function encodeMessage(message: Message): Buffer;
@@ -0,0 +1,88 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+ export const CATEGORY = {
3
+ "session.finished": "OC_SESSION",
4
+ "session.failed": "OC_SESSION",
5
+ "permission.asked": "OC_PERMISSION",
6
+ "form.asked": "OC_FORM",
7
+ test: "OC_SESSION",
8
+ };
9
+ export const MAX_PLAINTEXT = 2048;
10
+ /** APNs collapse id for a dedupe key: hashed, so the relay and Apple never see session or request IDs. */
11
+ export const collapseId = (key) => createHash("sha256").update(key, "utf8").digest("hex").slice(0, 32);
12
+ const B64URL = /^[A-Za-z0-9_-]+$/;
13
+ export function parsePairing(input) {
14
+ let value;
15
+ try {
16
+ value = JSON.parse(Buffer.from(input.trim(), "base64url").toString("utf8"));
17
+ }
18
+ catch {
19
+ throw new Error("pairing string is not base64url JSON");
20
+ }
21
+ if (value?.v !== 1)
22
+ throw new Error("unsupported pairing version");
23
+ const { relay, deviceId, secret, key } = value;
24
+ if (typeof relay !== "string" || !/^https?:\/\/[^/]+/.test(relay))
25
+ throw new Error("invalid relay url");
26
+ for (const [name, field] of Object.entries({ deviceId, secret, key }))
27
+ if (typeof field !== "string" || !B64URL.test(field))
28
+ throw new Error(`invalid ${name}`);
29
+ if (Buffer.from(key, "base64url").length !== 32)
30
+ throw new Error("key must be 32 bytes");
31
+ return { v: 1, relay: relay.replace(/\/+$/, ""), deviceId, secret, key };
32
+ }
33
+ export function encodePairing(pairing) {
34
+ return Buffer.from(JSON.stringify({ v: 1, ...pairing })).toString("base64url");
35
+ }
36
+ /** Pairing strings in env/options may be separated by commas or whitespace. */
37
+ export function splitPairings(value) {
38
+ const list = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
39
+ return list.flatMap((item) => (typeof item === "string" ? item.split(/[\s,]+/) : [])).filter(Boolean);
40
+ }
41
+ export const aad = (deviceId) => Buffer.from(`opencody-push:v1:${deviceId}`, "utf8");
42
+ // AES-256-GCM rather than ChaCha20-Poly1305: OpenCode runs plugins on Bun, whose node:crypto lacks ChaCha.
43
+ /** Output is base64(nonce[12] || ciphertext || tag[16]) = CryptoKit `AES.GCM.SealedBox.combined`. */
44
+ export function seal(key, deviceId, plaintext, nonce = randomBytes(12)) {
45
+ const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: 16 });
46
+ cipher.setAAD(aad(deviceId));
47
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
48
+ return Buffer.concat([nonce, body, cipher.getAuthTag()]).toString("base64");
49
+ }
50
+ export function open(key, deviceId, combined) {
51
+ const data = Buffer.from(combined, "base64");
52
+ const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12), { authTagLength: 16 });
53
+ decipher.setAAD(aad(deviceId));
54
+ decipher.setAuthTag(data.subarray(data.length - 16));
55
+ return Buffer.concat([decipher.update(data.subarray(12, data.length - 16)), decipher.final()]);
56
+ }
57
+ const cut = (value, max) => {
58
+ const chars = Array.from(value);
59
+ return chars.length <= max ? value : chars.slice(0, max - 1).join("") + "…";
60
+ };
61
+ // Keep the tail of paths: the project folder name is the informative part.
62
+ const cutStart = (value, max) => {
63
+ const chars = Array.from(value);
64
+ return chars.length <= max ? value : "…" + chars.slice(chars.length - max + 1).join("");
65
+ };
66
+ /** Serializes a message, shrinking free-text fields until it fits MAX_PLAINTEXT bytes. */
67
+ export function encodeMessage(message) {
68
+ const m = { ...message };
69
+ m.server = cut(m.server, 64);
70
+ if (m.title)
71
+ m.title = cut(m.title, 120);
72
+ if (m.directory)
73
+ m.directory = cutStart(m.directory, 200);
74
+ m.text = cut(m.text, 400);
75
+ for (;;) {
76
+ const bytes = Buffer.from(JSON.stringify(m), "utf8");
77
+ if (bytes.length <= MAX_PLAINTEXT)
78
+ return bytes;
79
+ if (Array.from(m.text).length > 20)
80
+ m.text = cut(m.text, Math.floor(Array.from(m.text).length / 2));
81
+ else if (m.title && Array.from(m.title).length > 20)
82
+ m.title = cut(m.title, Math.floor(Array.from(m.title).length / 2));
83
+ else if (m.directory && Array.from(m.directory).length > 20)
84
+ m.directory = cutStart(m.directory, Math.floor(Array.from(m.directory).length / 2));
85
+ else
86
+ throw new Error("message does not fit");
87
+ }
88
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "opencody-notify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "OpenCode V2 server plugin: end-to-end encrypted push notifications to OpenCody (iOS) via the OpenCody push relay",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "homepage": "https://willsoftwaresolutions.de",
9
+ "publishConfig": { "access": "public" },
10
+ "exports": {
11
+ ".": "./dist/index.js",
12
+ "./server": "./dist/index.js"
13
+ },
14
+ "files": ["dist", "server.js"],
15
+ "engines": { "node": ">=22.18" },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "prepare": "tsc",
19
+ "test": "node --test test/*.test.ts",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^24.0.0",
24
+ "typescript": "^5.9.0"
25
+ }
26
+ }
package/server.js ADDED
@@ -0,0 +1,2 @@
1
+ // Entry point for local-directory installs: OpenCode resolves <dir>/server, not package exports.
2
+ export { default } from "./dist/index.js"