diff --git a/manifest/manifest.go b/manifest/manifest.go index 4c7921f..f352e06 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -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. @@ -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"` @@ -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 } @@ -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) } @@ -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 } @@ -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" +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 673a8ee..1ee7004 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -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) @@ -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]) } @@ -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) @@ -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) + } +}