Skip to content
This repository was archived by the owner on Oct 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/bin/
.DS_Store
__pycache__/
*.pyc
19 changes: 19 additions & 0 deletions helpers/embed.go
Original file line number Diff line number Diff line change
@@ -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"
)
35 changes: 35 additions & 0 deletions helpers/embed_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
108 changes: 108 additions & 0 deletions helpers/nook-data.js
Original file line number Diff line number Diff line change
@@ -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),
}
133 changes: 133 additions & 0 deletions helpers/nook_data.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,76 @@ do { const r = await nook.data.list("items", { limit: 200, cursor }); all.push(.
</script>
```

## 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

```
Expand Down Expand Up @@ -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> nook secret set|list|unset <nook> KEY
```

Run these inside the nook's folder, or add `--nook <name>`.
Expand Down
Loading