workpeg 0.78.0__tar.gz → 0.79.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {workpeg-0.78.0/src/workpeg.egg-info → workpeg-0.79.1}/PKG-INFO +1 -1
  2. {workpeg-0.78.0 → workpeg-0.79.1}/pyproject.toml +1 -1
  3. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/cdn.py +10 -0
  4. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/cli.py +21 -6
  5. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/context.py +21 -2
  6. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/run.py +4 -1
  7. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/submit.py +40 -0
  8. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/ast_parser.py +6 -5
  9. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/fake_cloud.py +39 -3
  10. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/runtime/ai.js +43 -3
  11. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/runtime/workpeg-runtime.js +14 -4
  12. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/serve.py +48 -3
  13. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/interactive.py +3 -0
  14. {workpeg-0.78.0 → workpeg-0.79.1/src/workpeg.egg-info}/PKG-INFO +1 -1
  15. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_cdn.py +31 -0
  16. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_context.py +80 -0
  17. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_run.py +3 -3
  18. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_submit.py +62 -0
  19. {workpeg-0.78.0 → workpeg-0.79.1}/LICENSE +0 -0
  20. {workpeg-0.78.0 → workpeg-0.79.1}/MANIFEST.in +0 -0
  21. {workpeg-0.78.0 → workpeg-0.79.1}/README.md +0 -0
  22. {workpeg-0.78.0 → workpeg-0.79.1}/setup.cfg +0 -0
  23. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/__init__.py +0 -0
  24. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/build.py +0 -0
  25. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/config.py +0 -0
  26. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/create_new.py +0 -0
  27. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/runtime.py +0 -0
  28. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/__init__.py +0 -0
  29. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/Dockerfile +0 -0
  30. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/LICENSE +0 -0
  31. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/README.md +0 -0
  32. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/app/__init__.py +0 -0
  33. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/app/main.py +0 -0
  34. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/templates/functions/requirements.txt +0 -0
  35. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/__init__.py +0 -0
  36. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/actions.py +0 -0
  37. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/app.py +0 -0
  38. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/build.py +0 -0
  39. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/__init__.py +0 -0
  40. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/ir.py +0 -0
  41. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/compiler/js_generator.py +0 -0
  42. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/create.py +0 -0
  43. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/minify.py +0 -0
  44. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/runtime/pegment.css +0 -0
  45. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/scaffold.py +0 -0
  46. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/state.py +0 -0
  47. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/validators.py +0 -0
  48. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/watcher.py +0 -0
  49. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/__init__.py +0 -0
  50. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/base.py +0 -0
  51. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/canvas.py +0 -0
  52. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/display.py +0 -0
  53. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/forms.py +0 -0
  54. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/futures.py +0 -0
  55. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/gestures.py +0 -0
  56. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/ui/widgets/layout.py +0 -0
  57. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg/utils.py +0 -0
  58. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/SOURCES.txt +0 -0
  59. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/dependency_links.txt +0 -0
  60. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/entry_points.txt +0 -0
  61. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/requires.txt +0 -0
  62. {workpeg-0.78.0 → workpeg-0.79.1}/src/workpeg.egg-info/top_level.txt +0 -0
  63. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_build.py +0 -0
  64. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_changelog.py +0 -0
  65. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_changelog_guard.py +0 -0
  66. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_cli.py +0 -0
  67. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_config.py +0 -0
  68. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_create_new.py +0 -0
  69. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_release_script.py +0 -0
  70. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_runtime.py +0 -0
  71. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_utils.py +0 -0
  72. {workpeg-0.78.0 → workpeg-0.79.1}/tests/test_watch_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workpeg
3
- Version: 0.78.0
3
+ Version: 0.79.1
4
4
  Summary: Workpeg function runtime and SDK
5
5
  Author-email: Workpeg <support@workpeg.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "workpeg"
7
- version = "0.78.0"
7
+ version = "0.79.1"
8
8
  description = "Workpeg function runtime and SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -9,6 +9,8 @@ from pathlib import Path
9
9
 
10
10
  import requests
11
11
 
12
+ from workpeg.submit import _looks_secret
13
+
12
14
 
13
15
  DEFAULT_API_BASE = "https://repo.workpeg.com"
14
16
 
@@ -21,6 +23,10 @@ EXCLUDED_DIRS = {
21
23
  "venv",
22
24
  "dist",
23
25
  "build",
26
+ # credential stores — never publish these
27
+ ".ssh",
28
+ ".aws",
29
+ ".gnupg",
24
30
  }
25
31
 
26
32
  EXCLUDED_SUFFIXES = (".pyc", ".pyo")
@@ -79,6 +85,10 @@ def build_directory_tar_gz_bytes(path: Path) -> bytes:
79
85
  if item.name.endswith(EXCLUDED_SUFFIXES):
80
86
  continue
81
87
 
88
+ # Never publish secrets, and never follow a symlink out of the tree.
89
+ if item.is_symlink() or _looks_secret(item.name):
90
+ continue
91
+
82
92
  archive.add(item, arcname=str(rel))
83
93
 
84
94
  return out.getvalue()
@@ -388,6 +388,18 @@ def _fake_cloud_setup(app_name: str, explicit_port: int) -> "tuple[int, dict]":
388
388
  return port, {"ssl_cert": cert, "ssl_key": key, "url_host": domain}
389
389
 
390
390
 
391
+ def _run_server(serve_fn, *args, **kwargs) -> None:
392
+ """Run a blocking dev server, turning a ServeError (e.g. a privileged or
393
+ in-use port) into a clean CLI error instead of a raw traceback."""
394
+ from workpeg.ui.serve import ServeError
395
+
396
+ try:
397
+ serve_fn(*args, **kwargs)
398
+ except ServeError as e:
399
+ print(f"ERROR: {e}", file=sys.stderr)
400
+ raise SystemExit(1)
401
+
402
+
391
403
  def main(argv=None) -> None:
392
404
  argv = argv if argv is not None else sys.argv[1:]
393
405
 
@@ -597,7 +609,8 @@ def main(argv=None) -> None:
597
609
 
598
610
  # Watch the whole project directory so edits to any page file trigger
599
611
  # a recompile — not just changes to main.py.
600
- serve_blocking(
612
+ _run_server(
613
+ serve_blocking,
601
614
  str(project_dir / "dist"),
602
615
  port,
603
616
  watch_source=str(project_dir),
@@ -653,8 +666,9 @@ def main(argv=None) -> None:
653
666
 
654
667
  # Watch the project directory, not just main.py, so edits to
655
668
  # any page file also trigger a recompile.
656
- serve_blocking(out_dir, port, watch_source=str(source),
657
- peg_namespace=peg_ns, **extra)
669
+ _run_server(serve_blocking, out_dir, port,
670
+ watch_source=str(source),
671
+ peg_namespace=peg_ns, **extra)
658
672
  else:
659
673
  # No main.py — treat as a pre-built dist directory and serve
660
674
  # as-is. There's no recompile, so --peg-namespace can't apply
@@ -662,7 +676,7 @@ def main(argv=None) -> None:
662
676
  if peg_ns:
663
677
  print(" Note: --peg-namespace is ignored for a pre-built "
664
678
  "dist (rebuild to change it).", file=sys.stderr)
665
- serve_blocking(str(source), port, **extra)
679
+ _run_server(serve_blocking, str(source), port, **extra)
666
680
 
667
681
  elif source.suffix == ".py":
668
682
  from workpeg.ui.build import build_ui
@@ -676,8 +690,9 @@ def main(argv=None) -> None:
676
690
  print(f"ERROR: {e}", file=sys.stderr)
677
691
  raise SystemExit(2)
678
692
 
679
- serve_blocking(out_dir, port, watch_source=str(source),
680
- peg_namespace=peg_ns, **extra)
693
+ _run_server(serve_blocking, out_dir, port,
694
+ watch_source=str(source),
695
+ peg_namespace=peg_ns, **extra)
681
696
 
682
697
  else:
683
698
  print(
@@ -62,7 +62,16 @@ class _TableContext(BaseContext):
62
62
 
63
63
  @classmethod
64
64
  def _verify_columns(cls, columns):
65
- return True # TODO
65
+ # A structural check, not a full schema: columns is a list of column
66
+ # definitions (each an object). Defense-in-depth on data the platform
67
+ # serialises in — reject a clearly-wrong shape, don't second-guess
68
+ # each field.
69
+ if columns is None:
70
+ return
71
+ if not isinstance(columns, list):
72
+ raise ValueError("Columns must be a list of column definitions.")
73
+ if not all(isinstance(c, dict) for c in columns):
74
+ raise ValueError("Each column must be a dictionary.")
66
75
 
67
76
  @classmethod
68
77
  def _verify_namespace(cls, namespace):
@@ -71,7 +80,17 @@ class _TableContext(BaseContext):
71
80
 
72
81
  @classmethod
73
82
  def _verify_peg(cls, peg):
74
- return True # TODO
83
+ # A peg identifier: a positive integer id (the common case) or a
84
+ # namespace string. bool is an int subclass in Python, so exclude it
85
+ # explicitly.
86
+ if peg is None:
87
+ return
88
+ if isinstance(peg, bool) or not isinstance(peg, (int, str)):
89
+ raise ValueError("Peg must be an integer id, a string, or None.")
90
+ if isinstance(peg, int) and peg <= 0:
91
+ raise ValueError("Peg id must be a positive integer.")
92
+ if isinstance(peg, str) and not peg.strip():
93
+ raise ValueError("Peg string must be non-empty.")
75
94
 
76
95
 
77
96
  class _StaffContext(BaseContext):
@@ -44,7 +44,10 @@ def run_with_docker(
44
44
  cmd += ["--name", name]
45
45
 
46
46
  if expose:
47
- cmd += ["-p", f"{port}:{port}"]
47
+ # Publish to LOOPBACK only — a locally-run function's /invoke shouldn't
48
+ # be reachable from the LAN. (The deployed runtime binds 0.0.0.0 inside
49
+ # its own managed container; this is the developer's machine.)
50
+ cmd += ["-p", f"127.0.0.1:{port}:{port}"]
48
51
 
49
52
  if network:
50
53
  cmd += ["--network", network]
@@ -16,6 +16,26 @@ JWT_ENV = "WORKPEG_PK"
16
16
  API_ENV = "WORKPEG_API_BASE"
17
17
  PATH_ENV = "WORKPEG_SUBMIT_PATH"
18
18
 
19
+ # Files that must never leave the machine in a bundle/upload — env files, key
20
+ # material, and credential stores. Matched by basename (case-insensitively).
21
+ _SECRET_NAMES = frozenset({
22
+ ".envrc", ".netrc", ".npmrc", ".pypirc", ".htpasswd", ".dockercfg",
23
+ "credentials", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
24
+ })
25
+ _SECRET_SUFFIXES = (".pem", ".key", ".pfx", ".p12", ".keystore", ".jks")
26
+
27
+
28
+ def _looks_secret(name: str) -> bool:
29
+ """A basename that looks like a secret (``.env``/``.env.local``, a key file,
30
+ a credential store) — excluded from bundles and uploads."""
31
+ lo = name.lower()
32
+ return (
33
+ lo == ".env"
34
+ or lo.startswith(".env.")
35
+ or lo in _SECRET_NAMES
36
+ or lo.endswith(_SECRET_SUFFIXES)
37
+ )
38
+
19
39
 
20
40
  def _fail(msg: str, code: int = 2) -> None:
21
41
  print(f"ERROR: {msg}", file=sys.stderr)
@@ -91,8 +111,13 @@ def build_tar_gz_bytes(source_dir: Path) -> bytes:
91
111
  "dist",
92
112
  "build",
93
113
  ".DS_Store",
114
+ # credential stores — never ship these off the machine
115
+ ".ssh",
116
+ ".aws",
117
+ ".gnupg",
94
118
  }
95
119
 
120
+ skipped_secrets: list[str] = []
96
121
  buf = io.BytesIO()
97
122
  with tarfile.open(fileobj=buf, mode="w:gz") as tar:
98
123
  for p in source_dir.rglob("*"):
@@ -101,12 +126,27 @@ def build_tar_gz_bytes(source_dir: Path) -> bytes:
101
126
  if any(part in exclude_names for part in rel.parts):
102
127
  continue
103
128
 
129
+ # Never bundle secrets, and never follow symlinks out of the project
130
+ # (a link could point at anything on the machine). Belt-and-braces on
131
+ # top of validate_function_project.
132
+ if p.is_symlink():
133
+ skipped_secrets.append(rel.as_posix() + " (symlink)")
134
+ continue
135
+ if _looks_secret(rel.name):
136
+ skipped_secrets.append(rel.as_posix())
137
+ continue
138
+
104
139
  if not p.exists():
105
140
  continue
106
141
 
107
142
  arcname = rel.as_posix()
108
143
  tar.add(p.as_posix(), arcname=arcname, recursive=False)
109
144
 
145
+ if skipped_secrets:
146
+ print(
147
+ " Skipped (looks like a secret / symlink, not bundled): "
148
+ + ", ".join(sorted(skipped_secrets))
149
+ )
110
150
  return buf.getvalue()
111
151
 
112
152
 
@@ -76,10 +76,11 @@ _WORKPEG_TABLE_HELPERS = {
76
76
 
77
77
 
78
78
  def _is_workpeg_host(url: str) -> bool:
79
- """True when *url* targets a Workpeg-owned host — ``workpeg`` appearing as a
80
- domain label (workpeg.com, *.workpeg.*, …), not as the TLD. Relative URLs
81
- have no host and are never Workpeg. Mirrors ``Workpeg._isWorkpegHost`` in
82
- the runtime."""
79
+ """True when *url* targets a Workpeg-owned host — ``workpeg`` as the
80
+ REGISTRABLE label, the one right before the TLD (workpeg.com, *.workpeg.io,
81
+ …), not merely somewhere in the name (so ``foo.workpeg.evil.com`` is NOT
82
+ Workpeg). Relative URLs have no host and are never Workpeg. Mirrors
83
+ ``Workpeg._isWorkpegHost`` in the runtime."""
83
84
  try:
84
85
  host = urlparse(url).hostname
85
86
  except ValueError:
@@ -87,7 +88,7 @@ def _is_workpeg_host(url: str) -> bool:
87
88
  if not host:
88
89
  return False
89
90
  labels = host.lower().split(".")
90
- return "workpeg" in labels[:-1]
91
+ return len(labels) >= 2 and labels[-2] == "workpeg"
91
92
 
92
93
 
93
94
  _SUPPORTED_WIDGETS = {
@@ -47,6 +47,30 @@ def slugify(name: str) -> str:
47
47
  return re.sub(r"[^a-z0-9-]", "", slug).strip("-")
48
48
 
49
49
 
50
+ # A plain hostname: dot-separated LDH labels, ≤253 chars. Anything else — a
51
+ # space, newline, slash, shell metachar — is rejected before it reaches a hosts
52
+ # file (where a newline could inject another mapping), a cert subject, or a path.
53
+ _VALID_DOMAIN = re.compile(
54
+ r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
55
+ r"(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$",
56
+ re.IGNORECASE,
57
+ )
58
+
59
+
60
+ def _require_valid_domain(domain: str) -> None:
61
+ if not isinstance(domain, str) or not _VALID_DOMAIN.match(domain):
62
+ raise FakeCloudError(f"Refusing to use an invalid hostname: {domain!r}")
63
+
64
+
65
+ def _lock_down_key(key: "Path") -> None:
66
+ """Make a generated TLS private key owner read/write only (0600). A no-op
67
+ where POSIX permissions don't apply."""
68
+ try:
69
+ os.chmod(key, 0o600)
70
+ except OSError:
71
+ pass
72
+
73
+
50
74
  def derive_domain(name: str) -> str:
51
75
  """Turn a project name into ``dev.<slug>.workpeg.com``.
52
76
 
@@ -107,6 +131,7 @@ def ensure_hosts_entry(domain: str, hosts_path: "str | None" = None) -> bool:
107
131
  terminal. Entries are tagged with a marker comment so they are easy
108
132
  to find and remove later.
109
133
  """
134
+ _require_valid_domain(domain)
110
135
  path = hosts_path or hosts_path_for_os()
111
136
  content = Path(path).read_text()
112
137
  if _has_hosts_entry(content, domain):
@@ -151,6 +176,7 @@ def ensure_certificate(
151
176
  ``"self-signed"`` (openssl fallback — browser warning). Certificates
152
177
  are cached under ``~/.workpeg/fake-cloud/<domain>/`` and reused.
153
178
  """
179
+ _require_valid_domain(domain) # it lands in a path, a subject, a SAN
154
180
  root = Path(cert_root) if cert_root else _CERT_ROOT
155
181
  cert_dir = root / domain
156
182
  cert = cert_dir / "cert.pem"
@@ -178,6 +204,7 @@ def ensure_certificate(
178
204
  check=True,
179
205
  capture_output=True,
180
206
  )
207
+ _lock_down_key(key)
181
208
  kind_file.write_text("mkcert")
182
209
  return str(cert), str(key), "mkcert"
183
210
  except subprocess.CalledProcessError:
@@ -198,6 +225,7 @@ def ensure_certificate(
198
225
  check=True,
199
226
  capture_output=True,
200
227
  )
228
+ _lock_down_key(key)
201
229
  kind_file.write_text("self-signed")
202
230
  return str(cert), str(key), "self-signed"
203
231
 
@@ -209,14 +237,22 @@ def ensure_certificate(
209
237
 
210
238
  def pick_https_port(preferred: int = 443, fallback: int = 8443) -> int:
211
239
  """Best HTTPS port available: 443 (no port in URL) when bindable,
212
- else 8443, else the first free port above 8443."""
240
+ else 8443, else the first free port above 8443.
241
+
242
+ Probes the bind on the *same* host the dev server will use
243
+ (``_bind_host()``, loopback by default) — not ``0.0.0.0`` — so the result
244
+ matches reality. On macOS a non-root process can bind 443 on all interfaces
245
+ but not on 127.0.0.1, so probing loopback correctly falls back to 8443
246
+ instead of handing back a 443 the server then can't bind."""
247
+ from workpeg.ui.serve import _bind_host, find_free_port
248
+
249
+ host = _bind_host()
213
250
  for port in (preferred, fallback):
214
251
  try:
215
252
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
216
- s.bind(("", port))
253
+ s.bind((host, port))
217
254
  return port
218
255
  except OSError:
219
256
  continue
220
- from workpeg.ui.serve import find_free_port
221
257
 
222
258
  return find_free_port(start=fallback + 1)
@@ -212,6 +212,43 @@
212
212
  });
213
213
  }
214
214
 
215
+ // Momentary "copied" confirmation on an icon copy button: swap its glyph to a
216
+ // check and its label to "Copied", then restore after a beat. Re-clicking
217
+ // resets the timer instead of stacking them, and the resting icon/label are
218
+ // captured only on the first flash so rapid clicks can't lose them.
219
+ function flashCopied(btn) {
220
+ if (!btn) return;
221
+ var ic = btn.querySelector("i");
222
+ if (btn._copiedTimer) {
223
+ clearTimeout(btn._copiedTimer);
224
+ } else if (ic) {
225
+ btn._restIcon = ic.className;
226
+ btn._restLabel = btn.getAttribute("aria-label");
227
+ }
228
+ if (ic) ic.className = "ti ti-check";
229
+ btn.classList.add("is-copied");
230
+ btn.setAttribute("aria-label", "Copied");
231
+ btn.setAttribute("title", "Copied");
232
+ btn._copiedTimer = setTimeout(function () {
233
+ if (ic) ic.className = btn._restIcon || "ti ti-copy";
234
+ btn.classList.remove("is-copied");
235
+ if (btn._restLabel) {
236
+ btn.setAttribute("aria-label", btn._restLabel);
237
+ btn.setAttribute("title", btn._restLabel);
238
+ }
239
+ btn._copiedTimer = null;
240
+ }, 1200);
241
+ }
242
+
243
+ // An icon copy button that confirms the copy with a momentary check.
244
+ function copyBtn(getText, extraCls) {
245
+ var btn = iconBtn("copy", "Copy", function () {
246
+ copyText(getText());
247
+ flashCopied(btn);
248
+ }, extraCls);
249
+ return btn;
250
+ }
251
+
215
252
  function escapeHtml(s) {
216
253
  return String(s == null ? "" : s)
217
254
  .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
@@ -666,7 +703,7 @@
666
703
  var bubble = el("div", "chat-message__bubble", { text: m.content });
667
704
  var actions = el("div", "chat-message__actions chat-message__actions--user", {
668
705
  children: [
669
- iconBtn("copy", "Copy", function () { copyText(m.content); })
706
+ copyBtn(function () { return m.content; })
670
707
  ]
671
708
  });
672
709
  return el("div", "chat-message chat-message--user", { children: [
@@ -732,8 +769,8 @@
732
769
 
733
770
  function buildAiActions(node) {
734
771
  var m = node._msg;
735
- var copy = iconBtn("copy", "Copy", function () {
736
- copyText(node._bubble.innerText || m.content); }, "chat-message__action");
772
+ var copy = copyBtn(function () {
773
+ return node._bubble.innerText || m.content; }, "chat-message__action");
737
774
  var regen = iconBtn("refresh", "Regenerate", function () { regenerate(); },
738
775
  "chat-message__action");
739
776
  var right = el("div", "chat-message__actions-right", { children: [
@@ -1187,6 +1224,9 @@
1187
1224
  R + " .wpai-iconbtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;color:var(--color-icon-tertiary);cursor:pointer;border-radius:var(--radius,8px);transition:all .15s ease}",
1188
1225
  R + " .wpai-iconbtn:hover{background:var(--color-surface-sunken2);color:var(--color-icon-primary)}",
1189
1226
  R + " .wpai-iconbtn.is-active{color:var(--color-brand-secondary,var(--color-brand-primary))}",
1227
+ // Momentary confirmation after a copy — the check reads as success, and this
1228
+ // wins over :hover (defined earlier) so it stays green even under the cursor.
1229
+ R + " .wpai-iconbtn.is-copied,.wpai-iconbtn.is-copied:hover{color:var(--color-success-500,#12b76a);background:transparent}",
1190
1230
  // chips + suggestions
1191
1231
  R + " .suggestions-section{display:grid;gap:16px;border-radius:var(--radius-lg,12px);padding:16px;width:100%}",
1192
1232
  R + " .chips-title{font-size:var(--text-body-sm-size,13px);color:var(--color-text-secondary);margin:0}",
@@ -49,7 +49,11 @@
49
49
  Workpeg._isWorkpegHost = function (url) {
50
50
  var host = urlHost(url);
51
51
  if (!host) return false;
52
- return host.split(".").slice(0, -1).indexOf("workpeg") !== -1;
52
+ // "workpeg" must be the REGISTRABLE label — the one right before the TLD.
53
+ // So workpeg.com and dev.x.workpeg.io match, but foo.workpeg.evil.com does
54
+ // NOT: a bare "workpeg" label buried in an attacker's domain is not us.
55
+ var labels = host.split(".");
56
+ return labels.length >= 2 && labels[labels.length - 2] === "workpeg";
53
57
  };
54
58
 
55
59
  // Core request: NO Workpeg-host guard (workpeg.* uses this directly).
@@ -117,8 +121,10 @@
117
121
  (window.location && window.location.hostname) || ""
118
122
  ).toLowerCase();
119
123
  var labels = host.split(".");
120
- var i = labels.indexOf("workpeg");
121
- return i > 0 ? labels[i - 1] : "";
124
+ // Only a genuine Workpeg host (workpeg as the registrable label) names a
125
+ // namespace the label right before "workpeg".
126
+ var i = labels.length - 2;
127
+ return (i > 0 && labels[i] === "workpeg") ? labels[i - 1] : "";
122
128
  } catch (e) {
123
129
  return "";
124
130
  }
@@ -407,7 +413,7 @@
407
413
  var ep = (window.location && window.location.pathname) || "/";
408
414
  window.location.href =
409
415
  Workpeg.accountsBase.replace(/\/+$/, "") +
410
- "/login?peg=" + encodeURIComponent(ns) + "&ep=" + ep;
416
+ "/login?peg=" + encodeURIComponent(ns) + "&ep=" + encodeURIComponent(ep);
411
417
  } catch (e) { /* no window — nothing to redirect */ }
412
418
  };
413
419
 
@@ -4398,6 +4404,10 @@
4398
4404
  }
4399
4405
  if (props.target) {
4400
4406
  el.target = props.target;
4407
+ // A new browsing context must not get a usable window.opener back to us
4408
+ // (reverse tabnabbing) and shouldn't leak our URL as Referer — match
4409
+ // HtmlView/Markdown, which already set this.
4410
+ if (props.target === "_blank") el.rel = "noopener noreferrer";
4401
4411
  }
4402
4412
  } else {
4403
4413
  el = document.createElement("div");
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import errno
3
4
  import http.server
4
5
  import json
5
6
  import os
@@ -12,6 +13,11 @@ from pathlib import Path
12
13
  from typing import Callable
13
14
 
14
15
 
16
+ class ServeError(Exception):
17
+ """A dev server couldn't start. Carries an actionable message for the CLI to
18
+ print instead of a raw traceback."""
19
+
20
+
15
21
  class _ReusingTCPServer(socketserver.TCPServer):
16
22
  allow_reuse_address = True
17
23
 
@@ -370,11 +376,48 @@ def find_free_port(start: int = 8080) -> int:
370
376
  )
371
377
 
372
378
 
379
+ def _bind_host() -> str:
380
+ """Interface the dev server listens on. Localhost by default, so the app,
381
+ live-reload endpoint and error overlay aren't exposed to the whole LAN; set
382
+ ``WORKPEG_BIND_HOST=0.0.0.0`` to reach it from another device on purpose."""
383
+ return os.environ.get("WORKPEG_BIND_HOST", "127.0.0.1").strip() or "127.0.0.1"
384
+
385
+
386
+ def _bind_tcp_server(server_cls, port: int, handler):
387
+ """Build *server_cls* bound to ``(_bind_host(), port)``, turning a failed
388
+ bind into a clear :class:`ServeError` (never a raw traceback).
389
+
390
+ macOS quirk worth calling out: a non-root process may bind a privileged
391
+ port (<1024) on *all* interfaces (``0.0.0.0``) but NOT on a single address
392
+ like ``127.0.0.1`` — so loopback-binding 443 fails with EACCES unless run
393
+ as root. That's why the message points at sudo / a high port / the
394
+ ``WORKPEG_BIND_HOST`` opt-out rather than just failing."""
395
+ host = _bind_host()
396
+ try:
397
+ return server_cls((host, port), handler)
398
+ except PermissionError as exc:
399
+ if port < 1024:
400
+ raise ServeError(
401
+ f"Can't bind {host}:{port} — port {port} is privileged (<1024). "
402
+ f"Re-run with sudo, use a high port (drop --port to auto-pick, "
403
+ f"or e.g. --port 8443), or set WORKPEG_BIND_HOST=0.0.0.0 to bind "
404
+ f"every interface (also reachable from the LAN)."
405
+ ) from exc
406
+ raise ServeError(f"Not permitted to bind {host}:{port}.") from exc
407
+ except OSError as exc:
408
+ if exc.errno == errno.EADDRINUSE:
409
+ raise ServeError(
410
+ f"{host}:{port} is already in use — stop whatever is using it, "
411
+ f"or pass a different --port."
412
+ ) from exc
413
+ raise
414
+
415
+
373
416
  def _create_server(
374
417
  directory: str, port: int
375
418
  ) -> tuple[_ReusingTCPServer, str]:
376
419
  abs_dir = str(Path(directory).resolve())
377
- server = _ReusingTCPServer(("", port), _make_handler(abs_dir))
420
+ server = _bind_tcp_server(_ReusingTCPServer, port, _make_handler(abs_dir))
378
421
  return server, f"http://localhost:{port}"
379
422
 
380
423
 
@@ -457,7 +500,8 @@ def serve_background(
457
500
  if watch_source is not None:
458
501
  abs_dir = str(Path(directory).resolve())
459
502
  handler_cls, broadcast = _make_reload_handler(abs_dir)
460
- server: socketserver.BaseServer = _ReloadTCPServer(("", port), handler_cls)
503
+ server: socketserver.BaseServer = _bind_tcp_server(
504
+ _ReloadTCPServer, port, handler_cls)
461
505
 
462
506
  from workpeg.ui.watcher import FileWatcher
463
507
 
@@ -510,7 +554,8 @@ def serve_blocking(
510
554
  if watch_source is not None:
511
555
  abs_dir = str(Path(directory).resolve())
512
556
  handler_cls, broadcast = _make_reload_handler(abs_dir)
513
- server: socketserver.BaseServer = _ReloadTCPServer(("", port), handler_cls)
557
+ server: socketserver.BaseServer = _bind_tcp_server(
558
+ _ReloadTCPServer, port, handler_cls)
514
559
 
515
560
  from workpeg.ui.watcher import FileWatcher
516
561
 
@@ -118,6 +118,9 @@ class Link(BaseWidget):
118
118
  p["label"] = self.label
119
119
  if self.target is not None:
120
120
  p["target"] = self.target
121
+ if self.aria_label:
122
+ # camelCase — the key the generator/runtime read (props.ariaLabel).
123
+ p["ariaLabel"] = self.aria_label
121
124
  if self.icon is not None:
122
125
  p["icon"] = self.icon
123
126
  return p
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workpeg
3
- Version: 0.78.0
3
+ Version: 0.79.1
4
4
  Summary: Workpeg function runtime and SDK
5
5
  Author-email: Workpeg <support@workpeg.com>
6
6
  License: MIT
@@ -85,6 +85,37 @@ def test_build_tar_includes_files_and_excludes_junk(tmp_path):
85
85
  assert "mod.pyc" not in names # .pyc suffix excluded
86
86
 
87
87
 
88
+ def test_build_tar_excludes_secrets_and_symlinks(tmp_path):
89
+ (tmp_path / "index.html").write_text("hi")
90
+ # Secrets a developer might leave in an asset dir — never publish these.
91
+ (tmp_path / ".env").write_text("SECRET=1")
92
+ (tmp_path / "server.key").write_text("key")
93
+ (tmp_path / "id_rsa").write_text("key")
94
+ (tmp_path / ".ssh").mkdir()
95
+ (tmp_path / ".ssh" / "config").write_text("x")
96
+
97
+ # A symlink escaping the tree must not be followed.
98
+ outside = tmp_path.parent / "outside.txt"
99
+ outside.write_text("secret")
100
+ symlink_supported = True
101
+ try:
102
+ (tmp_path / "escape.txt").symlink_to(outside)
103
+ except (OSError, NotImplementedError):
104
+ symlink_supported = False
105
+
106
+ data = build_directory_tar_gz_bytes(tmp_path)
107
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
108
+ names = set(tar.getnames())
109
+
110
+ assert "index.html" in names
111
+ assert ".env" not in names
112
+ assert "server.key" not in names
113
+ assert "id_rsa" not in names
114
+ assert not any(n.startswith(".ssh") for n in names)
115
+ if symlink_supported:
116
+ assert "escape.txt" not in names
117
+
118
+
88
119
  def test_build_tar_missing_dir_exits(tmp_path):
89
120
  with pytest.raises(SystemExit):
90
121
  build_directory_tar_gz_bytes(tmp_path / "does-not-exist")
@@ -199,3 +199,83 @@ class TestMetadataContext:
199
199
 
200
200
  for case in test_cases:
201
201
  assert _MetadataContext.verify(case) is True
202
+
203
+
204
+ class TestTableColumnsValidation:
205
+
206
+ def test_accepts_empty_list(self, valid_table_data):
207
+ valid_table_data["columns"] = []
208
+ assert _TableContext.verify(valid_table_data) is True
209
+
210
+ def test_accepts_list_of_dicts(self, valid_table_data):
211
+ valid_table_data["columns"] = [{"name": "a"}, {"name": "b"}]
212
+ assert _TableContext.verify(valid_table_data) is True
213
+
214
+ def test_accepts_none(self, valid_table_data):
215
+ valid_table_data["columns"] = None
216
+ assert _TableContext.verify(valid_table_data) is True
217
+
218
+ def test_rejects_non_list(self, valid_table_data):
219
+ valid_table_data["columns"] = {"not": "a list"}
220
+ with pytest.raises(
221
+ ValueError,
222
+ match="Columns must be a list of column definitions.",
223
+ ):
224
+ _TableContext.verify(valid_table_data)
225
+
226
+ def test_rejects_non_dict_entries(self, valid_table_data):
227
+ valid_table_data["columns"] = ["name", "age"]
228
+ with pytest.raises(
229
+ ValueError,
230
+ match="Each column must be a dictionary.",
231
+ ):
232
+ _TableContext.verify(valid_table_data)
233
+
234
+
235
+ class TestTablePegValidation:
236
+
237
+ def test_accepts_positive_int(self, valid_table_data):
238
+ valid_table_data["peg"] = 10
239
+ assert _TableContext.verify(valid_table_data) is True
240
+
241
+ def test_accepts_namespace_string(self, valid_table_data):
242
+ valid_table_data["peg"] = "workpeg.hr"
243
+ assert _TableContext.verify(valid_table_data) is True
244
+
245
+ def test_accepts_none(self, valid_table_data):
246
+ valid_table_data["peg"] = None
247
+ assert _TableContext.verify(valid_table_data) is True
248
+
249
+ def test_rejects_zero_and_negative(self, valid_table_data):
250
+ for bad in (0, -5):
251
+ valid_table_data["peg"] = bad
252
+ with pytest.raises(
253
+ ValueError,
254
+ match="Peg id must be a positive integer.",
255
+ ):
256
+ _TableContext.verify(valid_table_data)
257
+
258
+ def test_rejects_bool(self, valid_table_data):
259
+ # bool is an int subclass — must not sneak through as a peg id.
260
+ valid_table_data["peg"] = True
261
+ with pytest.raises(
262
+ ValueError,
263
+ match="Peg must be an integer id, a string, or None.",
264
+ ):
265
+ _TableContext.verify(valid_table_data)
266
+
267
+ def test_rejects_wrong_type(self, valid_table_data):
268
+ valid_table_data["peg"] = {"id": 1}
269
+ with pytest.raises(
270
+ ValueError,
271
+ match="Peg must be an integer id, a string, or None.",
272
+ ):
273
+ _TableContext.verify(valid_table_data)
274
+
275
+ def test_rejects_blank_string(self, valid_table_data):
276
+ valid_table_data["peg"] = " "
277
+ with pytest.raises(
278
+ ValueError,
279
+ match="Peg string must be non-empty.",
280
+ ):
281
+ _TableContext.verify(valid_table_data)
@@ -53,7 +53,7 @@ def test_run_with_docker_builds_first(monkeypatch, tmp_path):
53
53
  cmd, check = calls[0]
54
54
  assert cmd == [
55
55
  "docker", "run", "-d",
56
- "-p", "9000:9000",
56
+ "-p", "127.0.0.1:9000:9000",
57
57
  "--network", "workpeg_net",
58
58
  "--restart", "unless-stopped",
59
59
  "built-image",
@@ -132,7 +132,7 @@ def test_run_with_docker_no_build(monkeypatch, tmp_path):
132
132
  cmd, check = calls[0]
133
133
  assert cmd == [
134
134
  "docker", "run", "-d",
135
- "-p", "8000:8000",
135
+ "-p", "127.0.0.1:8000:8000",
136
136
  "--network", "bridge",
137
137
  "--restart", "unless-stopped",
138
138
  "configured-image",
@@ -307,7 +307,7 @@ def test_run_with_docker_expose(monkeypatch, tmp_path):
307
307
  cmd, check = calls[0]
308
308
  assert cmd == [
309
309
  "docker", "run", "-d",
310
- "-p", "9000:9000",
310
+ "-p", "127.0.0.1:9000:9000",
311
311
  "--restart", "unless-stopped",
312
312
  "demo",
313
313
  ]
@@ -137,6 +137,68 @@ def test_build_tar_gz_bytes_excludes_common_junk(tmp_path: Path):
137
137
  assert ".DS_Store" not in paths
138
138
 
139
139
 
140
+ def test_build_tar_gz_bytes_excludes_secrets(tmp_path: Path, capsys):
141
+ # A valid function project...
142
+ (tmp_path / "Dockerfile").write_text("FROM scratch")
143
+ (tmp_path / "app").mkdir()
144
+ (tmp_path / "app" / "main.py").write_text("x=1\n")
145
+
146
+ # ...alongside secrets a careless developer might leave in the tree.
147
+ (tmp_path / ".env").write_text("SECRET=hunter2")
148
+ (tmp_path / ".env.local").write_text("TOKEN=abc")
149
+ (tmp_path / "server.key").write_text("-----BEGIN PRIVATE KEY-----")
150
+ (tmp_path / "tls.pem").write_text("-----BEGIN CERTIFICATE-----")
151
+ (tmp_path / "id_rsa").write_text("ssh-key")
152
+ (tmp_path / "credentials").write_text("[default]")
153
+ (tmp_path / ".ssh").mkdir()
154
+ (tmp_path / ".ssh" / "known_hosts").write_text("host")
155
+
156
+ tgz = submit.build_tar_gz_bytes(tmp_path)
157
+ paths = list_tar_paths(tgz)
158
+
159
+ assert "app/main.py" in paths
160
+ for leaked in (
161
+ ".env", ".env.local", "server.key", "tls.pem",
162
+ "id_rsa", "credentials",
163
+ ):
164
+ assert leaked not in paths, f"{leaked} must never be bundled"
165
+ assert all(not p.startswith(".ssh/") for p in paths)
166
+ # And the developer is told what was withheld.
167
+ assert "looks like a secret" in capsys.readouterr().out
168
+
169
+
170
+ def test_build_tar_gz_bytes_skips_symlinks(tmp_path: Path):
171
+ (tmp_path / "Dockerfile").write_text("FROM scratch")
172
+ (tmp_path / "app").mkdir()
173
+ (tmp_path / "app" / "main.py").write_text("x=1\n")
174
+
175
+ # A symlink pointing at something outside the project must never be followed
176
+ # into the bundle.
177
+ outside = tmp_path.parent / "outside_secret.txt"
178
+ outside.write_text("do not exfiltrate")
179
+ link = tmp_path / "link_out.txt"
180
+ try:
181
+ link.symlink_to(outside)
182
+ except (OSError, NotImplementedError):
183
+ pytest.skip("symlinks unsupported on this platform")
184
+
185
+ tgz = submit.build_tar_gz_bytes(tmp_path)
186
+ paths = list_tar_paths(tgz)
187
+
188
+ assert "app/main.py" in paths
189
+ assert "link_out.txt" not in paths
190
+
191
+
192
+ def test_looks_secret_matches_expected_names():
193
+ for name in (
194
+ ".env", ".ENV", ".env.production", ".netrc", "id_ed25519",
195
+ "key.pem", "cert.PEM", "store.p12", "app.keystore",
196
+ ):
197
+ assert submit._looks_secret(name) is True
198
+ for name in ("main.py", "requirements.txt", "environment.yml", "readme.md"):
199
+ assert submit._looks_secret(name) is False
200
+
201
+
140
202
  def test_build_tar_gz_bytes_bad_path(tmp_path: Path):
141
203
  bad = tmp_path / "does-not-exist"
142
204
  with pytest.raises(SystemExit) as e:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes