@localauth/cli 0.1.0 → 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 CHANGED
@@ -156,6 +156,71 @@ input and answers with their link key in either mode. The Secret toggle only con
156
156
  a CLI login needs a browser on a machine you are not sitting at. Copy or link it into your agent's
157
157
  skills directory, for example `~/.claude/skills/localauth`.
158
158
 
159
+ ## Public API and browser tools
160
+
161
+ The [API reference](https://localauth.dev/docs/api) and
162
+ [OpenAPI 3.1 document](https://localauth.dev/api/v1/openapi.json) describe the public `/api/v1` API.
163
+ Changes within v1 are additive; incompatible changes get a new path version.
164
+ The website and CLI 0.2.0 use this same API. Better Auth stays at `/api/auth/*`, including the
165
+ iOS app's OAuth endpoints.
166
+
167
+ **Breaking upgrade to CLI 0.2.0.** The old `/api/me`, `/api/account/*`, `/api/runs/*`,
168
+ `/api/machines/*`, `/api/pairings/*`, `/api/verifications/*`, `/api/r/*` and `/api/tokens/*`
169
+ routes are removed. `/api/m/*` returns HTTP 410 with an instruction to run
170
+ `npm install -g @localauth/cli`. Existing scripts must use `/api/v1` paths, direct single
171
+ resources, paginated lists and the nested error shape. Open website tabs should reload.
172
+ Machine tokens, saved configuration, account keys and encrypted envelopes do not need conversion.
173
+
174
+ CLI 0.1.0 stops working once the Worker is updated, and CLI 0.2.0 needs the updated Worker:
175
+ update both together. Self-hosted Access deployments must move their machine-route bypass policy
176
+ to `/api/v1/machine/*` first.
177
+
178
+ Create a personal access token in **Account, API tokens**. Choose a name, permissions and,
179
+ optionally, an expiry. Copy it when it appears; the service stores only a hash of it.
180
+ The card lists creation, last use and expiry, and lets you revoke a token immediately.
181
+
182
+ ```sh
183
+ curl https://localauth.dev/api/v1/runs \
184
+ -H 'Authorization: Bearer lat_YOUR_TOKEN'
185
+ ```
186
+
187
+ Permissions are `runs:read`, `runs:write`, `machines:read` and `machines:write`.
188
+ Personal tokens cannot change the account, encryption keys, passkeys, other tokens or emoji
189
+ verification. In Cloudflare Access mode, personal tokens and OAuth are disabled.
190
+ The website uses the Access JWT and the CLI uses its machine token.
191
+
192
+ Responses use camelCase JSON and epoch milliseconds. Lists return `{ data, nextCursor }`
193
+ and accept `limit` (1–100, default 50) and `cursor`. Scripts and native clients use the
194
+ Authorization header. The website can also use its same-origin session cookie or Access JWT.
195
+ Browser credentials require matching Origin or `Sec-Fetch-Site: same-origin` on reads and
196
+ mutations; contradictory signals are rejected. An Authorization header always takes precedence,
197
+ even if invalid. Cross-origin clients use explicit header credentials; CORS never permits
198
+ cookie credentials. Errors return `{ error: { code, message } }`; rate limits return 429
199
+ with `Retry-After`. Machine tokens and link capabilities have separate routes and authority.
200
+
201
+ The iOS app (client `localauth-ios`) signs in with Better Auth's OAuth provider:
202
+ `/api/auth/oauth2/authorize`, `/api/auth/oauth2/token` and `/api/auth/oauth2/revoke`, with S256
203
+ PKCE and an exact redirect allowlist. Access tokens last an hour; refresh tokens last 90 days and
204
+ rotate on use. A retry within 10 seconds gets the same new pair; reusing a rotated refresh token
205
+ later revokes all of the app's tokens for that person. The app stays signed in independently
206
+ of the browser session it signed in through (it asks for `offline_access`). The app revokes its
207
+ refresh token on sign-out, deleting the account removes every token, and disabling the client
208
+ row switches the whole app off. Third-party clients, registration and
209
+ consent are not offered. The plugin has a documented race in that revocation, which this
210
+ repository patches; see [patches/README.md](patches/README.md).
211
+
212
+ End-to-end data stays sealed through the API. A client that needs plaintext must hold the
213
+ account private key, obtained locally from a passkey PRF wrap, and implement the
214
+ [encryption protocol and fixed test vectors](docs/protocol.md). An OAuth token alone cannot
215
+ decrypt it. Machine-side callback checks and account-key pinning remain mandatory.
216
+
217
+ On browsers supporting WebMCP, dashboard and link pages register login tools and the dashboard
218
+ and device page register machine approval tools. They use the page's existing decryption,
219
+ sealing, callback checks and confirmation dialogs. A link page sees only its run. A locked
220
+ browser asks the person to unlock on the page. Machine output and names are untrusted data.
221
+ There are no WebMCP tools for emoji verification, account, encryption, passkey or token changes,
222
+ sign-out or deletion. A person still compares emoji after approving an E2E machine.
223
+
159
224
  ## Encryption
160
225
 
161
226
  By default, any browser signed in to your account can read your logins. The Worker encrypts
@@ -257,8 +322,9 @@ production modes. Set it to a securely generated random value and retain it acro
257
322
  npx wrangler secret put DATA_KEY
258
323
  ```
259
324
 
260
- Machines talk to `/api/m/*` without a browser. Either add a Bypass policy for `/api/m/*` (those
261
- routes check machine tokens themselves), or create an Access service token, allow it with a
325
+ Machines talk to `/api/v1/machine/*` without a browser. Either add a Bypass policy for
326
+ `/api/v1/machine/*` (those routes check machine tokens themselves), or create an Access service
327
+ token, allow it with a
262
328
  Service Auth policy and set `LOCALAUTH_ACCESS_CLIENT_ID` and `LOCALAUTH_ACCESS_CLIENT_SECRET`
263
329
  before `localauth login`. Remove the `send_email` binding if you don't use email.
264
330
 
@@ -275,6 +341,37 @@ npx wrangler email sending enable <your domain> # sign-in codes by email
275
341
 
276
342
  Sign-in options without credentials are hidden. Passkeys need no setup.
277
343
 
344
+ **Sign in with Apple.** In Apple Developer, create an App ID with Sign in with Apple enabled,
345
+ then a web Services ID associated with that primary App ID. Configure the service's domain
346
+ and the return URL `https://YOUR_DOMAIN/api/auth/callback/apple`. Download a Sign in with
347
+ Apple `.p8` key, keeping its Key ID and your Team ID. Set these Worker secrets:
348
+
349
+ ```sh
350
+ npx wrangler secret put APPLE_CLIENT_ID # web Services ID
351
+ npx wrangler secret put APPLE_TEAM_ID
352
+ npx wrangler secret put APPLE_KEY_ID
353
+ npx wrangler secret put APPLE_PRIVATE_KEY # full .p8 PEM contents
354
+ ```
355
+
356
+ The Worker signs and caches Apple's ES256 client secret and renews it before its 180-day
357
+ expiry. There is no manually maintained client-secret JWT. Apple appears first on the sign-in
358
+ page once all four values exist; it can also be connected or disconnected in Account.
359
+ Apple's cross-site form callback is allowed only at its exact POST callback endpoint; OAuth
360
+ state validation still applies. Apple requires an HTTPS domain for callbacks.
361
+
362
+ When the app's bundle ID is decided, enable its Sign in with Apple capability and set
363
+ `APPLE_APP_BUNDLE_ID` to accept native identity-token audiences as well as the web Services ID.
364
+ Set `OAUTH_IOS_REDIRECT_URIS` to the comma-separated exact redirect URLs selected for the app
365
+ (a universal link and/or its custom scheme). No value is invented by the server, and an empty
366
+ allowlist disables authorization. The app's associated-domain file and push notifications
367
+ come with the iOS app; they are not part of this change.
368
+
369
+ Apple's Hide My Email can supply a private relay address. Automatic account linking uses a
370
+ verified email, so a relay address creates a separate localauth account from a different
371
+ GitHub or Google email. To use the same account, sign in to that localauth account first,
372
+ then connect Apple from Connected accounts. Configure Apple's private email relay sender
373
+ registration if you send sign-in email to relay addresses.
374
+
278
375
  Then create the tables and deploy:
279
376
 
280
377
  ```sh
package/cli/cli.ts CHANGED
@@ -9,7 +9,7 @@ import type { Flow, PairingResult, PairingStart, RunData, RunRecord } from '../s
9
9
  import { accessFromEnv, configPath, loadConnection, removeConnection, saveConnection, serviceUrl, type Connection } from './config.ts'
10
10
  import { parseRunArgs } from './retry.ts'
11
11
  import { reportBrowserUrls, runWrapped } from './run.ts'
12
- import { createService, messageOf, Unreachable } from './service.ts'
12
+ import { createService, listAll, messageOf, Unreachable } from './service.ts'
13
13
  import { verifyMachine } from './verify.ts'
14
14
 
15
15
  const HELP = `localauth: finish CLI logins on this machine from your phone
@@ -130,7 +130,7 @@ export async function main(args: string[]): Promise<number> {
130
130
  terminal.writeLine(`Not connected. Run: localauth login`)
131
131
  return 1
132
132
  }
133
- let reply = await createService(connection.url, connection)('GET', '/api/m/me')
133
+ let reply = await createService(connection.url, connection)('GET', '/api/v1/machine/me')
134
134
  if (reply.status === 401) return fail(`${connection.url} no longer knows this machine. Run: localauth login`)
135
135
  if (reply.status !== 200) return fail(messageOf(reply))
136
136
  let encryption = await refreshPin(connection).then(
@@ -157,7 +157,7 @@ export async function main(args: string[]): Promise<number> {
157
157
  terminal.writeLine('Not connected.')
158
158
  return 0
159
159
  }
160
- await createService(connection.url, connection)('DELETE', '/api/m/me').catch(() => null)
160
+ await createService(connection.url, connection)('DELETE', '/api/v1/machine/me').catch(() => null)
161
161
  removeConnection()
162
162
  terminal.writeLine(`Disconnected from ${connection.url}.`)
163
163
  return 0
@@ -198,7 +198,7 @@ function requireConnection(): Connection {
198
198
  async function login(url: string, name: string): Promise<number> {
199
199
  let keys = await generateKeyPair()
200
200
  let service = createService(url, { token: '', access: accessFromEnv() })
201
- let started = await service<PairingStart>('POST', '/api/m/pair', { publicKey: keys.publicKey, name })
201
+ let started = await service<PairingStart>('POST', '/api/v1/machine/pair', { publicKey: keys.publicKey, name })
202
202
  if (started.status !== 201) return fail(`${url} refused the connection: ${messageOf(started)}`)
203
203
 
204
204
  // Build the link from the requested service URL, never an address returned by the service.
@@ -214,7 +214,7 @@ async function login(url: string, name: string): Promise<number> {
214
214
  let deadline = Date.now() + started.data.expiresIn * 1000
215
215
  while (Date.now() < deadline) {
216
216
  await new Promise((resolve) => setTimeout(resolve, started.data.interval * 1000))
217
- let reply = await service<PairingResult>('POST', '/api/m/pair/token', { deviceCode: started.data.deviceCode }).catch(
217
+ let reply = await service<PairingResult>('POST', '/api/v1/machine/pair/token', { deviceCode: started.data.deviceCode }).catch(
218
218
  () => null,
219
219
  )
220
220
  if (!reply || reply.status === 202) continue
@@ -229,7 +229,7 @@ async function login(url: string, name: string): Promise<number> {
229
229
  }
230
230
  saveConnection(connection)
231
231
  terminal.writeLine(`${styles.green('✓')} Connected as ${styles.bold(reply.data.user.email)}.`)
232
- let me = await createService(url, connection)<{ accountKey: { publicKey: string } | null }>('GET', '/api/m/me')
232
+ let me = await createService(url, connection)<{ accountKey: { publicKey: string } | null }>('GET', '/api/v1/machine/me')
233
233
  if (me.status === 200 && me.data.accountKey) {
234
234
  terminal.writeLine('This account uses end-to-end encryption, so check this machine with four emoji.')
235
235
  if (!(await verifyMachine(connection, (line) => terminal.writeLine(line)))) return 1
@@ -252,12 +252,12 @@ interface OpenedRun {
252
252
  }
253
253
 
254
254
  async function openRuns(connection: Connection): Promise<OpenedRun[]> {
255
- let reply = await createService(connection.url, connection)<{ runs: RunRecord[] }>('GET', '/api/m/runs')
255
+ let reply = await listAll<RunRecord>(createService(connection.url, connection), '/api/v1/machine/runs')
256
256
  if (reply.status === 401) throw new NotConnected(`${connection.url} no longer knows this machine. Run: localauth login`)
257
257
  if (reply.status !== 200) throw new Unreachable(messageOf(reply))
258
258
  await refreshPin(connection)
259
259
  return Promise.all(
260
- reply.data.runs.map(async (record) => ({
260
+ reply.data.data.map(async (record) => ({
261
261
  record,
262
262
  data: await decodeRun(connection, record).catch(() => null),
263
263
  })),
@@ -277,7 +277,7 @@ async function finish(connection: Connection, pasted: string, runId: string | un
277
277
  let { run, flow } = match
278
278
  await refreshPin(connection)
279
279
  let service = createService(connection.url, connection)
280
- let sent = await service('POST', `/api/m/runs/${run.record.id}/callbacks`, await encodeCallback(connection, run.record, { flowId: flow.id, url: pasted.trim() }))
280
+ let sent = await service('POST', `/api/v1/machine/runs/${run.record.id}/callbacks`, await encodeCallback(connection, run.record, { flowId: flow.id, url: pasted.trim() }))
281
281
  if (sent.status !== 202) return fail(messageOf(sent))
282
282
 
283
283
  let deadline = Date.now() + 30_000
package/cli/encryption.ts CHANGED
@@ -20,7 +20,7 @@ export async function refreshPin(connection: Connection, say: (text: string) =>
20
20
  connection.accountPublicKey = saved.accountPublicKey
21
21
  connection.accountFingerprint = saved.accountFingerprint
22
22
  }
23
- let reply = await createService(connection.url, connection)<{ accountKey: { publicKey: string } | null; machine: { name: string } }>('GET', '/api/m/me', undefined, signal)
23
+ let reply = await createService(connection.url, connection)<{ accountKey: { publicKey: string } | null; machine: { name: string } }>('GET', '/api/v1/machine/me', undefined, signal)
24
24
  if (reply.status !== 200) throw new Error(messageOf(reply))
25
25
  let publicKey = reply.data.accountKey?.publicKey ?? null
26
26
  checkPin(connection.accountPublicKey, publicKey)
package/cli/run.ts CHANGED
@@ -159,7 +159,7 @@ export async function createRun(connection: Connection | null, argv: string[], s
159
159
  snapshot.output = redactor.redact(snapshot.output)
160
160
  for (let flow of snapshot.flows) flow.note = redactor.redact(flow.note)
161
161
  let sealed = await encodeRun(connection!, id, snapshot, signal)
162
- let reply = await service('PUT', `/api/m/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
162
+ let reply = await service('PUT', `/api/v1/machine/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
163
163
  if (!offline) say(`localauth: ${(error as Error).message}`)
164
164
  offline = true
165
165
  return null
@@ -249,16 +249,16 @@ export async function createRun(connection: Connection | null, argv: string[], s
249
249
  async takeMessages(signal: AbortSignal) {
250
250
  if (!service) return []
251
251
  await refreshPin(connection!, say, signal)
252
- let reply = await service<{ callbacks: { kind?: MessageKind; data: unknown }[] }>('POST', `/api/m/runs/${id}/callbacks/take`, undefined, signal)
252
+ let reply = await service<{ data: { kind?: MessageKind; data: unknown }[] }>('POST', `/api/v1/machine/runs/${id}/callbacks/take`, undefined, signal)
253
253
  if (reply.status === 404) throw new Error('This request expired or was dismissed.')
254
- return reply.status === 200 ? reply.data.callbacks : []
254
+ return reply.status === 200 ? reply.data.data : []
255
255
  },
256
256
  async openMessage<T>(kind: MessageKind, value: unknown) {
257
257
  return openMessage<T>({ privateKey: key!, publicKey: connection!.publicKey, accountPublicKey: connection?.accountPublicKey, linkKey: link?.key }, kind, id, value)
258
258
  },
259
259
  async takeRetry(signal: AbortSignal) {
260
260
  if (!service) return false
261
- let reply = await service<{ retry: boolean }>('POST', `/api/m/runs/${id}/retry/take`, undefined, signal)
261
+ let reply = await service<{ retry: boolean }>('POST', `/api/v1/machine/runs/${id}/retry/take`, undefined, signal)
262
262
  return reply.status === 200 && reply.data.retry === true
263
263
  },
264
264
  startCallbacks(onInput?: (text: string) => void) {
@@ -270,10 +270,10 @@ export async function createRun(connection: Connection | null, argv: string[], s
270
270
  await sleep(POLL_MS, undefined, { signal })
271
271
  if (!service || (!options.input && !data.flows.some((f) => f.status === 'waiting'))) continue
272
272
  await refreshPin(connection!, say, signal)
273
- let reply = await service<{ callbacks: { id: string; kind?: MessageKind; data: string | CallbackData }[] }>(
274
- 'POST', `/api/m/runs/${id}/callbacks/take`, undefined, signal,
273
+ let reply = await service<{ data: { id: string; kind?: MessageKind; data: string | CallbackData }[] }>(
274
+ 'POST', `/api/v1/machine/runs/${id}/callbacks/take`, undefined, signal,
275
275
  ).catch(() => null)
276
- for (let message of reply?.status === 200 ? reply.data.callbacks : []) {
276
+ for (let message of reply?.status === 200 ? reply.data.data : []) {
277
277
  if (message.kind === 'input') {
278
278
  if (options.input !== true || !onInput) continue
279
279
  let input = await openMessage<InputData>({ privateKey: key!, publicKey: connection!.publicKey, accountPublicKey: connection?.accountPublicKey, linkKey: link?.key }, 'input', id, message.data)
package/cli/service.ts CHANGED
@@ -2,7 +2,7 @@ import type { Connection } from './config.ts'
2
2
 
3
3
  export interface Reply<T = any> {
4
4
  status: number
5
- data: T & { error?: string; message?: string }
5
+ data: T & { error?: string | { code: string; message: string }; message?: string }
6
6
  }
7
7
 
8
8
  export class Unreachable extends Error {}
@@ -48,5 +48,19 @@ export function createService(url: string, connection?: Pick<Connection, 'token'
48
48
  export type Service = ReturnType<typeof createService>
49
49
 
50
50
  export function messageOf(reply: Reply): string {
51
- return reply.data?.error ?? reply.data?.message ?? `HTTP ${reply.status}`
51
+ let error = reply.data?.error
52
+ return (typeof error === 'string' ? error : error?.message) ?? reply.data?.message ?? `HTTP ${reply.status}`
53
+ }
54
+
55
+ /** Follow v1 cursors rather than silently truncating a machine's run history. */
56
+ export async function listAll<T>(service: Service, path: string): Promise<Reply<{ data: T[]; nextCursor: string | null }>> {
57
+ let data: T[] = [], cursor: string | null = null
58
+ do {
59
+ let query: string = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''
60
+ let reply: Reply<{ data: T[]; nextCursor: string | null }> = await service<{ data: T[]; nextCursor: string | null }>('GET', path + query)
61
+ if (reply.status !== 200) return reply
62
+ data.push(...reply.data.data)
63
+ cursor = reply.data.nextCursor
64
+ } while (cursor)
65
+ return { status: 200, data: { data, nextCursor: null } }
52
66
  }
package/cli/verify.ts CHANGED
@@ -22,7 +22,7 @@ const POLL_MS = 1500
22
22
  export async function verifyMachine(connection: Connection, say: Say, timeoutMs = 10 * 60 * 1000): Promise<boolean> {
23
23
  let service = createService(connection.url, connection)
24
24
  let own = await ephemeral()
25
- let started = await service<{ id: string }>('POST', '/api/m/verifications', {
25
+ let started = await service<{ id: string }>('POST', '/api/v1/machine/verifications', {
26
26
  commitment: await commitment(own.publicKey, connection.publicKey),
27
27
  })
28
28
  if (started.status === 409) throw new Error('This account has no end-to-end encryption to verify. Add a passkey on the Account page first.')
@@ -35,7 +35,7 @@ export async function verifyMachine(connection: Connection, say: Say, timeoutMs
35
35
  let view = await waitFor(service, id, deadline, (v) => v.status !== 'committed')
36
36
  if (view.status !== 'answered' || !view.browserEphemeral || !view.accountPublicKey) return refused(say, view)
37
37
 
38
- let revealed = await service('POST', `/api/m/verifications/${id}/reveal`, { ephemeral: own.publicKey })
38
+ let revealed = await service('POST', `/api/v1/machine/verifications/${id}/reveal`, { ephemeral: own.publicKey })
39
39
  if (revealed.status !== 200) throw new Error(messageOf(revealed))
40
40
  let transcript: Transcript = {
41
41
  verificationId: id,
@@ -64,9 +64,9 @@ export async function verifyMachine(connection: Connection, say: Say, timeoutMs
64
64
 
65
65
  async function waitFor(service: ReturnType<typeof createService>, id: string, deadline: number, done: (v: MachineView) => boolean) {
66
66
  while (Date.now() < deadline) {
67
- let reply = await service<{ verification: MachineView }>('GET', `/api/m/verifications/${id}`).catch(() => null)
67
+ let reply = await service<MachineView>('GET', `/api/v1/machine/verifications/${id}`).catch(() => null)
68
68
  if (reply?.status === 404) throw new Error('This verification expired. Run localauth verify again.')
69
- if (reply?.status === 200 && done(reply.data.verification)) return reply.data.verification
69
+ if (reply?.status === 200 && done(reply.data)) return reply.data
70
70
  await new Promise((resolve) => setTimeout(resolve, POLL_MS))
71
71
  }
72
72
  throw new Error('Nobody finished the verification in time. Run localauth verify again.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localauth/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Finish CLI logins on a headless server from your phone, with optional passkey-based end-to-end encryption",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,11 @@
31
31
  "!cli/*.test.ts",
32
32
  "shared/*.ts",
33
33
  "!shared/*.test.ts",
34
+ "!shared/protocol-vectors.ts",
34
35
  "skills/localauth/SKILL.md"
35
36
  ],
36
37
  "scripts": {
38
+ "prepare": "patch-package",
37
39
  "dev": "DESIGN_PREVIEW=1 wrangler dev --local-upstream localhost:8787 --var DESIGN_PREVIEW:1",
38
40
  "build": "node scripts/build-client.ts",
39
41
  "deploy": "wrangler deploy",
@@ -53,17 +55,20 @@
53
55
  "remix": "^3.0.0"
54
56
  },
55
57
  "devDependencies": {
56
- "@better-auth/passkey": "^1.7.10",
58
+ "@better-auth/api-key": "1.7.12",
59
+ "@better-auth/oauth-provider": "1.7.12",
60
+ "@better-auth/passkey": "1.7.12",
57
61
  "@cloudflare/workers-types": "^5.20261016.1",
58
62
  "@remix-run/ui": "0.12.1",
59
63
  "@simplewebauthn/browser": "^13.3.0",
60
64
  "@types/node": "^26.6.5",
61
- "auth": "^1.7.10",
62
- "better-auth": "^1.7.10",
65
+ "auth": "1.7.12",
66
+ "better-auth": "1.7.12",
63
67
  "drizzle-kit": "^0.31.11",
64
68
  "drizzle-orm": "^0.45.4",
65
69
  "esbuild": "^0.28.2",
66
70
  "jose": "^6.2.12",
71
+ "patch-package": "8.0.1",
67
72
  "playwright-core": "^1.64.1",
68
73
  "typescript": "^7.0.2",
69
74
  "wrangler": "^4.151.0"
@@ -92,7 +92,7 @@ export interface VerificationRecord {
92
92
  expiresAt: number
93
93
  }
94
94
 
95
- /** Response of POST /api/m/pair: what `localauth login` shows and polls with. */
95
+ /** Response of POST /api/v1/machine/pair: what `localauth login` shows and polls with. */
96
96
  export interface PairingStart {
97
97
  deviceCode: string
98
98
  userCode: string
@@ -104,7 +104,7 @@ export interface PairingStart {
104
104
  signInFirst?: boolean
105
105
  }
106
106
 
107
- /** Response of POST /api/m/pair/token once approved. */
107
+ /** Response of POST /api/v1/machine/pair/token once approved. */
108
108
  export interface PairingResult {
109
109
  token: string
110
110
  machineId: string
@@ -13,6 +13,16 @@ the user opens anywhere (localauth.dev, or their own deployment), and forwards t
13
13
  address they bring back to the waiting command. Data is encrypted at rest by default, with optional passkey-based end-to-end encryption.
14
14
  Tokens are created on this machine.
15
15
 
16
+ For scripts, the public API is documented at https://localauth.dev/docs/api and
17
+ https://localauth.dev/api/v1/openapi.json. The owner can create a scoped personal token on the
18
+ Account page and send it as `Authorization: Bearer lat_…`. It cannot change the account,
19
+ encryption, passkeys, tokens or emoji verification. End-to-end API data stays sealed; a client
20
+ needs the account key to read or answer it. Browser agents may use the page's WebMCP login
21
+ and machine-pairing tools; a person still compares emoji. CLI 0.2.0 uses `/api/v1/machine/*`;
22
+ link clients use `/api/v1/links/*`.
23
+ After the API upgrade, CLI 0.1.0 receives HTTP 410 with the update instruction. Ask the owner
24
+ to update it; never change an installed CLI or its config without authorization.
25
+
16
26
  ## 1. Check that localauth is ready
17
27
 
18
28
  ```sh