RT-Style goes online
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Mon, 12 Oct 2026 12:22:13 +0000 (12:22 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Mon, 12 Oct 2026 12:22:13 +0000 (12:22 +0000)
developer/authored/Manuscript.copy/server/style-server [new file with mode: 0644]
developer/authored/Manuscript.copy/server/style-server.service [new file with mode: 0644]
developer/tool/promote

diff --git a/developer/authored/Manuscript.copy/server/style-server b/developer/authored/Manuscript.copy/server/style-server
new file mode 100644 (file)
index 0000000..83f1959
--- /dev/null
@@ -0,0 +1,258 @@
+#!/usr/bin/env node
+// -*- mode: js; js-indent-level: 2; indent-tabs-mode: nil -*-
+/*
+style-server
+
+Interim HTTPS server for the RT-Style library at style.reasoningtechnology.com.
+Serves static files from one directory. It is meant to sit behind Cloudflare,
+which connects to it with Full (strict) TLS using the Cloudflare origin
+certificate. It is to be replaced by a server built on the TM library.
+
+Usage: style-server <command> ...
+
+  help | -h | --help                      print this message (also printed when given no arguments)
+  check <root> <cert> <key>               load the certificate and key, check the root directory, serve nothing
+  serve <root> <cert> <key> [port]        serve <root> over HTTPS on [port], 443 when not given
+
+  <root>   directory whose files are served; a request for /a/b.js is answered from <root>/a/b.js
+  <cert>   PEM certificate file
+  <key>    PEM private key file
+
+What it does for each request:
+  only GET and HEAD are answered; other methods get 405
+  the path is decoded and resolved inside <root>; any path, or symbolic link,
+    that would lead outside <root> gets 404
+  a directory is answered with its index.html, after a redirect that adds a
+    trailing slash to the address
+  files are sent with a content type chosen by extension, Cache-Control,
+    Access-Control-Allow-Origin "*", and Last-Modified; a request carrying
+    If-Modified-Since for an unchanged file gets 304 and no body
+
+One log line per request goes to standard output: client address, method,
+path, status, bytes. Behind Cloudflare the client address is taken from the
+CF-Connecting-IP header.
+*/
+
+const https = require("https");
+const fs = require("fs");
+const fsp = require("fs/promises");
+const path = require("path");
+
+const CACHE_CONTROL = "public, max-age=3600";
+
+const CONTENT_TYPE = {
+  ".html": "text/html; charset=utf-8"
+  ,".js": "text/javascript; charset=utf-8"
+  ,".mjs": "text/javascript; charset=utf-8"
+  ,".css": "text/css; charset=utf-8"
+  ,".json": "application/json; charset=utf-8"
+  ,".txt": "text/plain; charset=utf-8"
+  ,".svg": "image/svg+xml"
+  ,".png": "image/png"
+  ,".jpg": "image/jpeg"
+  ,".jpeg": "image/jpeg"
+  ,".gif": "image/gif"
+  ,".ico": "image/x-icon"
+  ,".woff": "font/woff"
+  ,".woff2": "font/woff2"
+  ,".ttf": "font/ttf"
+  ,".otf": "font/otf"
+  ,".pdf": "application/pdf"
+};
+
+// ---------------------------------------------------------------- work
+
+// Resolves a URL path to a real file path inside root_real, or null.
+// root_real is the root directory with its own symbolic links resolved.
+async function resolve_inside(root_real ,url_path){
+  let decoded;
+  try{ decoded = decodeURIComponent(url_path); }
+  catch(e){ return null; }                          // malformed %-encoding
+  if(decoded.includes("\0")) return null;
+
+  // Resolve lexically first: "." + "/a/../b" stays under root_real.
+  const candidate = path.resolve(root_real ,"." + path.posix.normalize("/" + decoded));
+  if(!is_inside(root_real ,candidate)) return null;
+
+  // Then follow symbolic links, and check again.
+  let real;
+  try{ real = await fsp.realpath(candidate); }
+  catch(e){ return null; }                          // does not exist
+  if(!is_inside(root_real ,real)) return null;
+  return real;
+}
+
+function is_inside(root ,p){
+  return p === root || p.startsWith(root + path.sep);
+}
+
+// Read when the request arrives: by the time a response is logged, the
+// connection may already be gone, and its address with it.
+function client_address(request){
+  return request.headers["cf-connecting-ip"] || request.socket.remoteAddress || "-";
+}
+
+function log_line(request ,status ,bytes){
+  const when = new Date().toISOString();
+  console.log(`${when} ${request.rt_client} ${request.method} ${request.url} ${status} ${bytes}`);
+}
+
+function send_status(request ,response ,status ,extra_headers = {}){
+  const body = status + "\n";
+  response.writeHead(status ,{
+    "Content-Type": "text/plain; charset=utf-8"
+    ,"Content-Length": Buffer.byteLength(body)
+    ,"Cache-Control": "no-store"
+    ,...extra_headers
+  });
+  response.end(request.method === "HEAD" ? undefined : body);
+  log_line(request ,status ,0);
+}
+
+async function handle(root_real ,request ,response){
+  request.rt_client = client_address(request);
+  if(request.method !== "GET" && request.method !== "HEAD"){
+    send_status(request ,response ,405 ,{"Allow": "GET, HEAD"});
+    return;
+  }
+
+  // Only the path part of the address is used; any ?query is ignored.
+  const url_path = (request.url || "/").split("?")[0].split("#")[0];
+  let file = await resolve_inside(root_real ,url_path);
+  if(file === null){ send_status(request ,response ,404); return; }
+
+  let stat = await fsp.stat(file);
+  if(stat.isDirectory()){
+    if(!url_path.endsWith("/")){
+      send_status(request ,response ,301 ,{"Location": url_path + "/"});
+      return;
+    }
+    file = await resolve_inside(root_real ,url_path + "index.html");
+    if(file === null){ send_status(request ,response ,404); return; }
+    stat = await fsp.stat(file);
+  }
+  if(!stat.isFile()){ send_status(request ,response ,404); return; }
+
+  const headers = {
+    "Content-Type": CONTENT_TYPE[path.extname(file).toLowerCase()] || "application/octet-stream"
+    ,"Cache-Control": CACHE_CONTROL
+    ,"Access-Control-Allow-Origin": "*"
+    ,"X-Content-Type-Options": "nosniff"
+    ,"Last-Modified": stat.mtime.toUTCString()
+  };
+
+  // HTTP dates have whole-second precision, so compare whole seconds.
+  const since = Date.parse(request.headers["if-modified-since"] || "");
+  if(!Number.isNaN(since) && Math.floor(stat.mtimeMs / 1000) * 1000 <= since){
+    response.writeHead(304 ,headers);
+    response.end();
+    log_line(request ,304 ,0);
+    return;
+  }
+
+  headers["Content-Length"] = stat.size;
+  response.writeHead(200 ,headers);
+  if(request.method === "HEAD"){
+    response.end();
+    log_line(request ,200 ,0);
+    return;
+  }
+  // Log once, when the response finishes or the connection closes, whichever
+  // comes first. A client that has received all Content-Length bytes may hang
+  // up before the response formally ends, so a response counts as complete
+  // when the whole file was read and passed on, and as aborted otherwise.
+  const stream = fs.createReadStream(file);
+  let logged = false;
+  const log_once = () => {
+    if(logged) return;
+    logged = true;
+    log_line(request ,200 ,stream.bytesRead === stat.size ? stat.size : `aborted@${stream.bytesRead}`);
+  };
+  response.on("finish" ,log_once);
+  response.on("close" ,log_once);
+  stream.on("error" ,() => response.destroy());
+  stream.pipe(response);
+}
+
+function load_tls(cert_path ,key_path){
+  return {
+    cert: fs.readFileSync(cert_path)
+    ,key: fs.readFileSync(key_path)
+    ,minVersion: "TLSv1.2"
+  };
+}
+
+async function check(root ,cert_path ,key_path){
+  const root_real = await fsp.realpath(root);
+  if(!(await fsp.stat(root_real)).isDirectory()) throw new Error(`${root} is not a directory`);
+  const tls = load_tls(cert_path ,key_path);
+  // Building a secure context fails if the key does not match the certificate.
+  require("tls").createSecureContext(tls);
+  return root_real;
+}
+
+async function serve(root ,cert_path ,key_path ,port){
+  const root_real = await check(root ,cert_path ,key_path);
+  const server = https.createServer(load_tls(cert_path ,key_path) ,(request ,response) => {
+    handle(root_real ,request ,response).catch(e => {
+      console.error(`error: ${request.url}: ${e.message}`);
+      if(!response.headersSent) send_status(request ,response ,500);
+      else response.destroy();
+    });
+  });
+  server.on("tlsClientError" ,() => {});           // scanners and broken clients; not worth a log line
+  await new Promise((ok ,fail) => { server.once("error" ,fail); server.listen(port ,ok); });
+  console.log(`style-server: serving ${root_real} on port ${port}`);
+
+  const stop = () => {
+    console.log("style-server: stopping");
+    server.close(() => process.exit(0));
+    setTimeout(() => process.exit(0) ,5000).unref();
+  };
+  process.on("SIGTERM" ,stop);
+  process.on("SIGINT" ,stop);
+}
+
+// ---------------------------------------------------------------- command line
+
+function print_help(){
+  const source = fs.readFileSync(__filename ,"utf8");
+  const start = source.indexOf("/*\n") + 3;
+  console.log(source.slice(start ,source.indexOf("*/" ,start)).trim());
+  return 0;
+}
+
+async function CLI(argv){
+  if(argv.length === 0 || ["help" ,"-h" ,"--help"].includes(argv[0])) return print_help();
+  const [command ,...rest] = argv;
+
+  if(command === "check"){
+    if(rest.length !== 3){ console.error("Error: check needs <root> <cert> <key>."); return 1; }
+    const root_real = await check(...rest);
+    console.log(`style-server: ok: root ${root_real}, certificate and key load and match`);
+    return 0;
+  }
+
+  if(command === "serve"){
+    if(rest.length !== 3 && rest.length !== 4){
+      console.error("Error: serve needs <root> <cert> <key> [port].");
+      return 1;
+    }
+    const port = rest.length === 4 ? Number(rest[3]) : 443;
+    if(!Number.isInteger(port) || port < 1 || port > 65535){
+      console.error(`Error: bad port '${rest[3]}'.`);
+      return 1;
+    }
+    await serve(rest[0] ,rest[1] ,rest[2] ,port);
+    return null;                                    // keep running
+  }
+
+  console.error(`Error: unknown command '${command}'.\n`);
+  print_help();
+  return 1;
+}
+
+CLI(process.argv.slice(2)).then(
+  code => { if(code !== null) process.exit(code); }
+  ,e => { console.error(`Error: ${e.message}`); process.exit(1); }
+);
diff --git a/developer/authored/Manuscript.copy/server/style-server.service b/developer/authored/Manuscript.copy/server/style-server.service
new file mode 100644 (file)
index 0000000..c944792
--- /dev/null
@@ -0,0 +1,39 @@
+# /etc/systemd/system/style-server.service
+#
+# Runs style-server as an unprivileged, throwaway user, with only the one
+# privilege it needs: binding to port 443.
+
+[Unit]
+Description=RT-Style interim HTTPS server (style.reasoningtechnology.com)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+# systemd copies these into a private directory that only this service can
+# read, named by %d below. The key itself stays root-only in /etc/ssl/private.
+LoadCredential=cert:/etc/ssl/certs/style.reasoningtechnology.com.pem
+LoadCredential=key:/etc/ssl/private/style.reasoningtechnology.com.key
+
+ExecStart=/usr/local/bin/style-server serve /srv/style %d/cert %d/key 443
+Restart=on-failure
+RestartSec=5
+
+# A user made up for this service, existing only while it runs.
+DynamicUser=yes
+# The one privilege it needs: binding to a port below 1024.
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+NoNewPrivileges=yes
+
+# It reads /srv/style and nothing else of note; it writes nothing.
+ProtectSystem=strict
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+RestrictAddressFamilies=AF_INET AF_INET6
+
+[Install]
+WantedBy=multi-user.target
index 56e8cea..7292374 100755 (executable)
@@ -18,16 +18,22 @@ sys.path.insert(0, os.path.join(SCRIPT_DIR, "build_component"))
 import sync_engine
 
 HELP = """usage: promote {write|clean|ls|diff|help|dry write}
-  write       Writes promoted files from scratchpad into consumer. Only updates newer files.
+  write       Copies what is staged in scratchpad/made into consumer. Only updates newer files.
   clean       Remove all contents of the consumer directory (preserves .gitignore).
   ls          List consumer as an indented tree: PERMS  OWNER  NAME.
-  diff        List missing, orphaned, or out of sync files between scratchpad and consumer.
+  diff        List missing, orphaned, or out of sync files between scratchpad/made and consumer.
   help        Show this message.
   dry write   Preview what write would do without modifying the filesystem.
+
+Promoted files are read-only: 400, or 500 when executable. Directories below
+consumer/ are 500, so not even the owner can add, replace or remove a file in
+them; write and clean open them (700) for the duration and lock them again.
+consumer/ itself stays 700, as it holds the .gitignore git manages.
 """
 
 SETUP_MUST_BE = "developer/tool/setup"
 DEFAULT_DIR_MODE = 0o700
+LOCKED_DIR_MODE = 0o500
 
 def exit_with_status(msg, code=1):
   print(f"promote: {msg}", file=sys.stderr)
@@ -38,7 +44,7 @@ def assert_setup():
   if setup_val != SETUP_MUST_BE:
     hint = (
       "SETUP is not 'developer/tool/setup'.\n"
-      "Enter the project with:  . setup developer\n"
+      "Enter the developer role with:  ./setup developer\n"
       "That script exports: ROLE=developer; SETUP=$ROLE/tool/setup"
     )
     exit_with_status(f"bad environment: SETUP='{setup_val}'. {hint}")
@@ -65,6 +71,18 @@ def ensure_dir(path_str, mode=DEFAULT_DIR_MODE, dry=False):
   os.makedirs(path_str, exist_ok=True)
   ensure_mode(path_str, mode)
 
+def consumer_subdirs(root_dp):
+  """Every directory below root_dp; root_dp itself is not included."""
+  found = []
+  for TM_root, TM_dirs, TM_files in os.walk(root_dp):
+    for TM_d in TM_dirs:
+      found.append(os.path.join(TM_root, TM_d))
+  return found
+
+def set_subdir_modes(root_dp, mode):
+  for TM_d in consumer_subdirs(root_dp):
+    ensure_mode(TM_d, mode)
+
 def filemode(m):
   try: return stat.filemode(m)
   except Exception: return oct(m & 0o777)
@@ -123,20 +141,26 @@ def list_tree(root_dp):
 def cmd_write(dry=False):
   dst_root = cpath()
   ensure_dir(dst_root, DEFAULT_DIR_MODE, dry=dry)
-  src_root = dpath("scratchpad")
-  
-  sync_engine.execute_sync(
-    src_root, 
-    dst_root, 
-    prefix="promote", 
-    dry=dry, 
-    apply_modes=True, 
-    ignore_list=[".gitignore"]
-  )
+  src_root = dpath("scratchpad", "made")
+
+  # Files are replaced by writing a temporary file beside them and renaming
+  # it into place, which needs write permission on the directory.
+  if not dry: set_subdir_modes(dst_root, DEFAULT_DIR_MODE)
+  try:
+    sync_engine.execute_sync(
+      src_root,
+      dst_root,
+      prefix="promote",
+      dry=dry,
+      apply_modes=True,
+      ignore_list=[".gitignore"]
+    )
+  finally:
+    if not dry: set_subdir_modes(dst_root, LOCKED_DIR_MODE)
 
 def cmd_diff():
   dst_root = cpath()
-  src_root = dpath("scratchpad")
+  src_root = dpath("scratchpad", "made")
 
   src_files = set()
   if os.path.isdir(src_root):
@@ -166,7 +190,7 @@ def cmd_diff():
       print(f"Pending promotion (missing in consumer): {TM_rel}")
       found_diff = True
     elif TM_rel not in src_files:
-      print(f"Orphaned in consumer (missing in scratchpad): {TM_rel}")
+      print(f"Orphaned in consumer (missing in scratchpad/made): {TM_rel}")
       found_diff = True
     else:
       src_abs = os.path.join(src_root, TM_rel)
@@ -177,18 +201,20 @@ def cmd_diff():
         dst_mtime = os.stat(dst_abs).st_mtime
 
         if src_mtime > dst_mtime:
-          print(f"Pending update (contents differ, newer in scratchpad): {TM_rel}")
+          print(f"Pending update (contents differ, newer in scratchpad/made): {TM_rel}")
         else:
           print(f"Contents differ (locally modified in consumer): {TM_rel}")
         found_diff = True
 
   if not found_diff:
-    print("No differences found. Consumer matches developer scratchpad.")
+    print("No differences found. Consumer matches developer scratchpad/made.")
 
 def cmd_clean():
   consumer_root_dir = cpath()
   if not os.path.isdir(consumer_root_dir):
     return
+  # Removing an entry needs write permission on the directory holding it.
+  set_subdir_modes(consumer_root_dir, DEFAULT_DIR_MODE)
   for TM_name in os.listdir(consumer_root_dir):
     if TM_name == ".gitignore":
       continue