diff --git a/.gitignore b/.gitignore index 07a846b..86d0ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /bin/ .DS_Store +__pycache__/ +*.pyc diff --git a/helpers/embed.go b/helpers/embed.go new file mode 100644 index 0000000..191b689 --- /dev/null +++ b/helpers/embed.go @@ -0,0 +1,19 @@ +// Package helpers embeds the two data clients a nook's own server code uses. nook-init writes +// them beside the nook's files at boot, so they always match the server they talk to and an +// author never copies a stale one around. +package helpers + +import _ "embed" + +//go:embed nook_data.py +var Python string + +//go:embed nook-data.js +var Node string + +// Names are what nook-init calls them in the nook's directory. Both are reserved: a file the +// author deploys under either name is replaced. +const ( + PythonName = "nook_data.py" + NodeName = "nook-data.js" +) diff --git a/helpers/embed_test.go b/helpers/embed_test.go new file mode 100644 index 0000000..0910a8f --- /dev/null +++ b/helpers/embed_test.go @@ -0,0 +1,35 @@ +package helpers + +import ( + "strings" + "testing" +) + +// The helpers are the surface a nook author's agent writes against, so the things the skill file +// promises about them are worth pinning. +func TestHelpersAreWhatTheSkillDescribes(t *testing.T) { + for name, src := range map[string]string{PythonName: Python, NodeName: Node} { + if len(src) < 500 { + t.Fatalf("%s is empty or truncated (%d bytes)", name, len(src)) + } + for _, needed := range []string{"NOOK_DATA_URL", "NOOK_TOKEN", "X-Nook-As", "viewer"} { + if !strings.Contains(src, needed) { + t.Errorf("%s does not mention %s", name, needed) + } + } + // Acting for a viewer has to be by token. An address would let a nook name anyone. + if strings.Contains(src, "X-Nook-As: \"+email") || strings.Contains(src, "'X-Nook-As': email") { + t.Errorf("%s sends an email as X-Nook-As", name) + } + } + for _, fn := range []string{"def list", "def get", "def create", "def update", "def delete"} { + if !strings.Contains(Python, fn) { + t.Errorf("nook_data.py is missing %s", fn) + } + } + for _, fn := range []string{"list(", "get(", "create(", "update(", "delete("} { + if !strings.Contains(Node, fn) { + t.Errorf("nook-data.js is missing %s", fn) + } + } +} diff --git a/helpers/nook-data.js b/helpers/nook-data.js new file mode 100644 index 0000000..38ec0ec --- /dev/null +++ b/helpers/nook-data.js @@ -0,0 +1,108 @@ +// Read and write this nook's data from its own server code. +// +// A nook's pages use nook.js. This is the same thing for the code behind them, with the same +// rules: shared collections are shared, a me/ collection belongs to one person, and a viewer can +// read but not change shared data. +// +// const nook = require('./nook-data.js') +// +// // For whoever is making this request. Pass the request's headers. +// const me = nook.viewer(req.headers) +// await me.create('me/notes', { text: 'mine' }) +// await me.list('notes') +// me.user // { email, name, role } +// +// // With nobody on the other end, from a cron entry. Shared collections only. +// await nook.list('notes') +// +// Nothing here needs configuring: nookd puts NOOK_DATA_URL and NOOK_TOKEN in the environment. +// This file is CommonJS, which is what Node uses for a .js file in a folder with no package.json. +// If yours sets "type": "module", rename it to nook-data.cjs and require that. + +class NookError extends Error { + constructor(status, message) { + super(message) + this.name = 'NookError' + this.status = status + } +} + +class Nook { + constructor(asToken, user) { + this.base = process.env.NOOK_DATA_URL || '' + this.token = process.env.NOOK_TOKEN || '' + this.user = user || {} + this._as = asToken + } + + // Documents in a collection, newest last unless order is 'desc'. + async list(collection, opts = {}) { + const q = new URLSearchParams() + if (opts.limit) q.set('limit', opts.limit) + if (opts.cursor) q.set('cursor', opts.cursor) + if (opts.order) q.set('order', opts.order) + for (const [field, value] of Object.entries(opts.where || {})) q.set('where', `${field}:${value}`) + const out = await this._call('GET', collection, null, q) + return out.documents || [] + } + + get(collection, id) { return this._call('GET', `${collection}/${id}`) } + create(collection, doc) { return this._call('POST', collection, doc) } + // Merges fields into the document; anything not named is left alone. + update(collection, id, fields) { return this._call('PATCH', `${collection}/${id}`, fields) } + async delete(collection, id) { await this._call('DELETE', `${collection}/${id}`) } + + async _call(method, path, body, query) { + if (!this.base) throw new NookError(0, 'NOOK_DATA_URL is not set: this is not running as a nook') + let url = this.base + encodeURI(path) + if (query && [...query].length) url += '?' + query.toString() + const headers = { Authorization: 'Bearer ' + this.token, 'Content-Type': 'application/json' } + if (this._as) headers['X-Nook-As'] = this._as + const resp = await fetch(url, { + method, + headers, + body: body === undefined || body === null ? undefined : JSON.stringify(body), + }) + const text = await resp.text() + if (!resp.ok) { + let message = text + try { message = JSON.parse(text).error || text } catch {} + throw new NookError(resp.status, message) + } + return text ? JSON.parse(text) : {} + } +} + +// Headers arrive as a plain object from node:http, or as a Headers from a framework. +function header(headers, name) { + if (!headers) return '' + if (typeof headers.get === 'function') return headers.get(name) || '' + return headers[name] || headers[name.toLowerCase()] || '' +} + +// A Nook that acts for the person making this request. +// +// The proxy puts their identity in the request's headers, and X-Nook-As is what lets this read +// and write their data. Pass the headers through rather than an email address: a nook can act for +// someone who is using it, not for anyone it can name. +function viewer(headers) { + return new Nook(header(headers, 'X-Nook-As'), { + email: header(headers, 'X-Nook-User'), + name: header(headers, 'X-Nook-Name'), + role: header(headers, 'X-Nook-Role'), + }) +} + +// The nook acting as itself: shared collections only, for cron and anything with no viewer. +const self = new Nook() + +module.exports = { + Nook, + NookError, + viewer, + list: self.list.bind(self), + get: self.get.bind(self), + create: self.create.bind(self), + update: self.update.bind(self), + delete: self.delete.bind(self), +} diff --git a/helpers/nook_data.py b/helpers/nook_data.py new file mode 100644 index 0000000..a4324cc --- /dev/null +++ b/helpers/nook_data.py @@ -0,0 +1,133 @@ +"""Read and write this nook's data from its own server code. + +A nook's pages use nook.js. This is the same thing for the code behind them, with the same rules: +shared collections are shared, a me/ collection belongs to one person, and a viewer can read but +not change shared data. + + import nook_data + + # For whoever is making this request. Pass the request's headers. + me = nook_data.viewer(headers) + me.create("me/notes", {"text": "mine"}) + me.list("notes") + me.user # {"email": ..., "name": ..., "role": ...} + + # With nobody on the other end, from a cron entry. Shared collections only. + nook_data.list("notes") + +Nothing here needs configuring: nookd puts NOOK_DATA_URL and NOOK_TOKEN in the environment. +""" + +import json +import os +import urllib.error +import urllib.parse +import urllib.request + +__all__ = ["Nook", "NookError", "viewer", "list", "get", "create", "update", "delete"] + + +class NookError(Exception): + """A call the data API refused. .status is the HTTP status.""" + + def __init__(self, status, message): + super().__init__(message) + self.status = status + + +class Nook: + def __init__(self, as_token=None, user=None): + self.base = os.environ.get("NOOK_DATA_URL", "") + self.token = os.environ.get("NOOK_TOKEN", "") + self.user = user or {} + self._as = as_token + + def list(self, collection, limit=None, cursor=None, order=None, where=None): + """Documents in a collection, newest last unless order='desc'.""" + q = {} + if limit: + q["limit"] = limit + if cursor: + q["cursor"] = cursor + if order: + q["order"] = order + for field, value in (where or {}).items(): + q["where"] = "%s:%s" % (field, value) + return self._call("GET", collection, params=q).get("documents", []) + + def get(self, collection, id): + return self._call("GET", "%s/%s" % (collection, id)) + + def create(self, collection, doc): + return self._call("POST", collection, body=doc) + + def update(self, collection, id, fields): + """Merges fields into the document; anything not named is left alone.""" + return self._call("PATCH", "%s/%s" % (collection, id), body=fields) + + def delete(self, collection, id): + self._call("DELETE", "%s/%s" % (collection, id)) + + def _call(self, method, path, body=None, params=None): + if not self.base: + raise NookError(0, "NOOK_DATA_URL is not set: this is not running as a nook") + url = self.base + urllib.parse.quote(path) + if params: + url += "?" + urllib.parse.urlencode(params) + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, method=method, data=data) + req.add_header("Authorization", "Bearer " + self.token) + req.add_header("Content-Type", "application/json") + if self._as: + req.add_header("X-Nook-As", self._as) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raw = e.read() + try: + message = json.loads(raw).get("error", raw.decode("utf-8", "replace")) + except Exception: + message = raw.decode("utf-8", "replace") + raise NookError(e.code, message) from None + + +def _header(headers, name): + """Headers come as a dict, a WSGI environ, or http.server's own object. Try each shape.""" + if headers is None: + return "" + for key in (name, name.lower(), "HTTP_" + name.upper().replace("-", "_")): + try: + value = headers.get(key) + except AttributeError: + return "" + if value: + return value + return "" + + +def viewer(headers): + """A Nook that acts for the person making this request. + + The proxy puts their identity in the request's headers, and X-Nook-As is what lets this read + and write their data. Pass the headers through rather than an email address: a nook can act + for someone who is using it, not for anyone it can name. + """ + return Nook( + as_token=_header(headers, "X-Nook-As"), + user={ + "email": _header(headers, "X-Nook-User"), + "name": _header(headers, "X-Nook-Name"), + "role": _header(headers, "X-Nook-Role"), + }, + ) + + +# The nook acting as itself: shared collections only, for cron and anything with no viewer. +_self = Nook() +list = _self.list # noqa: A001 - mirrors nook.js, where it is nook.data.list +get = _self.get +create = _self.create +update = _self.update +delete = _self.delete diff --git a/skill/SKILL.md b/skill/SKILL.md index 676bf03..7b5be04 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -167,6 +167,76 @@ do { const r = await nook.data.list("items", { limit: 200, cursor }); all.push(. ``` +## When a nook needs a server + +Most nooks are a page and nothing else. Add a `run` command to `nook.json` and the nook runs code +on a server of its own instead: use it when the page cannot do the job, which usually means an API +key that must not reach a browser, a call to a service that will not allow one, or work on a +schedule. + +```json +{ "name": "board", "run": "python app.py", "port": 8000, + "secrets": ["SLACK_TOKEN"], "egress": ["hooks.slack.com"], + "cron": [{ "schedule": "0 9 * * 1-5", "run": "python digest.py" }] } +``` + +- `run` is the command. `port` is what it listens on, 1024 or above. Listen on `0.0.0.0`, and + read the port from `PORT` if you like; it is in the environment. +- Python 3.12 and Node 22 are available. There is no build step: the folder is what runs. +- `secrets` names the values the nook needs. Set each one with `nook secret set board SLACK_TOKEN`, + which reads the value from a pipe or a prompt. **A value is never an argument, never in + `nook.json`, and never readable again** — not by the owner, not by us. The nook will not start + until every declared name is set, and the deploy says which are missing. +- `egress` is every host the code may reach. Anything not listed is blocked. Leave it out and the + nook has no outbound network at all. +- `cron` runs a command on a schedule, in UTC, five fields, at most five entries. + +Add `"source": "viewers"` if you want anyone who can open the nook to read its code; by default +that is the owner and its editors, because code on a server never reaches a browser the way a +page does. + +### Reading and writing data from server code + +`nook_data.py` and `nook-data.js` are written into the nook's folder for you. They are the same +thing `nook.js` is for a page, with the same rules. + +```python +import nook_data + +# For whoever is making this request: pass the request's headers straight through. +me = nook_data.viewer(self.headers) +me.create("me/notes", {"text": "only mine"}) +me.list("notes") +me.user["email"] # who they are; the nook never sees a password or a token of theirs + +# From a cron entry, with nobody on the other end. Shared collections only. +nook_data.list("notes") +``` + +```js +const nook = require('./nook-data.js') +const me = nook.viewer(req.headers) +await me.create('me/notes', { text: 'only mine' }) +await nook.list('notes') // as the nook itself +``` + +**Pass the headers, not an email address.** `viewer()` takes a token out of them that lets the +nook act for that person while they are using it. There is no way to act for someone by naming +them, which is what keeps a `me/` collection private even from the nook's own code. + +### What the nook is given, and what it is not + +The proxy sets `X-Nook-User`, `X-Nook-Name`, `X-Nook-Role`, and the token `viewer()` uses. It +strips `Authorization` and Nook's own cookies, so **the code never sees a credential**, only who +someone is. Cookies the nook sets itself pass through normally, so it can keep a session of its +own if it needs one. + +Everything else is fenced: the machine reaches only the hosts in `egress` plus Nook itself, holds +nothing between restarts, and is stopped when nobody has opened the nook for ten minutes and +started again on the next request. Keep anything that must last in the data API. + +`nook logs board` shows what the code printed, including scheduled jobs and any restarts. + ## Sharing ``` @@ -216,6 +286,7 @@ yourself, so nothing you build is stuck anywhere. ``` nook list nook versions nook rollback [version] nook open nook delete nook account whoami|plan|upgrade|logout +nook logs nook secret set|list|unset KEY ``` Run these inside the nook's folder, or add `--nook `.