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
3 changes: 3 additions & 0 deletions cmd/nook/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ func main() {
err = importNook(args, asJSON)
case "data":
err = data(args, asJSON)
case "secret", "secrets":
err = secret(args, asJSON)
case "delete":
err = deleteNook(args, asJSON)
case "mcp":
Expand Down Expand Up @@ -122,6 +124,7 @@ more:
transfer [nook] <email> versions [nook] rollback [nook] [version]
export [nook] [-o file] import <file> [--name n] [--share]
data <nook> list|get|create|update|delete|export|import <collection> ...
secret set|list|unset [nook] KEY values come from stdin or a prompt, never argv
delete [nook] mcp (run the MCP server on stdio)
account whoami|plan|upgrade|logout|stats

Expand Down
192 changes: 192 additions & 0 deletions cmd/nook/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package main

import (
"bufio"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"regexp"
"strings"
)

// The server's rule, checked here so a bad name fails before the request rather than as a 400.
var secretNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`)

// A secret's value is never an argument: argv shows up in shell history and in ps. It comes from
// a pipe, or from a prompt with the terminal's echo off. It is never printed back, by any command.

func secret(args []string, asJSON bool) error {
if len(args) == 0 {
return fmt.Errorf("usage: nook secret set|list|unset [nook] [KEY]")
}
sub, rest := args[0], args[1:]
name, rest, err := nookName(rest, isSecretName)
if err != nil {
return err
}
switch sub {
case "list", "ls":
out, err := call(http.MethodGet, "/v1/nooks/"+name+"/secrets", nil, "")
if err != nil {
return err
}
return emit(asJSON, out, secretLines(out))
case "set":
if len(rest) == 0 {
return fmt.Errorf("usage: nook secret set [nook] KEY (the value comes from stdin or a prompt)")
}
if err := checkSecretName(rest[0]); err != nil {
return err
}
value, err := readSecretValue(rest[0])
if err != nil {
return err
}
out, err := callJSON(http.MethodPut, "/v1/nooks/"+name+"/secrets/"+rest[0], map[string]string{"value": value})
if err != nil {
return err
}
return emit(asJSON, out, fmt.Sprintf("%s is set for %s. its value is never shown again.\n%s", rest[0], name, secretLines(out)))
case "unset", "rm":
if len(rest) == 0 {
return fmt.Errorf("usage: nook secret unset [nook] KEY")
}
if err := checkSecretName(rest[0]); err != nil {
return err
}
out, err := call(http.MethodDelete, "/v1/nooks/"+name+"/secrets/"+rest[0], nil, "")
if err != nil {
return err
}
return emit(asJSON, out, fmt.Sprintf("%s is gone from %s\n%s", rest[0], name, secretLines(out)))
}
return fmt.Errorf("nook secret set|list|unset")
}

// isSecretName keeps an uppercase KEY from being mistaken for the nook's name. It is looser than
// the rule below on purpose: "_FOO" should be read as a misspelled key and named as one, not
// silently taken for a nook.
func isSecretName(a string) bool {
return a != "" && a == strings.ToUpper(a) && strings.Trim(a, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") == ""
}

func checkSecretName(name string) error {
if !secretNameRe.MatchString(name) {
return fmt.Errorf("%q is not a secret name: start with an uppercase letter, then letters, digits, and underscores, like SLACK_TOKEN", name)
}
if strings.HasPrefix(name, "NOOK_") {
return fmt.Errorf("%q is reserved: NOOK_ names are set by the runtime", name)
}
return nil
}

func readSecretValue(key string) (string, error) {
st, _ := os.Stdin.Stat()
piped := st != nil && st.Mode()&os.ModeCharDevice == 0
if piped {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("reading the value for %s: %w", key, err)
}
// Exactly one trailing newline comes off, the one a shell or an editor adds. Everything
// else is kept: a PEM key and a JSON credential are both normal things to pipe in.
v := strings.TrimSuffix(string(b), "\n")
v = strings.TrimSuffix(v, "\r")
if v == "" {
return "", fmt.Errorf("%s cannot be empty", key)
}
return v, nil
}
fmt.Fprintf(os.Stderr, "value for %s (not shown): ", key)
restore := echoOff()
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
restore()
fmt.Fprintln(os.Stderr)
if err != nil && strings.TrimSpace(line) == "" {
return "", fmt.Errorf("no value given for %s", key)
}
v := strings.TrimRight(line, "\r\n")
if v == "" {
return "", fmt.Errorf("%s cannot be empty", key)
}
return v, nil
}

// echoOff hides typing through stty, so the CLI needs no dependency for one prompt. Where stty
// is not there the value echoes, which is worth saying rather than silently showing it.
func echoOff() func() {
if stty("-echo") != nil {
fmt.Fprint(os.Stderr, "\n(cannot hide input on this terminal; it will be visible) ")
return func() {}
}
// Ctrl+C at the prompt must not leave the terminal echoless, typing blind until stty echo.
sig := make(chan os.Signal, 1)
done := make(chan struct{})
signal.Notify(sig, os.Interrupt)
go func() {
select {
case <-sig:
stty("echo")
fmt.Fprintln(os.Stderr)
os.Exit(130)
case <-done:
}
}()
return func() {
signal.Stop(sig)
close(done)
stty("echo")
}
}

func stty(arg string) error {
cmd := exec.Command("stty", arg)
cmd.Stdin = os.Stdin // stty acts on the terminal it is given, which is the one being typed at
return cmd.Run()
}

func secretLines(out map[string]any) string {
set := map[string]any{}
if list, ok := out["secrets"].([]any); ok {
for _, s := range list {
if m, ok := s.(map[string]any); ok {
set[fmt.Sprintf("%v", m["name"])] = m["updated_by"]
}
}
}
var b strings.Builder
seen := map[string]bool{}
if d, ok := out["declared"].([]any); ok {
for _, name := range d {
k := fmt.Sprintf("%v", name)
seen[k] = true
if by, ok := set[k]; ok {
fmt.Fprintf(&b, " %s set by %v\n", k, by)
} else {
fmt.Fprintf(&b, " %s not set\n", k)
}
}
}
for k, by := range set {
if !seen[k] {
fmt.Fprintf(&b, " %s set by %v, not declared in nook.json\n", k, by)
}
}
if b.Len() == 0 {
return " no secrets"
}
if m, ok := out["missing"].([]any); ok && len(m) > 0 {
fmt.Fprintf(&b, "\nthis nook cannot start until %d more %s set.", len(m), plural(len(m), "is", "are"))
}
return strings.TrimRight(b.String(), "\n")
}

func plural(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
82 changes: 82 additions & 0 deletions cmd/nook/secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"os"
"strings"
"testing"
)

// piped runs readSecretValue with b on stdin, the way a shell pipe delivers it.
func piped(t *testing.T, b string) (string, error) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = old })
go func() { w.WriteString(b); w.Close() }()
return readSecretValue("KEY")
}

func TestPipedValueIsNotTruncated(t *testing.T) {
pem := "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADAN\nBgkqhkiG9w0B\n-----END PRIVATE KEY-----\n"
got, err := piped(t, pem)
if err != nil {
t.Fatal(err)
}
// One trailing newline comes off; every other line stays. Truncating at the first newline
// would store a value that looks set and fails at runtime.
if want := strings.TrimSuffix(pem, "\n"); got != want {
t.Errorf("got %q, want %q", got, want)
}
if strings.Count(got, "\n") != 3 {
t.Errorf("lost lines: %q", got)
}
}

func TestPipedValueKeepsWhatMatters(t *testing.T) {
for name, c := range map[string]struct{ in, want string }{
"one trailing newline comes off": {"abc\n", "abc"},
"only one comes off": {"abc\n\n", "abc\n"},
"crlf comes off": {"abc\r\n", "abc"},
"no newline is fine": {"abc", "abc"},
"inner spacing is kept": {"a b\tc \n", "a b\tc "},
"json survives": {"{\n \"k\": \"v\"\n}\n", "{\n \"k\": \"v\"\n}"},
} {
got, err := piped(t, c.in)
if err != nil || got != c.want {
t.Errorf("%s: got %q (%v), want %q", name, got, err, c.want)
}
}
if _, err := piped(t, "\n"); err == nil {
t.Error("a newline alone is an empty value")
}
if _, err := piped(t, ""); err == nil {
t.Error("nothing piped is an empty value")
}
}

func TestCheckSecretName(t *testing.T) {
for _, ok := range []string{"A", "SLACK_TOKEN", "A1", "A_B_C"} {
if err := checkSecretName(ok); err != nil {
t.Errorf("%q should be a name: %v", ok, err)
}
}
// These reached the server before and came back as a 400.
for _, bad := range []string{"", "_FOO", "123", "lower", "WITH-DASH", "WITH SPACE", "NOOK_TOKEN", strings.Repeat("A", 65)} {
if err := checkSecretName(bad); err == nil {
t.Errorf("%q should be refused here, not by the server", bad)
}
}
// Still recognised as an intended key, so the error names the key rather than the nook.
for _, k := range []string{"_FOO", "123", "SLACK_TOKEN"} {
if !isSecretName(k) {
t.Errorf("%q should be read as a key", k)
}
}
if isSecretName("board") {
t.Error("a nook name is not a key")
}
}
20 changes: 19 additions & 1 deletion manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var (
hostRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
reserved = map[string]bool{"api": true, "www": true, "app": true, "admin": true, "static": true, "mail": true, "dashboard": true, "nook": true}
types = map[string]bool{"static": true, "service": true}
sources = map[string]bool{"editors": true, "viewers": true}
)

// A CronEntry runs `Run` inside the machine on `Schedule`, five-field cron in UTC.
Expand All @@ -40,6 +41,9 @@ type Manifest struct {
Entry string `json:"entry,omitempty"`

// Service nooks only. See DESIGN.md §10b.
// Source is who may read the run code: "editors" (owner and editors, the default) or
// "viewers" (anyone who can open the nook, as a static nook's files always are).
Source string `json:"source,omitempty"`
Run string `json:"run,omitempty"`
Port int `json:"port,omitempty"`
Secrets []string `json:"secrets,omitempty"`
Expand All @@ -60,6 +64,10 @@ func Parse(b []byte) (*Manifest, error) {
if m.Type == "static" && m.Entry == "" {
m.Entry = "index.html"
}
// Written out explicitly, so a pull or an export of a service nook states who can read it.
if m.Type == "service" && m.Source == "" {
m.Source = "editors"
}
if err := m.Validate(); err != nil {
return nil, err
}
Expand All @@ -85,7 +93,7 @@ func (m *Manifest) Validate() error {
for _, f := range []struct {
name string
set bool
}{{"run", m.Run != ""}, {"port", m.Port != 0}, {"secrets", len(m.Secrets) > 0}, {"egress", len(m.Egress) > 0}, {"cron", len(m.Cron) > 0}} {
}{{"source", m.Source != ""}, {"run", m.Run != ""}, {"port", m.Port != 0}, {"secrets", len(m.Secrets) > 0}, {"egress", len(m.Egress) > 0}, {"cron", len(m.Cron) > 0}} {
if f.set {
return fmt.Errorf(`nook.json: %s only applies to a service nook; set "type": "service" or remove it`, f.name)
}
Expand All @@ -97,6 +105,9 @@ func (m *Manifest) validateService() error {
if m.Entry != "" {
return fmt.Errorf(`nook.json: entry is for static nooks; a service nook serves its own routes from run`)
}
if m.Source != "" && !sources[m.Source] {
return fmt.Errorf(`nook.json: source %q must be "editors" (the default) or "viewers"`, m.Source)
}
if err := command("run", m.Run); err != nil {
return err
}
Expand Down Expand Up @@ -180,3 +191,10 @@ func command(field, s string) error {
}
return nil
}

// SourceVisibleToViewers says whether anyone who can open the nook may read its code. A static
// nook's files reach every viewer's browser anyway, so they always are; a service nook's do not,
// and stay with the owner and editors unless the manifest opts in.
func (m *Manifest) SourceVisibleToViewers() bool {
return !m.IsService() || m.Source == "viewers"
}
22 changes: 22 additions & 0 deletions manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func TestParseStatic(t *testing.T) {
`{"name":"ok","secrets":["TOKEN"]}`,
`{"name":"ok","egress":["example.com"]}`,
`{"name":"ok","cron":[{"schedule":"* * * * *","run":"x"}]}`,
`{"name":"ok","source":"viewers"}`,
} {
if _, err := Parse([]byte(bad)); err == nil {
t.Errorf("expected error for %s", bad)
Expand All @@ -41,6 +42,10 @@ func TestParseService(t *testing.T) {
if m.Entry != "" {
t.Errorf("a service nook has no entry, got %q", m.Entry)
}
// Unset means the run code stays with the owner and editors, and says so when written back.
if m.Source != "editors" || m.SourceVisibleToViewers() {
t.Errorf("source defaults closed, got %q", m.Source)
}
if m.Egress[1] != "api.example.com" {
t.Errorf("egress hosts are lowercased, got %q", m.Egress[1])
}
Expand Down Expand Up @@ -68,6 +73,8 @@ func TestServiceValidation(t *testing.T) {
"egress bare word": `{"name":"board","type":"service","run":"x","port":8000,"egress":["localhost"]}`,
"cron schedule": `{"name":"board","type":"service","run":"x","port":8000,"cron":[{"schedule":"0 9 * *","run":"y"}]}`,
"cron run blank": `{"name":"board","type":"service","run":"x","port":8000,"cron":[{"schedule":"* * * * *","run":""}]}`,
"source unknown": `{"name":"board","type":"service","run":"x","port":8000,"source":"everyone"}`,
"source public": `{"name":"board","type":"service","run":"x","port":8000,"source":"public"}`,
} {
if _, err := Parse([]byte(bad)); err == nil {
t.Errorf("%s: expected an error for %s", name, bad)
Expand All @@ -82,3 +89,18 @@ func TestServiceValidation(t *testing.T) {
t.Errorf("egress is optional: %v", err)
}
}

func TestSourceVisibility(t *testing.T) {
// A static nook's files reach the browser anyway, so they are always readable.
m, err := Parse([]byte(`{"name":"page"}`))
if err != nil || !m.SourceVisibleToViewers() {
t.Errorf("static source is always visible: %+v %v", m, err)
}
if m.Source != "" {
t.Errorf("static nooks carry no source field, got %q", m.Source)
}
open, err := Parse([]byte(`{"name":"board","type":"service","run":"x","port":8000,"source":"viewers"}`))
if err != nil || !open.SourceVisibleToViewers() {
t.Errorf(`"viewers" opts in: %+v %v`, open, err)
}
}
Loading