@pylonsync/functions 0.3.274 → 0.3.276

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.274",
3
+ "version": "0.3.276",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/runtime.ts CHANGED
@@ -40,9 +40,13 @@ import { join, basename } from "path";
40
40
  // consuming apps type-check this source under node/DOM where the `Bun` global
41
41
  // is absent — so declare the surface we touch (mirrors the ambient in
42
42
  // ssr-client-bundler.ts). Keeps `tsc` clean in a scaffolded app.
43
+ interface BunFileSink {
44
+ write(chunk: string): number;
45
+ flush(): number | Promise<number>;
46
+ }
43
47
  declare const Bun: {
44
48
  write(destination: unknown, input: string): Promise<number>;
45
- stdout: unknown;
49
+ stdout: { writer(): BunFileSink };
46
50
  stderr: unknown;
47
51
  stdin: { stream(): ReadableStream<Uint8Array> };
48
52
  };
@@ -71,9 +75,27 @@ interface ResultMessage {
71
75
  // Send
72
76
  // ---------------------------------------------------------------------------
73
77
 
78
+ // Protocol frames go through ONE FileSink over stdout. The old
79
+ // `Bun.write(Bun.stdout, line)` returned an UNAWAITED promise, so once a
80
+ // runner multiplexes concurrent calls (since the v0.3.259 runner change) two
81
+ // handlers emitting frames larger than PIPE_BUF (~4KB) could race their
82
+ // write() syscalls and interleave on stdout — the host's NDJSON reader then
83
+ // failed to parse the corrupted line and dropped the frame, hanging the caller
84
+ // to its timeout. A single sink appends every frame to one ordered buffer, so
85
+ // writes can't interleave; `send` is the only stdout writer.
86
+ let stdoutSink: BunFileSink | undefined;
74
87
  function send(msg: Record<string, unknown>): void {
75
88
  const line = JSON.stringify(msg) + "\n";
76
- Bun.write(Bun.stdout, line);
89
+ if (!stdoutSink) stdoutSink = Bun.stdout.writer();
90
+ stdoutSink.write(line);
91
+ // flush() can return a Promise under pipe backpressure. The single sink
92
+ // already preserves frame order, so we don't await — just keep a flush
93
+ // error (e.g. EPIPE after the host exits) from becoming an unhandled
94
+ // rejection.
95
+ const flushed = stdoutSink.flush();
96
+ if (flushed && typeof (flushed as Promise<number>).then === "function") {
97
+ (flushed as Promise<number>).then(undefined, () => {});
98
+ }
77
99
  }
78
100
 
79
101
  /**
@@ -910,12 +910,18 @@ async function _doBuildInner(
910
910
  // rather than an inline string in CLIENT_RUNTIME_SOURCE that nothing
911
911
  // could render. Bun pulls it into the shared chunk via the runtime's
912
912
  // static `import { createPylonBoundary } from "./client-boundary"`.
913
+ //
914
+ // Resolve THIS module's directory from the standard `import.meta.url`
915
+ // (via node:url) rather than Bun's `import.meta.dir` — the latter is a
916
+ // Bun-only extension that `tsc` doesn't know about, so any app that
917
+ // imports `@pylonsync/functions` and runs `tsc` would otherwise fail
918
+ // type-checking on this file. `import.meta.url` works in Bun and Node.
919
+ const urlMod: any = await import("node:url");
920
+ const fileURLToPath = urlMod.fileURLToPath ?? urlMod.default?.fileURLToPath;
921
+ const here = path.dirname(fileURLToPath(import.meta.url));
913
922
  fs.writeFileSync(
914
923
  path.join(stageDir, "client-boundary.ts"),
915
- fs.readFileSync(
916
- path.join(import.meta.dir, "ssr-client-boundary.ts"),
917
- "utf8",
918
- ),
924
+ fs.readFileSync(path.join(here, "ssr-client-boundary.ts"), "utf8"),
919
925
  "utf8",
920
926
  );
921
927
 
@@ -11,6 +11,7 @@ import {
11
11
  buildHydrationTail,
12
12
  errorDigest,
13
13
  resolveOrigin,
14
+ isSafeRedirect,
14
15
  asRouteControl,
15
16
  PylonRouteControl,
16
17
  } from "./ssr-runtime";
@@ -284,6 +285,24 @@ describe("renderMetadata head-tag marking (client-nav sync)", () => {
284
285
  expect(titles[0].children).toEqual(["Hello"]);
285
286
  });
286
287
 
288
+ test("emits og:site_name when openGraph.siteName is set", () => {
289
+ const frag = renderMetadata(fakeReact, {
290
+ openGraph: { title: "OG", siteName: "Pylon" },
291
+ });
292
+ const metas: any[] = frag.children.filter((k: any) => k.type === "meta");
293
+ const siteName = metas.find((m) => m.props.property === "og:site_name");
294
+ expect(siteName).toBeDefined();
295
+ expect(siteName.props.content).toBe("Pylon");
296
+ // It's a head tag like the rest, so it must carry the nav-swap marker.
297
+ expect(siteName.props["data-pylon-meta"]).toBe("");
298
+ });
299
+
300
+ test("omits og:site_name when not provided", () => {
301
+ const frag = renderMetadata(fakeReact, { openGraph: { title: "OG" } });
302
+ const metas: any[] = frag.children.filter((k: any) => k.type === "meta");
303
+ expect(metas.some((m) => m.props.property === "og:site_name")).toBe(false);
304
+ });
305
+
287
306
  test("returns null when there's nothing to emit", () => {
288
307
  expect(renderMetadata(fakeReact, undefined)).toBeNull();
289
308
  expect(renderMetadata(fakeReact, {})).toBeNull();
@@ -431,3 +450,42 @@ describe("asRouteControl — route-control normalization (redirect/notFound)", (
431
450
  expect(asRouteControl("PYLON_NOT_FOUND")).toBeNull(); // a bare string, not an error
432
451
  });
433
452
  });
453
+
454
+ describe("isSafeRedirect — open-redirect guard for response.redirect()", () => {
455
+ const trusted = {
456
+ publicUrl: "https://app.example.com",
457
+ trustedHostsCsv: "checkout.stripe.com, other.example.com",
458
+ };
459
+
460
+ test("allows same-site relative paths", () => {
461
+ expect(isSafeRedirect("/", trusted)).toBe(true);
462
+ expect(isSafeRedirect("/dashboard", trusted)).toBe(true);
463
+ expect(isSafeRedirect("/a/b?x=1#h", trusted)).toBe(true);
464
+ // %2F in a path stays a path segment (browsers don't change origin on it).
465
+ expect(isSafeRedirect("/%2F%2Fevil.com", trusted)).toBe(true);
466
+ });
467
+
468
+ test("rejects the classic open-redirect vectors", () => {
469
+ expect(isSafeRedirect("//evil.com", trusted)).toBe(false); // protocol-relative
470
+ expect(isSafeRedirect("/\\evil.com", trusted)).toBe(false); // backslash trick
471
+ expect(isSafeRedirect("\\/evil.com", trusted)).toBe(false);
472
+ expect(isSafeRedirect("https://evil.com", trusted)).toBe(false); // other origin
473
+ expect(isSafeRedirect("https://evil.com/path", trusted)).toBe(false);
474
+ expect(isSafeRedirect("javascript:alert(1)", trusted)).toBe(false);
475
+ expect(isSafeRedirect("data:text/html,x", trusted)).toBe(false);
476
+ expect(isSafeRedirect("dashboard", trusted)).toBe(false); // bare-relative → reject
477
+ });
478
+
479
+ test("allows absolute URLs to a trusted host (public origin / PYLON_TRUSTED_HOSTS / loopback)", () => {
480
+ expect(isSafeRedirect("https://app.example.com/next", trusted)).toBe(true);
481
+ expect(isSafeRedirect("https://checkout.stripe.com/pay/abc", trusted)).toBe(true);
482
+ expect(isSafeRedirect("http://localhost:3000/x", trusted)).toBe(true);
483
+ expect(isSafeRedirect("http://127.0.0.1/x", trusted)).toBe(true);
484
+ });
485
+
486
+ test("with no trusted config, only relative paths + loopback are allowed", () => {
487
+ expect(isSafeRedirect("/ok", {})).toBe(true);
488
+ expect(isSafeRedirect("http://localhost/x", {})).toBe(true);
489
+ expect(isSafeRedirect("https://app.example.com/x", {})).toBe(false);
490
+ });
491
+ });
@@ -264,6 +264,24 @@ export function makeResponseController(
264
264
  if (!Number.isInteger(status) || status < 300 || status > 399) {
265
265
  throw new Error(`pylon ssr: redirect() status must be 3xx, got ${status}`);
266
266
  }
267
+ // SECURITY: refuse open redirects. A relative path or a trusted-host
268
+ // absolute is fine; anything else (//evil.com, https://evil.com,
269
+ // javascript:, …) throws — surfaced as a render error, never a silent
270
+ // off-site redirect from `redirect(request-derived-url)`.
271
+ const env = (globalThis as any).process?.env ?? {};
272
+ if (
273
+ !isSafeRedirect(url, {
274
+ publicUrl: env.PYLON_PUBLIC_URL,
275
+ canonicalHost: env.PYLON_CANONICAL_HOST,
276
+ trustedHostsCsv: env.PYLON_TRUSTED_HOSTS,
277
+ })
278
+ ) {
279
+ throw new Error(
280
+ `pylon ssr: redirect() refused an untrusted target ${JSON.stringify(url)} — ` +
281
+ `use a same-site path (e.g. "/dashboard") or add the host to ` +
282
+ `PYLON_TRUSTED_HOSTS. (Refusing to emit an open redirect.)`,
283
+ );
284
+ }
267
285
  const e = new PylonRouteControl("redirect");
268
286
  e.url = url;
269
287
  e.redirectStatus = status;
@@ -339,6 +357,9 @@ export interface SsrMetadata {
339
357
  imageAlt?: string;
340
358
  url?: string;
341
359
  type?: string;
360
+ /** `og:site_name` — the brand the page belongs to (e.g. "Pylon").
361
+ * Discord and other unfurlers show this above the title. */
362
+ siteName?: string;
342
363
  };
343
364
  twitter?: {
344
365
  card?: string;
@@ -399,6 +420,7 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
399
420
  }
400
421
  if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
401
422
  if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
423
+ if (og.siteName) kids.push(el("meta", { key: "ogsn", property: "og:site_name", content: og.siteName }));
402
424
  }
403
425
  const tw = m.twitter;
404
426
  if (tw) {
@@ -700,6 +722,47 @@ function resolveRequestOrigin(headers: Record<string, string> | undefined): stri
700
722
  });
701
723
  }
702
724
 
725
+ /** SECURITY: validate a `response.redirect()` target to prevent OPEN REDIRECTS.
726
+ * Mirrors the OAuth-layer `validate_trusted_redirect` (crates/auth). A relative
727
+ * same-site path is always allowed; an absolute URL only when it's http(s) to a
728
+ * trusted host — the app's public/canonical origin, a `PYLON_TRUSTED_HOSTS`
729
+ * entry, or loopback. Everything else is rejected: protocol-relative
730
+ * `//evil.com`, backslash tricks (`/\evil.com` — browsers normalize `\`→`/`),
731
+ * other-origin absolutes, and `javascript:`/`data:` schemes. So the natural
732
+ * `response.redirect(searchParams.get("next"))` can't be turned into an
733
+ * off-site redirect by attacker-supplied input. Exported for tests. */
734
+ export function isSafeRedirect(
735
+ url: string,
736
+ opts: { publicUrl?: string; canonicalHost?: string; trustedHostsCsv?: string },
737
+ ): boolean {
738
+ // Relative same-site path: exactly one leading slash. Reject protocol-
739
+ // relative (`//host`) and backslash variants that resolve cross-origin.
740
+ if (url.startsWith("/")) {
741
+ return url.length < 2 || (url[1] !== "/" && url[1] !== "\\");
742
+ }
743
+ if (url.startsWith("\\")) return false; // `\\host` / `\/host`
744
+ // Absolute: only http(s) to a trusted host. A bare-relative ("dashboard"),
745
+ // opaque, or unparseable target throws here → rejected (fail closed).
746
+ let parsed: URL;
747
+ try {
748
+ parsed = new URL(url);
749
+ } catch {
750
+ return false;
751
+ }
752
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
753
+ const host = parsed.host.toLowerCase();
754
+ if (LOOPBACK_HOST.test(host)) return true;
755
+ const allow = new Set<string>();
756
+ const add = (v: string) => {
757
+ const h = hostOf(v);
758
+ if (h) allow.add(h);
759
+ };
760
+ add(opts.publicUrl || "");
761
+ add(opts.canonicalHost || "");
762
+ for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
763
+ return allow.has(host);
764
+ }
765
+
703
766
  /** Merge auto-discovered social-card images into a page's metadata. An
704
767
  * explicit `openGraph.image` / `twitter.image` always wins; otherwise a
705
768
  * colocated `opengraph-image.*` (and `twitter-image.*`, falling back to
package/src/types.ts CHANGED
@@ -293,6 +293,14 @@ export interface Scheduler {
293
293
  * webhook). Available on action ctx only — sending email is external
294
294
  * I/O, not allowed in mutation transactions.
295
295
  *
296
+ * This is the APP email channel (`PYLON_EMAIL_*`): arbitrary recipient
297
+ * and body, so it must be the app's own provider. It is deliberately
298
+ * separate from Pylon's built-in auth emails (codes / password reset /
299
+ * invitations), which send via a `PYLON_AUTH_EMAIL_*` channel. On Pylon
300
+ * Cloud the auth channel may be a shared, locked-down platform key, so
301
+ * `ctx.email` stays inert until you set `PYLON_EMAIL_*` yourself — the
302
+ * shared auth key can never be used to send arbitrary mail.
303
+ *
296
304
  * The runtime owns provider config + credentials; functions only
297
305
  * supply the (to, subject, body) tuple. Failures are surfaced as
298
306
  * thrown errors; on success the return is void.