@homespunapps/core 1.6.53 → 1.6.55

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.
@@ -75,5 +75,95 @@ export interface AppStreamHandle {
75
75
  * `truncated:true`) so callers only handle individual entries.
76
76
  */
77
77
  export declare function openAppStream(opts: OpenAppStreamOptions, handlers: AppStreamHandlers): AppStreamHandle;
78
+ export interface OpenWorkerStreamOptions {
79
+ /**
80
+ * The relay's API base, e.g. `https://app.homespun.dev` — NOT an app origin, and
81
+ * NOT a WebSocket URL. The path is appended here so a caller cannot get it wrong,
82
+ * which the per-app version demonstrated was worth taking out of their hands.
83
+ */
84
+ baseUrl: string;
85
+ /** Agent API key, carried via the `homespun.agentkey.<key>` subprotocol token. */
86
+ apiKey: string;
87
+ }
88
+ export interface WorkerStreamHandlers {
89
+ /**
90
+ * Fired once on connect.
91
+ *
92
+ * `push` is the relay telling the worker whether offering credit will achieve
93
+ * anything. A worker that promised its capacity to a relay with push switched off
94
+ * would hold that capacity back from its own polling and starve waiting for frames
95
+ * that are never coming, so this is answered rather than inferred. An older relay
96
+ * omits it, and `false` is the safe reading: poll.
97
+ */
98
+ onHello?: (info: {
99
+ push: boolean;
100
+ }) => void;
101
+ /**
102
+ * A task was queued for one of this owner's apps. `appId` is present so a worker
103
+ * can narrow its claim, and is the ONLY thing the frame carries: no task id, no
104
+ * prompt, no row data. A consumer that never receives one (no socket, dropped
105
+ * frame, older relay) must still poll and lose nothing but time.
106
+ */
107
+ onAgentTaskAvailable?: (info: {
108
+ appId: string;
109
+ }) => void;
110
+ /**
111
+ * A task has been ASSIGNED to this worker: leased, with the whole envelope.
112
+ *
113
+ * Unlike the hint above, this is work in hand. The lease is already running, so a
114
+ * consumer that ignores the frame holds a task nobody is doing until the lease lapses.
115
+ * The envelope is the same shape the claim route returns, deliberately, so a consumer
116
+ * can hand it to the same executor either way.
117
+ */
118
+ onAssign?: (envelope: WorkerTaskEnvelope) => void;
119
+ /** Fired when the socket closes (cleanly or otherwise). */
120
+ onClose?: (info: {
121
+ code: number;
122
+ reason: string;
123
+ }) => void;
124
+ /** Fired on a transport-level error, including a rejected upgrade. */
125
+ onError?: (err: Error) => void;
126
+ }
127
+ /**
128
+ * One assigned task, as the relay sends it.
129
+ *
130
+ * Kept OPEN (`[k: string]: unknown`) on purpose. This client does not interpret the
131
+ * envelope, it forwards it, and a closed type here would silently drop any field the
132
+ * relay adds later: a consumer piping the whole object to a child process would start
133
+ * handing over a truncated version of it after a relay upgrade. Only the fields this
134
+ * layer actually reasons about are named.
135
+ */
136
+ export interface WorkerTaskEnvelope {
137
+ task_id: string;
138
+ app_id: string;
139
+ app_slug: string;
140
+ task_type: string;
141
+ [k: string]: unknown;
142
+ }
143
+ /** A live worker stream, with the client-to-server half of the protocol. */
144
+ export interface WorkerStreamHandle extends AppStreamHandle {
145
+ /**
146
+ * Declare how many tasks this worker will accept, right now.
147
+ *
148
+ * ABSOLUTE, not an increment: the relay replaces whatever it held. Send it again after
149
+ * each task finishes to top back up. The worker has to drive this, because an ack goes
150
+ * over HTTP to whichever replica answers and that is usually not the one holding this
151
+ * socket, so the relay cannot see a task finish.
152
+ *
153
+ * Returns false if the socket was not open, so a caller can tell the difference
154
+ * between "declared" and "shouted into a closed pipe".
155
+ */
156
+ sendReady(credits: number): boolean;
157
+ }
158
+ /** Derive the worker stream's WebSocket URL from an API base (https -> wss). */
159
+ export declare function workerWsUrlFromBase(baseUrl: string): string;
160
+ /**
161
+ * Open the worker stream: one socket, wakes for every app this key's owner has.
162
+ *
163
+ * Does not reconnect. That is the caller's job, because only the caller knows whether
164
+ * an outage is worth announcing and the CLI already has the backoff-with-one-warning
165
+ * behaviour this would otherwise duplicate.
166
+ */
167
+ export declare function openWorkerStream(opts: OpenWorkerStreamOptions, handlers: WorkerStreamHandlers): WorkerStreamHandle;
78
168
  /** Derive an app's `/_hs/ws` URL from its `url` field (https -> wss). */
79
169
  export declare function appWsUrlFromAppUrl(appUrl: string): string;
@@ -128,6 +128,112 @@ export function openAppStream(opts, handlers) {
128
128
  },
129
129
  };
130
130
  }
131
+ /** Derive the worker stream's WebSocket URL from an API base (https -> wss). */
132
+ export function workerWsUrlFromBase(baseUrl) {
133
+ const u = new URL(baseUrl);
134
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
135
+ u.pathname = u.pathname.replace(/\/$/, "") + "/v1/agent-tasks/stream";
136
+ u.search = "";
137
+ u.hash = "";
138
+ return u.toString();
139
+ }
140
+ /**
141
+ * Open the worker stream: one socket, wakes for every app this key's owner has.
142
+ *
143
+ * Does not reconnect. That is the caller's job, because only the caller knows whether
144
+ * an outage is worth announcing and the CLI already has the backoff-with-one-warning
145
+ * behaviour this would otherwise duplicate.
146
+ */
147
+ export function openWorkerStream(opts, handlers) {
148
+ const socket = new WebSocket(workerWsUrlFromBase(opts.baseUrl), [
149
+ "homespun.v1",
150
+ `homespun.agentkey.${opts.apiKey}`,
151
+ ]);
152
+ socket.on("message", (raw) => {
153
+ let msg;
154
+ try {
155
+ msg = JSON.parse(raw.toString());
156
+ }
157
+ catch (e) {
158
+ handlers.onError?.(new Error(`failed to parse worker stream frame as JSON: ${e instanceof Error ? e.message : String(e)}`));
159
+ return;
160
+ }
161
+ if (!msg || typeof msg !== "object") {
162
+ handlers.onError?.(new Error("unexpected non-object worker stream frame"));
163
+ return;
164
+ }
165
+ const obj = msg;
166
+ switch (obj["type"]) {
167
+ case "hello":
168
+ handlers.onHello?.({ push: obj["push"] === true });
169
+ return;
170
+ case "agent-task.available": {
171
+ const appId = obj["app_id"];
172
+ // A wake with no app id is not passed on as a wake for app "". The worker
173
+ // would claim with an empty filter and either drain apps it was told not to
174
+ // or claim nothing at all, and both are worse than reporting a bad frame.
175
+ if (typeof appId !== "string" || appId === "") {
176
+ handlers.onError?.(new Error("worker stream wake frame carried no app_id"));
177
+ return;
178
+ }
179
+ handlers.onAgentTaskAvailable?.({ appId });
180
+ return;
181
+ }
182
+ case "agent-task.assign": {
183
+ // Validated harder than the hint, because this frame is WORK rather than a
184
+ // suggestion. A leased task the consumer cannot identify is a task nobody will
185
+ // ack, and it stays out of circulation for a whole lease before anyone notices.
186
+ const taskId = obj["task_id"];
187
+ if (typeof taskId !== "string" || taskId === "") {
188
+ handlers.onError?.(new Error("worker stream assign frame carried no task_id"));
189
+ return;
190
+ }
191
+ // `type` is stripped: it is this protocol's envelope tag, not part of the task,
192
+ // and leaving it in would put a field in a consumer's stdin that the claim route
193
+ // never sends. The two paths must hand over the same object.
194
+ const envelope = { ...obj };
195
+ delete envelope["type"];
196
+ handlers.onAssign?.(envelope);
197
+ return;
198
+ }
199
+ default:
200
+ // Tolerated, not an error. A newer relay may add a frame type, and a worker
201
+ // that treated one as a fault would report an outage every time the relay
202
+ // gained a feature. Unknown frames are simply not wakes.
203
+ return;
204
+ }
205
+ });
206
+ socket.on("close", (code, reason) => {
207
+ handlers.onClose?.({ code, reason: reason.toString() });
208
+ });
209
+ socket.on("error", (err) => {
210
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
211
+ });
212
+ return {
213
+ close() {
214
+ try {
215
+ socket.close();
216
+ }
217
+ catch {
218
+ /* already closing */
219
+ }
220
+ },
221
+ sendReady(credits) {
222
+ if (socket.readyState !== WebSocket.OPEN)
223
+ return false;
224
+ try {
225
+ socket.send(JSON.stringify({ type: "ready", credits }));
226
+ return true;
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ },
232
+ get socket() {
233
+ return socket;
234
+ },
235
+ };
236
+ }
131
237
  /** Derive an app's `/_hs/ws` URL from its `url` field (https -> wss). */
132
238
  export function appWsUrlFromAppUrl(appUrl) {
133
239
  const u = new URL(appUrl);
package/dist/client.d.ts CHANGED
@@ -318,6 +318,20 @@ export declare class HomespunClient {
318
318
  cursor?: string;
319
319
  slug?: string;
320
320
  }): Promise<AppsPage>;
321
+ /**
322
+ * GET /v1/apps/advisories — the owner security audit.
323
+ *
324
+ * Every security advisory the caller's OWN live apps earn, computed from
325
+ * their stored manifests rather than at deploy time. That is the point: a
326
+ * deploy-time warning only ever reaches an app that redeploys, so an app
327
+ * deployed once and never touched keeps its shape forever with nobody told.
328
+ *
329
+ * Read-only. It never changes an app; the repair is one redeploy per app,
330
+ * and the correct fix genuinely differs per app.
331
+ */
332
+ appAdvisories(opts?: {
333
+ severity?: "high" | "medium" | "low";
334
+ }): Promise<AppAdvisoriesReport>;
321
335
  /** GET /v1/apps/:id — full app detail (manifest, current_version, quota). */
322
336
  getApp(appId: string): Promise<AppDetail>;
323
337
  /**
@@ -1052,6 +1066,44 @@ export interface AppsPage {
1052
1066
  items: AppSummary[];
1053
1067
  next_cursor: string | null;
1054
1068
  }
1069
+ /** One security finding against a collection's declared permission shape. */
1070
+ export interface CollectionAdvisory {
1071
+ /** Stable identifier, safe to filter and count on. */
1072
+ code: string;
1073
+ collection: string;
1074
+ /**
1075
+ * `high` = exploitable by an anonymous caller today, with no precondition
1076
+ * beyond opening the app. `medium` = a real weakness that needs something
1077
+ * else to go wrong first. `low` = a design smell, frequently intentional.
1078
+ */
1079
+ severity: "high" | "medium" | "low";
1080
+ message: string;
1081
+ }
1082
+ /** One app's findings, from `GET /v1/apps/advisories`. */
1083
+ export interface AppAdvisories {
1084
+ app_id: string;
1085
+ slug: string;
1086
+ visibility: "private" | "link" | "public";
1087
+ status: string;
1088
+ current_version: number;
1089
+ highest_severity: "high" | "medium" | "low";
1090
+ advisories: CollectionAdvisory[];
1091
+ }
1092
+ /** `GET /v1/apps/advisories`: the owner security audit across every owned app. */
1093
+ export interface AppAdvisoriesReport {
1094
+ /** Live apps considered, including those with nothing to report. */
1095
+ apps_scanned: number;
1096
+ apps_affected: number;
1097
+ /** Counts across the WHOLE audit, unaffected by any `severity` filter. */
1098
+ counts: {
1099
+ high: number;
1100
+ medium: number;
1101
+ low: number;
1102
+ };
1103
+ /** True when a cap stopped the walk before the owner's apps ran out. */
1104
+ truncated: boolean;
1105
+ items: AppAdvisories[];
1106
+ }
1055
1107
  /** `GET /v1/apps/:id/document`: an app's authored HTML source (current version). */
1056
1108
  export interface AppDocument {
1057
1109
  /** The authored HTML, exactly as deployed (no served-page prelude injected). */
package/dist/client.js CHANGED
@@ -610,6 +610,27 @@ export class HomespunClient {
610
610
  this.fail(r);
611
611
  return this.asObject(r);
612
612
  }
613
+ /**
614
+ * GET /v1/apps/advisories — the owner security audit.
615
+ *
616
+ * Every security advisory the caller's OWN live apps earn, computed from
617
+ * their stored manifests rather than at deploy time. That is the point: a
618
+ * deploy-time warning only ever reaches an app that redeploys, so an app
619
+ * deployed once and never touched keeps its shape forever with nobody told.
620
+ *
621
+ * Read-only. It never changes an app; the repair is one redeploy per app,
622
+ * and the correct fix genuinely differs per app.
623
+ */
624
+ async appAdvisories(opts = {}) {
625
+ const q = new URLSearchParams();
626
+ if (opts.severity !== undefined)
627
+ q.set("severity", opts.severity);
628
+ const qs = q.toString();
629
+ const r = await this.call("GET", `/v1/apps/advisories${qs ? "?" + qs : ""}`);
630
+ if (!r.ok)
631
+ this.fail(r);
632
+ return this.asObject(r);
633
+ }
613
634
  /** GET /v1/apps/:id — full app detail (manifest, current_version, quota). */
614
635
  async getApp(appId) {
615
636
  const r = await this.call("GET", `/v1/apps/${encodeURIComponent(appId)}`);
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export { openStream } from "./stream.js";
4
4
  export type { OpenStreamOptions, StreamHandlers, StreamHandle, } from "./stream.js";
5
5
  export { openAppStream, appWsUrlFromAppUrl } from "./app-stream.js";
6
6
  export type { OpenAppStreamOptions, AppStreamHandlers, AppStreamHandle, } from "./app-stream.js";
7
+ export { openWorkerStream, workerWsUrlFromBase } from "./app-stream.js";
8
+ export type { OpenWorkerStreamOptions, WorkerStreamHandlers, WorkerStreamHandle, WorkerTaskEnvelope, } from "./app-stream.js";
7
9
  export { registerAgent } from "./register.js";
8
10
  export type { RegisterAgentOptions, RegisterAgentResult } from "./register.js";
9
11
  export { feedbackTypeSchema, submitFeedbackSchema } from "./schemas.js";
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  export { HomespunClient, HomespunApiError } from "./client.js";
4
4
  export { openStream } from "./stream.js";
5
5
  export { openAppStream, appWsUrlFromAppUrl } from "./app-stream.js";
6
+ export { openWorkerStream, workerWsUrlFromBase } from "./app-stream.js";
6
7
  export { registerAgent } from "./register.js";
7
8
  export { feedbackTypeSchema, submitFeedbackSchema } from "./schemas.js";
8
9
  export { RELAY_FAILURE_REPORT_HINT } from "./feedback-prompt.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/core",
3
- "version": "1.6.53",
3
+ "version": "1.6.55",
4
4
  "description": "Homespun relay client: typed HTTP + WebSocket operations against a Homespun relay. Framework-free.",
5
5
  "license": "MIT",
6
6
  "type": "module",