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 +177 -0
- package/package.json +49 -0
- package/src/index.d.ts +127 -0
- package/src/index.js +259 -0
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;
|