lauther-client 0.2.0 → 0.4.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,67 @@ 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. Three kinds: where they are, one picture, and a short
88
+ typed answer.
89
+
90
+ ```js
91
+ const where = await lauther.requestLocation({
92
+ title: "Share your location so I can find the nearest pharmacy?",
93
+ timeoutSeconds: 300,
94
+ });
95
+
96
+ if (where.response === "approve") {
97
+ const { lat, lng, accuracy, at } = where.result;
98
+ }
99
+ ```
100
+
101
+ ```js
102
+ const shot = await lauther.requestPhoto({ title: "Photo of the printer display?" });
103
+
104
+ if (shot.response === "approve") {
105
+ const { url, bytes, width, height } = shot.result; // a JPEG download link
106
+ }
107
+ ```
108
+
109
+ ```js
110
+ const code = await lauther.requestText({
111
+ title: "What is the 6-digit code from your authenticator?",
112
+ placeholder: "6-digit code",
113
+ });
114
+
115
+ if (code.response === "approve") {
116
+ const { text } = code.result; // what they typed
117
+ }
118
+ ```
119
+
120
+ All three return `{ id, response, confirmedBy, result, waitedSeconds, expired }`.
121
+ `response` is `"approve"`, `"deny"` or `null`, and `result` is `null` unless
122
+ the person approved. They wait the way `requestApproval()` does: past the
123
+ server's 25 seconds on the **same** request, so the phone still shows one.
124
+ Silence is a returned outcome rather than a thrown error. `expired: true`
125
+ means the request lapsed on the phone (10 minutes by default; set
126
+ `expiresIn`, 60 to 3600 seconds) and the wait ends early.
127
+
128
+ - **Location** is one reading, taken when they tap Share and sent once:
129
+ `{ lat, lng, accuracy, at }`, with `accuracy` in metres.
130
+ - **Photo** is a JPEG they take or pick, at most 2 MB. You receive a link,
131
+ and **the link works for 7 days**; download it if you want to keep it.
132
+ - **Text** is a short answer they type on the phone: `{ text }`, 1 to 500
133
+ characters. Use it for a code, a choice, a short fact only they know. The
134
+ optional `placeholder` (up to 80 characters) hints at the shape of the
135
+ answer, such as "6-digit code".
136
+
137
+ Options for all three: `title`, `message`, `requireBiometric`, `priority`,
138
+ `expiresIn`, plus `timeoutSeconds` and `pollIntervalMs` for the wait;
139
+ `requestText()` also takes `placeholder`. For
140
+ full control, `request(kind, options)` is the single call behind them (it
141
+ answers within `wait` seconds, 0 to 25), and `poll(id)` also returns `result`
142
+ and `expired`.
143
+
83
144
  ## Handling failure
84
145
 
85
146
  ```js
@@ -119,7 +180,11 @@ reported separately rather than collapsed into one flag.
119
180
  | `push(options)` | `title`, `message`, `priority`, `url`, `path`, `tag`, `icon`, `color`, `sound`, `image` |
120
181
  | `approve(options)` | `title`, `message`, `wait` (0–25s), `approveTitle`, `denyTitle`, `requireBiometric`, `priority` |
121
182
  | `requestApproval(question, options?)` | Ask and wait, past the 25s cap, on one notification |
122
- | `poll(id)` | Ask about an approval already started |
183
+ | `request(kind, options?)` | Ask for `"location"`, `"photo"` or `"text"`; `title`, `message`, `requireBiometric`, `priority`, `wait`, `expiresIn`, `placeholder` |
184
+ | `requestLocation(options?)` | Ask for a position and wait, past the 25s cap, on one request |
185
+ | `requestPhoto(options?)` | Ask for one picture and wait, past the 25s cap |
186
+ | `requestText(options?)` | Ask a short question and wait for the typed answer, past the 25s cap |
187
+ | `poll(id)` | Ask about an approval or request already started (carries `result` and `expired`) |
123
188
  | `ask(question, options?)` | `requestApproval()` reduced to a boolean |
124
189
 
125
190
  `priority` runs `-2` to `2`. **`2` is the only level that crosses quiet
@@ -133,7 +198,7 @@ TypeScript types are bundled; there is no `@types` package to install.
133
198
 
134
199
  ## You do not need this package
135
200
 
136
- The whole client is `fetch` against two documented endpoints. If you would
201
+ The whole client is `fetch` against a few documented endpoints. If you would
137
202
  rather not add a dependency, this is the entire integration:
138
203
 
139
204
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lauther-client",
3
- "version": "0.2.0",
3
+ "version": "0.4.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`, `PhotoResult` or
74
+ * `TextResult` once approved, `null` until then, when denied, and for plain approvals.
75
+ */
76
+ result?: LocationResult | PhotoResult | TextResult | 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,86 @@ 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" | "text";
102
+
103
+ export interface RequestOptions {
104
+ /**
105
+ * The question. Defaults to "Share your location?" / "Take a photo?" /
106
+ * "Answer a question?".
107
+ */
108
+ title?: string;
109
+ message?: string;
110
+ /** Demand a fingerprint or face before the share goes out. */
111
+ requireBiometric?: boolean;
112
+ priority?: Priority;
113
+ /** Seconds to wait for an answer, 0-25. Defaults to 25. */
114
+ wait?: number;
115
+ /** Seconds the request stays answerable, 60-3600. Defaults to 600. */
116
+ expiresIn?: number;
117
+ /**
118
+ * Text requests only: a hint at the shape of the answer, shown in the
119
+ * field on the phone ("6-digit code"). Up to 80 characters.
120
+ */
121
+ placeholder?: string;
122
+ }
123
+
124
+ /** One position, as the phone read it a moment before sending. */
125
+ export interface LocationResult {
126
+ lat: number;
127
+ lng: number;
128
+ /** Metres. Absent when the phone did not report one. */
129
+ accuracy?: number;
130
+ /** ISO 8601 time of the reading. */
131
+ at: string;
132
+ }
133
+
134
+ /** One picture. Shared photos are kept for 7 days, then deleted. */
135
+ export interface PhotoResult {
136
+ /** Download link for the JPEG. */
137
+ url: string;
138
+ bytes: number;
139
+ width?: number;
140
+ height?: number;
141
+ }
142
+
143
+ /** A typed answer: 1 to 500 characters, trimmed. */
144
+ export interface TextResult {
145
+ text: string;
146
+ }
147
+
148
+ export interface RequestResult {
149
+ ok: true;
150
+ id: string;
151
+ /** `null` means nobody answered in time. Silence is not consent. */
152
+ response: "approve" | "deny" | null;
153
+ /** What was shared. `null` unless the person approved. */
154
+ result: LocationResult | PhotoResult | TextResult | null;
155
+ }
156
+
157
+ export interface RequestWaitOptions extends Omit<RequestOptions, "wait"> {
158
+ /**
159
+ * How long to wait in total, in seconds. The server long-polls for the
160
+ * first 25s; beyond that the client polls the same request, so the person
161
+ * still sees exactly one.
162
+ */
163
+ timeoutSeconds?: number;
164
+ /** Gap between polls once past the server's 25s window. Defaults to 3000. */
165
+ pollIntervalMs?: number;
166
+ }
167
+
168
+ export interface RequestOutcome<R = LocationResult | PhotoResult | TextResult> {
169
+ id: string;
170
+ /** `null` means nobody answered, or the request expired. */
171
+ response: "approve" | "deny" | null;
172
+ confirmedBy: string | null;
173
+ /** What was shared. `null` unless `response` is `"approve"`. */
174
+ result: R | null;
175
+ waitedSeconds: number;
176
+ /** `true` when the request lapsed on the phone before anyone answered. */
177
+ expired: boolean;
178
+ }
179
+
93
180
  export interface LautherOptions {
94
181
  baseUrl?: string;
95
182
  fetch?: typeof globalThis.fetch;
@@ -117,6 +204,14 @@ export declare class Lauther {
117
204
  poll(id: string): Promise<PollResult>;
118
205
  /** Ask and wait, past the server's 25s cap, on one notification. */
119
206
  requestApproval(question: string, options?: WaitOptions): Promise<ApprovalOutcome>;
207
+ /** Ask the person to share `"location"`, a `"photo"` or a typed `"text"`; answers within `wait` seconds. */
208
+ request(kind: RequestKind, options?: RequestOptions): Promise<RequestResult>;
209
+ /** Ask for the person's position and wait, past the server's 25s cap. */
210
+ requestLocation(options?: RequestWaitOptions): Promise<RequestOutcome<LocationResult>>;
211
+ /** Ask for one picture and wait, past the server's 25s cap. */
212
+ requestPhoto(options?: RequestWaitOptions): Promise<RequestOutcome<PhotoResult>>;
213
+ /** Ask a short question and wait for the typed answer, past the server's 25s cap. */
214
+ requestText(options?: RequestWaitOptions): Promise<RequestOutcome<TextResult>>;
120
215
  /** True only on an explicit Approve; a denial and a timeout are both false. */
121
216
  ask(question: string, options?: WaitOptions): Promise<boolean>;
122
217
  }
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,123 @@ export class Lauther {
176
179
  };
177
180
  }
178
181
 
182
+ /**
183
+ * Ask the person to share something from their phone: where they are, a
184
+ * picture, or a short typed answer. 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`, `PhotoResult` and
189
+ * `TextResult`); on anything else it is `null`.
190
+ *
191
+ * @param {"location" | "photo" | "text"} 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", "photo" or "text".');
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
+ * Ask a short question and wait for the typed answer: a code, a name, a
233
+ * choice. Same waiting rules as `requestApproval()`. `options.placeholder`
234
+ * (up to 80 characters) hints at the shape of the answer on the phone, for
235
+ * example "6-digit code". `result.text` is 1 to 500 characters.
236
+ *
237
+ * @param {import("./index.d.ts").RequestWaitOptions} [options]
238
+ * @returns {Promise<import("./index.d.ts").RequestOutcome<import("./index.d.ts").TextResult>>}
239
+ */
240
+ requestText(options = {}) {
241
+ return this.#requestAndWait("text", options);
242
+ }
243
+
244
+ /**
245
+ * Shared by `requestLocation()`, `requestPhoto()` and `requestText()`:
246
+ * start the request, then keep asking about the SAME notification until it
247
+ * is answered, expires, or `timeoutSeconds` runs out.
248
+ *
249
+ * @param {"location" | "photo" | "text"} kind
250
+ * @param {import("./index.d.ts").RequestWaitOptions} options
251
+ */
252
+ async #requestAndWait(kind, options) {
253
+ const {
254
+ timeoutSeconds = MAX_WAIT_SECONDS,
255
+ pollIntervalMs = 3000,
256
+ ...rest
257
+ } = options ?? {};
258
+ const startedAt = Date.now();
259
+ const elapsed = () => (Date.now() - startedAt) / 1000;
260
+
261
+ const started = await this.request(kind, {
262
+ ...rest,
263
+ wait: Math.min(timeoutSeconds, MAX_WAIT_SECONDS),
264
+ });
265
+ if (started.response) {
266
+ return {
267
+ id: started.id, response: started.response,
268
+ confirmedBy: started.confirmedBy ?? null, result: started.result ?? null,
269
+ waitedSeconds: Math.round(elapsed()), expired: false,
270
+ };
271
+ }
272
+
273
+ while (elapsed() < timeoutSeconds) {
274
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
275
+ const {response, confirmedBy, result, expired} = await this.poll(started.id);
276
+ if (response) {
277
+ return {
278
+ id: started.id, response, confirmedBy: confirmedBy ?? null,
279
+ result: result ?? null, waitedSeconds: Math.round(elapsed()),
280
+ expired: false,
281
+ };
282
+ }
283
+ // The request lapsed on the phone: nothing more can arrive, so stop
284
+ // waiting rather than idling out the rest of the timeout.
285
+ if (expired === true) {
286
+ return {
287
+ id: started.id, response: null, confirmedBy: null, result: null,
288
+ waitedSeconds: Math.round(elapsed()), expired: true,
289
+ };
290
+ }
291
+ }
292
+ // Nobody answered. As with requestApproval(): an outcome, not an error.
293
+ return {
294
+ id: started.id, response: null, confirmedBy: null, result: null,
295
+ waitedSeconds: Math.round(elapsed()), expired: false,
296
+ };
297
+ }
298
+
179
299
  /**
180
300
  * `requestApproval()` reduced to a boolean, for the common `if` around
181
301
  * something irreversible.