workpeg 0.78.0__tar.gz → 0.79.0__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.0}/PKG-INFO +1 -1
  2. {workpeg-0.78.0 → workpeg-0.79.0}/pyproject.toml +1 -1
  3. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/cdn.py +10 -0
  4. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/context.py +21 -2
  5. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/run.py +4 -1
  6. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/submit.py +40 -0
  7. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/ast_parser.py +6 -5
  8. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/fake_cloud.py +28 -0
  9. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/runtime/ai.js +43 -3
  10. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/runtime/workpeg-runtime.js +14 -4
  11. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/serve.py +10 -3
  12. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/interactive.py +3 -0
  13. {workpeg-0.78.0 → workpeg-0.79.0/src/workpeg.egg-info}/PKG-INFO +1 -1
  14. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_cdn.py +31 -0
  15. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_context.py +80 -0
  16. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_run.py +3 -3
  17. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_submit.py +62 -0
  18. {workpeg-0.78.0 → workpeg-0.79.0}/LICENSE +0 -0
  19. {workpeg-0.78.0 → workpeg-0.79.0}/MANIFEST.in +0 -0
  20. {workpeg-0.78.0 → workpeg-0.79.0}/README.md +0 -0
  21. {workpeg-0.78.0 → workpeg-0.79.0}/setup.cfg +0 -0
  22. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/__init__.py +0 -0
  23. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/build.py +0 -0
  24. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/cli.py +0 -0
  25. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/config.py +0 -0
  26. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/create_new.py +0 -0
  27. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/runtime.py +0 -0
  28. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/__init__.py +0 -0
  29. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/Dockerfile +0 -0
  30. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/LICENSE +0 -0
  31. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/README.md +0 -0
  32. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/app/__init__.py +0 -0
  33. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/app/main.py +0 -0
  34. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/requirements.txt +0 -0
  35. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/__init__.py +0 -0
  36. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/actions.py +0 -0
  37. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/app.py +0 -0
  38. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/build.py +0 -0
  39. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/__init__.py +0 -0
  40. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/ir.py +0 -0
  41. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/js_generator.py +0 -0
  42. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/create.py +0 -0
  43. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/minify.py +0 -0
  44. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/runtime/pegment.css +0 -0
  45. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/scaffold.py +0 -0
  46. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/state.py +0 -0
  47. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/validators.py +0 -0
  48. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/watcher.py +0 -0
  49. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/__init__.py +0 -0
  50. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/base.py +0 -0
  51. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/canvas.py +0 -0
  52. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/display.py +0 -0
  53. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/forms.py +0 -0
  54. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/futures.py +0 -0
  55. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/gestures.py +0 -0
  56. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/layout.py +0 -0
  57. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/utils.py +0 -0
  58. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/SOURCES.txt +0 -0
  59. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/dependency_links.txt +0 -0
  60. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/entry_points.txt +0 -0
  61. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/requires.txt +0 -0
  62. {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/top_level.txt +0 -0
  63. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_build.py +0 -0
  64. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_changelog.py +0 -0
  65. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_changelog_guard.py +0 -0
  66. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_cli.py +0 -0
  67. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_config.py +0 -0
  68. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_create_new.py +0 -0
  69. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_release_script.py +0 -0
  70. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_runtime.py +0 -0
  71. {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_utils.py +0 -0
  72. {workpeg-0.78.0 → workpeg-0.79.0}/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.0
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.0"
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()
@@ -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
 
@@ -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");
@@ -370,11 +370,18 @@ def find_free_port(start: int = 8080) -> int:
370
370
  )
371
371
 
372
372
 
373
+ def _bind_host() -> str:
374
+ """Interface the dev server listens on. Localhost by default, so the app,
375
+ live-reload endpoint and error overlay aren't exposed to the whole LAN; set
376
+ ``WORKPEG_BIND_HOST=0.0.0.0`` to reach it from another device on purpose."""
377
+ return os.environ.get("WORKPEG_BIND_HOST", "127.0.0.1").strip() or "127.0.0.1"
378
+
379
+
373
380
  def _create_server(
374
381
  directory: str, port: int
375
382
  ) -> tuple[_ReusingTCPServer, str]:
376
383
  abs_dir = str(Path(directory).resolve())
377
- server = _ReusingTCPServer(("", port), _make_handler(abs_dir))
384
+ server = _ReusingTCPServer((_bind_host(), port), _make_handler(abs_dir))
378
385
  return server, f"http://localhost:{port}"
379
386
 
380
387
 
@@ -457,7 +464,7 @@ def serve_background(
457
464
  if watch_source is not None:
458
465
  abs_dir = str(Path(directory).resolve())
459
466
  handler_cls, broadcast = _make_reload_handler(abs_dir)
460
- server: socketserver.BaseServer = _ReloadTCPServer(("", port), handler_cls)
467
+ server: socketserver.BaseServer = _ReloadTCPServer((_bind_host(), port), handler_cls)
461
468
 
462
469
  from workpeg.ui.watcher import FileWatcher
463
470
 
@@ -510,7 +517,7 @@ def serve_blocking(
510
517
  if watch_source is not None:
511
518
  abs_dir = str(Path(directory).resolve())
512
519
  handler_cls, broadcast = _make_reload_handler(abs_dir)
513
- server: socketserver.BaseServer = _ReloadTCPServer(("", port), handler_cls)
520
+ server: socketserver.BaseServer = _ReloadTCPServer((_bind_host(), port), handler_cls)
514
521
 
515
522
  from workpeg.ui.watcher import FileWatcher
516
523
 
@@ -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.0
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
File without changes