lauther-client 0.2.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 ADDED
@@ -0,0 +1,177 @@
1
+ # lauther-client
2
+
3
+ Push notifications to your phone from any web app or script — and questions
4
+ that **wait for a yes** before something irreversible happens.
5
+
6
+ Zero dependencies. Node 18+. Two methods.
7
+
8
+ ```bash
9
+ npm install lauther-client
10
+ ```
11
+
12
+ ## Why this exists
13
+
14
+ A browser tab cannot reach someone who has closed it. Building a native app
15
+ per project to fix that is not a plan. Lauther is one app your projects share
16
+ over an HTTP call, so any of them can reach a real phone.
17
+
18
+ ## Send a notification
19
+
20
+ ```js
21
+ import { Lauther } from "lauther-client";
22
+
23
+ const lauther = new Lauther(process.env.LAUTHER_TOKEN);
24
+
25
+ await lauther.push({
26
+ title: "New signup",
27
+ message: "alex@example.com just created an account",
28
+ });
29
+ ```
30
+
31
+ Get a token in the app: **Apps → + → New token**. It pushes to your phone and
32
+ nobody else's, and you can revoke it any time.
33
+
34
+ ## Ask a question and wait for the answer
35
+
36
+ This is the part that has no equivalent in a normal push service. The call
37
+ blocks until you press a button on your phone, then returns what you pressed.
38
+
39
+ ```js
40
+ if (await lauther.ask("Deploy v2.4 to production?")) {
41
+ await deploy();
42
+ }
43
+ ```
44
+
45
+ **Only an explicit Approve returns `true`.** A denial and a timeout both
46
+ return `false` — an unanswered question must never read as a yes.
47
+
48
+ ### Waiting longer than 25 seconds
49
+
50
+ The API long-polls for at most 25 seconds, which suits a shell one-liner and
51
+ not a script whose human has wandered off. Pass `timeoutSeconds` and the client
52
+ keeps asking about the **same** notification past that, so a ten-minute wait
53
+ still puts one question on the phone rather than one every half minute:
54
+
55
+ ```js
56
+ const out = await lauther.requestApproval("Restore last night's snapshot?", {
57
+ timeoutSeconds: 600,
58
+ requireBiometric: true,
59
+ });
60
+ // { id, response: "approve" | "deny" | null, confirmedBy, waitedSeconds }
61
+ ```
62
+
63
+ `response: null` means nobody answered. That is a returned outcome rather than
64
+ a thrown error, deliberately — throwing invites a `catch` that treats silence
65
+ as success.
66
+
67
+ Useful for anything you would regret doing automatically: a deploy, a refund,
68
+ a database restore, or an AI agent about to act on your behalf.
69
+
70
+ ```js
71
+ // Demand a fingerprint before the approval counts.
72
+ const { response } = await lauther.approve({
73
+ title: "Refund €240 to order #1183?",
74
+ message: "Requested by support agent Kim",
75
+ requireBiometric: true,
76
+ approveTitle: "Refund",
77
+ denyTitle: "Keep",
78
+ wait: 25,
79
+ });
80
+ // response is "approve", "deny", or null when nobody answered in time.
81
+ ```
82
+
83
+ ## Handling failure
84
+
85
+ ```js
86
+ import { Lauther, LautherError } from "lauther-client";
87
+
88
+ try {
89
+ await lauther.push({ title: "Backup finished" });
90
+ } catch (err) {
91
+ if (err instanceof LautherError) {
92
+ console.error(err.status, err.code, err.message);
93
+ }
94
+ }
95
+ ```
96
+
97
+ Branch on `err.code`, never on `err.message` — messages are written for people
98
+ and may be reworded. A network failure is reported as unreachable rather than
99
+ as a refusal, because those need different handling.
100
+
101
+ ## Knowing whether it arrived
102
+
103
+ `push()` resolves with what actually happened:
104
+
105
+ ```js
106
+ const { delivered, devices } = await lauther.push({ title: "Hello" });
107
+ ```
108
+
109
+ `delivered: 0` with `devices: 0` means **no phone is registered** on the
110
+ account — usually the app has not been opened since signing in. That is a
111
+ different problem from every device rejecting the message, so the two are
112
+ reported separately rather than collapsed into one flag.
113
+
114
+ ## API
115
+
116
+ | | |
117
+ | --- | --- |
118
+ | `new Lauther(token, options?)` | `options`: `baseUrl`, `fetch`, `timeoutMs` |
119
+ | `push(options)` | `title`, `message`, `priority`, `url`, `path`, `tag`, `icon`, `color`, `sound`, `image` |
120
+ | `approve(options)` | `title`, `message`, `wait` (0–25s), `approveTitle`, `denyTitle`, `requireBiometric`, `priority` |
121
+ | `requestApproval(question, options?)` | Ask and wait, past the 25s cap, on one notification |
122
+ | `poll(id)` | Ask about an approval already started |
123
+ | `ask(question, options?)` | `requestApproval()` reduced to a boolean |
124
+
125
+ `priority` runs `-2` to `2`. **`2` is the only level that crosses quiet
126
+ hours** — worth setting on about three things and no more.
127
+
128
+ Images require a Supporter account. Without one the image is dropped and the
129
+ notification still arrives, because a script should not start failing over an
130
+ entitlement.
131
+
132
+ TypeScript types are bundled; there is no `@types` package to install.
133
+
134
+ ## You do not need this package
135
+
136
+ The whole client is `fetch` against two documented endpoints. If you would
137
+ rather not add a dependency, this is the entire integration:
138
+
139
+ ```js
140
+ await fetch("https://api.lauther.id/v1/push", {
141
+ method: "POST",
142
+ headers: {
143
+ Authorization: `Bearer ${process.env.LAUTHER_TOKEN}`,
144
+ "Content-Type": "application/json",
145
+ },
146
+ body: JSON.stringify({ title: "Backup finished" }),
147
+ });
148
+ ```
149
+
150
+ That is deliberate. A service you can leave in one line has to keep earning
151
+ you.
152
+
153
+ ## Worth knowing before you build on it
154
+
155
+ - **Hosted only.** The relay holds the APNs connection and the identity
156
+ layer, which is the part that cannot meaningfully run on your own box.
157
+ - **Not end-to-end encrypted.** Message bodies travel over TLS and are
158
+ readable by the service. Do not route secrets through it.
159
+ - **Pushover-compatible.** Tools that let you change the Pushover *server
160
+ URL* work by pointing them at `https://api.lauther.id`, so moving your
161
+ senders elsewhere later is a URL change rather than a rewrite.
162
+
163
+ ## Using this from a coding agent
164
+
165
+ [`lauther-mcp`](https://www.npmjs.com/package/lauther-mcp) wraps this as an MCP
166
+ server, so Claude Code, Cursor or Windsurf can ask you for approval on your
167
+ phone before doing something irreversible.
168
+
169
+ ## Links
170
+
171
+ - Docs — https://lauther.app/docs.html
172
+ - The app — [App Store](https://apps.apple.com/app/id6802635695) · [Google Play](https://play.google.com/store/apps/details?id=id.lauther)
173
+ - Recipes for curl, Python, Home Assistant, Uptime Kuma, GitHub Actions —
174
+ https://github.com/csanadymiklos/lauther-examples
175
+ - Something wrong? **hello@lauther.id** reaches a human.
176
+
177
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "lauther-client",
3
+ "version": "0.2.0",
4
+ "description": "Send push notifications to your phone from any web app or script — and ask questions that wait for a yes.",
5
+ "keywords": [
6
+ "push",
7
+ "notifications",
8
+ "push-notifications",
9
+ "approval",
10
+ "human-in-the-loop",
11
+ "agent",
12
+ "webhook",
13
+ "ios",
14
+ "android",
15
+ "lauther",
16
+ "lauther-client"
17
+ ],
18
+ "homepage": "https://lauther.app",
19
+ "bugs": {
20
+ "url": "https://github.com/csanadymiklos/lauther-js/issues"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Dipling Kft.",
24
+ "type": "module",
25
+ "main": "./src/index.js",
26
+ "module": "./src/index.js",
27
+ "types": "./src/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./src/index.d.ts",
31
+ "default": "./src/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "src",
36
+ "README.md"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/csanadymiklos/lauther-js.git",
47
+ "directory": "packages/lauther-client"
48
+ }
49
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Priority. 2 is the only level that crosses quiet hours — the setting you
3
+ * want on about three things and no more.
4
+ */
5
+ export type Priority = -2 | -1 | 0 | 1 | 2;
6
+
7
+ export interface PushOptions {
8
+ /** Shown as the notification's headline. Trimmed to 250 characters. */
9
+ title?: string;
10
+ /** The body. Trimmed to 2000 characters. */
11
+ message?: string;
12
+ priority?: Priority;
13
+ /** A link the notification opens. Use `path` instead for a paired site. */
14
+ url?: string;
15
+ /** Path on your paired site. Opens signed in; must be same-origin. */
16
+ path?: string;
17
+ /** Groups related notifications so a new one replaces the last. */
18
+ tag?: string;
19
+ icon?: string;
20
+ color?: string;
21
+ sound?: string;
22
+ /** Image URL. Supporter accounts only; otherwise dropped, never fatal. */
23
+ image?: string;
24
+ /** Base64 image. Same entitlement as `image`. */
25
+ imageBase64?: string;
26
+ }
27
+
28
+ export interface PushResult {
29
+ ok: true;
30
+ id: string;
31
+ /** Devices that took the message. 0 with `devices: 0` means none registered. */
32
+ delivered: number;
33
+ /** Devices Lauther tried. */
34
+ devices: number;
35
+ /** `"stored"`, or `"omitted_not_entitled"` when the account cannot attach images. */
36
+ image?: "stored" | "omitted_not_entitled";
37
+ /** Present only when every device rejected it. */
38
+ reason?: "devices_rejected";
39
+ errors?: Record<string, unknown>;
40
+ }
41
+
42
+ export interface ApproveOptions {
43
+ /** The question. Required — it is what you are being asked to approve. */
44
+ title: string;
45
+ message?: string;
46
+ /** Seconds to wait for an answer, 0-25. Defaults to 25. */
47
+ wait?: number;
48
+ /** Label on the approve button. Defaults to "Approve". */
49
+ approveTitle?: string;
50
+ /** Label on the deny button. Defaults to "Deny". */
51
+ denyTitle?: string;
52
+ /**
53
+ * Demand a fingerprint or face before Approve is accepted. Denying never
54
+ * requires one — refusing should always be cheap.
55
+ */
56
+ requireBiometric?: boolean;
57
+ priority?: Priority;
58
+ }
59
+
60
+ export interface ApproveResult {
61
+ ok: true;
62
+ id: string;
63
+ /** `null` means nobody answered in time. Silence is not consent. */
64
+ response: "approve" | "deny" | null;
65
+ }
66
+
67
+ export interface PollResult {
68
+ /** `null` until someone answers. */
69
+ response: "approve" | "deny" | null;
70
+ /** How the answer was confirmed, e.g. a biometric check. `null` if not. */
71
+ confirmedBy: string | null;
72
+ }
73
+
74
+ export interface WaitOptions extends Omit<ApproveOptions, "title" | "wait"> {
75
+ /**
76
+ * How long to wait in total, in seconds. The server long-polls for the
77
+ * first 25s; beyond that the client polls the same notification, so the
78
+ * person still sees exactly one question.
79
+ */
80
+ timeoutSeconds?: number;
81
+ /** Gap between polls once past the server's 25s window. Defaults to 3000. */
82
+ pollIntervalMs?: number;
83
+ }
84
+
85
+ export interface ApprovalOutcome {
86
+ id: string;
87
+ /** `null` means nobody answered in time. Silence is not consent. */
88
+ response: "approve" | "deny" | null;
89
+ confirmedBy: string | null;
90
+ waitedSeconds: number;
91
+ }
92
+
93
+ export interface LautherOptions {
94
+ baseUrl?: string;
95
+ fetch?: typeof globalThis.fetch;
96
+ /** Client-side abort. Defaults to 40s, comfortably past the 25s server wait. */
97
+ timeoutMs?: number;
98
+ }
99
+
100
+ export declare class LautherError extends Error {
101
+ name: "LautherError";
102
+ /** HTTP status, absent when the request never reached the server. */
103
+ status?: number;
104
+ /** Machine-readable reason. Branch on this, never on `message`. */
105
+ code?: string;
106
+ body?: unknown;
107
+ }
108
+
109
+ export declare class Lauther {
110
+ constructor(token: string, options?: LautherOptions);
111
+ token: string;
112
+ baseUrl: string;
113
+ timeoutMs: number;
114
+ push(options: PushOptions): Promise<PushResult>;
115
+ approve(options: ApproveOptions): Promise<ApproveResult>;
116
+ /** Ask about an approval already started, by its notification id. */
117
+ poll(id: string): Promise<PollResult>;
118
+ /** Ask and wait, past the server's 25s cap, on one notification. */
119
+ requestApproval(question: string, options?: WaitOptions): Promise<ApprovalOutcome>;
120
+ /** True only on an explicit Approve; a denial and a timeout are both false. */
121
+ ask(question: string, options?: WaitOptions): Promise<boolean>;
122
+ }
123
+
124
+ export declare function push(options: PushOptions): Promise<PushResult>;
125
+ export declare function ask(question: string, options?: WaitOptions): Promise<boolean>;
126
+
127
+ export default Lauther;
package/src/index.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Lauther — push notifications and human approvals for anything that can make
3
+ * an HTTP request.
4
+ *
5
+ * Zero dependencies on purpose. The whole client is `fetch` against two
6
+ * documented endpoints, so there is no SDK to go stale against the API and
7
+ * nothing to audit before you install it. If this package ever disappears,
8
+ * the four lines it replaces are in the README.
9
+ *
10
+ * @see https://lauther.app/docs.html
11
+ */
12
+
13
+ const DEFAULT_BASE_URL = "https://api.lauther.id";
14
+
15
+ /** The server caps its own wait at 25s; going higher just idles the socket. */
16
+ const MAX_WAIT_SECONDS = 25;
17
+
18
+ /**
19
+ * An error carrying what the API actually said.
20
+ *
21
+ * `code` is the machine-readable reason where the API supplies one (for
22
+ * example `alias_limit_reached`, `cross_origin_path`). Branch on `code`,
23
+ * never on `message` — messages are written for people and may be reworded.
24
+ */
25
+ export class LautherError extends Error {
26
+ /**
27
+ * @param {string} message
28
+ * @param {{status?: number, code?: string, body?: unknown}} [detail]
29
+ */
30
+ constructor(message, detail = {}) {
31
+ super(message);
32
+ this.name = "LautherError";
33
+ this.status = detail.status;
34
+ this.code = detail.code;
35
+ this.body = detail.body;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {unknown} v
41
+ * @returns {v is Record<string, unknown>}
42
+ */
43
+ const isObject = (v) => typeof v === "object" && v !== null;
44
+
45
+ /** Drop undefined so we never send `"title": undefined` and confuse a proxy. */
46
+ const compact = (obj) => {
47
+ const out = {};
48
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;
49
+ return out;
50
+ };
51
+
52
+ export class Lauther {
53
+ /**
54
+ * @param {string} token Your `lpt_…` token. In the app: Apps → + → New token.
55
+ * @param {{baseUrl?: string, fetch?: typeof globalThis.fetch, timeoutMs?: number}} [options]
56
+ */
57
+ constructor(token, options = {}) {
58
+ if (!token || typeof token !== "string") {
59
+ throw new LautherError(
60
+ "A Lauther token is required. Mint one in the app under " +
61
+ "Apps → + → New token, then pass it as new Lauther(token).");
62
+ }
63
+ this.token = token;
64
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
65
+ this.timeoutMs = options.timeoutMs ?? 40_000;
66
+ // Captured rather than looked up per call so tests can inject one, and so
67
+ // a caller who polyfills after construction gets predictable behaviour.
68
+ this._fetch = options.fetch ?? globalThis.fetch;
69
+ if (typeof this._fetch !== "function") {
70
+ throw new LautherError(
71
+ "No fetch available. Node 18+ has it built in; on older runtimes " +
72
+ "pass one: new Lauther(token, { fetch }).");
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Send a notification. Resolves once Lauther has accepted it.
78
+ *
79
+ * Note `delivered` in the result: it counts devices that actually took the
80
+ * message. `delivered: 0` with `devices: 0` means no phone is registered on
81
+ * the account — usually the app has not been opened since signing in — and
82
+ * is not the same failure as every device rejecting it.
83
+ *
84
+ * @param {import("./index.d.ts").PushOptions} options
85
+ */
86
+ async push(options) {
87
+ const {title, message, ...rest} = options ?? {};
88
+ if (!title && !message) {
89
+ throw new LautherError("push() needs a title or a message.");
90
+ }
91
+ return this.#request("/v1/push", {title, message, ...rest});
92
+ }
93
+
94
+ /**
95
+ * Put Approve / Deny on your phone and wait for the answer.
96
+ *
97
+ * The request blocks server-side for up to 25 seconds. `response` is
98
+ * `"approve"`, `"deny"`, or `null` when nobody answered in time — three
99
+ * outcomes, and silence is not consent.
100
+ *
101
+ * @param {import("./index.d.ts").ApproveOptions} options
102
+ */
103
+ async approve(options) {
104
+ const {title, wait = MAX_WAIT_SECONDS, ...rest} = options ?? {};
105
+ if (!title) throw new LautherError("approve() needs a title — it is the question.");
106
+ return this.#request("/v1/approve", {
107
+ title,
108
+ wait: Math.min(Math.max(Number(wait) || 0, 0), MAX_WAIT_SECONDS),
109
+ ...rest,
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Ask about an approval you already started, by its notification id.
115
+ *
116
+ * Note this endpoint takes the token in the body rather than as a bearer
117
+ * header — that is the API's shape, not a mistake here.
118
+ *
119
+ * @param {string} id The `id` returned by `approve()`.
120
+ * @returns {Promise<import("./index.d.ts").PollResult>}
121
+ */
122
+ async poll(id) {
123
+ if (!id) throw new LautherError("poll() needs the id returned by approve().");
124
+ return this.#request("/v1/action/poll", {token: this.token, id});
125
+ }
126
+
127
+ /**
128
+ * Ask a question and wait for the answer, for as long as you are willing.
129
+ *
130
+ * The server caps its own long-poll at 25 seconds, which suits a shell
131
+ * one-liner and not an agent whose owner has wandered off. Past that, this
132
+ * keeps asking about the SAME notification rather than sending another one,
133
+ * so the person sees one question on their phone and not a pile of them.
134
+ *
135
+ * @param {string} question
136
+ * @param {import("./index.d.ts").WaitOptions} [options]
137
+ * @returns {Promise<import("./index.d.ts").ApprovalOutcome>}
138
+ */
139
+ async requestApproval(question, options = {}) {
140
+ const {
141
+ timeoutSeconds = MAX_WAIT_SECONDS,
142
+ pollIntervalMs = 3000,
143
+ ...rest
144
+ } = options;
145
+ const startedAt = Date.now();
146
+ const elapsed = () => (Date.now() - startedAt) / 1000;
147
+
148
+ const started = await this.approve({
149
+ title: question,
150
+ wait: Math.min(timeoutSeconds, MAX_WAIT_SECONDS),
151
+ ...rest,
152
+ });
153
+ if (started.response) {
154
+ return {
155
+ id: started.id, response: started.response,
156
+ confirmedBy: started.confirmedBy ?? null, waitedSeconds: Math.round(elapsed()),
157
+ };
158
+ }
159
+
160
+ while (elapsed() < timeoutSeconds) {
161
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
162
+ const {response, confirmedBy} = await this.poll(started.id);
163
+ if (response) {
164
+ return {
165
+ id: started.id, response,
166
+ confirmedBy: confirmedBy ?? null, waitedSeconds: Math.round(elapsed()),
167
+ };
168
+ }
169
+ }
170
+ // Nobody answered. Deliberately NOT an error: "no answer" is a real
171
+ // outcome a caller must handle, and throwing would tempt a catch that
172
+ // treats it as a yes.
173
+ return {
174
+ id: started.id, response: null, confirmedBy: null,
175
+ waitedSeconds: Math.round(elapsed()),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * `requestApproval()` reduced to a boolean, for the common `if` around
181
+ * something irreversible.
182
+ *
183
+ * **Only an explicit Approve returns true.** A denial and a timeout both
184
+ * return false, which is the safe way round: an unanswered question must
185
+ * never read as a yes.
186
+ *
187
+ * @param {string} question
188
+ * @param {import("./index.d.ts").WaitOptions} [options]
189
+ * @returns {Promise<boolean>}
190
+ */
191
+ async ask(question, options = {}) {
192
+ const out = await this.requestApproval(question, options);
193
+ return out.response === "approve";
194
+ }
195
+
196
+ /**
197
+ * @param {string} path
198
+ * @param {Record<string, unknown>} body
199
+ */
200
+ async #request(path, body) {
201
+ let res;
202
+ try {
203
+ res = await this._fetch(`${this.baseUrl}${path}`, {
204
+ method: "POST",
205
+ headers: {
206
+ "Authorization": `Bearer ${this.token}`,
207
+ "Content-Type": "application/json",
208
+ },
209
+ body: JSON.stringify(compact(body)),
210
+ signal: AbortSignal.timeout(this.timeoutMs),
211
+ });
212
+ } catch (cause) {
213
+ // A timeout here is the network, not a denial. Say so, because the
214
+ // caller's next decision depends on telling those apart.
215
+ throw new LautherError(
216
+ `Could not reach Lauther at ${this.baseUrl}${path}: ${cause?.message ?? cause}`,
217
+ {body: undefined});
218
+ }
219
+
220
+ const text = await res.text();
221
+ let parsed;
222
+ try {
223
+ parsed = text ? JSON.parse(text) : {};
224
+ } catch {
225
+ parsed = {raw: text};
226
+ }
227
+
228
+ if (!res.ok) {
229
+ const code = isObject(parsed) && typeof parsed.code === "string" ?
230
+ parsed.code : undefined;
231
+ const message = isObject(parsed) && typeof parsed.error === "string" ?
232
+ parsed.error : `HTTP ${res.status}`;
233
+ throw new LautherError(message, {status: res.status, code, body: parsed});
234
+ }
235
+ return parsed;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Convenience for the one-liner case, reading the token from
241
+ * `process.env.LAUTHER_TOKEN`.
242
+ *
243
+ * @param {import("./index.d.ts").PushOptions} options
244
+ */
245
+ export function push(options) {
246
+ return new Lauther(process.env?.LAUTHER_TOKEN).push(options);
247
+ }
248
+
249
+ /**
250
+ * As `push`, for a question. Returns true only on an explicit Approve.
251
+ *
252
+ * @param {string} question
253
+ * @param {Omit<import("./index.d.ts").ApproveOptions, "title">} [options]
254
+ */
255
+ export function ask(question, options) {
256
+ return new Lauther(process.env?.LAUTHER_TOKEN).ask(question, options);
257
+ }
258
+
259
+ export default Lauther;