lauther-client 0.2.0 → 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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Push notifications to your phone from any web app or script — and questions
4
4
  that **wait for a yes** before something irreversible happens.
5
5
 
6
- Zero dependencies. Node 18+. Two methods.
6
+ Zero dependencies. Node 18+.
7
7
 
8
8
  ```bash
9
9
  npm install lauther-client
@@ -80,6 +80,50 @@ const { response } = await lauther.approve({
80
80
  // response is "approve", "deny", or null when nobody answered in time.
81
81
  ```
82
82
 
83
+ ## Requests
84
+
85
+ A request is an approval whose "yes" carries something back: the person taps
86
+ **Share**, the phone does one thing while they watch, and the result rides
87
+ home on the answer. Two kinds: where they are, and one picture.
88
+
89
+ ```js
90
+ const where = await lauther.requestLocation({
91
+ title: "Share your location so I can find the nearest pharmacy?",
92
+ timeoutSeconds: 300,
93
+ });
94
+
95
+ if (where.response === "approve") {
96
+ const { lat, lng, accuracy, at } = where.result;
97
+ }
98
+ ```
99
+
100
+ ```js
101
+ const shot = await lauther.requestPhoto({ title: "Photo of the printer display?" });
102
+
103
+ if (shot.response === "approve") {
104
+ const { url, bytes, width, height } = shot.result; // a JPEG download link
105
+ }
106
+ ```
107
+
108
+ Both return `{ id, response, confirmedBy, result, waitedSeconds, expired }`.
109
+ `response` is `"approve"`, `"deny"` or `null`, and `result` is `null` unless
110
+ the person approved. They wait the way `requestApproval()` does: past the
111
+ server's 25 seconds on the **same** request, so the phone still shows one.
112
+ Silence is a returned outcome rather than a thrown error. `expired: true`
113
+ means the request lapsed on the phone (10 minutes by default; set
114
+ `expiresIn`, 60 to 3600 seconds) and the wait ends early.
115
+
116
+ - **Location** is one reading, taken when they tap Share and sent once:
117
+ `{ lat, lng, accuracy, at }`, with `accuracy` in metres.
118
+ - **Photo** is a JPEG they take or pick, at most 2 MB. You receive a link,
119
+ and **the link works for 7 days**; download it if you want to keep it.
120
+
121
+ Options for both: `title`, `message`, `requireBiometric`, `priority`,
122
+ `expiresIn`, plus `timeoutSeconds` and `pollIntervalMs` for the wait. For
123
+ full control, `request(kind, options)` is the single call behind them (it
124
+ answers within `wait` seconds, 0 to 25), and `poll(id)` also returns `result`
125
+ and `expired`.
126
+
83
127
  ## Handling failure
84
128
 
85
129
  ```js
@@ -119,7 +163,10 @@ reported separately rather than collapsed into one flag.
119
163
  | `push(options)` | `title`, `message`, `priority`, `url`, `path`, `tag`, `icon`, `color`, `sound`, `image` |
120
164
  | `approve(options)` | `title`, `message`, `wait` (0–25s), `approveTitle`, `denyTitle`, `requireBiometric`, `priority` |
121
165
  | `requestApproval(question, options?)` | Ask and wait, past the 25s cap, on one notification |
122
- | `poll(id)` | Ask about an approval already started |
166
+ | `request(kind, options?)` | Ask for `"location"` or `"photo"`; `title`, `message`, `requireBiometric`, `priority`, `wait`, `expiresIn` |
167
+ | `requestLocation(options?)` | Ask for a position and wait, past the 25s cap, on one request |
168
+ | `requestPhoto(options?)` | Ask for one picture and wait, past the 25s cap |
169
+ | `poll(id)` | Ask about an approval or request already started (carries `result` and `expired`) |
123
170
  | `ask(question, options?)` | `requestApproval()` reduced to a boolean |
124
171
 
125
172
  `priority` runs `-2` to `2`. **`2` is the only level that crosses quiet
@@ -133,7 +180,7 @@ TypeScript types are bundled; there is no `@types` package to install.
133
180
 
134
181
  ## You do not need this package
135
182
 
136
- The whole client is `fetch` against two documented endpoints. If you would
183
+ The whole client is `fetch` against a few documented endpoints. If you would
137
184
  rather not add a dependency, this is the entire integration:
138
185
 
139
186
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lauther-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Send push notifications to your phone from any web app or script — and ask questions that wait for a yes.",
5
5
  "keywords": [
6
6
  "push",
package/src/index.d.ts CHANGED
@@ -69,6 +69,13 @@ export interface PollResult {
69
69
  response: "approve" | "deny" | null;
70
70
  /** How the answer was confirmed, e.g. a biometric check. `null` if not. */
71
71
  confirmedBy: string | null;
72
+ /**
73
+ * What a request carried back: a `LocationResult` or `PhotoResult` once
74
+ * approved, `null` until then, when denied, and for plain approvals.
75
+ */
76
+ result?: LocationResult | PhotoResult | null;
77
+ /** `true` once a request has lapsed unanswered; there is no point polling on. */
78
+ expired?: boolean;
72
79
  }
73
80
 
74
81
  export interface WaitOptions extends Omit<ApproveOptions, "title" | "wait"> {
@@ -90,6 +97,73 @@ export interface ApprovalOutcome {
90
97
  waitedSeconds: number;
91
98
  }
92
99
 
100
+ /** The kinds of thing a person can be asked to share. */
101
+ export type RequestKind = "location" | "photo";
102
+
103
+ export interface RequestOptions {
104
+ /** The question. Defaults to "Share your location?" / "Take a photo?". */
105
+ title?: string;
106
+ message?: string;
107
+ /** Demand a fingerprint or face before the share goes out. */
108
+ requireBiometric?: boolean;
109
+ priority?: Priority;
110
+ /** Seconds to wait for an answer, 0-25. Defaults to 25. */
111
+ wait?: number;
112
+ /** Seconds the request stays answerable, 60-3600. Defaults to 600. */
113
+ expiresIn?: number;
114
+ }
115
+
116
+ /** One position, as the phone read it a moment before sending. */
117
+ export interface LocationResult {
118
+ lat: number;
119
+ lng: number;
120
+ /** Metres. Absent when the phone did not report one. */
121
+ accuracy?: number;
122
+ /** ISO 8601 time of the reading. */
123
+ at: string;
124
+ }
125
+
126
+ /** One picture. Shared photos are kept for 7 days, then deleted. */
127
+ export interface PhotoResult {
128
+ /** Download link for the JPEG. */
129
+ url: string;
130
+ bytes: number;
131
+ width?: number;
132
+ height?: number;
133
+ }
134
+
135
+ export interface RequestResult {
136
+ ok: true;
137
+ id: string;
138
+ /** `null` means nobody answered in time. Silence is not consent. */
139
+ response: "approve" | "deny" | null;
140
+ /** What was shared. `null` unless the person approved. */
141
+ result: LocationResult | PhotoResult | null;
142
+ }
143
+
144
+ export interface RequestWaitOptions extends Omit<RequestOptions, "wait"> {
145
+ /**
146
+ * How long to wait in total, in seconds. The server long-polls for the
147
+ * first 25s; beyond that the client polls the same request, so the person
148
+ * still sees exactly one.
149
+ */
150
+ timeoutSeconds?: number;
151
+ /** Gap between polls once past the server's 25s window. Defaults to 3000. */
152
+ pollIntervalMs?: number;
153
+ }
154
+
155
+ export interface RequestOutcome<R = LocationResult | PhotoResult> {
156
+ id: string;
157
+ /** `null` means nobody answered, or the request expired. */
158
+ response: "approve" | "deny" | null;
159
+ confirmedBy: string | null;
160
+ /** What was shared. `null` unless `response` is `"approve"`. */
161
+ result: R | null;
162
+ waitedSeconds: number;
163
+ /** `true` when the request lapsed on the phone before anyone answered. */
164
+ expired: boolean;
165
+ }
166
+
93
167
  export interface LautherOptions {
94
168
  baseUrl?: string;
95
169
  fetch?: typeof globalThis.fetch;
@@ -117,6 +191,12 @@ export declare class Lauther {
117
191
  poll(id: string): Promise<PollResult>;
118
192
  /** Ask and wait, past the server's 25s cap, on one notification. */
119
193
  requestApproval(question: string, options?: WaitOptions): Promise<ApprovalOutcome>;
194
+ /** Ask the person to share `"location"` or a `"photo"`; answers within `wait` seconds. */
195
+ request(kind: RequestKind, options?: RequestOptions): Promise<RequestResult>;
196
+ /** Ask for the person's position and wait, past the server's 25s cap. */
197
+ requestLocation(options?: RequestWaitOptions): Promise<RequestOutcome<LocationResult>>;
198
+ /** Ask for one picture and wait, past the server's 25s cap. */
199
+ requestPhoto(options?: RequestWaitOptions): Promise<RequestOutcome<PhotoResult>>;
120
200
  /** True only on an explicit Approve; a denial and a timeout are both false. */
121
201
  ask(question: string, options?: WaitOptions): Promise<boolean>;
122
202
  }
package/src/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Lauther — push notifications and human approvals for anything that can make
3
3
  * an HTTP request.
4
4
  *
5
- * Zero dependencies on purpose. The whole client is `fetch` against two
5
+ * Zero dependencies on purpose. The whole client is `fetch` against a few
6
6
  * documented endpoints, so there is no SDK to go stale against the API and
7
7
  * nothing to audit before you install it. If this package ever disappears,
8
8
  * the four lines it replaces are in the README.
@@ -116,7 +116,10 @@ export class Lauther {
116
116
  * Note this endpoint takes the token in the body rather than as a bearer
117
117
  * header — that is the API's shape, not a mistake here.
118
118
  *
119
- * @param {string} id The `id` returned by `approve()`.
119
+ * For a request, the answer also carries `result` (what was shared, or
120
+ * `null`) and `expired` (true once the request has lapsed unanswered).
121
+ *
122
+ * @param {string} id The `id` returned by `approve()` or `request()`.
120
123
  * @returns {Promise<import("./index.d.ts").PollResult>}
121
124
  */
122
125
  async poll(id) {
@@ -176,6 +179,110 @@ export class Lauther {
176
179
  };
177
180
  }
178
181
 
182
+ /**
183
+ * Ask the person to share something from their phone: where they are, or a
184
+ * picture. The "yes" carries the payload.
185
+ *
186
+ * Like `approve()` this blocks server-side for up to 25 seconds, and
187
+ * `response` is `"approve"`, `"deny"` or `null`. On an approve, `result`
188
+ * holds what was shared (see `LocationResult` and `PhotoResult`); on
189
+ * anything else it is `null`.
190
+ *
191
+ * @param {"location" | "photo"} kind
192
+ * @param {import("./index.d.ts").RequestOptions} [options]
193
+ * @returns {Promise<import("./index.d.ts").RequestResult>}
194
+ */
195
+ async request(kind, options) {
196
+ if (!kind) {
197
+ throw new LautherError('request() needs a kind: "location" or "photo".');
198
+ }
199
+ const {wait = MAX_WAIT_SECONDS, ...rest} = options ?? {};
200
+ return this.#request("/v1/request", {
201
+ kind,
202
+ ...rest,
203
+ wait: Math.min(Math.max(Number(wait) || 0, 0), MAX_WAIT_SECONDS),
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Ask for the person's position, once, and wait for the answer as long as
209
+ * you are willing. Same waiting rules as `requestApproval()`: one request
210
+ * on the phone, polled past the server's 25s cap, never thrown on timeout.
211
+ *
212
+ * @param {import("./index.d.ts").RequestWaitOptions} [options]
213
+ * @returns {Promise<import("./index.d.ts").RequestOutcome<import("./index.d.ts").LocationResult>>}
214
+ */
215
+ requestLocation(options = {}) {
216
+ return this.#requestAndWait("location", options);
217
+ }
218
+
219
+ /**
220
+ * Ask for one picture and wait for it. Same waiting rules as
221
+ * `requestApproval()`. `result.url` is a download link that expires after
222
+ * 7 days.
223
+ *
224
+ * @param {import("./index.d.ts").RequestWaitOptions} [options]
225
+ * @returns {Promise<import("./index.d.ts").RequestOutcome<import("./index.d.ts").PhotoResult>>}
226
+ */
227
+ requestPhoto(options = {}) {
228
+ return this.#requestAndWait("photo", options);
229
+ }
230
+
231
+ /**
232
+ * Shared by `requestLocation()` and `requestPhoto()`: start the request,
233
+ * then keep asking about the SAME notification until it is answered,
234
+ * expires, or `timeoutSeconds` runs out.
235
+ *
236
+ * @param {"location" | "photo"} kind
237
+ * @param {import("./index.d.ts").RequestWaitOptions} options
238
+ */
239
+ async #requestAndWait(kind, options) {
240
+ const {
241
+ timeoutSeconds = MAX_WAIT_SECONDS,
242
+ pollIntervalMs = 3000,
243
+ ...rest
244
+ } = options ?? {};
245
+ const startedAt = Date.now();
246
+ const elapsed = () => (Date.now() - startedAt) / 1000;
247
+
248
+ const started = await this.request(kind, {
249
+ ...rest,
250
+ wait: Math.min(timeoutSeconds, MAX_WAIT_SECONDS),
251
+ });
252
+ if (started.response) {
253
+ return {
254
+ id: started.id, response: started.response,
255
+ confirmedBy: started.confirmedBy ?? null, result: started.result ?? null,
256
+ waitedSeconds: Math.round(elapsed()), expired: false,
257
+ };
258
+ }
259
+
260
+ while (elapsed() < timeoutSeconds) {
261
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
262
+ const {response, confirmedBy, result, expired} = await this.poll(started.id);
263
+ if (response) {
264
+ return {
265
+ id: started.id, response, confirmedBy: confirmedBy ?? null,
266
+ result: result ?? null, waitedSeconds: Math.round(elapsed()),
267
+ expired: false,
268
+ };
269
+ }
270
+ // The request lapsed on the phone: nothing more can arrive, so stop
271
+ // waiting rather than idling out the rest of the timeout.
272
+ if (expired === true) {
273
+ return {
274
+ id: started.id, response: null, confirmedBy: null, result: null,
275
+ waitedSeconds: Math.round(elapsed()), expired: true,
276
+ };
277
+ }
278
+ }
279
+ // Nobody answered. As with requestApproval(): an outcome, not an error.
280
+ return {
281
+ id: started.id, response: null, confirmedBy: null, result: null,
282
+ waitedSeconds: Math.round(elapsed()), expired: false,
283
+ };
284
+ }
285
+
179
286
  /**
180
287
  * `requestApproval()` reduced to a boolean, for the common `if` around
181
288
  * something irreversible.