Metadata-Version: 2.4
Name: nidl-client
Version: 0.2.0
Summary: Drive a running nidl core from a Python program, over its local socket or its remote port.
License: MIT OR Apache-2.0
Project-URL: Source, https://github.com/crissmoldovan/nidl/tree/main/clients/python
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Dynamic: license-file

# nidl-client

Drive a running [nidl](https://github.com/crissmoldovan/nidl) core from a Python program, over
its local socket, or over HTTPS to its remote port on another machine.

```python
import nidl_client

nidl = nidl_client.connect()  # the core serving ~/.nidl; or connect("/path/to/data")
if nidl.has("targets.list"):
    targets = nidl.command("targets.list")
```

- **It never starts a core.** When none is running, `connect()` raises `NidlError` with code
  `not_running` and says how to start one: `nidl serve --data-dir ~/.nidl`, or open the nidl app.
- **It checks the socket before trusting it:** each folder on the way is yours or root's and not
  writable by others, the control folder is `0700`, the core's record is a private file describing
  this folder's core, the socket is the one that record names, and the process at the other end
  runs as you (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` on macOS). A check that fails is
  `unsafe_folder`. Python's standard library cannot read ACLs; the Rust library checks those too.
- **It is an app, not the owner.** It connects as the `app` client kind: it can read, draft,
  preview and apply, but credential commands, such as connecting an account or unlocking
  credentials, are refused with `requires_owner_action`. Those are yours to run, from the CLI or
  the desktop.
- **Command names are listed.** `CommandName` is a `Literal` of the commands an app may run,
  generated from the core's registry, and `COMMAND_NAMES` holds them. `command()` is annotated
  with it, so a type checker flags a misspelled or refused name. At run time any string goes to the
  core, so a newer core's command still works. Results are plain JSON.
- **Each call is one connection:** the folder is checked, the core says hello, the command runs,
  the connection closes.

`NidlError` has `code` for programs, `message` for people, and `outcome`: `not_started`, or
`unknown` when the connection was lost after the command was sent, since it may have run. The
codes: `not_running`, `unsafe_folder`, `not_offered`, `version_conflict`, `path_too_long`,
`timeout`, `protocol_error`, or the core's own, such as `not_found` or `requires_owner_action`.

## A core on another machine

When a machine's owner has turned on nidl's remote port there (`nidl remote enable`) and issued you
a remote token (`nidl remote-tokens issue`), connect to that core by its URL, `https://HOST:PORT`:

```python
nidl = nidl_client.connect(
    url="https://nidl.example:8443",
    token=os.environ["NIDL_TOKEN"],
    fingerprint="sha256:...",  # as `nidl remote status` shows it on that machine
)
```

- **The certificate is checked before the token is sent.** With a fingerprint, only nidl's own
  certificate with that fingerprint is accepted, as SSH remembers a host's key; the handshake still
  proves the server holds its key. Without one, the certificate must be one the system trusts for the URL's
  host, as when the owner serves a certificate of their own. Anything else fails with
  `untrusted_certificate`, and nothing is sent.
- **The token's scope decides what runs.** A `read` token lists and reads. An `operate` token also
  drafts targets, refreshes catalogs and readings, and previews and applies routing. Neither runs
  credential commands or starts paid model checks: those stay with the owner at the machine
  (`requires_owner_action`).
- **`nidl.identity`** has no `root` or `instanceNonce` for a remote core, which does not say what names
  its files; it says the token's `scope`.
- **Each call is one HTTPS connection** to the remote port, closed after the answer.

The remote codes, beside those above: `unauthorized` (the token is unknown, revoked or expired),
`untrusted_certificate` (the certificate is not the one expected; nothing was sent), `throttled`
(too many failed attempts from this address; wait a minute), `invalid_input` (the URL, token or
fingerprint is malformed, or the command is over 1 MiB), `unavailable` (the machine could not be
reached, or its core is busy or not serving), and `unsupported` (that nidl's remote port takes no
commands; update it there).

## Lending your project's key, and reporting readings

Another program can use nidl for its own accounts once the owner has made it a project there
(`nidl projects create`), with a **borrowed** account for each API key it holds
(`nidl projects add-account`, mode `borrowed`) and an API target with an app token
(`nidl targets create`, `nidl app-tokens issue`). Your program lends nidl its key for as long as it
wants requests to the target's endpoint, `/h/<target>/v1/…`, routed with it, and takes it back:

```python
lease = nidl.lend(account_id, api_key, app_token=os.environ["NIDL_APP_TOKEN"], expires_in_seconds=3600)
# ... calls to the target's endpoint route with the key while the lease lives ...
nidl.withdraw(account_id)
```

- **The key travels in the socket's secret frame,** never in a command's arguments, so
  `command("pools.lend")` is refused with `invalid_input`. nidl keeps it in memory only, never on
  disk, for 60 seconds to 12 hours (an hour unless you say) and never past the app token. It is
  dropped when the lease expires, when you withdraw it, when the owner revokes the token or removes
  its target, and when the core stops. Lend again to renew it.
- **The app token proves the account is your project's.** A token of another project's target, a
  revoked or expired one, or none, is refused with `not_authorized`.
- **On this machine only.** `lend` and `withdraw` on a remote core fail with `unsupported` before
  anything is sent.
- The lease is `{accountId, lentAt, expiresAt, lentBy}`, `lentBy` being your app token's id. It
  never holds the key.

Python cannot wipe the `str` you pass in; the frame's own bytes are overwritten once sent.

An **observed** account is one nidl never holds a key for, a subscription for instance, whose quota
your program reports so the owner sees it beside their own:

```python
nidl.observe(account_id, [{"key": "five_hour", "percent": 37.5, "resetsAt": "2026-10-21T15:00:00Z"}])
```

At most 16 windows, each a `key`, a `percent` from 0 to 100, and optionally a `label` and a
`resetsAt`, captured within the last 24 hours (now, unless you say). On a remote core this needs an
`operate` token bound to your project (`nidl remote-tokens issue` with its `project`).

Long-running commands return an operation id: poll it with
`nidl.command("operations.get", {"operationId": ...})`.

The protocol is described in
[docs/protocol/nidl-core-protocol.md](https://github.com/crissmoldovan/nidl/blob/main/docs/protocol/nidl-core-protocol.md).
Python 3.9 or later, macOS or Linux, standard library only: the remote port uses `ssl` and
`http.client`.
