@jdscript/opencode-notify 0.1.1 → 0.1.2

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/VERIFICATION.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Verification — initial plugin / app integration
2
2
 
3
+ ## 0.1.2 scoped Live Activity encryption
4
+
5
+ The `info` capability advertises `activityEncryption: "route-v1"`. An `attach`
6
+ can provide a dedicated recipient public key/authentication secret for that
7
+ activity; alerts retain the subscription key. Route encryption and its expiry
8
+ cannot change under an existing route ID. Live jobs are capped by route expiry.
9
+
10
+ Fourteen sender/crypto tests (85 assertions) pass, including cross-key rejection,
11
+ immutable activity keys/expiry, persisted activity encryption across restart,
12
+ and an explicit encrypted test update. The packed artifact passed the isolated
13
+ upstream SDK → Rust relay → Swift receiver pipeline with six envelopes. The
14
+ Swift activity path selects temporary route keys with a failing Keychain loader,
15
+ proving that rendering does not depend on direct Keychain access. Legacy attach
16
+ inputs without route encryption remain supported for older clients.
17
+
3
18
  ## 0.1.1 distribution check
4
19
 
5
20
  `@opencode/plugin` is a runtime dependency. The 0.1.0 CLI-install path resolved
@@ -11,6 +26,13 @@ loopback registry served the packed artifact and the real upstream SDK resolved
11
26
  its dependency graph in a fresh home. The complete Rust relay/Swift receiver
12
27
  pipeline accepted six encrypted envelopes. No model or Apple push was used.
13
28
 
29
+ The published npm `@jdscript/opencode-notify@0.1.1` then passed both
30
+ `python3 ../scripts/mock-plugin-install.py --package @jdscript/opencode-notify@0.1.1`
31
+ and `python3 scripts/check-upstream.py --rust-relay --swift --package @jdscript/opencode-notify@0.1.1`.
32
+ These freshly downloaded the release, verified CLI installation and automatic
33
+ global RPC loading, and decrypted all six relay envelopes with the production
34
+ Swift receiver. Published tarball SHA-1: `0b98f8d23f93277114a7a16ed8a2f599918ea746`.
35
+
14
36
  ## Passed
15
37
 
16
38
  - TypeScript typecheck and distributable build.
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ function makePlugin() {
33
33
  })();
34
34
  const runtime = await shared.sender;
35
35
  const registration = await ctx.rpc.register(Definition, {
36
- info: async () => ({ version: 1, encoding: "aes128gcm", applicationVersion: 1, activityScopes: ["session", "server", "workspace"] }),
36
+ info: async () => ({ version: 1, encoding: "aes128gcm", applicationVersion: 1, activityScopes: ["session", "server", "workspace"], activityEncryption: "route-v1" }),
37
37
  subscribe: async (input, context) => runtime
38
38
  .subscribe(input)
39
39
  .catch((error) => context.error("invalid", "Notification setup failed", { code: code(error) })),
@@ -54,6 +54,8 @@ function makePlugin() {
54
54
  detach: async (input, context) => runtime
55
55
  .detach(input.subscriptionID, input.activityID)
56
56
  .catch((error) => context.error("invalid", "Activity removal failed", { code: code(error) })),
57
+ test: async (input, context) => runtime.testActivity(input.subscriptionID, input.activityID)
58
+ .catch((error) => context.error("invalid", "Activity test failed", { code: code(error) })),
57
59
  });
58
60
  const controller = new AbortController();
59
61
  const events = (async () => {
package/dist/rpc.d.ts CHANGED
@@ -24,6 +24,10 @@ export declare const Route: z.ZodObject<{
24
24
  subscriptionID: z.ZodString;
25
25
  activityID: z.ZodString;
26
26
  }, z.core.$strict>;
27
+ export declare const ActivityEncryption: z.ZodObject<{
28
+ recipientPublicKey: z.ZodString;
29
+ authSecret: z.ZodString;
30
+ }, z.core.$strict>;
27
31
  export declare const Attach: z.ZodObject<{
28
32
  subscriptionID: z.ZodString;
29
33
  activityID: z.ZodString;
@@ -35,6 +39,10 @@ export declare const Attach: z.ZodObject<{
35
39
  workspace: "workspace";
36
40
  }>>;
37
41
  serverName: z.ZodOptional<z.ZodString>;
42
+ encryption: z.ZodOptional<z.ZodObject<{
43
+ recipientPublicKey: z.ZodString;
44
+ authSecret: z.ZodString;
45
+ }, z.core.$strict>>;
38
46
  seedComplete: z.ZodOptional<z.ZodBoolean>;
39
47
  seeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
40
48
  sessionID: z.ZodString;
@@ -74,6 +82,7 @@ export declare const Definition: {
74
82
  server: "server";
75
83
  workspace: "workspace";
76
84
  }>>>;
85
+ activityEncryption: z.ZodOptional<z.ZodLiteral<"route-v1">>;
77
86
  }, z.core.$strict>;
78
87
  readonly errors: {
79
88
  invalid: z.ZodObject<{
@@ -161,6 +170,10 @@ export declare const Definition: {
161
170
  workspace: "workspace";
162
171
  }>>;
163
172
  serverName: z.ZodOptional<z.ZodString>;
173
+ encryption: z.ZodOptional<z.ZodObject<{
174
+ recipientPublicKey: z.ZodString;
175
+ authSecret: z.ZodString;
176
+ }, z.core.$strict>>;
164
177
  seedComplete: z.ZodOptional<z.ZodBoolean>;
165
178
  seeds: z.ZodOptional<z.ZodArray<z.ZodObject<{
166
179
  sessionID: z.ZodString;
@@ -195,5 +208,19 @@ export declare const Definition: {
195
208
  }, z.core.$strict>;
196
209
  };
197
210
  };
211
+ readonly test: {
212
+ readonly input: z.ZodObject<{
213
+ subscriptionID: z.ZodString;
214
+ activityID: z.ZodString;
215
+ }, z.core.$strict>;
216
+ readonly output: z.ZodObject<{
217
+ ok: z.ZodLiteral<true>;
218
+ }, z.core.$strict>;
219
+ readonly errors: {
220
+ invalid: z.ZodObject<{
221
+ code: z.ZodString;
222
+ }, z.core.$strict>;
223
+ };
224
+ };
198
225
  };
199
226
  };
package/dist/rpc.js CHANGED
@@ -21,10 +21,12 @@ export const Subscribe = z
21
21
  .strict();
22
22
  export const Reference = z.object({ subscriptionID: UUID }).strict();
23
23
  export const Route = z.object({ subscriptionID: UUID, activityID: UUID }).strict();
24
+ export const ActivityEncryption = z.object({ recipientPublicKey: z.string().length(87), authSecret: z.string().length(22) }).strict();
24
25
  export const Attach = Route.extend({
25
26
  sessionID: z.string().startsWith("ses").max(200), expiresAt: seconds,
26
27
  scope: z.enum(["session", "server", "workspace"]).optional(),
27
28
  serverName: z.string().max(160).optional(),
29
+ encryption: ActivityEncryption.optional(),
28
30
  seedComplete: z.boolean().optional(),
29
31
  seeds: z.array(z.object({ sessionID: z.string().startsWith("ses").max(200), running: z.boolean(), waiting: z.array(z.string().max(200)).max(64) }).strict()).max(512).optional(),
30
32
  shells: z.array(z.object({ id: z.string().max(200), sessionID: z.string().startsWith("ses").max(200).optional() }).strict()).max(512).optional(),
@@ -47,7 +49,7 @@ export const Definition = Rpc.define({
47
49
  info: {
48
50
  input: z.object({}).strict(),
49
51
  output: z
50
- .object({ version: z.literal(1), encoding: z.literal("aes128gcm"), applicationVersion: z.literal(1), activityScopes: z.array(z.enum(["session", "server", "workspace"])).optional() })
52
+ .object({ version: z.literal(1), encoding: z.literal("aes128gcm"), applicationVersion: z.literal(1), activityScopes: z.array(z.enum(["session", "server", "workspace"])).optional(), activityEncryption: z.literal("route-v1").optional() })
51
53
  .strict(),
52
54
  errors,
53
55
  },
@@ -56,5 +58,6 @@ export const Definition = Rpc.define({
56
58
  status: { input: Reference, output: Status, errors },
57
59
  attach: { input: Attach, output: ok, errors },
58
60
  detach: { input: Route, output: ok, errors },
61
+ test: { input: Route, output: ok, errors },
59
62
  },
60
63
  });
package/dist/sender.d.ts CHANGED
@@ -51,6 +51,9 @@ export declare class Sender {
51
51
  detach(subscriptionID: string, activityID: string): Promise<{
52
52
  ok: true;
53
53
  }>;
54
+ testActivity(subscriptionID: string, activityID: string): Promise<{
55
+ ok: true;
56
+ }>;
54
57
  /** Semantic events only: no reasoning/text deltas, transcripts, tool arguments or outputs. */
55
58
  onEvent(event: Event): Promise<void>;
56
59
  private node;
package/dist/sender.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createECDH, createHash, randomUUID } from "node:crypto";
2
2
  import { basename } from "node:path";
3
3
  import { z } from "zod";
4
- import { Subscribe } from "./rpc.js";
4
+ import { ActivityEncryption, Subscribe } from "./rpc.js";
5
5
  import { decode, encrypt } from "./crypto.js";
6
6
  const StoredSubscription = z.object({
7
7
  input: Subscribe,
@@ -20,6 +20,7 @@ const StoredActivity = z.object({
20
20
  lastSent: z.number(),
21
21
  scope: z.enum(["session", "server", "workspace"]).optional(),
22
22
  serverName: z.string().optional(),
23
+ encryption: ActivityEncryption.optional(),
23
24
  });
24
25
  const StoredJob = z.object({
25
26
  subscriptionID: z.string(),
@@ -163,6 +164,21 @@ export class Sender {
163
164
  throw new SenderError("activity_binding_changed");
164
165
  if (old && (old.scope ?? "session") !== (input.scope ?? "session"))
165
166
  throw new SenderError("activity_binding_changed");
167
+ if (old && (old.encryption?.recipientPublicKey !== input.encryption?.recipientPublicKey || old.encryption?.authSecret !== input.encryption?.authSecret))
168
+ throw new SenderError("activity_encryption_changed");
169
+ if (old?.encryption && old.expiresAt !== input.expiresAt)
170
+ throw new SenderError("activity_expiry_changed");
171
+ if (input.encryption) {
172
+ const probe = createECDH("prime256v1");
173
+ probe.generateKeys();
174
+ try {
175
+ probe.computeSecret(decode(input.encryption.recipientPublicKey, 65));
176
+ decode(input.encryption.authSecret, 16);
177
+ }
178
+ catch {
179
+ throw new SenderError("invalid_activity_encryption");
180
+ }
181
+ }
166
182
  if (input.scope === "server" || input.scope === "workspace") {
167
183
  if (input.sessionID !== "ses_workspace")
168
184
  throw new SenderError("invalid_workspace_binding");
@@ -192,7 +208,7 @@ export class Sender {
192
208
  }
193
209
  const route = {
194
210
  subscriptionID: input.subscriptionID, activityID: input.activityID, sessionID: input.sessionID,
195
- expiresAt: input.expiresAt, scope: input.scope, serverName: input.serverName,
211
+ expiresAt: input.expiresAt, scope: input.scope, serverName: input.serverName, encryption: input.encryption,
196
212
  ended: false,
197
213
  unavailable: false,
198
214
  lastTimestamp: old?.lastTimestamp ?? 0,
@@ -228,6 +244,28 @@ export class Sender {
228
244
  return { ok: true };
229
245
  });
230
246
  }
247
+ testActivity(subscriptionID, activityID) {
248
+ return this.serial(async () => {
249
+ const sub = this.requireSubscription(subscriptionID, true);
250
+ const route = this.activities.get(routeKey(subscriptionID, activityID));
251
+ if (!route || route.ended || route.unavailable || route.expiresAt <= now())
252
+ throw new SenderError("activity_unavailable");
253
+ if ((sub.billingUntil ?? 0) > now())
254
+ throw new SenderError("entitlement_required");
255
+ const timestamp = now();
256
+ if (timestamp <= route.lastTimestamp)
257
+ throw new SenderError("activity_update_pending");
258
+ const root = isOverview(route) ? undefined : await this.node(route.sessionID);
259
+ const content = root ? this.content(root) : this.workspaceContent(route);
260
+ content.detail = "Test update";
261
+ await this.queue("activity/" + routeKey(subscriptionID, activityID), sub, root ? { id: root.info.id, directory: root.info.location.directory } : { id: "ses_workspace", directory: "/" }, content, { activityID, event: "update", timestamp }, route.scope === "workspace" ? false : undefined, route);
262
+ route.lastTimestamp = timestamp;
263
+ route.lastSent = timestamp;
264
+ await this.save("activity/" + routeKey(subscriptionID, activityID), route);
265
+ this.schedule();
266
+ return { ok: true };
267
+ });
268
+ }
231
269
  /** Semantic events only: no reasoning/text deltas, transcripts, tool arguments or outputs. */
232
270
  onEvent(event) {
233
271
  return this.serial(async () => {
@@ -549,7 +587,7 @@ export class Sender {
549
587
  activityID: route.activityID,
550
588
  event,
551
589
  timestamp,
552
- }, route.scope === "workspace" ? settled : undefined);
590
+ }, route.scope === "workspace" ? settled : undefined, route);
553
591
  route.lastTimestamp = timestamp;
554
592
  route.lastSent = timestamp;
555
593
  this.dirty.delete(routeKey(route.subscriptionID, route.activityID));
@@ -557,7 +595,7 @@ export class Sender {
557
595
  this.dirty.set(routeKey(route.subscriptionID, route.activityID), false);
558
596
  await this.save("activity/" + routeKey(route.subscriptionID, route.activityID), route);
559
597
  }
560
- async queue(key, sub, binding, content, activity, settled) {
598
+ async queue(key, sub, binding, content, activity, settled, route) {
561
599
  if (this.jobs.size >= 512 && !this.jobs.has(key))
562
600
  throw new SenderError("outbox_full");
563
601
  const eventID = randomUUID();
@@ -570,7 +608,7 @@ export class Sender {
570
608
  kind: activity ? "live_activity" : "alert",
571
609
  producer: "server",
572
610
  issuedAt: now(),
573
- expiresAt: Math.min(now() + 3600, sub.input.expiresAt),
611
+ expiresAt: Math.min(now() + 3600, sub.input.expiresAt, route?.expiresAt ?? Number.MAX_SAFE_INTEGER),
574
612
  sessionID: binding.id,
575
613
  directory: binding.directory,
576
614
  content,
@@ -579,7 +617,8 @@ export class Sender {
579
617
  const bytes = Buffer.from(JSON.stringify(payload));
580
618
  if (bytes.length > 2400 || Buffer.byteLength(payload.directory) > 1024)
581
619
  throw new SenderError("content_too_large");
582
- const ciphertext = encrypt(bytes, sub.input.recipientPublicKey, sub.input.authSecret);
620
+ const recipient = route?.encryption ?? sub.input;
621
+ const ciphertext = encrypt(bytes, recipient.recipientPublicKey, recipient.authSecret);
583
622
  const job = {
584
623
  subscriptionID: sub.input.subscriptionID,
585
624
  expiresAt: payload.expiresAt,
package/dist/types.d.ts CHANGED
@@ -56,6 +56,10 @@ export interface Activity {
56
56
  lastSent: number;
57
57
  scope?: "session" | "server" | "workspace";
58
58
  serverName?: string;
59
+ encryption?: {
60
+ recipientPublicKey: string;
61
+ authSecret: string;
62
+ };
59
63
  }
60
64
  export interface Push {
61
65
  event_id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jdscript/opencode-notify",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "End-to-end encrypted mobile notifications and Live Activity updates for OpenCode v2",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",