diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index b37c4146..74f16fa2 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -673,6 +673,18 @@ "tier": "optional", "icon": "/assets/img/app-icons/angor-green.png", "repoUrl": "https://github.com/hoytech/strfry" + }, + { + "id": "justworks", + "title": "Just Works", + "version": "0.1.0", + "description": "Turn your existing business links into a website with Just Works. Open your website editor and business tools from one lightweight launcher. Uses the hosted Just Works services; an internet connection is required.", + "icon": "/assets/img/app-icons/justworks.svg", + "author": "Just Works contributors", + "category": "business", + "tier": "optional", + "repoUrl": "https://github.com/bencoin21/justworks.cash", + "dockerImage": "localhost/archipelago-justworks:0.1.0" } ] } diff --git a/apps/justworks/manifest.yml b/apps/justworks/manifest.yml new file mode 100644 index 00000000..0b98c85d --- /dev/null +++ b/apps/justworks/manifest.yml @@ -0,0 +1,62 @@ +app: + id: justworks + name: Just Works + version: 0.1.0 + description: >- + Turn your existing business links into a website with Just Works. + Open your website editor and business tools from one lightweight launcher. + Uses the hosted Just Works services; an internet connection is required. + category: business + upstream: + kind: manual + url: https://github.com/bencoin21/justworks-business + container: + build: + context: /opt/archipelago/docker/justworks + dockerfile: Dockerfile + tag: localhost/archipelago-justworks:0.1.0 + network: archy-net + resources: + cpu_limit: 1 + memory_limit: 256Mi + disk_limit: 128Mi + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: isolated + ports: + - host: 8340 + container: 8340 + protocol: tcp + bind: 127.0.0.1 + auth: gated + session_passthrough: true + volumes: + - type: bind + source: /var/lib/archipelago/justworks + target: /data + environment: [] + health_check: + type: http + endpoint: http://127.0.0.1:8340 + path: /healthz + interval: 30s + timeout: 5s + retries: 3 + interfaces: + main: + name: Just Works + description: Website and business tools + type: ui + port: 8340 + protocol: http + path: / + metadata: + icon: /assets/img/app-icons/justworks.svg + author: Just Works contributors + repo: https://github.com/bencoin21/justworks.cash + tier: optional + launch: + requires_host_frame: true + open_in_new_tab: false diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 21b122eb..1647a33b 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -33,6 +33,7 @@ pub const APP_LAUNCH_PORTS: &[u16] = &[ 8334, 8336, 8337, + 8340, 8888, 8998, 8999, diff --git a/docker/justworks/.dockerignore b/docker/justworks/.dockerignore new file mode 100644 index 00000000..d89fc386 --- /dev/null +++ b/docker/justworks/.dockerignore @@ -0,0 +1,2 @@ +__pycache__ +test_*.py diff --git a/docker/justworks/Dockerfile b/docker/justworks/Dockerfile new file mode 100644 index 00000000..1bb63b3b --- /dev/null +++ b/docker/justworks/Dockerfile @@ -0,0 +1,11 @@ +FROM docker.io/library/python:3.13-alpine@sha256:2d9aefe2fef018a7eb2c13064c89c71929800fd2e5dccdbf52ea5da5bb8d929a +RUN apk add --no-cache nodejs +WORKDIR /app +COPY server.py payments.py boot.py ./ +COPY business ./business +COPY public ./public +COPY UPSTREAM-LICENSE /usr/share/licenses/justworks/LICENSE +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +USER 65534:65534 +EXPOSE 8340 +CMD ["python3", "boot.py"] diff --git a/docker/justworks/README.txt b/docker/justworks/README.txt new file mode 100644 index 00000000..77365853 --- /dev/null +++ b/docker/justworks/README.txt @@ -0,0 +1,113 @@ +Just Works for Archipelago — app and demo + +Scope + One optional Archipelago app. First launch embeds the original Just Works home. + After a published website is connected, show Website and Business tools. + Dashboard > Setup > Instant business site contains the native OS guided flow. + The split launcher appears after the first connected website. A small Business + tools switch is always available at bottom-left, with a Website return path. + It uses the shared ring-button style, building/person icons, and icon-only + layout on phones to leave room for the upstream menu at bottom-right. + No changes to, publication in, or deployment of the existing Just Works repos. + Native wallet keys never leave the node. Website owner keys can be imported + into a separate native business identity. Reuse requires the node password and + explicit key-sharing approval; only its nsec is forwarded to Just Works for + the selected login/payment action. No secrets are saved in browser storage. + +Real-service mode + The original hosted creator/editor and an isolated Business copy load inside + the app iframe. Business data persists in /var/lib/archipelago/justworks. + The same wrapper is used when Archipelago opens the app in its own tab. + Owner authorization is verified by Just Works. Connecting a public link is not + ownership proof. + The app's GET /api/site checks the existing Core public API for published status. + It returns only a bounded display name, public identity and fixed-origin links. + Outbound reads reject redirects and are bounded in size/time; there is no generic + proxy or client-controlled upstream host. Only the page slug is saved in browser + storage. Another browser must connect its own link. Removing the app/link does + not delete the hosted business or its data. Internet access is required. + Isolated Business discovery resolves published sites directly through Core. + It uses the connected npub automatically and preserves the upstream UI. + No demo recipient fallback is enabled; legacy hardcoded order checkout is + blocked. The website payment page and merchant-address quick-pay are separate. + The local proxy has a fixed loopback destination and requires same-origin writes. + +Demo mode + The dashboard demo shows the native guide and embeds the original hosted site. + It does not simulate a replacement website editor or Business interface. + Connecting a published page requires the installed adapter on a real node. + +Setup completion + The native guide checks the published page through a hidden app-origin bridge. + Requests are correlated by random IDs; replies require the exact iframe window + and origin. Merely opening the app never completes the guide. The bridge saves + only the public slug; storage failure does not mark setup complete. + The launcher rechecks publication before revealing Business tools. + +Native wallet connection + The third OS guide step calls existing wallet.ecash-lnaddress; no LND/Bitcoin + dependency, additional wallet app or custom mint is installed. The native + @minibits.cash address is displayed automatically, not typed by the merchant. + Owner login/key authorization is entered for Connect wallet & publish. Core + verifies it; the adapter changes only the payment field using existing APIs. + POST /api/payment-connect requires exact same-origin JSON and a bounded body; + fixed-origin requests refuse redirects. No credentials are logged or stored. + Success requires Core's verified artifact, published status and a fresh public + record whose lud16 exactly matches the native address. Partial save/publish + failures remain incomplete and offer retry. No payment is sent by setup. + Hosted website tip flow reads this public record. Legacy Business order flows + with hardcoded destinations are not changed or certified by this integration. + Wallet Receive > Ecash uses the existing claim/retry flow. Node ecash still + depends on its existing hosted address/mint service; this is not self-hosting + the mint or receiving into LND. Existing Business merchant discovery applies. + +Runtime + Python adapter and isolated Business service, non-root, read-only root, no + capabilities, 256 MiB limit. + Port 8340 is loopback-bound behind Archipelago's app gate. The manifest carries + the icon, UI classification, build context and host-frame integration. + Run locally: JW_HOST=127.0.0.1 JW_PORT=18340 python3 server.py + Build: docker build -t localhost/archipelago-justworks:0.1.0 docker/justworks + +Design-system provenance + Vendored from bencoin21/justworks-business at + 0aec5c44fb90f8642afab8f2e9d4c13364af1ae0 (2026-09-20), MIT. + The original tokens, design-system.css imports, logo renderer, base/components, + layout presets and mobile chrome are retained. The only vendor adaptation is + ds/frame.js configureNavigation(): local launcher destinations replace + the upstream customer menu. launcher.css owns the new page composition. + The dashboard SVG uses the official portal paths in white, + normalized with scripts/normalize-app-icon.py (12% margin). Archipelago adds + its tile plate. No replacement brand glyph or external artwork is used. + Just Works MIT notice: UPSTREAM-LICENSE and public/licenses/JustWorks-LICENSE. + Syne and Geist font licenses: public/fonts/*-LICENSE. + +Verification + python3 -m unittest discover -s docker/justworks -p 'test_*.py' + From neode-ui: npm test -- src/views/goals/__tests__/justworksCompletion.test.ts + src/views/goals/__tests__/goalStepActions.test.ts src/stores/__tests__/goals.test.ts + From neode-ui: npm run build + bash scripts/validate-app-manifest.sh apps/justworks/manifest.yml + python3 scripts/check-app-catalog-drift.py --release --strict + Local shared browser scenarios/reports live outside the repository, under + ~/.local/share/browser-check/projects/archy-justworks/ and /tmp/justworks-*. + Browser fixture tests perform no production writes. A container read-only check + used the existing public barreirinha-bar-cafe-3 site; no credentials were used. + +Deployment boundary + Deployed and verified as an Archipelago demo on yaya. Not an OTA, catalog + publication or change to the hosted Just Works deployment. Shared-platform release/integration remains with the + existing platform owner; preserve their concurrent work when integrating. + +Native key rollout + Backend additions: identity.capabilities and password-protected identity.import-nostr. + Import verifies the expected npub, creates a separate business identity, reuses + an existing matching business identity, and never replaces another identity. + The native guide checks capabilities before collecting an import key. Nodes + without the new RPC retain direct owner-key/login access and a clear update note. + Export uses existing identity.export-keys after node-password verification; + other exported secret fields are never forwarded to the app. + The original hosted CMS has no session injection mechanism. Its own sign-in + remains separate. Saved credentials support payment setup and local Business. + Source: isolated justworks-business-archipelago worktree, upstream 0aec5c4. + Business LICENSE and bundled upstream assets retained in business/. diff --git a/docker/justworks/UPSTREAM-LICENSE b/docker/justworks/UPSTREAM-LICENSE new file mode 100644 index 00000000..02fe5437 --- /dev/null +++ b/docker/justworks/UPSTREAM-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Napplet contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker/justworks/boot.py b/docker/justworks/boot.py new file mode 100644 index 00000000..6881356d --- /dev/null +++ b/docker/justworks/boot.py @@ -0,0 +1,17 @@ +"""Run the app adapter and isolated Business service with shared lifecycle.""" +import os +import subprocess +import time +children = [] +try: + children.append(subprocess.Popen(['python3', 'business/server.py'], env={**os.environ, + 'JWB_HOST':'127.0.0.1', 'JWB_PORT':'4310', 'JWB_BASE_PATH':'/business-demo', + 'JWB_DATA_DIR':'/data/business', 'JWB_AUTH_MODE':'enforce', + 'JWB_COOKIE_SECURE':'0', 'JWB_ARCHIPELAGO':'1'})) + children.append(subprocess.Popen(['python3', 'server.py'])) + while all(child.poll() is None for child in children): time.sleep(0.5) +finally: + for child in children: + if child.poll() is None: child.terminate() + for child in children: child.wait() +raise SystemExit(1) diff --git a/docker/justworks/business/LICENSE b/docker/justworks/business/LICENSE new file mode 100644 index 00000000..02fe5437 --- /dev/null +++ b/docker/justworks/business/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Napplet contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker/justworks/business/business_rollout.py b/docker/justworks/business/business_rollout.py new file mode 100644 index 00000000..72638c5c --- /dev/null +++ b/docker/justworks/business/business_rollout.py @@ -0,0 +1,275 @@ +"""Operational rollout and browser-bound Business SSO, not a new identity provider.""" +import hashlib +import json +import os +import re +import secrets +import time +from http.cookies import SimpleCookie +from urllib.parse import urlsplit, urlencode + +import business_security as security + +GROUPS = ("merchant_config", "staff_reads", "staff_mutations") +DEFAULT_ORIGINS = ("https://business.justworks.cash", "https://pos.justworks.cash") +SSO_COOKIE = "__Host-jwb_exchange" +TABLES = ("products", "tables", "orders", "payments", "quick_payments", "bookings", "merchant_modules") + + +def migrate(conn): + conn.executescript(""" + CREATE TABLE IF NOT EXISTS business_session_audiences ( + token_hash TEXT PRIMARY KEY, audience TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS business_identity_verifications ( + npub TEXT PRIMARY KEY, slug TEXT NOT NULL, verified_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS business_merchant_aliases ( + alias TEXT PRIMARY KEY, npub TEXT NOT NULL UNIQUE, verified_slug TEXT NOT NULL, + migrated_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS business_rollout_approvals ( + route_group TEXT NOT NULL, npub TEXT NOT NULL, approved_at INTEGER NOT NULL, + PRIMARY KEY(route_group,npub)); + CREATE TABLE IF NOT EXISTS business_exchanges ( + request_hash TEXT PRIMARY KEY, binder_hash TEXT NOT NULL, npub TEXT NOT NULL, + audience TEXT NOT NULL, return_path TEXT NOT NULL, expires_at INTEGER NOT NULL, + code_hash TEXT UNIQUE, source_session TEXT, consumed INTEGER NOT NULL DEFAULT 0); + CREATE INDEX IF NOT EXISTS business_exchanges_expiry ON business_exchanges(expires_at); + """) + + +def legacy_merchant(conn): + row = conn.execute("SELECT npub FROM business_merchant_aliases WHERE alias='localpub-demo'").fetchone() + return row[0] if row else "localpub-demo" + + +def group_for(method, path): + if method == "GET" and path in security.PRIVILEGED_GET | {"/api/business"}: + return "staff_reads" + if method == "POST" and path in security.PRIVILEGED_POST: + return "merchant_config" + if method == "POST" and re.fullmatch(r"/api/(orders|payments|bookings)/[^/]+/(status|acknowledge)", path): + return "staff_mutations" + return None + + +def policy(): + groups = json.loads(os.environ.get("JWB_AUTH_GROUPS", "{}")) + if not isinstance(groups, dict) or any(k not in GROUPS or v not in ("shadow", "enforce") for k, v in groups.items()): + raise ValueError("invalid JWB_AUTH_GROUPS") + canaries = set(filter(None, (x.strip() for x in os.environ.get("JWB_AUTH_CANARIES", "").split(",")))) + for merchant in canaries: + security.canonical_npub(merchant) + return groups, canaries + + +def effective_mode(conn, method, path, merchant): + global_mode = security.mode() + # Existing global enforce is retained for regression/local tests only. + # Operators use shadow + groups; force-shadow overrides all group settings. + if global_mode == "off" or os.environ.get("JWB_AUTH_FORCE_SHADOW", "0") == "1": + return "off" if global_mode == "off" else "shadow" + if global_mode == "enforce": + return "enforce" + groups, canaries = policy() + group = group_for(method, path) + if groups.get(group) != "enforce" or merchant not in canaries: + return "shadow" + approved = conn.execute("SELECT 1 FROM business_rollout_approvals WHERE route_group=? AND npub=?", (group, merchant)).fetchone() + return "enforce" if approved else "shadow" + + +def verification(conn, npub, slug): + conn.execute("INSERT OR REPLACE INTO business_identity_verifications VALUES (?,?,?)", (security.canonical_npub(npub), slug, int(time.time()))) + conn.commit() + + +def migration_plan(conn, npub, slug): + npub = security.canonical_npub(npub) + existing = conn.execute("SELECT * FROM business_merchant_aliases WHERE alias='localpub-demo'").fetchone() + if existing: + if existing["npub"] != npub or existing["verified_slug"] != slug: + raise ValueError("legacy merchant already mapped to a different identity") + return {"already_applied": True, "source": "localpub-demo", "target": npub, "counts": {}} + verified = conn.execute("SELECT slug,verified_at FROM business_identity_verifications WHERE npub=?", (npub,)).fetchone() + if not verified or verified[0] != slug or verified[1] < int(time.time()) - 86400: + raise ValueError("recent Core-verified owner login and matching slug required") + if not conn.execute("SELECT 1 FROM merchant WHERE id='localpub-demo'").fetchone(): + raise ValueError("legacy merchant missing") + counts = {table: conn.execute(f"SELECT COUNT(*) FROM {table} WHERE merchant_id='localpub-demo'").fetchone()[0] for table in TABLES} + # Never merge unrelated tenant records or choose which configuration wins. + if conn.execute("SELECT 1 FROM merchant WHERE id=?", (npub,)).fetchone() or any( + conn.execute(f"SELECT 1 FROM {table} WHERE merchant_id=? LIMIT 1", (npub,)).fetchone() for table in TABLES + ): + raise ValueError("target has existing data; explicit reconciliation required before migration") + return {"already_applied": False, "source": "localpub-demo", "target": npub, "counts": counts} + + +def migrate_legacy(conn, npub, slug, apply=False): + conn.execute("BEGIN IMMEDIATE") + try: + report = migration_plan(conn, npub, slug) + if apply and not report["already_applied"]: + conn.execute("UPDATE merchant SET id=? WHERE id='localpub-demo'", (npub,)) + for table in TABLES: + conn.execute(f"UPDATE {table} SET merchant_id=? WHERE merchant_id='localpub-demo'", (npub,)) + conn.execute("INSERT INTO business_merchant_aliases VALUES ('localpub-demo',?,?,?)", (npub, slug, int(time.time()))) + conn.execute("INSERT INTO business_audit(occurred_at,event,route,merchant,actor,reason) VALUES (?,'merchant_migrated','operator',?,?,'explicit_verified_mapping')", (int(time.time()), npub, npub)) + conn.commit() + else: + conn.rollback() + return {**report, "applied": apply} + except Exception: + conn.rollback() + raise + + +def origins(): + values = tuple(x.strip() for x in os.environ.get("JWB_SSO_ORIGINS", ",".join(DEFAULT_ORIGINS)).split(",") if x.strip()) + for value in values: + p = urlsplit(value) + if p.scheme != "https" or not p.hostname or p.username or p.password or p.port or p.path or p.query or p.fragment or value != f"https://{p.hostname}" or not p.hostname.endswith(".justworks.cash"): + raise ValueError("SSO origins must be exact HTTPS JustWorks subdomains") + if DEFAULT_ORIGINS[0] not in values: + raise ValueError("business.justworks.cash must remain the Business session hub") + return values + + +def request_origin(headers): + host = headers.get("Host", "").lower() + origin = "https://" + host + if origin not in origins(): + raise ValueError("SSO host not allowed") + if headers.get("Origin") != origin or headers.get("Sec-Fetch-Site") == "cross-site": + raise ValueError("explicit same-origin request required") + return origin + + +def enabled(): + return os.environ.get("JWB_SSO_ENABLED", "0") == "1" + + +def safe_return(value): + if not isinstance(value, str) or len(value) > 2000 or not value.startswith("/") or value.startswith("//") or "\\" in value or any(ord(c) < 32 for c in value): + raise ValueError("invalid return path") + p = urlsplit(value) + if p.scheme or p.netloc or p.fragment or "%" in p.path: + raise ValueError("invalid return path") + return value + + +def opaque(value): + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", value): + raise ValueError("invalid exchange") + return value + + +def exchange_cookie(token="", ttl=0): + return f"{SSO_COOKIE}={token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={ttl}" + + +def start(conn, values, headers): + audience = request_origin(headers) + npub = security.canonical_npub(values.get("npub")) + target = safe_return(values.get("return_path", "/")) + request_id, binder = secrets.token_urlsafe(32), secrets.token_urlsafe(32) + timestamp = int(time.time()) + conn.execute("DELETE FROM business_exchanges WHERE expires_at<=? OR consumed=1", (timestamp,)) + if conn.execute("SELECT COUNT(*) FROM business_exchanges").fetchone()[0] >= 1000: + raise ValueError("exchange capacity reached") + conn.execute("INSERT INTO business_exchanges(request_hash,binder_hash,npub,audience,return_path,expires_at) VALUES (?,?,?,?,?,?)", (security.token_hash(request_id), security.token_hash(binder), npub, audience, target, timestamp + 180)) + conn.commit() + return {"request_id": request_id, "authorize_url": DEFAULT_ORIGINS[0] + "/business-sso?" + urlencode({"npub": npub}) + "#" + request_id, "source_origin": DEFAULT_ORIGINS[0]}, exchange_cookie(binder, 180) + + +def pending(conn, request_id): + row = conn.execute("SELECT * FROM business_exchanges WHERE request_hash=? AND expires_at>? AND consumed=0", (security.token_hash(opaque(request_id)), int(time.time()))).fetchone() + if not row: + raise ValueError("exchange expired or used") + return row + + +def source_details(conn, values, headers, approve=False): + if request_origin(headers) != DEFAULT_ORIGINS[0]: + raise ValueError("only Business session hub may approve") + row = pending(conn, values.get("request_id")) + current = security.session(conn, headers) + if not current or current["npub"] != row["npub"]: + raise ValueError("matching verified Business login required") + if row["audience"] not in origins(): + raise ValueError("audience no longer allowed") + result = {"audience": row["audience"], "npub": row["npub"]} + if approve: + if row["code_hash"]: + raise ValueError("exchange already approved") + code = secrets.token_urlsafe(32) + cookie = SimpleCookie(); cookie.load(headers.get("Cookie", "")) + source_hash = security.token_hash(cookie[security.COOKIE].value) + conn.execute("UPDATE business_exchanges SET code_hash=?,source_session=?,expires_at=? WHERE request_hash=?", (security.token_hash(code), source_hash, min(row["expires_at"], int(time.time()) + 60), row["request_hash"])) + conn.commit() + result["code"] = code + security.audit(conn, "exchange_approved", "/api/business-sso/approve", row["npub"], current["npub"]) + return result + + +def complete(conn, values, headers): + audience = request_origin(headers) + row = pending(conn, values.get("request_id")) + cookie = SimpleCookie(); cookie.load(headers.get("Cookie", "")) + binder = cookie[SSO_COOKIE].value if SSO_COOKIE in cookie else "" + code_hash = security.token_hash(opaque(values.get("code"))) + source = conn.execute("SELECT s.npub FROM business_sessions s JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.token_hash=? AND s.expires_at>? AND a.audience=?", (row["source_session"], int(time.time()), DEFAULT_ORIGINS[0][8:])).fetchone() + if row["audience"] != audience or not secrets.compare_digest(row["binder_hash"], security.token_hash(binder)) or not secrets.compare_digest(row["code_hash"] or "", code_hash) or not source or source[0] != row["npub"]: + raise ValueError("exchange binding mismatch") + # The caller serializes HTTP operations with the application lock. This UPDATE + # also makes consumption conditional for other operator/SQLite clients. + updated = conn.execute("UPDATE business_exchanges SET consumed=1 WHERE request_hash=? AND consumed=0", (row["request_hash"],)) + if updated.rowcount != 1: + raise ValueError("exchange already used") + conn.commit() + session_cookie, expires = security.issue(conn, row["npub"], headers) + security.audit(conn, "exchange_completed", "/api/business-sso/complete", row["npub"], row["npub"]) + return {"ok": True, "npub": row["npub"], "expires_at": expires, "return_path": row["return_path"]}, session_cookie + + +def readiness(conn, npub, group): + npub = security.canonical_npub(npub) + if group not in GROUPS: + raise ValueError("unknown route group") + blockers = [] + if not conn.execute("SELECT 1 FROM business_identity_verifications WHERE npub=?", (npub,)).fetchone(): + blockers.append("no_verified_owner_login") + hosts = {row[0] for row in conn.execute("SELECT DISTINCT a.audience FROM business_sessions s JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.npub=? AND s.expires_at>?", (npub, int(time.time())))} + if not all(urlsplit(origin).hostname in hosts for origin in origins()): + blockers.append("missing_verified_session_on_configured_hosts") + if not enabled() or not conn.execute("SELECT 1 FROM business_audit WHERE event='exchange_completed' AND merchant=?", (npub,)).fetchone(): + blockers.append("subdomain_exchange_not_verified") + if legacy_merchant(conn) == "localpub-demo": + blockers.append("legacy_owner_mapping_unresolved") + return blockers + + +def approve_group(conn, npub, group): + blockers = readiness(conn, npub, group) + if blockers: + raise ValueError("readiness blocked: " + ", ".join(blockers)) + conn.execute("INSERT OR REPLACE INTO business_rollout_approvals VALUES (?,?,?)", (group, npub, int(time.time()))) + conn.commit() + security.audit(conn, "rollout_approved", "operator", npub, npub, group) + + +def audit_report(conn, since=0, reveal=False): + def identity(value): + return value if reveal or value == "localpub-demo" else "merchant:" + hashlib.sha256(value.encode()).hexdigest()[:12] if value else "unknown" + rows = conn.execute("SELECT event,route,merchant,reason,COUNT(*) AS events,MIN(occurred_at) AS first_seen,MAX(occurred_at) AS last_seen FROM business_audit WHERE occurred_at>=? AND event IN ('would_deny','denied') GROUP BY event,route,merchant,reason ORDER BY events DESC", (since,)) + observations = [{**dict(row), "merchant": identity(row["merchant"]), "group": group_for("GET" if row["route"] in security.PRIVILEGED_GET else "POST", row["route"])} for row in rows] + groups, canaries = policy() + active_sessions = conn.execute("SELECT COUNT(*) FROM business_sessions WHERE expires_at>?", (int(time.time()),)).fetchone()[0] + blockers = [] + if not active_sessions: blockers.append("no_active_business_sessions") + if legacy_merchant(conn) == 'localpub-demo': blockers.append("legacy_owner_mapping_unresolved") + if not enabled(): blockers.append("subdomain_exchange_disabled") + if not canaries: blockers.append("no_explicit_canary_merchants") + return {"mode": security.mode(), "force_shadow": os.environ.get("JWB_AUTH_FORCE_SHADOW") == "1", "groups": groups, + "canaries": [identity(x) for x in sorted(canaries)], "legacy_mapping": identity(legacy_merchant(conn)), + "active_sessions": active_sessions, "blockers": blockers, + "observations": observations, "coalesced_events_not_request_counts": True, + "canary_readiness": [{"merchant": identity(x), "group": group, "blockers": readiness(conn, x, group), "approved": bool(conn.execute("SELECT 1 FROM business_rollout_approvals WHERE route_group=? AND npub=?", (group, x)).fetchone())} for x in sorted(canaries) for group in GROUPS]} diff --git a/docker/justworks/business/business_security.py b/docker/justworks/business/business_security.py new file mode 100644 index 00000000..5a4fc0a0 --- /dev/null +++ b/docker/justworks/business/business_security.py @@ -0,0 +1,226 @@ +"""Additive Business authorization; never an identity provider or key store.""" +import hashlib +import ipaddress +import os +import re +import secrets +import time +from http.cookies import SimpleCookie, CookieError +from collections import OrderedDict + +COOKIE = "jwb_session" +PRIVILEGED_GET = {"/api/dashboard", "/api/pos"} +PRIVILEGED_POST = {"/api/config", "/api/business-config", "/api/business-profile"} +RATE_BUCKETS = OrderedDict() + + +def canonical_npub(value): + """Validate NIP-19 bech32 checksum and the 32-byte public key length.""" + alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + if not isinstance(value, str) or len(value) != 63 or not value.startswith("npub1"): + raise ValueError("invalid npub") + try: + data = [alphabet.index(char) for char in value[5:]] + except ValueError: + raise ValueError("invalid npub") from None + checksum = 1 + expanded = [ord(c) >> 5 for c in "npub"] + [0] + [ord(c) & 31 for c in "npub"] + for item in expanded + data: + high = checksum >> 25 + checksum = ((checksum & 0x1ffffff) << 5) ^ item + for bit, generator in enumerate((0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)): + if (high >> bit) & 1: checksum ^= generator + if checksum != 1 or data[-7] & 15: + raise ValueError("invalid npub") + return value + + +def client_ip(remote, headers): + """Trust one Nginx-overwritten X-Real-IP only from explicitly enabled loopback.""" + def normalize(value): + if not isinstance(value, str) or '%' in value: + raise ValueError("invalid address") + address = ipaddress.ip_address(value.strip()) + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + return address.ipv4_mapped + return address + + try: + peer = normalize(remote) + except ValueError: + return "unknown" + if os.environ.get("JWB_TRUST_PROXY", "0") != "1" or not peer.is_loopback: + return str(peer) + # HTTPMessage permits duplicate headers; a normal dict is used by unit tests. + values = headers.get_all("X-Real-IP", []) if hasattr(headers, "get_all") else [headers.get("X-Real-IP", "")] + if len(values) == 1: + try: + return str(normalize(values[0])) + except ValueError: + pass + return str(peer) + + +def rate_scope(path, values): + # Table flows ignore caller-supplied npub; opaque tokens never enter bucket keys. + if path in {"/api/orders", "/api/payments"}: + for field in ("token", "order_id", "payment_id"): + value = values.get(field) + if isinstance(value, str) and value: + return field + ":" + token_hash(value) + return "unscoped" + try: + return "merchant:" + canonical_npub(values.get("npub", "")) + except ValueError: + return "unscoped" + + +def rate_allowed(remote, path, values=None, headers=None): + # Caller holds the process lock. Keep only a digest of the normalized peer. + timestamp = int(time.time()) // 60 + peer = client_ip(remote, headers or {}) + key = (token_hash(peer), path, rate_scope(path, values or {})) + prior_window, count = RATE_BUCKETS.pop(key, (timestamp, 0)) + count = count + 1 if prior_window == timestamp else 1 + RATE_BUCKETS[key] = (timestamp, count) + while len(RATE_BUCKETS) > 2048: RATE_BUCKETS.popitem(last=False) + return count <= (20 if path == "/api/business-owner-login" else 120) + + +def mode(): + value = os.environ.get("JWB_AUTH_MODE", "shadow") + if value not in {"off", "shadow", "enforce"}: + raise RuntimeError("JWB_AUTH_MODE must be off, shadow or enforce") + return value + + +def migrate(conn): + conn.executescript(""" + CREATE TABLE IF NOT EXISTS business_sessions ( + token_hash TEXT PRIMARY KEY, npub TEXT NOT NULL, created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS business_sessions_expiry ON business_sessions(expires_at); + CREATE TABLE IF NOT EXISTS business_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at INTEGER NOT NULL, + event TEXT NOT NULL, route TEXT NOT NULL, merchant TEXT NOT NULL, + actor TEXT NOT NULL, reason TEXT NOT NULL); + """) + import business_rollout + business_rollout.migrate(conn) + + +def audit(conn, event, route="", merchant="", actor="", reason=""): + # Only fixed event/reason codes and public identities; never bodies, cookies or exceptions. + timestamp = int(time.time()) + route = re.sub(r"/api/(orders|payments|bookings)/[^/]+/", r"/api/\1/:id/", route) + merchant = merchant if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}|localpub-demo", merchant) else "" + actor = actor if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}", actor) else "" + if event == "would_deny" and conn.execute( + "SELECT 1 FROM business_audit WHERE event=? AND route=? AND merchant=? AND reason=? AND occurred_at>? LIMIT 1", + (event, route, merchant, reason, timestamp - 60)).fetchone(): + return + conn.execute("INSERT INTO business_audit(occurred_at,event,route,merchant,actor,reason) VALUES (?,?,?,?,?,?)", + (timestamp, event, route, merchant, actor, reason)) + conn.execute("DELETE FROM business_audit WHERE id NOT IN (SELECT id FROM business_audit ORDER BY id DESC LIMIT 10000)") + conn.execute("DELETE FROM business_sessions WHERE expires_at<=?", (timestamp,)) + conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)") + conn.commit() + + +def token_hash(token): + return hashlib.sha256(token.encode()).hexdigest() + + +def session(conn, headers): + try: + cookie = SimpleCookie() + cookie.load(headers.get("Cookie", "")) + token = cookie[COOKIE].value + if not re.fullmatch(r"[A-Za-z0-9_-]{43}", token): + return None + row = conn.execute("SELECT s.npub,s.expires_at FROM business_sessions s LEFT JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.token_hash=? AND s.expires_at>? AND (a.audience=? OR a.audience IS NULL)", + (token_hash(token), int(time.time()), headers.get("Host", "").lower())).fetchone() + return dict(row) if row else None + except (KeyError, ValueError, CookieError): + return None + + +def revoke(conn, headers, all_sessions=False): + current = session(conn, headers) + if all_sessions and current: + conn.execute("DELETE FROM business_sessions WHERE npub=?", (current["npub"],)) + else: + cookie = SimpleCookie() + try: + cookie.load(headers.get("Cookie", "")) + conn.execute("DELETE FROM business_sessions WHERE token_hash=?", (token_hash(cookie[COOKIE].value),)) + except (KeyError, ValueError, CookieError): + pass + conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)") + conn.commit() + return current + + +def cookie_header(token="", max_age=0): + # Local HTTP opt-out is operator configuration, never inferred from attacker-controlled Host. + secure = "" if os.environ.get("JWB_COOKIE_SECURE", "1") == "0" else "; Secure" + return f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}" + + +def issue(conn, npub, headers): + npub = canonical_npub(npub) + revoke(conn, headers) + token = secrets.token_urlsafe(32) + ttl = max(60, min(86400, int(os.environ.get("JWB_SESSION_TTL", "28800")))) + expires = int(time.time()) + ttl + conn.execute("INSERT INTO business_sessions(token_hash,npub,created_at,expires_at) VALUES (?,?,?,?)", (token_hash(token), npub, int(time.time()), expires)) + conn.execute("INSERT INTO business_session_audiences VALUES (?,?)", (token_hash(token), headers.get("Host", "").lower())) + conn.execute("DELETE FROM business_sessions WHERE npub=? AND token_hash NOT IN (SELECT token_hash FROM business_sessions WHERE npub=? ORDER BY created_at DESC,rowid DESC LIMIT 10)", (npub, npub)) + conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)") + conn.commit() + return cookie_header(token, ttl), expires + + +def target(conn, method, path, values): + from business_rollout import legacy_merchant + if method == "GET" and path in PRIVILEGED_GET: + return legacy_merchant(conn) if path == "/api/dashboard" else str(values.get("npub", "")).strip() + if method == "POST" and path in PRIVILEGED_POST: + return legacy_merchant(conn) if path == "/api/config" else str(values.get("npub", "")).strip() + match = re.fullmatch(r"/api/(orders|payments|bookings)/([^/]+)/(status|acknowledge)", path) + if method == "POST" and match: + table, identity, _ = match.groups() + row = conn.execute(f"SELECT merchant_id FROM {table} WHERE id=?", (identity,)).fetchone() + return row[0] if row else "" + return None + + +def same_origin(headers): + from urllib.parse import urlparse + origin = headers.get("Origin") + if headers.get("Sec-Fetch-Site") == "cross-site": + return False + if not origin: + return True # non-browser clients; browser JSON requests require CORS preflight + parsed = urlparse(origin) + return parsed.scheme in {"http", "https"} and parsed.netloc.lower() == headers.get("Host", "").lower() + + +def authorize(conn, method, path, values, headers): + merchant = target(conn, method, path, values) + if merchant is None: + return True, None + current = session(conn, headers) + reason = "" + if not current: + reason = "missing_session" + elif current["npub"] != merchant: + reason = "tenant_mismatch" + elif method != "GET" and not same_origin(headers): + reason = "cross_origin" + from business_rollout import effective_mode + selected = effective_mode(conn, method, path, merchant) + if reason and selected != "off": + audit(conn, "denied" if selected == "enforce" else "would_deny", path, merchant, + current["npub"] if current else "", reason) + return not reason or selected != "enforce", (merchant, current["npub"] if current else "") diff --git a/docker/justworks/business/payment-helper.cjs b/docker/justworks/business/payment-helper.cjs new file mode 100644 index 00000000..fe8cb3fe --- /dev/null +++ b/docker/justworks/business/payment-helper.cjs @@ -0,0 +1,15351 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod2) => function __require() { + return mod2 || (0, cb[__getOwnPropNames(cb)[0]])((mod2 = { exports: {} }).exports, mod2), mod2.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod2, isNodeMode, target) => (target = mod2 != null ? __create(__getProtoOf(mod2)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod2 || !mod2.__esModule ? __defProp(target, "default", { value: mod2, enumerable: true }) : target, + mod2 +)); + +// node_modules/bech32/dist/index.js +var require_dist = __commonJS({ + "node_modules/bech32/dist/index.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.bech32m = exports2.bech32 = void 0; + var ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + var ALPHABET_MAP = {}; + for (let z = 0; z < ALPHABET.length; z++) { + const x = ALPHABET.charAt(z); + ALPHABET_MAP[x] = z; + } + function polymodStep(pre) { + const b = pre >> 25; + return (pre & 33554431) << 5 ^ -(b >> 0 & 1) & 996825010 ^ -(b >> 1 & 1) & 642813549 ^ -(b >> 2 & 1) & 513874426 ^ -(b >> 3 & 1) & 1027748829 ^ -(b >> 4 & 1) & 705979059; + } + function prefixChk(prefix) { + let chk = 1; + for (let i3 = 0; i3 < prefix.length; ++i3) { + const c = prefix.charCodeAt(i3); + if (c < 33 || c > 126) + return "Invalid prefix (" + prefix + ")"; + chk = polymodStep(chk) ^ c >> 5; + } + chk = polymodStep(chk); + for (let i3 = 0; i3 < prefix.length; ++i3) { + const v = prefix.charCodeAt(i3); + chk = polymodStep(chk) ^ v & 31; + } + return chk; + } + function convert(data, inBits, outBits, pad2) { + let value = 0; + let bits = 0; + const maxV = (1 << outBits) - 1; + const result = []; + for (let i3 = 0; i3 < data.length; ++i3) { + value = value << inBits | data[i3]; + bits += inBits; + while (bits >= outBits) { + bits -= outBits; + result.push(value >> bits & maxV); + } + } + if (pad2) { + if (bits > 0) { + result.push(value << outBits - bits & maxV); + } + } else { + if (bits >= inBits) + return "Excess padding"; + if (value << outBits - bits & maxV) + return "Non-zero padding"; + } + return result; + } + function toWords(bytes4) { + return convert(bytes4, 8, 5, true); + } + function fromWordsUnsafe(words) { + const res = convert(words, 5, 8, false); + if (Array.isArray(res)) + return res; + } + function fromWords(words) { + const res = convert(words, 5, 8, false); + if (Array.isArray(res)) + return res; + throw new Error(res); + } + function getLibraryFromEncoding(encoding) { + let ENCODING_CONST; + if (encoding === "bech32") { + ENCODING_CONST = 1; + } else { + ENCODING_CONST = 734539939; + } + function encode(prefix, words, LIMIT) { + LIMIT = LIMIT || 90; + if (prefix.length + 7 + words.length > LIMIT) + throw new TypeError("Exceeds length limit"); + prefix = prefix.toLowerCase(); + let chk = prefixChk(prefix); + if (typeof chk === "string") + throw new Error(chk); + let result = prefix + "1"; + for (let i3 = 0; i3 < words.length; ++i3) { + const x = words[i3]; + if (x >> 5 !== 0) + throw new Error("Non 5-bit word"); + chk = polymodStep(chk) ^ x; + result += ALPHABET.charAt(x); + } + for (let i3 = 0; i3 < 6; ++i3) { + chk = polymodStep(chk); + } + chk ^= ENCODING_CONST; + for (let i3 = 0; i3 < 6; ++i3) { + const v = chk >> (5 - i3) * 5 & 31; + result += ALPHABET.charAt(v); + } + return result; + } + function __decode(str, LIMIT) { + LIMIT = LIMIT || 90; + if (str.length < 8) + return str + " too short"; + if (str.length > LIMIT) + return "Exceeds length limit"; + const lowered = str.toLowerCase(); + const uppered = str.toUpperCase(); + if (str !== lowered && str !== uppered) + return "Mixed-case string " + str; + str = lowered; + const split = str.lastIndexOf("1"); + if (split === -1) + return "No separator character for " + str; + if (split === 0) + return "Missing prefix for " + str; + const prefix = str.slice(0, split); + const wordChars = str.slice(split + 1); + if (wordChars.length < 6) + return "Data too short"; + let chk = prefixChk(prefix); + if (typeof chk === "string") + return chk; + const words = []; + for (let i3 = 0; i3 < wordChars.length; ++i3) { + const c = wordChars.charAt(i3); + const v = ALPHABET_MAP[c]; + if (v === void 0) + return "Unknown character " + c; + chk = polymodStep(chk) ^ v; + if (i3 + 6 >= wordChars.length) + continue; + words.push(v); + } + if (chk !== ENCODING_CONST) + return "Invalid checksum for " + str; + return { prefix, words }; + } + function decodeUnsafe(str, LIMIT) { + const res = __decode(str, LIMIT); + if (typeof res === "object") + return res; + } + function decode2(str, LIMIT) { + const res = __decode(str, LIMIT); + if (typeof res === "object") + return res; + throw new Error(res); + } + return { + decodeUnsafe, + decode: decode2, + encode, + toWords, + fromWordsUnsafe, + fromWords + }; + } + exports2.bech32 = getLibraryFromEncoding("bech32"); + exports2.bech32m = getLibraryFromEncoding("bech32m"); + } +}); + +// node_modules/ws/lib/constants.js +var require_constants = __commonJS({ + "node_modules/ws/lib/constants.js"(exports2, module2) { + "use strict"; + var BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"]; + var hasBlob = typeof Blob !== "undefined"; + if (hasBlob) BINARY_TYPES.push("blob"); + module2.exports = { + BINARY_TYPES, + EMPTY_BUFFER: Buffer.alloc(0), + GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", + hasBlob, + kForOnEventAttribute: Symbol("kIsForOnEventAttribute"), + kListener: Symbol("kListener"), + kStatusCode: Symbol("status-code"), + kWebSocket: Symbol("websocket"), + NOOP: () => { + } + }; + } +}); + +// node_modules/ws/lib/buffer-util.js +var require_buffer_util = __commonJS({ + "node_modules/ws/lib/buffer-util.js"(exports2, module2) { + "use strict"; + var { EMPTY_BUFFER: EMPTY_BUFFER2 } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER2; + if (list.length === 1) return list[0]; + const target = Buffer.allocUnsafe(totalLength); + let offset = 0; + for (let i3 = 0; i3 < list.length; i3++) { + const buf = list[i3]; + target.set(buf, offset); + offset += buf.length; + } + if (offset < totalLength) { + return new FastBuffer(target.buffer, target.byteOffset, offset); + } + return target; + } + function _mask(source, mask, output4, offset, length) { + for (let i3 = 0; i3 < length; i3++) { + output4[offset + i3] = source[i3] ^ mask[i3 & 3]; + } + } + function _unmask(buffer, mask) { + for (let i3 = 0; i3 < buffer.length; i3++) { + buffer[i3] ^= mask[i3 & 3]; + } + } + function toArrayBuffer(buf) { + if (buf.length === buf.buffer.byteLength) { + return buf.buffer; + } + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); + } + function toBuffer(data) { + toBuffer.readOnly = true; + if (Buffer.isBuffer(data)) return data; + let buf; + if (data instanceof ArrayBuffer) { + buf = new FastBuffer(data); + } else if (ArrayBuffer.isView(data)) { + buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); + } else { + buf = Buffer.from(data); + toBuffer.readOnly = false; + } + return buf; + } + module2.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask + }; + if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require("bufferutil"); + module2.exports.mask = function(source, mask, output4, offset, length) { + if (length < 48) _mask(source, mask, output4, offset, length); + else bufferUtil.mask(source, mask, output4, offset, length); + }; + module2.exports.unmask = function(buffer, mask) { + if (buffer.length < 32) _unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + } + } + } +}); + +// node_modules/ws/lib/limiter.js +var require_limiter = __commonJS({ + "node_modules/ws/lib/limiter.js"(exports2, module2) { + "use strict"; + var kDone = Symbol("kDone"); + var kRun = Symbol("kRun"); + var Limiter = class { + /** + * Creates a new `Limiter`. + * + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently + */ + constructor(concurrency) { + this[kDone] = () => { + this.pending--; + this[kRun](); + }; + this.concurrency = concurrency || Infinity; + this.jobs = []; + this.pending = 0; + } + /** + * Adds a job to the queue. + * + * @param {Function} job The job to run + * @public + */ + add(job) { + this.jobs.push(job); + this[kRun](); + } + /** + * Removes a job from the queue and runs it if possible. + * + * @private + */ + [kRun]() { + if (this.pending === this.concurrency) return; + if (this.jobs.length) { + const job = this.jobs.shift(); + this.pending++; + job(this[kDone]); + } + } + }; + module2.exports = Limiter; + } +}); + +// node_modules/ws/lib/permessage-deflate.js +var require_permessage_deflate = __commonJS({ + "node_modules/ws/lib/permessage-deflate.js"(exports2, module2) { + "use strict"; + var zlib = require("zlib"); + var bufferUtil = require_buffer_util(); + var Limiter = require_limiter(); + var { kStatusCode } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + var TRAILER = Buffer.from([0, 0, 255, 255]); + var kPerMessageDeflate = Symbol("permessage-deflate"); + var kTotalLength = Symbol("total-length"); + var kCallback = Symbol("callback"); + var kBuffers = Symbol("buffers"); + var kError = Symbol("error"); + var zlibLimiter; + var PerMessageDeflate = class { + /** + * Creates a PerMessageDeflate instance. + * + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + * @param {Boolean} [isServer=false] Create the instance in either server or + * client mode + * @param {Number} [maxPayload=0] The maximum allowed message length + */ + constructor(options, isServer, maxPayload) { + this._maxPayload = maxPayload | 0; + this._options = options || {}; + this._threshold = this._options.threshold !== void 0 ? this._options.threshold : 1024; + this._isServer = !!isServer; + this._deflate = null; + this._inflate = null; + this.params = null; + if (!zlibLimiter) { + const concurrency = this._options.concurrencyLimit !== void 0 ? this._options.concurrencyLimit : 10; + zlibLimiter = new Limiter(concurrency); + } + } + /** + * @type {String} + */ + static get extensionName() { + return "permessage-deflate"; + } + /** + * Create an extension negotiation offer. + * + * @return {Object} Extension parameters + * @public + */ + offer() { + const params = {}; + if (this._options.serverNoContextTakeover) { + params.server_no_context_takeover = true; + } + if (this._options.clientNoContextTakeover) { + params.client_no_context_takeover = true; + } + if (this._options.serverMaxWindowBits) { + params.server_max_window_bits = this._options.serverMaxWindowBits; + } + if (this._options.clientMaxWindowBits) { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } else if (this._options.clientMaxWindowBits == null) { + params.client_max_window_bits = true; + } + return params; + } + /** + * Accept an extension negotiation offer/response. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Object} Accepted configuration + * @public + */ + accept(configurations) { + configurations = this.normalizeParams(configurations); + this.params = this._isServer ? this.acceptAsServer(configurations) : this.acceptAsClient(configurations); + return this.params; + } + /** + * Releases all resources used by the extension. + * + * @public + */ + cleanup() { + if (this._inflate) { + this._inflate.close(); + this._inflate = null; + } + if (this._deflate) { + const callback = this._deflate[kCallback]; + this._deflate.close(); + this._deflate = null; + if (callback) { + callback( + new Error( + "The deflate stream was closed while data was being processed" + ) + ); + } + } + } + /** + * Accept an extension negotiation offer. + * + * @param {Array} offers The extension negotiation offers + * @return {Object} Accepted configuration + * @private + */ + acceptAsServer(offers) { + const opts = this._options; + const accepted = offers.find((params) => { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { + return false; + } + return true; + }); + if (!accepted) { + throw new Error("None of the extension offers can be accepted"); + } + if (opts.serverNoContextTakeover) { + accepted.server_no_context_takeover = true; + } + if (opts.clientNoContextTakeover) { + accepted.client_no_context_takeover = true; + } + if (typeof opts.serverMaxWindowBits === "number") { + accepted.server_max_window_bits = opts.serverMaxWindowBits; + } + if (typeof opts.clientMaxWindowBits === "number") { + accepted.client_max_window_bits = opts.clientMaxWindowBits; + } else if (accepted.client_max_window_bits === true || opts.clientMaxWindowBits === false) { + delete accepted.client_max_window_bits; + } + return accepted; + } + /** + * Accept the extension negotiation response. + * + * @param {Array} response The extension negotiation response + * @return {Object} Accepted configuration + * @private + */ + acceptAsClient(response) { + const params = response[0]; + if (this._options.clientNoContextTakeover === false && params.client_no_context_takeover) { + throw new Error('Unexpected parameter "client_no_context_takeover"'); + } + if (!params.client_max_window_bits) { + if (typeof this._options.clientMaxWindowBits === "number") { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } + } else if (this._options.clientMaxWindowBits === false || typeof this._options.clientMaxWindowBits === "number" && params.client_max_window_bits > this._options.clientMaxWindowBits) { + throw new Error( + 'Unexpected or invalid parameter "client_max_window_bits"' + ); + } + return params; + } + /** + * Normalize parameters. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Array} The offers/response with normalized parameters + * @private + */ + normalizeParams(configurations) { + configurations.forEach((params) => { + Object.keys(params).forEach((key) => { + let value = params[key]; + if (value.length > 1) { + throw new Error(`Parameter "${key}" must have only a single value`); + } + value = value[0]; + if (key === "client_max_window_bits") { + if (value !== true) { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (!this._isServer) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else if (key === "server_max_window_bits") { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (key === "client_no_context_takeover" || key === "server_no_context_takeover") { + if (value !== true) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else { + throw new Error(`Unknown parameter "${key}"`); + } + params[key] = value; + }); + }); + return configurations; + } + /** + * Decompress data. Concurrency limited. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + decompress(data, fin, callback) { + zlibLimiter.add((done) => { + this._decompress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Compress data. Concurrency limited. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + compress(data, fin, callback) { + zlibLimiter.add((done) => { + this._compress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Decompress data. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _decompress(data, fin, callback) { + const endpoint = this._isServer ? "client" : "server"; + if (!this._inflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); + this._inflate[kPerMessageDeflate] = this; + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + this._inflate.on("error", inflateOnError); + this._inflate.on("data", inflateOnData); + } + this._inflate[kCallback] = callback; + this._inflate.write(data); + if (fin) this._inflate.write(TRAILER); + this._inflate.flush(() => { + const err = this._inflate[kError]; + if (err) { + this._inflate.close(); + this._inflate = null; + callback(err); + return; + } + const data2 = bufferUtil.concat( + this._inflate[kBuffers], + this._inflate[kTotalLength] + ); + if (this._inflate._readableState.endEmitted) { + this._inflate.close(); + this._inflate = null; + } else { + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } + } + callback(null, data2); + }); + } + /** + * Compress data. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _compress(data, fin, callback) { + const endpoint = this._isServer ? "server" : "client"; + if (!this._deflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + this._deflate.on("data", deflateOnData); + } + this._deflate[kCallback] = callback; + this._deflate.write(data); + this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { + if (!this._deflate) { + return; + } + let data2 = bufferUtil.concat( + this._deflate[kBuffers], + this._deflate[kTotalLength] + ); + if (fin) { + data2 = new FastBuffer(data2.buffer, data2.byteOffset, data2.length - 4); + } + this._deflate[kCallback] = null; + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._deflate.reset(); + } + callback(null, data2); + }); + } + }; + module2.exports = PerMessageDeflate; + function deflateOnData(chunk) { + this[kBuffers].push(chunk); + this[kTotalLength] += chunk.length; + } + function inflateOnData(chunk) { + this[kTotalLength] += chunk.length; + if (this[kPerMessageDeflate]._maxPayload < 1 || this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload) { + this[kBuffers].push(chunk); + return; + } + this[kError] = new RangeError("Max payload size exceeded"); + this[kError].code = "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"; + this[kError][kStatusCode] = 1009; + this.removeListener("data", inflateOnData); + this.reset(); + } + function inflateOnError(err) { + this[kPerMessageDeflate]._inflate = null; + if (this[kError]) { + this[kCallback](this[kError]); + return; + } + err[kStatusCode] = 1007; + this[kCallback](err); + } + } +}); + +// node_modules/ws/lib/validation.js +var require_validation = __commonJS({ + "node_modules/ws/lib/validation.js"(exports2, module2) { + "use strict"; + var { isUtf8 } = require("buffer"); + var { hasBlob } = require_constants(); + var tokenChars = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 0 - 15 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 16 - 31 + 0, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 1, + 0, + // 32 - 47 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + // 48 - 63 + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 64 - 79 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + // 80 - 95 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 96 - 111 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 0, + 1, + 0 + // 112 - 127 + ]; + function isValidStatusCode(code) { + return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999; + } + function _isValidUTF8(buf) { + const len = buf.length; + let i3 = 0; + while (i3 < len) { + if ((buf[i3] & 128) === 0) { + i3++; + } else if ((buf[i3] & 224) === 192) { + if (i3 + 1 === len || (buf[i3 + 1] & 192) !== 128 || (buf[i3] & 254) === 192) { + return false; + } + i3 += 2; + } else if ((buf[i3] & 240) === 224) { + if (i3 + 2 >= len || (buf[i3 + 1] & 192) !== 128 || (buf[i3 + 2] & 192) !== 128 || buf[i3] === 224 && (buf[i3 + 1] & 224) === 128 || // Overlong + buf[i3] === 237 && (buf[i3 + 1] & 224) === 160) { + return false; + } + i3 += 3; + } else if ((buf[i3] & 248) === 240) { + if (i3 + 3 >= len || (buf[i3 + 1] & 192) !== 128 || (buf[i3 + 2] & 192) !== 128 || (buf[i3 + 3] & 192) !== 128 || buf[i3] === 240 && (buf[i3 + 1] & 240) === 128 || // Overlong + buf[i3] === 244 && buf[i3 + 1] > 143 || buf[i3] > 244) { + return false; + } + i3 += 4; + } else { + return false; + } + } + return true; + } + function isBlob(value) { + return hasBlob && typeof value === "object" && typeof value.arrayBuffer === "function" && typeof value.type === "string" && typeof value.stream === "function" && (value[Symbol.toStringTag] === "Blob" || value[Symbol.toStringTag] === "File"); + } + module2.exports = { + isBlob, + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars + }; + if (isUtf8) { + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); + }; + } else if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require("utf-8-validate"); + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + } + } + } +}); + +// node_modules/ws/lib/receiver.js +var require_receiver = __commonJS({ + "node_modules/ws/lib/receiver.js"(exports2, module2) { + "use strict"; + var { Writable } = require("stream"); + var PerMessageDeflate = require_permessage_deflate(); + var { + BINARY_TYPES, + EMPTY_BUFFER: EMPTY_BUFFER2, + kStatusCode, + kWebSocket + } = require_constants(); + var { concat, toArrayBuffer, unmask } = require_buffer_util(); + var { isValidStatusCode, isValidUTF8 } = require_validation(); + var FastBuffer = Buffer[Symbol.species]; + var GET_INFO = 0; + var GET_PAYLOAD_LENGTH_16 = 1; + var GET_PAYLOAD_LENGTH_64 = 2; + var GET_MASK = 3; + var GET_DATA = 4; + var INFLATING = 5; + var DEFER_EVENT = 6; + var Receiver2 = class extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {String} [options.binaryType=nodebuffer] The type for binary data + * @param {Object} [options.extensions] An object containing the negotiated + * extensions + * @param {Boolean} [options.isServer=false] Specifies whether to operate in + * client or server mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + */ + constructor(options = {}) { + super(); + this._allowSynchronousEvents = options.allowSynchronousEvents !== void 0 ? options.allowSynchronousEvents : true; + this._binaryType = options.binaryType || BINARY_TYPES[0]; + this._extensions = options.extensions || {}; + this._isServer = !!options.isServer; + this._maxPayload = options.maxPayload | 0; + this._skipUTF8Validation = !!options.skipUTF8Validation; + this[kWebSocket] = void 0; + this._bufferedBytes = 0; + this._buffers = []; + this._compressed = false; + this._payloadLength = 0; + this._mask = void 0; + this._fragmented = 0; + this._masked = false; + this._fin = false; + this._opcode = 0; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragments = []; + this._errored = false; + this._loop = false; + this._state = GET_INFO; + } + /** + * Implements `Writable.prototype._write()`. + * + * @param {Buffer} chunk The chunk of data to write + * @param {String} encoding The character encoding of `chunk` + * @param {Function} cb Callback + * @private + */ + _write(chunk, encoding, cb) { + if (this._opcode === 8 && this._state == GET_INFO) return cb(); + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + if (n === this._buffers[0].length) return this._buffers.shift(); + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + return new FastBuffer(buf.buffer, buf.byteOffset, n); + } + const dst = Buffer.allocUnsafe(n); + do { + const buf = this._buffers[0]; + const offset = dst.length - n; + if (n >= buf.length) { + dst.set(this._buffers.shift(), offset); + } else { + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + } + n -= buf.length; + } while (n > 0); + return dst; + } + /** + * Starts the parsing loop. + * + * @param {Function} cb Callback + * @private + */ + startLoop(cb) { + this._loop = true; + do { + switch (this._state) { + case GET_INFO: + this.getInfo(cb); + break; + case GET_PAYLOAD_LENGTH_16: + this.getPayloadLength16(cb); + break; + case GET_PAYLOAD_LENGTH_64: + this.getPayloadLength64(cb); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + this.getData(cb); + break; + case INFLATING: + case DEFER_EVENT: + this._loop = false; + return; + } + } while (this._loop); + if (!this._errored) cb(); + } + /** + * Reads the first two bytes of a frame. + * + * @param {Function} cb Callback + * @private + */ + getInfo(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + const buf = this.consume(2); + if ((buf[0] & 48) !== 0) { + const error = this.createError( + RangeError, + "RSV2 and RSV3 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_2_3" + ); + cb(error); + return; + } + const compressed = (buf[0] & 64) === 64; + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + this._fin = (buf[0] & 128) === 128; + this._opcode = buf[0] & 15; + this._payloadLength = buf[1] & 127; + if (this._opcode === 0) { + if (compressed) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + if (!this._fragmented) { + const error = this.createError( + RangeError, + "invalid opcode 0", + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + this._opcode = this._fragmented; + } else if (this._opcode === 1 || this._opcode === 2) { + if (this._fragmented) { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + this._compressed = compressed; + } else if (this._opcode > 7 && this._opcode < 11) { + if (!this._fin) { + const error = this.createError( + RangeError, + "FIN must be set", + true, + 1002, + "WS_ERR_EXPECTED_FIN" + ); + cb(error); + return; + } + if (compressed) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + if (this._payloadLength > 125 || this._opcode === 8 && this._payloadLength === 1) { + const error = this.createError( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + "WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH" + ); + cb(error); + return; + } + } else { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + if (!this._fin && !this._fragmented) this._fragmented = this._opcode; + this._masked = (buf[1] & 128) === 128; + if (this._isServer) { + if (!this._masked) { + const error = this.createError( + RangeError, + "MASK must be set", + true, + 1002, + "WS_ERR_EXPECTED_MASK" + ); + cb(error); + return; + } + } else if (this._masked) { + const error = this.createError( + RangeError, + "MASK must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_MASK" + ); + cb(error); + return; + } + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; + else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; + else this.haveLength(cb); + } + /** + * Gets extended payload length (7+16). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength16(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + this._payloadLength = this.consume(2).readUInt16BE(0); + this.haveLength(cb); + } + /** + * Gets extended payload length (7+64). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength64(cb) { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + if (num > Math.pow(2, 53 - 32) - 1) { + const error = this.createError( + RangeError, + "Unsupported WebSocket frame: payload length > 2^53 - 1", + false, + 1009, + "WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH" + ); + cb(error); + return; + } + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + this.haveLength(cb); + } + /** + * Payload length has been read. + * + * @param {Function} cb Callback + * @private + */ + haveLength(cb) { + if (this._payloadLength && this._opcode < 8) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error); + return; + } + } + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + this._mask = this.consume(4); + this._state = GET_DATA; + } + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER2; + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + data = this.consume(this._payloadLength); + if (this._masked && (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0) { + unmask(data, this._mask); + } + } + if (this._opcode > 7) { + this.controlMessage(data, cb); + return; + } + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + if (data.length) { + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + this.dataMessage(cb); + } + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error); + return; + } + this._fragments.push(buf); + } + this.dataMessage(cb); + if (this._state === GET_INFO) this.startLoop(cb); + }); + } + /** + * Handles a data message. + * + * @param {Function} cb Callback + * @private + */ + dataMessage(cb) { + if (!this._fin) { + this._state = GET_INFO; + return; + } + const messageLength = this._messageLength; + const fragments = this._fragments; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + if (this._opcode === 2) { + let data; + if (this._binaryType === "nodebuffer") { + data = concat(fragments, messageLength); + } else if (this._binaryType === "arraybuffer") { + data = toArrayBuffer(concat(fragments, messageLength)); + } else if (this._binaryType === "blob") { + data = new Blob(fragments); + } else { + data = fragments; + } + if (this._allowSynchronousEvents) { + this.emit("message", data, true); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", data, true); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } else { + const buf = concat(fragments, messageLength); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error); + return; + } + if (this._state === INFLATING || this._allowSynchronousEvents) { + this.emit("message", buf, false); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", buf, false); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + } + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data, cb) { + if (this._opcode === 8) { + if (data.length === 0) { + this._loop = false; + this.emit("conclude", 1005, EMPTY_BUFFER2); + this.end(); + } else { + const code = data.readUInt16BE(0); + if (!isValidStatusCode(code)) { + const error = this.createError( + RangeError, + `invalid status code ${code}`, + true, + 1002, + "WS_ERR_INVALID_CLOSE_CODE" + ); + cb(error); + return; + } + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error); + return; + } + this._loop = false; + this.emit("conclude", code, buf); + this.end(); + } + this._state = GET_INFO; + return; + } + if (this._allowSynchronousEvents) { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + /** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ + createError(ErrorCtor, message, prefix, statusCode, errorCode) { + this._loop = false; + this._errored = true; + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + Error.captureStackTrace(err, this.createError); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; + } + }; + module2.exports = Receiver2; + } +}); + +// node_modules/ws/lib/sender.js +var require_sender = __commonJS({ + "node_modules/ws/lib/sender.js"(exports2, module2) { + "use strict"; + var { Duplex } = require("stream"); + var { randomFillSync } = require("crypto"); + var PerMessageDeflate = require_permessage_deflate(); + var { EMPTY_BUFFER: EMPTY_BUFFER2, kWebSocket, NOOP } = require_constants(); + var { isBlob, isValidStatusCode } = require_validation(); + var { mask: applyMask, toBuffer } = require_buffer_util(); + var kByteLength = Symbol("kByteLength"); + var maskBuffer = Buffer.alloc(4); + var RANDOM_POOL_SIZE = 8 * 1024; + var randomPool; + var randomPoolPointer = RANDOM_POOL_SIZE; + var DEFAULT = 0; + var DEFLATING = 1; + var GET_BLOB_DATA = 2; + var Sender2 = class _Sender { + /** + * Creates a Sender instance. + * + * @param {Duplex} socket The connection socket + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key + */ + constructor(socket, extensions, generateMask) { + this._extensions = extensions || {}; + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + this._socket = socket; + this._firstFragment = true; + this._compress = false; + this._bufferedBytes = 0; + this._queue = []; + this._state = DEFAULT; + this.onerror = NOOP; + this[kWebSocket] = void 0; + } + /** + * Frames a piece of data according to the HyBi WebSocket protocol. + * + * @param {(Buffer|String)} data The data to frame + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data + * @public + */ + static frame(data, options) { + let mask; + let merge = false; + let offset = 2; + let skipMasking = false; + if (options.mask) { + mask = options.maskBuffer || maskBuffer; + if (options.generateMask) { + options.generateMask(mask); + } else { + if (randomPoolPointer === RANDOM_POOL_SIZE) { + if (randomPool === void 0) { + randomPool = Buffer.alloc(RANDOM_POOL_SIZE); + } + randomFillSync(randomPool, 0, RANDOM_POOL_SIZE); + randomPoolPointer = 0; + } + mask[0] = randomPool[randomPoolPointer++]; + mask[1] = randomPool[randomPoolPointer++]; + mask[2] = randomPool[randomPoolPointer++]; + mask[3] = randomPool[randomPoolPointer++]; + } + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + offset = 6; + } + let dataLength; + if (typeof data === "string") { + if ((!options.mask || skipMasking) && options[kByteLength] !== void 0) { + dataLength = options[kByteLength]; + } else { + data = Buffer.from(data); + dataLength = data.length; + } + } else { + dataLength = data.length; + merge = options.mask && options.readOnly && !skipMasking; + } + let payloadLength = dataLength; + if (dataLength >= 65536) { + offset += 8; + payloadLength = 127; + } else if (dataLength > 125) { + offset += 2; + payloadLength = 126; + } + const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset); + target[0] = options.fin ? options.opcode | 128 : options.opcode; + if (options.rsv1) target[0] |= 64; + target[1] = payloadLength; + if (payloadLength === 126) { + target.writeUInt16BE(dataLength, 2); + } else if (payloadLength === 127) { + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); + } + if (!options.mask) return [target, data]; + target[1] |= 128; + target[offset - 4] = mask[0]; + target[offset - 3] = mask[1]; + target[offset - 2] = mask[2]; + target[offset - 1] = mask[3]; + if (skipMasking) return [target, data]; + if (merge) { + applyMask(data, mask, target, offset, dataLength); + return [target]; + } + applyMask(data, mask, data, 0, dataLength); + return [target, data]; + } + /** + * Sends a close message to the other peer. + * + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback + * @public + */ + close(code, data, mask, cb) { + let buf; + if (code === void 0) { + buf = EMPTY_BUFFER2; + } else if (typeof code !== "number" || !isValidStatusCode(code)) { + throw new TypeError("First argument must be a valid error code number"); + } else if (data === void 0 || !data.length) { + buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(code, 0); + } else { + const length = Buffer.byteLength(data); + if (length > 123) { + throw new RangeError("The message must not be greater than 123 bytes"); + } + buf = Buffer.allocUnsafe(2 + length); + buf.writeUInt16BE(code, 0); + if (typeof data === "string") { + buf.write(data, 2); + } else { + buf.set(data, 2); + } + } + const options = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 8, + readOnly: false, + rsv1: false + }; + if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, buf, false, options, cb]); + } else { + this.sendFrame(_Sender.frame(buf, options), cb); + } + } + /** + * Sends a ping message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + ping(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 9, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(_Sender.frame(data, options), cb); + } + } + /** + * Sends a pong message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + pong(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 10, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(_Sender.frame(data, options), cb); + } + } + /** + * Sends a data message to the other peer. + * + * @param {*} data The message to send + * @param {Object} options Options object + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback + * @public + */ + send(data, options, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options.binary ? 2 : 1; + let rsv1 = options.compress; + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (this._firstFragment) { + this._firstFragment = false; + if (rsv1 && perMessageDeflate && perMessageDeflate.params[perMessageDeflate._isServer ? "server_no_context_takeover" : "client_no_context_takeover"]) { + rsv1 = byteLength >= perMessageDeflate._threshold; + } + this._compress = rsv1; + } else { + rsv1 = false; + opcode = 0; + } + if (options.fin) this._firstFragment = true; + const opts = { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, this._compress, opts, cb]); + } else { + this.getBlobData(data, this._compress, opts, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } + /** + * Gets the contents of a blob as binary data. + * + * @param {Blob} blob The blob + * @param {Boolean} [compress=false] Specifies whether or not to compress + * the data + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + getBlobData(blob, compress, options, cb) { + this._bufferedBytes += options[kByteLength]; + this._state = GET_BLOB_DATA; + blob.arrayBuffer().then((arrayBuffer) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while the blob was being read" + ); + process.nextTick(callCallbacks, this, err, cb); + return; + } + this._bufferedBytes -= options[kByteLength]; + const data = toBuffer(arrayBuffer); + if (!compress) { + this._state = DEFAULT; + this.sendFrame(_Sender.frame(data, options), cb); + this.dequeue(); + } else { + this.dispatch(data, compress, options, cb); + } + }).catch((err) => { + process.nextTick(onError, this, err, cb); + }); + } + /** + * Dispatches a message. + * + * @param {(Buffer|String)} data The message to send + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + dispatch(data, compress, options, cb) { + if (!compress) { + this.sendFrame(_Sender.frame(data, options), cb); + return; + } + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + this._bufferedBytes += options[kByteLength]; + this._state = DEFLATING; + perMessageDeflate.compress(data, options.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while data was being compressed" + ); + callCallbacks(this, err, cb); + return; + } + this._bufferedBytes -= options[kByteLength]; + this._state = DEFAULT; + options.readOnly = false; + this.sendFrame(_Sender.frame(buf, options), cb); + this.dequeue(); + }); + } + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (this._state === DEFAULT && this._queue.length) { + const params = this._queue.shift(); + this._bufferedBytes -= params[3][kByteLength]; + Reflect.apply(params[0], this, params.slice(1)); + } + } + /** + * Enqueues a send operation. + * + * @param {Array} params Send operation parameters. + * @private + */ + enqueue(params) { + this._bufferedBytes += params[3][kByteLength]; + this._queue.push(params); + } + /** + * Sends a frame. + * + * @param {(Buffer | String)[]} list The frame to send + * @param {Function} [cb] Callback + * @private + */ + sendFrame(list, cb) { + if (list.length === 2) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); + this._socket.uncork(); + } else { + this._socket.write(list[0], cb); + } + } + }; + module2.exports = Sender2; + function callCallbacks(sender, err, cb) { + if (typeof cb === "function") cb(err); + for (let i3 = 0; i3 < sender._queue.length; i3++) { + const params = sender._queue[i3]; + const callback = params[params.length - 1]; + if (typeof callback === "function") callback(err); + } + } + function onError(sender, err, cb) { + callCallbacks(sender, err, cb); + sender.onerror(err); + } + } +}); + +// node_modules/ws/lib/event-target.js +var require_event_target = __commonJS({ + "node_modules/ws/lib/event-target.js"(exports2, module2) { + "use strict"; + var { kForOnEventAttribute, kListener } = require_constants(); + var kCode = Symbol("kCode"); + var kData = Symbol("kData"); + var kError = Symbol("kError"); + var kMessage = Symbol("kMessage"); + var kReason = Symbol("kReason"); + var kTarget = Symbol("kTarget"); + var kType = Symbol("kType"); + var kWasClean = Symbol("kWasClean"); + var Event = class { + /** + * Create a new `Event`. + * + * @param {String} type The name of the event + * @throws {TypeError} If the `type` argument is not specified + */ + constructor(type) { + this[kTarget] = null; + this[kType] = type; + } + /** + * @type {*} + */ + get target() { + return this[kTarget]; + } + /** + * @type {String} + */ + get type() { + return this[kType]; + } + }; + Object.defineProperty(Event.prototype, "target", { enumerable: true }); + Object.defineProperty(Event.prototype, "type", { enumerable: true }); + var CloseEvent = class extends Event { + /** + * Create a new `CloseEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {Number} [options.code=0] The status code explaining why the + * connection was closed + * @param {String} [options.reason=''] A human-readable string explaining why + * the connection was closed + * @param {Boolean} [options.wasClean=false] Indicates whether or not the + * connection was cleanly closed + */ + constructor(type, options = {}) { + super(type); + this[kCode] = options.code === void 0 ? 0 : options.code; + this[kReason] = options.reason === void 0 ? "" : options.reason; + this[kWasClean] = options.wasClean === void 0 ? false : options.wasClean; + } + /** + * @type {Number} + */ + get code() { + return this[kCode]; + } + /** + * @type {String} + */ + get reason() { + return this[kReason]; + } + /** + * @type {Boolean} + */ + get wasClean() { + return this[kWasClean]; + } + }; + Object.defineProperty(CloseEvent.prototype, "code", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "reason", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "wasClean", { enumerable: true }); + var ErrorEvent = class extends Event { + /** + * Create a new `ErrorEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.error=null] The error that generated this event + * @param {String} [options.message=''] The error message + */ + constructor(type, options = {}) { + super(type); + this[kError] = options.error === void 0 ? null : options.error; + this[kMessage] = options.message === void 0 ? "" : options.message; + } + /** + * @type {*} + */ + get error() { + return this[kError]; + } + /** + * @type {String} + */ + get message() { + return this[kMessage]; + } + }; + Object.defineProperty(ErrorEvent.prototype, "error", { enumerable: true }); + Object.defineProperty(ErrorEvent.prototype, "message", { enumerable: true }); + var MessageEvent = class extends Event { + /** + * Create a new `MessageEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.data=null] The message content + */ + constructor(type, options = {}) { + super(type); + this[kData] = options.data === void 0 ? null : options.data; + } + /** + * @type {*} + */ + get data() { + return this[kData]; + } + }; + Object.defineProperty(MessageEvent.prototype, "data", { enumerable: true }); + var EventTarget = { + /** + * Register an event listener. + * + * @param {String} type A string representing the event type to listen for + * @param {(Function|Object)} handler The listener to add + * @param {Object} [options] An options object specifies characteristics about + * the event listener + * @param {Boolean} [options.once=false] A `Boolean` indicating that the + * listener should be invoked at most once after being added. If `true`, + * the listener would be automatically removed when invoked. + * @public + */ + addEventListener(type, handler, options = {}) { + for (const listener of this.listeners(type)) { + if (!options[kForOnEventAttribute] && listener[kListener] === handler && !listener[kForOnEventAttribute]) { + return; + } + } + let wrapper; + if (type === "message") { + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent("message", { + data: isBinary ? data : data.toString() + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "close") { + wrapper = function onClose(code, message) { + const event = new CloseEvent("close", { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "error") { + wrapper = function onError(error) { + const event = new ErrorEvent("error", { + error, + message: error.message + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "open") { + wrapper = function onOpen() { + const event = new Event("open"); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else { + return; + } + wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; + wrapper[kListener] = handler; + if (options.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); + } + }, + /** + * Remove an event listener. + * + * @param {String} type A string representing the event type to remove + * @param {(Function|Object)} handler The listener to remove + * @public + */ + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; + } + } + } + }; + module2.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent + }; + function callListener(listener, thisArg, event) { + if (typeof listener === "object" && listener.handleEvent) { + listener.handleEvent.call(listener, event); + } else { + listener.call(thisArg, event); + } + } + } +}); + +// node_modules/ws/lib/extension.js +var require_extension = __commonJS({ + "node_modules/ws/lib/extension.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation(); + function push(dest, name, elem) { + if (dest[name] === void 0) dest[name] = [elem]; + else dest[name].push(elem); + } + function parse4(header) { + const offers = /* @__PURE__ */ Object.create(null); + let params = /* @__PURE__ */ Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let code = -1; + let end = -1; + let i3 = 0; + for (; i3 < header.length; i3++) { + code = header.charCodeAt(i3); + if (extensionName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i3; + } else if (i3 !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i3; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + if (end === -1) end = i3; + const name = header.slice(start, end); + if (code === 44) { + push(offers, name, params); + params = /* @__PURE__ */ Object.create(null); + } else { + extensionName = name; + } + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + } else if (paramName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i3; + } else if (code === 32 || code === 9) { + if (end === -1 && start !== -1) end = i3; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + if (end === -1) end = i3; + push(params, header.slice(start, end), true); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + start = end = -1; + } else if (code === 61 && start !== -1 && end === -1) { + paramName = header.slice(start, i3); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + } else { + if (isEscaping) { + if (tokenChars[code] !== 1) { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + if (start === -1) start = i3; + else if (!mustUnescape) mustUnescape = true; + isEscaping = false; + } else if (inQuotes) { + if (tokenChars[code] === 1) { + if (start === -1) start = i3; + } else if (code === 34 && start !== -1) { + inQuotes = false; + end = i3; + } else if (code === 92) { + isEscaping = true; + } else { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + } else if (code === 34 && header.charCodeAt(i3 - 1) === 61) { + inQuotes = true; + } else if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i3; + } else if (start !== -1 && (code === 32 || code === 9)) { + if (end === -1) end = i3; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + if (end === -1) end = i3; + let value = header.slice(start, end); + if (mustUnescape) { + value = value.replace(/\\/g, ""); + mustUnescape = false; + } + push(params, paramName, value); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + paramName = void 0; + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + } + } + if (start === -1 || inQuotes || code === 32 || code === 9) { + throw new SyntaxError("Unexpected end of input"); + } + if (end === -1) end = i3; + const token = header.slice(start, end); + if (extensionName === void 0) { + push(offers, token, params); + } else { + if (paramName === void 0) { + push(params, token, true); + } else if (mustUnescape) { + push(params, paramName, token.replace(/\\/g, "")); + } else { + push(params, paramName, token); + } + push(offers, extensionName, params); + } + return offers; + } + function format(extensions) { + return Object.keys(extensions).map((extension) => { + let configurations = extensions[extension]; + if (!Array.isArray(configurations)) configurations = [configurations]; + return configurations.map((params) => { + return [extension].concat( + Object.keys(params).map((k) => { + let values = params[k]; + if (!Array.isArray(values)) values = [values]; + return values.map((v) => v === true ? k : `${k}=${v}`).join("; "); + }) + ).join("; "); + }).join(", "); + }).join(", "); + } + module2.exports = { format, parse: parse4 }; + } +}); + +// node_modules/ws/lib/websocket.js +var require_websocket = __commonJS({ + "node_modules/ws/lib/websocket.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var https = require("https"); + var http = require("http"); + var net = require("net"); + var tls = require("tls"); + var { randomBytes: randomBytes3, createHash } = require("crypto"); + var { Duplex, Readable } = require("stream"); + var { URL: URL2 } = require("url"); + var PerMessageDeflate = require_permessage_deflate(); + var Receiver2 = require_receiver(); + var Sender2 = require_sender(); + var { isBlob } = require_validation(); + var { + BINARY_TYPES, + EMPTY_BUFFER: EMPTY_BUFFER2, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP + } = require_constants(); + var { + EventTarget: { addEventListener, removeEventListener } + } = require_event_target(); + var { format, parse: parse4 } = require_extension(); + var { toBuffer } = require_buffer_util(); + var closeTimeout = 30 * 1e3; + var kAborted = Symbol("kAborted"); + var protocolVersions = [8, 13]; + var readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]; + var subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + var WebSocket3 = class _WebSocket4 extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options) { + super(); + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER2; + this._closeTimer = null; + this._errorEmitted = false; + this._extensions = {}; + this._paused = false; + this._protocol = ""; + this._readyState = _WebSocket4.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + if (protocols === void 0) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === "object" && protocols !== null) { + options = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + initAsClient(this, address, protocols, options); + } else { + this._autoPong = options.autoPong; + this._isServer = true; + } + } + /** + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + this._binaryType = type; + if (this._receiver) this._receiver._binaryType = type; + } + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + return this._socket._writableState.length + this._sender._bufferedBytes; + } + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + /** + * @type {String} + */ + get url() { + return this._url; + } + /** + * Set up the socket and the internal resources. + * + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + const receiver = new Receiver2({ + allowSynchronousEvents: options.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + const sender = new Sender2(socket, this._extensions, options.generateMask); + this._receiver = receiver; + this._sender = sender; + this._socket = socket; + receiver[kWebSocket] = this; + sender[kWebSocket] = this; + socket[kWebSocket] = this; + receiver.on("conclude", receiverOnConclude); + receiver.on("drain", receiverOnDrain); + receiver.on("error", receiverOnError); + receiver.on("message", receiverOnMessage); + receiver.on("ping", receiverOnPing); + receiver.on("pong", receiverOnPong); + sender.onerror = senderOnError; + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); + if (head.length > 0) socket.unshift(head); + socket.on("close", socketOnClose); + socket.on("data", socketOnData); + socket.on("end", socketOnEnd); + socket.on("error", socketOnError); + this._readyState = _WebSocket4.OPEN; + this.emit("open"); + } + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = _WebSocket4.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + return; + } + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + this._receiver.removeAllListeners(); + this._readyState = _WebSocket4.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + } + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === _WebSocket4.CLOSED) return; + if (this.readyState === _WebSocket4.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this.readyState === _WebSocket4.CLOSING) { + if (this._closeFrameSent && (this._closeFrameReceived || this._receiver._writableState.errorEmitted)) { + this._socket.end(); + } + return; + } + this._readyState = _WebSocket4.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + if (err) return; + this._closeFrameSent = true; + if (this._closeFrameReceived || this._receiver._writableState.errorEmitted) { + this._socket.end(); + } + }); + setCloseTimer(this); + } + /** + * Pause the socket. + * + * @public + */ + pause() { + if (this.readyState === _WebSocket4.CONNECTING || this.readyState === _WebSocket4.CLOSED) { + return; + } + this._paused = true; + this._socket.pause(); + } + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === _WebSocket4.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket4.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER2, mask, cb); + } + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === _WebSocket4.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket4.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER2, mask, cb); + } + /** + * Resume the socket. + * + * @public + */ + resume() { + if (this.readyState === _WebSocket4.CONNECTING || this.readyState === _WebSocket4.CLOSED) { + return; + } + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options, cb) { + if (this.readyState === _WebSocket4.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof options === "function") { + cb = options; + options = {}; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket4.OPEN) { + sendAfterClose(this, data, cb); + return; + } + const opts = { + binary: typeof data !== "string", + mask: !this._isServer, + compress: true, + fin: true, + ...options + }; + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + this._sender.send(data || EMPTY_BUFFER2, opts, cb); + } + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === _WebSocket4.CLOSED) return; + if (this.readyState === _WebSocket4.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this._socket) { + this._readyState = _WebSocket4.CLOSING; + this._socket.destroy(); + } + } + }; + Object.defineProperty(WebSocket3, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket3.prototype, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket3, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket3.prototype, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket3, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket3.prototype, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket3, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + Object.defineProperty(WebSocket3.prototype, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + [ + "binaryType", + "bufferedAmount", + "extensions", + "isPaused", + "protocol", + "readyState", + "url" + ].forEach((property) => { + Object.defineProperty(WebSocket3.prototype, property, { enumerable: true }); + }); + ["open", "error", "close", "message"].forEach((method) => { + Object.defineProperty(WebSocket3.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + if (typeof handler !== "function") return; + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); + }); + WebSocket3.prototype.addEventListener = addEventListener; + WebSocket3.prototype.removeEventListener = removeEventListener; + module2.exports = WebSocket3; + function initAsClient(websocket, address, protocols, options) { + const opts = { + allowSynchronousEvents: true, + autoPong: true, + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + socketPath: void 0, + hostname: void 0, + protocol: void 0, + timeout: void 0, + method: "GET", + host: void 0, + path: void 0, + port: void 0 + }; + websocket._autoPong = opts.autoPong; + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} (supported versions: ${protocolVersions.join(", ")})` + ); + } + let parsedUrl; + if (address instanceof URL2) { + parsedUrl = address; + } else { + try { + parsedUrl = new URL2(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } + } + if (parsedUrl.protocol === "http:") { + parsedUrl.protocol = "ws:"; + } else if (parsedUrl.protocol === "https:") { + parsedUrl.protocol = "wss:"; + } + websocket._url = parsedUrl.href; + const isSecure = parsedUrl.protocol === "wss:"; + const isIpcUrl = parsedUrl.protocol === "ws+unix:"; + let invalidUrlMessage; + if (parsedUrl.protocol !== "ws:" && !isSecure && !isIpcUrl) { + invalidUrlMessage = `The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`; + } else if (isIpcUrl && !parsedUrl.pathname) { + invalidUrlMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidUrlMessage = "The URL contains a fragment identifier"; + } + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes3(16).toString("base64"); + const request = isSecure ? https.request : http.request; + const protocolSet = /* @__PURE__ */ new Set(); + let perMessageDeflate; + opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect); + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith("[") ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + "Sec-WebSocket-Version": opts.protocolVersion, + "Sec-WebSocket-Key": key, + Connection: "Upgrade", + Upgrade: "websocket" + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate( + opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, + false, + opts.maxPayload + ); + opts.headers["Sec-WebSocket-Extensions"] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if (typeof protocol !== "string" || !subprotocolRegex.test(protocol) || protocolSet.has(protocol)) { + throw new SyntaxError( + "An invalid or duplicated subprotocol was specified" + ); + } + protocolSet.add(protocol); + } + opts.headers["Sec-WebSocket-Protocol"] = protocols.join(","); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers["Sec-WebSocket-Origin"] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + if (isIpcUrl) { + const parts = opts.path.split(":"); + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + let req; + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl ? opts.socketPath : parsedUrl.host; + const headers = options && options.headers; + options = { ...options, headers: {} }; + if (headers) { + for (const [key2, value] of Object.entries(headers)) { + options.headers[key2.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount("redirect") === 0) { + const isSameHost = isIpcUrl ? websocket._originalIpc ? opts.socketPath === websocket._originalHostOrSocketPath : false : websocket._originalIpc ? false : parsedUrl.host === websocket._originalHostOrSocketPath; + if (!isSameHost || websocket._originalSecure && !isSecure) { + delete opts.headers.authorization; + delete opts.headers.cookie; + if (!isSameHost) delete opts.headers.host; + opts.auth = void 0; + } + } + if (opts.auth && !options.headers.authorization) { + options.headers.authorization = "Basic " + Buffer.from(opts.auth).toString("base64"); + } + req = websocket._req = request(opts); + if (websocket._redirects) { + websocket.emit("redirect", websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + if (opts.timeout) { + req.on("timeout", () => { + abortHandshake(websocket, req, "Opening handshake has timed out"); + }); + } + req.on("error", (err) => { + if (req === null || req[kAborted]) return; + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + req.on("response", (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + if (location && opts.followRedirects && statusCode >= 300 && statusCode < 400) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, "Maximum redirects exceeded"); + return; + } + req.abort(); + let addr; + try { + addr = new URL2(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + initAsClient(websocket, addr, protocols, options); + } else if (!websocket.emit("unexpected-response", req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + req.on("upgrade", (res, socket, head) => { + websocket.emit("upgrade", res); + if (websocket.readyState !== WebSocket3.CONNECTING) return; + req = websocket._req = null; + const upgrade = res.headers.upgrade; + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + abortHandshake(websocket, socket, "Invalid Upgrade header"); + return; + } + const digest = createHash("sha1").update(key + GUID).digest("base64"); + if (res.headers["sec-websocket-accept"] !== digest) { + abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header"); + return; + } + const serverProt = res.headers["sec-websocket-protocol"]; + let protError; + if (serverProt !== void 0) { + if (!protocolSet.size) { + protError = "Server sent a subprotocol but none was requested"; + } else if (!protocolSet.has(serverProt)) { + protError = "Server sent an invalid subprotocol"; + } + } else if (protocolSet.size) { + protError = "Server sent no subprotocol"; + } + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + if (serverProt) websocket._protocol = serverProt; + const secWebSocketExtensions = res.headers["sec-websocket-extensions"]; + if (secWebSocketExtensions !== void 0) { + if (!perMessageDeflate) { + const message = "Server sent a Sec-WebSocket-Extensions header but no extension was requested"; + abortHandshake(websocket, socket, message); + return; + } + let extensions; + try { + extensions = parse4(secWebSocketExtensions); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + const extensionNames = Object.keys(extensions); + if (extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate.extensionName) { + const message = "Server indicated an extension that was not requested"; + abortHandshake(websocket, socket, message); + return; + } + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + websocket._extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } + } + function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket3.CLOSING; + websocket._errorEmitted = true; + websocket.emit("error", err); + websocket.emitClose(); + } + function netConnect(options) { + options.path = options.socketPath; + return net.connect(options); + } + function tlsConnect(options) { + options.path = void 0; + if (!options.servername && options.servername !== "") { + options.servername = net.isIP(options.host) ? "" : options.host; + } + return tls.connect(options); + } + function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket3.CLOSING; + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + if (stream.socket && !stream.socket.destroyed) { + stream.socket.destroy(); + } + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once("error", websocket.emit.bind(websocket, "error")); + stream.once("close", websocket.emitClose.bind(websocket)); + } + } + function sendAfterClose(websocket, data, cb) { + if (data) { + const length = isBlob(data) ? data.size : toBuffer(data).length; + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} (${readyStates[websocket.readyState]})` + ); + process.nextTick(cb, err); + } + } + function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + if (websocket._socket[kWebSocket] === void 0) return; + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + if (code === 1005) websocket.close(); + else websocket.close(code, reason); + } + function receiverOnDrain() { + const websocket = this[kWebSocket]; + if (!websocket.isPaused) websocket._socket.resume(); + } + function receiverOnError(err) { + const websocket = this[kWebSocket]; + if (websocket._socket[kWebSocket] !== void 0) { + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + websocket.close(err[kStatusCode]); + } + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function receiverOnFinish() { + this[kWebSocket].emitClose(); + } + function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit("message", data, isBinary); + } + function receiverOnPing(data) { + const websocket = this[kWebSocket]; + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); + websocket.emit("ping", data); + } + function receiverOnPong(data) { + this[kWebSocket].emit("pong", data); + } + function resume(stream) { + stream.resume(); + } + function senderOnError(err) { + const websocket = this[kWebSocket]; + if (websocket.readyState === WebSocket3.CLOSED) return; + if (websocket.readyState === WebSocket3.OPEN) { + websocket._readyState = WebSocket3.CLOSING; + setCloseTimer(websocket); + } + this._socket.end(); + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + closeTimeout + ); + } + function socketOnClose() { + const websocket = this[kWebSocket]; + this.removeListener("close", socketOnClose); + this.removeListener("data", socketOnData); + this.removeListener("end", socketOnEnd); + websocket._readyState = WebSocket3.CLOSING; + let chunk; + if (!this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && (chunk = websocket._socket.read()) !== null) { + websocket._receiver.write(chunk); + } + websocket._receiver.end(); + this[kWebSocket] = void 0; + clearTimeout(websocket._closeTimer); + if (websocket._receiver._writableState.finished || websocket._receiver._writableState.errorEmitted) { + websocket.emitClose(); + } else { + websocket._receiver.on("error", receiverOnFinish); + websocket._receiver.on("finish", receiverOnFinish); + } + } + function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } + } + function socketOnEnd() { + const websocket = this[kWebSocket]; + websocket._readyState = WebSocket3.CLOSING; + websocket._receiver.end(); + this.end(); + } + function socketOnError() { + const websocket = this[kWebSocket]; + this.removeListener("error", socketOnError); + this.on("error", NOOP); + if (websocket) { + websocket._readyState = WebSocket3.CLOSING; + this.destroy(); + } + } + } +}); + +// node_modules/ws/lib/stream.js +var require_stream = __commonJS({ + "node_modules/ws/lib/stream.js"(exports2, module2) { + "use strict"; + var WebSocket3 = require_websocket(); + var { Duplex } = require("stream"); + function emitClose(stream) { + stream.emit("close"); + } + function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } + } + function duplexOnError(err) { + this.removeListener("error", duplexOnError); + this.destroy(); + if (this.listenerCount("error") === 0) { + this.emit("error", err); + } + } + function createWebSocketStream2(ws, options) { + let terminateOnDestroy = true; + const duplex = new Duplex({ + ...options, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + ws.on("message", function message(msg, isBinary) { + const data = !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + if (!duplex.push(data)) ws.pause(); + }); + ws.once("error", function error(err) { + if (duplex.destroyed) return; + terminateOnDestroy = false; + duplex.destroy(err); + }); + ws.once("close", function close() { + if (duplex.destroyed) return; + duplex.push(null); + }); + duplex._destroy = function(err, callback) { + if (ws.readyState === ws.CLOSED) { + callback(err); + process.nextTick(emitClose, duplex); + return; + } + let called = false; + ws.once("error", function error(err2) { + called = true; + callback(err2); + }); + ws.once("close", function close() { + if (!called) callback(err); + process.nextTick(emitClose, duplex); + }); + if (terminateOnDestroy) ws.terminate(); + }; + duplex._final = function(callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._final(callback); + }); + return; + } + if (ws._socket === null) return; + if (ws._socket._writableState.finished) { + callback(); + if (duplex._readableState.endEmitted) duplex.destroy(); + } else { + ws._socket.once("finish", function finish() { + callback(); + }); + ws.close(); + } + }; + duplex._read = function() { + if (ws.isPaused) ws.resume(); + }; + duplex._write = function(chunk, encoding, callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._write(chunk, encoding, callback); + }); + return; + } + ws.send(chunk, callback); + }; + duplex.on("end", duplexOnEnd); + duplex.on("error", duplexOnError); + return duplex; + } + module2.exports = createWebSocketStream2; + } +}); + +// node_modules/ws/lib/subprotocol.js +var require_subprotocol = __commonJS({ + "node_modules/ws/lib/subprotocol.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation(); + function parse4(header) { + const protocols = /* @__PURE__ */ new Set(); + let start = -1; + let end = -1; + let i3 = 0; + for (i3; i3 < header.length; i3++) { + const code = header.charCodeAt(i3); + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i3; + } else if (i3 !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i3; + } else if (code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + if (end === -1) end = i3; + const protocol2 = header.slice(start, end); + if (protocols.has(protocol2)) { + throw new SyntaxError(`The "${protocol2}" subprotocol is duplicated`); + } + protocols.add(protocol2); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i3}`); + } + } + if (start === -1 || end !== -1) { + throw new SyntaxError("Unexpected end of input"); + } + const protocol = header.slice(start, i3); + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + protocols.add(protocol); + return protocols; + } + module2.exports = { parse: parse4 }; + } +}); + +// node_modules/ws/lib/websocket-server.js +var require_websocket_server = __commonJS({ + "node_modules/ws/lib/websocket-server.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var http = require("http"); + var { Duplex } = require("stream"); + var { createHash } = require("crypto"); + var extension = require_extension(); + var PerMessageDeflate = require_permessage_deflate(); + var subprotocol = require_subprotocol(); + var WebSocket3 = require_websocket(); + var { GUID, kWebSocket } = require_constants(); + var keyRegex = /^[+/0-9A-Za-z]{22}==$/; + var RUNNING = 0; + var CLOSING = 1; + var CLOSED = 2; + var WebSocketServer2 = class extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options, callback) { + super(); + options = { + allowSynchronousEvents: true, + autoPong: true, + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + verifyClient: null, + noServer: false, + backlog: null, + // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + WebSocket: WebSocket3, + ...options + }; + if (options.port == null && !options.server && !options.noServer || options.port != null && (options.server || options.noServer) || options.server && options.noServer) { + throw new TypeError( + 'One and only one of the "port", "server", or "noServer" options must be specified' + ); + } + if (options.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + res.writeHead(426, { + "Content-Length": body.length, + "Content-Type": "text/plain" + }); + res.end(body); + }); + this._server.listen( + options.port, + options.host, + options.backlog, + callback + ); + } else if (options.server) { + this._server = options.server; + } + if (this._server) { + const emitConnection = this.emit.bind(this, "connection"); + this._removeListeners = addListeners(this._server, { + listening: this.emit.bind(this, "listening"), + error: this.emit.bind(this, "error"), + upgrade: (req, socket, head) => { + this.handleUpgrade(req, socket, head, emitConnection); + } + }); + } + if (options.perMessageDeflate === true) options.perMessageDeflate = {}; + if (options.clientTracking) { + this.clients = /* @__PURE__ */ new Set(); + this._shouldEmitClose = false; + } + this.options = options; + this._state = RUNNING; + } + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + if (!this._server) return null; + return this._server.address(); + } + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once("close", () => { + cb(new Error("The server is not running")); + }); + } + process.nextTick(emitClose, this); + return; + } + if (cb) this.once("close", cb); + if (this._state === CLOSING) return; + this._state = CLOSING; + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; + this._removeListeners(); + this._removeListeners = this._server = null; + server.close(() => { + emitClose(this); + }); + } + } + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf("?"); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + if (pathname !== this.options.path) return false; + } + return true; + } + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + socket.on("error", socketOnError); + const key = req.headers["sec-websocket-key"]; + const upgrade = req.headers.upgrade; + const version = +req.headers["sec-websocket-version"]; + if (req.method !== "GET") { + const message = "Invalid HTTP method"; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + const message = "Invalid Upgrade header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (key === void 0 || !keyRegex.test(key)) { + const message = "Missing or invalid Sec-WebSocket-Key header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (version !== 13 && version !== 8) { + const message = "Missing or invalid Sec-WebSocket-Version header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { + "Sec-WebSocket-Version": "13, 8" + }); + return; + } + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; + } + const secWebSocketProtocol = req.headers["sec-websocket-protocol"]; + let protocols = /* @__PURE__ */ new Set(); + if (secWebSocketProtocol !== void 0) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + const message = "Invalid Sec-WebSocket-Protocol header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + const secWebSocketExtensions = req.headers["sec-websocket-extensions"]; + const extensions = {}; + if (this.options.perMessageDeflate && secWebSocketExtensions !== void 0) { + const perMessageDeflate = new PerMessageDeflate( + this.options.perMessageDeflate, + true, + this.options.maxPayload + ); + try { + const offers = extension.parse(secWebSocketExtensions); + if (offers[PerMessageDeflate.extensionName]) { + perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + } catch (err) { + const message = "Invalid or unacceptable Sec-WebSocket-Extensions header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + if (this.options.verifyClient) { + const info = { + origin: req.headers[`${version === 8 ? "sec-websocket-origin" : "origin"}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req + }; + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(socket, code || 401, message, headers); + } + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); + }); + return; + } + if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); + } + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + } + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket + * @private + */ + completeUpgrade(extensions, key, protocols, req, socket, head, cb) { + if (!socket.readable || !socket.writable) return socket.destroy(); + if (socket[kWebSocket]) { + throw new Error( + "server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration" + ); + } + if (this._state > RUNNING) return abortHandshake(socket, 503); + const digest = createHash("sha1").update(key + GUID).digest("base64"); + const headers = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${digest}` + ]; + const ws = new this.options.WebSocket(null, void 0, this.options); + if (protocols.size) { + const protocol = this.options.handleProtocols ? this.options.handleProtocols(protocols, req) : protocols.values().next().value; + if (protocol) { + headers.push(`Sec-WebSocket-Protocol: ${protocol}`); + ws._protocol = protocol; + } + } + if (extensions[PerMessageDeflate.extensionName]) { + const params = extensions[PerMessageDeflate.extensionName].params; + const value = extension.format({ + [PerMessageDeflate.extensionName]: [params] + }); + headers.push(`Sec-WebSocket-Extensions: ${value}`); + ws._extensions = extensions; + } + this.emit("headers", headers, req); + socket.write(headers.concat("\r\n").join("\r\n")); + socket.removeListener("error", socketOnError); + ws.setSocket(socket, head, { + allowSynchronousEvents: this.options.allowSynchronousEvents, + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); + if (this.clients) { + this.clients.add(ws); + ws.on("close", () => { + this.clients.delete(ws); + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); + } + cb(ws, req); + } + }; + module2.exports = WebSocketServer2; + function addListeners(server, map) { + for (const event of Object.keys(map)) server.on(event, map[event]); + return function removeListeners() { + for (const event of Object.keys(map)) { + server.removeListener(event, map[event]); + } + }; + } + function emitClose(server) { + server._state = CLOSED; + server.emit("close"); + } + function socketOnError() { + this.destroy(); + } + function abortHandshake(socket, code, message, headers) { + message = message || http.STATUS_CODES[code]; + headers = { + Connection: "close", + "Content-Type": "text/html", + "Content-Length": Buffer.byteLength(message), + ...headers + }; + socket.once("finish", socket.destroy); + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r +` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join("\r\n") + "\r\n\r\n" + message + ); + } + function abortHandshakeOrEmitwsClientError(server, req, socket, code, message, headers) { + if (server.listenerCount("wsClientError")) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + server.emit("wsClientError", err, socket, req); + } else { + abortHandshake(socket, code, message, headers); + } + } + } +}); + +// node_modules/qrcode/lib/can-promise.js +var require_can_promise = __commonJS({ + "node_modules/qrcode/lib/can-promise.js"(exports2, module2) { + module2.exports = function() { + return typeof Promise === "function" && Promise.prototype && Promise.prototype.then; + }; + } +}); + +// node_modules/qrcode/lib/core/utils.js +var require_utils = __commonJS({ + "node_modules/qrcode/lib/core/utils.js"(exports2) { + var toSJISFunction; + var CODEWORDS_COUNT = [ + 0, + // Not used + 26, + 44, + 70, + 100, + 134, + 172, + 196, + 242, + 292, + 346, + 404, + 466, + 532, + 581, + 655, + 733, + 815, + 901, + 991, + 1085, + 1156, + 1258, + 1364, + 1474, + 1588, + 1706, + 1828, + 1921, + 2051, + 2185, + 2323, + 2465, + 2611, + 2761, + 2876, + 3034, + 3196, + 3362, + 3532, + 3706 + ]; + exports2.getSymbolSize = function getSymbolSize(version) { + if (!version) throw new Error('"version" cannot be null or undefined'); + if (version < 1 || version > 40) throw new Error('"version" should be in range from 1 to 40'); + return version * 4 + 17; + }; + exports2.getSymbolTotalCodewords = function getSymbolTotalCodewords(version) { + return CODEWORDS_COUNT[version]; + }; + exports2.getBCHDigit = function(data) { + let digit = 0; + while (data !== 0) { + digit++; + data >>>= 1; + } + return digit; + }; + exports2.setToSJISFunction = function setToSJISFunction(f) { + if (typeof f !== "function") { + throw new Error('"toSJISFunc" is not a valid function.'); + } + toSJISFunction = f; + }; + exports2.isKanjiModeEnabled = function() { + return typeof toSJISFunction !== "undefined"; + }; + exports2.toSJIS = function toSJIS(kanji) { + return toSJISFunction(kanji); + }; + } +}); + +// node_modules/qrcode/lib/core/error-correction-level.js +var require_error_correction_level = __commonJS({ + "node_modules/qrcode/lib/core/error-correction-level.js"(exports2) { + exports2.L = { bit: 1 }; + exports2.M = { bit: 0 }; + exports2.Q = { bit: 3 }; + exports2.H = { bit: 2 }; + function fromString(string) { + if (typeof string !== "string") { + throw new Error("Param is not a string"); + } + const lcStr = string.toLowerCase(); + switch (lcStr) { + case "l": + case "low": + return exports2.L; + case "m": + case "medium": + return exports2.M; + case "q": + case "quartile": + return exports2.Q; + case "h": + case "high": + return exports2.H; + default: + throw new Error("Unknown EC Level: " + string); + } + } + exports2.isValid = function isValid2(level) { + return level && typeof level.bit !== "undefined" && level.bit >= 0 && level.bit < 4; + }; + exports2.from = function from(value, defaultValue) { + if (exports2.isValid(value)) { + return value; + } + try { + return fromString(value); + } catch (e) { + return defaultValue; + } + }; + } +}); + +// node_modules/qrcode/lib/core/bit-buffer.js +var require_bit_buffer = __commonJS({ + "node_modules/qrcode/lib/core/bit-buffer.js"(exports2, module2) { + function BitBuffer() { + this.buffer = []; + this.length = 0; + } + BitBuffer.prototype = { + get: function(index) { + const bufIndex = Math.floor(index / 8); + return (this.buffer[bufIndex] >>> 7 - index % 8 & 1) === 1; + }, + put: function(num, length) { + for (let i3 = 0; i3 < length; i3++) { + this.putBit((num >>> length - i3 - 1 & 1) === 1); + } + }, + getLengthInBits: function() { + return this.length; + }, + putBit: function(bit) { + const bufIndex = Math.floor(this.length / 8); + if (this.buffer.length <= bufIndex) { + this.buffer.push(0); + } + if (bit) { + this.buffer[bufIndex] |= 128 >>> this.length % 8; + } + this.length++; + } + }; + module2.exports = BitBuffer; + } +}); + +// node_modules/qrcode/lib/core/bit-matrix.js +var require_bit_matrix = __commonJS({ + "node_modules/qrcode/lib/core/bit-matrix.js"(exports2, module2) { + function BitMatrix(size) { + if (!size || size < 1) { + throw new Error("BitMatrix size must be defined and greater than 0"); + } + this.size = size; + this.data = new Uint8Array(size * size); + this.reservedBit = new Uint8Array(size * size); + } + BitMatrix.prototype.set = function(row, col, value, reserved) { + const index = row * this.size + col; + this.data[index] = value; + if (reserved) this.reservedBit[index] = true; + }; + BitMatrix.prototype.get = function(row, col) { + return this.data[row * this.size + col]; + }; + BitMatrix.prototype.xor = function(row, col, value) { + this.data[row * this.size + col] ^= value; + }; + BitMatrix.prototype.isReserved = function(row, col) { + return this.reservedBit[row * this.size + col]; + }; + module2.exports = BitMatrix; + } +}); + +// node_modules/qrcode/lib/core/alignment-pattern.js +var require_alignment_pattern = __commonJS({ + "node_modules/qrcode/lib/core/alignment-pattern.js"(exports2) { + var getSymbolSize = require_utils().getSymbolSize; + exports2.getRowColCoords = function getRowColCoords(version) { + if (version === 1) return []; + const posCount = Math.floor(version / 7) + 2; + const size = getSymbolSize(version); + const intervals = size === 145 ? 26 : Math.ceil((size - 13) / (2 * posCount - 2)) * 2; + const positions = [size - 7]; + for (let i3 = 1; i3 < posCount - 1; i3++) { + positions[i3] = positions[i3 - 1] - intervals; + } + positions.push(6); + return positions.reverse(); + }; + exports2.getPositions = function getPositions(version) { + const coords = []; + const pos = exports2.getRowColCoords(version); + const posLength = pos.length; + for (let i3 = 0; i3 < posLength; i3++) { + for (let j = 0; j < posLength; j++) { + if (i3 === 0 && j === 0 || // top-left + i3 === 0 && j === posLength - 1 || // bottom-left + i3 === posLength - 1 && j === 0) { + continue; + } + coords.push([pos[i3], pos[j]]); + } + } + return coords; + }; + } +}); + +// node_modules/qrcode/lib/core/finder-pattern.js +var require_finder_pattern = __commonJS({ + "node_modules/qrcode/lib/core/finder-pattern.js"(exports2) { + var getSymbolSize = require_utils().getSymbolSize; + var FINDER_PATTERN_SIZE = 7; + exports2.getPositions = function getPositions(version) { + const size = getSymbolSize(version); + return [ + // top-left + [0, 0], + // top-right + [size - FINDER_PATTERN_SIZE, 0], + // bottom-left + [0, size - FINDER_PATTERN_SIZE] + ]; + }; + } +}); + +// node_modules/qrcode/lib/core/mask-pattern.js +var require_mask_pattern = __commonJS({ + "node_modules/qrcode/lib/core/mask-pattern.js"(exports2) { + exports2.Patterns = { + PATTERN000: 0, + PATTERN001: 1, + PATTERN010: 2, + PATTERN011: 3, + PATTERN100: 4, + PATTERN101: 5, + PATTERN110: 6, + PATTERN111: 7 + }; + var PenaltyScores = { + N1: 3, + N2: 3, + N3: 40, + N4: 10 + }; + exports2.isValid = function isValid2(mask) { + return mask != null && mask !== "" && !isNaN(mask) && mask >= 0 && mask <= 7; + }; + exports2.from = function from(value) { + return exports2.isValid(value) ? parseInt(value, 10) : void 0; + }; + exports2.getPenaltyN1 = function getPenaltyN1(data) { + const size = data.size; + let points = 0; + let sameCountCol = 0; + let sameCountRow = 0; + let lastCol = null; + let lastRow = null; + for (let row = 0; row < size; row++) { + sameCountCol = sameCountRow = 0; + lastCol = lastRow = null; + for (let col = 0; col < size; col++) { + let module3 = data.get(row, col); + if (module3 === lastCol) { + sameCountCol++; + } else { + if (sameCountCol >= 5) points += PenaltyScores.N1 + (sameCountCol - 5); + lastCol = module3; + sameCountCol = 1; + } + module3 = data.get(col, row); + if (module3 === lastRow) { + sameCountRow++; + } else { + if (sameCountRow >= 5) points += PenaltyScores.N1 + (sameCountRow - 5); + lastRow = module3; + sameCountRow = 1; + } + } + if (sameCountCol >= 5) points += PenaltyScores.N1 + (sameCountCol - 5); + if (sameCountRow >= 5) points += PenaltyScores.N1 + (sameCountRow - 5); + } + return points; + }; + exports2.getPenaltyN2 = function getPenaltyN2(data) { + const size = data.size; + let points = 0; + for (let row = 0; row < size - 1; row++) { + for (let col = 0; col < size - 1; col++) { + const last = data.get(row, col) + data.get(row, col + 1) + data.get(row + 1, col) + data.get(row + 1, col + 1); + if (last === 4 || last === 0) points++; + } + } + return points * PenaltyScores.N2; + }; + exports2.getPenaltyN3 = function getPenaltyN3(data) { + const size = data.size; + let points = 0; + let bitsCol = 0; + let bitsRow = 0; + for (let row = 0; row < size; row++) { + bitsCol = bitsRow = 0; + for (let col = 0; col < size; col++) { + bitsCol = bitsCol << 1 & 2047 | data.get(row, col); + if (col >= 10 && (bitsCol === 1488 || bitsCol === 93)) points++; + bitsRow = bitsRow << 1 & 2047 | data.get(col, row); + if (col >= 10 && (bitsRow === 1488 || bitsRow === 93)) points++; + } + } + return points * PenaltyScores.N3; + }; + exports2.getPenaltyN4 = function getPenaltyN4(data) { + let darkCount = 0; + const modulesCount = data.data.length; + for (let i3 = 0; i3 < modulesCount; i3++) darkCount += data.data[i3]; + const k = Math.abs(Math.ceil(darkCount * 100 / modulesCount / 5) - 10); + return k * PenaltyScores.N4; + }; + function getMaskAt(maskPattern, i3, j) { + switch (maskPattern) { + case exports2.Patterns.PATTERN000: + return (i3 + j) % 2 === 0; + case exports2.Patterns.PATTERN001: + return i3 % 2 === 0; + case exports2.Patterns.PATTERN010: + return j % 3 === 0; + case exports2.Patterns.PATTERN011: + return (i3 + j) % 3 === 0; + case exports2.Patterns.PATTERN100: + return (Math.floor(i3 / 2) + Math.floor(j / 3)) % 2 === 0; + case exports2.Patterns.PATTERN101: + return i3 * j % 2 + i3 * j % 3 === 0; + case exports2.Patterns.PATTERN110: + return (i3 * j % 2 + i3 * j % 3) % 2 === 0; + case exports2.Patterns.PATTERN111: + return (i3 * j % 3 + (i3 + j) % 2) % 2 === 0; + default: + throw new Error("bad maskPattern:" + maskPattern); + } + } + exports2.applyMask = function applyMask(pattern, data) { + const size = data.size; + for (let col = 0; col < size; col++) { + for (let row = 0; row < size; row++) { + if (data.isReserved(row, col)) continue; + data.xor(row, col, getMaskAt(pattern, row, col)); + } + } + }; + exports2.getBestMask = function getBestMask(data, setupFormatFunc) { + const numPatterns = Object.keys(exports2.Patterns).length; + let bestPattern = 0; + let lowerPenalty = Infinity; + for (let p = 0; p < numPatterns; p++) { + setupFormatFunc(p); + exports2.applyMask(p, data); + const penalty = exports2.getPenaltyN1(data) + exports2.getPenaltyN2(data) + exports2.getPenaltyN3(data) + exports2.getPenaltyN4(data); + exports2.applyMask(p, data); + if (penalty < lowerPenalty) { + lowerPenalty = penalty; + bestPattern = p; + } + } + return bestPattern; + }; + } +}); + +// node_modules/qrcode/lib/core/error-correction-code.js +var require_error_correction_code = __commonJS({ + "node_modules/qrcode/lib/core/error-correction-code.js"(exports2) { + var ECLevel = require_error_correction_level(); + var EC_BLOCKS_TABLE = [ + // L M Q H + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 1, + 2, + 2, + 4, + 1, + 2, + 4, + 4, + 2, + 4, + 4, + 4, + 2, + 4, + 6, + 5, + 2, + 4, + 6, + 6, + 2, + 5, + 8, + 8, + 4, + 5, + 8, + 8, + 4, + 5, + 8, + 11, + 4, + 8, + 10, + 11, + 4, + 9, + 12, + 16, + 4, + 9, + 16, + 16, + 6, + 10, + 12, + 18, + 6, + 10, + 17, + 16, + 6, + 11, + 16, + 19, + 6, + 13, + 18, + 21, + 7, + 14, + 21, + 25, + 8, + 16, + 20, + 25, + 8, + 17, + 23, + 25, + 9, + 17, + 23, + 34, + 9, + 18, + 25, + 30, + 10, + 20, + 27, + 32, + 12, + 21, + 29, + 35, + 12, + 23, + 34, + 37, + 12, + 25, + 34, + 40, + 13, + 26, + 35, + 42, + 14, + 28, + 38, + 45, + 15, + 29, + 40, + 48, + 16, + 31, + 43, + 51, + 17, + 33, + 45, + 54, + 18, + 35, + 48, + 57, + 19, + 37, + 51, + 60, + 19, + 38, + 53, + 63, + 20, + 40, + 56, + 66, + 21, + 43, + 59, + 70, + 22, + 45, + 62, + 74, + 24, + 47, + 65, + 77, + 25, + 49, + 68, + 81 + ]; + var EC_CODEWORDS_TABLE = [ + // L M Q H + 7, + 10, + 13, + 17, + 10, + 16, + 22, + 28, + 15, + 26, + 36, + 44, + 20, + 36, + 52, + 64, + 26, + 48, + 72, + 88, + 36, + 64, + 96, + 112, + 40, + 72, + 108, + 130, + 48, + 88, + 132, + 156, + 60, + 110, + 160, + 192, + 72, + 130, + 192, + 224, + 80, + 150, + 224, + 264, + 96, + 176, + 260, + 308, + 104, + 198, + 288, + 352, + 120, + 216, + 320, + 384, + 132, + 240, + 360, + 432, + 144, + 280, + 408, + 480, + 168, + 308, + 448, + 532, + 180, + 338, + 504, + 588, + 196, + 364, + 546, + 650, + 224, + 416, + 600, + 700, + 224, + 442, + 644, + 750, + 252, + 476, + 690, + 816, + 270, + 504, + 750, + 900, + 300, + 560, + 810, + 960, + 312, + 588, + 870, + 1050, + 336, + 644, + 952, + 1110, + 360, + 700, + 1020, + 1200, + 390, + 728, + 1050, + 1260, + 420, + 784, + 1140, + 1350, + 450, + 812, + 1200, + 1440, + 480, + 868, + 1290, + 1530, + 510, + 924, + 1350, + 1620, + 540, + 980, + 1440, + 1710, + 570, + 1036, + 1530, + 1800, + 570, + 1064, + 1590, + 1890, + 600, + 1120, + 1680, + 1980, + 630, + 1204, + 1770, + 2100, + 660, + 1260, + 1860, + 2220, + 720, + 1316, + 1950, + 2310, + 750, + 1372, + 2040, + 2430 + ]; + exports2.getBlocksCount = function getBlocksCount(version, errorCorrectionLevel) { + switch (errorCorrectionLevel) { + case ECLevel.L: + return EC_BLOCKS_TABLE[(version - 1) * 4 + 0]; + case ECLevel.M: + return EC_BLOCKS_TABLE[(version - 1) * 4 + 1]; + case ECLevel.Q: + return EC_BLOCKS_TABLE[(version - 1) * 4 + 2]; + case ECLevel.H: + return EC_BLOCKS_TABLE[(version - 1) * 4 + 3]; + default: + return void 0; + } + }; + exports2.getTotalCodewordsCount = function getTotalCodewordsCount(version, errorCorrectionLevel) { + switch (errorCorrectionLevel) { + case ECLevel.L: + return EC_CODEWORDS_TABLE[(version - 1) * 4 + 0]; + case ECLevel.M: + return EC_CODEWORDS_TABLE[(version - 1) * 4 + 1]; + case ECLevel.Q: + return EC_CODEWORDS_TABLE[(version - 1) * 4 + 2]; + case ECLevel.H: + return EC_CODEWORDS_TABLE[(version - 1) * 4 + 3]; + default: + return void 0; + } + }; + } +}); + +// node_modules/qrcode/lib/core/galois-field.js +var require_galois_field = __commonJS({ + "node_modules/qrcode/lib/core/galois-field.js"(exports2) { + var EXP_TABLE = new Uint8Array(512); + var LOG_TABLE = new Uint8Array(256); + (function initTables() { + let x = 1; + for (let i3 = 0; i3 < 255; i3++) { + EXP_TABLE[i3] = x; + LOG_TABLE[x] = i3; + x <<= 1; + if (x & 256) { + x ^= 285; + } + } + for (let i3 = 255; i3 < 512; i3++) { + EXP_TABLE[i3] = EXP_TABLE[i3 - 255]; + } + })(); + exports2.log = function log(n) { + if (n < 1) throw new Error("log(" + n + ")"); + return LOG_TABLE[n]; + }; + exports2.exp = function exp(n) { + return EXP_TABLE[n]; + }; + exports2.mul = function mul3(x, y) { + if (x === 0 || y === 0) return 0; + return EXP_TABLE[LOG_TABLE[x] + LOG_TABLE[y]]; + }; + } +}); + +// node_modules/qrcode/lib/core/polynomial.js +var require_polynomial = __commonJS({ + "node_modules/qrcode/lib/core/polynomial.js"(exports2) { + var GF = require_galois_field(); + exports2.mul = function mul3(p1, p2) { + const coeff = new Uint8Array(p1.length + p2.length - 1); + for (let i3 = 0; i3 < p1.length; i3++) { + for (let j = 0; j < p2.length; j++) { + coeff[i3 + j] ^= GF.mul(p1[i3], p2[j]); + } + } + return coeff; + }; + exports2.mod = function mod2(divident, divisor) { + let result = new Uint8Array(divident); + while (result.length - divisor.length >= 0) { + const coeff = result[0]; + for (let i3 = 0; i3 < divisor.length; i3++) { + result[i3] ^= GF.mul(divisor[i3], coeff); + } + let offset = 0; + while (offset < result.length && result[offset] === 0) offset++; + result = result.slice(offset); + } + return result; + }; + exports2.generateECPolynomial = function generateECPolynomial(degree) { + let poly = new Uint8Array([1]); + for (let i3 = 0; i3 < degree; i3++) { + poly = exports2.mul(poly, new Uint8Array([1, GF.exp(i3)])); + } + return poly; + }; + } +}); + +// node_modules/qrcode/lib/core/reed-solomon-encoder.js +var require_reed_solomon_encoder = __commonJS({ + "node_modules/qrcode/lib/core/reed-solomon-encoder.js"(exports2, module2) { + var Polynomial = require_polynomial(); + function ReedSolomonEncoder(degree) { + this.genPoly = void 0; + this.degree = degree; + if (this.degree) this.initialize(this.degree); + } + ReedSolomonEncoder.prototype.initialize = function initialize(degree) { + this.degree = degree; + this.genPoly = Polynomial.generateECPolynomial(this.degree); + }; + ReedSolomonEncoder.prototype.encode = function encode(data) { + if (!this.genPoly) { + throw new Error("Encoder not initialized"); + } + const paddedData = new Uint8Array(data.length + this.degree); + paddedData.set(data); + const remainder = Polynomial.mod(paddedData, this.genPoly); + const start = this.degree - remainder.length; + if (start > 0) { + const buff = new Uint8Array(this.degree); + buff.set(remainder, start); + return buff; + } + return remainder; + }; + module2.exports = ReedSolomonEncoder; + } +}); + +// node_modules/qrcode/lib/core/version-check.js +var require_version_check = __commonJS({ + "node_modules/qrcode/lib/core/version-check.js"(exports2) { + exports2.isValid = function isValid2(version) { + return !isNaN(version) && version >= 1 && version <= 40; + }; + } +}); + +// node_modules/qrcode/lib/core/regex.js +var require_regex = __commonJS({ + "node_modules/qrcode/lib/core/regex.js"(exports2) { + var numeric = "[0-9]+"; + var alphanumeric = "[A-Z $%*+\\-./:]+"; + var kanji = "(?:[u3000-u303F]|[u3040-u309F]|[u30A0-u30FF]|[uFF00-uFFEF]|[u4E00-u9FAF]|[u2605-u2606]|[u2190-u2195]|u203B|[u2010u2015u2018u2019u2025u2026u201Cu201Du2225u2260]|[u0391-u0451]|[u00A7u00A8u00B1u00B4u00D7u00F7])+"; + kanji = kanji.replace(/u/g, "\\u"); + var byte = "(?:(?![A-Z0-9 $%*+\\-./:]|" + kanji + ")(?:.|[\r\n]))+"; + exports2.KANJI = new RegExp(kanji, "g"); + exports2.BYTE_KANJI = new RegExp("[^A-Z0-9 $%*+\\-./:]+", "g"); + exports2.BYTE = new RegExp(byte, "g"); + exports2.NUMERIC = new RegExp(numeric, "g"); + exports2.ALPHANUMERIC = new RegExp(alphanumeric, "g"); + var TEST_KANJI = new RegExp("^" + kanji + "$"); + var TEST_NUMERIC = new RegExp("^" + numeric + "$"); + var TEST_ALPHANUMERIC = new RegExp("^[A-Z0-9 $%*+\\-./:]+$"); + exports2.testKanji = function testKanji(str) { + return TEST_KANJI.test(str); + }; + exports2.testNumeric = function testNumeric(str) { + return TEST_NUMERIC.test(str); + }; + exports2.testAlphanumeric = function testAlphanumeric(str) { + return TEST_ALPHANUMERIC.test(str); + }; + } +}); + +// node_modules/qrcode/lib/core/mode.js +var require_mode = __commonJS({ + "node_modules/qrcode/lib/core/mode.js"(exports2) { + var VersionCheck = require_version_check(); + var Regex = require_regex(); + exports2.NUMERIC = { + id: "Numeric", + bit: 1 << 0, + ccBits: [10, 12, 14] + }; + exports2.ALPHANUMERIC = { + id: "Alphanumeric", + bit: 1 << 1, + ccBits: [9, 11, 13] + }; + exports2.BYTE = { + id: "Byte", + bit: 1 << 2, + ccBits: [8, 16, 16] + }; + exports2.KANJI = { + id: "Kanji", + bit: 1 << 3, + ccBits: [8, 10, 12] + }; + exports2.MIXED = { + bit: -1 + }; + exports2.getCharCountIndicator = function getCharCountIndicator(mode, version) { + if (!mode.ccBits) throw new Error("Invalid mode: " + mode); + if (!VersionCheck.isValid(version)) { + throw new Error("Invalid version: " + version); + } + if (version >= 1 && version < 10) return mode.ccBits[0]; + else if (version < 27) return mode.ccBits[1]; + return mode.ccBits[2]; + }; + exports2.getBestModeForData = function getBestModeForData(dataStr) { + if (Regex.testNumeric(dataStr)) return exports2.NUMERIC; + else if (Regex.testAlphanumeric(dataStr)) return exports2.ALPHANUMERIC; + else if (Regex.testKanji(dataStr)) return exports2.KANJI; + else return exports2.BYTE; + }; + exports2.toString = function toString(mode) { + if (mode && mode.id) return mode.id; + throw new Error("Invalid mode"); + }; + exports2.isValid = function isValid2(mode) { + return mode && mode.bit && mode.ccBits; + }; + function fromString(string) { + if (typeof string !== "string") { + throw new Error("Param is not a string"); + } + const lcStr = string.toLowerCase(); + switch (lcStr) { + case "numeric": + return exports2.NUMERIC; + case "alphanumeric": + return exports2.ALPHANUMERIC; + case "kanji": + return exports2.KANJI; + case "byte": + return exports2.BYTE; + default: + throw new Error("Unknown mode: " + string); + } + } + exports2.from = function from(value, defaultValue) { + if (exports2.isValid(value)) { + return value; + } + try { + return fromString(value); + } catch (e) { + return defaultValue; + } + }; + } +}); + +// node_modules/qrcode/lib/core/version.js +var require_version = __commonJS({ + "node_modules/qrcode/lib/core/version.js"(exports2) { + var Utils = require_utils(); + var ECCode = require_error_correction_code(); + var ECLevel = require_error_correction_level(); + var Mode = require_mode(); + var VersionCheck = require_version_check(); + var G18 = 1 << 12 | 1 << 11 | 1 << 10 | 1 << 9 | 1 << 8 | 1 << 5 | 1 << 2 | 1 << 0; + var G18_BCH = Utils.getBCHDigit(G18); + function getBestVersionForDataLength(mode, length, errorCorrectionLevel) { + for (let currentVersion = 1; currentVersion <= 40; currentVersion++) { + if (length <= exports2.getCapacity(currentVersion, errorCorrectionLevel, mode)) { + return currentVersion; + } + } + return void 0; + } + function getReservedBitsCount(mode, version) { + return Mode.getCharCountIndicator(mode, version) + 4; + } + function getTotalBitsFromDataArray(segments, version) { + let totalBits = 0; + segments.forEach(function(data) { + const reservedBits = getReservedBitsCount(data.mode, version); + totalBits += reservedBits + data.getBitsLength(); + }); + return totalBits; + } + function getBestVersionForMixedData(segments, errorCorrectionLevel) { + for (let currentVersion = 1; currentVersion <= 40; currentVersion++) { + const length = getTotalBitsFromDataArray(segments, currentVersion); + if (length <= exports2.getCapacity(currentVersion, errorCorrectionLevel, Mode.MIXED)) { + return currentVersion; + } + } + return void 0; + } + exports2.from = function from(value, defaultValue) { + if (VersionCheck.isValid(value)) { + return parseInt(value, 10); + } + return defaultValue; + }; + exports2.getCapacity = function getCapacity(version, errorCorrectionLevel, mode) { + if (!VersionCheck.isValid(version)) { + throw new Error("Invalid QR Code version"); + } + if (typeof mode === "undefined") mode = Mode.BYTE; + const totalCodewords = Utils.getSymbolTotalCodewords(version); + const ecTotalCodewords = ECCode.getTotalCodewordsCount(version, errorCorrectionLevel); + const dataTotalCodewordsBits = (totalCodewords - ecTotalCodewords) * 8; + if (mode === Mode.MIXED) return dataTotalCodewordsBits; + const usableBits = dataTotalCodewordsBits - getReservedBitsCount(mode, version); + switch (mode) { + case Mode.NUMERIC: + return Math.floor(usableBits / 10 * 3); + case Mode.ALPHANUMERIC: + return Math.floor(usableBits / 11 * 2); + case Mode.KANJI: + return Math.floor(usableBits / 13); + case Mode.BYTE: + default: + return Math.floor(usableBits / 8); + } + }; + exports2.getBestVersionForData = function getBestVersionForData(data, errorCorrectionLevel) { + let seg; + const ecl = ECLevel.from(errorCorrectionLevel, ECLevel.M); + if (Array.isArray(data)) { + if (data.length > 1) { + return getBestVersionForMixedData(data, ecl); + } + if (data.length === 0) { + return 1; + } + seg = data[0]; + } else { + seg = data; + } + return getBestVersionForDataLength(seg.mode, seg.getLength(), ecl); + }; + exports2.getEncodedBits = function getEncodedBits(version) { + if (!VersionCheck.isValid(version) || version < 7) { + throw new Error("Invalid QR Code version"); + } + let d = version << 12; + while (Utils.getBCHDigit(d) - G18_BCH >= 0) { + d ^= G18 << Utils.getBCHDigit(d) - G18_BCH; + } + return version << 12 | d; + }; + } +}); + +// node_modules/qrcode/lib/core/format-info.js +var require_format_info = __commonJS({ + "node_modules/qrcode/lib/core/format-info.js"(exports2) { + var Utils = require_utils(); + var G15 = 1 << 10 | 1 << 8 | 1 << 5 | 1 << 4 | 1 << 2 | 1 << 1 | 1 << 0; + var G15_MASK = 1 << 14 | 1 << 12 | 1 << 10 | 1 << 4 | 1 << 1; + var G15_BCH = Utils.getBCHDigit(G15); + exports2.getEncodedBits = function getEncodedBits(errorCorrectionLevel, mask) { + const data = errorCorrectionLevel.bit << 3 | mask; + let d = data << 10; + while (Utils.getBCHDigit(d) - G15_BCH >= 0) { + d ^= G15 << Utils.getBCHDigit(d) - G15_BCH; + } + return (data << 10 | d) ^ G15_MASK; + }; + } +}); + +// node_modules/qrcode/lib/core/numeric-data.js +var require_numeric_data = __commonJS({ + "node_modules/qrcode/lib/core/numeric-data.js"(exports2, module2) { + var Mode = require_mode(); + function NumericData(data) { + this.mode = Mode.NUMERIC; + this.data = data.toString(); + } + NumericData.getBitsLength = function getBitsLength(length) { + return 10 * Math.floor(length / 3) + (length % 3 ? length % 3 * 3 + 1 : 0); + }; + NumericData.prototype.getLength = function getLength() { + return this.data.length; + }; + NumericData.prototype.getBitsLength = function getBitsLength() { + return NumericData.getBitsLength(this.data.length); + }; + NumericData.prototype.write = function write(bitBuffer) { + let i3, group, value; + for (i3 = 0; i3 + 3 <= this.data.length; i3 += 3) { + group = this.data.substr(i3, 3); + value = parseInt(group, 10); + bitBuffer.put(value, 10); + } + const remainingNum = this.data.length - i3; + if (remainingNum > 0) { + group = this.data.substr(i3); + value = parseInt(group, 10); + bitBuffer.put(value, remainingNum * 3 + 1); + } + }; + module2.exports = NumericData; + } +}); + +// node_modules/qrcode/lib/core/alphanumeric-data.js +var require_alphanumeric_data = __commonJS({ + "node_modules/qrcode/lib/core/alphanumeric-data.js"(exports2, module2) { + var Mode = require_mode(); + var ALPHA_NUM_CHARS = [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + " ", + "$", + "%", + "*", + "+", + "-", + ".", + "/", + ":" + ]; + function AlphanumericData(data) { + this.mode = Mode.ALPHANUMERIC; + this.data = data; + } + AlphanumericData.getBitsLength = function getBitsLength(length) { + return 11 * Math.floor(length / 2) + 6 * (length % 2); + }; + AlphanumericData.prototype.getLength = function getLength() { + return this.data.length; + }; + AlphanumericData.prototype.getBitsLength = function getBitsLength() { + return AlphanumericData.getBitsLength(this.data.length); + }; + AlphanumericData.prototype.write = function write(bitBuffer) { + let i3; + for (i3 = 0; i3 + 2 <= this.data.length; i3 += 2) { + let value = ALPHA_NUM_CHARS.indexOf(this.data[i3]) * 45; + value += ALPHA_NUM_CHARS.indexOf(this.data[i3 + 1]); + bitBuffer.put(value, 11); + } + if (this.data.length % 2) { + bitBuffer.put(ALPHA_NUM_CHARS.indexOf(this.data[i3]), 6); + } + }; + module2.exports = AlphanumericData; + } +}); + +// node_modules/qrcode/lib/core/byte-data.js +var require_byte_data = __commonJS({ + "node_modules/qrcode/lib/core/byte-data.js"(exports2, module2) { + var Mode = require_mode(); + function ByteData(data) { + this.mode = Mode.BYTE; + if (typeof data === "string") { + this.data = new TextEncoder().encode(data); + } else { + this.data = new Uint8Array(data); + } + } + ByteData.getBitsLength = function getBitsLength(length) { + return length * 8; + }; + ByteData.prototype.getLength = function getLength() { + return this.data.length; + }; + ByteData.prototype.getBitsLength = function getBitsLength() { + return ByteData.getBitsLength(this.data.length); + }; + ByteData.prototype.write = function(bitBuffer) { + for (let i3 = 0, l = this.data.length; i3 < l; i3++) { + bitBuffer.put(this.data[i3], 8); + } + }; + module2.exports = ByteData; + } +}); + +// node_modules/qrcode/lib/core/kanji-data.js +var require_kanji_data = __commonJS({ + "node_modules/qrcode/lib/core/kanji-data.js"(exports2, module2) { + var Mode = require_mode(); + var Utils = require_utils(); + function KanjiData(data) { + this.mode = Mode.KANJI; + this.data = data; + } + KanjiData.getBitsLength = function getBitsLength(length) { + return length * 13; + }; + KanjiData.prototype.getLength = function getLength() { + return this.data.length; + }; + KanjiData.prototype.getBitsLength = function getBitsLength() { + return KanjiData.getBitsLength(this.data.length); + }; + KanjiData.prototype.write = function(bitBuffer) { + let i3; + for (i3 = 0; i3 < this.data.length; i3++) { + let value = Utils.toSJIS(this.data[i3]); + if (value >= 33088 && value <= 40956) { + value -= 33088; + } else if (value >= 57408 && value <= 60351) { + value -= 49472; + } else { + throw new Error( + "Invalid SJIS character: " + this.data[i3] + "\nMake sure your charset is UTF-8" + ); + } + value = (value >>> 8 & 255) * 192 + (value & 255); + bitBuffer.put(value, 13); + } + }; + module2.exports = KanjiData; + } +}); + +// node_modules/dijkstrajs/dijkstra.js +var require_dijkstra = __commonJS({ + "node_modules/dijkstrajs/dijkstra.js"(exports2, module2) { + "use strict"; + var dijkstra = { + single_source_shortest_paths: function(graph, s, d) { + var predecessors = {}; + var costs = {}; + costs[s] = 0; + var open = dijkstra.PriorityQueue.make(); + open.push(s, 0); + var closest, u, v, cost_of_s_to_u, adjacent_nodes, cost_of_e, cost_of_s_to_u_plus_cost_of_e, cost_of_s_to_v, first_visit; + while (!open.empty()) { + closest = open.pop(); + u = closest.value; + cost_of_s_to_u = closest.cost; + adjacent_nodes = graph[u] || {}; + for (v in adjacent_nodes) { + if (adjacent_nodes.hasOwnProperty(v)) { + cost_of_e = adjacent_nodes[v]; + cost_of_s_to_u_plus_cost_of_e = cost_of_s_to_u + cost_of_e; + cost_of_s_to_v = costs[v]; + first_visit = typeof costs[v] === "undefined"; + if (first_visit || cost_of_s_to_v > cost_of_s_to_u_plus_cost_of_e) { + costs[v] = cost_of_s_to_u_plus_cost_of_e; + open.push(v, cost_of_s_to_u_plus_cost_of_e); + predecessors[v] = u; + } + } + } + } + if (typeof d !== "undefined" && typeof costs[d] === "undefined") { + var msg = ["Could not find a path from ", s, " to ", d, "."].join(""); + throw new Error(msg); + } + return predecessors; + }, + extract_shortest_path_from_predecessor_list: function(predecessors, d) { + var nodes = []; + var u = d; + var predecessor; + while (u) { + nodes.push(u); + predecessor = predecessors[u]; + u = predecessors[u]; + } + nodes.reverse(); + return nodes; + }, + find_path: function(graph, s, d) { + var predecessors = dijkstra.single_source_shortest_paths(graph, s, d); + return dijkstra.extract_shortest_path_from_predecessor_list( + predecessors, + d + ); + }, + /** + * A very naive priority queue implementation. + */ + PriorityQueue: { + make: function(opts) { + var T = dijkstra.PriorityQueue, t = {}, key; + opts = opts || {}; + for (key in T) { + if (T.hasOwnProperty(key)) { + t[key] = T[key]; + } + } + t.queue = []; + t.sorter = opts.sorter || T.default_sorter; + return t; + }, + default_sorter: function(a, b) { + return a.cost - b.cost; + }, + /** + * Add a new item to the queue and ensure the highest priority element + * is at the front of the queue. + */ + push: function(value, cost) { + var item = { value, cost }; + this.queue.push(item); + this.queue.sort(this.sorter); + }, + /** + * Return the highest priority element in the queue. + */ + pop: function() { + return this.queue.shift(); + }, + empty: function() { + return this.queue.length === 0; + } + } + }; + if (typeof module2 !== "undefined") { + module2.exports = dijkstra; + } + } +}); + +// node_modules/qrcode/lib/core/segments.js +var require_segments = __commonJS({ + "node_modules/qrcode/lib/core/segments.js"(exports2) { + var Mode = require_mode(); + var NumericData = require_numeric_data(); + var AlphanumericData = require_alphanumeric_data(); + var ByteData = require_byte_data(); + var KanjiData = require_kanji_data(); + var Regex = require_regex(); + var Utils = require_utils(); + var dijkstra = require_dijkstra(); + function getStringByteLength(str) { + return unescape(encodeURIComponent(str)).length; + } + function getSegments(regex2, mode, str) { + const segments = []; + let result; + while ((result = regex2.exec(str)) !== null) { + segments.push({ + data: result[0], + index: result.index, + mode, + length: result[0].length + }); + } + return segments; + } + function getSegmentsFromString(dataStr) { + const numSegs = getSegments(Regex.NUMERIC, Mode.NUMERIC, dataStr); + const alphaNumSegs = getSegments(Regex.ALPHANUMERIC, Mode.ALPHANUMERIC, dataStr); + let byteSegs; + let kanjiSegs; + if (Utils.isKanjiModeEnabled()) { + byteSegs = getSegments(Regex.BYTE, Mode.BYTE, dataStr); + kanjiSegs = getSegments(Regex.KANJI, Mode.KANJI, dataStr); + } else { + byteSegs = getSegments(Regex.BYTE_KANJI, Mode.BYTE, dataStr); + kanjiSegs = []; + } + const segs = numSegs.concat(alphaNumSegs, byteSegs, kanjiSegs); + return segs.sort(function(s1, s2) { + return s1.index - s2.index; + }).map(function(obj) { + return { + data: obj.data, + mode: obj.mode, + length: obj.length + }; + }); + } + function getSegmentBitsLength(length, mode) { + switch (mode) { + case Mode.NUMERIC: + return NumericData.getBitsLength(length); + case Mode.ALPHANUMERIC: + return AlphanumericData.getBitsLength(length); + case Mode.KANJI: + return KanjiData.getBitsLength(length); + case Mode.BYTE: + return ByteData.getBitsLength(length); + } + } + function mergeSegments(segs) { + return segs.reduce(function(acc, curr) { + const prevSeg = acc.length - 1 >= 0 ? acc[acc.length - 1] : null; + if (prevSeg && prevSeg.mode === curr.mode) { + acc[acc.length - 1].data += curr.data; + return acc; + } + acc.push(curr); + return acc; + }, []); + } + function buildNodes(segs) { + const nodes = []; + for (let i3 = 0; i3 < segs.length; i3++) { + const seg = segs[i3]; + switch (seg.mode) { + case Mode.NUMERIC: + nodes.push([ + seg, + { data: seg.data, mode: Mode.ALPHANUMERIC, length: seg.length }, + { data: seg.data, mode: Mode.BYTE, length: seg.length } + ]); + break; + case Mode.ALPHANUMERIC: + nodes.push([ + seg, + { data: seg.data, mode: Mode.BYTE, length: seg.length } + ]); + break; + case Mode.KANJI: + nodes.push([ + seg, + { data: seg.data, mode: Mode.BYTE, length: getStringByteLength(seg.data) } + ]); + break; + case Mode.BYTE: + nodes.push([ + { data: seg.data, mode: Mode.BYTE, length: getStringByteLength(seg.data) } + ]); + } + } + return nodes; + } + function buildGraph(nodes, version) { + const table = {}; + const graph = { start: {} }; + let prevNodeIds = ["start"]; + for (let i3 = 0; i3 < nodes.length; i3++) { + const nodeGroup = nodes[i3]; + const currentNodeIds = []; + for (let j = 0; j < nodeGroup.length; j++) { + const node = nodeGroup[j]; + const key = "" + i3 + j; + currentNodeIds.push(key); + table[key] = { node, lastCount: 0 }; + graph[key] = {}; + for (let n = 0; n < prevNodeIds.length; n++) { + const prevNodeId = prevNodeIds[n]; + if (table[prevNodeId] && table[prevNodeId].node.mode === node.mode) { + graph[prevNodeId][key] = getSegmentBitsLength(table[prevNodeId].lastCount + node.length, node.mode) - getSegmentBitsLength(table[prevNodeId].lastCount, node.mode); + table[prevNodeId].lastCount += node.length; + } else { + if (table[prevNodeId]) table[prevNodeId].lastCount = node.length; + graph[prevNodeId][key] = getSegmentBitsLength(node.length, node.mode) + 4 + Mode.getCharCountIndicator(node.mode, version); + } + } + } + prevNodeIds = currentNodeIds; + } + for (let n = 0; n < prevNodeIds.length; n++) { + graph[prevNodeIds[n]].end = 0; + } + return { map: graph, table }; + } + function buildSingleSegment(data, modesHint) { + let mode; + const bestMode = Mode.getBestModeForData(data); + mode = Mode.from(modesHint, bestMode); + if (mode !== Mode.BYTE && mode.bit < bestMode.bit) { + throw new Error('"' + data + '" cannot be encoded with mode ' + Mode.toString(mode) + ".\n Suggested mode is: " + Mode.toString(bestMode)); + } + if (mode === Mode.KANJI && !Utils.isKanjiModeEnabled()) { + mode = Mode.BYTE; + } + switch (mode) { + case Mode.NUMERIC: + return new NumericData(data); + case Mode.ALPHANUMERIC: + return new AlphanumericData(data); + case Mode.KANJI: + return new KanjiData(data); + case Mode.BYTE: + return new ByteData(data); + } + } + exports2.fromArray = function fromArray(array) { + return array.reduce(function(acc, seg) { + if (typeof seg === "string") { + acc.push(buildSingleSegment(seg, null)); + } else if (seg.data) { + acc.push(buildSingleSegment(seg.data, seg.mode)); + } + return acc; + }, []); + }; + exports2.fromString = function fromString(data, version) { + const segs = getSegmentsFromString(data, Utils.isKanjiModeEnabled()); + const nodes = buildNodes(segs); + const graph = buildGraph(nodes, version); + const path = dijkstra.find_path(graph.map, "start", "end"); + const optimizedSegs = []; + for (let i3 = 1; i3 < path.length - 1; i3++) { + optimizedSegs.push(graph.table[path[i3]].node); + } + return exports2.fromArray(mergeSegments(optimizedSegs)); + }; + exports2.rawSplit = function rawSplit(data) { + return exports2.fromArray( + getSegmentsFromString(data, Utils.isKanjiModeEnabled()) + ); + }; + } +}); + +// node_modules/qrcode/lib/core/qrcode.js +var require_qrcode = __commonJS({ + "node_modules/qrcode/lib/core/qrcode.js"(exports2) { + var Utils = require_utils(); + var ECLevel = require_error_correction_level(); + var BitBuffer = require_bit_buffer(); + var BitMatrix = require_bit_matrix(); + var AlignmentPattern = require_alignment_pattern(); + var FinderPattern = require_finder_pattern(); + var MaskPattern = require_mask_pattern(); + var ECCode = require_error_correction_code(); + var ReedSolomonEncoder = require_reed_solomon_encoder(); + var Version = require_version(); + var FormatInfo = require_format_info(); + var Mode = require_mode(); + var Segments = require_segments(); + function setupFinderPattern(matrix, version) { + const size = matrix.size; + const pos = FinderPattern.getPositions(version); + for (let i3 = 0; i3 < pos.length; i3++) { + const row = pos[i3][0]; + const col = pos[i3][1]; + for (let r = -1; r <= 7; r++) { + if (row + r <= -1 || size <= row + r) continue; + for (let c = -1; c <= 7; c++) { + if (col + c <= -1 || size <= col + c) continue; + if (r >= 0 && r <= 6 && (c === 0 || c === 6) || c >= 0 && c <= 6 && (r === 0 || r === 6) || r >= 2 && r <= 4 && c >= 2 && c <= 4) { + matrix.set(row + r, col + c, true, true); + } else { + matrix.set(row + r, col + c, false, true); + } + } + } + } + } + function setupTimingPattern(matrix) { + const size = matrix.size; + for (let r = 8; r < size - 8; r++) { + const value = r % 2 === 0; + matrix.set(r, 6, value, true); + matrix.set(6, r, value, true); + } + } + function setupAlignmentPattern(matrix, version) { + const pos = AlignmentPattern.getPositions(version); + for (let i3 = 0; i3 < pos.length; i3++) { + const row = pos[i3][0]; + const col = pos[i3][1]; + for (let r = -2; r <= 2; r++) { + for (let c = -2; c <= 2; c++) { + if (r === -2 || r === 2 || c === -2 || c === 2 || r === 0 && c === 0) { + matrix.set(row + r, col + c, true, true); + } else { + matrix.set(row + r, col + c, false, true); + } + } + } + } + } + function setupVersionInfo(matrix, version) { + const size = matrix.size; + const bits = Version.getEncodedBits(version); + let row, col, mod2; + for (let i3 = 0; i3 < 18; i3++) { + row = Math.floor(i3 / 3); + col = i3 % 3 + size - 8 - 3; + mod2 = (bits >> i3 & 1) === 1; + matrix.set(row, col, mod2, true); + matrix.set(col, row, mod2, true); + } + } + function setupFormatInfo(matrix, errorCorrectionLevel, maskPattern) { + const size = matrix.size; + const bits = FormatInfo.getEncodedBits(errorCorrectionLevel, maskPattern); + let i3, mod2; + for (i3 = 0; i3 < 15; i3++) { + mod2 = (bits >> i3 & 1) === 1; + if (i3 < 6) { + matrix.set(i3, 8, mod2, true); + } else if (i3 < 8) { + matrix.set(i3 + 1, 8, mod2, true); + } else { + matrix.set(size - 15 + i3, 8, mod2, true); + } + if (i3 < 8) { + matrix.set(8, size - i3 - 1, mod2, true); + } else if (i3 < 9) { + matrix.set(8, 15 - i3 - 1 + 1, mod2, true); + } else { + matrix.set(8, 15 - i3 - 1, mod2, true); + } + } + matrix.set(size - 8, 8, 1, true); + } + function setupData(matrix, data) { + const size = matrix.size; + let inc = -1; + let row = size - 1; + let bitIndex = 7; + let byteIndex = 0; + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col--; + while (true) { + for (let c = 0; c < 2; c++) { + if (!matrix.isReserved(row, col - c)) { + let dark = false; + if (byteIndex < data.length) { + dark = (data[byteIndex] >>> bitIndex & 1) === 1; + } + matrix.set(row, col - c, dark); + bitIndex--; + if (bitIndex === -1) { + byteIndex++; + bitIndex = 7; + } + } + } + row += inc; + if (row < 0 || size <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + } + function createData(version, errorCorrectionLevel, segments) { + const buffer = new BitBuffer(); + segments.forEach(function(data) { + buffer.put(data.mode.bit, 4); + buffer.put(data.getLength(), Mode.getCharCountIndicator(data.mode, version)); + data.write(buffer); + }); + const totalCodewords = Utils.getSymbolTotalCodewords(version); + const ecTotalCodewords = ECCode.getTotalCodewordsCount(version, errorCorrectionLevel); + const dataTotalCodewordsBits = (totalCodewords - ecTotalCodewords) * 8; + if (buffer.getLengthInBits() + 4 <= dataTotalCodewordsBits) { + buffer.put(0, 4); + } + while (buffer.getLengthInBits() % 8 !== 0) { + buffer.putBit(0); + } + const remainingByte = (dataTotalCodewordsBits - buffer.getLengthInBits()) / 8; + for (let i3 = 0; i3 < remainingByte; i3++) { + buffer.put(i3 % 2 ? 17 : 236, 8); + } + return createCodewords(buffer, version, errorCorrectionLevel); + } + function createCodewords(bitBuffer, version, errorCorrectionLevel) { + const totalCodewords = Utils.getSymbolTotalCodewords(version); + const ecTotalCodewords = ECCode.getTotalCodewordsCount(version, errorCorrectionLevel); + const dataTotalCodewords = totalCodewords - ecTotalCodewords; + const ecTotalBlocks = ECCode.getBlocksCount(version, errorCorrectionLevel); + const blocksInGroup2 = totalCodewords % ecTotalBlocks; + const blocksInGroup1 = ecTotalBlocks - blocksInGroup2; + const totalCodewordsInGroup1 = Math.floor(totalCodewords / ecTotalBlocks); + const dataCodewordsInGroup1 = Math.floor(dataTotalCodewords / ecTotalBlocks); + const dataCodewordsInGroup2 = dataCodewordsInGroup1 + 1; + const ecCount = totalCodewordsInGroup1 - dataCodewordsInGroup1; + const rs = new ReedSolomonEncoder(ecCount); + let offset = 0; + const dcData = new Array(ecTotalBlocks); + const ecData = new Array(ecTotalBlocks); + let maxDataSize = 0; + const buffer = new Uint8Array(bitBuffer.buffer); + for (let b = 0; b < ecTotalBlocks; b++) { + const dataSize = b < blocksInGroup1 ? dataCodewordsInGroup1 : dataCodewordsInGroup2; + dcData[b] = buffer.slice(offset, offset + dataSize); + ecData[b] = rs.encode(dcData[b]); + offset += dataSize; + maxDataSize = Math.max(maxDataSize, dataSize); + } + const data = new Uint8Array(totalCodewords); + let index = 0; + let i3, r; + for (i3 = 0; i3 < maxDataSize; i3++) { + for (r = 0; r < ecTotalBlocks; r++) { + if (i3 < dcData[r].length) { + data[index++] = dcData[r][i3]; + } + } + } + for (i3 = 0; i3 < ecCount; i3++) { + for (r = 0; r < ecTotalBlocks; r++) { + data[index++] = ecData[r][i3]; + } + } + return data; + } + function createSymbol(data, version, errorCorrectionLevel, maskPattern) { + let segments; + if (Array.isArray(data)) { + segments = Segments.fromArray(data); + } else if (typeof data === "string") { + let estimatedVersion = version; + if (!estimatedVersion) { + const rawSegments = Segments.rawSplit(data); + estimatedVersion = Version.getBestVersionForData(rawSegments, errorCorrectionLevel); + } + segments = Segments.fromString(data, estimatedVersion || 40); + } else { + throw new Error("Invalid data"); + } + const bestVersion = Version.getBestVersionForData(segments, errorCorrectionLevel); + if (!bestVersion) { + throw new Error("The amount of data is too big to be stored in a QR Code"); + } + if (!version) { + version = bestVersion; + } else if (version < bestVersion) { + throw new Error( + "\nThe chosen QR Code version cannot contain this amount of data.\nMinimum version required to store current data is: " + bestVersion + ".\n" + ); + } + const dataBits = createData(version, errorCorrectionLevel, segments); + const moduleCount = Utils.getSymbolSize(version); + const modules = new BitMatrix(moduleCount); + setupFinderPattern(modules, version); + setupTimingPattern(modules); + setupAlignmentPattern(modules, version); + setupFormatInfo(modules, errorCorrectionLevel, 0); + if (version >= 7) { + setupVersionInfo(modules, version); + } + setupData(modules, dataBits); + if (isNaN(maskPattern)) { + maskPattern = MaskPattern.getBestMask( + modules, + setupFormatInfo.bind(null, modules, errorCorrectionLevel) + ); + } + MaskPattern.applyMask(maskPattern, modules); + setupFormatInfo(modules, errorCorrectionLevel, maskPattern); + return { + modules, + version, + errorCorrectionLevel, + maskPattern, + segments + }; + } + exports2.create = function create(data, options) { + if (typeof data === "undefined" || data === "") { + throw new Error("No input text"); + } + let errorCorrectionLevel = ECLevel.M; + let version; + let mask; + if (typeof options !== "undefined") { + errorCorrectionLevel = ECLevel.from(options.errorCorrectionLevel, ECLevel.M); + version = Version.from(options.version); + mask = MaskPattern.from(options.maskPattern); + if (options.toSJISFunc) { + Utils.setToSJISFunction(options.toSJISFunc); + } + } + return createSymbol(data, version, errorCorrectionLevel, mask); + }; + } +}); + +// node_modules/pngjs/lib/chunkstream.js +var require_chunkstream = __commonJS({ + "node_modules/pngjs/lib/chunkstream.js"(exports2, module2) { + "use strict"; + var util = require("util"); + var Stream = require("stream"); + var ChunkStream = module2.exports = function() { + Stream.call(this); + this._buffers = []; + this._buffered = 0; + this._reads = []; + this._paused = false; + this._encoding = "utf8"; + this.writable = true; + }; + util.inherits(ChunkStream, Stream); + ChunkStream.prototype.read = function(length, callback) { + this._reads.push({ + length: Math.abs(length), + // if length < 0 then at most this length + allowLess: length < 0, + func: callback + }); + process.nextTick( + function() { + this._process(); + if (this._paused && this._reads && this._reads.length > 0) { + this._paused = false; + this.emit("drain"); + } + }.bind(this) + ); + }; + ChunkStream.prototype.write = function(data, encoding) { + if (!this.writable) { + this.emit("error", new Error("Stream not writable")); + return false; + } + let dataBuffer; + if (Buffer.isBuffer(data)) { + dataBuffer = data; + } else { + dataBuffer = Buffer.from(data, encoding || this._encoding); + } + this._buffers.push(dataBuffer); + this._buffered += dataBuffer.length; + this._process(); + if (this._reads && this._reads.length === 0) { + this._paused = true; + } + return this.writable && !this._paused; + }; + ChunkStream.prototype.end = function(data, encoding) { + if (data) { + this.write(data, encoding); + } + this.writable = false; + if (!this._buffers) { + return; + } + if (this._buffers.length === 0) { + this._end(); + } else { + this._buffers.push(null); + this._process(); + } + }; + ChunkStream.prototype.destroySoon = ChunkStream.prototype.end; + ChunkStream.prototype._end = function() { + if (this._reads.length > 0) { + this.emit("error", new Error("Unexpected end of input")); + } + this.destroy(); + }; + ChunkStream.prototype.destroy = function() { + if (!this._buffers) { + return; + } + this.writable = false; + this._reads = null; + this._buffers = null; + this.emit("close"); + }; + ChunkStream.prototype._processReadAllowingLess = function(read) { + this._reads.shift(); + let smallerBuf = this._buffers[0]; + if (smallerBuf.length > read.length) { + this._buffered -= read.length; + this._buffers[0] = smallerBuf.slice(read.length); + read.func.call(this, smallerBuf.slice(0, read.length)); + } else { + this._buffered -= smallerBuf.length; + this._buffers.shift(); + read.func.call(this, smallerBuf); + } + }; + ChunkStream.prototype._processRead = function(read) { + this._reads.shift(); + let pos = 0; + let count = 0; + let data = Buffer.alloc(read.length); + while (pos < read.length) { + let buf = this._buffers[count++]; + let len = Math.min(buf.length, read.length - pos); + buf.copy(data, pos, 0, len); + pos += len; + if (len !== buf.length) { + this._buffers[--count] = buf.slice(len); + } + } + if (count > 0) { + this._buffers.splice(0, count); + } + this._buffered -= read.length; + read.func.call(this, data); + }; + ChunkStream.prototype._process = function() { + try { + while (this._buffered > 0 && this._reads && this._reads.length > 0) { + let read = this._reads[0]; + if (read.allowLess) { + this._processReadAllowingLess(read); + } else if (this._buffered >= read.length) { + this._processRead(read); + } else { + break; + } + } + if (this._buffers && !this.writable) { + this._end(); + } + } catch (ex) { + this.emit("error", ex); + } + }; + } +}); + +// node_modules/pngjs/lib/interlace.js +var require_interlace = __commonJS({ + "node_modules/pngjs/lib/interlace.js"(exports2) { + "use strict"; + var imagePasses = [ + { + // pass 1 - 1px + x: [0], + y: [0] + }, + { + // pass 2 - 1px + x: [4], + y: [0] + }, + { + // pass 3 - 2px + x: [0, 4], + y: [4] + }, + { + // pass 4 - 4px + x: [2, 6], + y: [0, 4] + }, + { + // pass 5 - 8px + x: [0, 2, 4, 6], + y: [2, 6] + }, + { + // pass 6 - 16px + x: [1, 3, 5, 7], + y: [0, 2, 4, 6] + }, + { + // pass 7 - 32px + x: [0, 1, 2, 3, 4, 5, 6, 7], + y: [1, 3, 5, 7] + } + ]; + exports2.getImagePasses = function(width, height) { + let images = []; + let xLeftOver = width % 8; + let yLeftOver = height % 8; + let xRepeats = (width - xLeftOver) / 8; + let yRepeats = (height - yLeftOver) / 8; + for (let i3 = 0; i3 < imagePasses.length; i3++) { + let pass = imagePasses[i3]; + let passWidth = xRepeats * pass.x.length; + let passHeight = yRepeats * pass.y.length; + for (let j = 0; j < pass.x.length; j++) { + if (pass.x[j] < xLeftOver) { + passWidth++; + } else { + break; + } + } + for (let j = 0; j < pass.y.length; j++) { + if (pass.y[j] < yLeftOver) { + passHeight++; + } else { + break; + } + } + if (passWidth > 0 && passHeight > 0) { + images.push({ width: passWidth, height: passHeight, index: i3 }); + } + } + return images; + }; + exports2.getInterlaceIterator = function(width) { + return function(x, y, pass) { + let outerXLeftOver = x % imagePasses[pass].x.length; + let outerX = (x - outerXLeftOver) / imagePasses[pass].x.length * 8 + imagePasses[pass].x[outerXLeftOver]; + let outerYLeftOver = y % imagePasses[pass].y.length; + let outerY = (y - outerYLeftOver) / imagePasses[pass].y.length * 8 + imagePasses[pass].y[outerYLeftOver]; + return outerX * 4 + outerY * width * 4; + }; + }; + } +}); + +// node_modules/pngjs/lib/paeth-predictor.js +var require_paeth_predictor = __commonJS({ + "node_modules/pngjs/lib/paeth-predictor.js"(exports2, module2) { + "use strict"; + module2.exports = function paethPredictor(left, above, upLeft) { + let paeth = left + above - upLeft; + let pLeft = Math.abs(paeth - left); + let pAbove = Math.abs(paeth - above); + let pUpLeft = Math.abs(paeth - upLeft); + if (pLeft <= pAbove && pLeft <= pUpLeft) { + return left; + } + if (pAbove <= pUpLeft) { + return above; + } + return upLeft; + }; + } +}); + +// node_modules/pngjs/lib/filter-parse.js +var require_filter_parse = __commonJS({ + "node_modules/pngjs/lib/filter-parse.js"(exports2, module2) { + "use strict"; + var interlaceUtils = require_interlace(); + var paethPredictor = require_paeth_predictor(); + function getByteWidth(width, bpp, depth) { + let byteWidth = width * bpp; + if (depth !== 8) { + byteWidth = Math.ceil(byteWidth / (8 / depth)); + } + return byteWidth; + } + var Filter = module2.exports = function(bitmapInfo, dependencies) { + let width = bitmapInfo.width; + let height = bitmapInfo.height; + let interlace = bitmapInfo.interlace; + let bpp = bitmapInfo.bpp; + let depth = bitmapInfo.depth; + this.read = dependencies.read; + this.write = dependencies.write; + this.complete = dependencies.complete; + this._imageIndex = 0; + this._images = []; + if (interlace) { + let passes = interlaceUtils.getImagePasses(width, height); + for (let i3 = 0; i3 < passes.length; i3++) { + this._images.push({ + byteWidth: getByteWidth(passes[i3].width, bpp, depth), + height: passes[i3].height, + lineIndex: 0 + }); + } + } else { + this._images.push({ + byteWidth: getByteWidth(width, bpp, depth), + height, + lineIndex: 0 + }); + } + if (depth === 8) { + this._xComparison = bpp; + } else if (depth === 16) { + this._xComparison = bpp * 2; + } else { + this._xComparison = 1; + } + }; + Filter.prototype.start = function() { + this.read( + this._images[this._imageIndex].byteWidth + 1, + this._reverseFilterLine.bind(this) + ); + }; + Filter.prototype._unFilterType1 = function(rawData, unfilteredLine, byteWidth) { + let xComparison = this._xComparison; + let xBiggerThan = xComparison - 1; + for (let x = 0; x < byteWidth; x++) { + let rawByte = rawData[1 + x]; + let f1Left = x > xBiggerThan ? unfilteredLine[x - xComparison] : 0; + unfilteredLine[x] = rawByte + f1Left; + } + }; + Filter.prototype._unFilterType2 = function(rawData, unfilteredLine, byteWidth) { + let lastLine = this._lastLine; + for (let x = 0; x < byteWidth; x++) { + let rawByte = rawData[1 + x]; + let f2Up = lastLine ? lastLine[x] : 0; + unfilteredLine[x] = rawByte + f2Up; + } + }; + Filter.prototype._unFilterType3 = function(rawData, unfilteredLine, byteWidth) { + let xComparison = this._xComparison; + let xBiggerThan = xComparison - 1; + let lastLine = this._lastLine; + for (let x = 0; x < byteWidth; x++) { + let rawByte = rawData[1 + x]; + let f3Up = lastLine ? lastLine[x] : 0; + let f3Left = x > xBiggerThan ? unfilteredLine[x - xComparison] : 0; + let f3Add = Math.floor((f3Left + f3Up) / 2); + unfilteredLine[x] = rawByte + f3Add; + } + }; + Filter.prototype._unFilterType4 = function(rawData, unfilteredLine, byteWidth) { + let xComparison = this._xComparison; + let xBiggerThan = xComparison - 1; + let lastLine = this._lastLine; + for (let x = 0; x < byteWidth; x++) { + let rawByte = rawData[1 + x]; + let f4Up = lastLine ? lastLine[x] : 0; + let f4Left = x > xBiggerThan ? unfilteredLine[x - xComparison] : 0; + let f4UpLeft = x > xBiggerThan && lastLine ? lastLine[x - xComparison] : 0; + let f4Add = paethPredictor(f4Left, f4Up, f4UpLeft); + unfilteredLine[x] = rawByte + f4Add; + } + }; + Filter.prototype._reverseFilterLine = function(rawData) { + let filter = rawData[0]; + let unfilteredLine; + let currentImage = this._images[this._imageIndex]; + let byteWidth = currentImage.byteWidth; + if (filter === 0) { + unfilteredLine = rawData.slice(1, byteWidth + 1); + } else { + unfilteredLine = Buffer.alloc(byteWidth); + switch (filter) { + case 1: + this._unFilterType1(rawData, unfilteredLine, byteWidth); + break; + case 2: + this._unFilterType2(rawData, unfilteredLine, byteWidth); + break; + case 3: + this._unFilterType3(rawData, unfilteredLine, byteWidth); + break; + case 4: + this._unFilterType4(rawData, unfilteredLine, byteWidth); + break; + default: + throw new Error("Unrecognised filter type - " + filter); + } + } + this.write(unfilteredLine); + currentImage.lineIndex++; + if (currentImage.lineIndex >= currentImage.height) { + this._lastLine = null; + this._imageIndex++; + currentImage = this._images[this._imageIndex]; + } else { + this._lastLine = unfilteredLine; + } + if (currentImage) { + this.read(currentImage.byteWidth + 1, this._reverseFilterLine.bind(this)); + } else { + this._lastLine = null; + this.complete(); + } + }; + } +}); + +// node_modules/pngjs/lib/filter-parse-async.js +var require_filter_parse_async = __commonJS({ + "node_modules/pngjs/lib/filter-parse-async.js"(exports2, module2) { + "use strict"; + var util = require("util"); + var ChunkStream = require_chunkstream(); + var Filter = require_filter_parse(); + var FilterAsync = module2.exports = function(bitmapInfo) { + ChunkStream.call(this); + let buffers = []; + let that = this; + this._filter = new Filter(bitmapInfo, { + read: this.read.bind(this), + write: function(buffer) { + buffers.push(buffer); + }, + complete: function() { + that.emit("complete", Buffer.concat(buffers)); + } + }); + this._filter.start(); + }; + util.inherits(FilterAsync, ChunkStream); + } +}); + +// node_modules/pngjs/lib/constants.js +var require_constants2 = __commonJS({ + "node_modules/pngjs/lib/constants.js"(exports2, module2) { + "use strict"; + module2.exports = { + PNG_SIGNATURE: [137, 80, 78, 71, 13, 10, 26, 10], + TYPE_IHDR: 1229472850, + TYPE_IEND: 1229278788, + TYPE_IDAT: 1229209940, + TYPE_PLTE: 1347179589, + TYPE_tRNS: 1951551059, + // eslint-disable-line camelcase + TYPE_gAMA: 1732332865, + // eslint-disable-line camelcase + // color-type bits + COLORTYPE_GRAYSCALE: 0, + COLORTYPE_PALETTE: 1, + COLORTYPE_COLOR: 2, + COLORTYPE_ALPHA: 4, + // e.g. grayscale and alpha + // color-type combinations + COLORTYPE_PALETTE_COLOR: 3, + COLORTYPE_COLOR_ALPHA: 6, + COLORTYPE_TO_BPP_MAP: { + 0: 1, + 2: 3, + 3: 1, + 4: 2, + 6: 4 + }, + GAMMA_DIVISION: 1e5 + }; + } +}); + +// node_modules/pngjs/lib/crc.js +var require_crc = __commonJS({ + "node_modules/pngjs/lib/crc.js"(exports2, module2) { + "use strict"; + var crcTable = []; + (function() { + for (let i3 = 0; i3 < 256; i3++) { + let currentCrc = i3; + for (let j = 0; j < 8; j++) { + if (currentCrc & 1) { + currentCrc = 3988292384 ^ currentCrc >>> 1; + } else { + currentCrc = currentCrc >>> 1; + } + } + crcTable[i3] = currentCrc; + } + })(); + var CrcCalculator = module2.exports = function() { + this._crc = -1; + }; + CrcCalculator.prototype.write = function(data) { + for (let i3 = 0; i3 < data.length; i3++) { + this._crc = crcTable[(this._crc ^ data[i3]) & 255] ^ this._crc >>> 8; + } + return true; + }; + CrcCalculator.prototype.crc32 = function() { + return this._crc ^ -1; + }; + CrcCalculator.crc32 = function(buf) { + let crc = -1; + for (let i3 = 0; i3 < buf.length; i3++) { + crc = crcTable[(crc ^ buf[i3]) & 255] ^ crc >>> 8; + } + return crc ^ -1; + }; + } +}); + +// node_modules/pngjs/lib/parser.js +var require_parser = __commonJS({ + "node_modules/pngjs/lib/parser.js"(exports2, module2) { + "use strict"; + var constants = require_constants2(); + var CrcCalculator = require_crc(); + var Parser = module2.exports = function(options, dependencies) { + this._options = options; + options.checkCRC = options.checkCRC !== false; + this._hasIHDR = false; + this._hasIEND = false; + this._emittedHeadersFinished = false; + this._palette = []; + this._colorType = 0; + this._chunks = {}; + this._chunks[constants.TYPE_IHDR] = this._handleIHDR.bind(this); + this._chunks[constants.TYPE_IEND] = this._handleIEND.bind(this); + this._chunks[constants.TYPE_IDAT] = this._handleIDAT.bind(this); + this._chunks[constants.TYPE_PLTE] = this._handlePLTE.bind(this); + this._chunks[constants.TYPE_tRNS] = this._handleTRNS.bind(this); + this._chunks[constants.TYPE_gAMA] = this._handleGAMA.bind(this); + this.read = dependencies.read; + this.error = dependencies.error; + this.metadata = dependencies.metadata; + this.gamma = dependencies.gamma; + this.transColor = dependencies.transColor; + this.palette = dependencies.palette; + this.parsed = dependencies.parsed; + this.inflateData = dependencies.inflateData; + this.finished = dependencies.finished; + this.simpleTransparency = dependencies.simpleTransparency; + this.headersFinished = dependencies.headersFinished || function() { + }; + }; + Parser.prototype.start = function() { + this.read(constants.PNG_SIGNATURE.length, this._parseSignature.bind(this)); + }; + Parser.prototype._parseSignature = function(data) { + let signature = constants.PNG_SIGNATURE; + for (let i3 = 0; i3 < signature.length; i3++) { + if (data[i3] !== signature[i3]) { + this.error(new Error("Invalid file signature")); + return; + } + } + this.read(8, this._parseChunkBegin.bind(this)); + }; + Parser.prototype._parseChunkBegin = function(data) { + let length = data.readUInt32BE(0); + let type = data.readUInt32BE(4); + let name = ""; + for (let i3 = 4; i3 < 8; i3++) { + name += String.fromCharCode(data[i3]); + } + let ancillary = Boolean(data[4] & 32); + if (!this._hasIHDR && type !== constants.TYPE_IHDR) { + this.error(new Error("Expected IHDR on beggining")); + return; + } + this._crc = new CrcCalculator(); + this._crc.write(Buffer.from(name)); + if (this._chunks[type]) { + return this._chunks[type](length); + } + if (!ancillary) { + this.error(new Error("Unsupported critical chunk type " + name)); + return; + } + this.read(length + 4, this._skipChunk.bind(this)); + }; + Parser.prototype._skipChunk = function() { + this.read(8, this._parseChunkBegin.bind(this)); + }; + Parser.prototype._handleChunkEnd = function() { + this.read(4, this._parseChunkEnd.bind(this)); + }; + Parser.prototype._parseChunkEnd = function(data) { + let fileCrc = data.readInt32BE(0); + let calcCrc = this._crc.crc32(); + if (this._options.checkCRC && calcCrc !== fileCrc) { + this.error(new Error("Crc error - " + fileCrc + " - " + calcCrc)); + return; + } + if (!this._hasIEND) { + this.read(8, this._parseChunkBegin.bind(this)); + } + }; + Parser.prototype._handleIHDR = function(length) { + this.read(length, this._parseIHDR.bind(this)); + }; + Parser.prototype._parseIHDR = function(data) { + this._crc.write(data); + let width = data.readUInt32BE(0); + let height = data.readUInt32BE(4); + let depth = data[8]; + let colorType = data[9]; + let compr = data[10]; + let filter = data[11]; + let interlace = data[12]; + if (depth !== 8 && depth !== 4 && depth !== 2 && depth !== 1 && depth !== 16) { + this.error(new Error("Unsupported bit depth " + depth)); + return; + } + if (!(colorType in constants.COLORTYPE_TO_BPP_MAP)) { + this.error(new Error("Unsupported color type")); + return; + } + if (compr !== 0) { + this.error(new Error("Unsupported compression method")); + return; + } + if (filter !== 0) { + this.error(new Error("Unsupported filter method")); + return; + } + if (interlace !== 0 && interlace !== 1) { + this.error(new Error("Unsupported interlace method")); + return; + } + this._colorType = colorType; + let bpp = constants.COLORTYPE_TO_BPP_MAP[this._colorType]; + this._hasIHDR = true; + this.metadata({ + width, + height, + depth, + interlace: Boolean(interlace), + palette: Boolean(colorType & constants.COLORTYPE_PALETTE), + color: Boolean(colorType & constants.COLORTYPE_COLOR), + alpha: Boolean(colorType & constants.COLORTYPE_ALPHA), + bpp, + colorType + }); + this._handleChunkEnd(); + }; + Parser.prototype._handlePLTE = function(length) { + this.read(length, this._parsePLTE.bind(this)); + }; + Parser.prototype._parsePLTE = function(data) { + this._crc.write(data); + let entries = Math.floor(data.length / 3); + for (let i3 = 0; i3 < entries; i3++) { + this._palette.push([data[i3 * 3], data[i3 * 3 + 1], data[i3 * 3 + 2], 255]); + } + this.palette(this._palette); + this._handleChunkEnd(); + }; + Parser.prototype._handleTRNS = function(length) { + this.simpleTransparency(); + this.read(length, this._parseTRNS.bind(this)); + }; + Parser.prototype._parseTRNS = function(data) { + this._crc.write(data); + if (this._colorType === constants.COLORTYPE_PALETTE_COLOR) { + if (this._palette.length === 0) { + this.error(new Error("Transparency chunk must be after palette")); + return; + } + if (data.length > this._palette.length) { + this.error(new Error("More transparent colors than palette size")); + return; + } + for (let i3 = 0; i3 < data.length; i3++) { + this._palette[i3][3] = data[i3]; + } + this.palette(this._palette); + } + if (this._colorType === constants.COLORTYPE_GRAYSCALE) { + this.transColor([data.readUInt16BE(0)]); + } + if (this._colorType === constants.COLORTYPE_COLOR) { + this.transColor([ + data.readUInt16BE(0), + data.readUInt16BE(2), + data.readUInt16BE(4) + ]); + } + this._handleChunkEnd(); + }; + Parser.prototype._handleGAMA = function(length) { + this.read(length, this._parseGAMA.bind(this)); + }; + Parser.prototype._parseGAMA = function(data) { + this._crc.write(data); + this.gamma(data.readUInt32BE(0) / constants.GAMMA_DIVISION); + this._handleChunkEnd(); + }; + Parser.prototype._handleIDAT = function(length) { + if (!this._emittedHeadersFinished) { + this._emittedHeadersFinished = true; + this.headersFinished(); + } + this.read(-length, this._parseIDAT.bind(this, length)); + }; + Parser.prototype._parseIDAT = function(length, data) { + this._crc.write(data); + if (this._colorType === constants.COLORTYPE_PALETTE_COLOR && this._palette.length === 0) { + throw new Error("Expected palette not found"); + } + this.inflateData(data); + let leftOverLength = length - data.length; + if (leftOverLength > 0) { + this._handleIDAT(leftOverLength); + } else { + this._handleChunkEnd(); + } + }; + Parser.prototype._handleIEND = function(length) { + this.read(length, this._parseIEND.bind(this)); + }; + Parser.prototype._parseIEND = function(data) { + this._crc.write(data); + this._hasIEND = true; + this._handleChunkEnd(); + if (this.finished) { + this.finished(); + } + }; + } +}); + +// node_modules/pngjs/lib/bitmapper.js +var require_bitmapper = __commonJS({ + "node_modules/pngjs/lib/bitmapper.js"(exports2) { + "use strict"; + var interlaceUtils = require_interlace(); + var pixelBppMapper = [ + // 0 - dummy entry + function() { + }, + // 1 - L + // 0: 0, 1: 0, 2: 0, 3: 0xff + function(pxData, data, pxPos, rawPos) { + if (rawPos === data.length) { + throw new Error("Ran out of data"); + } + let pixel = data[rawPos]; + pxData[pxPos] = pixel; + pxData[pxPos + 1] = pixel; + pxData[pxPos + 2] = pixel; + pxData[pxPos + 3] = 255; + }, + // 2 - LA + // 0: 0, 1: 0, 2: 0, 3: 1 + function(pxData, data, pxPos, rawPos) { + if (rawPos + 1 >= data.length) { + throw new Error("Ran out of data"); + } + let pixel = data[rawPos]; + pxData[pxPos] = pixel; + pxData[pxPos + 1] = pixel; + pxData[pxPos + 2] = pixel; + pxData[pxPos + 3] = data[rawPos + 1]; + }, + // 3 - RGB + // 0: 0, 1: 1, 2: 2, 3: 0xff + function(pxData, data, pxPos, rawPos) { + if (rawPos + 2 >= data.length) { + throw new Error("Ran out of data"); + } + pxData[pxPos] = data[rawPos]; + pxData[pxPos + 1] = data[rawPos + 1]; + pxData[pxPos + 2] = data[rawPos + 2]; + pxData[pxPos + 3] = 255; + }, + // 4 - RGBA + // 0: 0, 1: 1, 2: 2, 3: 3 + function(pxData, data, pxPos, rawPos) { + if (rawPos + 3 >= data.length) { + throw new Error("Ran out of data"); + } + pxData[pxPos] = data[rawPos]; + pxData[pxPos + 1] = data[rawPos + 1]; + pxData[pxPos + 2] = data[rawPos + 2]; + pxData[pxPos + 3] = data[rawPos + 3]; + } + ]; + var pixelBppCustomMapper = [ + // 0 - dummy entry + function() { + }, + // 1 - L + // 0: 0, 1: 0, 2: 0, 3: 0xff + function(pxData, pixelData, pxPos, maxBit) { + let pixel = pixelData[0]; + pxData[pxPos] = pixel; + pxData[pxPos + 1] = pixel; + pxData[pxPos + 2] = pixel; + pxData[pxPos + 3] = maxBit; + }, + // 2 - LA + // 0: 0, 1: 0, 2: 0, 3: 1 + function(pxData, pixelData, pxPos) { + let pixel = pixelData[0]; + pxData[pxPos] = pixel; + pxData[pxPos + 1] = pixel; + pxData[pxPos + 2] = pixel; + pxData[pxPos + 3] = pixelData[1]; + }, + // 3 - RGB + // 0: 0, 1: 1, 2: 2, 3: 0xff + function(pxData, pixelData, pxPos, maxBit) { + pxData[pxPos] = pixelData[0]; + pxData[pxPos + 1] = pixelData[1]; + pxData[pxPos + 2] = pixelData[2]; + pxData[pxPos + 3] = maxBit; + }, + // 4 - RGBA + // 0: 0, 1: 1, 2: 2, 3: 3 + function(pxData, pixelData, pxPos) { + pxData[pxPos] = pixelData[0]; + pxData[pxPos + 1] = pixelData[1]; + pxData[pxPos + 2] = pixelData[2]; + pxData[pxPos + 3] = pixelData[3]; + } + ]; + function bitRetriever(data, depth) { + let leftOver = []; + let i3 = 0; + function split() { + if (i3 === data.length) { + throw new Error("Ran out of data"); + } + let byte = data[i3]; + i3++; + let byte8, byte7, byte6, byte5, byte4, byte3, byte2, byte1; + switch (depth) { + default: + throw new Error("unrecognised depth"); + case 16: + byte2 = data[i3]; + i3++; + leftOver.push((byte << 8) + byte2); + break; + case 4: + byte2 = byte & 15; + byte1 = byte >> 4; + leftOver.push(byte1, byte2); + break; + case 2: + byte4 = byte & 3; + byte3 = byte >> 2 & 3; + byte2 = byte >> 4 & 3; + byte1 = byte >> 6 & 3; + leftOver.push(byte1, byte2, byte3, byte4); + break; + case 1: + byte8 = byte & 1; + byte7 = byte >> 1 & 1; + byte6 = byte >> 2 & 1; + byte5 = byte >> 3 & 1; + byte4 = byte >> 4 & 1; + byte3 = byte >> 5 & 1; + byte2 = byte >> 6 & 1; + byte1 = byte >> 7 & 1; + leftOver.push(byte1, byte2, byte3, byte4, byte5, byte6, byte7, byte8); + break; + } + } + return { + get: function(count) { + while (leftOver.length < count) { + split(); + } + let returner = leftOver.slice(0, count); + leftOver = leftOver.slice(count); + return returner; + }, + resetAfterLine: function() { + leftOver.length = 0; + }, + end: function() { + if (i3 !== data.length) { + throw new Error("extra data found"); + } + } + }; + } + function mapImage8Bit(image, pxData, getPxPos, bpp, data, rawPos) { + let imageWidth = image.width; + let imageHeight = image.height; + let imagePass = image.index; + for (let y = 0; y < imageHeight; y++) { + for (let x = 0; x < imageWidth; x++) { + let pxPos = getPxPos(x, y, imagePass); + pixelBppMapper[bpp](pxData, data, pxPos, rawPos); + rawPos += bpp; + } + } + return rawPos; + } + function mapImageCustomBit(image, pxData, getPxPos, bpp, bits, maxBit) { + let imageWidth = image.width; + let imageHeight = image.height; + let imagePass = image.index; + for (let y = 0; y < imageHeight; y++) { + for (let x = 0; x < imageWidth; x++) { + let pixelData = bits.get(bpp); + let pxPos = getPxPos(x, y, imagePass); + pixelBppCustomMapper[bpp](pxData, pixelData, pxPos, maxBit); + } + bits.resetAfterLine(); + } + } + exports2.dataToBitMap = function(data, bitmapInfo) { + let width = bitmapInfo.width; + let height = bitmapInfo.height; + let depth = bitmapInfo.depth; + let bpp = bitmapInfo.bpp; + let interlace = bitmapInfo.interlace; + let bits; + if (depth !== 8) { + bits = bitRetriever(data, depth); + } + let pxData; + if (depth <= 8) { + pxData = Buffer.alloc(width * height * 4); + } else { + pxData = new Uint16Array(width * height * 4); + } + let maxBit = Math.pow(2, depth) - 1; + let rawPos = 0; + let images; + let getPxPos; + if (interlace) { + images = interlaceUtils.getImagePasses(width, height); + getPxPos = interlaceUtils.getInterlaceIterator(width, height); + } else { + let nonInterlacedPxPos = 0; + getPxPos = function() { + let returner = nonInterlacedPxPos; + nonInterlacedPxPos += 4; + return returner; + }; + images = [{ width, height }]; + } + for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { + if (depth === 8) { + rawPos = mapImage8Bit( + images[imageIndex], + pxData, + getPxPos, + bpp, + data, + rawPos + ); + } else { + mapImageCustomBit( + images[imageIndex], + pxData, + getPxPos, + bpp, + bits, + maxBit + ); + } + } + if (depth === 8) { + if (rawPos !== data.length) { + throw new Error("extra data found"); + } + } else { + bits.end(); + } + return pxData; + }; + } +}); + +// node_modules/pngjs/lib/format-normaliser.js +var require_format_normaliser = __commonJS({ + "node_modules/pngjs/lib/format-normaliser.js"(exports2, module2) { + "use strict"; + function dePalette(indata, outdata, width, height, palette) { + let pxPos = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let color = palette[indata[pxPos]]; + if (!color) { + throw new Error("index " + indata[pxPos] + " not in palette"); + } + for (let i3 = 0; i3 < 4; i3++) { + outdata[pxPos + i3] = color[i3]; + } + pxPos += 4; + } + } + } + function replaceTransparentColor(indata, outdata, width, height, transColor) { + let pxPos = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let makeTrans = false; + if (transColor.length === 1) { + if (transColor[0] === indata[pxPos]) { + makeTrans = true; + } + } else if (transColor[0] === indata[pxPos] && transColor[1] === indata[pxPos + 1] && transColor[2] === indata[pxPos + 2]) { + makeTrans = true; + } + if (makeTrans) { + for (let i3 = 0; i3 < 4; i3++) { + outdata[pxPos + i3] = 0; + } + } + pxPos += 4; + } + } + } + function scaleDepth(indata, outdata, width, height, depth) { + let maxOutSample = 255; + let maxInSample = Math.pow(2, depth) - 1; + let pxPos = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + for (let i3 = 0; i3 < 4; i3++) { + outdata[pxPos + i3] = Math.floor( + indata[pxPos + i3] * maxOutSample / maxInSample + 0.5 + ); + } + pxPos += 4; + } + } + } + module2.exports = function(indata, imageData) { + let depth = imageData.depth; + let width = imageData.width; + let height = imageData.height; + let colorType = imageData.colorType; + let transColor = imageData.transColor; + let palette = imageData.palette; + let outdata = indata; + if (colorType === 3) { + dePalette(indata, outdata, width, height, palette); + } else { + if (transColor) { + replaceTransparentColor(indata, outdata, width, height, transColor); + } + if (depth !== 8) { + if (depth === 16) { + outdata = Buffer.alloc(width * height * 4); + } + scaleDepth(indata, outdata, width, height, depth); + } + } + return outdata; + }; + } +}); + +// node_modules/pngjs/lib/parser-async.js +var require_parser_async = __commonJS({ + "node_modules/pngjs/lib/parser-async.js"(exports2, module2) { + "use strict"; + var util = require("util"); + var zlib = require("zlib"); + var ChunkStream = require_chunkstream(); + var FilterAsync = require_filter_parse_async(); + var Parser = require_parser(); + var bitmapper = require_bitmapper(); + var formatNormaliser = require_format_normaliser(); + var ParserAsync = module2.exports = function(options) { + ChunkStream.call(this); + this._parser = new Parser(options, { + read: this.read.bind(this), + error: this._handleError.bind(this), + metadata: this._handleMetaData.bind(this), + gamma: this.emit.bind(this, "gamma"), + palette: this._handlePalette.bind(this), + transColor: this._handleTransColor.bind(this), + finished: this._finished.bind(this), + inflateData: this._inflateData.bind(this), + simpleTransparency: this._simpleTransparency.bind(this), + headersFinished: this._headersFinished.bind(this) + }); + this._options = options; + this.writable = true; + this._parser.start(); + }; + util.inherits(ParserAsync, ChunkStream); + ParserAsync.prototype._handleError = function(err) { + this.emit("error", err); + this.writable = false; + this.destroy(); + if (this._inflate && this._inflate.destroy) { + this._inflate.destroy(); + } + if (this._filter) { + this._filter.destroy(); + this._filter.on("error", function() { + }); + } + this.errord = true; + }; + ParserAsync.prototype._inflateData = function(data) { + if (!this._inflate) { + if (this._bitmapInfo.interlace) { + this._inflate = zlib.createInflate(); + this._inflate.on("error", this.emit.bind(this, "error")); + this._filter.on("complete", this._complete.bind(this)); + this._inflate.pipe(this._filter); + } else { + let rowSize = (this._bitmapInfo.width * this._bitmapInfo.bpp * this._bitmapInfo.depth + 7 >> 3) + 1; + let imageSize = rowSize * this._bitmapInfo.height; + let chunkSize = Math.max(imageSize, zlib.Z_MIN_CHUNK); + this._inflate = zlib.createInflate({ chunkSize }); + let leftToInflate = imageSize; + let emitError = this.emit.bind(this, "error"); + this._inflate.on("error", function(err) { + if (!leftToInflate) { + return; + } + emitError(err); + }); + this._filter.on("complete", this._complete.bind(this)); + let filterWrite = this._filter.write.bind(this._filter); + this._inflate.on("data", function(chunk) { + if (!leftToInflate) { + return; + } + if (chunk.length > leftToInflate) { + chunk = chunk.slice(0, leftToInflate); + } + leftToInflate -= chunk.length; + filterWrite(chunk); + }); + this._inflate.on("end", this._filter.end.bind(this._filter)); + } + } + this._inflate.write(data); + }; + ParserAsync.prototype._handleMetaData = function(metaData) { + this._metaData = metaData; + this._bitmapInfo = Object.create(metaData); + this._filter = new FilterAsync(this._bitmapInfo); + }; + ParserAsync.prototype._handleTransColor = function(transColor) { + this._bitmapInfo.transColor = transColor; + }; + ParserAsync.prototype._handlePalette = function(palette) { + this._bitmapInfo.palette = palette; + }; + ParserAsync.prototype._simpleTransparency = function() { + this._metaData.alpha = true; + }; + ParserAsync.prototype._headersFinished = function() { + this.emit("metadata", this._metaData); + }; + ParserAsync.prototype._finished = function() { + if (this.errord) { + return; + } + if (!this._inflate) { + this.emit("error", "No Inflate block"); + } else { + this._inflate.end(); + } + }; + ParserAsync.prototype._complete = function(filteredData) { + if (this.errord) { + return; + } + let normalisedBitmapData; + try { + let bitmapData = bitmapper.dataToBitMap(filteredData, this._bitmapInfo); + normalisedBitmapData = formatNormaliser(bitmapData, this._bitmapInfo); + bitmapData = null; + } catch (ex) { + this._handleError(ex); + return; + } + this.emit("parsed", normalisedBitmapData); + }; + } +}); + +// node_modules/pngjs/lib/bitpacker.js +var require_bitpacker = __commonJS({ + "node_modules/pngjs/lib/bitpacker.js"(exports2, module2) { + "use strict"; + var constants = require_constants2(); + module2.exports = function(dataIn, width, height, options) { + let outHasAlpha = [constants.COLORTYPE_COLOR_ALPHA, constants.COLORTYPE_ALPHA].indexOf( + options.colorType + ) !== -1; + if (options.colorType === options.inputColorType) { + let bigEndian = (function() { + let buffer = new ArrayBuffer(2); + new DataView(buffer).setInt16( + 0, + 256, + true + /* littleEndian */ + ); + return new Int16Array(buffer)[0] !== 256; + })(); + if (options.bitDepth === 8 || options.bitDepth === 16 && bigEndian) { + return dataIn; + } + } + let data = options.bitDepth !== 16 ? dataIn : new Uint16Array(dataIn.buffer); + let maxValue = 255; + let inBpp = constants.COLORTYPE_TO_BPP_MAP[options.inputColorType]; + if (inBpp === 4 && !options.inputHasAlpha) { + inBpp = 3; + } + let outBpp = constants.COLORTYPE_TO_BPP_MAP[options.colorType]; + if (options.bitDepth === 16) { + maxValue = 65535; + outBpp *= 2; + } + let outData = Buffer.alloc(width * height * outBpp); + let inIndex = 0; + let outIndex = 0; + let bgColor = options.bgColor || {}; + if (bgColor.red === void 0) { + bgColor.red = maxValue; + } + if (bgColor.green === void 0) { + bgColor.green = maxValue; + } + if (bgColor.blue === void 0) { + bgColor.blue = maxValue; + } + function getRGBA() { + let red; + let green; + let blue; + let alpha = maxValue; + switch (options.inputColorType) { + case constants.COLORTYPE_COLOR_ALPHA: + alpha = data[inIndex + 3]; + red = data[inIndex]; + green = data[inIndex + 1]; + blue = data[inIndex + 2]; + break; + case constants.COLORTYPE_COLOR: + red = data[inIndex]; + green = data[inIndex + 1]; + blue = data[inIndex + 2]; + break; + case constants.COLORTYPE_ALPHA: + alpha = data[inIndex + 1]; + red = data[inIndex]; + green = red; + blue = red; + break; + case constants.COLORTYPE_GRAYSCALE: + red = data[inIndex]; + green = red; + blue = red; + break; + default: + throw new Error( + "input color type:" + options.inputColorType + " is not supported at present" + ); + } + if (options.inputHasAlpha) { + if (!outHasAlpha) { + alpha /= maxValue; + red = Math.min( + Math.max(Math.round((1 - alpha) * bgColor.red + alpha * red), 0), + maxValue + ); + green = Math.min( + Math.max(Math.round((1 - alpha) * bgColor.green + alpha * green), 0), + maxValue + ); + blue = Math.min( + Math.max(Math.round((1 - alpha) * bgColor.blue + alpha * blue), 0), + maxValue + ); + } + } + return { red, green, blue, alpha }; + } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let rgba = getRGBA(data, inIndex); + switch (options.colorType) { + case constants.COLORTYPE_COLOR_ALPHA: + case constants.COLORTYPE_COLOR: + if (options.bitDepth === 8) { + outData[outIndex] = rgba.red; + outData[outIndex + 1] = rgba.green; + outData[outIndex + 2] = rgba.blue; + if (outHasAlpha) { + outData[outIndex + 3] = rgba.alpha; + } + } else { + outData.writeUInt16BE(rgba.red, outIndex); + outData.writeUInt16BE(rgba.green, outIndex + 2); + outData.writeUInt16BE(rgba.blue, outIndex + 4); + if (outHasAlpha) { + outData.writeUInt16BE(rgba.alpha, outIndex + 6); + } + } + break; + case constants.COLORTYPE_ALPHA: + case constants.COLORTYPE_GRAYSCALE: { + let grayscale = (rgba.red + rgba.green + rgba.blue) / 3; + if (options.bitDepth === 8) { + outData[outIndex] = grayscale; + if (outHasAlpha) { + outData[outIndex + 1] = rgba.alpha; + } + } else { + outData.writeUInt16BE(grayscale, outIndex); + if (outHasAlpha) { + outData.writeUInt16BE(rgba.alpha, outIndex + 2); + } + } + break; + } + default: + throw new Error("unrecognised color Type " + options.colorType); + } + inIndex += inBpp; + outIndex += outBpp; + } + } + return outData; + }; + } +}); + +// node_modules/pngjs/lib/filter-pack.js +var require_filter_pack = __commonJS({ + "node_modules/pngjs/lib/filter-pack.js"(exports2, module2) { + "use strict"; + var paethPredictor = require_paeth_predictor(); + function filterNone(pxData, pxPos, byteWidth, rawData, rawPos) { + for (let x = 0; x < byteWidth; x++) { + rawData[rawPos + x] = pxData[pxPos + x]; + } + } + function filterSumNone(pxData, pxPos, byteWidth) { + let sum = 0; + let length = pxPos + byteWidth; + for (let i3 = pxPos; i3 < length; i3++) { + sum += Math.abs(pxData[i3]); + } + return sum; + } + function filterSub(pxData, pxPos, byteWidth, rawData, rawPos, bpp) { + for (let x = 0; x < byteWidth; x++) { + let left = x >= bpp ? pxData[pxPos + x - bpp] : 0; + let val = pxData[pxPos + x] - left; + rawData[rawPos + x] = val; + } + } + function filterSumSub(pxData, pxPos, byteWidth, bpp) { + let sum = 0; + for (let x = 0; x < byteWidth; x++) { + let left = x >= bpp ? pxData[pxPos + x - bpp] : 0; + let val = pxData[pxPos + x] - left; + sum += Math.abs(val); + } + return sum; + } + function filterUp(pxData, pxPos, byteWidth, rawData, rawPos) { + for (let x = 0; x < byteWidth; x++) { + let up = pxPos > 0 ? pxData[pxPos + x - byteWidth] : 0; + let val = pxData[pxPos + x] - up; + rawData[rawPos + x] = val; + } + } + function filterSumUp(pxData, pxPos, byteWidth) { + let sum = 0; + let length = pxPos + byteWidth; + for (let x = pxPos; x < length; x++) { + let up = pxPos > 0 ? pxData[x - byteWidth] : 0; + let val = pxData[x] - up; + sum += Math.abs(val); + } + return sum; + } + function filterAvg(pxData, pxPos, byteWidth, rawData, rawPos, bpp) { + for (let x = 0; x < byteWidth; x++) { + let left = x >= bpp ? pxData[pxPos + x - bpp] : 0; + let up = pxPos > 0 ? pxData[pxPos + x - byteWidth] : 0; + let val = pxData[pxPos + x] - (left + up >> 1); + rawData[rawPos + x] = val; + } + } + function filterSumAvg(pxData, pxPos, byteWidth, bpp) { + let sum = 0; + for (let x = 0; x < byteWidth; x++) { + let left = x >= bpp ? pxData[pxPos + x - bpp] : 0; + let up = pxPos > 0 ? pxData[pxPos + x - byteWidth] : 0; + let val = pxData[pxPos + x] - (left + up >> 1); + sum += Math.abs(val); + } + return sum; + } + function filterPaeth(pxData, pxPos, byteWidth, rawData, rawPos, bpp) { + for (let x = 0; x < byteWidth; x++) { + let left = x >= bpp ? pxData[pxPos + x - bpp] : 0; + let up = pxPos > 0 ? pxData[pxPos + x - byteWidth] : 0; + let upleft = pxPos > 0 && x >= bpp ? pxData[pxPos + x - (byteWidth + bpp)] : 0; + let val = pxData[pxPos + x] - paethPredictor(left, up, upleft); + rawData[rawPos + x] = val; + } + } + function filterSumPaeth(pxData, pxPos, byteWidth, bpp) { + let sum = 0; + for (let x = 0; x < byteWidth; x++) { + let left = x >= bpp ? pxData[pxPos + x - bpp] : 0; + let up = pxPos > 0 ? pxData[pxPos + x - byteWidth] : 0; + let upleft = pxPos > 0 && x >= bpp ? pxData[pxPos + x - (byteWidth + bpp)] : 0; + let val = pxData[pxPos + x] - paethPredictor(left, up, upleft); + sum += Math.abs(val); + } + return sum; + } + var filters = { + 0: filterNone, + 1: filterSub, + 2: filterUp, + 3: filterAvg, + 4: filterPaeth + }; + var filterSums = { + 0: filterSumNone, + 1: filterSumSub, + 2: filterSumUp, + 3: filterSumAvg, + 4: filterSumPaeth + }; + module2.exports = function(pxData, width, height, options, bpp) { + let filterTypes; + if (!("filterType" in options) || options.filterType === -1) { + filterTypes = [0, 1, 2, 3, 4]; + } else if (typeof options.filterType === "number") { + filterTypes = [options.filterType]; + } else { + throw new Error("unrecognised filter types"); + } + if (options.bitDepth === 16) { + bpp *= 2; + } + let byteWidth = width * bpp; + let rawPos = 0; + let pxPos = 0; + let rawData = Buffer.alloc((byteWidth + 1) * height); + let sel = filterTypes[0]; + for (let y = 0; y < height; y++) { + if (filterTypes.length > 1) { + let min = Infinity; + for (let i3 = 0; i3 < filterTypes.length; i3++) { + let sum = filterSums[filterTypes[i3]](pxData, pxPos, byteWidth, bpp); + if (sum < min) { + sel = filterTypes[i3]; + min = sum; + } + } + } + rawData[rawPos] = sel; + rawPos++; + filters[sel](pxData, pxPos, byteWidth, rawData, rawPos, bpp); + rawPos += byteWidth; + pxPos += byteWidth; + } + return rawData; + }; + } +}); + +// node_modules/pngjs/lib/packer.js +var require_packer = __commonJS({ + "node_modules/pngjs/lib/packer.js"(exports2, module2) { + "use strict"; + var constants = require_constants2(); + var CrcStream = require_crc(); + var bitPacker = require_bitpacker(); + var filter = require_filter_pack(); + var zlib = require("zlib"); + var Packer = module2.exports = function(options) { + this._options = options; + options.deflateChunkSize = options.deflateChunkSize || 32 * 1024; + options.deflateLevel = options.deflateLevel != null ? options.deflateLevel : 9; + options.deflateStrategy = options.deflateStrategy != null ? options.deflateStrategy : 3; + options.inputHasAlpha = options.inputHasAlpha != null ? options.inputHasAlpha : true; + options.deflateFactory = options.deflateFactory || zlib.createDeflate; + options.bitDepth = options.bitDepth || 8; + options.colorType = typeof options.colorType === "number" ? options.colorType : constants.COLORTYPE_COLOR_ALPHA; + options.inputColorType = typeof options.inputColorType === "number" ? options.inputColorType : constants.COLORTYPE_COLOR_ALPHA; + if ([ + constants.COLORTYPE_GRAYSCALE, + constants.COLORTYPE_COLOR, + constants.COLORTYPE_COLOR_ALPHA, + constants.COLORTYPE_ALPHA + ].indexOf(options.colorType) === -1) { + throw new Error( + "option color type:" + options.colorType + " is not supported at present" + ); + } + if ([ + constants.COLORTYPE_GRAYSCALE, + constants.COLORTYPE_COLOR, + constants.COLORTYPE_COLOR_ALPHA, + constants.COLORTYPE_ALPHA + ].indexOf(options.inputColorType) === -1) { + throw new Error( + "option input color type:" + options.inputColorType + " is not supported at present" + ); + } + if (options.bitDepth !== 8 && options.bitDepth !== 16) { + throw new Error( + "option bit depth:" + options.bitDepth + " is not supported at present" + ); + } + }; + Packer.prototype.getDeflateOptions = function() { + return { + chunkSize: this._options.deflateChunkSize, + level: this._options.deflateLevel, + strategy: this._options.deflateStrategy + }; + }; + Packer.prototype.createDeflate = function() { + return this._options.deflateFactory(this.getDeflateOptions()); + }; + Packer.prototype.filterData = function(data, width, height) { + let packedData = bitPacker(data, width, height, this._options); + let bpp = constants.COLORTYPE_TO_BPP_MAP[this._options.colorType]; + let filteredData = filter(packedData, width, height, this._options, bpp); + return filteredData; + }; + Packer.prototype._packChunk = function(type, data) { + let len = data ? data.length : 0; + let buf = Buffer.alloc(len + 12); + buf.writeUInt32BE(len, 0); + buf.writeUInt32BE(type, 4); + if (data) { + data.copy(buf, 8); + } + buf.writeInt32BE( + CrcStream.crc32(buf.slice(4, buf.length - 4)), + buf.length - 4 + ); + return buf; + }; + Packer.prototype.packGAMA = function(gamma) { + let buf = Buffer.alloc(4); + buf.writeUInt32BE(Math.floor(gamma * constants.GAMMA_DIVISION), 0); + return this._packChunk(constants.TYPE_gAMA, buf); + }; + Packer.prototype.packIHDR = function(width, height) { + let buf = Buffer.alloc(13); + buf.writeUInt32BE(width, 0); + buf.writeUInt32BE(height, 4); + buf[8] = this._options.bitDepth; + buf[9] = this._options.colorType; + buf[10] = 0; + buf[11] = 0; + buf[12] = 0; + return this._packChunk(constants.TYPE_IHDR, buf); + }; + Packer.prototype.packIDAT = function(data) { + return this._packChunk(constants.TYPE_IDAT, data); + }; + Packer.prototype.packIEND = function() { + return this._packChunk(constants.TYPE_IEND, null); + }; + } +}); + +// node_modules/pngjs/lib/packer-async.js +var require_packer_async = __commonJS({ + "node_modules/pngjs/lib/packer-async.js"(exports2, module2) { + "use strict"; + var util = require("util"); + var Stream = require("stream"); + var constants = require_constants2(); + var Packer = require_packer(); + var PackerAsync = module2.exports = function(opt) { + Stream.call(this); + let options = opt || {}; + this._packer = new Packer(options); + this._deflate = this._packer.createDeflate(); + this.readable = true; + }; + util.inherits(PackerAsync, Stream); + PackerAsync.prototype.pack = function(data, width, height, gamma) { + this.emit("data", Buffer.from(constants.PNG_SIGNATURE)); + this.emit("data", this._packer.packIHDR(width, height)); + if (gamma) { + this.emit("data", this._packer.packGAMA(gamma)); + } + let filteredData = this._packer.filterData(data, width, height); + this._deflate.on("error", this.emit.bind(this, "error")); + this._deflate.on( + "data", + function(compressedData) { + this.emit("data", this._packer.packIDAT(compressedData)); + }.bind(this) + ); + this._deflate.on( + "end", + function() { + this.emit("data", this._packer.packIEND()); + this.emit("end"); + }.bind(this) + ); + this._deflate.end(filteredData); + }; + } +}); + +// node_modules/pngjs/lib/sync-inflate.js +var require_sync_inflate = __commonJS({ + "node_modules/pngjs/lib/sync-inflate.js"(exports2, module2) { + "use strict"; + var assert2 = require("assert").ok; + var zlib = require("zlib"); + var util = require("util"); + var kMaxLength = require("buffer").kMaxLength; + function Inflate(opts) { + if (!(this instanceof Inflate)) { + return new Inflate(opts); + } + if (opts && opts.chunkSize < zlib.Z_MIN_CHUNK) { + opts.chunkSize = zlib.Z_MIN_CHUNK; + } + zlib.Inflate.call(this, opts); + this._offset = this._offset === void 0 ? this._outOffset : this._offset; + this._buffer = this._buffer || this._outBuffer; + if (opts && opts.maxLength != null) { + this._maxLength = opts.maxLength; + } + } + function createInflate(opts) { + return new Inflate(opts); + } + function _close(engine, callback) { + if (callback) { + process.nextTick(callback); + } + if (!engine._handle) { + return; + } + engine._handle.close(); + engine._handle = null; + } + Inflate.prototype._processChunk = function(chunk, flushFlag, asyncCb) { + if (typeof asyncCb === "function") { + return zlib.Inflate._processChunk.call(this, chunk, flushFlag, asyncCb); + } + let self = this; + let availInBefore = chunk && chunk.length; + let availOutBefore = this._chunkSize - this._offset; + let leftToInflate = this._maxLength; + let inOff = 0; + let buffers = []; + let nread = 0; + let error; + this.on("error", function(err) { + error = err; + }); + function handleChunk(availInAfter, availOutAfter) { + if (self._hadError) { + return; + } + let have = availOutBefore - availOutAfter; + assert2(have >= 0, "have should not go down"); + if (have > 0) { + let out = self._buffer.slice(self._offset, self._offset + have); + self._offset += have; + if (out.length > leftToInflate) { + out = out.slice(0, leftToInflate); + } + buffers.push(out); + nread += out.length; + leftToInflate -= out.length; + if (leftToInflate === 0) { + return false; + } + } + if (availOutAfter === 0 || self._offset >= self._chunkSize) { + availOutBefore = self._chunkSize; + self._offset = 0; + self._buffer = Buffer.allocUnsafe(self._chunkSize); + } + if (availOutAfter === 0) { + inOff += availInBefore - availInAfter; + availInBefore = availInAfter; + return true; + } + return false; + } + assert2(this._handle, "zlib binding closed"); + let res; + do { + res = this._handle.writeSync( + flushFlag, + chunk, + // in + inOff, + // in_off + availInBefore, + // in_len + this._buffer, + // out + this._offset, + //out_off + availOutBefore + ); + res = res || this._writeState; + } while (!this._hadError && handleChunk(res[0], res[1])); + if (this._hadError) { + throw error; + } + if (nread >= kMaxLength) { + _close(this); + throw new RangeError( + "Cannot create final Buffer. It would be larger than 0x" + kMaxLength.toString(16) + " bytes" + ); + } + let buf = Buffer.concat(buffers, nread); + _close(this); + return buf; + }; + util.inherits(Inflate, zlib.Inflate); + function zlibBufferSync(engine, buffer) { + if (typeof buffer === "string") { + buffer = Buffer.from(buffer); + } + if (!(buffer instanceof Buffer)) { + throw new TypeError("Not a string or buffer"); + } + let flushFlag = engine._finishFlushFlag; + if (flushFlag == null) { + flushFlag = zlib.Z_FINISH; + } + return engine._processChunk(buffer, flushFlag); + } + function inflateSync(buffer, opts) { + return zlibBufferSync(new Inflate(opts), buffer); + } + module2.exports = exports2 = inflateSync; + exports2.Inflate = Inflate; + exports2.createInflate = createInflate; + exports2.inflateSync = inflateSync; + } +}); + +// node_modules/pngjs/lib/sync-reader.js +var require_sync_reader = __commonJS({ + "node_modules/pngjs/lib/sync-reader.js"(exports2, module2) { + "use strict"; + var SyncReader = module2.exports = function(buffer) { + this._buffer = buffer; + this._reads = []; + }; + SyncReader.prototype.read = function(length, callback) { + this._reads.push({ + length: Math.abs(length), + // if length < 0 then at most this length + allowLess: length < 0, + func: callback + }); + }; + SyncReader.prototype.process = function() { + while (this._reads.length > 0 && this._buffer.length) { + let read = this._reads[0]; + if (this._buffer.length && (this._buffer.length >= read.length || read.allowLess)) { + this._reads.shift(); + let buf = this._buffer; + this._buffer = buf.slice(read.length); + read.func.call(this, buf.slice(0, read.length)); + } else { + break; + } + } + if (this._reads.length > 0) { + return new Error("There are some read requests waitng on finished stream"); + } + if (this._buffer.length > 0) { + return new Error("unrecognised content at end of stream"); + } + }; + } +}); + +// node_modules/pngjs/lib/filter-parse-sync.js +var require_filter_parse_sync = __commonJS({ + "node_modules/pngjs/lib/filter-parse-sync.js"(exports2) { + "use strict"; + var SyncReader = require_sync_reader(); + var Filter = require_filter_parse(); + exports2.process = function(inBuffer, bitmapInfo) { + let outBuffers = []; + let reader = new SyncReader(inBuffer); + let filter = new Filter(bitmapInfo, { + read: reader.read.bind(reader), + write: function(bufferPart) { + outBuffers.push(bufferPart); + }, + complete: function() { + } + }); + filter.start(); + reader.process(); + return Buffer.concat(outBuffers); + }; + } +}); + +// node_modules/pngjs/lib/parser-sync.js +var require_parser_sync = __commonJS({ + "node_modules/pngjs/lib/parser-sync.js"(exports2, module2) { + "use strict"; + var hasSyncZlib = true; + var zlib = require("zlib"); + var inflateSync = require_sync_inflate(); + if (!zlib.deflateSync) { + hasSyncZlib = false; + } + var SyncReader = require_sync_reader(); + var FilterSync = require_filter_parse_sync(); + var Parser = require_parser(); + var bitmapper = require_bitmapper(); + var formatNormaliser = require_format_normaliser(); + module2.exports = function(buffer, options) { + if (!hasSyncZlib) { + throw new Error( + "To use the sync capability of this library in old node versions, please pin pngjs to v2.3.0" + ); + } + let err; + function handleError(_err_) { + err = _err_; + } + let metaData; + function handleMetaData(_metaData_) { + metaData = _metaData_; + } + function handleTransColor(transColor) { + metaData.transColor = transColor; + } + function handlePalette(palette) { + metaData.palette = palette; + } + function handleSimpleTransparency() { + metaData.alpha = true; + } + let gamma; + function handleGamma(_gamma_) { + gamma = _gamma_; + } + let inflateDataList = []; + function handleInflateData(inflatedData2) { + inflateDataList.push(inflatedData2); + } + let reader = new SyncReader(buffer); + let parser = new Parser(options, { + read: reader.read.bind(reader), + error: handleError, + metadata: handleMetaData, + gamma: handleGamma, + palette: handlePalette, + transColor: handleTransColor, + inflateData: handleInflateData, + simpleTransparency: handleSimpleTransparency + }); + parser.start(); + reader.process(); + if (err) { + throw err; + } + let inflateData = Buffer.concat(inflateDataList); + inflateDataList.length = 0; + let inflatedData; + if (metaData.interlace) { + inflatedData = zlib.inflateSync(inflateData); + } else { + let rowSize = (metaData.width * metaData.bpp * metaData.depth + 7 >> 3) + 1; + let imageSize = rowSize * metaData.height; + inflatedData = inflateSync(inflateData, { + chunkSize: imageSize, + maxLength: imageSize + }); + } + inflateData = null; + if (!inflatedData || !inflatedData.length) { + throw new Error("bad png - invalid inflate data response"); + } + let unfilteredData = FilterSync.process(inflatedData, metaData); + inflateData = null; + let bitmapData = bitmapper.dataToBitMap(unfilteredData, metaData); + unfilteredData = null; + let normalisedBitmapData = formatNormaliser(bitmapData, metaData); + metaData.data = normalisedBitmapData; + metaData.gamma = gamma || 0; + return metaData; + }; + } +}); + +// node_modules/pngjs/lib/packer-sync.js +var require_packer_sync = __commonJS({ + "node_modules/pngjs/lib/packer-sync.js"(exports2, module2) { + "use strict"; + var hasSyncZlib = true; + var zlib = require("zlib"); + if (!zlib.deflateSync) { + hasSyncZlib = false; + } + var constants = require_constants2(); + var Packer = require_packer(); + module2.exports = function(metaData, opt) { + if (!hasSyncZlib) { + throw new Error( + "To use the sync capability of this library in old node versions, please pin pngjs to v2.3.0" + ); + } + let options = opt || {}; + let packer = new Packer(options); + let chunks = []; + chunks.push(Buffer.from(constants.PNG_SIGNATURE)); + chunks.push(packer.packIHDR(metaData.width, metaData.height)); + if (metaData.gamma) { + chunks.push(packer.packGAMA(metaData.gamma)); + } + let filteredData = packer.filterData( + metaData.data, + metaData.width, + metaData.height + ); + let compressedData = zlib.deflateSync( + filteredData, + packer.getDeflateOptions() + ); + filteredData = null; + if (!compressedData || !compressedData.length) { + throw new Error("bad png - invalid compressed data response"); + } + chunks.push(packer.packIDAT(compressedData)); + chunks.push(packer.packIEND()); + return Buffer.concat(chunks); + }; + } +}); + +// node_modules/pngjs/lib/png-sync.js +var require_png_sync = __commonJS({ + "node_modules/pngjs/lib/png-sync.js"(exports2) { + "use strict"; + var parse4 = require_parser_sync(); + var pack = require_packer_sync(); + exports2.read = function(buffer, options) { + return parse4(buffer, options || {}); + }; + exports2.write = function(png, options) { + return pack(png, options); + }; + } +}); + +// node_modules/pngjs/lib/png.js +var require_png = __commonJS({ + "node_modules/pngjs/lib/png.js"(exports2) { + "use strict"; + var util = require("util"); + var Stream = require("stream"); + var Parser = require_parser_async(); + var Packer = require_packer_async(); + var PNGSync = require_png_sync(); + var PNG = exports2.PNG = function(options) { + Stream.call(this); + options = options || {}; + this.width = options.width | 0; + this.height = options.height | 0; + this.data = this.width > 0 && this.height > 0 ? Buffer.alloc(4 * this.width * this.height) : null; + if (options.fill && this.data) { + this.data.fill(0); + } + this.gamma = 0; + this.readable = this.writable = true; + this._parser = new Parser(options); + this._parser.on("error", this.emit.bind(this, "error")); + this._parser.on("close", this._handleClose.bind(this)); + this._parser.on("metadata", this._metadata.bind(this)); + this._parser.on("gamma", this._gamma.bind(this)); + this._parser.on( + "parsed", + function(data) { + this.data = data; + this.emit("parsed", data); + }.bind(this) + ); + this._packer = new Packer(options); + this._packer.on("data", this.emit.bind(this, "data")); + this._packer.on("end", this.emit.bind(this, "end")); + this._parser.on("close", this._handleClose.bind(this)); + this._packer.on("error", this.emit.bind(this, "error")); + }; + util.inherits(PNG, Stream); + PNG.sync = PNGSync; + PNG.prototype.pack = function() { + if (!this.data || !this.data.length) { + this.emit("error", "No data provided"); + return this; + } + process.nextTick( + function() { + this._packer.pack(this.data, this.width, this.height, this.gamma); + }.bind(this) + ); + return this; + }; + PNG.prototype.parse = function(data, callback) { + if (callback) { + let onParsed, onError; + onParsed = function(parsedData) { + this.removeListener("error", onError); + this.data = parsedData; + callback(null, this); + }.bind(this); + onError = function(err) { + this.removeListener("parsed", onParsed); + callback(err, null); + }.bind(this); + this.once("parsed", onParsed); + this.once("error", onError); + } + this.end(data); + return this; + }; + PNG.prototype.write = function(data) { + this._parser.write(data); + return true; + }; + PNG.prototype.end = function(data) { + this._parser.end(data); + }; + PNG.prototype._metadata = function(metadata) { + this.width = metadata.width; + this.height = metadata.height; + this.emit("metadata", metadata); + }; + PNG.prototype._gamma = function(gamma) { + this.gamma = gamma; + }; + PNG.prototype._handleClose = function() { + if (!this._parser.writable && !this._packer.readable) { + this.emit("close"); + } + }; + PNG.bitblt = function(src, dst, srcX, srcY, width, height, deltaX, deltaY) { + srcX |= 0; + srcY |= 0; + width |= 0; + height |= 0; + deltaX |= 0; + deltaY |= 0; + if (srcX > src.width || srcY > src.height || srcX + width > src.width || srcY + height > src.height) { + throw new Error("bitblt reading outside image"); + } + if (deltaX > dst.width || deltaY > dst.height || deltaX + width > dst.width || deltaY + height > dst.height) { + throw new Error("bitblt writing outside image"); + } + for (let y = 0; y < height; y++) { + src.data.copy( + dst.data, + (deltaY + y) * dst.width + deltaX << 2, + (srcY + y) * src.width + srcX << 2, + (srcY + y) * src.width + srcX + width << 2 + ); + } + }; + PNG.prototype.bitblt = function(dst, srcX, srcY, width, height, deltaX, deltaY) { + PNG.bitblt(this, dst, srcX, srcY, width, height, deltaX, deltaY); + return this; + }; + PNG.adjustGamma = function(src) { + if (src.gamma) { + for (let y = 0; y < src.height; y++) { + for (let x = 0; x < src.width; x++) { + let idx = src.width * y + x << 2; + for (let i3 = 0; i3 < 3; i3++) { + let sample = src.data[idx + i3] / 255; + sample = Math.pow(sample, 1 / 2.2 / src.gamma); + src.data[idx + i3] = Math.round(sample * 255); + } + } + } + src.gamma = 0; + } + }; + PNG.prototype.adjustGamma = function() { + PNG.adjustGamma(this); + }; + } +}); + +// node_modules/qrcode/lib/renderer/utils.js +var require_utils2 = __commonJS({ + "node_modules/qrcode/lib/renderer/utils.js"(exports2) { + function hex2rgba(hex2) { + if (typeof hex2 === "number") { + hex2 = hex2.toString(); + } + if (typeof hex2 !== "string") { + throw new Error("Color should be defined as hex string"); + } + let hexCode = hex2.slice().replace("#", "").split(""); + if (hexCode.length < 3 || hexCode.length === 5 || hexCode.length > 8) { + throw new Error("Invalid hex color: " + hex2); + } + if (hexCode.length === 3 || hexCode.length === 4) { + hexCode = Array.prototype.concat.apply([], hexCode.map(function(c) { + return [c, c]; + })); + } + if (hexCode.length === 6) hexCode.push("F", "F"); + const hexValue = parseInt(hexCode.join(""), 16); + return { + r: hexValue >> 24 & 255, + g: hexValue >> 16 & 255, + b: hexValue >> 8 & 255, + a: hexValue & 255, + hex: "#" + hexCode.slice(0, 6).join("") + }; + } + exports2.getOptions = function getOptions(options) { + if (!options) options = {}; + if (!options.color) options.color = {}; + const margin = typeof options.margin === "undefined" || options.margin === null || options.margin < 0 ? 4 : options.margin; + const width = options.width && options.width >= 21 ? options.width : void 0; + const scale = options.scale || 4; + return { + width, + scale: width ? 4 : scale, + margin, + color: { + dark: hex2rgba(options.color.dark || "#000000ff"), + light: hex2rgba(options.color.light || "#ffffffff") + }, + type: options.type, + rendererOpts: options.rendererOpts || {} + }; + }; + exports2.getScale = function getScale(qrSize, opts) { + return opts.width && opts.width >= qrSize + opts.margin * 2 ? opts.width / (qrSize + opts.margin * 2) : opts.scale; + }; + exports2.getImageWidth = function getImageWidth(qrSize, opts) { + const scale = exports2.getScale(qrSize, opts); + return Math.floor((qrSize + opts.margin * 2) * scale); + }; + exports2.qrToImageData = function qrToImageData(imgData, qr, opts) { + const size = qr.modules.size; + const data = qr.modules.data; + const scale = exports2.getScale(size, opts); + const symbolSize = Math.floor((size + opts.margin * 2) * scale); + const scaledMargin = opts.margin * scale; + const palette = [opts.color.light, opts.color.dark]; + for (let i3 = 0; i3 < symbolSize; i3++) { + for (let j = 0; j < symbolSize; j++) { + let posDst = (i3 * symbolSize + j) * 4; + let pxColor = opts.color.light; + if (i3 >= scaledMargin && j >= scaledMargin && i3 < symbolSize - scaledMargin && j < symbolSize - scaledMargin) { + const iSrc = Math.floor((i3 - scaledMargin) / scale); + const jSrc = Math.floor((j - scaledMargin) / scale); + pxColor = palette[data[iSrc * size + jSrc] ? 1 : 0]; + } + imgData[posDst++] = pxColor.r; + imgData[posDst++] = pxColor.g; + imgData[posDst++] = pxColor.b; + imgData[posDst] = pxColor.a; + } + } + }; + } +}); + +// node_modules/qrcode/lib/renderer/png.js +var require_png2 = __commonJS({ + "node_modules/qrcode/lib/renderer/png.js"(exports2) { + var fs = require("fs"); + var PNG = require_png().PNG; + var Utils = require_utils2(); + exports2.render = function render(qrData, options) { + const opts = Utils.getOptions(options); + const pngOpts = opts.rendererOpts; + const size = Utils.getImageWidth(qrData.modules.size, opts); + pngOpts.width = size; + pngOpts.height = size; + const pngImage = new PNG(pngOpts); + Utils.qrToImageData(pngImage.data, qrData, opts); + return pngImage; + }; + exports2.renderToDataURL = function renderToDataURL(qrData, options, cb) { + if (typeof cb === "undefined") { + cb = options; + options = void 0; + } + exports2.renderToBuffer(qrData, options, function(err, output4) { + if (err) cb(err); + let url = "data:image/png;base64,"; + url += output4.toString("base64"); + cb(null, url); + }); + }; + exports2.renderToBuffer = function renderToBuffer(qrData, options, cb) { + if (typeof cb === "undefined") { + cb = options; + options = void 0; + } + const png = exports2.render(qrData, options); + const buffer = []; + png.on("error", cb); + png.on("data", function(data) { + buffer.push(data); + }); + png.on("end", function() { + cb(null, Buffer.concat(buffer)); + }); + png.pack(); + }; + exports2.renderToFile = function renderToFile(path, qrData, options, cb) { + if (typeof cb === "undefined") { + cb = options; + options = void 0; + } + let called = false; + const done = (...args) => { + if (called) return; + called = true; + cb.apply(null, args); + }; + const stream = fs.createWriteStream(path); + stream.on("error", done); + stream.on("close", done); + exports2.renderToFileStream(stream, qrData, options); + }; + exports2.renderToFileStream = function renderToFileStream(stream, qrData, options) { + const png = exports2.render(qrData, options); + png.pack().pipe(stream); + }; + } +}); + +// node_modules/qrcode/lib/renderer/utf8.js +var require_utf8 = __commonJS({ + "node_modules/qrcode/lib/renderer/utf8.js"(exports2) { + var Utils = require_utils2(); + var BLOCK_CHAR = { + WW: " ", + WB: "\u2584", + BB: "\u2588", + BW: "\u2580" + }; + var INVERTED_BLOCK_CHAR = { + BB: " ", + BW: "\u2584", + WW: "\u2588", + WB: "\u2580" + }; + function getBlockChar(top, bottom, blocks) { + if (top && bottom) return blocks.BB; + if (top && !bottom) return blocks.BW; + if (!top && bottom) return blocks.WB; + return blocks.WW; + } + exports2.render = function(qrData, options, cb) { + const opts = Utils.getOptions(options); + let blocks = BLOCK_CHAR; + if (opts.color.dark.hex === "#ffffff" || opts.color.light.hex === "#000000") { + blocks = INVERTED_BLOCK_CHAR; + } + const size = qrData.modules.size; + const data = qrData.modules.data; + let output4 = ""; + let hMargin = Array(size + opts.margin * 2 + 1).join(blocks.WW); + hMargin = Array(opts.margin / 2 + 1).join(hMargin + "\n"); + const vMargin = Array(opts.margin + 1).join(blocks.WW); + output4 += hMargin; + for (let i3 = 0; i3 < size; i3 += 2) { + output4 += vMargin; + for (let j = 0; j < size; j++) { + const topModule = data[i3 * size + j]; + const bottomModule = data[(i3 + 1) * size + j]; + output4 += getBlockChar(topModule, bottomModule, blocks); + } + output4 += vMargin + "\n"; + } + output4 += hMargin.slice(0, -1); + if (typeof cb === "function") { + cb(null, output4); + } + return output4; + }; + exports2.renderToFile = function renderToFile(path, qrData, options, cb) { + if (typeof cb === "undefined") { + cb = options; + options = void 0; + } + const fs = require("fs"); + const utf82 = exports2.render(qrData, options); + fs.writeFile(path, utf82, cb); + }; + } +}); + +// node_modules/qrcode/lib/renderer/terminal/terminal.js +var require_terminal = __commonJS({ + "node_modules/qrcode/lib/renderer/terminal/terminal.js"(exports2) { + exports2.render = function(qrData, options, cb) { + const size = qrData.modules.size; + const data = qrData.modules.data; + const black = "\x1B[40m \x1B[0m"; + const white = "\x1B[47m \x1B[0m"; + let output4 = ""; + const hMargin = Array(size + 3).join(white); + const vMargin = Array(2).join(white); + output4 += hMargin + "\n"; + for (let i3 = 0; i3 < size; ++i3) { + output4 += white; + for (let j = 0; j < size; j++) { + output4 += data[i3 * size + j] ? black : white; + } + output4 += vMargin + "\n"; + } + output4 += hMargin + "\n"; + if (typeof cb === "function") { + cb(null, output4); + } + return output4; + }; + } +}); + +// node_modules/qrcode/lib/renderer/terminal/terminal-small.js +var require_terminal_small = __commonJS({ + "node_modules/qrcode/lib/renderer/terminal/terminal-small.js"(exports2) { + var backgroundWhite = "\x1B[47m"; + var backgroundBlack = "\x1B[40m"; + var foregroundWhite = "\x1B[37m"; + var foregroundBlack = "\x1B[30m"; + var reset = "\x1B[0m"; + var lineSetupNormal = backgroundWhite + foregroundBlack; + var lineSetupInverse = backgroundBlack + foregroundWhite; + var createPalette = function(lineSetup, foregroundWhite2, foregroundBlack2) { + return { + // 1 ... white, 2 ... black, 0 ... transparent (default) + "00": reset + " " + lineSetup, + "01": reset + foregroundWhite2 + "\u2584" + lineSetup, + "02": reset + foregroundBlack2 + "\u2584" + lineSetup, + 10: reset + foregroundWhite2 + "\u2580" + lineSetup, + 11: " ", + 12: "\u2584", + 20: reset + foregroundBlack2 + "\u2580" + lineSetup, + 21: "\u2580", + 22: "\u2588" + }; + }; + var mkCodePixel = function(modules, size, x, y) { + const sizePlus = size + 1; + if (x >= sizePlus || y >= sizePlus || y < -1 || x < -1) return "0"; + if (x >= size || y >= size || y < 0 || x < 0) return "1"; + const idx = y * size + x; + return modules[idx] ? "2" : "1"; + }; + var mkCode = function(modules, size, x, y) { + return mkCodePixel(modules, size, x, y) + mkCodePixel(modules, size, x, y + 1); + }; + exports2.render = function(qrData, options, cb) { + const size = qrData.modules.size; + const data = qrData.modules.data; + const inverse = !!(options && options.inverse); + const lineSetup = options && options.inverse ? lineSetupInverse : lineSetupNormal; + const white = inverse ? foregroundBlack : foregroundWhite; + const black = inverse ? foregroundWhite : foregroundBlack; + const palette = createPalette(lineSetup, white, black); + const newLine = reset + "\n" + lineSetup; + let output4 = lineSetup; + for (let y = -1; y < size + 1; y += 2) { + for (let x = -1; x < size; x++) { + output4 += palette[mkCode(data, size, x, y)]; + } + output4 += palette[mkCode(data, size, size, y)] + newLine; + } + output4 += reset; + if (typeof cb === "function") { + cb(null, output4); + } + return output4; + }; + } +}); + +// node_modules/qrcode/lib/renderer/terminal.js +var require_terminal2 = __commonJS({ + "node_modules/qrcode/lib/renderer/terminal.js"(exports2) { + var big = require_terminal(); + var small = require_terminal_small(); + exports2.render = function(qrData, options, cb) { + if (options && options.small) { + return small.render(qrData, options, cb); + } + return big.render(qrData, options, cb); + }; + } +}); + +// node_modules/qrcode/lib/renderer/svg-tag.js +var require_svg_tag = __commonJS({ + "node_modules/qrcode/lib/renderer/svg-tag.js"(exports2) { + var Utils = require_utils2(); + function getColorAttrib(color, attrib) { + const alpha = color.a / 255; + const str = attrib + '="' + color.hex + '"'; + return alpha < 1 ? str + " " + attrib + '-opacity="' + alpha.toFixed(2).slice(1) + '"' : str; + } + function svgCmd(cmd, x, y) { + let str = cmd + x; + if (typeof y !== "undefined") str += " " + y; + return str; + } + function qrToPath(data, size, margin) { + let path = ""; + let moveBy = 0; + let newRow = false; + let lineLength = 0; + for (let i3 = 0; i3 < data.length; i3++) { + const col = Math.floor(i3 % size); + const row = Math.floor(i3 / size); + if (!col && !newRow) newRow = true; + if (data[i3]) { + lineLength++; + if (!(i3 > 0 && col > 0 && data[i3 - 1])) { + path += newRow ? svgCmd("M", col + margin, 0.5 + row + margin) : svgCmd("m", moveBy, 0); + moveBy = 0; + newRow = false; + } + if (!(col + 1 < size && data[i3 + 1])) { + path += svgCmd("h", lineLength); + lineLength = 0; + } + } else { + moveBy++; + } + } + return path; + } + exports2.render = function render(qrData, options, cb) { + const opts = Utils.getOptions(options); + const size = qrData.modules.size; + const data = qrData.modules.data; + const qrcodesize = size + opts.margin * 2; + const bg = !opts.color.light.a ? "" : "'; + const path = "'; + const viewBox = 'viewBox="0 0 ' + qrcodesize + " " + qrcodesize + '"'; + const width = !opts.width ? "" : 'width="' + opts.width + '" height="' + opts.width + '" '; + const svgTag = '' + bg + path + "\n"; + if (typeof cb === "function") { + cb(null, svgTag); + } + return svgTag; + }; + } +}); + +// node_modules/qrcode/lib/renderer/svg.js +var require_svg = __commonJS({ + "node_modules/qrcode/lib/renderer/svg.js"(exports2) { + var svgTagRenderer = require_svg_tag(); + exports2.render = svgTagRenderer.render; + exports2.renderToFile = function renderToFile(path, qrData, options, cb) { + if (typeof cb === "undefined") { + cb = options; + options = void 0; + } + const fs = require("fs"); + const svgTag = exports2.render(qrData, options); + const xmlStr = '' + svgTag; + fs.writeFile(path, xmlStr, cb); + }; + } +}); + +// node_modules/qrcode/lib/renderer/canvas.js +var require_canvas = __commonJS({ + "node_modules/qrcode/lib/renderer/canvas.js"(exports2) { + var Utils = require_utils2(); + function clearCanvas(ctx, canvas, size) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!canvas.style) canvas.style = {}; + canvas.height = size; + canvas.width = size; + canvas.style.height = size + "px"; + canvas.style.width = size + "px"; + } + function getCanvasElement() { + try { + return document.createElement("canvas"); + } catch (e) { + throw new Error("You need to specify a canvas element"); + } + } + exports2.render = function render(qrData, canvas, options) { + let opts = options; + let canvasEl = canvas; + if (typeof opts === "undefined" && (!canvas || !canvas.getContext)) { + opts = canvas; + canvas = void 0; + } + if (!canvas) { + canvasEl = getCanvasElement(); + } + opts = Utils.getOptions(opts); + const size = Utils.getImageWidth(qrData.modules.size, opts); + const ctx = canvasEl.getContext("2d"); + const image = ctx.createImageData(size, size); + Utils.qrToImageData(image.data, qrData, opts); + clearCanvas(ctx, canvasEl, size); + ctx.putImageData(image, 0, 0); + return canvasEl; + }; + exports2.renderToDataURL = function renderToDataURL(qrData, canvas, options) { + let opts = options; + if (typeof opts === "undefined" && (!canvas || !canvas.getContext)) { + opts = canvas; + canvas = void 0; + } + if (!opts) opts = {}; + const canvasEl = exports2.render(qrData, canvas, opts); + const type = opts.type || "image/png"; + const rendererOpts = opts.rendererOpts || {}; + return canvasEl.toDataURL(type, rendererOpts.quality); + }; + } +}); + +// node_modules/qrcode/lib/browser.js +var require_browser = __commonJS({ + "node_modules/qrcode/lib/browser.js"(exports2) { + var canPromise = require_can_promise(); + var QRCode2 = require_qrcode(); + var CanvasRenderer = require_canvas(); + var SvgRenderer = require_svg_tag(); + function renderCanvas(renderFunc, canvas, text, opts, cb) { + const args = [].slice.call(arguments, 1); + const argsNum = args.length; + const isLastArgCb = typeof args[argsNum - 1] === "function"; + if (!isLastArgCb && !canPromise()) { + throw new Error("Callback required as last argument"); + } + if (isLastArgCb) { + if (argsNum < 2) { + throw new Error("Too few arguments provided"); + } + if (argsNum === 2) { + cb = text; + text = canvas; + canvas = opts = void 0; + } else if (argsNum === 3) { + if (canvas.getContext && typeof cb === "undefined") { + cb = opts; + opts = void 0; + } else { + cb = opts; + opts = text; + text = canvas; + canvas = void 0; + } + } + } else { + if (argsNum < 1) { + throw new Error("Too few arguments provided"); + } + if (argsNum === 1) { + text = canvas; + canvas = opts = void 0; + } else if (argsNum === 2 && !canvas.getContext) { + opts = text; + text = canvas; + canvas = void 0; + } + return new Promise(function(resolve, reject) { + try { + const data = QRCode2.create(text, opts); + resolve(renderFunc(data, canvas, opts)); + } catch (e) { + reject(e); + } + }); + } + try { + const data = QRCode2.create(text, opts); + cb(null, renderFunc(data, canvas, opts)); + } catch (e) { + cb(e); + } + } + exports2.create = QRCode2.create; + exports2.toCanvas = renderCanvas.bind(null, CanvasRenderer.render); + exports2.toDataURL = renderCanvas.bind(null, CanvasRenderer.renderToDataURL); + exports2.toString = renderCanvas.bind(null, function(data, _, opts) { + return SvgRenderer.render(data, opts); + }); + } +}); + +// node_modules/qrcode/lib/server.js +var require_server = __commonJS({ + "node_modules/qrcode/lib/server.js"(exports2) { + var canPromise = require_can_promise(); + var QRCode2 = require_qrcode(); + var PngRenderer = require_png2(); + var Utf8Renderer = require_utf8(); + var TerminalRenderer = require_terminal2(); + var SvgRenderer = require_svg(); + function checkParams(text, opts, cb) { + if (typeof text === "undefined") { + throw new Error("String required as first argument"); + } + if (typeof cb === "undefined") { + cb = opts; + opts = {}; + } + if (typeof cb !== "function") { + if (!canPromise()) { + throw new Error("Callback required as last argument"); + } else { + opts = cb || {}; + cb = null; + } + } + return { + opts, + cb + }; + } + function getTypeFromFilename(path) { + return path.slice((path.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase(); + } + function getRendererFromType(type) { + switch (type) { + case "svg": + return SvgRenderer; + case "txt": + case "utf8": + return Utf8Renderer; + case "png": + case "image/png": + default: + return PngRenderer; + } + } + function getStringRendererFromType(type) { + switch (type) { + case "svg": + return SvgRenderer; + case "terminal": + return TerminalRenderer; + case "utf8": + default: + return Utf8Renderer; + } + } + function render(renderFunc, text, params) { + if (!params.cb) { + return new Promise(function(resolve, reject) { + try { + const data = QRCode2.create(text, params.opts); + return renderFunc(data, params.opts, function(err, data2) { + return err ? reject(err) : resolve(data2); + }); + } catch (e) { + reject(e); + } + }); + } + try { + const data = QRCode2.create(text, params.opts); + return renderFunc(data, params.opts, params.cb); + } catch (e) { + params.cb(e); + } + } + exports2.create = QRCode2.create; + exports2.toCanvas = require_browser().toCanvas; + exports2.toString = function toString(text, opts, cb) { + const params = checkParams(text, opts, cb); + const type = params.opts ? params.opts.type : void 0; + const renderer = getStringRendererFromType(type); + return render(renderer.render, text, params); + }; + exports2.toDataURL = function toDataURL(text, opts, cb) { + const params = checkParams(text, opts, cb); + const renderer = getRendererFromType(params.opts.type); + return render(renderer.renderToDataURL, text, params); + }; + exports2.toBuffer = function toBuffer(text, opts, cb) { + const params = checkParams(text, opts, cb); + const renderer = getRendererFromType(params.opts.type); + return render(renderer.renderToBuffer, text, params); + }; + exports2.toFile = function toFile(path, text, opts, cb) { + if (typeof path !== "string" || !(typeof text === "string" || typeof text === "object")) { + throw new Error("Invalid argument"); + } + if (arguments.length < 3 && !canPromise()) { + throw new Error("Too few arguments provided"); + } + const params = checkParams(text, opts, cb); + const type = params.opts.type || getTypeFromFilename(path); + const renderer = getRendererFromType(type); + const renderToFile = renderer.renderToFile.bind(null, path); + return render(renderToFile, text, params); + }; + exports2.toFileStream = function toFileStream(stream, text, opts) { + if (arguments.length < 2) { + throw new Error("Too few arguments provided"); + } + const params = checkParams(text, opts, stream.emit.bind(stream, "error")); + const renderer = getRendererFromType("png"); + const renderToFileStream = renderer.renderToFileStream.bind(null, stream); + render(renderToFileStream, text, params); + }; + } +}); + +// node_modules/qrcode/lib/index.js +var require_lib = __commonJS({ + "node_modules/qrcode/lib/index.js"(exports2, module2) { + module2.exports = require_server(); + } +}); + +// tools/payment-helper.mjs +var import_bech32 = __toESM(require_dist(), 1); + +// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_assert.js +function number(n) { + if (!Number.isSafeInteger(n) || n < 0) + throw new Error(`Wrong positive integer: ${n}`); +} +function bytes(b, ...lengths) { + if (!(b instanceof Uint8Array)) + throw new Error("Expected Uint8Array"); + if (lengths.length > 0 && !lengths.includes(b.length)) + throw new Error(`Expected Uint8Array of length ${lengths}, not of length=${b.length}`); +} +function hash(hash3) { + if (typeof hash3 !== "function" || typeof hash3.create !== "function") + throw new Error("Hash should be wrapped by utils.wrapConstructor"); + number(hash3.outputLen); + number(hash3.blockLen); +} +function exists(instance, checkFinished = true) { + if (instance.destroyed) + throw new Error("Hash instance has been destroyed"); + if (checkFinished && instance.finished) + throw new Error("Hash#digest() has already been called"); +} +function output(out, instance) { + bytes(out); + const min = instance.outputLen; + if (out.length < min) { + throw new Error(`digestInto() expects output buffer of length at least ${min}`); + } +} + +// node_modules/@noble/curves/node_modules/@noble/hashes/esm/cryptoNode.js +var nc = __toESM(require("node:crypto"), 1); +var crypto = nc && typeof nc === "object" && "webcrypto" in nc ? nc.webcrypto : void 0; + +// node_modules/@noble/curves/node_modules/@noble/hashes/esm/utils.js +var u8a = (a) => a instanceof Uint8Array; +var createView = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength); +var rotr = (word, shift) => word << 32 - shift | word >>> shift; +var isLE = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68; +if (!isLE) + throw new Error("Non little-endian hardware is not supported"); +function utf8ToBytes(str) { + if (typeof str !== "string") + throw new Error(`utf8ToBytes expected string, got ${typeof str}`); + return new Uint8Array(new TextEncoder().encode(str)); +} +function toBytes(data) { + if (typeof data === "string") + data = utf8ToBytes(data); + if (!u8a(data)) + throw new Error(`expected Uint8Array, got ${typeof data}`); + return data; +} +function concatBytes(...arrays) { + const r = new Uint8Array(arrays.reduce((sum, a) => sum + a.length, 0)); + let pad2 = 0; + arrays.forEach((a) => { + if (!u8a(a)) + throw new Error("Uint8Array expected"); + r.set(a, pad2); + pad2 += a.length; + }); + return r; +} +var Hash = class { + // Safe version that clones internal state + clone() { + return this._cloneInto(); + } +}; +var toStr = {}.toString; +function wrapConstructor(hashCons) { + const hashC = (msg) => hashCons().update(toBytes(msg)).digest(); + const tmp = hashCons(); + hashC.outputLen = tmp.outputLen; + hashC.blockLen = tmp.blockLen; + hashC.create = () => hashCons(); + return hashC; +} +function randomBytes(bytesLength = 32) { + if (crypto && typeof crypto.getRandomValues === "function") { + return crypto.getRandomValues(new Uint8Array(bytesLength)); + } + throw new Error("crypto.getRandomValues must be defined"); +} + +// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_sha2.js +function setBigUint64(view, byteOffset, value, isLE4) { + if (typeof view.setBigUint64 === "function") + return view.setBigUint64(byteOffset, value, isLE4); + const _32n = BigInt(32); + const _u32_max = BigInt(4294967295); + const wh = Number(value >> _32n & _u32_max); + const wl = Number(value & _u32_max); + const h = isLE4 ? 4 : 0; + const l = isLE4 ? 0 : 4; + view.setUint32(byteOffset + h, wh, isLE4); + view.setUint32(byteOffset + l, wl, isLE4); +} +var SHA2 = class extends Hash { + constructor(blockLen, outputLen, padOffset, isLE4) { + super(); + this.blockLen = blockLen; + this.outputLen = outputLen; + this.padOffset = padOffset; + this.isLE = isLE4; + this.finished = false; + this.length = 0; + this.pos = 0; + this.destroyed = false; + this.buffer = new Uint8Array(blockLen); + this.view = createView(this.buffer); + } + update(data) { + exists(this); + const { view, buffer, blockLen } = this; + data = toBytes(data); + const len = data.length; + for (let pos = 0; pos < len; ) { + const take = Math.min(blockLen - this.pos, len - pos); + if (take === blockLen) { + const dataView = createView(data); + for (; blockLen <= len - pos; pos += blockLen) + this.process(dataView, pos); + continue; + } + buffer.set(data.subarray(pos, pos + take), this.pos); + this.pos += take; + pos += take; + if (this.pos === blockLen) { + this.process(view, 0); + this.pos = 0; + } + } + this.length += data.length; + this.roundClean(); + return this; + } + digestInto(out) { + exists(this); + output(out, this); + this.finished = true; + const { buffer, view, blockLen, isLE: isLE4 } = this; + let { pos } = this; + buffer[pos++] = 128; + this.buffer.subarray(pos).fill(0); + if (this.padOffset > blockLen - pos) { + this.process(view, 0); + pos = 0; + } + for (let i3 = pos; i3 < blockLen; i3++) + buffer[i3] = 0; + setBigUint64(view, blockLen - 8, BigInt(this.length * 8), isLE4); + this.process(view, 0); + const oview = createView(out); + const len = this.outputLen; + if (len % 4) + throw new Error("_sha2: outputLen should be aligned to 32bit"); + const outLen = len / 4; + const state = this.get(); + if (outLen > state.length) + throw new Error("_sha2: outputLen bigger than state"); + for (let i3 = 0; i3 < outLen; i3++) + oview.setUint32(4 * i3, state[i3], isLE4); + } + digest() { + const { buffer, outputLen } = this; + this.digestInto(buffer); + const res = buffer.slice(0, outputLen); + this.destroy(); + return res; + } + _cloneInto(to) { + to || (to = new this.constructor()); + to.set(...this.get()); + const { blockLen, buffer, length, finished, destroyed, pos } = this; + to.length = length; + to.pos = pos; + to.finished = finished; + to.destroyed = destroyed; + if (length % blockLen) + to.buffer.set(buffer); + return to; + } +}; + +// node_modules/@noble/curves/node_modules/@noble/hashes/esm/sha256.js +var Chi = (a, b, c) => a & b ^ ~a & c; +var Maj = (a, b, c) => a & b ^ a & c ^ b & c; +var SHA256_K = /* @__PURE__ */ new Uint32Array([ + 1116352408, + 1899447441, + 3049323471, + 3921009573, + 961987163, + 1508970993, + 2453635748, + 2870763221, + 3624381080, + 310598401, + 607225278, + 1426881987, + 1925078388, + 2162078206, + 2614888103, + 3248222580, + 3835390401, + 4022224774, + 264347078, + 604807628, + 770255983, + 1249150122, + 1555081692, + 1996064986, + 2554220882, + 2821834349, + 2952996808, + 3210313671, + 3336571891, + 3584528711, + 113926993, + 338241895, + 666307205, + 773529912, + 1294757372, + 1396182291, + 1695183700, + 1986661051, + 2177026350, + 2456956037, + 2730485921, + 2820302411, + 3259730800, + 3345764771, + 3516065817, + 3600352804, + 4094571909, + 275423344, + 430227734, + 506948616, + 659060556, + 883997877, + 958139571, + 1322822218, + 1537002063, + 1747873779, + 1955562222, + 2024104815, + 2227730452, + 2361852424, + 2428436474, + 2756734187, + 3204031479, + 3329325298 +]); +var IV = /* @__PURE__ */ new Uint32Array([ + 1779033703, + 3144134277, + 1013904242, + 2773480762, + 1359893119, + 2600822924, + 528734635, + 1541459225 +]); +var SHA256_W = /* @__PURE__ */ new Uint32Array(64); +var SHA256 = class extends SHA2 { + constructor() { + super(64, 32, 8, false); + this.A = IV[0] | 0; + this.B = IV[1] | 0; + this.C = IV[2] | 0; + this.D = IV[3] | 0; + this.E = IV[4] | 0; + this.F = IV[5] | 0; + this.G = IV[6] | 0; + this.H = IV[7] | 0; + } + get() { + const { A, B, C, D, E, F, G, H } = this; + return [A, B, C, D, E, F, G, H]; + } + // prettier-ignore + set(A, B, C, D, E, F, G, H) { + this.A = A | 0; + this.B = B | 0; + this.C = C | 0; + this.D = D | 0; + this.E = E | 0; + this.F = F | 0; + this.G = G | 0; + this.H = H | 0; + } + process(view, offset) { + for (let i3 = 0; i3 < 16; i3++, offset += 4) + SHA256_W[i3] = view.getUint32(offset, false); + for (let i3 = 16; i3 < 64; i3++) { + const W15 = SHA256_W[i3 - 15]; + const W2 = SHA256_W[i3 - 2]; + const s0 = rotr(W15, 7) ^ rotr(W15, 18) ^ W15 >>> 3; + const s1 = rotr(W2, 17) ^ rotr(W2, 19) ^ W2 >>> 10; + SHA256_W[i3] = s1 + SHA256_W[i3 - 7] + s0 + SHA256_W[i3 - 16] | 0; + } + let { A, B, C, D, E, F, G, H } = this; + for (let i3 = 0; i3 < 64; i3++) { + const sigma1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25); + const T1 = H + sigma1 + Chi(E, F, G) + SHA256_K[i3] + SHA256_W[i3] | 0; + const sigma0 = rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22); + const T2 = sigma0 + Maj(A, B, C) | 0; + H = G; + G = F; + F = E; + E = D + T1 | 0; + D = C; + C = B; + B = A; + A = T1 + T2 | 0; + } + A = A + this.A | 0; + B = B + this.B | 0; + C = C + this.C | 0; + D = D + this.D | 0; + E = E + this.E | 0; + F = F + this.F | 0; + G = G + this.G | 0; + H = H + this.H | 0; + this.set(A, B, C, D, E, F, G, H); + } + roundClean() { + SHA256_W.fill(0); + } + destroy() { + this.set(0, 0, 0, 0, 0, 0, 0, 0); + this.buffer.fill(0); + } +}; +var sha256 = /* @__PURE__ */ wrapConstructor(() => new SHA256()); + +// node_modules/@noble/curves/esm/abstract/utils.js +var utils_exports = {}; +__export(utils_exports, { + bitGet: () => bitGet, + bitLen: () => bitLen, + bitMask: () => bitMask, + bitSet: () => bitSet, + bytesToHex: () => bytesToHex, + bytesToNumberBE: () => bytesToNumberBE, + bytesToNumberLE: () => bytesToNumberLE, + concatBytes: () => concatBytes2, + createHmacDrbg: () => createHmacDrbg, + ensureBytes: () => ensureBytes, + equalBytes: () => equalBytes, + hexToBytes: () => hexToBytes, + hexToNumber: () => hexToNumber, + numberToBytesBE: () => numberToBytesBE, + numberToBytesLE: () => numberToBytesLE, + numberToHexUnpadded: () => numberToHexUnpadded, + numberToVarBytesBE: () => numberToVarBytesBE, + utf8ToBytes: () => utf8ToBytes2, + validateObject: () => validateObject +}); +var _0n = BigInt(0); +var _1n = BigInt(1); +var _2n = BigInt(2); +var u8a2 = (a) => a instanceof Uint8Array; +var hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i3) => i3.toString(16).padStart(2, "0")); +function bytesToHex(bytes4) { + if (!u8a2(bytes4)) + throw new Error("Uint8Array expected"); + let hex2 = ""; + for (let i3 = 0; i3 < bytes4.length; i3++) { + hex2 += hexes[bytes4[i3]]; + } + return hex2; +} +function numberToHexUnpadded(num) { + const hex2 = num.toString(16); + return hex2.length & 1 ? `0${hex2}` : hex2; +} +function hexToNumber(hex2) { + if (typeof hex2 !== "string") + throw new Error("hex string expected, got " + typeof hex2); + return BigInt(hex2 === "" ? "0" : `0x${hex2}`); +} +function hexToBytes(hex2) { + if (typeof hex2 !== "string") + throw new Error("hex string expected, got " + typeof hex2); + const len = hex2.length; + if (len % 2) + throw new Error("padded hex string expected, got unpadded hex of length " + len); + const array = new Uint8Array(len / 2); + for (let i3 = 0; i3 < array.length; i3++) { + const j = i3 * 2; + const hexByte = hex2.slice(j, j + 2); + const byte = Number.parseInt(hexByte, 16); + if (Number.isNaN(byte) || byte < 0) + throw new Error("Invalid byte sequence"); + array[i3] = byte; + } + return array; +} +function bytesToNumberBE(bytes4) { + return hexToNumber(bytesToHex(bytes4)); +} +function bytesToNumberLE(bytes4) { + if (!u8a2(bytes4)) + throw new Error("Uint8Array expected"); + return hexToNumber(bytesToHex(Uint8Array.from(bytes4).reverse())); +} +function numberToBytesBE(n, len) { + return hexToBytes(n.toString(16).padStart(len * 2, "0")); +} +function numberToBytesLE(n, len) { + return numberToBytesBE(n, len).reverse(); +} +function numberToVarBytesBE(n) { + return hexToBytes(numberToHexUnpadded(n)); +} +function ensureBytes(title, hex2, expectedLength) { + let res; + if (typeof hex2 === "string") { + try { + res = hexToBytes(hex2); + } catch (e) { + throw new Error(`${title} must be valid hex string, got "${hex2}". Cause: ${e}`); + } + } else if (u8a2(hex2)) { + res = Uint8Array.from(hex2); + } else { + throw new Error(`${title} must be hex string or Uint8Array`); + } + const len = res.length; + if (typeof expectedLength === "number" && len !== expectedLength) + throw new Error(`${title} expected ${expectedLength} bytes, got ${len}`); + return res; +} +function concatBytes2(...arrays) { + const r = new Uint8Array(arrays.reduce((sum, a) => sum + a.length, 0)); + let pad2 = 0; + arrays.forEach((a) => { + if (!u8a2(a)) + throw new Error("Uint8Array expected"); + r.set(a, pad2); + pad2 += a.length; + }); + return r; +} +function equalBytes(b1, b2) { + if (b1.length !== b2.length) + return false; + for (let i3 = 0; i3 < b1.length; i3++) + if (b1[i3] !== b2[i3]) + return false; + return true; +} +function utf8ToBytes2(str) { + if (typeof str !== "string") + throw new Error(`utf8ToBytes expected string, got ${typeof str}`); + return new Uint8Array(new TextEncoder().encode(str)); +} +function bitLen(n) { + let len; + for (len = 0; n > _0n; n >>= _1n, len += 1) + ; + return len; +} +function bitGet(n, pos) { + return n >> BigInt(pos) & _1n; +} +var bitSet = (n, pos, value) => { + return n | (value ? _1n : _0n) << BigInt(pos); +}; +var bitMask = (n) => (_2n << BigInt(n - 1)) - _1n; +var u8n = (data) => new Uint8Array(data); +var u8fr = (arr) => Uint8Array.from(arr); +function createHmacDrbg(hashLen, qByteLen, hmacFn) { + if (typeof hashLen !== "number" || hashLen < 2) + throw new Error("hashLen must be a number"); + if (typeof qByteLen !== "number" || qByteLen < 2) + throw new Error("qByteLen must be a number"); + if (typeof hmacFn !== "function") + throw new Error("hmacFn must be a function"); + let v = u8n(hashLen); + let k = u8n(hashLen); + let i3 = 0; + const reset = () => { + v.fill(1); + k.fill(0); + i3 = 0; + }; + const h = (...b) => hmacFn(k, v, ...b); + const reseed = (seed = u8n()) => { + k = h(u8fr([0]), seed); + v = h(); + if (seed.length === 0) + return; + k = h(u8fr([1]), seed); + v = h(); + }; + const gen = () => { + if (i3++ >= 1e3) + throw new Error("drbg: tried 1000 values"); + let len = 0; + const out = []; + while (len < qByteLen) { + v = h(); + const sl = v.slice(); + out.push(sl); + len += v.length; + } + return concatBytes2(...out); + }; + const genUntil = (seed, pred) => { + reset(); + reseed(seed); + let res = void 0; + while (!(res = pred(gen()))) + reseed(); + reset(); + return res; + }; + return genUntil; +} +var validatorFns = { + bigint: (val) => typeof val === "bigint", + function: (val) => typeof val === "function", + boolean: (val) => typeof val === "boolean", + string: (val) => typeof val === "string", + stringOrUint8Array: (val) => typeof val === "string" || val instanceof Uint8Array, + isSafeInteger: (val) => Number.isSafeInteger(val), + array: (val) => Array.isArray(val), + field: (val, object) => object.Fp.isValid(val), + hash: (val) => typeof val === "function" && Number.isSafeInteger(val.outputLen) +}; +function validateObject(object, validators, optValidators = {}) { + const checkField = (fieldName, type, isOptional) => { + const checkVal = validatorFns[type]; + if (typeof checkVal !== "function") + throw new Error(`Invalid validator "${type}", expected function`); + const val = object[fieldName]; + if (isOptional && val === void 0) + return; + if (!checkVal(val, object)) { + throw new Error(`Invalid param ${String(fieldName)}=${val} (${typeof val}), expected ${type}`); + } + }; + for (const [fieldName, type] of Object.entries(validators)) + checkField(fieldName, type, false); + for (const [fieldName, type] of Object.entries(optValidators)) + checkField(fieldName, type, true); + return object; +} + +// node_modules/@noble/curves/esm/abstract/modular.js +var _0n2 = BigInt(0); +var _1n2 = BigInt(1); +var _2n2 = BigInt(2); +var _3n = BigInt(3); +var _4n = BigInt(4); +var _5n = BigInt(5); +var _8n = BigInt(8); +var _9n = BigInt(9); +var _16n = BigInt(16); +function mod(a, b) { + const result = a % b; + return result >= _0n2 ? result : b + result; +} +function pow(num, power, modulo) { + if (modulo <= _0n2 || power < _0n2) + throw new Error("Expected power/modulo > 0"); + if (modulo === _1n2) + return _0n2; + let res = _1n2; + while (power > _0n2) { + if (power & _1n2) + res = res * num % modulo; + num = num * num % modulo; + power >>= _1n2; + } + return res; +} +function pow2(x, power, modulo) { + let res = x; + while (power-- > _0n2) { + res *= res; + res %= modulo; + } + return res; +} +function invert(number4, modulo) { + if (number4 === _0n2 || modulo <= _0n2) { + throw new Error(`invert: expected positive integers, got n=${number4} mod=${modulo}`); + } + let a = mod(number4, modulo); + let b = modulo; + let x = _0n2, y = _1n2, u = _1n2, v = _0n2; + while (a !== _0n2) { + const q = b / a; + const r = b % a; + const m = x - u * q; + const n = y - v * q; + b = a, a = r, x = u, y = v, u = m, v = n; + } + const gcd2 = b; + if (gcd2 !== _1n2) + throw new Error("invert: does not exist"); + return mod(x, modulo); +} +function tonelliShanks(P) { + const legendreC = (P - _1n2) / _2n2; + let Q, S, Z; + for (Q = P - _1n2, S = 0; Q % _2n2 === _0n2; Q /= _2n2, S++) + ; + for (Z = _2n2; Z < P && pow(Z, legendreC, P) !== P - _1n2; Z++) + ; + if (S === 1) { + const p1div4 = (P + _1n2) / _4n; + return function tonelliFast(Fp2, n) { + const root = Fp2.pow(n, p1div4); + if (!Fp2.eql(Fp2.sqr(root), n)) + throw new Error("Cannot find square root"); + return root; + }; + } + const Q1div2 = (Q + _1n2) / _2n2; + return function tonelliSlow(Fp2, n) { + if (Fp2.pow(n, legendreC) === Fp2.neg(Fp2.ONE)) + throw new Error("Cannot find square root"); + let r = S; + let g = Fp2.pow(Fp2.mul(Fp2.ONE, Z), Q); + let x = Fp2.pow(n, Q1div2); + let b = Fp2.pow(n, Q); + while (!Fp2.eql(b, Fp2.ONE)) { + if (Fp2.eql(b, Fp2.ZERO)) + return Fp2.ZERO; + let m = 1; + for (let t2 = Fp2.sqr(b); m < r; m++) { + if (Fp2.eql(t2, Fp2.ONE)) + break; + t2 = Fp2.sqr(t2); + } + const ge2 = Fp2.pow(g, _1n2 << BigInt(r - m - 1)); + g = Fp2.sqr(ge2); + x = Fp2.mul(x, ge2); + b = Fp2.mul(b, g); + r = m; + } + return x; + }; +} +function FpSqrt(P) { + if (P % _4n === _3n) { + const p1div4 = (P + _1n2) / _4n; + return function sqrt3mod4(Fp2, n) { + const root = Fp2.pow(n, p1div4); + if (!Fp2.eql(Fp2.sqr(root), n)) + throw new Error("Cannot find square root"); + return root; + }; + } + if (P % _8n === _5n) { + const c1 = (P - _5n) / _8n; + return function sqrt5mod8(Fp2, n) { + const n2 = Fp2.mul(n, _2n2); + const v = Fp2.pow(n2, c1); + const nv = Fp2.mul(n, v); + const i3 = Fp2.mul(Fp2.mul(nv, _2n2), v); + const root = Fp2.mul(nv, Fp2.sub(i3, Fp2.ONE)); + if (!Fp2.eql(Fp2.sqr(root), n)) + throw new Error("Cannot find square root"); + return root; + }; + } + if (P % _16n === _9n) { + } + return tonelliShanks(P); +} +var FIELD_FIELDS = [ + "create", + "isValid", + "is0", + "neg", + "inv", + "sqrt", + "sqr", + "eql", + "add", + "sub", + "mul", + "pow", + "div", + "addN", + "subN", + "mulN", + "sqrN" +]; +function validateField(field) { + const initial = { + ORDER: "bigint", + MASK: "bigint", + BYTES: "isSafeInteger", + BITS: "isSafeInteger" + }; + const opts = FIELD_FIELDS.reduce((map, val) => { + map[val] = "function"; + return map; + }, initial); + return validateObject(field, opts); +} +function FpPow(f, num, power) { + if (power < _0n2) + throw new Error("Expected power > 0"); + if (power === _0n2) + return f.ONE; + if (power === _1n2) + return num; + let p = f.ONE; + let d = num; + while (power > _0n2) { + if (power & _1n2) + p = f.mul(p, d); + d = f.sqr(d); + power >>= _1n2; + } + return p; +} +function FpInvertBatch(f, nums) { + const tmp = new Array(nums.length); + const lastMultiplied = nums.reduce((acc, num, i3) => { + if (f.is0(num)) + return acc; + tmp[i3] = acc; + return f.mul(acc, num); + }, f.ONE); + const inverted = f.inv(lastMultiplied); + nums.reduceRight((acc, num, i3) => { + if (f.is0(num)) + return acc; + tmp[i3] = f.mul(acc, tmp[i3]); + return f.mul(acc, num); + }, inverted); + return tmp; +} +function nLength(n, nBitLength) { + const _nBitLength = nBitLength !== void 0 ? nBitLength : n.toString(2).length; + const nByteLength = Math.ceil(_nBitLength / 8); + return { nBitLength: _nBitLength, nByteLength }; +} +function Field(ORDER, bitLen2, isLE4 = false, redef = {}) { + if (ORDER <= _0n2) + throw new Error(`Expected Field ORDER > 0, got ${ORDER}`); + const { nBitLength: BITS, nByteLength: BYTES } = nLength(ORDER, bitLen2); + if (BYTES > 2048) + throw new Error("Field lengths over 2048 bytes are not supported"); + const sqrtP = FpSqrt(ORDER); + const f = Object.freeze({ + ORDER, + BITS, + BYTES, + MASK: bitMask(BITS), + ZERO: _0n2, + ONE: _1n2, + create: (num) => mod(num, ORDER), + isValid: (num) => { + if (typeof num !== "bigint") + throw new Error(`Invalid field element: expected bigint, got ${typeof num}`); + return _0n2 <= num && num < ORDER; + }, + is0: (num) => num === _0n2, + isOdd: (num) => (num & _1n2) === _1n2, + neg: (num) => mod(-num, ORDER), + eql: (lhs, rhs) => lhs === rhs, + sqr: (num) => mod(num * num, ORDER), + add: (lhs, rhs) => mod(lhs + rhs, ORDER), + sub: (lhs, rhs) => mod(lhs - rhs, ORDER), + mul: (lhs, rhs) => mod(lhs * rhs, ORDER), + pow: (num, power) => FpPow(f, num, power), + div: (lhs, rhs) => mod(lhs * invert(rhs, ORDER), ORDER), + // Same as above, but doesn't normalize + sqrN: (num) => num * num, + addN: (lhs, rhs) => lhs + rhs, + subN: (lhs, rhs) => lhs - rhs, + mulN: (lhs, rhs) => lhs * rhs, + inv: (num) => invert(num, ORDER), + sqrt: redef.sqrt || ((n) => sqrtP(f, n)), + invertBatch: (lst) => FpInvertBatch(f, lst), + // TODO: do we really need constant cmov? + // We don't have const-time bigints anyway, so probably will be not very useful + cmov: (a, b, c) => c ? b : a, + toBytes: (num) => isLE4 ? numberToBytesLE(num, BYTES) : numberToBytesBE(num, BYTES), + fromBytes: (bytes4) => { + if (bytes4.length !== BYTES) + throw new Error(`Fp.fromBytes: expected ${BYTES}, got ${bytes4.length}`); + return isLE4 ? bytesToNumberLE(bytes4) : bytesToNumberBE(bytes4); + } + }); + return Object.freeze(f); +} +function getFieldBytesLength(fieldOrder) { + if (typeof fieldOrder !== "bigint") + throw new Error("field order must be bigint"); + const bitLength = fieldOrder.toString(2).length; + return Math.ceil(bitLength / 8); +} +function getMinHashLength(fieldOrder) { + const length = getFieldBytesLength(fieldOrder); + return length + Math.ceil(length / 2); +} +function mapHashToField(key, fieldOrder, isLE4 = false) { + const len = key.length; + const fieldLen = getFieldBytesLength(fieldOrder); + const minLen = getMinHashLength(fieldOrder); + if (len < 16 || len < minLen || len > 1024) + throw new Error(`expected ${minLen}-1024 bytes of input, got ${len}`); + const num = isLE4 ? bytesToNumberBE(key) : bytesToNumberLE(key); + const reduced = mod(num, fieldOrder - _1n2) + _1n2; + return isLE4 ? numberToBytesLE(reduced, fieldLen) : numberToBytesBE(reduced, fieldLen); +} + +// node_modules/@noble/curves/esm/abstract/curve.js +var _0n3 = BigInt(0); +var _1n3 = BigInt(1); +function wNAF(c, bits) { + const constTimeNegate = (condition, item) => { + const neg = item.negate(); + return condition ? neg : item; + }; + const opts = (W) => { + const windows = Math.ceil(bits / W) + 1; + const windowSize = 2 ** (W - 1); + return { windows, windowSize }; + }; + return { + constTimeNegate, + // non-const time multiplication ladder + unsafeLadder(elm, n) { + let p = c.ZERO; + let d = elm; + while (n > _0n3) { + if (n & _1n3) + p = p.add(d); + d = d.double(); + n >>= _1n3; + } + return p; + }, + /** + * Creates a wNAF precomputation window. Used for caching. + * Default window size is set by `utils.precompute()` and is equal to 8. + * Number of precomputed points depends on the curve size: + * 2^(𝑊−1) * (Math.ceil(𝑛 / 𝑊) + 1), where: + * - 𝑊 is the window size + * - 𝑛 is the bitlength of the curve order. + * For a 256-bit curve and window size 8, the number of precomputed points is 128 * 33 = 4224. + * @returns precomputed point tables flattened to a single array + */ + precomputeWindow(elm, W) { + const { windows, windowSize } = opts(W); + const points = []; + let p = elm; + let base = p; + for (let window = 0; window < windows; window++) { + base = p; + points.push(base); + for (let i3 = 1; i3 < windowSize; i3++) { + base = base.add(p); + points.push(base); + } + p = base.double(); + } + return points; + }, + /** + * Implements ec multiplication using precomputed tables and w-ary non-adjacent form. + * @param W window size + * @param precomputes precomputed tables + * @param n scalar (we don't check here, but should be less than curve order) + * @returns real and fake (for const-time) points + */ + wNAF(W, precomputes, n) { + const { windows, windowSize } = opts(W); + let p = c.ZERO; + let f = c.BASE; + const mask = BigInt(2 ** W - 1); + const maxNumber = 2 ** W; + const shiftBy = BigInt(W); + for (let window = 0; window < windows; window++) { + const offset = window * windowSize; + let wbits = Number(n & mask); + n >>= shiftBy; + if (wbits > windowSize) { + wbits -= maxNumber; + n += _1n3; + } + const offset1 = offset; + const offset2 = offset + Math.abs(wbits) - 1; + const cond1 = window % 2 !== 0; + const cond2 = wbits < 0; + if (wbits === 0) { + f = f.add(constTimeNegate(cond1, precomputes[offset1])); + } else { + p = p.add(constTimeNegate(cond2, precomputes[offset2])); + } + } + return { p, f }; + }, + wNAFCached(P, precomputesMap, n, transform) { + const W = P._WINDOW_SIZE || 1; + let comp = precomputesMap.get(P); + if (!comp) { + comp = this.precomputeWindow(P, W); + if (W !== 1) { + precomputesMap.set(P, transform(comp)); + } + } + return this.wNAF(W, comp, n); + } + }; +} +function validateBasic(curve) { + validateField(curve.Fp); + validateObject(curve, { + n: "bigint", + h: "bigint", + Gx: "field", + Gy: "field" + }, { + nBitLength: "isSafeInteger", + nByteLength: "isSafeInteger" + }); + return Object.freeze({ + ...nLength(curve.n, curve.nBitLength), + ...curve, + ...{ p: curve.Fp.ORDER } + }); +} + +// node_modules/@noble/curves/esm/abstract/weierstrass.js +function validatePointOpts(curve) { + const opts = validateBasic(curve); + validateObject(opts, { + a: "field", + b: "field" + }, { + allowedPrivateKeyLengths: "array", + wrapPrivateKey: "boolean", + isTorsionFree: "function", + clearCofactor: "function", + allowInfinityPoint: "boolean", + fromBytes: "function", + toBytes: "function" + }); + const { endo, Fp: Fp2, a } = opts; + if (endo) { + if (!Fp2.eql(a, Fp2.ZERO)) { + throw new Error("Endomorphism can only be defined for Koblitz curves that have a=0"); + } + if (typeof endo !== "object" || typeof endo.beta !== "bigint" || typeof endo.splitScalar !== "function") { + throw new Error("Expected endomorphism with beta: bigint and splitScalar: function"); + } + } + return Object.freeze({ ...opts }); +} +var { bytesToNumberBE: b2n, hexToBytes: h2b } = utils_exports; +var DER = { + // asn.1 DER encoding utils + Err: class DERErr extends Error { + constructor(m = "") { + super(m); + } + }, + _parseInt(data) { + const { Err: E } = DER; + if (data.length < 2 || data[0] !== 2) + throw new E("Invalid signature integer tag"); + const len = data[1]; + const res = data.subarray(2, len + 2); + if (!len || res.length !== len) + throw new E("Invalid signature integer: wrong length"); + if (res[0] & 128) + throw new E("Invalid signature integer: negative"); + if (res[0] === 0 && !(res[1] & 128)) + throw new E("Invalid signature integer: unnecessary leading zero"); + return { d: b2n(res), l: data.subarray(len + 2) }; + }, + toSig(hex2) { + const { Err: E } = DER; + const data = typeof hex2 === "string" ? h2b(hex2) : hex2; + if (!(data instanceof Uint8Array)) + throw new Error("ui8a expected"); + let l = data.length; + if (l < 2 || data[0] != 48) + throw new E("Invalid signature tag"); + if (data[1] !== l - 2) + throw new E("Invalid signature: incorrect length"); + const { d: r, l: sBytes } = DER._parseInt(data.subarray(2)); + const { d: s, l: rBytesLeft } = DER._parseInt(sBytes); + if (rBytesLeft.length) + throw new E("Invalid signature: left bytes after parsing"); + return { r, s }; + }, + hexFromSig(sig) { + const slice = (s2) => Number.parseInt(s2[0], 16) & 8 ? "00" + s2 : s2; + const h = (num) => { + const hex2 = num.toString(16); + return hex2.length & 1 ? `0${hex2}` : hex2; + }; + const s = slice(h(sig.s)); + const r = slice(h(sig.r)); + const shl = s.length / 2; + const rhl = r.length / 2; + const sl = h(shl); + const rl = h(rhl); + return `30${h(rhl + shl + 4)}02${rl}${r}02${sl}${s}`; + } +}; +var _0n4 = BigInt(0); +var _1n4 = BigInt(1); +var _2n3 = BigInt(2); +var _3n2 = BigInt(3); +var _4n2 = BigInt(4); +function weierstrassPoints(opts) { + const CURVE = validatePointOpts(opts); + const { Fp: Fp2 } = CURVE; + const toBytes4 = CURVE.toBytes || ((_c, point, _isCompressed) => { + const a = point.toAffine(); + return concatBytes2(Uint8Array.from([4]), Fp2.toBytes(a.x), Fp2.toBytes(a.y)); + }); + const fromBytes = CURVE.fromBytes || ((bytes4) => { + const tail = bytes4.subarray(1); + const x = Fp2.fromBytes(tail.subarray(0, Fp2.BYTES)); + const y = Fp2.fromBytes(tail.subarray(Fp2.BYTES, 2 * Fp2.BYTES)); + return { x, y }; + }); + function weierstrassEquation(x) { + const { a, b } = CURVE; + const x2 = Fp2.sqr(x); + const x3 = Fp2.mul(x2, x); + return Fp2.add(Fp2.add(x3, Fp2.mul(x, a)), b); + } + if (!Fp2.eql(Fp2.sqr(CURVE.Gy), weierstrassEquation(CURVE.Gx))) + throw new Error("bad generator point: equation left != right"); + function isWithinCurveOrder(num) { + return typeof num === "bigint" && _0n4 < num && num < CURVE.n; + } + function assertGE(num) { + if (!isWithinCurveOrder(num)) + throw new Error("Expected valid bigint: 0 < bigint < curve.n"); + } + function normPrivateKeyToScalar(key) { + const { allowedPrivateKeyLengths: lengths, nByteLength, wrapPrivateKey, n } = CURVE; + if (lengths && typeof key !== "bigint") { + if (key instanceof Uint8Array) + key = bytesToHex(key); + if (typeof key !== "string" || !lengths.includes(key.length)) + throw new Error("Invalid key"); + key = key.padStart(nByteLength * 2, "0"); + } + let num; + try { + num = typeof key === "bigint" ? key : bytesToNumberBE(ensureBytes("private key", key, nByteLength)); + } catch (error) { + throw new Error(`private key must be ${nByteLength} bytes, hex or bigint, not ${typeof key}`); + } + if (wrapPrivateKey) + num = mod(num, n); + assertGE(num); + return num; + } + const pointPrecomputes = /* @__PURE__ */ new Map(); + function assertPrjPoint(other) { + if (!(other instanceof Point2)) + throw new Error("ProjectivePoint expected"); + } + class Point2 { + constructor(px, py, pz) { + this.px = px; + this.py = py; + this.pz = pz; + if (px == null || !Fp2.isValid(px)) + throw new Error("x required"); + if (py == null || !Fp2.isValid(py)) + throw new Error("y required"); + if (pz == null || !Fp2.isValid(pz)) + throw new Error("z required"); + } + // Does not validate if the point is on-curve. + // Use fromHex instead, or call assertValidity() later. + static fromAffine(p) { + const { x, y } = p || {}; + if (!p || !Fp2.isValid(x) || !Fp2.isValid(y)) + throw new Error("invalid affine point"); + if (p instanceof Point2) + throw new Error("projective point not allowed"); + const is0 = (i3) => Fp2.eql(i3, Fp2.ZERO); + if (is0(x) && is0(y)) + return Point2.ZERO; + return new Point2(x, y, Fp2.ONE); + } + get x() { + return this.toAffine().x; + } + get y() { + return this.toAffine().y; + } + /** + * Takes a bunch of Projective Points but executes only one + * inversion on all of them. Inversion is very slow operation, + * so this improves performance massively. + * Optimization: converts a list of projective points to a list of identical points with Z=1. + */ + static normalizeZ(points) { + const toInv = Fp2.invertBatch(points.map((p) => p.pz)); + return points.map((p, i3) => p.toAffine(toInv[i3])).map(Point2.fromAffine); + } + /** + * Converts hash string or Uint8Array to Point. + * @param hex short/long ECDSA hex + */ + static fromHex(hex2) { + const P = Point2.fromAffine(fromBytes(ensureBytes("pointHex", hex2))); + P.assertValidity(); + return P; + } + // Multiplies generator point by privateKey. + static fromPrivateKey(privateKey) { + return Point2.BASE.multiply(normPrivateKeyToScalar(privateKey)); + } + // "Private method", don't use it directly + _setWindowSize(windowSize) { + this._WINDOW_SIZE = windowSize; + pointPrecomputes.delete(this); + } + // A point on curve is valid if it conforms to equation. + assertValidity() { + if (this.is0()) { + if (CURVE.allowInfinityPoint && !Fp2.is0(this.py)) + return; + throw new Error("bad point: ZERO"); + } + const { x, y } = this.toAffine(); + if (!Fp2.isValid(x) || !Fp2.isValid(y)) + throw new Error("bad point: x or y not FE"); + const left = Fp2.sqr(y); + const right = weierstrassEquation(x); + if (!Fp2.eql(left, right)) + throw new Error("bad point: equation left != right"); + if (!this.isTorsionFree()) + throw new Error("bad point: not in prime-order subgroup"); + } + hasEvenY() { + const { y } = this.toAffine(); + if (Fp2.isOdd) + return !Fp2.isOdd(y); + throw new Error("Field doesn't support isOdd"); + } + /** + * Compare one point to another. + */ + equals(other) { + assertPrjPoint(other); + const { px: X1, py: Y1, pz: Z1 } = this; + const { px: X2, py: Y2, pz: Z2 } = other; + const U1 = Fp2.eql(Fp2.mul(X1, Z2), Fp2.mul(X2, Z1)); + const U2 = Fp2.eql(Fp2.mul(Y1, Z2), Fp2.mul(Y2, Z1)); + return U1 && U2; + } + /** + * Flips point to one corresponding to (x, -y) in Affine coordinates. + */ + negate() { + return new Point2(this.px, Fp2.neg(this.py), this.pz); + } + // Renes-Costello-Batina exception-free doubling formula. + // There is 30% faster Jacobian formula, but it is not complete. + // https://eprint.iacr.org/2015/1060, algorithm 3 + // Cost: 8M + 3S + 3*a + 2*b3 + 15add. + double() { + const { a, b } = CURVE; + const b3 = Fp2.mul(b, _3n2); + const { px: X1, py: Y1, pz: Z1 } = this; + let X3 = Fp2.ZERO, Y3 = Fp2.ZERO, Z3 = Fp2.ZERO; + let t0 = Fp2.mul(X1, X1); + let t1 = Fp2.mul(Y1, Y1); + let t2 = Fp2.mul(Z1, Z1); + let t3 = Fp2.mul(X1, Y1); + t3 = Fp2.add(t3, t3); + Z3 = Fp2.mul(X1, Z1); + Z3 = Fp2.add(Z3, Z3); + X3 = Fp2.mul(a, Z3); + Y3 = Fp2.mul(b3, t2); + Y3 = Fp2.add(X3, Y3); + X3 = Fp2.sub(t1, Y3); + Y3 = Fp2.add(t1, Y3); + Y3 = Fp2.mul(X3, Y3); + X3 = Fp2.mul(t3, X3); + Z3 = Fp2.mul(b3, Z3); + t2 = Fp2.mul(a, t2); + t3 = Fp2.sub(t0, t2); + t3 = Fp2.mul(a, t3); + t3 = Fp2.add(t3, Z3); + Z3 = Fp2.add(t0, t0); + t0 = Fp2.add(Z3, t0); + t0 = Fp2.add(t0, t2); + t0 = Fp2.mul(t0, t3); + Y3 = Fp2.add(Y3, t0); + t2 = Fp2.mul(Y1, Z1); + t2 = Fp2.add(t2, t2); + t0 = Fp2.mul(t2, t3); + X3 = Fp2.sub(X3, t0); + Z3 = Fp2.mul(t2, t1); + Z3 = Fp2.add(Z3, Z3); + Z3 = Fp2.add(Z3, Z3); + return new Point2(X3, Y3, Z3); + } + // Renes-Costello-Batina exception-free addition formula. + // There is 30% faster Jacobian formula, but it is not complete. + // https://eprint.iacr.org/2015/1060, algorithm 1 + // Cost: 12M + 0S + 3*a + 3*b3 + 23add. + add(other) { + assertPrjPoint(other); + const { px: X1, py: Y1, pz: Z1 } = this; + const { px: X2, py: Y2, pz: Z2 } = other; + let X3 = Fp2.ZERO, Y3 = Fp2.ZERO, Z3 = Fp2.ZERO; + const a = CURVE.a; + const b3 = Fp2.mul(CURVE.b, _3n2); + let t0 = Fp2.mul(X1, X2); + let t1 = Fp2.mul(Y1, Y2); + let t2 = Fp2.mul(Z1, Z2); + let t3 = Fp2.add(X1, Y1); + let t4 = Fp2.add(X2, Y2); + t3 = Fp2.mul(t3, t4); + t4 = Fp2.add(t0, t1); + t3 = Fp2.sub(t3, t4); + t4 = Fp2.add(X1, Z1); + let t5 = Fp2.add(X2, Z2); + t4 = Fp2.mul(t4, t5); + t5 = Fp2.add(t0, t2); + t4 = Fp2.sub(t4, t5); + t5 = Fp2.add(Y1, Z1); + X3 = Fp2.add(Y2, Z2); + t5 = Fp2.mul(t5, X3); + X3 = Fp2.add(t1, t2); + t5 = Fp2.sub(t5, X3); + Z3 = Fp2.mul(a, t4); + X3 = Fp2.mul(b3, t2); + Z3 = Fp2.add(X3, Z3); + X3 = Fp2.sub(t1, Z3); + Z3 = Fp2.add(t1, Z3); + Y3 = Fp2.mul(X3, Z3); + t1 = Fp2.add(t0, t0); + t1 = Fp2.add(t1, t0); + t2 = Fp2.mul(a, t2); + t4 = Fp2.mul(b3, t4); + t1 = Fp2.add(t1, t2); + t2 = Fp2.sub(t0, t2); + t2 = Fp2.mul(a, t2); + t4 = Fp2.add(t4, t2); + t0 = Fp2.mul(t1, t4); + Y3 = Fp2.add(Y3, t0); + t0 = Fp2.mul(t5, t4); + X3 = Fp2.mul(t3, X3); + X3 = Fp2.sub(X3, t0); + t0 = Fp2.mul(t3, t1); + Z3 = Fp2.mul(t5, Z3); + Z3 = Fp2.add(Z3, t0); + return new Point2(X3, Y3, Z3); + } + subtract(other) { + return this.add(other.negate()); + } + is0() { + return this.equals(Point2.ZERO); + } + wNAF(n) { + return wnaf.wNAFCached(this, pointPrecomputes, n, (comp) => { + const toInv = Fp2.invertBatch(comp.map((p) => p.pz)); + return comp.map((p, i3) => p.toAffine(toInv[i3])).map(Point2.fromAffine); + }); + } + /** + * Non-constant-time multiplication. Uses double-and-add algorithm. + * It's faster, but should only be used when you don't care about + * an exposed private key e.g. sig verification, which works over *public* keys. + */ + multiplyUnsafe(n) { + const I = Point2.ZERO; + if (n === _0n4) + return I; + assertGE(n); + if (n === _1n4) + return this; + const { endo } = CURVE; + if (!endo) + return wnaf.unsafeLadder(this, n); + let { k1neg, k1, k2neg, k2 } = endo.splitScalar(n); + let k1p = I; + let k2p = I; + let d = this; + while (k1 > _0n4 || k2 > _0n4) { + if (k1 & _1n4) + k1p = k1p.add(d); + if (k2 & _1n4) + k2p = k2p.add(d); + d = d.double(); + k1 >>= _1n4; + k2 >>= _1n4; + } + if (k1neg) + k1p = k1p.negate(); + if (k2neg) + k2p = k2p.negate(); + k2p = new Point2(Fp2.mul(k2p.px, endo.beta), k2p.py, k2p.pz); + return k1p.add(k2p); + } + /** + * Constant time multiplication. + * Uses wNAF method. Windowed method may be 10% faster, + * but takes 2x longer to generate and consumes 2x memory. + * Uses precomputes when available. + * Uses endomorphism for Koblitz curves. + * @param scalar by which the point would be multiplied + * @returns New point + */ + multiply(scalar) { + assertGE(scalar); + let n = scalar; + let point, fake; + const { endo } = CURVE; + if (endo) { + const { k1neg, k1, k2neg, k2 } = endo.splitScalar(n); + let { p: k1p, f: f1p } = this.wNAF(k1); + let { p: k2p, f: f2p } = this.wNAF(k2); + k1p = wnaf.constTimeNegate(k1neg, k1p); + k2p = wnaf.constTimeNegate(k2neg, k2p); + k2p = new Point2(Fp2.mul(k2p.px, endo.beta), k2p.py, k2p.pz); + point = k1p.add(k2p); + fake = f1p.add(f2p); + } else { + const { p, f } = this.wNAF(n); + point = p; + fake = f; + } + return Point2.normalizeZ([point, fake])[0]; + } + /** + * Efficiently calculate `aP + bQ`. Unsafe, can expose private key, if used incorrectly. + * Not using Strauss-Shamir trick: precomputation tables are faster. + * The trick could be useful if both P and Q are not G (not in our case). + * @returns non-zero affine point + */ + multiplyAndAddUnsafe(Q, a, b) { + const G = Point2.BASE; + const mul3 = (P, a2) => a2 === _0n4 || a2 === _1n4 || !P.equals(G) ? P.multiplyUnsafe(a2) : P.multiply(a2); + const sum = mul3(this, a).add(mul3(Q, b)); + return sum.is0() ? void 0 : sum; + } + // Converts Projective point to affine (x, y) coordinates. + // Can accept precomputed Z^-1 - for example, from invertBatch. + // (x, y, z) ∋ (x=x/z, y=y/z) + toAffine(iz) { + const { px: x, py: y, pz: z } = this; + const is0 = this.is0(); + if (iz == null) + iz = is0 ? Fp2.ONE : Fp2.inv(z); + const ax = Fp2.mul(x, iz); + const ay = Fp2.mul(y, iz); + const zz = Fp2.mul(z, iz); + if (is0) + return { x: Fp2.ZERO, y: Fp2.ZERO }; + if (!Fp2.eql(zz, Fp2.ONE)) + throw new Error("invZ was invalid"); + return { x: ax, y: ay }; + } + isTorsionFree() { + const { h: cofactor, isTorsionFree } = CURVE; + if (cofactor === _1n4) + return true; + if (isTorsionFree) + return isTorsionFree(Point2, this); + throw new Error("isTorsionFree() has not been declared for the elliptic curve"); + } + clearCofactor() { + const { h: cofactor, clearCofactor } = CURVE; + if (cofactor === _1n4) + return this; + if (clearCofactor) + return clearCofactor(Point2, this); + return this.multiplyUnsafe(CURVE.h); + } + toRawBytes(isCompressed = true) { + this.assertValidity(); + return toBytes4(Point2, this, isCompressed); + } + toHex(isCompressed = true) { + return bytesToHex(this.toRawBytes(isCompressed)); + } + } + Point2.BASE = new Point2(CURVE.Gx, CURVE.Gy, Fp2.ONE); + Point2.ZERO = new Point2(Fp2.ZERO, Fp2.ONE, Fp2.ZERO); + const _bits = CURVE.nBitLength; + const wnaf = wNAF(Point2, CURVE.endo ? Math.ceil(_bits / 2) : _bits); + return { + CURVE, + ProjectivePoint: Point2, + normPrivateKeyToScalar, + weierstrassEquation, + isWithinCurveOrder + }; +} +function validateOpts(curve) { + const opts = validateBasic(curve); + validateObject(opts, { + hash: "hash", + hmac: "function", + randomBytes: "function" + }, { + bits2int: "function", + bits2int_modN: "function", + lowS: "boolean" + }); + return Object.freeze({ lowS: true, ...opts }); +} +function weierstrass(curveDef) { + const CURVE = validateOpts(curveDef); + const { Fp: Fp2, n: CURVE_ORDER } = CURVE; + const compressedLen = Fp2.BYTES + 1; + const uncompressedLen = 2 * Fp2.BYTES + 1; + function isValidFieldElement(num) { + return _0n4 < num && num < Fp2.ORDER; + } + function modN2(a) { + return mod(a, CURVE_ORDER); + } + function invN(a) { + return invert(a, CURVE_ORDER); + } + const { ProjectivePoint: Point2, normPrivateKeyToScalar, weierstrassEquation, isWithinCurveOrder } = weierstrassPoints({ + ...CURVE, + toBytes(_c, point, isCompressed) { + const a = point.toAffine(); + const x = Fp2.toBytes(a.x); + const cat = concatBytes2; + if (isCompressed) { + return cat(Uint8Array.from([point.hasEvenY() ? 2 : 3]), x); + } else { + return cat(Uint8Array.from([4]), x, Fp2.toBytes(a.y)); + } + }, + fromBytes(bytes4) { + const len = bytes4.length; + const head = bytes4[0]; + const tail = bytes4.subarray(1); + if (len === compressedLen && (head === 2 || head === 3)) { + const x = bytesToNumberBE(tail); + if (!isValidFieldElement(x)) + throw new Error("Point is not on curve"); + const y2 = weierstrassEquation(x); + let y = Fp2.sqrt(y2); + const isYOdd = (y & _1n4) === _1n4; + const isHeadOdd = (head & 1) === 1; + if (isHeadOdd !== isYOdd) + y = Fp2.neg(y); + return { x, y }; + } else if (len === uncompressedLen && head === 4) { + const x = Fp2.fromBytes(tail.subarray(0, Fp2.BYTES)); + const y = Fp2.fromBytes(tail.subarray(Fp2.BYTES, 2 * Fp2.BYTES)); + return { x, y }; + } else { + throw new Error(`Point of length ${len} was invalid. Expected ${compressedLen} compressed bytes or ${uncompressedLen} uncompressed bytes`); + } + } + }); + const numToNByteStr = (num) => bytesToHex(numberToBytesBE(num, CURVE.nByteLength)); + function isBiggerThanHalfOrder(number4) { + const HALF = CURVE_ORDER >> _1n4; + return number4 > HALF; + } + function normalizeS(s) { + return isBiggerThanHalfOrder(s) ? modN2(-s) : s; + } + const slcNum = (b, from, to) => bytesToNumberBE(b.slice(from, to)); + class Signature { + constructor(r, s, recovery) { + this.r = r; + this.s = s; + this.recovery = recovery; + this.assertValidity(); + } + // pair (bytes of r, bytes of s) + static fromCompact(hex2) { + const l = CURVE.nByteLength; + hex2 = ensureBytes("compactSignature", hex2, l * 2); + return new Signature(slcNum(hex2, 0, l), slcNum(hex2, l, 2 * l)); + } + // DER encoded ECDSA signature + // https://bitcoin.stackexchange.com/questions/57644/what-are-the-parts-of-a-bitcoin-transaction-input-script + static fromDER(hex2) { + const { r, s } = DER.toSig(ensureBytes("DER", hex2)); + return new Signature(r, s); + } + assertValidity() { + if (!isWithinCurveOrder(this.r)) + throw new Error("r must be 0 < r < CURVE.n"); + if (!isWithinCurveOrder(this.s)) + throw new Error("s must be 0 < s < CURVE.n"); + } + addRecoveryBit(recovery) { + return new Signature(this.r, this.s, recovery); + } + recoverPublicKey(msgHash) { + const { r, s, recovery: rec } = this; + const h = bits2int_modN(ensureBytes("msgHash", msgHash)); + if (rec == null || ![0, 1, 2, 3].includes(rec)) + throw new Error("recovery id invalid"); + const radj = rec === 2 || rec === 3 ? r + CURVE.n : r; + if (radj >= Fp2.ORDER) + throw new Error("recovery id 2 or 3 invalid"); + const prefix = (rec & 1) === 0 ? "02" : "03"; + const R = Point2.fromHex(prefix + numToNByteStr(radj)); + const ir = invN(radj); + const u1 = modN2(-h * ir); + const u2 = modN2(s * ir); + const Q = Point2.BASE.multiplyAndAddUnsafe(R, u1, u2); + if (!Q) + throw new Error("point at infinify"); + Q.assertValidity(); + return Q; + } + // Signatures should be low-s, to prevent malleability. + hasHighS() { + return isBiggerThanHalfOrder(this.s); + } + normalizeS() { + return this.hasHighS() ? new Signature(this.r, modN2(-this.s), this.recovery) : this; + } + // DER-encoded + toDERRawBytes() { + return hexToBytes(this.toDERHex()); + } + toDERHex() { + return DER.hexFromSig({ r: this.r, s: this.s }); + } + // padded bytes of r, then padded bytes of s + toCompactRawBytes() { + return hexToBytes(this.toCompactHex()); + } + toCompactHex() { + return numToNByteStr(this.r) + numToNByteStr(this.s); + } + } + const utils = { + isValidPrivateKey(privateKey) { + try { + normPrivateKeyToScalar(privateKey); + return true; + } catch (error) { + return false; + } + }, + normPrivateKeyToScalar, + /** + * Produces cryptographically secure private key from random of size + * (groupLen + ceil(groupLen / 2)) with modulo bias being negligible. + */ + randomPrivateKey: () => { + const length = getMinHashLength(CURVE.n); + return mapHashToField(CURVE.randomBytes(length), CURVE.n); + }, + /** + * Creates precompute table for an arbitrary EC point. Makes point "cached". + * Allows to massively speed-up `point.multiply(scalar)`. + * @returns cached point + * @example + * const fast = utils.precompute(8, ProjectivePoint.fromHex(someonesPubKey)); + * fast.multiply(privKey); // much faster ECDH now + */ + precompute(windowSize = 8, point = Point2.BASE) { + point._setWindowSize(windowSize); + point.multiply(BigInt(3)); + return point; + } + }; + function getPublicKey3(privateKey, isCompressed = true) { + return Point2.fromPrivateKey(privateKey).toRawBytes(isCompressed); + } + function isProbPub(item) { + const arr = item instanceof Uint8Array; + const str = typeof item === "string"; + const len = (arr || str) && item.length; + if (arr) + return len === compressedLen || len === uncompressedLen; + if (str) + return len === 2 * compressedLen || len === 2 * uncompressedLen; + if (item instanceof Point2) + return true; + return false; + } + function getSharedSecret(privateA, publicB, isCompressed = true) { + if (isProbPub(privateA)) + throw new Error("first arg must be private key"); + if (!isProbPub(publicB)) + throw new Error("second arg must be public key"); + const b = Point2.fromHex(publicB); + return b.multiply(normPrivateKeyToScalar(privateA)).toRawBytes(isCompressed); + } + const bits2int = CURVE.bits2int || function(bytes4) { + const num = bytesToNumberBE(bytes4); + const delta = bytes4.length * 8 - CURVE.nBitLength; + return delta > 0 ? num >> BigInt(delta) : num; + }; + const bits2int_modN = CURVE.bits2int_modN || function(bytes4) { + return modN2(bits2int(bytes4)); + }; + const ORDER_MASK = bitMask(CURVE.nBitLength); + function int2octets(num) { + if (typeof num !== "bigint") + throw new Error("bigint expected"); + if (!(_0n4 <= num && num < ORDER_MASK)) + throw new Error(`bigint expected < 2^${CURVE.nBitLength}`); + return numberToBytesBE(num, CURVE.nByteLength); + } + function prepSig(msgHash, privateKey, opts = defaultSigOpts) { + if (["recovered", "canonical"].some((k) => k in opts)) + throw new Error("sign() legacy options not supported"); + const { hash: hash3, randomBytes: randomBytes3 } = CURVE; + let { lowS, prehash, extraEntropy: ent } = opts; + if (lowS == null) + lowS = true; + msgHash = ensureBytes("msgHash", msgHash); + if (prehash) + msgHash = ensureBytes("prehashed msgHash", hash3(msgHash)); + const h1int = bits2int_modN(msgHash); + const d = normPrivateKeyToScalar(privateKey); + const seedArgs = [int2octets(d), int2octets(h1int)]; + if (ent != null) { + const e = ent === true ? randomBytes3(Fp2.BYTES) : ent; + seedArgs.push(ensureBytes("extraEntropy", e)); + } + const seed = concatBytes2(...seedArgs); + const m = h1int; + function k2sig(kBytes) { + const k = bits2int(kBytes); + if (!isWithinCurveOrder(k)) + return; + const ik = invN(k); + const q = Point2.BASE.multiply(k).toAffine(); + const r = modN2(q.x); + if (r === _0n4) + return; + const s = modN2(ik * modN2(m + r * d)); + if (s === _0n4) + return; + let recovery = (q.x === r ? 0 : 2) | Number(q.y & _1n4); + let normS = s; + if (lowS && isBiggerThanHalfOrder(s)) { + normS = normalizeS(s); + recovery ^= 1; + } + return new Signature(r, normS, recovery); + } + return { seed, k2sig }; + } + const defaultSigOpts = { lowS: CURVE.lowS, prehash: false }; + const defaultVerOpts = { lowS: CURVE.lowS, prehash: false }; + function sign(msgHash, privKey, opts = defaultSigOpts) { + const { seed, k2sig } = prepSig(msgHash, privKey, opts); + const C = CURVE; + const drbg = createHmacDrbg(C.hash.outputLen, C.nByteLength, C.hmac); + return drbg(seed, k2sig); + } + Point2.BASE._setWindowSize(8); + function verify(signature, msgHash, publicKey, opts = defaultVerOpts) { + const sg = signature; + msgHash = ensureBytes("msgHash", msgHash); + publicKey = ensureBytes("publicKey", publicKey); + if ("strict" in opts) + throw new Error("options.strict was renamed to lowS"); + const { lowS, prehash } = opts; + let _sig = void 0; + let P; + try { + if (typeof sg === "string" || sg instanceof Uint8Array) { + try { + _sig = Signature.fromDER(sg); + } catch (derError) { + if (!(derError instanceof DER.Err)) + throw derError; + _sig = Signature.fromCompact(sg); + } + } else if (typeof sg === "object" && typeof sg.r === "bigint" && typeof sg.s === "bigint") { + const { r: r2, s: s2 } = sg; + _sig = new Signature(r2, s2); + } else { + throw new Error("PARSE"); + } + P = Point2.fromHex(publicKey); + } catch (error) { + if (error.message === "PARSE") + throw new Error(`signature must be Signature instance, Uint8Array or hex string`); + return false; + } + if (lowS && _sig.hasHighS()) + return false; + if (prehash) + msgHash = CURVE.hash(msgHash); + const { r, s } = _sig; + const h = bits2int_modN(msgHash); + const is = invN(s); + const u1 = modN2(h * is); + const u2 = modN2(r * is); + const R = Point2.BASE.multiplyAndAddUnsafe(P, u1, u2)?.toAffine(); + if (!R) + return false; + const v = modN2(R.x); + return v === r; + } + return { + CURVE, + getPublicKey: getPublicKey3, + getSharedSecret, + sign, + verify, + ProjectivePoint: Point2, + Signature, + utils + }; +} + +// node_modules/@noble/curves/node_modules/@noble/hashes/esm/hmac.js +var HMAC = class extends Hash { + constructor(hash3, _key) { + super(); + this.finished = false; + this.destroyed = false; + hash(hash3); + const key = toBytes(_key); + this.iHash = hash3.create(); + if (typeof this.iHash.update !== "function") + throw new Error("Expected instance of class which extends utils.Hash"); + this.blockLen = this.iHash.blockLen; + this.outputLen = this.iHash.outputLen; + const blockLen = this.blockLen; + const pad2 = new Uint8Array(blockLen); + pad2.set(key.length > blockLen ? hash3.create().update(key).digest() : key); + for (let i3 = 0; i3 < pad2.length; i3++) + pad2[i3] ^= 54; + this.iHash.update(pad2); + this.oHash = hash3.create(); + for (let i3 = 0; i3 < pad2.length; i3++) + pad2[i3] ^= 54 ^ 92; + this.oHash.update(pad2); + pad2.fill(0); + } + update(buf) { + exists(this); + this.iHash.update(buf); + return this; + } + digestInto(out) { + exists(this); + bytes(out, this.outputLen); + this.finished = true; + this.iHash.digestInto(out); + this.oHash.update(out); + this.oHash.digestInto(out); + this.destroy(); + } + digest() { + const out = new Uint8Array(this.oHash.outputLen); + this.digestInto(out); + return out; + } + _cloneInto(to) { + to || (to = Object.create(Object.getPrototypeOf(this), {})); + const { oHash, iHash, finished, destroyed, blockLen, outputLen } = this; + to = to; + to.finished = finished; + to.destroyed = destroyed; + to.blockLen = blockLen; + to.outputLen = outputLen; + to.oHash = oHash._cloneInto(to.oHash); + to.iHash = iHash._cloneInto(to.iHash); + return to; + } + destroy() { + this.destroyed = true; + this.oHash.destroy(); + this.iHash.destroy(); + } +}; +var hmac = (hash3, key, message) => new HMAC(hash3, key).update(message).digest(); +hmac.create = (hash3, key) => new HMAC(hash3, key); + +// node_modules/@noble/curves/esm/_shortw_utils.js +function getHash(hash3) { + return { + hash: hash3, + hmac: (key, ...msgs) => hmac(hash3, key, concatBytes(...msgs)), + randomBytes + }; +} +function createCurve(curveDef, defHash) { + const create = (hash3) => weierstrass({ ...curveDef, ...getHash(hash3) }); + return Object.freeze({ ...create(defHash), create }); +} + +// node_modules/@noble/curves/esm/secp256k1.js +var secp256k1P = BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"); +var secp256k1N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); +var _1n5 = BigInt(1); +var _2n4 = BigInt(2); +var divNearest = (a, b) => (a + b / _2n4) / b; +function sqrtMod(y) { + const P = secp256k1P; + const _3n3 = BigInt(3), _6n = BigInt(6), _11n = BigInt(11), _22n = BigInt(22); + const _23n = BigInt(23), _44n = BigInt(44), _88n = BigInt(88); + const b2 = y * y * y % P; + const b3 = b2 * b2 * y % P; + const b6 = pow2(b3, _3n3, P) * b3 % P; + const b9 = pow2(b6, _3n3, P) * b3 % P; + const b11 = pow2(b9, _2n4, P) * b2 % P; + const b22 = pow2(b11, _11n, P) * b11 % P; + const b44 = pow2(b22, _22n, P) * b22 % P; + const b88 = pow2(b44, _44n, P) * b44 % P; + const b176 = pow2(b88, _88n, P) * b88 % P; + const b220 = pow2(b176, _44n, P) * b44 % P; + const b223 = pow2(b220, _3n3, P) * b3 % P; + const t1 = pow2(b223, _23n, P) * b22 % P; + const t2 = pow2(t1, _6n, P) * b2 % P; + const root = pow2(t2, _2n4, P); + if (!Fp.eql(Fp.sqr(root), y)) + throw new Error("Cannot find square root"); + return root; +} +var Fp = Field(secp256k1P, void 0, void 0, { sqrt: sqrtMod }); +var secp256k1 = createCurve({ + a: BigInt(0), + b: BigInt(7), + Fp, + n: secp256k1N, + // Base point (x, y) aka generator point + Gx: BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"), + Gy: BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"), + h: BigInt(1), + lowS: true, + /** + * secp256k1 belongs to Koblitz curves: it has efficiently computable endomorphism. + * Endomorphism uses 2x less RAM, speeds up precomputation by 2x and ECDH / key recovery by 20%. + * For precomputed wNAF it trades off 1/2 init time & 1/3 ram for 20% perf hit. + * Explanation: https://gist.github.com/paulmillr/eb670806793e84df628a7c434a873066 + */ + endo: { + beta: BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"), + splitScalar: (k) => { + const n = secp256k1N; + const a1 = BigInt("0x3086d221a7d46bcde86c90e49284eb15"); + const b1 = -_1n5 * BigInt("0xe4437ed6010e88286f547fa90abfe4c3"); + const a2 = BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"); + const b2 = a1; + const POW_2_128 = BigInt("0x100000000000000000000000000000000"); + const c1 = divNearest(b2 * k, n); + const c2 = divNearest(-b1 * k, n); + let k1 = mod(k - c1 * a1 - c2 * a2, n); + let k2 = mod(-c1 * b1 - c2 * b2, n); + const k1neg = k1 > POW_2_128; + const k2neg = k2 > POW_2_128; + if (k1neg) + k1 = n - k1; + if (k2neg) + k2 = n - k2; + if (k1 > POW_2_128 || k2 > POW_2_128) { + throw new Error("splitScalar: Endomorphism failed, k=" + k); + } + return { k1neg, k1, k2neg, k2 }; + } + } +}, sha256); +var _0n5 = BigInt(0); +var fe = (x) => typeof x === "bigint" && _0n5 < x && x < secp256k1P; +var ge = (x) => typeof x === "bigint" && _0n5 < x && x < secp256k1N; +var TAGGED_HASH_PREFIXES = {}; +function taggedHash(tag, ...messages) { + let tagP = TAGGED_HASH_PREFIXES[tag]; + if (tagP === void 0) { + const tagH = sha256(Uint8Array.from(tag, (c) => c.charCodeAt(0))); + tagP = concatBytes2(tagH, tagH); + TAGGED_HASH_PREFIXES[tag] = tagP; + } + return sha256(concatBytes2(tagP, ...messages)); +} +var pointToBytes = (point) => point.toRawBytes(true).slice(1); +var numTo32b = (n) => numberToBytesBE(n, 32); +var modP = (x) => mod(x, secp256k1P); +var modN = (x) => mod(x, secp256k1N); +var Point = secp256k1.ProjectivePoint; +var GmulAdd = (Q, a, b) => Point.BASE.multiplyAndAddUnsafe(Q, a, b); +function schnorrGetExtPubKey(priv) { + let d_ = secp256k1.utils.normPrivateKeyToScalar(priv); + let p = Point.fromPrivateKey(d_); + const scalar = p.hasEvenY() ? d_ : modN(-d_); + return { scalar, bytes: pointToBytes(p) }; +} +function lift_x(x) { + if (!fe(x)) + throw new Error("bad x: need 0 < x < p"); + const xx = modP(x * x); + const c = modP(xx * x + BigInt(7)); + let y = sqrtMod(c); + if (y % _2n4 !== _0n5) + y = modP(-y); + const p = new Point(x, y, _1n5); + p.assertValidity(); + return p; +} +function challenge(...args) { + return modN(bytesToNumberBE(taggedHash("BIP0340/challenge", ...args))); +} +function schnorrGetPublicKey(privateKey) { + return schnorrGetExtPubKey(privateKey).bytes; +} +function schnorrSign(message, privateKey, auxRand = randomBytes(32)) { + const m = ensureBytes("message", message); + const { bytes: px, scalar: d } = schnorrGetExtPubKey(privateKey); + const a = ensureBytes("auxRand", auxRand, 32); + const t = numTo32b(d ^ bytesToNumberBE(taggedHash("BIP0340/aux", a))); + const rand = taggedHash("BIP0340/nonce", t, px, m); + const k_ = modN(bytesToNumberBE(rand)); + if (k_ === _0n5) + throw new Error("sign failed: k is zero"); + const { bytes: rx, scalar: k } = schnorrGetExtPubKey(k_); + const e = challenge(rx, px, m); + const sig = new Uint8Array(64); + sig.set(rx, 0); + sig.set(numTo32b(modN(k + e * d)), 32); + if (!schnorrVerify(sig, m, px)) + throw new Error("sign: Invalid signature produced"); + return sig; +} +function schnorrVerify(signature, message, publicKey) { + const sig = ensureBytes("signature", signature, 64); + const m = ensureBytes("message", message); + const pub = ensureBytes("publicKey", publicKey, 32); + try { + const P = lift_x(bytesToNumberBE(pub)); + const r = bytesToNumberBE(sig.subarray(0, 32)); + if (!fe(r)) + return false; + const s = bytesToNumberBE(sig.subarray(32, 64)); + if (!ge(s)) + return false; + const e = challenge(numTo32b(r), pointToBytes(P), m); + const R = GmulAdd(P, s, modN(-e)); + if (!R || !R.hasEvenY() || R.toAffine().x !== r) + return false; + return true; + } catch (error) { + return false; + } +} +var schnorr = /* @__PURE__ */ (() => ({ + getPublicKey: schnorrGetPublicKey, + sign: schnorrSign, + verify: schnorrVerify, + utils: { + randomPrivateKey: secp256k1.utils.randomPrivateKey, + lift_x, + pointToBytes, + numberToBytesBE, + bytesToNumberBE, + taggedHash, + mod + } +}))(); + +// node_modules/@noble/hashes/esm/cryptoNode.js +var nc2 = __toESM(require("node:crypto"), 1); +var crypto2 = nc2 && typeof nc2 === "object" && "webcrypto" in nc2 ? nc2.webcrypto : void 0; + +// node_modules/@noble/hashes/esm/utils.js +var u8a3 = (a) => a instanceof Uint8Array; +var createView2 = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength); +var rotr2 = (word, shift) => word << 32 - shift | word >>> shift; +var isLE2 = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68; +if (!isLE2) + throw new Error("Non little-endian hardware is not supported"); +var hexes2 = Array.from({ length: 256 }, (v, i3) => i3.toString(16).padStart(2, "0")); +function bytesToHex2(bytes4) { + if (!u8a3(bytes4)) + throw new Error("Uint8Array expected"); + let hex2 = ""; + for (let i3 = 0; i3 < bytes4.length; i3++) { + hex2 += hexes2[bytes4[i3]]; + } + return hex2; +} +function hexToBytes2(hex2) { + if (typeof hex2 !== "string") + throw new Error("hex string expected, got " + typeof hex2); + const len = hex2.length; + if (len % 2) + throw new Error("padded hex string expected, got unpadded hex of length " + len); + const array = new Uint8Array(len / 2); + for (let i3 = 0; i3 < array.length; i3++) { + const j = i3 * 2; + const hexByte = hex2.slice(j, j + 2); + const byte = Number.parseInt(hexByte, 16); + if (Number.isNaN(byte) || byte < 0) + throw new Error("Invalid byte sequence"); + array[i3] = byte; + } + return array; +} +function utf8ToBytes3(str) { + if (typeof str !== "string") + throw new Error(`utf8ToBytes expected string, got ${typeof str}`); + return new Uint8Array(new TextEncoder().encode(str)); +} +function toBytes2(data) { + if (typeof data === "string") + data = utf8ToBytes3(data); + if (!u8a3(data)) + throw new Error(`expected Uint8Array, got ${typeof data}`); + return data; +} +function concatBytes3(...arrays) { + const r = new Uint8Array(arrays.reduce((sum, a) => sum + a.length, 0)); + let pad2 = 0; + arrays.forEach((a) => { + if (!u8a3(a)) + throw new Error("Uint8Array expected"); + r.set(a, pad2); + pad2 += a.length; + }); + return r; +} +var Hash2 = class { + // Safe version that clones internal state + clone() { + return this._cloneInto(); + } +}; +function wrapConstructor2(hashCons) { + const hashC = (msg) => hashCons().update(toBytes2(msg)).digest(); + const tmp = hashCons(); + hashC.outputLen = tmp.outputLen; + hashC.blockLen = tmp.blockLen; + hashC.create = () => hashCons(); + return hashC; +} +function randomBytes2(bytesLength = 32) { + if (crypto2 && typeof crypto2.getRandomValues === "function") { + return crypto2.getRandomValues(new Uint8Array(bytesLength)); + } + throw new Error("crypto.getRandomValues must be defined"); +} + +// node_modules/@noble/hashes/esm/_assert.js +function number2(n) { + if (!Number.isSafeInteger(n) || n < 0) + throw new Error(`Wrong positive integer: ${n}`); +} +function bool(b) { + if (typeof b !== "boolean") + throw new Error(`Expected boolean, not ${b}`); +} +function bytes2(b, ...lengths) { + if (!(b instanceof Uint8Array)) + throw new Error("Expected Uint8Array"); + if (lengths.length > 0 && !lengths.includes(b.length)) + throw new Error(`Expected Uint8Array of length ${lengths}, not of length=${b.length}`); +} +function hash2(hash3) { + if (typeof hash3 !== "function" || typeof hash3.create !== "function") + throw new Error("Hash should be wrapped by utils.wrapConstructor"); + number2(hash3.outputLen); + number2(hash3.blockLen); +} +function exists2(instance, checkFinished = true) { + if (instance.destroyed) + throw new Error("Hash instance has been destroyed"); + if (checkFinished && instance.finished) + throw new Error("Hash#digest() has already been called"); +} +function output2(out, instance) { + bytes2(out); + const min = instance.outputLen; + if (out.length < min) { + throw new Error(`digestInto() expects output buffer of length at least ${min}`); + } +} +var assert = { + number: number2, + bool, + bytes: bytes2, + hash: hash2, + exists: exists2, + output: output2 +}; +var assert_default = assert; + +// node_modules/@noble/hashes/esm/_sha2.js +function setBigUint642(view, byteOffset, value, isLE4) { + if (typeof view.setBigUint64 === "function") + return view.setBigUint64(byteOffset, value, isLE4); + const _32n = BigInt(32); + const _u32_max = BigInt(4294967295); + const wh = Number(value >> _32n & _u32_max); + const wl = Number(value & _u32_max); + const h = isLE4 ? 4 : 0; + const l = isLE4 ? 0 : 4; + view.setUint32(byteOffset + h, wh, isLE4); + view.setUint32(byteOffset + l, wl, isLE4); +} +var SHA22 = class extends Hash2 { + constructor(blockLen, outputLen, padOffset, isLE4) { + super(); + this.blockLen = blockLen; + this.outputLen = outputLen; + this.padOffset = padOffset; + this.isLE = isLE4; + this.finished = false; + this.length = 0; + this.pos = 0; + this.destroyed = false; + this.buffer = new Uint8Array(blockLen); + this.view = createView2(this.buffer); + } + update(data) { + assert_default.exists(this); + const { view, buffer, blockLen } = this; + data = toBytes2(data); + const len = data.length; + for (let pos = 0; pos < len; ) { + const take = Math.min(blockLen - this.pos, len - pos); + if (take === blockLen) { + const dataView = createView2(data); + for (; blockLen <= len - pos; pos += blockLen) + this.process(dataView, pos); + continue; + } + buffer.set(data.subarray(pos, pos + take), this.pos); + this.pos += take; + pos += take; + if (this.pos === blockLen) { + this.process(view, 0); + this.pos = 0; + } + } + this.length += data.length; + this.roundClean(); + return this; + } + digestInto(out) { + assert_default.exists(this); + assert_default.output(out, this); + this.finished = true; + const { buffer, view, blockLen, isLE: isLE4 } = this; + let { pos } = this; + buffer[pos++] = 128; + this.buffer.subarray(pos).fill(0); + if (this.padOffset > blockLen - pos) { + this.process(view, 0); + pos = 0; + } + for (let i3 = pos; i3 < blockLen; i3++) + buffer[i3] = 0; + setBigUint642(view, blockLen - 8, BigInt(this.length * 8), isLE4); + this.process(view, 0); + const oview = createView2(out); + const len = this.outputLen; + if (len % 4) + throw new Error("_sha2: outputLen should be aligned to 32bit"); + const outLen = len / 4; + const state = this.get(); + if (outLen > state.length) + throw new Error("_sha2: outputLen bigger than state"); + for (let i3 = 0; i3 < outLen; i3++) + oview.setUint32(4 * i3, state[i3], isLE4); + } + digest() { + const { buffer, outputLen } = this; + this.digestInto(buffer); + const res = buffer.slice(0, outputLen); + this.destroy(); + return res; + } + _cloneInto(to) { + to || (to = new this.constructor()); + to.set(...this.get()); + const { blockLen, buffer, length, finished, destroyed, pos } = this; + to.length = length; + to.pos = pos; + to.finished = finished; + to.destroyed = destroyed; + if (length % blockLen) + to.buffer.set(buffer); + return to; + } +}; + +// node_modules/@noble/hashes/esm/sha256.js +var Chi2 = (a, b, c) => a & b ^ ~a & c; +var Maj2 = (a, b, c) => a & b ^ a & c ^ b & c; +var SHA256_K2 = new Uint32Array([ + 1116352408, + 1899447441, + 3049323471, + 3921009573, + 961987163, + 1508970993, + 2453635748, + 2870763221, + 3624381080, + 310598401, + 607225278, + 1426881987, + 1925078388, + 2162078206, + 2614888103, + 3248222580, + 3835390401, + 4022224774, + 264347078, + 604807628, + 770255983, + 1249150122, + 1555081692, + 1996064986, + 2554220882, + 2821834349, + 2952996808, + 3210313671, + 3336571891, + 3584528711, + 113926993, + 338241895, + 666307205, + 773529912, + 1294757372, + 1396182291, + 1695183700, + 1986661051, + 2177026350, + 2456956037, + 2730485921, + 2820302411, + 3259730800, + 3345764771, + 3516065817, + 3600352804, + 4094571909, + 275423344, + 430227734, + 506948616, + 659060556, + 883997877, + 958139571, + 1322822218, + 1537002063, + 1747873779, + 1955562222, + 2024104815, + 2227730452, + 2361852424, + 2428436474, + 2756734187, + 3204031479, + 3329325298 +]); +var IV2 = new Uint32Array([ + 1779033703, + 3144134277, + 1013904242, + 2773480762, + 1359893119, + 2600822924, + 528734635, + 1541459225 +]); +var SHA256_W2 = new Uint32Array(64); +var SHA2562 = class extends SHA22 { + constructor() { + super(64, 32, 8, false); + this.A = IV2[0] | 0; + this.B = IV2[1] | 0; + this.C = IV2[2] | 0; + this.D = IV2[3] | 0; + this.E = IV2[4] | 0; + this.F = IV2[5] | 0; + this.G = IV2[6] | 0; + this.H = IV2[7] | 0; + } + get() { + const { A, B, C, D, E, F, G, H } = this; + return [A, B, C, D, E, F, G, H]; + } + // prettier-ignore + set(A, B, C, D, E, F, G, H) { + this.A = A | 0; + this.B = B | 0; + this.C = C | 0; + this.D = D | 0; + this.E = E | 0; + this.F = F | 0; + this.G = G | 0; + this.H = H | 0; + } + process(view, offset) { + for (let i3 = 0; i3 < 16; i3++, offset += 4) + SHA256_W2[i3] = view.getUint32(offset, false); + for (let i3 = 16; i3 < 64; i3++) { + const W15 = SHA256_W2[i3 - 15]; + const W2 = SHA256_W2[i3 - 2]; + const s0 = rotr2(W15, 7) ^ rotr2(W15, 18) ^ W15 >>> 3; + const s1 = rotr2(W2, 17) ^ rotr2(W2, 19) ^ W2 >>> 10; + SHA256_W2[i3] = s1 + SHA256_W2[i3 - 7] + s0 + SHA256_W2[i3 - 16] | 0; + } + let { A, B, C, D, E, F, G, H } = this; + for (let i3 = 0; i3 < 64; i3++) { + const sigma1 = rotr2(E, 6) ^ rotr2(E, 11) ^ rotr2(E, 25); + const T1 = H + sigma1 + Chi2(E, F, G) + SHA256_K2[i3] + SHA256_W2[i3] | 0; + const sigma0 = rotr2(A, 2) ^ rotr2(A, 13) ^ rotr2(A, 22); + const T2 = sigma0 + Maj2(A, B, C) | 0; + H = G; + G = F; + F = E; + E = D + T1 | 0; + D = C; + C = B; + B = A; + A = T1 + T2 | 0; + } + A = A + this.A | 0; + B = B + this.B | 0; + C = C + this.C | 0; + D = D + this.D | 0; + E = E + this.E | 0; + F = F + this.F | 0; + G = G + this.G | 0; + H = H + this.H | 0; + this.set(A, B, C, D, E, F, G, H); + } + roundClean() { + SHA256_W2.fill(0); + } + destroy() { + this.set(0, 0, 0, 0, 0, 0, 0, 0); + this.buffer.fill(0); + } +}; +var SHA224 = class extends SHA2562 { + constructor() { + super(); + this.A = 3238371032 | 0; + this.B = 914150663 | 0; + this.C = 812702999 | 0; + this.D = 4144912697 | 0; + this.E = 4290775857 | 0; + this.F = 1750603025 | 0; + this.G = 1694076839 | 0; + this.H = 3204075428 | 0; + this.outputLen = 28; + } +}; +var sha2562 = wrapConstructor2(() => new SHA2562()); +var sha224 = wrapConstructor2(() => new SHA224()); + +// node_modules/@scure/base/lib/esm/index.js +function assertNumber(n) { + if (!Number.isSafeInteger(n)) + throw new Error(`Wrong integer: ${n}`); +} +function chain(...args) { + const wrap = (a, b) => (c) => a(b(c)); + const encode = Array.from(args).reverse().reduce((acc, i3) => acc ? wrap(acc, i3.encode) : i3.encode, void 0); + const decode2 = args.reduce((acc, i3) => acc ? wrap(acc, i3.decode) : i3.decode, void 0); + return { encode, decode: decode2 }; +} +function alphabet(alphabet2) { + return { + encode: (digits) => { + if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") + throw new Error("alphabet.encode input should be an array of numbers"); + return digits.map((i3) => { + assertNumber(i3); + if (i3 < 0 || i3 >= alphabet2.length) + throw new Error(`Digit index outside alphabet: ${i3} (alphabet: ${alphabet2.length})`); + return alphabet2[i3]; + }); + }, + decode: (input) => { + if (!Array.isArray(input) || input.length && typeof input[0] !== "string") + throw new Error("alphabet.decode input should be array of strings"); + return input.map((letter) => { + if (typeof letter !== "string") + throw new Error(`alphabet.decode: not string element=${letter}`); + const index = alphabet2.indexOf(letter); + if (index === -1) + throw new Error(`Unknown letter: "${letter}". Allowed: ${alphabet2}`); + return index; + }); + } + }; +} +function join(separator = "") { + if (typeof separator !== "string") + throw new Error("join separator should be string"); + return { + encode: (from) => { + if (!Array.isArray(from) || from.length && typeof from[0] !== "string") + throw new Error("join.encode input should be array of strings"); + for (let i3 of from) + if (typeof i3 !== "string") + throw new Error(`join.encode: non-string input=${i3}`); + return from.join(separator); + }, + decode: (to) => { + if (typeof to !== "string") + throw new Error("join.decode input should be string"); + return to.split(separator); + } + }; +} +function padding(bits, chr = "=") { + assertNumber(bits); + if (typeof chr !== "string") + throw new Error("padding chr should be string"); + return { + encode(data) { + if (!Array.isArray(data) || data.length && typeof data[0] !== "string") + throw new Error("padding.encode input should be array of strings"); + for (let i3 of data) + if (typeof i3 !== "string") + throw new Error(`padding.encode: non-string input=${i3}`); + while (data.length * bits % 8) + data.push(chr); + return data; + }, + decode(input) { + if (!Array.isArray(input) || input.length && typeof input[0] !== "string") + throw new Error("padding.encode input should be array of strings"); + for (let i3 of input) + if (typeof i3 !== "string") + throw new Error(`padding.decode: non-string input=${i3}`); + let end = input.length; + if (end * bits % 8) + throw new Error("Invalid padding: string should have whole number of bytes"); + for (; end > 0 && input[end - 1] === chr; end--) { + if (!((end - 1) * bits % 8)) + throw new Error("Invalid padding: string has too much padding"); + } + return input.slice(0, end); + } + }; +} +function normalize(fn) { + if (typeof fn !== "function") + throw new Error("normalize fn should be function"); + return { encode: (from) => from, decode: (to) => fn(to) }; +} +function convertRadix(data, from, to) { + if (from < 2) + throw new Error(`convertRadix: wrong from=${from}, base cannot be less than 2`); + if (to < 2) + throw new Error(`convertRadix: wrong to=${to}, base cannot be less than 2`); + if (!Array.isArray(data)) + throw new Error("convertRadix: data should be array"); + if (!data.length) + return []; + let pos = 0; + const res = []; + const digits = Array.from(data); + digits.forEach((d) => { + assertNumber(d); + if (d < 0 || d >= from) + throw new Error(`Wrong integer: ${d}`); + }); + while (true) { + let carry = 0; + let done = true; + for (let i3 = pos; i3 < digits.length; i3++) { + const digit = digits[i3]; + const digitBase = from * carry + digit; + if (!Number.isSafeInteger(digitBase) || from * carry / from !== carry || digitBase - digit !== from * carry) { + throw new Error("convertRadix: carry overflow"); + } + carry = digitBase % to; + digits[i3] = Math.floor(digitBase / to); + if (!Number.isSafeInteger(digits[i3]) || digits[i3] * to + carry !== digitBase) + throw new Error("convertRadix: carry overflow"); + if (!done) + continue; + else if (!digits[i3]) + pos = i3; + else + done = false; + } + res.push(carry); + if (done) + break; + } + for (let i3 = 0; i3 < data.length - 1 && data[i3] === 0; i3++) + res.push(0); + return res.reverse(); +} +var gcd = (a, b) => !b ? a : gcd(b, a % b); +var radix2carry = (from, to) => from + (to - gcd(from, to)); +function convertRadix2(data, from, to, padding2) { + if (!Array.isArray(data)) + throw new Error("convertRadix2: data should be array"); + if (from <= 0 || from > 32) + throw new Error(`convertRadix2: wrong from=${from}`); + if (to <= 0 || to > 32) + throw new Error(`convertRadix2: wrong to=${to}`); + if (radix2carry(from, to) > 32) { + throw new Error(`convertRadix2: carry overflow from=${from} to=${to} carryBits=${radix2carry(from, to)}`); + } + let carry = 0; + let pos = 0; + const mask = 2 ** to - 1; + const res = []; + for (const n of data) { + assertNumber(n); + if (n >= 2 ** from) + throw new Error(`convertRadix2: invalid data word=${n} from=${from}`); + carry = carry << from | n; + if (pos + from > 32) + throw new Error(`convertRadix2: carry overflow pos=${pos} from=${from}`); + pos += from; + for (; pos >= to; pos -= to) + res.push((carry >> pos - to & mask) >>> 0); + carry &= 2 ** pos - 1; + } + carry = carry << to - pos & mask; + if (!padding2 && pos >= from) + throw new Error("Excess padding"); + if (!padding2 && carry) + throw new Error(`Non-zero padding: ${carry}`); + if (padding2 && pos > 0) + res.push(carry >>> 0); + return res; +} +function radix(num) { + assertNumber(num); + return { + encode: (bytes4) => { + if (!(bytes4 instanceof Uint8Array)) + throw new Error("radix.encode input should be Uint8Array"); + return convertRadix(Array.from(bytes4), 2 ** 8, num); + }, + decode: (digits) => { + if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") + throw new Error("radix.decode input should be array of strings"); + return Uint8Array.from(convertRadix(digits, num, 2 ** 8)); + } + }; +} +function radix2(bits, revPadding = false) { + assertNumber(bits); + if (bits <= 0 || bits > 32) + throw new Error("radix2: bits should be in (0..32]"); + if (radix2carry(8, bits) > 32 || radix2carry(bits, 8) > 32) + throw new Error("radix2: carry overflow"); + return { + encode: (bytes4) => { + if (!(bytes4 instanceof Uint8Array)) + throw new Error("radix2.encode input should be Uint8Array"); + return convertRadix2(Array.from(bytes4), 8, bits, !revPadding); + }, + decode: (digits) => { + if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") + throw new Error("radix2.decode input should be array of strings"); + return Uint8Array.from(convertRadix2(digits, bits, 8, revPadding)); + } + }; +} +function unsafeWrapper(fn) { + if (typeof fn !== "function") + throw new Error("unsafeWrapper fn should be function"); + return function(...args) { + try { + return fn.apply(null, args); + } catch (e) { + } + }; +} +var base16 = chain(radix2(4), alphabet("0123456789ABCDEF"), join("")); +var base32 = chain(radix2(5), alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"), padding(5), join("")); +var base32hex = chain(radix2(5), alphabet("0123456789ABCDEFGHIJKLMNOPQRSTUV"), padding(5), join("")); +var base32crockford = chain(radix2(5), alphabet("0123456789ABCDEFGHJKMNPQRSTVWXYZ"), join(""), normalize((s) => s.toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1"))); +var base64 = chain(radix2(6), alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"), padding(6), join("")); +var base64url = chain(radix2(6), alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"), padding(6), join("")); +var genBase58 = (abc) => chain(radix(58), alphabet(abc), join("")); +var base58 = genBase58("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"); +var base58flickr = genBase58("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"); +var base58xrp = genBase58("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"); +var XMR_BLOCK_LEN = [0, 2, 3, 5, 6, 7, 9, 10, 11]; +var base58xmr = { + encode(data) { + let res = ""; + for (let i3 = 0; i3 < data.length; i3 += 8) { + const block = data.subarray(i3, i3 + 8); + res += base58.encode(block).padStart(XMR_BLOCK_LEN[block.length], "1"); + } + return res; + }, + decode(str) { + let res = []; + for (let i3 = 0; i3 < str.length; i3 += 11) { + const slice = str.slice(i3, i3 + 11); + const blockLen = XMR_BLOCK_LEN.indexOf(slice.length); + const block = base58.decode(slice); + for (let j = 0; j < block.length - blockLen; j++) { + if (block[j] !== 0) + throw new Error("base58xmr: wrong padding"); + } + res = res.concat(Array.from(block.slice(block.length - blockLen))); + } + return Uint8Array.from(res); + } +}; +var BECH_ALPHABET = chain(alphabet("qpzry9x8gf2tvdw0s3jn54khce6mua7l"), join("")); +var POLYMOD_GENERATORS = [996825010, 642813549, 513874426, 1027748829, 705979059]; +function bech32Polymod(pre) { + const b = pre >> 25; + let chk = (pre & 33554431) << 5; + for (let i3 = 0; i3 < POLYMOD_GENERATORS.length; i3++) { + if ((b >> i3 & 1) === 1) + chk ^= POLYMOD_GENERATORS[i3]; + } + return chk; +} +function bechChecksum(prefix, words, encodingConst = 1) { + const len = prefix.length; + let chk = 1; + for (let i3 = 0; i3 < len; i3++) { + const c = prefix.charCodeAt(i3); + if (c < 33 || c > 126) + throw new Error(`Invalid prefix (${prefix})`); + chk = bech32Polymod(chk) ^ c >> 5; + } + chk = bech32Polymod(chk); + for (let i3 = 0; i3 < len; i3++) + chk = bech32Polymod(chk) ^ prefix.charCodeAt(i3) & 31; + for (let v of words) + chk = bech32Polymod(chk) ^ v; + for (let i3 = 0; i3 < 6; i3++) + chk = bech32Polymod(chk); + chk ^= encodingConst; + return BECH_ALPHABET.encode(convertRadix2([chk % 2 ** 30], 30, 5, false)); +} +function genBech32(encoding) { + const ENCODING_CONST = encoding === "bech32" ? 1 : 734539939; + const _words = radix2(5); + const fromWords = _words.decode; + const toWords = _words.encode; + const fromWordsUnsafe = unsafeWrapper(fromWords); + function encode(prefix, words, limit2 = 90) { + if (typeof prefix !== "string") + throw new Error(`bech32.encode prefix should be string, not ${typeof prefix}`); + if (!Array.isArray(words) || words.length && typeof words[0] !== "number") + throw new Error(`bech32.encode words should be array of numbers, not ${typeof words}`); + const actualLength = prefix.length + 7 + words.length; + if (limit2 !== false && actualLength > limit2) + throw new TypeError(`Length ${actualLength} exceeds limit ${limit2}`); + prefix = prefix.toLowerCase(); + return `${prefix}1${BECH_ALPHABET.encode(words)}${bechChecksum(prefix, words, ENCODING_CONST)}`; + } + function decode2(str, limit2 = 90) { + if (typeof str !== "string") + throw new Error(`bech32.decode input should be string, not ${typeof str}`); + if (str.length < 8 || limit2 !== false && str.length > limit2) + throw new TypeError(`Wrong string length: ${str.length} (${str}). Expected (8..${limit2})`); + const lowered = str.toLowerCase(); + if (str !== lowered && str !== str.toUpperCase()) + throw new Error(`String must be lowercase or uppercase`); + str = lowered; + const sepIndex = str.lastIndexOf("1"); + if (sepIndex === 0 || sepIndex === -1) + throw new Error(`Letter "1" must be present between prefix and data only`); + const prefix = str.slice(0, sepIndex); + const _words2 = str.slice(sepIndex + 1); + if (_words2.length < 6) + throw new Error("Data must be at least 6 characters long"); + const words = BECH_ALPHABET.decode(_words2).slice(0, -6); + const sum = bechChecksum(prefix, words, ENCODING_CONST); + if (!_words2.endsWith(sum)) + throw new Error(`Invalid checksum in ${str}: expected "${sum}"`); + return { prefix, words }; + } + const decodeUnsafe = unsafeWrapper(decode2); + function decodeToBytes(str) { + const { prefix, words } = decode2(str, false); + return { prefix, words, bytes: fromWords(words) }; + } + return { encode, decode: decode2, decodeToBytes, decodeUnsafe, fromWords, fromWordsUnsafe, toWords }; +} +var bech32 = genBech32("bech32"); +var bech32m = genBech32("bech32m"); +var utf8 = { + encode: (data) => new TextDecoder().decode(data), + decode: (str) => new TextEncoder().encode(str) +}; +var hex = chain(radix2(4), alphabet("0123456789abcdef"), join(""), normalize((s) => { + if (typeof s !== "string" || s.length % 2) + throw new TypeError(`hex.decode: expected string, got ${typeof s} with length ${s.length}`); + return s.toLowerCase(); +})); +var CODERS = { + utf8, + hex, + base16, + base32, + base64, + base64url, + base58, + base58xmr +}; +var coderTypeError = `Invalid encoding type. Available types: ${Object.keys(CODERS).join(", ")}`; + +// node_modules/@noble/ciphers/esm/_assert.js +function number3(n) { + if (!Number.isSafeInteger(n) || n < 0) + throw new Error(`positive integer expected, not ${n}`); +} +function bool2(b) { + if (typeof b !== "boolean") + throw new Error(`boolean expected, not ${b}`); +} +function isBytes(a) { + return a instanceof Uint8Array || a != null && typeof a === "object" && a.constructor.name === "Uint8Array"; +} +function bytes3(b, ...lengths) { + if (!isBytes(b)) + throw new Error("Uint8Array expected"); + if (lengths.length > 0 && !lengths.includes(b.length)) + throw new Error(`Uint8Array expected of length ${lengths}, not of length=${b.length}`); +} +function exists3(instance, checkFinished = true) { + if (instance.destroyed) + throw new Error("Hash instance has been destroyed"); + if (checkFinished && instance.finished) + throw new Error("Hash#digest() has already been called"); +} +function output3(out, instance) { + bytes3(out); + const min = instance.outputLen; + if (out.length < min) { + throw new Error(`digestInto() expects output buffer of length at least ${min}`); + } +} + +// node_modules/@noble/ciphers/esm/utils.js +var u8 = (arr) => new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength); +var u32 = (arr) => new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4)); +var createView3 = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength); +var isLE3 = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68; +if (!isLE3) + throw new Error("Non little-endian hardware is not supported"); +function utf8ToBytes4(str) { + if (typeof str !== "string") + throw new Error(`string expected, got ${typeof str}`); + return new Uint8Array(new TextEncoder().encode(str)); +} +function toBytes3(data) { + if (typeof data === "string") + data = utf8ToBytes4(data); + else if (isBytes(data)) + data = data.slice(); + else + throw new Error(`Uint8Array expected, got ${typeof data}`); + return data; +} +function checkOpts(defaults, opts) { + if (opts == null || typeof opts !== "object") + throw new Error("options must be defined"); + const merged = Object.assign(defaults, opts); + return merged; +} +function equalBytes2(a, b) { + if (a.length !== b.length) + return false; + let diff = 0; + for (let i3 = 0; i3 < a.length; i3++) + diff |= a[i3] ^ b[i3]; + return diff === 0; +} +var wrapCipher = /* @__NO_SIDE_EFFECTS__ */ (params, c) => { + Object.assign(c, params); + return c; +}; +function setBigUint643(view, byteOffset, value, isLE4) { + if (typeof view.setBigUint64 === "function") + return view.setBigUint64(byteOffset, value, isLE4); + const _32n = BigInt(32); + const _u32_max = BigInt(4294967295); + const wh = Number(value >> _32n & _u32_max); + const wl = Number(value & _u32_max); + const h = isLE4 ? 4 : 0; + const l = isLE4 ? 0 : 4; + view.setUint32(byteOffset + h, wh, isLE4); + view.setUint32(byteOffset + l, wl, isLE4); +} + +// node_modules/@noble/ciphers/esm/_polyval.js +var BLOCK_SIZE = 16; +var ZEROS16 = /* @__PURE__ */ new Uint8Array(16); +var ZEROS32 = u32(ZEROS16); +var POLY = 225; +var mul2 = (s0, s1, s2, s3) => { + const hiBit = s3 & 1; + return { + s3: s2 << 31 | s3 >>> 1, + s2: s1 << 31 | s2 >>> 1, + s1: s0 << 31 | s1 >>> 1, + s0: s0 >>> 1 ^ POLY << 24 & -(hiBit & 1) + // reduce % poly + }; +}; +var swapLE = (n) => (n >>> 0 & 255) << 24 | (n >>> 8 & 255) << 16 | (n >>> 16 & 255) << 8 | n >>> 24 & 255 | 0; +function _toGHASHKey(k) { + k.reverse(); + const hiBit = k[15] & 1; + let carry = 0; + for (let i3 = 0; i3 < k.length; i3++) { + const t = k[i3]; + k[i3] = t >>> 1 | carry; + carry = (t & 1) << 7; + } + k[0] ^= -hiBit & 225; + return k; +} +var estimateWindow = (bytes4) => { + if (bytes4 > 64 * 1024) + return 8; + if (bytes4 > 1024) + return 4; + return 2; +}; +var GHASH = class { + // We select bits per window adaptively based on expectedLength + constructor(key, expectedLength) { + this.blockLen = BLOCK_SIZE; + this.outputLen = BLOCK_SIZE; + this.s0 = 0; + this.s1 = 0; + this.s2 = 0; + this.s3 = 0; + this.finished = false; + key = toBytes3(key); + bytes3(key, 16); + const kView = createView3(key); + let k0 = kView.getUint32(0, false); + let k1 = kView.getUint32(4, false); + let k2 = kView.getUint32(8, false); + let k3 = kView.getUint32(12, false); + const doubles = []; + for (let i3 = 0; i3 < 128; i3++) { + doubles.push({ s0: swapLE(k0), s1: swapLE(k1), s2: swapLE(k2), s3: swapLE(k3) }); + ({ s0: k0, s1: k1, s2: k2, s3: k3 } = mul2(k0, k1, k2, k3)); + } + const W = estimateWindow(expectedLength || 1024); + if (![1, 2, 4, 8].includes(W)) + throw new Error(`ghash: wrong window size=${W}, should be 2, 4 or 8`); + this.W = W; + const bits = 128; + const windows = bits / W; + const windowSize = this.windowSize = 2 ** W; + const items = []; + for (let w = 0; w < windows; w++) { + for (let byte = 0; byte < windowSize; byte++) { + let s0 = 0, s1 = 0, s2 = 0, s3 = 0; + for (let j = 0; j < W; j++) { + const bit = byte >>> W - j - 1 & 1; + if (!bit) + continue; + const { s0: d0, s1: d1, s2: d2, s3: d3 } = doubles[W * w + j]; + s0 ^= d0, s1 ^= d1, s2 ^= d2, s3 ^= d3; + } + items.push({ s0, s1, s2, s3 }); + } + } + this.t = items; + } + _updateBlock(s0, s1, s2, s3) { + s0 ^= this.s0, s1 ^= this.s1, s2 ^= this.s2, s3 ^= this.s3; + const { W, t, windowSize } = this; + let o0 = 0, o1 = 0, o2 = 0, o3 = 0; + const mask = (1 << W) - 1; + let w = 0; + for (const num of [s0, s1, s2, s3]) { + for (let bytePos = 0; bytePos < 4; bytePos++) { + const byte = num >>> 8 * bytePos & 255; + for (let bitPos = 8 / W - 1; bitPos >= 0; bitPos--) { + const bit = byte >>> W * bitPos & mask; + const { s0: e0, s1: e1, s2: e2, s3: e3 } = t[w * windowSize + bit]; + o0 ^= e0, o1 ^= e1, o2 ^= e2, o3 ^= e3; + w += 1; + } + } + } + this.s0 = o0; + this.s1 = o1; + this.s2 = o2; + this.s3 = o3; + } + update(data) { + data = toBytes3(data); + exists3(this); + const b32 = u32(data); + const blocks = Math.floor(data.length / BLOCK_SIZE); + const left = data.length % BLOCK_SIZE; + for (let i3 = 0; i3 < blocks; i3++) { + this._updateBlock(b32[i3 * 4 + 0], b32[i3 * 4 + 1], b32[i3 * 4 + 2], b32[i3 * 4 + 3]); + } + if (left) { + ZEROS16.set(data.subarray(blocks * BLOCK_SIZE)); + this._updateBlock(ZEROS32[0], ZEROS32[1], ZEROS32[2], ZEROS32[3]); + ZEROS32.fill(0); + } + return this; + } + destroy() { + const { t } = this; + for (const elm of t) { + elm.s0 = 0, elm.s1 = 0, elm.s2 = 0, elm.s3 = 0; + } + } + digestInto(out) { + exists3(this); + output3(out, this); + this.finished = true; + const { s0, s1, s2, s3 } = this; + const o32 = u32(out); + o32[0] = s0; + o32[1] = s1; + o32[2] = s2; + o32[3] = s3; + return out; + } + digest() { + const res = new Uint8Array(BLOCK_SIZE); + this.digestInto(res); + this.destroy(); + return res; + } +}; +var Polyval = class extends GHASH { + constructor(key, expectedLength) { + key = toBytes3(key); + const ghKey = _toGHASHKey(key.slice()); + super(ghKey, expectedLength); + ghKey.fill(0); + } + update(data) { + data = toBytes3(data); + exists3(this); + const b32 = u32(data); + const left = data.length % BLOCK_SIZE; + const blocks = Math.floor(data.length / BLOCK_SIZE); + for (let i3 = 0; i3 < blocks; i3++) { + this._updateBlock(swapLE(b32[i3 * 4 + 3]), swapLE(b32[i3 * 4 + 2]), swapLE(b32[i3 * 4 + 1]), swapLE(b32[i3 * 4 + 0])); + } + if (left) { + ZEROS16.set(data.subarray(blocks * BLOCK_SIZE)); + this._updateBlock(swapLE(ZEROS32[3]), swapLE(ZEROS32[2]), swapLE(ZEROS32[1]), swapLE(ZEROS32[0])); + ZEROS32.fill(0); + } + return this; + } + digestInto(out) { + exists3(this); + output3(out, this); + this.finished = true; + const { s0, s1, s2, s3 } = this; + const o32 = u32(out); + o32[0] = s0; + o32[1] = s1; + o32[2] = s2; + o32[3] = s3; + return out.reverse(); + } +}; +function wrapConstructorWithKey(hashCons) { + const hashC = (msg, key) => hashCons(key, msg.length).update(toBytes3(msg)).digest(); + const tmp = hashCons(new Uint8Array(16), 0); + hashC.outputLen = tmp.outputLen; + hashC.blockLen = tmp.blockLen; + hashC.create = (key, expectedLength) => hashCons(key, expectedLength); + return hashC; +} +var ghash = wrapConstructorWithKey((key, expectedLength) => new GHASH(key, expectedLength)); +var polyval = wrapConstructorWithKey((key, expectedLength) => new Polyval(key, expectedLength)); + +// node_modules/@noble/ciphers/esm/aes.js +var BLOCK_SIZE2 = 16; +var BLOCK_SIZE32 = 4; +var EMPTY_BLOCK = new Uint8Array(BLOCK_SIZE2); +var POLY2 = 283; +function mul22(n) { + return n << 1 ^ POLY2 & -(n >> 7); +} +function mul(a, b) { + let res = 0; + for (; b > 0; b >>= 1) { + res ^= a & -(b & 1); + a = mul22(a); + } + return res; +} +var sbox = /* @__PURE__ */ (() => { + let t = new Uint8Array(256); + for (let i3 = 0, x = 1; i3 < 256; i3++, x ^= mul22(x)) + t[i3] = x; + const box = new Uint8Array(256); + box[0] = 99; + for (let i3 = 0; i3 < 255; i3++) { + let x = t[255 - i3]; + x |= x << 8; + box[t[i3]] = (x ^ x >> 4 ^ x >> 5 ^ x >> 6 ^ x >> 7 ^ 99) & 255; + } + return box; +})(); +var invSbox = /* @__PURE__ */ sbox.map((_, j) => sbox.indexOf(j)); +var rotr32_8 = (n) => n << 24 | n >>> 8; +var rotl32_8 = (n) => n << 8 | n >>> 24; +function genTtable(sbox2, fn) { + if (sbox2.length !== 256) + throw new Error("Wrong sbox length"); + const T0 = new Uint32Array(256).map((_, j) => fn(sbox2[j])); + const T1 = T0.map(rotl32_8); + const T2 = T1.map(rotl32_8); + const T3 = T2.map(rotl32_8); + const T01 = new Uint32Array(256 * 256); + const T23 = new Uint32Array(256 * 256); + const sbox22 = new Uint16Array(256 * 256); + for (let i3 = 0; i3 < 256; i3++) { + for (let j = 0; j < 256; j++) { + const idx = i3 * 256 + j; + T01[idx] = T0[i3] ^ T1[j]; + T23[idx] = T2[i3] ^ T3[j]; + sbox22[idx] = sbox2[i3] << 8 | sbox2[j]; + } + } + return { sbox: sbox2, sbox2: sbox22, T0, T1, T2, T3, T01, T23 }; +} +var tableEncoding = /* @__PURE__ */ genTtable(sbox, (s) => mul(s, 3) << 24 | s << 16 | s << 8 | mul(s, 2)); +var tableDecoding = /* @__PURE__ */ genTtable(invSbox, (s) => mul(s, 11) << 24 | mul(s, 13) << 16 | mul(s, 9) << 8 | mul(s, 14)); +var xPowers = /* @__PURE__ */ (() => { + const p = new Uint8Array(16); + for (let i3 = 0, x = 1; i3 < 16; i3++, x = mul22(x)) + p[i3] = x; + return p; +})(); +function expandKeyLE(key) { + bytes3(key); + const len = key.length; + if (![16, 24, 32].includes(len)) + throw new Error(`aes: wrong key size: should be 16, 24 or 32, got: ${len}`); + const { sbox2 } = tableEncoding; + const k32 = u32(key); + const Nk = k32.length; + const subByte = (n) => applySbox(sbox2, n, n, n, n); + const xk = new Uint32Array(len + 28); + xk.set(k32); + for (let i3 = Nk; i3 < xk.length; i3++) { + let t = xk[i3 - 1]; + if (i3 % Nk === 0) + t = subByte(rotr32_8(t)) ^ xPowers[i3 / Nk - 1]; + else if (Nk > 6 && i3 % Nk === 4) + t = subByte(t); + xk[i3] = xk[i3 - Nk] ^ t; + } + return xk; +} +function expandKeyDecLE(key) { + const encKey = expandKeyLE(key); + const xk = encKey.slice(); + const Nk = encKey.length; + const { sbox2 } = tableEncoding; + const { T0, T1, T2, T3 } = tableDecoding; + for (let i3 = 0; i3 < Nk; i3 += 4) { + for (let j = 0; j < 4; j++) + xk[i3 + j] = encKey[Nk - i3 - 4 + j]; + } + encKey.fill(0); + for (let i3 = 4; i3 < Nk - 4; i3++) { + const x = xk[i3]; + const w = applySbox(sbox2, x, x, x, x); + xk[i3] = T0[w & 255] ^ T1[w >>> 8 & 255] ^ T2[w >>> 16 & 255] ^ T3[w >>> 24]; + } + return xk; +} +function apply0123(T01, T23, s0, s1, s2, s3) { + return T01[s0 << 8 & 65280 | s1 >>> 8 & 255] ^ T23[s2 >>> 8 & 65280 | s3 >>> 24 & 255]; +} +function applySbox(sbox2, s0, s1, s2, s3) { + return sbox2[s0 & 255 | s1 & 65280] | sbox2[s2 >>> 16 & 255 | s3 >>> 16 & 65280] << 16; +} +function encrypt(xk, s0, s1, s2, s3) { + const { sbox2, T01, T23 } = tableEncoding; + let k = 0; + s0 ^= xk[k++], s1 ^= xk[k++], s2 ^= xk[k++], s3 ^= xk[k++]; + const rounds = xk.length / 4 - 2; + for (let i3 = 0; i3 < rounds; i3++) { + const t02 = xk[k++] ^ apply0123(T01, T23, s0, s1, s2, s3); + const t12 = xk[k++] ^ apply0123(T01, T23, s1, s2, s3, s0); + const t22 = xk[k++] ^ apply0123(T01, T23, s2, s3, s0, s1); + const t32 = xk[k++] ^ apply0123(T01, T23, s3, s0, s1, s2); + s0 = t02, s1 = t12, s2 = t22, s3 = t32; + } + const t0 = xk[k++] ^ applySbox(sbox2, s0, s1, s2, s3); + const t1 = xk[k++] ^ applySbox(sbox2, s1, s2, s3, s0); + const t2 = xk[k++] ^ applySbox(sbox2, s2, s3, s0, s1); + const t3 = xk[k++] ^ applySbox(sbox2, s3, s0, s1, s2); + return { s0: t0, s1: t1, s2: t2, s3: t3 }; +} +function decrypt(xk, s0, s1, s2, s3) { + const { sbox2, T01, T23 } = tableDecoding; + let k = 0; + s0 ^= xk[k++], s1 ^= xk[k++], s2 ^= xk[k++], s3 ^= xk[k++]; + const rounds = xk.length / 4 - 2; + for (let i3 = 0; i3 < rounds; i3++) { + const t02 = xk[k++] ^ apply0123(T01, T23, s0, s3, s2, s1); + const t12 = xk[k++] ^ apply0123(T01, T23, s1, s0, s3, s2); + const t22 = xk[k++] ^ apply0123(T01, T23, s2, s1, s0, s3); + const t32 = xk[k++] ^ apply0123(T01, T23, s3, s2, s1, s0); + s0 = t02, s1 = t12, s2 = t22, s3 = t32; + } + const t0 = xk[k++] ^ applySbox(sbox2, s0, s3, s2, s1); + const t1 = xk[k++] ^ applySbox(sbox2, s1, s0, s3, s2); + const t2 = xk[k++] ^ applySbox(sbox2, s2, s1, s0, s3); + const t3 = xk[k++] ^ applySbox(sbox2, s3, s2, s1, s0); + return { s0: t0, s1: t1, s2: t2, s3: t3 }; +} +function getDst(len, dst) { + if (!dst) + return new Uint8Array(len); + bytes3(dst); + if (dst.length < len) + throw new Error(`aes: wrong destination length, expected at least ${len}, got: ${dst.length}`); + return dst; +} +function ctrCounter(xk, nonce, src, dst) { + bytes3(nonce, BLOCK_SIZE2); + bytes3(src); + const srcLen = src.length; + dst = getDst(srcLen, dst); + const ctr3 = nonce; + const c32 = u32(ctr3); + let { s0, s1, s2, s3 } = encrypt(xk, c32[0], c32[1], c32[2], c32[3]); + const src32 = u32(src); + const dst32 = u32(dst); + for (let i3 = 0; i3 + 4 <= src32.length; i3 += 4) { + dst32[i3 + 0] = src32[i3 + 0] ^ s0; + dst32[i3 + 1] = src32[i3 + 1] ^ s1; + dst32[i3 + 2] = src32[i3 + 2] ^ s2; + dst32[i3 + 3] = src32[i3 + 3] ^ s3; + let carry = 1; + for (let i4 = ctr3.length - 1; i4 >= 0; i4--) { + carry = carry + (ctr3[i4] & 255) | 0; + ctr3[i4] = carry & 255; + carry >>>= 8; + } + ({ s0, s1, s2, s3 } = encrypt(xk, c32[0], c32[1], c32[2], c32[3])); + } + const start = BLOCK_SIZE2 * Math.floor(src32.length / BLOCK_SIZE32); + if (start < srcLen) { + const b32 = new Uint32Array([s0, s1, s2, s3]); + const buf = u8(b32); + for (let i3 = start, pos = 0; i3 < srcLen; i3++, pos++) + dst[i3] = src[i3] ^ buf[pos]; + } + return dst; +} +function ctr32(xk, isLE4, nonce, src, dst) { + bytes3(nonce, BLOCK_SIZE2); + bytes3(src); + dst = getDst(src.length, dst); + const ctr3 = nonce; + const c32 = u32(ctr3); + const view = createView3(ctr3); + const src32 = u32(src); + const dst32 = u32(dst); + const ctrPos = isLE4 ? 0 : 12; + const srcLen = src.length; + let ctrNum = view.getUint32(ctrPos, isLE4); + let { s0, s1, s2, s3 } = encrypt(xk, c32[0], c32[1], c32[2], c32[3]); + for (let i3 = 0; i3 + 4 <= src32.length; i3 += 4) { + dst32[i3 + 0] = src32[i3 + 0] ^ s0; + dst32[i3 + 1] = src32[i3 + 1] ^ s1; + dst32[i3 + 2] = src32[i3 + 2] ^ s2; + dst32[i3 + 3] = src32[i3 + 3] ^ s3; + ctrNum = ctrNum + 1 >>> 0; + view.setUint32(ctrPos, ctrNum, isLE4); + ({ s0, s1, s2, s3 } = encrypt(xk, c32[0], c32[1], c32[2], c32[3])); + } + const start = BLOCK_SIZE2 * Math.floor(src32.length / BLOCK_SIZE32); + if (start < srcLen) { + const b32 = new Uint32Array([s0, s1, s2, s3]); + const buf = u8(b32); + for (let i3 = start, pos = 0; i3 < srcLen; i3++, pos++) + dst[i3] = src[i3] ^ buf[pos]; + } + return dst; +} +var ctr = wrapCipher({ blockSize: 16, nonceLength: 16 }, function ctr2(key, nonce) { + bytes3(key); + bytes3(nonce, BLOCK_SIZE2); + function processCtr(buf, dst) { + const xk = expandKeyLE(key); + const n = nonce.slice(); + const out = ctrCounter(xk, n, buf, dst); + xk.fill(0); + n.fill(0); + return out; + } + return { + encrypt: (plaintext, dst) => processCtr(plaintext, dst), + decrypt: (ciphertext, dst) => processCtr(ciphertext, dst) + }; +}); +function validateBlockDecrypt(data) { + bytes3(data); + if (data.length % BLOCK_SIZE2 !== 0) { + throw new Error(`aes/(cbc-ecb).decrypt ciphertext should consist of blocks with size ${BLOCK_SIZE2}`); + } +} +function validateBlockEncrypt(plaintext, pcks5, dst) { + let outLen = plaintext.length; + const remaining = outLen % BLOCK_SIZE2; + if (!pcks5 && remaining !== 0) + throw new Error("aec/(cbc-ecb): unpadded plaintext with disabled padding"); + const b = u32(plaintext); + if (pcks5) { + let left = BLOCK_SIZE2 - remaining; + if (!left) + left = BLOCK_SIZE2; + outLen = outLen + left; + } + const out = getDst(outLen, dst); + const o = u32(out); + return { b, o, out }; +} +function validatePCKS(data, pcks5) { + if (!pcks5) + return data; + const len = data.length; + if (!len) + throw new Error(`aes/pcks5: empty ciphertext not allowed`); + const lastByte = data[len - 1]; + if (lastByte <= 0 || lastByte > 16) + throw new Error(`aes/pcks5: wrong padding byte: ${lastByte}`); + const out = data.subarray(0, -lastByte); + for (let i3 = 0; i3 < lastByte; i3++) + if (data[len - i3 - 1] !== lastByte) + throw new Error(`aes/pcks5: wrong padding`); + return out; +} +function padPCKS(left) { + const tmp = new Uint8Array(16); + const tmp32 = u32(tmp); + tmp.set(left); + const paddingByte = BLOCK_SIZE2 - left.length; + for (let i3 = BLOCK_SIZE2 - paddingByte; i3 < BLOCK_SIZE2; i3++) + tmp[i3] = paddingByte; + return tmp32; +} +var ecb = wrapCipher({ blockSize: 16 }, function ecb2(key, opts = {}) { + bytes3(key); + const pcks5 = !opts.disablePadding; + return { + encrypt: (plaintext, dst) => { + bytes3(plaintext); + const { b, o, out: _out } = validateBlockEncrypt(plaintext, pcks5, dst); + const xk = expandKeyLE(key); + let i3 = 0; + for (; i3 + 4 <= b.length; ) { + const { s0, s1, s2, s3 } = encrypt(xk, b[i3 + 0], b[i3 + 1], b[i3 + 2], b[i3 + 3]); + o[i3++] = s0, o[i3++] = s1, o[i3++] = s2, o[i3++] = s3; + } + if (pcks5) { + const tmp32 = padPCKS(plaintext.subarray(i3 * 4)); + const { s0, s1, s2, s3 } = encrypt(xk, tmp32[0], tmp32[1], tmp32[2], tmp32[3]); + o[i3++] = s0, o[i3++] = s1, o[i3++] = s2, o[i3++] = s3; + } + xk.fill(0); + return _out; + }, + decrypt: (ciphertext, dst) => { + validateBlockDecrypt(ciphertext); + const xk = expandKeyDecLE(key); + const out = getDst(ciphertext.length, dst); + const b = u32(ciphertext); + const o = u32(out); + for (let i3 = 0; i3 + 4 <= b.length; ) { + const { s0, s1, s2, s3 } = decrypt(xk, b[i3 + 0], b[i3 + 1], b[i3 + 2], b[i3 + 3]); + o[i3++] = s0, o[i3++] = s1, o[i3++] = s2, o[i3++] = s3; + } + xk.fill(0); + return validatePCKS(out, pcks5); + } + }; +}); +var cbc = wrapCipher({ blockSize: 16, nonceLength: 16 }, function cbc2(key, iv, opts = {}) { + bytes3(key); + bytes3(iv, 16); + const pcks5 = !opts.disablePadding; + return { + encrypt: (plaintext, dst) => { + const xk = expandKeyLE(key); + const { b, o, out: _out } = validateBlockEncrypt(plaintext, pcks5, dst); + const n32 = u32(iv); + let s0 = n32[0], s1 = n32[1], s2 = n32[2], s3 = n32[3]; + let i3 = 0; + for (; i3 + 4 <= b.length; ) { + s0 ^= b[i3 + 0], s1 ^= b[i3 + 1], s2 ^= b[i3 + 2], s3 ^= b[i3 + 3]; + ({ s0, s1, s2, s3 } = encrypt(xk, s0, s1, s2, s3)); + o[i3++] = s0, o[i3++] = s1, o[i3++] = s2, o[i3++] = s3; + } + if (pcks5) { + const tmp32 = padPCKS(plaintext.subarray(i3 * 4)); + s0 ^= tmp32[0], s1 ^= tmp32[1], s2 ^= tmp32[2], s3 ^= tmp32[3]; + ({ s0, s1, s2, s3 } = encrypt(xk, s0, s1, s2, s3)); + o[i3++] = s0, o[i3++] = s1, o[i3++] = s2, o[i3++] = s3; + } + xk.fill(0); + return _out; + }, + decrypt: (ciphertext, dst) => { + validateBlockDecrypt(ciphertext); + const xk = expandKeyDecLE(key); + const n32 = u32(iv); + const out = getDst(ciphertext.length, dst); + const b = u32(ciphertext); + const o = u32(out); + let s0 = n32[0], s1 = n32[1], s2 = n32[2], s3 = n32[3]; + for (let i3 = 0; i3 + 4 <= b.length; ) { + const ps0 = s0, ps1 = s1, ps2 = s2, ps3 = s3; + s0 = b[i3 + 0], s1 = b[i3 + 1], s2 = b[i3 + 2], s3 = b[i3 + 3]; + const { s0: o0, s1: o1, s2: o2, s3: o3 } = decrypt(xk, s0, s1, s2, s3); + o[i3++] = o0 ^ ps0, o[i3++] = o1 ^ ps1, o[i3++] = o2 ^ ps2, o[i3++] = o3 ^ ps3; + } + xk.fill(0); + return validatePCKS(out, pcks5); + } + }; +}); +var cfb = wrapCipher({ blockSize: 16, nonceLength: 16 }, function cfb2(key, iv) { + bytes3(key); + bytes3(iv, 16); + function processCfb(src, isEncrypt, dst) { + const xk = expandKeyLE(key); + const srcLen = src.length; + dst = getDst(srcLen, dst); + const src32 = u32(src); + const dst32 = u32(dst); + const next32 = isEncrypt ? dst32 : src32; + const n32 = u32(iv); + let s0 = n32[0], s1 = n32[1], s2 = n32[2], s3 = n32[3]; + for (let i3 = 0; i3 + 4 <= src32.length; ) { + const { s0: e0, s1: e1, s2: e2, s3: e3 } = encrypt(xk, s0, s1, s2, s3); + dst32[i3 + 0] = src32[i3 + 0] ^ e0; + dst32[i3 + 1] = src32[i3 + 1] ^ e1; + dst32[i3 + 2] = src32[i3 + 2] ^ e2; + dst32[i3 + 3] = src32[i3 + 3] ^ e3; + s0 = next32[i3++], s1 = next32[i3++], s2 = next32[i3++], s3 = next32[i3++]; + } + const start = BLOCK_SIZE2 * Math.floor(src32.length / BLOCK_SIZE32); + if (start < srcLen) { + ({ s0, s1, s2, s3 } = encrypt(xk, s0, s1, s2, s3)); + const buf = u8(new Uint32Array([s0, s1, s2, s3])); + for (let i3 = start, pos = 0; i3 < srcLen; i3++, pos++) + dst[i3] = src[i3] ^ buf[pos]; + buf.fill(0); + } + xk.fill(0); + return dst; + } + return { + encrypt: (plaintext, dst) => processCfb(plaintext, true, dst), + decrypt: (ciphertext, dst) => processCfb(ciphertext, false, dst) + }; +}); +function computeTag(fn, isLE4, key, data, AAD) { + const h = fn.create(key, data.length + (AAD?.length || 0)); + if (AAD) + h.update(AAD); + h.update(data); + const num = new Uint8Array(16); + const view = createView3(num); + if (AAD) + setBigUint643(view, 0, BigInt(AAD.length * 8), isLE4); + setBigUint643(view, 8, BigInt(data.length * 8), isLE4); + h.update(num); + return h.digest(); +} +var gcm = wrapCipher({ blockSize: 16, nonceLength: 12, tagLength: 16 }, function gcm2(key, nonce, AAD) { + bytes3(nonce); + if (nonce.length === 0) + throw new Error("aes/gcm: empty nonce"); + const tagLength = 16; + function _computeTag(authKey, tagMask, data) { + const tag = computeTag(ghash, false, authKey, data, AAD); + for (let i3 = 0; i3 < tagMask.length; i3++) + tag[i3] ^= tagMask[i3]; + return tag; + } + function deriveKeys() { + const xk = expandKeyLE(key); + const authKey = EMPTY_BLOCK.slice(); + const counter = EMPTY_BLOCK.slice(); + ctr32(xk, false, counter, counter, authKey); + if (nonce.length === 12) { + counter.set(nonce); + } else { + const nonceLen = EMPTY_BLOCK.slice(); + const view = createView3(nonceLen); + setBigUint643(view, 8, BigInt(nonce.length * 8), false); + ghash.create(authKey).update(nonce).update(nonceLen).digestInto(counter); + } + const tagMask = ctr32(xk, false, counter, EMPTY_BLOCK); + return { xk, authKey, counter, tagMask }; + } + return { + encrypt: (plaintext) => { + bytes3(plaintext); + const { xk, authKey, counter, tagMask } = deriveKeys(); + const out = new Uint8Array(plaintext.length + tagLength); + ctr32(xk, false, counter, plaintext, out); + const tag = _computeTag(authKey, tagMask, out.subarray(0, out.length - tagLength)); + out.set(tag, plaintext.length); + xk.fill(0); + return out; + }, + decrypt: (ciphertext) => { + bytes3(ciphertext); + if (ciphertext.length < tagLength) + throw new Error(`aes/gcm: ciphertext less than tagLen (${tagLength})`); + const { xk, authKey, counter, tagMask } = deriveKeys(); + const data = ciphertext.subarray(0, -tagLength); + const passedTag = ciphertext.subarray(-tagLength); + const tag = _computeTag(authKey, tagMask, data); + if (!equalBytes2(tag, passedTag)) + throw new Error("aes/gcm: invalid ghash tag"); + const out = ctr32(xk, false, counter, data); + authKey.fill(0); + tagMask.fill(0); + xk.fill(0); + return out; + } + }; +}); +var limit = (name, min, max) => (value) => { + if (!Number.isSafeInteger(value) || min > value || value > max) + throw new Error(`${name}: invalid value=${value}, must be [${min}..${max}]`); +}; +var siv = wrapCipher({ blockSize: 16, nonceLength: 12, tagLength: 16 }, function siv2(key, nonce, AAD) { + const tagLength = 16; + const AAD_LIMIT = limit("AAD", 0, 2 ** 36); + const PLAIN_LIMIT = limit("plaintext", 0, 2 ** 36); + const NONCE_LIMIT = limit("nonce", 12, 12); + const CIPHER_LIMIT = limit("ciphertext", 16, 2 ** 36 + 16); + bytes3(nonce); + NONCE_LIMIT(nonce.length); + if (AAD) { + bytes3(AAD); + AAD_LIMIT(AAD.length); + } + function deriveKeys() { + const len = key.length; + if (len !== 16 && len !== 24 && len !== 32) + throw new Error(`key length must be 16, 24 or 32 bytes, got: ${len} bytes`); + const xk = expandKeyLE(key); + const encKey = new Uint8Array(len); + const authKey = new Uint8Array(16); + const n32 = u32(nonce); + let s0 = 0, s1 = n32[0], s2 = n32[1], s3 = n32[2]; + let counter = 0; + for (const derivedKey of [authKey, encKey].map(u32)) { + const d32 = u32(derivedKey); + for (let i3 = 0; i3 < d32.length; i3 += 2) { + const { s0: o0, s1: o1 } = encrypt(xk, s0, s1, s2, s3); + d32[i3 + 0] = o0; + d32[i3 + 1] = o1; + s0 = ++counter; + } + } + xk.fill(0); + return { authKey, encKey: expandKeyLE(encKey) }; + } + function _computeTag(encKey, authKey, data) { + const tag = computeTag(polyval, true, authKey, data, AAD); + for (let i3 = 0; i3 < 12; i3++) + tag[i3] ^= nonce[i3]; + tag[15] &= 127; + const t32 = u32(tag); + let s0 = t32[0], s1 = t32[1], s2 = t32[2], s3 = t32[3]; + ({ s0, s1, s2, s3 } = encrypt(encKey, s0, s1, s2, s3)); + t32[0] = s0, t32[1] = s1, t32[2] = s2, t32[3] = s3; + return tag; + } + function processSiv(encKey, tag, input) { + let block = tag.slice(); + block[15] |= 128; + return ctr32(encKey, true, block, input); + } + return { + encrypt: (plaintext) => { + bytes3(plaintext); + PLAIN_LIMIT(plaintext.length); + const { encKey, authKey } = deriveKeys(); + const tag = _computeTag(encKey, authKey, plaintext); + const out = new Uint8Array(plaintext.length + tagLength); + out.set(tag, plaintext.length); + out.set(processSiv(encKey, tag, plaintext)); + encKey.fill(0); + authKey.fill(0); + return out; + }, + decrypt: (ciphertext) => { + bytes3(ciphertext); + CIPHER_LIMIT(ciphertext.length); + const tag = ciphertext.subarray(-tagLength); + const { encKey, authKey } = deriveKeys(); + const plaintext = processSiv(encKey, tag, ciphertext.subarray(0, -tagLength)); + const expectedTag = _computeTag(encKey, authKey, plaintext); + encKey.fill(0); + authKey.fill(0); + if (!equalBytes2(tag, expectedTag)) + throw new Error("invalid polyval tag"); + return plaintext; + } + }; +}); + +// node_modules/@noble/ciphers/esm/_poly1305.js +var u8to16 = (a, i3) => a[i3++] & 255 | (a[i3++] & 255) << 8; +var Poly1305 = class { + constructor(key) { + this.blockLen = 16; + this.outputLen = 16; + this.buffer = new Uint8Array(16); + this.r = new Uint16Array(10); + this.h = new Uint16Array(10); + this.pad = new Uint16Array(8); + this.pos = 0; + this.finished = false; + key = toBytes3(key); + bytes3(key, 32); + const t0 = u8to16(key, 0); + const t1 = u8to16(key, 2); + const t2 = u8to16(key, 4); + const t3 = u8to16(key, 6); + const t4 = u8to16(key, 8); + const t5 = u8to16(key, 10); + const t6 = u8to16(key, 12); + const t7 = u8to16(key, 14); + this.r[0] = t0 & 8191; + this.r[1] = (t0 >>> 13 | t1 << 3) & 8191; + this.r[2] = (t1 >>> 10 | t2 << 6) & 7939; + this.r[3] = (t2 >>> 7 | t3 << 9) & 8191; + this.r[4] = (t3 >>> 4 | t4 << 12) & 255; + this.r[5] = t4 >>> 1 & 8190; + this.r[6] = (t4 >>> 14 | t5 << 2) & 8191; + this.r[7] = (t5 >>> 11 | t6 << 5) & 8065; + this.r[8] = (t6 >>> 8 | t7 << 8) & 8191; + this.r[9] = t7 >>> 5 & 127; + for (let i3 = 0; i3 < 8; i3++) + this.pad[i3] = u8to16(key, 16 + 2 * i3); + } + process(data, offset, isLast = false) { + const hibit = isLast ? 0 : 1 << 11; + const { h, r } = this; + const r0 = r[0]; + const r1 = r[1]; + const r2 = r[2]; + const r3 = r[3]; + const r4 = r[4]; + const r5 = r[5]; + const r6 = r[6]; + const r7 = r[7]; + const r8 = r[8]; + const r9 = r[9]; + const t0 = u8to16(data, offset + 0); + const t1 = u8to16(data, offset + 2); + const t2 = u8to16(data, offset + 4); + const t3 = u8to16(data, offset + 6); + const t4 = u8to16(data, offset + 8); + const t5 = u8to16(data, offset + 10); + const t6 = u8to16(data, offset + 12); + const t7 = u8to16(data, offset + 14); + let h0 = h[0] + (t0 & 8191); + let h1 = h[1] + ((t0 >>> 13 | t1 << 3) & 8191); + let h2 = h[2] + ((t1 >>> 10 | t2 << 6) & 8191); + let h3 = h[3] + ((t2 >>> 7 | t3 << 9) & 8191); + let h4 = h[4] + ((t3 >>> 4 | t4 << 12) & 8191); + let h5 = h[5] + (t4 >>> 1 & 8191); + let h6 = h[6] + ((t4 >>> 14 | t5 << 2) & 8191); + let h7 = h[7] + ((t5 >>> 11 | t6 << 5) & 8191); + let h8 = h[8] + ((t6 >>> 8 | t7 << 8) & 8191); + let h9 = h[9] + (t7 >>> 5 | hibit); + let c = 0; + let d0 = c + h0 * r0 + h1 * (5 * r9) + h2 * (5 * r8) + h3 * (5 * r7) + h4 * (5 * r6); + c = d0 >>> 13; + d0 &= 8191; + d0 += h5 * (5 * r5) + h6 * (5 * r4) + h7 * (5 * r3) + h8 * (5 * r2) + h9 * (5 * r1); + c += d0 >>> 13; + d0 &= 8191; + let d1 = c + h0 * r1 + h1 * r0 + h2 * (5 * r9) + h3 * (5 * r8) + h4 * (5 * r7); + c = d1 >>> 13; + d1 &= 8191; + d1 += h5 * (5 * r6) + h6 * (5 * r5) + h7 * (5 * r4) + h8 * (5 * r3) + h9 * (5 * r2); + c += d1 >>> 13; + d1 &= 8191; + let d2 = c + h0 * r2 + h1 * r1 + h2 * r0 + h3 * (5 * r9) + h4 * (5 * r8); + c = d2 >>> 13; + d2 &= 8191; + d2 += h5 * (5 * r7) + h6 * (5 * r6) + h7 * (5 * r5) + h8 * (5 * r4) + h9 * (5 * r3); + c += d2 >>> 13; + d2 &= 8191; + let d3 = c + h0 * r3 + h1 * r2 + h2 * r1 + h3 * r0 + h4 * (5 * r9); + c = d3 >>> 13; + d3 &= 8191; + d3 += h5 * (5 * r8) + h6 * (5 * r7) + h7 * (5 * r6) + h8 * (5 * r5) + h9 * (5 * r4); + c += d3 >>> 13; + d3 &= 8191; + let d4 = c + h0 * r4 + h1 * r3 + h2 * r2 + h3 * r1 + h4 * r0; + c = d4 >>> 13; + d4 &= 8191; + d4 += h5 * (5 * r9) + h6 * (5 * r8) + h7 * (5 * r7) + h8 * (5 * r6) + h9 * (5 * r5); + c += d4 >>> 13; + d4 &= 8191; + let d5 = c + h0 * r5 + h1 * r4 + h2 * r3 + h3 * r2 + h4 * r1; + c = d5 >>> 13; + d5 &= 8191; + d5 += h5 * r0 + h6 * (5 * r9) + h7 * (5 * r8) + h8 * (5 * r7) + h9 * (5 * r6); + c += d5 >>> 13; + d5 &= 8191; + let d6 = c + h0 * r6 + h1 * r5 + h2 * r4 + h3 * r3 + h4 * r2; + c = d6 >>> 13; + d6 &= 8191; + d6 += h5 * r1 + h6 * r0 + h7 * (5 * r9) + h8 * (5 * r8) + h9 * (5 * r7); + c += d6 >>> 13; + d6 &= 8191; + let d7 = c + h0 * r7 + h1 * r6 + h2 * r5 + h3 * r4 + h4 * r3; + c = d7 >>> 13; + d7 &= 8191; + d7 += h5 * r2 + h6 * r1 + h7 * r0 + h8 * (5 * r9) + h9 * (5 * r8); + c += d7 >>> 13; + d7 &= 8191; + let d8 = c + h0 * r8 + h1 * r7 + h2 * r6 + h3 * r5 + h4 * r4; + c = d8 >>> 13; + d8 &= 8191; + d8 += h5 * r3 + h6 * r2 + h7 * r1 + h8 * r0 + h9 * (5 * r9); + c += d8 >>> 13; + d8 &= 8191; + let d9 = c + h0 * r9 + h1 * r8 + h2 * r7 + h3 * r6 + h4 * r5; + c = d9 >>> 13; + d9 &= 8191; + d9 += h5 * r4 + h6 * r3 + h7 * r2 + h8 * r1 + h9 * r0; + c += d9 >>> 13; + d9 &= 8191; + c = (c << 2) + c | 0; + c = c + d0 | 0; + d0 = c & 8191; + c = c >>> 13; + d1 += c; + h[0] = d0; + h[1] = d1; + h[2] = d2; + h[3] = d3; + h[4] = d4; + h[5] = d5; + h[6] = d6; + h[7] = d7; + h[8] = d8; + h[9] = d9; + } + finalize() { + const { h, pad: pad2 } = this; + const g = new Uint16Array(10); + let c = h[1] >>> 13; + h[1] &= 8191; + for (let i3 = 2; i3 < 10; i3++) { + h[i3] += c; + c = h[i3] >>> 13; + h[i3] &= 8191; + } + h[0] += c * 5; + c = h[0] >>> 13; + h[0] &= 8191; + h[1] += c; + c = h[1] >>> 13; + h[1] &= 8191; + h[2] += c; + g[0] = h[0] + 5; + c = g[0] >>> 13; + g[0] &= 8191; + for (let i3 = 1; i3 < 10; i3++) { + g[i3] = h[i3] + c; + c = g[i3] >>> 13; + g[i3] &= 8191; + } + g[9] -= 1 << 13; + let mask = (c ^ 1) - 1; + for (let i3 = 0; i3 < 10; i3++) + g[i3] &= mask; + mask = ~mask; + for (let i3 = 0; i3 < 10; i3++) + h[i3] = h[i3] & mask | g[i3]; + h[0] = (h[0] | h[1] << 13) & 65535; + h[1] = (h[1] >>> 3 | h[2] << 10) & 65535; + h[2] = (h[2] >>> 6 | h[3] << 7) & 65535; + h[3] = (h[3] >>> 9 | h[4] << 4) & 65535; + h[4] = (h[4] >>> 12 | h[5] << 1 | h[6] << 14) & 65535; + h[5] = (h[6] >>> 2 | h[7] << 11) & 65535; + h[6] = (h[7] >>> 5 | h[8] << 8) & 65535; + h[7] = (h[8] >>> 8 | h[9] << 5) & 65535; + let f = h[0] + pad2[0]; + h[0] = f & 65535; + for (let i3 = 1; i3 < 8; i3++) { + f = (h[i3] + pad2[i3] | 0) + (f >>> 16) | 0; + h[i3] = f & 65535; + } + } + update(data) { + exists3(this); + const { buffer, blockLen } = this; + data = toBytes3(data); + const len = data.length; + for (let pos = 0; pos < len; ) { + const take = Math.min(blockLen - this.pos, len - pos); + if (take === blockLen) { + for (; blockLen <= len - pos; pos += blockLen) + this.process(data, pos); + continue; + } + buffer.set(data.subarray(pos, pos + take), this.pos); + this.pos += take; + pos += take; + if (this.pos === blockLen) { + this.process(buffer, 0, false); + this.pos = 0; + } + } + return this; + } + destroy() { + this.h.fill(0); + this.r.fill(0); + this.buffer.fill(0); + this.pad.fill(0); + } + digestInto(out) { + exists3(this); + output3(out, this); + this.finished = true; + const { buffer, h } = this; + let { pos } = this; + if (pos) { + buffer[pos++] = 1; + for (; pos < 16; pos++) + buffer[pos] = 0; + this.process(buffer, 0, true); + } + this.finalize(); + let opos = 0; + for (let i3 = 0; i3 < 8; i3++) { + out[opos++] = h[i3] >>> 0; + out[opos++] = h[i3] >>> 8; + } + return out; + } + digest() { + const { buffer, outputLen } = this; + this.digestInto(buffer); + const res = buffer.slice(0, outputLen); + this.destroy(); + return res; + } +}; +function wrapConstructorWithKey2(hashCons) { + const hashC = (msg, key) => hashCons(key).update(toBytes3(msg)).digest(); + const tmp = hashCons(new Uint8Array(32)); + hashC.outputLen = tmp.outputLen; + hashC.blockLen = tmp.blockLen; + hashC.create = (key) => hashCons(key); + return hashC; +} +var poly1305 = wrapConstructorWithKey2((key) => new Poly1305(key)); + +// node_modules/@noble/ciphers/esm/_arx.js +var _utf8ToBytes = (str) => Uint8Array.from(str.split("").map((c) => c.charCodeAt(0))); +var sigma16 = _utf8ToBytes("expand 16-byte k"); +var sigma32 = _utf8ToBytes("expand 32-byte k"); +var sigma16_32 = u32(sigma16); +var sigma32_32 = u32(sigma32); +var sigma = sigma32_32.slice(); +function rotl(a, b) { + return a << b | a >>> 32 - b; +} +function isAligned32(b) { + return b.byteOffset % 4 === 0; +} +var BLOCK_LEN = 64; +var BLOCK_LEN32 = 16; +var MAX_COUNTER = 2 ** 32 - 1; +var U32_EMPTY = new Uint32Array(); +function runCipher(core, sigma2, key, nonce, data, output4, counter, rounds) { + const len = data.length; + const block = new Uint8Array(BLOCK_LEN); + const b32 = u32(block); + const isAligned = isAligned32(data) && isAligned32(output4); + const d32 = isAligned ? u32(data) : U32_EMPTY; + const o32 = isAligned ? u32(output4) : U32_EMPTY; + for (let pos = 0; pos < len; counter++) { + core(sigma2, key, nonce, b32, counter, rounds); + if (counter >= MAX_COUNTER) + throw new Error("arx: counter overflow"); + const take = Math.min(BLOCK_LEN, len - pos); + if (isAligned && take === BLOCK_LEN) { + const pos32 = pos / 4; + if (pos % 4 !== 0) + throw new Error("arx: invalid block position"); + for (let j = 0, posj; j < BLOCK_LEN32; j++) { + posj = pos32 + j; + o32[posj] = d32[posj] ^ b32[j]; + } + pos += BLOCK_LEN; + continue; + } + for (let j = 0, posj; j < take; j++) { + posj = pos + j; + output4[posj] = data[posj] ^ block[j]; + } + pos += take; + } +} +function createCipher(core, opts) { + const { allowShortKeys, extendNonceFn, counterLength, counterRight, rounds } = checkOpts({ allowShortKeys: false, counterLength: 8, counterRight: false, rounds: 20 }, opts); + if (typeof core !== "function") + throw new Error("core must be a function"); + number3(counterLength); + number3(rounds); + bool2(counterRight); + bool2(allowShortKeys); + return (key, nonce, data, output4, counter = 0) => { + bytes3(key); + bytes3(nonce); + bytes3(data); + const len = data.length; + if (!output4) + output4 = new Uint8Array(len); + bytes3(output4); + number3(counter); + if (counter < 0 || counter >= MAX_COUNTER) + throw new Error("arx: counter overflow"); + if (output4.length < len) + throw new Error(`arx: output (${output4.length}) is shorter than data (${len})`); + const toClean = []; + let l = key.length, k, sigma2; + if (l === 32) { + k = key.slice(); + toClean.push(k); + sigma2 = sigma32_32; + } else if (l === 16 && allowShortKeys) { + k = new Uint8Array(32); + k.set(key); + k.set(key, 16); + sigma2 = sigma16_32; + toClean.push(k); + } else { + throw new Error(`arx: invalid 32-byte key, got length=${l}`); + } + if (!isAligned32(nonce)) { + nonce = nonce.slice(); + toClean.push(nonce); + } + const k32 = u32(k); + if (extendNonceFn) { + if (nonce.length !== 24) + throw new Error(`arx: extended nonce must be 24 bytes`); + extendNonceFn(sigma2, k32, u32(nonce.subarray(0, 16)), k32); + nonce = nonce.subarray(16); + } + const nonceNcLen = 16 - counterLength; + if (nonceNcLen !== nonce.length) + throw new Error(`arx: nonce must be ${nonceNcLen} or 16 bytes`); + if (nonceNcLen !== 12) { + const nc3 = new Uint8Array(12); + nc3.set(nonce, counterRight ? 0 : 12 - nonce.length); + nonce = nc3; + toClean.push(nonce); + } + const n32 = u32(nonce); + runCipher(core, sigma2, k32, n32, data, output4, counter, rounds); + while (toClean.length > 0) + toClean.pop().fill(0); + return output4; + }; +} + +// node_modules/@noble/ciphers/esm/chacha.js +function chachaCore(s, k, n, out, cnt, rounds = 20) { + let y00 = s[0], y01 = s[1], y02 = s[2], y03 = s[3], y04 = k[0], y05 = k[1], y06 = k[2], y07 = k[3], y08 = k[4], y09 = k[5], y10 = k[6], y11 = k[7], y12 = cnt, y13 = n[0], y14 = n[1], y15 = n[2]; + let x00 = y00, x01 = y01, x02 = y02, x03 = y03, x04 = y04, x05 = y05, x06 = y06, x07 = y07, x08 = y08, x09 = y09, x10 = y10, x11 = y11, x12 = y12, x13 = y13, x14 = y14, x15 = y15; + for (let r = 0; r < rounds; r += 2) { + x00 = x00 + x04 | 0; + x12 = rotl(x12 ^ x00, 16); + x08 = x08 + x12 | 0; + x04 = rotl(x04 ^ x08, 12); + x00 = x00 + x04 | 0; + x12 = rotl(x12 ^ x00, 8); + x08 = x08 + x12 | 0; + x04 = rotl(x04 ^ x08, 7); + x01 = x01 + x05 | 0; + x13 = rotl(x13 ^ x01, 16); + x09 = x09 + x13 | 0; + x05 = rotl(x05 ^ x09, 12); + x01 = x01 + x05 | 0; + x13 = rotl(x13 ^ x01, 8); + x09 = x09 + x13 | 0; + x05 = rotl(x05 ^ x09, 7); + x02 = x02 + x06 | 0; + x14 = rotl(x14 ^ x02, 16); + x10 = x10 + x14 | 0; + x06 = rotl(x06 ^ x10, 12); + x02 = x02 + x06 | 0; + x14 = rotl(x14 ^ x02, 8); + x10 = x10 + x14 | 0; + x06 = rotl(x06 ^ x10, 7); + x03 = x03 + x07 | 0; + x15 = rotl(x15 ^ x03, 16); + x11 = x11 + x15 | 0; + x07 = rotl(x07 ^ x11, 12); + x03 = x03 + x07 | 0; + x15 = rotl(x15 ^ x03, 8); + x11 = x11 + x15 | 0; + x07 = rotl(x07 ^ x11, 7); + x00 = x00 + x05 | 0; + x15 = rotl(x15 ^ x00, 16); + x10 = x10 + x15 | 0; + x05 = rotl(x05 ^ x10, 12); + x00 = x00 + x05 | 0; + x15 = rotl(x15 ^ x00, 8); + x10 = x10 + x15 | 0; + x05 = rotl(x05 ^ x10, 7); + x01 = x01 + x06 | 0; + x12 = rotl(x12 ^ x01, 16); + x11 = x11 + x12 | 0; + x06 = rotl(x06 ^ x11, 12); + x01 = x01 + x06 | 0; + x12 = rotl(x12 ^ x01, 8); + x11 = x11 + x12 | 0; + x06 = rotl(x06 ^ x11, 7); + x02 = x02 + x07 | 0; + x13 = rotl(x13 ^ x02, 16); + x08 = x08 + x13 | 0; + x07 = rotl(x07 ^ x08, 12); + x02 = x02 + x07 | 0; + x13 = rotl(x13 ^ x02, 8); + x08 = x08 + x13 | 0; + x07 = rotl(x07 ^ x08, 7); + x03 = x03 + x04 | 0; + x14 = rotl(x14 ^ x03, 16); + x09 = x09 + x14 | 0; + x04 = rotl(x04 ^ x09, 12); + x03 = x03 + x04 | 0; + x14 = rotl(x14 ^ x03, 8); + x09 = x09 + x14 | 0; + x04 = rotl(x04 ^ x09, 7); + } + let oi = 0; + out[oi++] = y00 + x00 | 0; + out[oi++] = y01 + x01 | 0; + out[oi++] = y02 + x02 | 0; + out[oi++] = y03 + x03 | 0; + out[oi++] = y04 + x04 | 0; + out[oi++] = y05 + x05 | 0; + out[oi++] = y06 + x06 | 0; + out[oi++] = y07 + x07 | 0; + out[oi++] = y08 + x08 | 0; + out[oi++] = y09 + x09 | 0; + out[oi++] = y10 + x10 | 0; + out[oi++] = y11 + x11 | 0; + out[oi++] = y12 + x12 | 0; + out[oi++] = y13 + x13 | 0; + out[oi++] = y14 + x14 | 0; + out[oi++] = y15 + x15 | 0; +} +function hchacha(s, k, i3, o32) { + let x00 = s[0], x01 = s[1], x02 = s[2], x03 = s[3], x04 = k[0], x05 = k[1], x06 = k[2], x07 = k[3], x08 = k[4], x09 = k[5], x10 = k[6], x11 = k[7], x12 = i3[0], x13 = i3[1], x14 = i3[2], x15 = i3[3]; + for (let r = 0; r < 20; r += 2) { + x00 = x00 + x04 | 0; + x12 = rotl(x12 ^ x00, 16); + x08 = x08 + x12 | 0; + x04 = rotl(x04 ^ x08, 12); + x00 = x00 + x04 | 0; + x12 = rotl(x12 ^ x00, 8); + x08 = x08 + x12 | 0; + x04 = rotl(x04 ^ x08, 7); + x01 = x01 + x05 | 0; + x13 = rotl(x13 ^ x01, 16); + x09 = x09 + x13 | 0; + x05 = rotl(x05 ^ x09, 12); + x01 = x01 + x05 | 0; + x13 = rotl(x13 ^ x01, 8); + x09 = x09 + x13 | 0; + x05 = rotl(x05 ^ x09, 7); + x02 = x02 + x06 | 0; + x14 = rotl(x14 ^ x02, 16); + x10 = x10 + x14 | 0; + x06 = rotl(x06 ^ x10, 12); + x02 = x02 + x06 | 0; + x14 = rotl(x14 ^ x02, 8); + x10 = x10 + x14 | 0; + x06 = rotl(x06 ^ x10, 7); + x03 = x03 + x07 | 0; + x15 = rotl(x15 ^ x03, 16); + x11 = x11 + x15 | 0; + x07 = rotl(x07 ^ x11, 12); + x03 = x03 + x07 | 0; + x15 = rotl(x15 ^ x03, 8); + x11 = x11 + x15 | 0; + x07 = rotl(x07 ^ x11, 7); + x00 = x00 + x05 | 0; + x15 = rotl(x15 ^ x00, 16); + x10 = x10 + x15 | 0; + x05 = rotl(x05 ^ x10, 12); + x00 = x00 + x05 | 0; + x15 = rotl(x15 ^ x00, 8); + x10 = x10 + x15 | 0; + x05 = rotl(x05 ^ x10, 7); + x01 = x01 + x06 | 0; + x12 = rotl(x12 ^ x01, 16); + x11 = x11 + x12 | 0; + x06 = rotl(x06 ^ x11, 12); + x01 = x01 + x06 | 0; + x12 = rotl(x12 ^ x01, 8); + x11 = x11 + x12 | 0; + x06 = rotl(x06 ^ x11, 7); + x02 = x02 + x07 | 0; + x13 = rotl(x13 ^ x02, 16); + x08 = x08 + x13 | 0; + x07 = rotl(x07 ^ x08, 12); + x02 = x02 + x07 | 0; + x13 = rotl(x13 ^ x02, 8); + x08 = x08 + x13 | 0; + x07 = rotl(x07 ^ x08, 7); + x03 = x03 + x04 | 0; + x14 = rotl(x14 ^ x03, 16); + x09 = x09 + x14 | 0; + x04 = rotl(x04 ^ x09, 12); + x03 = x03 + x04 | 0; + x14 = rotl(x14 ^ x03, 8); + x09 = x09 + x14 | 0; + x04 = rotl(x04 ^ x09, 7); + } + let oi = 0; + o32[oi++] = x00; + o32[oi++] = x01; + o32[oi++] = x02; + o32[oi++] = x03; + o32[oi++] = x12; + o32[oi++] = x13; + o32[oi++] = x14; + o32[oi++] = x15; +} +var chacha20 = /* @__PURE__ */ createCipher(chachaCore, { + counterRight: false, + counterLength: 4, + allowShortKeys: false +}); +var xchacha20 = /* @__PURE__ */ createCipher(chachaCore, { + counterRight: false, + counterLength: 8, + extendNonceFn: hchacha, + allowShortKeys: false +}); +var ZEROS162 = /* @__PURE__ */ new Uint8Array(16); +var updatePadded = (h, msg) => { + h.update(msg); + const left = msg.length % 16; + if (left) + h.update(ZEROS162.subarray(left)); +}; +var ZEROS322 = /* @__PURE__ */ new Uint8Array(32); +function computeTag2(fn, key, nonce, data, AAD) { + const authKey = fn(key, nonce, ZEROS322); + const h = poly1305.create(authKey); + if (AAD) + updatePadded(h, AAD); + updatePadded(h, data); + const num = new Uint8Array(16); + const view = createView3(num); + setBigUint643(view, 0, BigInt(AAD ? AAD.length : 0), true); + setBigUint643(view, 8, BigInt(data.length), true); + h.update(num); + const res = h.digest(); + authKey.fill(0); + return res; +} +var _poly1305_aead = (xorStream) => (key, nonce, AAD) => { + const tagLength = 16; + bytes3(key, 32); + bytes3(nonce); + return { + encrypt: (plaintext, output4) => { + const plength = plaintext.length; + const clength = plength + tagLength; + if (output4) { + bytes3(output4, clength); + } else { + output4 = new Uint8Array(clength); + } + xorStream(key, nonce, plaintext, output4, 1); + const tag = computeTag2(xorStream, key, nonce, output4.subarray(0, -tagLength), AAD); + output4.set(tag, plength); + return output4; + }, + decrypt: (ciphertext, output4) => { + const clength = ciphertext.length; + const plength = clength - tagLength; + if (clength < tagLength) + throw new Error(`encrypted data must be at least ${tagLength} bytes`); + if (output4) { + bytes3(output4, plength); + } else { + output4 = new Uint8Array(plength); + } + const data = ciphertext.subarray(0, -tagLength); + const passedTag = ciphertext.subarray(-tagLength); + const tag = computeTag2(xorStream, key, nonce, data, AAD); + if (!equalBytes2(passedTag, tag)) + throw new Error("invalid tag"); + xorStream(key, nonce, data, output4, 1); + return output4; + } + }; +}; +var chacha20poly1305 = /* @__PURE__ */ wrapCipher({ blockSize: 64, nonceLength: 12, tagLength: 16 }, _poly1305_aead(chacha20)); +var xchacha20poly1305 = /* @__PURE__ */ wrapCipher({ blockSize: 64, nonceLength: 24, tagLength: 16 }, _poly1305_aead(xchacha20)); + +// node_modules/@noble/hashes/esm/hmac.js +var HMAC2 = class extends Hash2 { + constructor(hash3, _key) { + super(); + this.finished = false; + this.destroyed = false; + assert_default.hash(hash3); + const key = toBytes2(_key); + this.iHash = hash3.create(); + if (typeof this.iHash.update !== "function") + throw new Error("Expected instance of class which extends utils.Hash"); + this.blockLen = this.iHash.blockLen; + this.outputLen = this.iHash.outputLen; + const blockLen = this.blockLen; + const pad2 = new Uint8Array(blockLen); + pad2.set(key.length > blockLen ? hash3.create().update(key).digest() : key); + for (let i3 = 0; i3 < pad2.length; i3++) + pad2[i3] ^= 54; + this.iHash.update(pad2); + this.oHash = hash3.create(); + for (let i3 = 0; i3 < pad2.length; i3++) + pad2[i3] ^= 54 ^ 92; + this.oHash.update(pad2); + pad2.fill(0); + } + update(buf) { + assert_default.exists(this); + this.iHash.update(buf); + return this; + } + digestInto(out) { + assert_default.exists(this); + assert_default.bytes(out, this.outputLen); + this.finished = true; + this.iHash.digestInto(out); + this.oHash.update(out); + this.oHash.digestInto(out); + this.destroy(); + } + digest() { + const out = new Uint8Array(this.oHash.outputLen); + this.digestInto(out); + return out; + } + _cloneInto(to) { + to || (to = Object.create(Object.getPrototypeOf(this), {})); + const { oHash, iHash, finished, destroyed, blockLen, outputLen } = this; + to = to; + to.finished = finished; + to.destroyed = destroyed; + to.blockLen = blockLen; + to.outputLen = outputLen; + to.oHash = oHash._cloneInto(to.oHash); + to.iHash = iHash._cloneInto(to.iHash); + return to; + } + destroy() { + this.destroyed = true; + this.oHash.destroy(); + this.iHash.destroy(); + } +}; +var hmac2 = (hash3, key, message) => new HMAC2(hash3, key).update(message).digest(); +hmac2.create = (hash3, key) => new HMAC2(hash3, key); + +// node_modules/@noble/hashes/esm/hkdf.js +function extract(hash3, ikm, salt) { + assert_default.hash(hash3); + if (salt === void 0) + salt = new Uint8Array(hash3.outputLen); + return hmac2(hash3, toBytes2(salt), toBytes2(ikm)); +} +var HKDF_COUNTER = new Uint8Array([0]); +var EMPTY_BUFFER = new Uint8Array(); +function expand(hash3, prk, info, length = 32) { + assert_default.hash(hash3); + assert_default.number(length); + if (length > 255 * hash3.outputLen) + throw new Error("Length should be <= 255*HashLen"); + const blocks = Math.ceil(length / hash3.outputLen); + if (info === void 0) + info = EMPTY_BUFFER; + const okm = new Uint8Array(blocks * hash3.outputLen); + const HMAC3 = hmac2.create(hash3, prk); + const HMACTmp = HMAC3._cloneInto(); + const T = new Uint8Array(HMAC3.outputLen); + for (let counter = 0; counter < blocks; counter++) { + HKDF_COUNTER[0] = counter + 1; + HMACTmp.update(counter === 0 ? EMPTY_BUFFER : T).update(info).update(HKDF_COUNTER).digestInto(T); + okm.set(T, hash3.outputLen * counter); + HMAC3._cloneInto(HMACTmp); + } + HMAC3.destroy(); + HMACTmp.destroy(); + T.fill(0); + HKDF_COUNTER.fill(0); + return okm.slice(0, length); +} + +// node_modules/nostr-tools/lib/esm/index.js +var __defProp2 = Object.defineProperty; +var __export2 = (target, all) => { + for (var name in all) + __defProp2(target, name, { get: all[name], enumerable: true }); +}; +var verifiedSymbol = Symbol("verified"); +var isRecord = (obj) => obj instanceof Object; +function validateEvent(event) { + if (!isRecord(event)) + return false; + if (typeof event.kind !== "number") + return false; + if (typeof event.content !== "string") + return false; + if (typeof event.created_at !== "number") + return false; + if (typeof event.pubkey !== "string") + return false; + if (!event.pubkey.match(/^[a-f0-9]{64}$/)) + return false; + if (!Array.isArray(event.tags)) + return false; + for (let i22 = 0; i22 < event.tags.length; i22++) { + let tag = event.tags[i22]; + if (!Array.isArray(tag)) + return false; + for (let j = 0; j < tag.length; j++) { + if (typeof tag[j] !== "string") + return false; + } + } + return true; +} +var utils_exports2 = {}; +__export2(utils_exports2, { + Queue: () => Queue, + QueueNode: () => QueueNode, + binarySearch: () => binarySearch, + insertEventIntoAscendingList: () => insertEventIntoAscendingList, + insertEventIntoDescendingList: () => insertEventIntoDescendingList, + normalizeURL: () => normalizeURL, + utf8Decoder: () => utf8Decoder, + utf8Encoder: () => utf8Encoder +}); +var utf8Decoder = new TextDecoder("utf-8"); +var utf8Encoder = new TextEncoder(); +function normalizeURL(url) { + try { + if (url.indexOf("://") === -1) + url = "wss://" + url; + let p = new URL(url); + p.pathname = p.pathname.replace(/\/+/g, "/"); + if (p.pathname.endsWith("/")) + p.pathname = p.pathname.slice(0, -1); + if (p.port === "80" && p.protocol === "ws:" || p.port === "443" && p.protocol === "wss:") + p.port = ""; + p.searchParams.sort(); + p.hash = ""; + return p.toString(); + } catch (e) { + throw new Error(`Invalid URL: ${url}`); + } +} +function insertEventIntoDescendingList(sortedArray, event) { + const [idx, found] = binarySearch(sortedArray, (b) => { + if (event.id === b.id) + return 0; + if (event.created_at === b.created_at) + return -1; + return b.created_at - event.created_at; + }); + if (!found) { + sortedArray.splice(idx, 0, event); + } + return sortedArray; +} +function insertEventIntoAscendingList(sortedArray, event) { + const [idx, found] = binarySearch(sortedArray, (b) => { + if (event.id === b.id) + return 0; + if (event.created_at === b.created_at) + return -1; + return event.created_at - b.created_at; + }); + if (!found) { + sortedArray.splice(idx, 0, event); + } + return sortedArray; +} +function binarySearch(arr, compare) { + let start = 0; + let end = arr.length - 1; + while (start <= end) { + const mid = Math.floor((start + end) / 2); + const cmp = compare(arr[mid]); + if (cmp === 0) { + return [mid, true]; + } + if (cmp < 0) { + end = mid - 1; + } else { + start = mid + 1; + } + } + return [start, false]; +} +var QueueNode = class { + value; + next = null; + prev = null; + constructor(message) { + this.value = message; + } +}; +var Queue = class { + first; + last; + constructor() { + this.first = null; + this.last = null; + } + enqueue(value) { + const newNode = new QueueNode(value); + if (!this.last) { + this.first = newNode; + this.last = newNode; + } else if (this.last === this.first) { + this.last = newNode; + this.last.prev = this.first; + this.first.next = newNode; + } else { + newNode.prev = this.last; + this.last.next = newNode; + this.last = newNode; + } + return true; + } + dequeue() { + if (!this.first) + return null; + if (this.first === this.last) { + const target2 = this.first; + this.first = null; + this.last = null; + return target2.value; + } + const target = this.first; + this.first = target.next; + if (this.first) { + this.first.prev = null; + } + return target.value; + } +}; +var JS = class { + generateSecretKey() { + return schnorr.utils.randomPrivateKey(); + } + getPublicKey(secretKey) { + return bytesToHex2(schnorr.getPublicKey(secretKey)); + } + finalizeEvent(t, secretKey) { + const event = t; + event.pubkey = bytesToHex2(schnorr.getPublicKey(secretKey)); + event.id = getEventHash(event); + event.sig = bytesToHex2(schnorr.sign(getEventHash(event), secretKey)); + event[verifiedSymbol] = true; + return event; + } + verifyEvent(event) { + if (typeof event[verifiedSymbol] === "boolean") + return event[verifiedSymbol]; + const hash3 = getEventHash(event); + if (hash3 !== event.id) { + event[verifiedSymbol] = false; + return false; + } + try { + const valid = schnorr.verify(event.sig, hash3, event.pubkey); + event[verifiedSymbol] = valid; + return valid; + } catch (err) { + event[verifiedSymbol] = false; + return false; + } + } +}; +function serializeEvent(evt) { + if (!validateEvent(evt)) + throw new Error("can't serialize event with wrong or missing properties"); + return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]); +} +function getEventHash(event) { + let eventHash = sha2562(utf8Encoder.encode(serializeEvent(event))); + return bytesToHex2(eventHash); +} +var i = new JS(); +var generateSecretKey = i.generateSecretKey; +var getPublicKey = i.getPublicKey; +var finalizeEvent = i.finalizeEvent; +var verifyEvent = i.verifyEvent; +var kinds_exports = {}; +__export2(kinds_exports, { + Application: () => Application, + BadgeAward: () => BadgeAward, + BadgeDefinition: () => BadgeDefinition, + BlockedRelaysList: () => BlockedRelaysList, + BookmarkList: () => BookmarkList, + Bookmarksets: () => Bookmarksets, + Calendar: () => Calendar, + CalendarEventRSVP: () => CalendarEventRSVP, + ChannelCreation: () => ChannelCreation, + ChannelHideMessage: () => ChannelHideMessage, + ChannelMessage: () => ChannelMessage, + ChannelMetadata: () => ChannelMetadata, + ChannelMuteUser: () => ChannelMuteUser, + ClassifiedListing: () => ClassifiedListing, + ClientAuth: () => ClientAuth, + CommunitiesList: () => CommunitiesList, + CommunityDefinition: () => CommunityDefinition, + CommunityPostApproval: () => CommunityPostApproval, + Contacts: () => Contacts, + CreateOrUpdateProduct: () => CreateOrUpdateProduct, + CreateOrUpdateStall: () => CreateOrUpdateStall, + Curationsets: () => Curationsets, + Date: () => Date2, + DirectMessageRelaysList: () => DirectMessageRelaysList, + DraftClassifiedListing: () => DraftClassifiedListing, + DraftLong: () => DraftLong, + Emojisets: () => Emojisets, + EncryptedDirectMessage: () => EncryptedDirectMessage, + EventDeletion: () => EventDeletion, + FileMetadata: () => FileMetadata, + FileServerPreference: () => FileServerPreference, + Followsets: () => Followsets, + GenericRepost: () => GenericRepost, + Genericlists: () => Genericlists, + GiftWrap: () => GiftWrap, + HTTPAuth: () => HTTPAuth, + Handlerinformation: () => Handlerinformation, + Handlerrecommendation: () => Handlerrecommendation, + Highlights: () => Highlights, + InterestsList: () => InterestsList, + Interestsets: () => Interestsets, + JobFeedback: () => JobFeedback, + JobRequest: () => JobRequest, + JobResult: () => JobResult, + Label: () => Label, + LightningPubRPC: () => LightningPubRPC, + LiveChatMessage: () => LiveChatMessage, + LiveEvent: () => LiveEvent, + LongFormArticle: () => LongFormArticle, + Metadata: () => Metadata, + Mutelist: () => Mutelist, + NWCWalletInfo: () => NWCWalletInfo, + NWCWalletRequest: () => NWCWalletRequest, + NWCWalletResponse: () => NWCWalletResponse, + NostrConnect: () => NostrConnect, + OpenTimestamps: () => OpenTimestamps, + Pinlist: () => Pinlist, + PrivateDirectMessage: () => PrivateDirectMessage, + ProblemTracker: () => ProblemTracker, + ProfileBadges: () => ProfileBadges, + PublicChatsList: () => PublicChatsList, + Reaction: () => Reaction, + RecommendRelay: () => RecommendRelay, + RelayList: () => RelayList, + Relaysets: () => Relaysets, + Report: () => Report, + Reporting: () => Reporting, + Repost: () => Repost, + Seal: () => Seal, + SearchRelaysList: () => SearchRelaysList, + ShortTextNote: () => ShortTextNote, + Time: () => Time, + UserEmojiList: () => UserEmojiList, + UserStatuses: () => UserStatuses, + Zap: () => Zap, + ZapGoal: () => ZapGoal, + ZapRequest: () => ZapRequest, + classifyKind: () => classifyKind, + isAddressableKind: () => isAddressableKind, + isEphemeralKind: () => isEphemeralKind, + isKind: () => isKind, + isParameterizedReplaceableKind: () => isParameterizedReplaceableKind, + isRegularKind: () => isRegularKind, + isReplaceableKind: () => isReplaceableKind +}); +function isRegularKind(kind) { + return 1e3 <= kind && kind < 1e4 || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind); +} +function isReplaceableKind(kind) { + return [0, 3].includes(kind) || 1e4 <= kind && kind < 2e4; +} +function isEphemeralKind(kind) { + return 2e4 <= kind && kind < 3e4; +} +function isAddressableKind(kind) { + return 3e4 <= kind && kind < 4e4; +} +var isParameterizedReplaceableKind = isAddressableKind; +function classifyKind(kind) { + if (isRegularKind(kind)) + return "regular"; + if (isReplaceableKind(kind)) + return "replaceable"; + if (isEphemeralKind(kind)) + return "ephemeral"; + if (isAddressableKind(kind)) + return "parameterized"; + return "unknown"; +} +function isKind(event, kind) { + const kindAsArray = kind instanceof Array ? kind : [kind]; + return validateEvent(event) && kindAsArray.includes(event.kind) || false; +} +var Metadata = 0; +var ShortTextNote = 1; +var RecommendRelay = 2; +var Contacts = 3; +var EncryptedDirectMessage = 4; +var EventDeletion = 5; +var Repost = 6; +var Reaction = 7; +var BadgeAward = 8; +var Seal = 13; +var PrivateDirectMessage = 14; +var GenericRepost = 16; +var ChannelCreation = 40; +var ChannelMetadata = 41; +var ChannelMessage = 42; +var ChannelHideMessage = 43; +var ChannelMuteUser = 44; +var OpenTimestamps = 1040; +var GiftWrap = 1059; +var FileMetadata = 1063; +var LiveChatMessage = 1311; +var ProblemTracker = 1971; +var Report = 1984; +var Reporting = 1984; +var Label = 1985; +var CommunityPostApproval = 4550; +var JobRequest = 5999; +var JobResult = 6999; +var JobFeedback = 7e3; +var ZapGoal = 9041; +var ZapRequest = 9734; +var Zap = 9735; +var Highlights = 9802; +var Mutelist = 1e4; +var Pinlist = 10001; +var RelayList = 10002; +var BookmarkList = 10003; +var CommunitiesList = 10004; +var PublicChatsList = 10005; +var BlockedRelaysList = 10006; +var SearchRelaysList = 10007; +var InterestsList = 10015; +var UserEmojiList = 10030; +var DirectMessageRelaysList = 10050; +var FileServerPreference = 10096; +var NWCWalletInfo = 13194; +var LightningPubRPC = 21e3; +var ClientAuth = 22242; +var NWCWalletRequest = 23194; +var NWCWalletResponse = 23195; +var NostrConnect = 24133; +var HTTPAuth = 27235; +var Followsets = 3e4; +var Genericlists = 30001; +var Relaysets = 30002; +var Bookmarksets = 30003; +var Curationsets = 30004; +var ProfileBadges = 30008; +var BadgeDefinition = 30009; +var Interestsets = 30015; +var CreateOrUpdateStall = 30017; +var CreateOrUpdateProduct = 30018; +var LongFormArticle = 30023; +var DraftLong = 30024; +var Emojisets = 30030; +var Application = 30078; +var LiveEvent = 30311; +var UserStatuses = 30315; +var ClassifiedListing = 30402; +var DraftClassifiedListing = 30403; +var Date2 = 31922; +var Time = 31923; +var Calendar = 31924; +var CalendarEventRSVP = 31925; +var Handlerrecommendation = 31989; +var Handlerinformation = 31990; +var CommunityDefinition = 34550; +var fakejson_exports = {}; +__export2(fakejson_exports, { + getHex64: () => getHex64, + getInt: () => getInt, + getSubscriptionId: () => getSubscriptionId, + matchEventId: () => matchEventId, + matchEventKind: () => matchEventKind, + matchEventPubkey: () => matchEventPubkey +}); +function getHex64(json, field) { + let len = field.length + 3; + let idx = json.indexOf(`"${field}":`) + len; + let s = json.slice(idx).indexOf(`"`) + idx + 1; + return json.slice(s, s + 64); +} +function getInt(json, field) { + let len = field.length; + let idx = json.indexOf(`"${field}":`) + len + 3; + let sliced = json.slice(idx); + let end = Math.min(sliced.indexOf(","), sliced.indexOf("}")); + return parseInt(sliced.slice(0, end), 10); +} +function getSubscriptionId(json) { + let idx = json.slice(0, 22).indexOf(`"EVENT"`); + if (idx === -1) + return null; + let pstart = json.slice(idx + 7 + 1).indexOf(`"`); + if (pstart === -1) + return null; + let start = idx + 7 + 1 + pstart; + let pend = json.slice(start + 1, 80).indexOf(`"`); + if (pend === -1) + return null; + let end = start + 1 + pend; + return json.slice(start + 1, end); +} +function matchEventId(json, id) { + return id === getHex64(json, "id"); +} +function matchEventPubkey(json, pubkey) { + return pubkey === getHex64(json, "pubkey"); +} +function matchEventKind(json, kind) { + return kind === getInt(json, "kind"); +} +var nip42_exports = {}; +__export2(nip42_exports, { + makeAuthEvent: () => makeAuthEvent +}); +function makeAuthEvent(relayURL, challenge2) { + return { + kind: ClientAuth, + created_at: Math.floor(Date.now() / 1e3), + tags: [ + ["relay", relayURL], + ["challenge", challenge2] + ], + content: "" + }; +} +var _WebSocket; +try { + _WebSocket = WebSocket; +} catch { +} +var _WebSocket2; +try { + _WebSocket2 = WebSocket; +} catch { +} +var nip19_exports = {}; +__export2(nip19_exports, { + BECH32_REGEX: () => BECH32_REGEX, + Bech32MaxSize: () => Bech32MaxSize, + NostrTypeGuard: () => NostrTypeGuard, + decode: () => decode, + decodeNostrURI: () => decodeNostrURI, + encodeBytes: () => encodeBytes, + naddrEncode: () => naddrEncode, + neventEncode: () => neventEncode, + noteEncode: () => noteEncode, + nprofileEncode: () => nprofileEncode, + npubEncode: () => npubEncode, + nsecEncode: () => nsecEncode +}); +var NostrTypeGuard = { + isNProfile: (value) => /^nprofile1[a-z\d]+$/.test(value || ""), + isNEvent: (value) => /^nevent1[a-z\d]+$/.test(value || ""), + isNAddr: (value) => /^naddr1[a-z\d]+$/.test(value || ""), + isNSec: (value) => /^nsec1[a-z\d]{58}$/.test(value || ""), + isNPub: (value) => /^npub1[a-z\d]{58}$/.test(value || ""), + isNote: (value) => /^note1[a-z\d]+$/.test(value || ""), + isNcryptsec: (value) => /^ncryptsec1[a-z\d]+$/.test(value || "") +}; +var Bech32MaxSize = 5e3; +var BECH32_REGEX = /[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/; +function integerToUint8Array(number4) { + const uint8Array = new Uint8Array(4); + uint8Array[0] = number4 >> 24 & 255; + uint8Array[1] = number4 >> 16 & 255; + uint8Array[2] = number4 >> 8 & 255; + uint8Array[3] = number4 & 255; + return uint8Array; +} +function decodeNostrURI(nip19code) { + try { + if (nip19code.startsWith("nostr:")) + nip19code = nip19code.substring(6); + return decode(nip19code); + } catch (_err) { + return { type: "invalid", data: null }; + } +} +function decode(code) { + let { prefix, words } = bech32.decode(code, Bech32MaxSize); + let data = new Uint8Array(bech32.fromWords(words)); + switch (prefix) { + case "nprofile": { + let tlv = parseTLV(data); + if (!tlv[0]?.[0]) + throw new Error("missing TLV 0 for nprofile"); + if (tlv[0][0].length !== 32) + throw new Error("TLV 0 should be 32 bytes"); + return { + type: "nprofile", + data: { + pubkey: bytesToHex2(tlv[0][0]), + relays: tlv[1] ? tlv[1].map((d) => utf8Decoder.decode(d)) : [] + } + }; + } + case "nevent": { + let tlv = parseTLV(data); + if (!tlv[0]?.[0]) + throw new Error("missing TLV 0 for nevent"); + if (tlv[0][0].length !== 32) + throw new Error("TLV 0 should be 32 bytes"); + if (tlv[2] && tlv[2][0].length !== 32) + throw new Error("TLV 2 should be 32 bytes"); + if (tlv[3] && tlv[3][0].length !== 4) + throw new Error("TLV 3 should be 4 bytes"); + return { + type: "nevent", + data: { + id: bytesToHex2(tlv[0][0]), + relays: tlv[1] ? tlv[1].map((d) => utf8Decoder.decode(d)) : [], + author: tlv[2]?.[0] ? bytesToHex2(tlv[2][0]) : void 0, + kind: tlv[3]?.[0] ? parseInt(bytesToHex2(tlv[3][0]), 16) : void 0 + } + }; + } + case "naddr": { + let tlv = parseTLV(data); + if (!tlv[0]?.[0]) + throw new Error("missing TLV 0 for naddr"); + if (!tlv[2]?.[0]) + throw new Error("missing TLV 2 for naddr"); + if (tlv[2][0].length !== 32) + throw new Error("TLV 2 should be 32 bytes"); + if (!tlv[3]?.[0]) + throw new Error("missing TLV 3 for naddr"); + if (tlv[3][0].length !== 4) + throw new Error("TLV 3 should be 4 bytes"); + return { + type: "naddr", + data: { + identifier: utf8Decoder.decode(tlv[0][0]), + pubkey: bytesToHex2(tlv[2][0]), + kind: parseInt(bytesToHex2(tlv[3][0]), 16), + relays: tlv[1] ? tlv[1].map((d) => utf8Decoder.decode(d)) : [] + } + }; + } + case "nsec": + return { type: prefix, data }; + case "npub": + case "note": + return { type: prefix, data: bytesToHex2(data) }; + default: + throw new Error(`unknown prefix ${prefix}`); + } +} +function parseTLV(data) { + let result = {}; + let rest = data; + while (rest.length > 0) { + let t = rest[0]; + let l = rest[1]; + let v = rest.slice(2, 2 + l); + rest = rest.slice(2 + l); + if (v.length < l) + throw new Error(`not enough data to read on TLV ${t}`); + result[t] = result[t] || []; + result[t].push(v); + } + return result; +} +function nsecEncode(key) { + return encodeBytes("nsec", key); +} +function npubEncode(hex2) { + return encodeBytes("npub", hexToBytes2(hex2)); +} +function noteEncode(hex2) { + return encodeBytes("note", hexToBytes2(hex2)); +} +function encodeBech32(prefix, data) { + let words = bech32.toWords(data); + return bech32.encode(prefix, words, Bech32MaxSize); +} +function encodeBytes(prefix, bytes4) { + return encodeBech32(prefix, bytes4); +} +function nprofileEncode(profile) { + let data = encodeTLV({ + 0: [hexToBytes2(profile.pubkey)], + 1: (profile.relays || []).map((url) => utf8Encoder.encode(url)) + }); + return encodeBech32("nprofile", data); +} +function neventEncode(event) { + let kindArray; + if (event.kind !== void 0) { + kindArray = integerToUint8Array(event.kind); + } + let data = encodeTLV({ + 0: [hexToBytes2(event.id)], + 1: (event.relays || []).map((url) => utf8Encoder.encode(url)), + 2: event.author ? [hexToBytes2(event.author)] : [], + 3: kindArray ? [new Uint8Array(kindArray)] : [] + }); + return encodeBech32("nevent", data); +} +function naddrEncode(addr) { + let kind = new ArrayBuffer(4); + new DataView(kind).setUint32(0, addr.kind, false); + let data = encodeTLV({ + 0: [utf8Encoder.encode(addr.identifier)], + 1: (addr.relays || []).map((url) => utf8Encoder.encode(url)), + 2: [hexToBytes2(addr.pubkey)], + 3: [new Uint8Array(kind)] + }); + return encodeBech32("naddr", data); +} +function encodeTLV(tlv) { + let entries = []; + Object.entries(tlv).reverse().forEach(([t, vs]) => { + vs.forEach((v) => { + let entry = new Uint8Array(v.length + 2); + entry.set([parseInt(t)], 0); + entry.set([v.length], 1); + entry.set(v, 2); + entries.push(entry); + }); + }); + return concatBytes3(...entries); +} +var nip04_exports = {}; +__export2(nip04_exports, { + decrypt: () => decrypt2, + encrypt: () => encrypt2 +}); +function encrypt2(secretKey, pubkey, text) { + const privkey = secretKey instanceof Uint8Array ? bytesToHex2(secretKey) : secretKey; + const key = secp256k1.getSharedSecret(privkey, "02" + pubkey); + const normalizedKey = getNormalizedX(key); + let iv = Uint8Array.from(randomBytes2(16)); + let plaintext = utf8Encoder.encode(text); + let ciphertext = cbc(normalizedKey, iv).encrypt(plaintext); + let ctb64 = base64.encode(new Uint8Array(ciphertext)); + let ivb64 = base64.encode(new Uint8Array(iv.buffer)); + return `${ctb64}?iv=${ivb64}`; +} +function decrypt2(secretKey, pubkey, data) { + const privkey = secretKey instanceof Uint8Array ? bytesToHex2(secretKey) : secretKey; + let [ctb64, ivb64] = data.split("?iv="); + let key = secp256k1.getSharedSecret(privkey, "02" + pubkey); + let normalizedKey = getNormalizedX(key); + let iv = base64.decode(ivb64); + let ciphertext = base64.decode(ctb64); + let plaintext = cbc(normalizedKey, iv).decrypt(ciphertext); + return utf8Decoder.decode(plaintext); +} +function getNormalizedX(key) { + return key.slice(1, 33); +} +var nip05_exports = {}; +__export2(nip05_exports, { + NIP05_REGEX: () => NIP05_REGEX, + isNip05: () => isNip05, + isValid: () => isValid, + queryProfile: () => queryProfile, + searchDomain: () => searchDomain, + useFetchImplementation: () => useFetchImplementation +}); +var NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/; +var isNip05 = (value) => NIP05_REGEX.test(value || ""); +var _fetch; +try { + _fetch = fetch; +} catch (_) { + null; +} +function useFetchImplementation(fetchImplementation) { + _fetch = fetchImplementation; +} +async function searchDomain(domain, query = "") { + try { + const url = `https://${domain}/.well-known/nostr.json?name=${query}`; + const res = await _fetch(url, { redirect: "manual" }); + if (res.status !== 200) { + throw Error("Wrong response code"); + } + const json = await res.json(); + return json.names; + } catch (_) { + return {}; + } +} +async function queryProfile(fullname) { + const match = fullname.match(NIP05_REGEX); + if (!match) + return null; + const [, name = "_", domain] = match; + try { + const url = `https://${domain}/.well-known/nostr.json?name=${name}`; + const res = await _fetch(url, { redirect: "manual" }); + if (res.status !== 200) { + throw Error("Wrong response code"); + } + const json = await res.json(); + const pubkey = json.names[name]; + return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null; + } catch (_e) { + return null; + } +} +async function isValid(pubkey, nip05) { + const res = await queryProfile(nip05); + return res ? res.pubkey === pubkey : false; +} +var nip10_exports = {}; +__export2(nip10_exports, { + parse: () => parse +}); +function parse(event) { + const result = { + reply: void 0, + root: void 0, + mentions: [], + profiles: [], + quotes: [] + }; + let maybeParent; + let maybeRoot; + for (let i22 = event.tags.length - 1; i22 >= 0; i22--) { + const tag = event.tags[i22]; + if (tag[0] === "e" && tag[1]) { + const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag; + const eventPointer = { + id: eTagEventId, + relays: eTagRelayUrl ? [eTagRelayUrl] : [], + author: eTagAuthor + }; + if (eTagMarker === "root") { + result.root = eventPointer; + continue; + } + if (eTagMarker === "reply") { + result.reply = eventPointer; + continue; + } + if (eTagMarker === "mention") { + result.mentions.push(eventPointer); + continue; + } + if (!maybeParent) { + maybeParent = eventPointer; + } else { + maybeRoot = eventPointer; + } + result.mentions.push(eventPointer); + continue; + } + if (tag[0] === "q" && tag[1]) { + const [_, eTagEventId, eTagRelayUrl] = tag; + result.quotes.push({ + id: eTagEventId, + relays: eTagRelayUrl ? [eTagRelayUrl] : [] + }); + } + if (tag[0] === "p" && tag[1]) { + result.profiles.push({ + pubkey: tag[1], + relays: tag[2] ? [tag[2]] : [] + }); + continue; + } + } + if (!result.root) { + result.root = maybeRoot || maybeParent || result.reply; + } + if (!result.reply) { + result.reply = maybeParent || result.root; + } + ; + [result.reply, result.root].forEach((ref) => { + if (!ref) + return; + let idx = result.mentions.indexOf(ref); + if (idx !== -1) { + result.mentions.splice(idx, 1); + } + if (ref.author) { + let author = result.profiles.find((p) => p.pubkey === ref.author); + if (author && author.relays) { + if (!ref.relays) { + ref.relays = []; + } + author.relays.forEach((url) => { + if (ref.relays?.indexOf(url) === -1) + ref.relays.push(url); + }); + author.relays = ref.relays; + } + } + }); + result.mentions.forEach((ref) => { + if (ref.author) { + let author = result.profiles.find((p) => p.pubkey === ref.author); + if (author && author.relays) { + if (!ref.relays) { + ref.relays = []; + } + author.relays.forEach((url) => { + if (ref.relays.indexOf(url) === -1) + ref.relays.push(url); + }); + author.relays = ref.relays; + } + } + }); + return result; +} +var nip11_exports = {}; +__export2(nip11_exports, { + fetchRelayInformation: () => fetchRelayInformation, + useFetchImplementation: () => useFetchImplementation2 +}); +var _fetch2; +try { + _fetch2 = fetch; +} catch { +} +function useFetchImplementation2(fetchImplementation) { + _fetch2 = fetchImplementation; +} +async function fetchRelayInformation(url) { + return await (await fetch(url.replace("ws://", "http://").replace("wss://", "https://"), { + headers: { Accept: "application/nostr+json" } + })).json(); +} +var nip13_exports = {}; +__export2(nip13_exports, { + fastEventHash: () => fastEventHash, + getPow: () => getPow, + minePow: () => minePow +}); +function getPow(hex2) { + let count = 0; + for (let i22 = 0; i22 < 64; i22 += 8) { + const nibble = parseInt(hex2.substring(i22, i22 + 8), 16); + if (nibble === 0) { + count += 32; + } else { + count += Math.clz32(nibble); + break; + } + } + return count; +} +function minePow(unsigned, difficulty) { + let count = 0; + const event = unsigned; + const tag = ["nonce", count.toString(), difficulty.toString()]; + event.tags.push(tag); + while (true) { + const now2 = Math.floor((/* @__PURE__ */ new Date()).getTime() / 1e3); + if (now2 !== event.created_at) { + count = 0; + event.created_at = now2; + } + tag[1] = (++count).toString(); + event.id = fastEventHash(event); + if (getPow(event.id) >= difficulty) { + break; + } + } + return event; +} +function fastEventHash(evt) { + return bytesToHex2( + sha2562(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))) + ); +} +var nip17_exports = {}; +__export2(nip17_exports, { + unwrapEvent: () => unwrapEvent2, + unwrapManyEvents: () => unwrapManyEvents2, + wrapEvent: () => wrapEvent2, + wrapManyEvents: () => wrapManyEvents2 +}); +var nip59_exports = {}; +__export2(nip59_exports, { + createRumor: () => createRumor, + createSeal: () => createSeal, + createWrap: () => createWrap, + unwrapEvent: () => unwrapEvent, + unwrapManyEvents: () => unwrapManyEvents, + wrapEvent: () => wrapEvent, + wrapManyEvents: () => wrapManyEvents +}); +var nip44_exports = {}; +__export2(nip44_exports, { + decrypt: () => decrypt22, + encrypt: () => encrypt22, + getConversationKey: () => getConversationKey, + v2: () => v2 +}); +var minPlaintextSize = 1; +var maxPlaintextSize = 65535; +function getConversationKey(privkeyA, pubkeyB) { + const sharedX = secp256k1.getSharedSecret(privkeyA, "02" + pubkeyB).subarray(1, 33); + return extract(sha2562, sharedX, "nip44-v2"); +} +function getMessageKeys(conversationKey, nonce) { + const keys = expand(sha2562, conversationKey, nonce, 76); + return { + chacha_key: keys.subarray(0, 32), + chacha_nonce: keys.subarray(32, 44), + hmac_key: keys.subarray(44, 76) + }; +} +function calcPaddedLen(len) { + if (!Number.isSafeInteger(len) || len < 1) + throw new Error("expected positive integer"); + if (len <= 32) + return 32; + const nextPower = 1 << Math.floor(Math.log2(len - 1)) + 1; + const chunk = nextPower <= 256 ? 32 : nextPower / 8; + return chunk * (Math.floor((len - 1) / chunk) + 1); +} +function writeU16BE(num) { + if (!Number.isSafeInteger(num) || num < minPlaintextSize || num > maxPlaintextSize) + throw new Error("invalid plaintext size: must be between 1 and 65535 bytes"); + const arr = new Uint8Array(2); + new DataView(arr.buffer).setUint16(0, num, false); + return arr; +} +function pad(plaintext) { + const unpadded = utf8Encoder.encode(plaintext); + const unpaddedLen = unpadded.length; + const prefix = writeU16BE(unpaddedLen); + const suffix = new Uint8Array(calcPaddedLen(unpaddedLen) - unpaddedLen); + return concatBytes3(prefix, unpadded, suffix); +} +function unpad(padded) { + const unpaddedLen = new DataView(padded.buffer).getUint16(0); + const unpadded = padded.subarray(2, 2 + unpaddedLen); + if (unpaddedLen < minPlaintextSize || unpaddedLen > maxPlaintextSize || unpadded.length !== unpaddedLen || padded.length !== 2 + calcPaddedLen(unpaddedLen)) + throw new Error("invalid padding"); + return utf8Decoder.decode(unpadded); +} +function hmacAad(key, message, aad) { + if (aad.length !== 32) + throw new Error("AAD associated data must be 32 bytes"); + const combined = concatBytes3(aad, message); + return hmac2(sha2562, key, combined); +} +function decodePayload(payload) { + if (typeof payload !== "string") + throw new Error("payload must be a valid string"); + const plen = payload.length; + if (plen < 132 || plen > 87472) + throw new Error("invalid payload length: " + plen); + if (payload[0] === "#") + throw new Error("unknown encryption version"); + let data; + try { + data = base64.decode(payload); + } catch (error) { + throw new Error("invalid base64: " + error.message); + } + const dlen = data.length; + if (dlen < 99 || dlen > 65603) + throw new Error("invalid data length: " + dlen); + const vers = data[0]; + if (vers !== 2) + throw new Error("unknown encryption version " + vers); + return { + nonce: data.subarray(1, 33), + ciphertext: data.subarray(33, -32), + mac: data.subarray(-32) + }; +} +function encrypt22(plaintext, conversationKey, nonce = randomBytes2(32)) { + const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce); + const padded = pad(plaintext); + const ciphertext = chacha20(chacha_key, chacha_nonce, padded); + const mac = hmacAad(hmac_key, ciphertext, nonce); + return base64.encode(concatBytes3(new Uint8Array([2]), nonce, ciphertext, mac)); +} +function decrypt22(payload, conversationKey) { + const { nonce, ciphertext, mac } = decodePayload(payload); + const { chacha_key, chacha_nonce, hmac_key } = getMessageKeys(conversationKey, nonce); + const calculatedMac = hmacAad(hmac_key, ciphertext, nonce); + if (!equalBytes2(calculatedMac, mac)) + throw new Error("invalid MAC"); + const padded = chacha20(chacha_key, chacha_nonce, ciphertext); + return unpad(padded); +} +var v2 = { + utils: { + getConversationKey, + calcPaddedLen + }, + encrypt: encrypt22, + decrypt: decrypt22 +}; +var TWO_DAYS = 2 * 24 * 60 * 60; +var now = () => Math.round(Date.now() / 1e3); +var randomNow = () => Math.round(now() - Math.random() * TWO_DAYS); +var nip44ConversationKey = (privateKey, publicKey) => getConversationKey(privateKey, publicKey); +var nip44Encrypt = (data, privateKey, publicKey) => encrypt22(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey)); +var nip44Decrypt = (data, privateKey) => JSON.parse(decrypt22(data.content, nip44ConversationKey(privateKey, data.pubkey))); +function createRumor(event, privateKey) { + const rumor = { + created_at: now(), + content: "", + tags: [], + ...event, + pubkey: getPublicKey(privateKey) + }; + rumor.id = getEventHash(rumor); + return rumor; +} +function createSeal(rumor, privateKey, recipientPublicKey) { + return finalizeEvent( + { + kind: Seal, + content: nip44Encrypt(rumor, privateKey, recipientPublicKey), + created_at: randomNow(), + tags: [] + }, + privateKey + ); +} +function createWrap(seal, recipientPublicKey) { + const randomKey = generateSecretKey(); + return finalizeEvent( + { + kind: GiftWrap, + content: nip44Encrypt(seal, randomKey, recipientPublicKey), + created_at: randomNow(), + tags: [["p", recipientPublicKey]] + }, + randomKey + ); +} +function wrapEvent(event, senderPrivateKey, recipientPublicKey) { + const rumor = createRumor(event, senderPrivateKey); + const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey); + return createWrap(seal, recipientPublicKey); +} +function wrapManyEvents(event, senderPrivateKey, recipientsPublicKeys) { + if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) { + throw new Error("At least one recipient is required."); + } + const senderPublicKey = getPublicKey(senderPrivateKey); + const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]; + recipientsPublicKeys.forEach((recipientPublicKey) => { + wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey)); + }); + return wrappeds; +} +function unwrapEvent(wrap, recipientPrivateKey) { + const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey); + return nip44Decrypt(unwrappedSeal, recipientPrivateKey); +} +function unwrapManyEvents(wrappedEvents, recipientPrivateKey) { + let unwrappedEvents = []; + wrappedEvents.forEach((e) => { + unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey)); + }); + unwrappedEvents.sort((a, b) => a.created_at - b.created_at); + return unwrappedEvents; +} +function createEvent(recipients, message, conversationTitle, replyTo) { + const baseEvent = { + created_at: Math.ceil(Date.now() / 1e3), + kind: PrivateDirectMessage, + tags: [], + content: message + }; + const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]; + recipientsArray.forEach(({ publicKey, relayUrl }) => { + baseEvent.tags.push(relayUrl ? ["p", publicKey, relayUrl] : ["p", publicKey]); + }); + if (replyTo) { + baseEvent.tags.push(["e", replyTo.eventId, replyTo.relayUrl || "", "reply"]); + } + if (conversationTitle) { + baseEvent.tags.push(["subject", conversationTitle]); + } + return baseEvent; +} +function wrapEvent2(senderPrivateKey, recipient, message, conversationTitle, replyTo) { + const event = createEvent(recipient, message, conversationTitle, replyTo); + return wrapEvent(event, senderPrivateKey, recipient.publicKey); +} +function wrapManyEvents2(senderPrivateKey, recipients, message, conversationTitle, replyTo) { + if (!recipients || recipients.length === 0) { + throw new Error("At least one recipient is required."); + } + const senderPublicKey = getPublicKey(senderPrivateKey); + return [{ publicKey: senderPublicKey }, ...recipients].map( + (recipient) => wrapEvent2(senderPrivateKey, recipient, message, conversationTitle, replyTo) + ); +} +var unwrapEvent2 = unwrapEvent; +var unwrapManyEvents2 = unwrapManyEvents; +var nip18_exports = {}; +__export2(nip18_exports, { + finishRepostEvent: () => finishRepostEvent, + getRepostedEvent: () => getRepostedEvent, + getRepostedEventPointer: () => getRepostedEventPointer +}); +function finishRepostEvent(t, reposted, relayUrl, privateKey) { + let kind; + const tags = [...t.tags ?? [], ["e", reposted.id, relayUrl], ["p", reposted.pubkey]]; + if (reposted.kind === ShortTextNote) { + kind = Repost; + } else { + kind = GenericRepost; + tags.push(["k", String(reposted.kind)]); + } + return finalizeEvent( + { + kind, + tags, + content: t.content === "" || reposted.tags?.find((tag) => tag[0] === "-") ? "" : JSON.stringify(reposted), + created_at: t.created_at + }, + privateKey + ); +} +function getRepostedEventPointer(event) { + if (![Repost, GenericRepost].includes(event.kind)) { + return void 0; + } + let lastETag; + let lastPTag; + for (let i22 = event.tags.length - 1; i22 >= 0 && (lastETag === void 0 || lastPTag === void 0); i22--) { + const tag = event.tags[i22]; + if (tag.length >= 2) { + if (tag[0] === "e" && lastETag === void 0) { + lastETag = tag; + } else if (tag[0] === "p" && lastPTag === void 0) { + lastPTag = tag; + } + } + } + if (lastETag === void 0) { + return void 0; + } + return { + id: lastETag[1], + relays: [lastETag[2], lastPTag?.[2]].filter((x) => typeof x === "string"), + author: lastPTag?.[1] + }; +} +function getRepostedEvent(event, { skipVerification } = {}) { + const pointer = getRepostedEventPointer(event); + if (pointer === void 0 || event.content === "") { + return void 0; + } + let repostedEvent; + try { + repostedEvent = JSON.parse(event.content); + } catch (error) { + return void 0; + } + if (repostedEvent.id !== pointer.id) { + return void 0; + } + if (!skipVerification && !verifyEvent(repostedEvent)) { + return void 0; + } + return repostedEvent; +} +var nip21_exports = {}; +__export2(nip21_exports, { + NOSTR_URI_REGEX: () => NOSTR_URI_REGEX, + parse: () => parse2, + test: () => test +}); +var NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`); +function test(value) { + return typeof value === "string" && new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value); +} +function parse2(uri) { + const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`)); + if (!match) + throw new Error(`Invalid Nostr URI: ${uri}`); + return { + uri: match[0], + value: match[1], + decoded: decode(match[1]) + }; +} +var nip25_exports = {}; +__export2(nip25_exports, { + finishReactionEvent: () => finishReactionEvent, + getReactedEventPointer: () => getReactedEventPointer +}); +function finishReactionEvent(t, reacted, privateKey) { + const inheritedTags = reacted.tags.filter((tag) => tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")); + return finalizeEvent( + { + ...t, + kind: Reaction, + tags: [...t.tags ?? [], ...inheritedTags, ["e", reacted.id], ["p", reacted.pubkey]], + content: t.content ?? "+" + }, + privateKey + ); +} +function getReactedEventPointer(event) { + if (event.kind !== Reaction) { + return void 0; + } + let lastETag; + let lastPTag; + for (let i22 = event.tags.length - 1; i22 >= 0 && (lastETag === void 0 || lastPTag === void 0); i22--) { + const tag = event.tags[i22]; + if (tag.length >= 2) { + if (tag[0] === "e" && lastETag === void 0) { + lastETag = tag; + } else if (tag[0] === "p" && lastPTag === void 0) { + lastPTag = tag; + } + } + } + if (lastETag === void 0 || lastPTag === void 0) { + return void 0; + } + return { + id: lastETag[1], + relays: [lastETag[2], lastPTag[2]].filter((x) => x !== void 0), + author: lastPTag[1] + }; +} +var nip27_exports = {}; +__export2(nip27_exports, { + parse: () => parse3 +}); +var noCharacter = /\W/m; +var noURLCharacter = /\W |\W$|$|,| /m; +function* parse3(content) { + const max = content.length; + let prevIndex = 0; + let index = 0; + while (index < max) { + let u = content.indexOf(":", index); + if (u === -1) { + break; + } + if (content.substring(u - 5, u) === "nostr") { + const m = content.substring(u + 60).match(noCharacter); + const end = m ? u + 60 + m.index : max; + try { + let pointer; + let { data, type } = decode(content.substring(u + 1, end)); + switch (type) { + case "npub": + pointer = { pubkey: data }; + break; + case "nsec": + case "note": + index = end + 1; + continue; + default: + pointer = data; + } + if (prevIndex !== u - 5) { + yield { type: "text", text: content.substring(prevIndex, u - 5) }; + } + yield { type: "reference", pointer }; + index = end; + prevIndex = index; + continue; + } catch (_err) { + index = u + 1; + continue; + } + } else if (content.substring(u - 5, u) === "https" || content.substring(u - 4, u) === "http") { + const m = content.substring(u + 4).match(noURLCharacter); + const end = m ? u + 4 + m.index : max; + const prefixLen = content[u - 1] === "s" ? 5 : 4; + try { + let url = new URL(content.substring(u - prefixLen, end)); + if (url.hostname.indexOf(".") === -1) { + throw new Error("invalid url"); + } + if (prevIndex !== u - prefixLen) { + yield { type: "text", text: content.substring(prevIndex, u - prefixLen) }; + } + if (url.pathname.endsWith(".png") || url.pathname.endsWith(".jpg") || url.pathname.endsWith(".jpeg") || url.pathname.endsWith(".gif") || url.pathname.endsWith(".webp")) { + yield { type: "image", url: url.toString() }; + index = end; + prevIndex = index; + continue; + } + if (url.pathname.endsWith(".mp4") || url.pathname.endsWith(".avi") || url.pathname.endsWith(".webm") || url.pathname.endsWith(".mkv")) { + yield { type: "video", url: url.toString() }; + index = end; + prevIndex = index; + continue; + } + if (url.pathname.endsWith(".mp3") || url.pathname.endsWith(".aac") || url.pathname.endsWith(".ogg") || url.pathname.endsWith(".opus")) { + yield { type: "audio", url: url.toString() }; + index = end; + prevIndex = index; + continue; + } + yield { type: "url", url: url.toString() }; + index = end; + prevIndex = index; + continue; + } catch (_err) { + index = end + 1; + continue; + } + } else if (content.substring(u - 3, u) === "wss" || content.substring(u - 2, u) === "ws") { + const m = content.substring(u + 4).match(noURLCharacter); + const end = m ? u + 4 + m.index : max; + const prefixLen = content[u - 1] === "s" ? 3 : 2; + try { + let url = new URL(content.substring(u - prefixLen, end)); + if (url.hostname.indexOf(".") === -1) { + throw new Error("invalid ws url"); + } + if (prevIndex !== u - prefixLen) { + yield { type: "text", text: content.substring(prevIndex, u - prefixLen) }; + } + yield { type: "relay", url: url.toString() }; + index = end; + prevIndex = index; + continue; + } catch (_err) { + index = end + 1; + continue; + } + } else { + index = u + 1; + continue; + } + } + if (prevIndex !== max) { + yield { type: "text", text: content.substring(prevIndex) }; + } +} +var nip28_exports = {}; +__export2(nip28_exports, { + channelCreateEvent: () => channelCreateEvent, + channelHideMessageEvent: () => channelHideMessageEvent, + channelMessageEvent: () => channelMessageEvent, + channelMetadataEvent: () => channelMetadataEvent, + channelMuteUserEvent: () => channelMuteUserEvent +}); +var channelCreateEvent = (t, privateKey) => { + let content; + if (typeof t.content === "object") { + content = JSON.stringify(t.content); + } else if (typeof t.content === "string") { + content = t.content; + } else { + return void 0; + } + return finalizeEvent( + { + kind: ChannelCreation, + tags: [...t.tags ?? []], + content, + created_at: t.created_at + }, + privateKey + ); +}; +var channelMetadataEvent = (t, privateKey) => { + let content; + if (typeof t.content === "object") { + content = JSON.stringify(t.content); + } else if (typeof t.content === "string") { + content = t.content; + } else { + return void 0; + } + return finalizeEvent( + { + kind: ChannelMetadata, + tags: [["e", t.channel_create_event_id], ...t.tags ?? []], + content, + created_at: t.created_at + }, + privateKey + ); +}; +var channelMessageEvent = (t, privateKey) => { + const tags = [["e", t.channel_create_event_id, t.relay_url, "root"]]; + if (t.reply_to_channel_message_event_id) { + tags.push(["e", t.reply_to_channel_message_event_id, t.relay_url, "reply"]); + } + return finalizeEvent( + { + kind: ChannelMessage, + tags: [...tags, ...t.tags ?? []], + content: t.content, + created_at: t.created_at + }, + privateKey + ); +}; +var channelHideMessageEvent = (t, privateKey) => { + let content; + if (typeof t.content === "object") { + content = JSON.stringify(t.content); + } else if (typeof t.content === "string") { + content = t.content; + } else { + return void 0; + } + return finalizeEvent( + { + kind: ChannelHideMessage, + tags: [["e", t.channel_message_event_id], ...t.tags ?? []], + content, + created_at: t.created_at + }, + privateKey + ); +}; +var channelMuteUserEvent = (t, privateKey) => { + let content; + if (typeof t.content === "object") { + content = JSON.stringify(t.content); + } else if (typeof t.content === "string") { + content = t.content; + } else { + return void 0; + } + return finalizeEvent( + { + kind: ChannelMuteUser, + tags: [["p", t.pubkey_to_mute], ...t.tags ?? []], + content, + created_at: t.created_at + }, + privateKey + ); +}; +var nip30_exports = {}; +__export2(nip30_exports, { + EMOJI_SHORTCODE_REGEX: () => EMOJI_SHORTCODE_REGEX, + matchAll: () => matchAll, + regex: () => regex, + replaceAll: () => replaceAll +}); +var EMOJI_SHORTCODE_REGEX = /:(\w+):/; +var regex = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, "g"); +function* matchAll(content) { + const matches = content.matchAll(regex()); + for (const match of matches) { + try { + const [shortcode, name] = match; + yield { + shortcode, + name, + start: match.index, + end: match.index + shortcode.length + }; + } catch (_e) { + } + } +} +function replaceAll(content, replacer) { + return content.replaceAll(regex(), (shortcode, name) => { + return replacer({ + shortcode, + name + }); + }); +} +var nip39_exports = {}; +__export2(nip39_exports, { + useFetchImplementation: () => useFetchImplementation3, + validateGithub: () => validateGithub +}); +var _fetch3; +try { + _fetch3 = fetch; +} catch { +} +function useFetchImplementation3(fetchImplementation) { + _fetch3 = fetchImplementation; +} +async function validateGithub(pubkey, username, proof) { + try { + let res = await (await _fetch3(`https://gist.github.com/${username}/${proof}/raw`)).text(); + return res === `Verifying that I control the following Nostr public key: ${pubkey}`; + } catch (_) { + return false; + } +} +var nip47_exports = {}; +__export2(nip47_exports, { + makeNwcRequestEvent: () => makeNwcRequestEvent, + parseConnectionString: () => parseConnectionString +}); +function parseConnectionString(connectionString) { + const { pathname, searchParams } = new URL(connectionString); + const pubkey = pathname; + const relay = searchParams.get("relay"); + const secret = searchParams.get("secret"); + if (!pubkey || !relay || !secret) { + throw new Error("invalid connection string"); + } + return { pubkey, relay, secret }; +} +async function makeNwcRequestEvent(pubkey, secretKey, invoice) { + const content = { + method: "pay_invoice", + params: { + invoice + } + }; + const encryptedContent = await encrypt2(secretKey, pubkey, JSON.stringify(content)); + const eventTemplate = { + kind: NWCWalletRequest, + created_at: Math.round(Date.now() / 1e3), + content: encryptedContent, + tags: [["p", pubkey]] + }; + return finalizeEvent(eventTemplate, secretKey); +} +var nip54_exports = {}; +__export2(nip54_exports, { + normalizeIdentifier: () => normalizeIdentifier +}); +function normalizeIdentifier(name) { + name = name.trim().toLowerCase(); + name = name.normalize("NFKC"); + return Array.from(name).map((char) => { + if (new RegExp("\\p{Letter}", "u").test(char) || new RegExp("\\p{Number}", "u").test(char)) { + return char; + } + return "-"; + }).join(""); +} +var nip57_exports = {}; +__export2(nip57_exports, { + getZapEndpoint: () => getZapEndpoint, + makeZapReceipt: () => makeZapReceipt, + makeZapRequest: () => makeZapRequest, + useFetchImplementation: () => useFetchImplementation4, + validateZapRequest: () => validateZapRequest +}); +var _fetch4; +try { + _fetch4 = fetch; +} catch { +} +function useFetchImplementation4(fetchImplementation) { + _fetch4 = fetchImplementation; +} +async function getZapEndpoint(metadata) { + try { + let lnurl = ""; + let { lud06, lud16 } = JSON.parse(metadata.content); + if (lud06) { + let { words } = bech32.decode(lud06, 1e3); + let data = bech32.fromWords(words); + lnurl = utf8Decoder.decode(data); + } else if (lud16) { + let [name, domain] = lud16.split("@"); + lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString(); + } else { + return null; + } + let res = await _fetch4(lnurl); + let body = await res.json(); + if (body.allowsNostr && body.nostrPubkey) { + return body.callback; + } + } catch (err) { + } + return null; +} +function makeZapRequest({ + profile, + event, + amount, + relays, + comment = "" +}) { + if (!amount) + throw new Error("amount not given"); + if (!profile) + throw new Error("profile not given"); + let zr = { + kind: 9734, + created_at: Math.round(Date.now() / 1e3), + content: comment, + tags: [ + ["p", profile], + ["amount", amount.toString()], + ["relays", ...relays] + ] + }; + if (event && typeof event === "string") { + zr.tags.push(["e", event]); + } + if (event && typeof event === "object") { + if (isReplaceableKind(event.kind)) { + const a = ["a", `${event.kind}:${event.pubkey}:`]; + zr.tags.push(a); + } else if (isAddressableKind(event.kind)) { + let d = event.tags.find(([t, v]) => t === "d" && v); + if (!d) + throw new Error("d tag not found or is empty"); + const a = ["a", `${event.kind}:${event.pubkey}:${d[1]}`]; + zr.tags.push(a); + } + } + return zr; +} +function validateZapRequest(zapRequestString) { + let zapRequest; + try { + zapRequest = JSON.parse(zapRequestString); + } catch (err) { + return "Invalid zap request JSON."; + } + if (!validateEvent(zapRequest)) + return "Zap request is not a valid Nostr event."; + if (!verifyEvent(zapRequest)) + return "Invalid signature on zap request."; + let p = zapRequest.tags.find(([t, v]) => t === "p" && v); + if (!p) + return "Zap request doesn't have a 'p' tag."; + if (!p[1].match(/^[a-f0-9]{64}$/)) + return "Zap request 'p' tag is not valid hex."; + let e = zapRequest.tags.find(([t, v]) => t === "e" && v); + if (e && !e[1].match(/^[a-f0-9]{64}$/)) + return "Zap request 'e' tag is not valid hex."; + let relays = zapRequest.tags.find(([t, v]) => t === "relays" && v); + if (!relays) + return "Zap request doesn't have a 'relays' tag."; + return null; +} +function makeZapReceipt({ + zapRequest, + preimage, + bolt11, + paidAt +}) { + let zr = JSON.parse(zapRequest); + let tagsFromZapRequest = zr.tags.filter(([t]) => t === "e" || t === "p" || t === "a"); + let zap = { + kind: 9735, + created_at: Math.round(paidAt.getTime() / 1e3), + content: "", + tags: [...tagsFromZapRequest, ["P", zr.pubkey], ["bolt11", bolt11], ["description", zapRequest]] + }; + if (preimage) { + zap.tags.push(["preimage", preimage]); + } + return zap; +} +var nip98_exports = {}; +__export2(nip98_exports, { + getToken: () => getToken, + hashPayload: () => hashPayload, + unpackEventFromToken: () => unpackEventFromToken, + validateEvent: () => validateEvent2, + validateEventKind: () => validateEventKind, + validateEventMethodTag: () => validateEventMethodTag, + validateEventPayloadTag: () => validateEventPayloadTag, + validateEventTimestamp: () => validateEventTimestamp, + validateEventUrlTag: () => validateEventUrlTag, + validateToken: () => validateToken +}); +var _authorizationScheme = "Nostr "; +async function getToken(loginUrl, httpMethod, sign, includeAuthorizationScheme = false, payload) { + const event = { + kind: HTTPAuth, + tags: [ + ["u", loginUrl], + ["method", httpMethod] + ], + created_at: Math.round((/* @__PURE__ */ new Date()).getTime() / 1e3), + content: "" + }; + if (payload) { + event.tags.push(["payload", hashPayload(payload)]); + } + const signedEvent = await sign(event); + const authorizationScheme = includeAuthorizationScheme ? _authorizationScheme : ""; + return authorizationScheme + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent))); +} +async function validateToken(token, url, method) { + const event = await unpackEventFromToken(token).catch((error) => { + throw error; + }); + const valid = await validateEvent2(event, url, method).catch((error) => { + throw error; + }); + return valid; +} +async function unpackEventFromToken(token) { + if (!token) { + throw new Error("Missing token"); + } + token = token.replace(_authorizationScheme, ""); + const eventB64 = utf8Decoder.decode(base64.decode(token)); + if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith("{")) { + throw new Error("Invalid token"); + } + const event = JSON.parse(eventB64); + return event; +} +function validateEventTimestamp(event) { + if (!event.created_at) { + return false; + } + return Math.round((/* @__PURE__ */ new Date()).getTime() / 1e3) - event.created_at < 60; +} +function validateEventKind(event) { + return event.kind === HTTPAuth; +} +function validateEventUrlTag(event, url) { + const urlTag = event.tags.find((t) => t[0] === "u"); + if (!urlTag) { + return false; + } + return urlTag.length > 0 && urlTag[1] === url; +} +function validateEventMethodTag(event, method) { + const methodTag = event.tags.find((t) => t[0] === "method"); + if (!methodTag) { + return false; + } + return methodTag.length > 0 && methodTag[1].toLowerCase() === method.toLowerCase(); +} +function hashPayload(payload) { + const hash3 = sha2562(utf8Encoder.encode(JSON.stringify(payload))); + return bytesToHex2(hash3); +} +function validateEventPayloadTag(event, payload) { + const payloadTag = event.tags.find((t) => t[0] === "payload"); + if (!payloadTag) { + return false; + } + const payloadHash = hashPayload(payload); + return payloadTag.length > 0 && payloadTag[1] === payloadHash; +} +async function validateEvent2(event, url, method, body) { + if (!verifyEvent(event)) { + throw new Error("Invalid nostr event, signature invalid"); + } + if (!validateEventKind(event)) { + throw new Error("Invalid nostr event, kind invalid"); + } + if (!validateEventTimestamp(event)) { + throw new Error("Invalid nostr event, created_at timestamp invalid"); + } + if (!validateEventUrlTag(event, url)) { + throw new Error("Invalid nostr event, url tag invalid"); + } + if (!validateEventMethodTag(event, method)) { + throw new Error("Invalid nostr event, method tag invalid"); + } + if (Boolean(body) && typeof body === "object" && Object.keys(body).length > 0) { + if (!validateEventPayloadTag(event, body)) { + throw new Error("Invalid nostr event, payload tag does not match request body hash"); + } + } + return true; +} + +// node_modules/nostr-tools/lib/esm/pool.js +var verifiedSymbol2 = Symbol("verified"); +var isRecord2 = (obj) => obj instanceof Object; +function validateEvent3(event) { + if (!isRecord2(event)) + return false; + if (typeof event.kind !== "number") + return false; + if (typeof event.content !== "string") + return false; + if (typeof event.created_at !== "number") + return false; + if (typeof event.pubkey !== "string") + return false; + if (!event.pubkey.match(/^[a-f0-9]{64}$/)) + return false; + if (!Array.isArray(event.tags)) + return false; + for (let i22 = 0; i22 < event.tags.length; i22++) { + let tag = event.tags[i22]; + if (!Array.isArray(tag)) + return false; + for (let j = 0; j < tag.length; j++) { + if (typeof tag[j] !== "string") + return false; + } + } + return true; +} +var utf8Decoder2 = new TextDecoder("utf-8"); +var utf8Encoder2 = new TextEncoder(); +function normalizeURL2(url) { + try { + if (url.indexOf("://") === -1) + url = "wss://" + url; + let p = new URL(url); + p.pathname = p.pathname.replace(/\/+/g, "/"); + if (p.pathname.endsWith("/")) + p.pathname = p.pathname.slice(0, -1); + if (p.port === "80" && p.protocol === "ws:" || p.port === "443" && p.protocol === "wss:") + p.port = ""; + p.searchParams.sort(); + p.hash = ""; + return p.toString(); + } catch (e) { + throw new Error(`Invalid URL: ${url}`); + } +} +var QueueNode2 = class { + value; + next = null; + prev = null; + constructor(message) { + this.value = message; + } +}; +var Queue2 = class { + first; + last; + constructor() { + this.first = null; + this.last = null; + } + enqueue(value) { + const newNode = new QueueNode2(value); + if (!this.last) { + this.first = newNode; + this.last = newNode; + } else if (this.last === this.first) { + this.last = newNode; + this.last.prev = this.first; + this.first.next = newNode; + } else { + newNode.prev = this.last; + this.last.next = newNode; + this.last = newNode; + } + return true; + } + dequeue() { + if (!this.first) + return null; + if (this.first === this.last) { + const target2 = this.first; + this.first = null; + this.last = null; + return target2.value; + } + const target = this.first; + this.first = target.next; + if (this.first) { + this.first.prev = null; + } + return target.value; + } +}; +var JS2 = class { + generateSecretKey() { + return schnorr.utils.randomPrivateKey(); + } + getPublicKey(secretKey) { + return bytesToHex2(schnorr.getPublicKey(secretKey)); + } + finalizeEvent(t, secretKey) { + const event = t; + event.pubkey = bytesToHex2(schnorr.getPublicKey(secretKey)); + event.id = getEventHash2(event); + event.sig = bytesToHex2(schnorr.sign(getEventHash2(event), secretKey)); + event[verifiedSymbol2] = true; + return event; + } + verifyEvent(event) { + if (typeof event[verifiedSymbol2] === "boolean") + return event[verifiedSymbol2]; + const hash3 = getEventHash2(event); + if (hash3 !== event.id) { + event[verifiedSymbol2] = false; + return false; + } + try { + const valid = schnorr.verify(event.sig, hash3, event.pubkey); + event[verifiedSymbol2] = valid; + return valid; + } catch (err) { + event[verifiedSymbol2] = false; + return false; + } + } +}; +function serializeEvent2(evt) { + if (!validateEvent3(evt)) + throw new Error("can't serialize event with wrong or missing properties"); + return JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]); +} +function getEventHash2(event) { + let eventHash = sha2562(utf8Encoder2.encode(serializeEvent2(event))); + return bytesToHex2(eventHash); +} +var i2 = new JS2(); +var generateSecretKey2 = i2.generateSecretKey; +var getPublicKey2 = i2.getPublicKey; +var finalizeEvent2 = i2.finalizeEvent; +var verifyEvent2 = i2.verifyEvent; +var ClientAuth2 = 22242; +function matchFilter(filter, event) { + if (filter.ids && filter.ids.indexOf(event.id) === -1) { + return false; + } + if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) { + return false; + } + if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) { + return false; + } + for (let f in filter) { + if (f[0] === "#") { + let tagName = f.slice(1); + let values = filter[`#${tagName}`]; + if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1)) + return false; + } + } + if (filter.since && event.created_at < filter.since) + return false; + if (filter.until && event.created_at > filter.until) + return false; + return true; +} +function matchFilters(filters, event) { + for (let i22 = 0; i22 < filters.length; i22++) { + if (matchFilter(filters[i22], event)) { + return true; + } + } + return false; +} +function getHex642(json, field) { + let len = field.length + 3; + let idx = json.indexOf(`"${field}":`) + len; + let s = json.slice(idx).indexOf(`"`) + idx + 1; + return json.slice(s, s + 64); +} +function getSubscriptionId2(json) { + let idx = json.slice(0, 22).indexOf(`"EVENT"`); + if (idx === -1) + return null; + let pstart = json.slice(idx + 7 + 1).indexOf(`"`); + if (pstart === -1) + return null; + let start = idx + 7 + 1 + pstart; + let pend = json.slice(start + 1, 80).indexOf(`"`); + if (pend === -1) + return null; + let end = start + 1 + pend; + return json.slice(start + 1, end); +} +function makeAuthEvent2(relayURL, challenge2) { + return { + kind: ClientAuth2, + created_at: Math.floor(Date.now() / 1e3), + tags: [ + ["relay", relayURL], + ["challenge", challenge2] + ], + content: "" + }; +} +async function yieldThread() { + return new Promise((resolve) => { + const ch = new MessageChannel(); + const handler = () => { + ch.port1.removeEventListener("message", handler); + resolve(); + }; + ch.port1.addEventListener("message", handler); + ch.port2.postMessage(0); + ch.port1.start(); + }); +} +var alwaysTrue = (t) => { + t[verifiedSymbol2] = true; + return true; +}; +var AbstractRelay = class { + url; + _connected = false; + onclose = null; + onnotice = (msg) => console.debug(`NOTICE from ${this.url}: ${msg}`); + _onauth = null; + baseEoseTimeout = 4400; + connectionTimeout = 4400; + publishTimeout = 4400; + openSubs = /* @__PURE__ */ new Map(); + connectionTimeoutHandle; + connectionPromise; + openCountRequests = /* @__PURE__ */ new Map(); + openEventPublishes = /* @__PURE__ */ new Map(); + ws; + incomingMessageQueue = new Queue2(); + queueRunning = false; + challenge; + authPromise; + serial = 0; + verifyEvent; + _WebSocket; + constructor(url, opts) { + this.url = normalizeURL2(url); + this.verifyEvent = opts.verifyEvent; + this._WebSocket = opts.websocketImplementation || WebSocket; + } + static async connect(url, opts) { + const relay = new AbstractRelay(url, opts); + await relay.connect(); + return relay; + } + closeAllSubscriptions(reason) { + for (let [_, sub] of this.openSubs) { + sub.close(reason); + } + this.openSubs.clear(); + for (let [_, ep] of this.openEventPublishes) { + ep.reject(new Error(reason)); + } + this.openEventPublishes.clear(); + for (let [_, cr] of this.openCountRequests) { + cr.reject(new Error(reason)); + } + this.openCountRequests.clear(); + } + get connected() { + return this._connected; + } + async connect() { + if (this.connectionPromise) + return this.connectionPromise; + this.challenge = void 0; + this.authPromise = void 0; + this.connectionPromise = new Promise((resolve, reject) => { + this.connectionTimeoutHandle = setTimeout(() => { + reject("connection timed out"); + this.connectionPromise = void 0; + this.onclose?.(); + this.closeAllSubscriptions("relay connection timed out"); + }, this.connectionTimeout); + try { + this.ws = new this._WebSocket(this.url); + } catch (err) { + clearTimeout(this.connectionTimeoutHandle); + reject(err); + return; + } + this.ws.onopen = () => { + clearTimeout(this.connectionTimeoutHandle); + this._connected = true; + resolve(); + }; + this.ws.onerror = (ev) => { + clearTimeout(this.connectionTimeoutHandle); + reject(ev.message || "websocket error"); + if (this._connected) { + this._connected = false; + this.connectionPromise = void 0; + this.onclose?.(); + this.closeAllSubscriptions("relay connection errored"); + } + }; + this.ws.onclose = async () => { + if (this._connected) { + this._connected = false; + this.connectionPromise = void 0; + this.onclose?.(); + this.closeAllSubscriptions("relay connection closed"); + } + }; + this.ws.onmessage = this._onmessage.bind(this); + }); + return this.connectionPromise; + } + async runQueue() { + this.queueRunning = true; + while (true) { + if (false === this.handleNext()) { + break; + } + await yieldThread(); + } + this.queueRunning = false; + } + handleNext() { + const json = this.incomingMessageQueue.dequeue(); + if (!json) { + return false; + } + const subid = getSubscriptionId2(json); + if (subid) { + const so = this.openSubs.get(subid); + if (!so) { + return; + } + const id = getHex642(json, "id"); + const alreadyHave = so.alreadyHaveEvent?.(id); + so.receivedEvent?.(this, id); + if (alreadyHave) { + return; + } + } + try { + let data = JSON.parse(json); + switch (data[0]) { + case "EVENT": { + const so = this.openSubs.get(data[1]); + const event = data[2]; + if (this.verifyEvent(event) && matchFilters(so.filters, event)) { + so.onevent(event); + } + return; + } + case "COUNT": { + const id = data[1]; + const payload = data[2]; + const cr = this.openCountRequests.get(id); + if (cr) { + cr.resolve(payload.count); + this.openCountRequests.delete(id); + } + return; + } + case "EOSE": { + const so = this.openSubs.get(data[1]); + if (!so) + return; + so.receivedEose(); + return; + } + case "OK": { + const id = data[1]; + const ok = data[2]; + const reason = data[3]; + const ep = this.openEventPublishes.get(id); + if (ep) { + clearTimeout(ep.timeout); + if (ok) + ep.resolve(reason); + else + ep.reject(new Error(reason)); + this.openEventPublishes.delete(id); + } + return; + } + case "CLOSED": { + const id = data[1]; + const so = this.openSubs.get(id); + if (!so) + return; + so.closed = true; + so.close(data[2]); + return; + } + case "NOTICE": + this.onnotice(data[1]); + return; + case "AUTH": { + this.challenge = data[1]; + this._onauth?.(data[1]); + return; + } + } + } catch (err) { + return; + } + } + async send(message) { + if (!this.connectionPromise) + throw new Error("sending on closed connection"); + this.connectionPromise.then(() => { + this.ws?.send(message); + }); + } + async auth(signAuthEvent) { + const challenge2 = this.challenge; + if (!challenge2) + throw new Error("can't perform auth, no challenge was received"); + if (this.authPromise) + return this.authPromise; + this.authPromise = new Promise(async (resolve, reject) => { + const evt = await signAuthEvent(makeAuthEvent2(this.url, challenge2)); + const timeout = setTimeout(() => { + const ep = this.openEventPublishes.get(evt.id); + if (ep) { + ep.reject(new Error("auth timed out")); + this.openEventPublishes.delete(evt.id); + } + }, this.publishTimeout); + this.openEventPublishes.set(evt.id, { resolve, reject, timeout }); + this.send('["AUTH",' + JSON.stringify(evt) + "]"); + }); + return this.authPromise; + } + async publish(event) { + const ret = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const ep = this.openEventPublishes.get(event.id); + if (ep) { + ep.reject(new Error("publish timed out")); + this.openEventPublishes.delete(event.id); + } + }, this.publishTimeout); + this.openEventPublishes.set(event.id, { resolve, reject, timeout }); + }); + this.send('["EVENT",' + JSON.stringify(event) + "]"); + return ret; + } + async count(filters, params) { + this.serial++; + const id = params?.id || "count:" + this.serial; + const ret = new Promise((resolve, reject) => { + this.openCountRequests.set(id, { resolve, reject }); + }); + this.send('["COUNT","' + id + '",' + JSON.stringify(filters).substring(1)); + return ret; + } + subscribe(filters, params) { + const subscription = this.prepareSubscription(filters, params); + subscription.fire(); + return subscription; + } + prepareSubscription(filters, params) { + this.serial++; + const id = params.id || (params.label ? params.label + ":" : "sub:") + this.serial; + const subscription = new Subscription(this, id, filters, params); + this.openSubs.set(id, subscription); + return subscription; + } + close() { + this.closeAllSubscriptions("relay connection closed by us"); + this._connected = false; + this.ws?.close(); + } + _onmessage(ev) { + this.incomingMessageQueue.enqueue(ev.data); + if (!this.queueRunning) { + this.runQueue(); + } + } +}; +var Subscription = class { + relay; + id; + closed = false; + eosed = false; + filters; + alreadyHaveEvent; + receivedEvent; + onevent; + oneose; + onclose; + eoseTimeout; + eoseTimeoutHandle; + constructor(relay, id, filters, params) { + this.relay = relay; + this.filters = filters; + this.id = id; + this.alreadyHaveEvent = params.alreadyHaveEvent; + this.receivedEvent = params.receivedEvent; + this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout; + this.oneose = params.oneose; + this.onclose = params.onclose; + this.onevent = params.onevent || ((event) => { + console.warn( + `onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`, + event + ); + }); + } + fire() { + this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1)); + this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout); + } + receivedEose() { + if (this.eosed) + return; + clearTimeout(this.eoseTimeoutHandle); + this.eosed = true; + this.oneose?.(); + } + close(reason = "closed by caller") { + if (!this.closed && this.relay.connected) { + this.relay.send('["CLOSE",' + JSON.stringify(this.id) + "]"); + this.closed = true; + } + this.relay.openSubs.delete(this.id); + this.onclose?.(reason); + } +}; +var AbstractSimplePool = class { + relays = /* @__PURE__ */ new Map(); + seenOn = /* @__PURE__ */ new Map(); + trackRelays = false; + verifyEvent; + trustedRelayURLs = /* @__PURE__ */ new Set(); + _WebSocket; + constructor(opts) { + this.verifyEvent = opts.verifyEvent; + this._WebSocket = opts.websocketImplementation; + } + async ensureRelay(url, params) { + url = normalizeURL2(url); + let relay = this.relays.get(url); + if (!relay) { + relay = new AbstractRelay(url, { + verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent, + websocketImplementation: this._WebSocket + }); + if (params?.connectionTimeout) + relay.connectionTimeout = params.connectionTimeout; + this.relays.set(url, relay); + } + await relay.connect(); + return relay; + } + close(relays) { + relays.map(normalizeURL2).forEach((url) => { + this.relays.get(url)?.close(); + }); + } + subscribe(relays, filter, params) { + return this.subscribeMap( + relays.map((url) => ({ url, filter })), + params + ); + } + subscribeMany(relays, filters, params) { + return this.subscribeMap( + relays.flatMap((url) => filters.map((filter) => ({ url, filter }))), + params + ); + } + subscribeMap(requests, params) { + if (this.trackRelays) { + params.receivedEvent = (relay, id) => { + let set = this.seenOn.get(id); + if (!set) { + set = /* @__PURE__ */ new Set(); + this.seenOn.set(id, set); + } + set.add(relay); + }; + } + const _knownIds = /* @__PURE__ */ new Set(); + const subs = []; + const eosesReceived = []; + let handleEose = (i22) => { + if (eosesReceived[i22]) + return; + eosesReceived[i22] = true; + if (eosesReceived.filter((a) => a).length === requests.length) { + params.oneose?.(); + handleEose = () => { + }; + } + }; + const closesReceived = []; + let handleClose = (i22, reason) => { + if (closesReceived[i22]) + return; + handleEose(i22); + closesReceived[i22] = reason; + if (closesReceived.filter((a) => a).length === requests.length) { + params.onclose?.(closesReceived); + handleClose = () => { + }; + } + }; + const localAlreadyHaveEventHandler = (id) => { + if (params.alreadyHaveEvent?.(id)) { + return true; + } + const have = _knownIds.has(id); + _knownIds.add(id); + return have; + }; + const allOpened = Promise.all( + requests.map(async ({ url, filter }, i22) => { + url = normalizeURL2(url); + let relay; + try { + relay = await this.ensureRelay(url, { + connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1e3) : void 0 + }); + } catch (err) { + handleClose(i22, err?.message || String(err)); + return; + } + let subscription = relay.subscribe([filter], { + ...params, + oneose: () => handleEose(i22), + onclose: (reason) => { + if (reason.startsWith("auth-required:") && params.doauth) { + relay.auth(params.doauth).then(() => { + relay.subscribe([filter], { + ...params, + oneose: () => handleEose(i22), + onclose: (reason2) => { + handleClose(i22, reason2); + }, + alreadyHaveEvent: localAlreadyHaveEventHandler, + eoseTimeout: params.maxWait + }); + }).catch((err) => { + handleClose(i22, `auth was required and attempted, but failed with: ${err}`); + }); + } else { + handleClose(i22, reason); + } + }, + alreadyHaveEvent: localAlreadyHaveEventHandler, + eoseTimeout: params.maxWait + }); + subs.push(subscription); + }) + ); + return { + async close() { + await allOpened; + subs.forEach((sub) => { + sub.close(); + }); + } + }; + } + subscribeManyMap(requests, params) { + if (this.trackRelays) { + params.receivedEvent = (relay, id) => { + let set = this.seenOn.get(id); + if (!set) { + set = /* @__PURE__ */ new Set(); + this.seenOn.set(id, set); + } + set.add(relay); + }; + } + const _knownIds = /* @__PURE__ */ new Set(); + const subs = []; + const relaysLength = Object.keys(requests).length; + const eosesReceived = []; + let handleEose = (i22) => { + if (eosesReceived[i22]) + return; + eosesReceived[i22] = true; + if (eosesReceived.filter((a) => a).length === relaysLength) { + params.oneose?.(); + handleEose = () => { + }; + } + }; + const closesReceived = []; + let handleClose = (i22, reason) => { + if (closesReceived[i22]) + return; + handleEose(i22); + closesReceived[i22] = reason; + if (closesReceived.filter((a) => a).length === relaysLength) { + params.onclose?.(closesReceived); + handleClose = () => { + }; + } + }; + const localAlreadyHaveEventHandler = (id) => { + if (params.alreadyHaveEvent?.(id)) { + return true; + } + const have = _knownIds.has(id); + _knownIds.add(id); + return have; + }; + const allOpened = Promise.all( + Object.entries(requests).map(async (req, i22, arr) => { + if (arr.indexOf(req) !== i22) { + handleClose(i22, "duplicate url"); + return; + } + let [url, filters] = req; + url = normalizeURL2(url); + let relay; + try { + relay = await this.ensureRelay(url, { + connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1e3) : void 0 + }); + } catch (err) { + handleClose(i22, err?.message || String(err)); + return; + } + let subscription = relay.subscribe(filters, { + ...params, + oneose: () => handleEose(i22), + onclose: (reason) => { + if (reason.startsWith("auth-required:") && params.doauth) { + relay.auth(params.doauth).then(() => { + relay.subscribe(filters, { + ...params, + oneose: () => handleEose(i22), + onclose: (reason2) => { + handleClose(i22, reason2); + }, + alreadyHaveEvent: localAlreadyHaveEventHandler, + eoseTimeout: params.maxWait + }); + }).catch((err) => { + handleClose(i22, `auth was required and attempted, but failed with: ${err}`); + }); + } else { + handleClose(i22, reason); + } + }, + alreadyHaveEvent: localAlreadyHaveEventHandler, + eoseTimeout: params.maxWait + }); + subs.push(subscription); + }) + ); + return { + async close() { + await allOpened; + subs.forEach((sub) => { + sub.close(); + }); + } + }; + } + subscribeEose(relays, filter, params) { + const subcloser = this.subscribe(relays, filter, { + ...params, + oneose() { + subcloser.close(); + } + }); + return subcloser; + } + subscribeManyEose(relays, filters, params) { + const subcloser = this.subscribeMany(relays, filters, { + ...params, + oneose() { + subcloser.close(); + } + }); + return subcloser; + } + async querySync(relays, filter, params) { + return new Promise(async (resolve) => { + const events = []; + this.subscribeEose(relays, filter, { + ...params, + onevent(event) { + events.push(event); + }, + onclose(_) { + resolve(events); + } + }); + }); + } + async get(relays, filter, params) { + filter.limit = 1; + const events = await this.querySync(relays, filter, params); + events.sort((a, b) => b.created_at - a.created_at); + return events[0] || null; + } + publish(relays, event) { + return relays.map(normalizeURL2).map(async (url, i22, arr) => { + if (arr.indexOf(url) !== i22) { + return Promise.reject("duplicate url"); + } + let r = await this.ensureRelay(url); + return r.publish(event).then((reason) => { + if (this.trackRelays) { + let set = this.seenOn.get(event.id); + if (!set) { + set = /* @__PURE__ */ new Set(); + this.seenOn.set(event.id, set); + } + set.add(r); + } + return reason; + }); + }); + } + listConnectionStatus() { + const map = /* @__PURE__ */ new Map(); + this.relays.forEach((relay, url) => map.set(url, relay.connected)); + return map; + } + destroy() { + this.relays.forEach((conn) => conn.close()); + this.relays = /* @__PURE__ */ new Map(); + } +}; +var _WebSocket3; +try { + _WebSocket3 = WebSocket; +} catch { +} +function useWebSocketImplementation(websocketImplementation) { + _WebSocket3 = websocketImplementation; +} +var SimplePool = class extends AbstractSimplePool { + constructor() { + super({ verifyEvent: verifyEvent2, websocketImplementation: _WebSocket3 }); + } +}; + +// node_modules/ws/wrapper.mjs +var import_stream = __toESM(require_stream(), 1); +var import_receiver = __toESM(require_receiver(), 1); +var import_sender = __toESM(require_sender(), 1); +var import_websocket = __toESM(require_websocket(), 1); +var import_websocket_server = __toESM(require_websocket_server(), 1); +var wrapper_default = import_websocket.default; + +// tools/payment-helper.mjs +var import_qrcode = __toESM(require_lib(), 1); + +// tools/screen-tips.mjs +function recentScreenTips(events, recipient, provider, now2) { + const seen = /* @__PURE__ */ new Set(); + const tips = []; + for (const event of events.slice(0, 100)) { + try { + if (event.kind !== 9735 || event.pubkey !== provider || !validateEvent(event) || !verifyEvent(event) || !Number.isSafeInteger(event.created_at) || event.created_at <= now2 - 120 || event.created_at > now2 || !event.tags.some((t) => t[0] === "p" && t[1] === recipient)) continue; + const description = event.tags.find((t) => t[0] === "description")?.[1]; + if (!description || description.length > 16e3) continue; + const request = JSON.parse(description); + if (request.kind !== 9734 || !validateEvent(request) || !verifyEvent(request) || !request.tags.some((t) => t[0] === "p" && t[1] === recipient) || request.tags.some((t) => t[0] === "e")) continue; + const msats = Number(request.tags.find((t) => t[0] === "amount")?.[1]); + if (!Number.isSafeInteger(msats) || msats < 1e3 || seen.has(request.id)) continue; + seen.add(request.id); + tips.push({ sats: Math.floor(msats / 1e3), occurred_at: new Date(event.created_at * 1e3).toISOString() }); + } catch { + } + } + return tips.sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)).slice(0, 30); +} + +// tools/payment-helper.mjs +useWebSocketImplementation(wrapper_default); +async function main() { + const input = JSON.parse(await new Promise((resolve) => { + let value = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => value += chunk); + process.stdin.on("end", () => resolve(value)); + })); + if (input.action === "create") { + const secret = generateSecretKey(); + const lnurl = import_bech32.bech32.encode("lnurl", import_bech32.bech32.toWords(Buffer.from(input.lnurlUrl, "utf8")), 2e3); + const event = finalizeEvent({ + kind: 9734, + created_at: Math.floor(Date.now() / 1e3), + content: input.content || `Just Works \xB7 Table ${input.table}`, + tags: [["relays", ...input.relays], ["amount", String(input.amountMsat)], ["lnurl", lnurl], ["p", input.recipient]] + }, secret); + process.stdout.write(JSON.stringify({ event, pubkey: getPublicKey(secret), lnurl })); + } else if (input.action === "check") { + const pool = new SimplePool(); + try { + const events = await pool.querySync(input.relays, { kinds: [9735], "#p": [input.recipient], since: input.since - 5, limit: 100 }, { maxWait: 1800 }); + const receipt = events.find((event) => { + if (event.pubkey !== input.recipient) return false; + const description = event.tags.find((tag) => tag[0] === "description")?.[1]; + if (!description) return false; + try { + return JSON.parse(description).pubkey === input.pubkey; + } catch { + return false; + } + }); + process.stdout.write(JSON.stringify({ paid: Boolean(receipt), receipt: receipt?.id || null })); + } finally { + pool.close(input.relays); + } + } else if (input.action === "screen-tips") { + const decoded = nip19_exports.decode(input.npub); + if (decoded.type !== "npub" || !/^[a-f0-9]{64}$/.test(input.provider)) throw new Error("invalid merchant/provider"); + const now2 = Math.floor(Date.now() / 1e3); + const pool = new SimplePool(); + try { + const events = await pool.querySync(input.relays, { kinds: [9735], authors: [input.provider], "#p": [decoded.data], since: now2 - 120, limit: 100 }, { maxWait: 1800 }); + process.stdout.write(JSON.stringify({ tips: recentScreenTips(events, decoded.data, input.provider, now2) })); + } finally { + pool.close(input.relays); + } + } else if (input.action === "latest") { + const decoded = nip19_exports.decode(input.npub); + if (decoded.type !== "npub") throw new Error("invalid npub"); + const pool = new SimplePool(); + try { + const events = await pool.querySync(input.relays, { kinds: [1, 30023], authors: [decoded.data], limit: 20 }, { maxWait: 4500 }); + const event = events.filter((item) => item.content?.trim()).sort((a, b) => b.created_at - a.created_at)[0]; + process.stdout.write(JSON.stringify({ event: event ? { id: event.id, kind: event.kind, content: event.content, created_at: event.created_at, pubkey: event.pubkey } : null })); + } finally { + pool.close(input.relays); + } + } else if (input.action === "qr") { + const svg = await import_qrcode.default.toString(input.text, { type: "svg", margin: 1, width: 320, color: { dark: "#0a0a0a", light: "#ffffff" }, errorCorrectionLevel: "M" }); + process.stdout.write(JSON.stringify({ svg })); + } else { + throw new Error("unknown action"); + } +} +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); +/*! Bundled license information: + +@noble/hashes/esm/utils.js: +@noble/hashes/esm/utils.js: + (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *) + +@noble/curves/esm/abstract/utils.js: +@noble/curves/esm/abstract/modular.js: +@noble/curves/esm/abstract/curve.js: +@noble/curves/esm/abstract/weierstrass.js: +@noble/curves/esm/_shortw_utils.js: +@noble/curves/esm/secp256k1.js: + (*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *) + +@scure/base/lib/esm/index.js: + (*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) *) + +@noble/ciphers/esm/utils.js: + (*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) *) +*/ diff --git a/docker/justworks/business/public/assets/app-mode.css b/docker/justworks/business/public/assets/app-mode.css new file mode 100644 index 00000000..7806c119 --- /dev/null +++ b/docker/justworks/business/public/assets/app-mode.css @@ -0,0 +1,19 @@ +.app-mode{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;color:#f7f7f4;font:500 12px/1.4 var(--jw-font-sans,system-ui,sans-serif)} +.app-mode>button,.app-help-close{appearance:none;border:1px solid #ffffff45;border-radius:999px;background:#101613d9;color:#f7f7f4;min-height:40px;padding:8px 14px;font:inherit;cursor:pointer;white-space:nowrap} +.app-mode button:hover{border-color:#ff996f;background:#242c27}.app-mode button:focus-visible{outline:3px solid #ff996f;outline-offset:3px}.app-mode button[hidden]{display:none}.app-mode button:disabled{opacity:.6;cursor:wait} +.app-mode-status:empty{display:none}.app-mode-status{max-width:28em;color:#ffce96;flex-basis:100%;text-align:right} +.app-install-help{position:fixed;inset:0;margin:auto;width:min(440px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:auto;padding:28px;border:1px solid #ffffff40;border-radius:24px;background:#111914;color:#f7f7f4;font:400 16px/1.6 var(--jw-font-sans,system-ui,sans-serif);text-align:left;box-shadow:0 24px 100px #0009} +.app-install-help::backdrop{background:#000b;backdrop-filter:blur(6px)}.app-install-help h2{font-size:25px;line-height:1.2;letter-spacing:-.03em;margin:0 0 20px}.app-install-help p{color:#d5dfd8}.app-help-close{font-size:15px;min-width:100px} +body[data-pwa-app=pos]>.app-mode{display:none} +.pos-app-controls{position:sticky;top:calc(76px + env(safe-area-inset-top));z-index:10;padding:8px max(24px,env(safe-area-inset-right)) 8px max(24px,env(safe-area-inset-left));background:#080909} +body[data-pwa-app=pos] .tabs{top:calc(76px + var(--app-toolbar-height,56px) + env(safe-area-inset-top))} +body[data-pwa-app=pos]{padding-top:calc(76px + env(safe-area-inset-top));padding-bottom:env(safe-area-inset-bottom)} +body[data-pwa-app=pos] .topbar[data-topbar]{top:env(safe-area-inset-top)!important} +body[data-pwa-app=pos] .topbar{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto auto;gap:16px} +body[data-pwa-app=pos] .topbar .jw-login-open{margin:0;justify-self:end} +.screen-utilities{justify-self:end;display:flex;flex-direction:column;align-items:flex-end;gap:8px;min-width:0}.screen-utilities .app-mode{font-size:12px}.screen-utilities .app-mode>button{min-height:36px;padding:6px 11px} +.screen-utilities time{white-space:nowrap;flex-shrink:0} +body[data-pwa-app=screen] .stage{padding-top:max(3vh,env(safe-area-inset-top));padding-right:max(4vw,env(safe-area-inset-right));padding-bottom:max(2vh,env(safe-area-inset-bottom));padding-left:max(4vw,env(safe-area-inset-left))} +@media(max-width:700px){body[data-pwa-app=pos]{padding-top:calc(64px + env(safe-area-inset-top))}.pos-app-controls{top:calc(64px + env(safe-area-inset-top));padding:8px 12px}body[data-pwa-app=pos] .tabs{top:calc(64px + var(--app-toolbar-height,60px) + env(safe-area-inset-top))}.app-mode>button{min-height:44px}.screen-utilities{align-items:flex-start;justify-self:start;grid-column:1/-1;flex-direction:row;justify-content:space-between;width:100%}.screen-utilities .app-mode{justify-content:flex-end}.screen-utilities .app-mode>button{min-height:40px}.app-mode-status{font-size:11px}.app-install-help{padding:24px}} +@media print{.app-mode{display:none!important}} +@media(max-width:700px){body[data-pwa-app=pos] .topbar{grid-template-columns:minmax(0,1fr) auto auto;gap:10px;padding-inline:12px}body[data-pwa-app=pos] .topbar .merchant{display:none}body[data-pwa-app=pos] .brand small{display:none}body[data-pwa-app=pos] .topbar .jw-login-open{min-height:40px}} diff --git a/docker/justworks/business/public/assets/app-mode.js b/docker/justworks/business/public/assets/app-mode.js new file mode 100644 index 00000000..051e4ee5 --- /dev/null +++ b/docker/justworks/business/public/assets/app-mode.js @@ -0,0 +1,93 @@ +// Shared display controls; credentials, live records and writes never enter storage. +const app = document.body.dataset.pwaApp; +const standalone = matchMedia('(display-mode: standalone)'); +const fullscreen = matchMedia('(display-mode: fullscreen)'); +let installed = standalone.matches || fullscreen.matches || navigator.standalone === true; +let installPrompt; +const controls = document.createElement('div'); +controls.className = 'app-mode'; +controls.setAttribute('aria-label', `${app === 'pos' ? 'POS' : 'Screen'} app controls`); +controls.innerHTML = `

Install Just Works ${app === 'pos' ? 'POS' : 'Screen'}

Your merchant link stays with this app. A connection is required for live activity.

`; +const install = controls.querySelector('.app-install'); +const full = controls.querySelector('.app-fullscreen'); +const status = controls.querySelector('.app-mode-status'); +const help = controls.querySelector('dialog'); +const manifest = document.querySelector('link[rel="manifest"]'); +const npub = new URLSearchParams(location.search).get('npub'); +// Manifest endpoint validates checksum. Never carry login tokens or fragments. +if (npub && /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(npub)) { + manifest.href = `/${app}.webmanifest?npub=${encodeURIComponent(npub)}`; +} +function syncInstall() { + install.hidden = installed; + install.textContent = installPrompt ? 'Install app' : 'Install guide'; +} +function syncFullscreen() { + const active = Boolean(document.fullscreenElement || document.webkitFullscreenElement); + full.textContent = active ? 'Exit fullscreen' : 'Fullscreen'; + full.setAttribute('aria-pressed', String(active)); + full.title = active ? 'Exit fullscreen (Esc)' : 'Enter fullscreen'; +} +function syncConnection() { + status.textContent = navigator.onLine ? '' : 'Offline · live updates unavailable'; + controls.classList.toggle('is-offline', !navigator.onLine); +} +window.addEventListener('beforeinstallprompt', event => { + event.preventDefault(); + installPrompt = event; + syncInstall(); +}); +window.addEventListener('appinstalled', () => { + installed = true; + installPrompt = undefined; + syncInstall(); + status.textContent = 'App installed'; +}); +standalone.addEventListener('change', () => {installed = standalone.matches || navigator.standalone === true; syncInstall();}); +install.addEventListener('click', async () => { + if (installPrompt) { + const prompt = installPrompt; + installPrompt = undefined; + install.disabled = true; + try { + await prompt.prompt(); + const choice = await prompt.userChoice; + if (choice.outcome === 'accepted') status.textContent = 'Installation requested'; + } catch {status.textContent = 'Use your browser menu to install this app.';} + finally {install.disabled = false; syncInstall();} + return; + } + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + help.querySelector('.app-install-instructions').textContent = ios + ? 'In Safari, tap Share → Add to Home Screen, then enable Open as Web App if shown.' + : 'Open your browser menu and choose Install app or Add to Home Screen. If it is not offered, use a browser that supports web app installation.'; + help.showModal(); +}); +help.querySelector('.app-help-close').addEventListener('click', () => help.close()); +help.addEventListener('click', event => {if (event.target === help) {const r = help.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) help.close();}}); +full.addEventListener('click', async () => { + const active = document.fullscreenElement || document.webkitFullscreenElement; + const action = active ? (document.exitFullscreen || document.webkitExitFullscreen) : (document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen); + if (!action) {status.textContent = 'Fullscreen is unavailable in this browser. Install the app for a standalone view.'; return;} + try {await action.call(active ? document : document.documentElement); syncFullscreen(); status.textContent = '';} + catch {status.textContent = 'Fullscreen could not open. Try again or use your browser fullscreen command.';} +}); +document.addEventListener('fullscreenchange', syncFullscreen); +document.addEventListener('webkitfullscreenchange', syncFullscreen); +document.addEventListener('fullscreenerror', () => {status.textContent = 'Fullscreen is unavailable right now.'; syncFullscreen();}); +window.addEventListener('online', syncConnection); +window.addEventListener('offline', syncConnection); +syncInstall(); syncFullscreen(); syncConnection(); + +// Dedicated production origins only: a localhost multi-surface server must not +// install competing root-scope workers. Tests use separate secure origins. +if (location.hostname === `${app}.justworks.cash` && 'serviceWorker' in navigator) { + navigator.serviceWorker.register(`/${app}-sw.js`, {scope: '/', updateViaCache: 'none'}) + .catch(() => {install.title = 'Installation may be unavailable; the live website still works.';}); +} +export function mountAppControls(host) { + host.append(controls); + if (app === 'pos' && 'ResizeObserver' in window) { + new ResizeObserver(() => document.body.style.setProperty('--app-toolbar-height', `${host.getBoundingClientRect().height}px`)).observe(host); + } +} diff --git a/docker/justworks/business/public/assets/app.css b/docker/justworks/business/public/assets/app.css new file mode 100644 index 00000000..a9933478 --- /dev/null +++ b/docker/justworks/business/public/assets/app.css @@ -0,0 +1 @@ +:root{color-scheme:light;--bg:var(--jw-bg,#fafafa);--panel:var(--jw-surface,#fafafa);--soft:var(--jw-surface-sunken,#f2f4f3);--line:var(--jw-line,rgba(10,10,10,.1));--line-strong:var(--jw-line-strong,rgba(10,10,10,.2));--text:var(--jw-text-primary,#0a0a0a);--body:var(--jw-text-body,#454545);--muted:var(--jw-text-muted,#737373);--accent:var(--jw-accent,#f4511e);--green:var(--jw-success,#08806b);--gold:var(--jw-highlight,#ffcc4d);font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html{min-height:100%;background:var(--bg)}body{min-height:100vh;margin:0;background:var(--jw-bg-gradient,linear-gradient(180deg,#fafafa,#e9ebea));color:var(--text)}button,input,textarea{font:inherit}.shell{width:min(1240px,calc(100% - 40px));margin:auto;padding:24px 0 70px}.top{min-height:72px;display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:clamp(36px,7vw,80px)}.brand{display:flex;align-items:center;gap:12px;font-weight:900;font-size:17px;letter-spacing:-.02em}.brand small{display:block;color:var(--muted);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.hero-copy{margin-bottom:34px}.eyebrow{margin:0;color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:11px;font-weight:900}.muted{color:var(--muted)}h1,h2{font-family:var(--jw-font-display,"Syne Variable",system-ui,sans-serif);font-synthesis:none}h1{max-width:850px;font-size:clamp(44px,7vw,88px);line-height:.92;letter-spacing:-.055em;margin:12px 0 18px;font-weight:650}.hero-copy>p:last-child{max-width:650px;color:var(--body);font-size:18px;line-height:1.55}.grid{display:grid;grid-template-columns:minmax(0,.9fr) minmax(360px,1.35fr);gap:16px}.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--jw-radius-lg,24px);padding:clamp(20px,3vw,30px);box-shadow:var(--jw-shadow-sm,0 8px 22px rgba(0,0,0,.045))}.card h2{margin:7px 0 4px;font-size:clamp(25px,3vw,36px);letter-spacing:-.04em}.switchrow,.row,.orderhead{display:flex;justify-content:space-between;align-items:center;gap:14px}.switch{position:relative;width:54px;height:30px;margin:0;flex:none}.switch input{opacity:0}.switch span{position:absolute;inset:0;background:#c9cecb;border-radius:99px;cursor:pointer;box-shadow:inset 0 0 0 1px var(--line-strong)}.switch span:after{content:"";position:absolute;width:22px;height:22px;left:4px;top:4px;border-radius:50%;background:#fafafa;transition:.2s;box-shadow:0 2px 5px rgba(0,0,0,.18)}.switch input:checked+span{background:var(--green)}.switch input:checked+span:after{transform:translateX(24px)}label{display:block;color:var(--muted);font-size:12px;font-weight:800;margin:20px 0 7px;text-transform:uppercase;letter-spacing:.06em}input,textarea{width:100%;background:var(--soft);border:1px solid var(--line);border-radius:12px;padding:12px 13px;color:var(--text);outline:none}input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--jw-accent-ring,rgba(244,81,30,.18))}.products{display:grid;gap:8px}.productedit{display:grid;grid-template-columns:1fr 100px;gap:8px}.button{border:1px solid var(--text);border-radius:999px;padding:11px 18px;font-weight:850;cursor:pointer;background:var(--text);color:var(--bg);transition:transform .15s ease}.button:hover{transform:translateY(-1px)}.button.secondary{background:transparent;color:var(--text);border-color:var(--line-strong)}.button.small{padding:7px 11px;font-size:12px}.orders{display:grid;gap:10px;margin-top:18px}.order{border:1px solid var(--line);border-radius:15px;padding:16px;background:var(--soft)}.order[data-status=closed]{opacity:.55}.badge{display:inline-flex;align-items:center;border:1px solid transparent;border-radius:99px;padding:6px 10px;font-size:10px;font-weight:850;text-transform:uppercase;letter-spacing:.05em;background:color-mix(in srgb, var(--gold) 20%, var(--panel));color:color-mix(in srgb, var(--gold) 65%, var(--text))}.order[data-status=accepted] .badge{background:color-mix(in srgb, var(--jw-info,#2764ff) 18%, var(--panel));color:var(--jw-info,#2764ff)}.order[data-status=delivered] .badge{background:color-mix(in srgb, var(--green) 20%, var(--panel));color:var(--green)}.order[data-status=closed] .badge{background:var(--soft);color:var(--muted)}.items{font-size:18px;font-weight:850;margin:12px 0}.actions{display:flex;gap:7px;flex-wrap:wrap}.empty{padding:60px 24px;text-align:center;border:1px dashed var(--line-strong);border-radius:15px;color:var(--muted)}.demo-links{display:flex;gap:8px;flex-wrap:wrap}.demo-links a{color:var(--text);border:1px solid var(--line-strong);padding:9px 14px;border-radius:99px;text-decoration:none;font-size:13px;font-weight:750;background:var(--panel)}.table-destination{margin-top:24px;padding-top:20px;border-top:1px solid var(--line)}#tableUrl{word-break:break-all;margin-top:8px;font-size:12px;color:var(--body)}.toast{position:fixed;right:20px;bottom:20px;background:var(--green);color:#fff;padding:12px 18px;border-radius:12px;font-weight:800;opacity:0;transform:translateY(10px);transition:.2s}.toast.show{opacity:1;transform:none}@media(prefers-color-scheme:dark){:root{color-scheme:dark}}@media(max-width:800px){.shell{width:min(100% - 28px,1240px)}.grid{grid-template-columns:1fr}.top{align-items:flex-start;flex-direction:column;margin-bottom:40px}.hero-copy>p:last-child{font-size:16px}.demo-links{width:100%}.demo-links a{flex:1;text-align:center}} diff --git a/docker/justworks/business/public/assets/archipelago-paths.js b/docker/justworks/business/public/assets/archipelago-paths.js new file mode 100644 index 00000000..58f44409 --- /dev/null +++ b/docker/justworks/business/public/assets/archipelago-paths.js @@ -0,0 +1,10 @@ +// Keep the unchanged Business UI's API requests in its isolated app namespace. +const businessFetch = window.fetch.bind(window); +window.fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.origin === location.origin && url.pathname.startsWith('/api/')) { + url.pathname = '/business-demo' + url.pathname; + input = input instanceof Request ? new Request(url, input) : url; + } + return businessFetch(input, init); +}; diff --git a/docker/justworks/business/public/assets/book.css b/docker/justworks/business/public/assets/book.css new file mode 100644 index 00000000..9200ece5 --- /dev/null +++ b/docker/justworks/business/public/assets/book.css @@ -0,0 +1,3 @@ +:root{--orange:#ff4f1f;--black:#080808;--ink:#0d0d0d;--muted:#747873;--line:#d3d6d2;--soft:#eff1ee;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html,body{min-height:100%;margin:0;background:var(--black)}button,input{font:inherit}.visual{position:relative;min-height:160px;padding:18px 20px 24px;display:flex;flex-direction:column;color:#fff;background:#111 var(--hero,linear-gradient(135deg,#252525,#090909)) center/cover}.wash{position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.88),rgba(0,0,0,.2)),linear-gradient(90deg,rgba(0,0,0,.75),transparent)}nav,.identity{position:relative;z-index:1}nav{display:flex;justify-content:space-between;align-items:center}nav a{color:#fff;text-decoration:none;font-size:14px;font-weight:900}nav small{font-size:8px;font-weight:900;letter-spacing:.15em;text-transform:uppercase}.identity{margin-top:auto}.identity p,.eyebrow{margin:0;color:var(--orange);font-size:10px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.identity h1{margin:15px 0 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(34px,11vw,48px);line-height:.85;letter-spacing:-.06em}.identity>span{display:none}.booking{min-height:calc(100dvh - 160px);padding:26px 20px max(24px,env(safe-area-inset-bottom));background:#fff}form{max-width:560px;margin:auto}h2{max-width:500px;margin:9px 0 25px;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(38px,11vw,54px);font-weight:620;line-height:.92;letter-spacing:-.055em}.mode{display:grid;grid-template-columns:1fr 1fr;margin-bottom:18px;padding:4px;border-radius:12px;background:var(--soft)}.mode button,.soon button{min-height:47px;border:0;border-radius:9px;background:transparent;color:var(--muted);font-weight:850}.mode button.active,.soon button.active{background:var(--black);color:#fff}.booking label,#when>label{display:block;margin:17px 0 7px;color:var(--muted);font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase}.booking label small{font-weight:600}.booking input{width:100%;min-height:51px;padding:14px;border:1px solid var(--line);border-radius:11px;background:var(--soft);color:var(--ink);outline:none}.booking input:focus{border-color:var(--orange);box-shadow:0 0 0 3px rgba(255,79,31,.14)}.soon{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.soon button{border:1px solid var(--line);font-size:13px}.guests{display:grid;grid-template-columns:55px 1fr 55px;align-items:center;min-height:57px;border:1px solid var(--line);border-radius:11px;background:var(--soft)}.guests button{height:100%;border:0;background:transparent;font-size:24px}.guests strong{text-align:center;font-size:22px}#submit{width:100%;min-height:58px;display:flex;justify-content:space-between;align-items:center;margin-top:23px;padding:0 18px;border:0;border-radius:11px;background:var(--orange);color:#fff;font-weight:900}.privacy{text-align:center;color:var(--muted);font-size:9px}.loading,.failure,.confirmed{min-height:100vh;display:grid;place-content:center;text-align:center;padding:28px;background:var(--black);color:#fff}.loading>span,.failure>span,.confirmed>span{font-size:70px}.failure h1,.confirmed h1{max-width:800px;margin:18px auto;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(45px,8vw,90px);line-height:.9}.failure small,.confirmed>small{color:#888}.confirmed>span{width:90px;height:90px;display:grid;place-items:center;margin:0 auto 24px;border-radius:50%;background:#16806a;font-size:44px}.confirmed>p:not(.eyebrow){max-width:620px;color:#bbb;font-size:17px;line-height:1.6} +@media(min-width:760px){body{padding:24px}.visual,.booking{min-height:min(780px,calc(100dvh - 48px))}#app:has(.visual){display:grid;grid-template-columns:minmax(330px,44%) minmax(430px,56%);width:min(1180px,100%);margin:auto}.visual{padding:28px 34px 40px;border-radius:28px 0 0 28px}.identity p{font-size:11px}.identity h1{font-size:clamp(58px,6vw,88px)}.identity>span{display:block;max-width:330px;margin-top:20px;color:#ccc}.booking{display:grid;align-items:center;padding:44px clamp(40px,5vw,72px);border-radius:0 28px 28px 0}h2{font-size:clamp(48px,4.6vw,66px)}} +@media(max-width:410px){.visual{min-height:145px}.booking{min-height:calc(100dvh - 145px);padding-top:22px}h2{font-size:37px}.soon button{font-size:12px}} diff --git a/docker/justworks/business/public/assets/book.js b/docker/justworks/business/public/assets/book.js new file mode 100644 index 00000000..f9743c06 --- /dev/null +++ b/docker/justworks/business/public/assets/book.js @@ -0,0 +1,13 @@ +import {frame} from './ds/frame.js'; +import {npubFrom} from './ds/env.js'; +const app=document.querySelector('#app'),npub=npubFrom();let merchant,mode='now',delay=0,guests=2,busy=false; +const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +function timeValue(offsetMinutes){const date=new Date(Date.now()+offsetMinutes*60000);date.setMinutes(Math.ceil(date.getMinutes()/30)*30,0,0);return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}T${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`} +function error(message){app.innerHTML=`
⌁

Table booking

${esc(message)}

Use book.justworks.cash/?npub=npub1…
`} +function render(data){merchant=data.merchant;document.title=`Book ${merchant.name} · Just Works`;frame(app,{label:'Bookings',surface:'book',html:`

Reserve a table

When should we expect you?

Your table at ${esc(merchant.name)}, without the phone call.

⌁
${esc(merchant.name)}Taking bookings
${[['now','Soon'],['later','Later']].map(([value,label])=>``).join('')}

No account · no app · sent directly to ${esc(merchant.name)}

`});document.querySelectorAll('[data-mode]').forEach(button=>button.onclick=()=>setMode(button.dataset.mode));document.querySelector('#less').onclick=()=>setGuests(guests-1);document.querySelector('#more').onclick=()=>setGuests(guests+1);document.querySelector('#form').onsubmit=submit;renderWhen()} +function setMode(next){mode=next;document.querySelectorAll('[data-mode]').forEach(button=>button.classList.toggle('active',button.dataset.mode===mode));renderWhen()} +function setGuests(next){guests=Math.max(1,Math.min(20,next));document.querySelector('#guestCount').textContent=guests} +function renderWhen(){const host=document.querySelector('#when');if(mode==='now'){host.innerHTML=``;host.querySelectorAll('[data-delay]').forEach(button=>button.onclick=()=>{delay=Number(button.dataset.delay);renderWhen()})}else host.innerHTML=``} +async function submit(event){event.preventDefault();if(busy)return;busy=true;const button=document.querySelector('#submit');button.disabled=true;button.firstChild.textContent='Sending… ';try{const scheduled=mode==='now'?new Date(Date.now()+delay*60000):new Date(document.querySelector('#date').value);const response=await fetch('/api/bookings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,name:document.querySelector('#name').value,notes:document.querySelector('#notes').value,guests,mode,scheduled_at:scheduled.toISOString()})});const result=await response.json();if(!response.ok)throw new Error(result.error||'Could not book');confirm(result)}catch(error){button.disabled=false;button.firstChild.textContent='Try again ';alert(error.message)}finally{busy=false}} +function confirm(result){const date=new Date(result.scheduled_at);frame(app,{label:'Bookings',surface:'book',html:`
✓

Request sent

Table for ${result.guests} requested.

${esc(merchant.name)} received your booking for ${date.toLocaleDateString([],{weekday:'long',day:'numeric',month:'long'})} at ${date.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}.

The merchant still needs to confirm it. Keep this screen open for your reference.
`})} +if(!npub)error('Add the merchant npub to book.');else fetch(`/api/order-merchant?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async response=>{const data=await response.json();if(!response.ok)throw new Error(data.error);render(data)}).catch(reason=>error(reason.message||'Merchant unavailable')); diff --git a/docker/justworks/business/public/assets/business-deps.css b/docker/justworks/business/public/assets/business-deps.css new file mode 100644 index 00000000..25f8542e --- /dev/null +++ b/docker/justworks/business/public/assets/business-deps.css @@ -0,0 +1,4 @@ +.module.needs-setup{border-color:#d4d4cf;background:#e8e8e4;color:#696965} +.module.needs-setup .module-icon{background:#aaa} +.module.needs-setup .module-actions>a{max-width:76%;color:#111;pointer-events:auto} +.setup-lock{color:#999;font-size:9px;font-weight:850;letter-spacing:.1em;text-transform:uppercase} diff --git a/docker/justworks/business/public/assets/business-destinations.js b/docker/justworks/business/public/assets/business-destinations.js new file mode 100644 index 00000000..cdb15d75 --- /dev/null +++ b/docker/justworks/business/public/assets/business-destinations.js @@ -0,0 +1,72 @@ +import { link, base } from './ds/frame.js'; +import { prod } from './ds/env.js'; +import { moduleQrUrl } from './module-qr.js?v=2'; + +// The dashboard and the printable master use one destination catalog. +export const freeDefinitions = [ + ['justworks', 'Just Works page', 'Your public merchant page', 'JW'], + ['tip', 'Tip', 'Bitcoin tips and community wall', '⚡'], + ['nsite', 'nsite', 'Your site on Nostr', 'NS'], + ['nostr', 'Nostr profile', 'Your signed identity', 'NP'], + ['maps', 'Maps', 'Find your place on the map', '⌖'], + ['gallery', 'Gallery', 'Photos and discovery', '□'], +]; +export const businessDefinitions = [ + ['pos', 'POS', 'Run orders and payments', 'Counter view', '↗', false], + ['order', 'Order', 'Order to table', 'Customer', '+', true], + ['pay', 'Pay Bill', 'Settle an open order', 'Customer', '€', true], + ['payandgo', 'Just Pay', 'Pay a paper receipt', 'Customer', '₿', true], + ['booking', 'Bookings', 'Tables now or later', 'Customer', '⌁', true, 'book'], + ['screen', 'Screen', 'Merchant media display', '4K display', '▣', true], + ['stats', 'Stats', 'Audience, actions and revenue', 'Merchant view', '↗', true, 'business'], + ['events', 'Events', 'Merchant Nostr events', 'Coming soon', '◌', true], +]; + +export function businessUrl(npub, sheet = false) { + const path = sheet ? '/qr-sheet' : prod ? '/' : '/dashboard'; + return `${prod ? 'https://business.justworks.cash' : location.origin + base}${path}?npub=${encodeURIComponent(npub)}`; +} + +export function moduleDestination(definition, data, npub) { + const [id, , , , , configurable, target = id] = definition; + const state = data.modules?.[id] || {}; + const paymentSetup = ['pay', 'payandgo'].includes(id) && !data.payment_ready; + const disabled = id === 'events' || state.status === 'coming-soon' || paymentSetup; + const url = paymentSetup + ? `${base}/profile?npub=${encodeURIComponent(npub)}#lightning` + : id === 'stats' + ? `${prod ? 'https://business.justworks.cash' : base}/stats?npub=${encodeURIComponent(npub)}` + : prod ? `https://${target}.justworks.cash/?npub=${encodeURIComponent(npub)}` : link(target); + return {state, paymentSetup, disabled, url, + qrUrl: disabled ? '' : moduleQrUrl(url, npub), + available: !disabled && (!configurable || !!state.enabled)}; +} + +export function sheetDestinations(data, npub) { + return [ + {id: 'business', title: 'Business dashboard', url: businessUrl(npub)}, + ...freeDefinitions.filter(([id]) => data.links?.[id] && data.links[id] !== '#') + .map(([id, title]) => ({id, title, url: data.links[id]})), + ...businessDefinitions.flatMap(definition => { + const destination = moduleDestination(definition, data, npub); + return destination.available ? [{id: definition[0], title: definition[1], url: destination.qrUrl}] : []; + }), + ]; +} + +// Verify the Bech32 checksum and the 32-byte public-key payload, without a CDN. +export function validNpub(value) { + if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(value || '')) return false; + const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + const words = [...value.slice(5)].map(char => alphabet.indexOf(char)); + let checksum = 1; + const expanded = [...'npub'].map(char => char.charCodeAt(0) >> 5) + .concat(0, [...'npub'].map(char => char.charCodeAt(0) & 31), words); + const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + for (const word of expanded) { + const top = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ word; + for (let bit = 0; bit < 5; bit++) if ((top >>> bit) & 1) checksum ^= generators[bit]; + } + return checksum === 1 && (words[51] & 15) === 0; +} diff --git a/docker/justworks/business/public/assets/business-sso.css b/docker/justworks/business/public/assets/business-sso.css new file mode 100644 index 00000000..38bbde73 --- /dev/null +++ b/docker/justworks/business/public/assets/business-sso.css @@ -0,0 +1,2 @@ +.exchange {max-width:36rem; margin:2rem auto; padding:1.5rem; font:1rem/1.5 system-ui,sans-serif; overflow-wrap:anywhere} +.exchange h1 {font-size:1.6rem}.exchange button {padding:.8rem 1rem; margin:.3rem; min-height:44px; cursor:pointer}.exchange button:disabled {cursor:default; opacity:.6}.exchange :focus-visible {outline:3px solid #e99a30; outline-offset:3px}.jw-session-transfer {padding:.75rem; margin-bottom:1rem; width:100%; cursor:pointer} diff --git a/docker/justworks/business/public/assets/business-sso.js b/docker/justworks/business/public/assets/business-sso.js new file mode 100644 index 00000000..0fec0999 --- /dev/null +++ b/docker/justworks/business/public/assets/business-sso.js @@ -0,0 +1,64 @@ +// Business-only session transfer. Identity verification remains with Core/Keys/Login. +export async function post(path, body) { + const response = await fetch(path, {method:'POST', credentials:'same-origin', cache:'no-store', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Please log in and try again.'); + return result; +} + +export async function transferSession(npub, status) { + const popup = window.open('about:blank', 'justworks-session', 'popup,width=560,height=680'); + if (!popup) throw new Error('Allow the login window, or use the login form below.'); + let remove = () => {}; + try { + status.textContent = 'Confirm access in the Business login window…'; + const pending = await post('/api/business-sso/start', {npub, return_path:location.pathname + location.search}); + await new Promise((resolve, reject) => { + let busy = false; + const timeout = setTimeout(() => { remove(); reject(new Error('Login window expired. Please try again.')); }, 180000); + const onMessage = async event => { + if (busy || event.origin !== pending.source_origin || event.source !== popup || event.data?.type !== 'justworks-session' || event.data.request_id !== pending.request_id) return; + busy = true; + try { + await post('/api/business-sso/complete', {request_id:pending.request_id, code:event.data.code}); + remove(); resolve(); + } catch (error) { remove(); reject(error); } + }; + remove = () => { clearTimeout(timeout); window.removeEventListener('message', onMessage); }; + window.addEventListener('message', onMessage); + popup.location = pending.authorize_url; + }); + status.textContent = 'Business session connected.'; + // Keep the existing URL and fragment; no server-provided redirect is followed. + location.reload(); + } finally { remove(); if (!popup.closed) popup.close(); } +} + +if (location.pathname.endsWith('/business-sso')) { + const requestId = location.hash.slice(1); + history.replaceState(null, '', location.pathname + location.search); + const status = document.querySelector('#exchange-status'); + const destination = document.querySelector('#exchange-destination'); + const approve = document.querySelector('#exchange-approve'); + async function details() { + approve.disabled = true; + try { + const result = await post('/api/business-sso/details', {request_id:requestId}); + destination.textContent = new URL(result.audience).hostname; + status.textContent = 'Your verified Business login will be used for this site only.'; + approve.disabled = !window.opener; + if (!window.opener) status.textContent = 'Start session transfer from the site you want to open.'; + } catch (error) { status.textContent = 'Use Login above, then confirm access. ' + error.message; } + } + approve.addEventListener('click', async () => { + approve.disabled = true; + try { + const result = await post('/api/business-sso/approve', {request_id:requestId}); + window.opener.postMessage({type:'justworks-session', request_id:requestId, code:result.code}, result.audience); + status.textContent = 'Access confirmed. Return to your original window.'; + } catch (error) { status.textContent = error.message; } + }); + document.querySelector('#exchange-cancel').onclick = () => window.close(); + window.addEventListener('jw-session-changed', details); + details(); +} diff --git a/docker/justworks/business/public/assets/business.css b/docker/justworks/business/public/assets/business.css new file mode 100644 index 00000000..b2e1f588 --- /dev/null +++ b/docker/justworks/business/public/assets/business.css @@ -0,0 +1,7 @@ +:root{--orange:#ff4f1f;--black:#080808;--ink:#101010;--muted:#777;--line:#deded9;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html{background:var(--black)}body{min-height:100vh;margin:0;background:#f5f5f1;color:var(--ink)}a{color:inherit}.top{position:fixed;top:0;left:0;right:0;z-index:40;height:76px;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);background:transparent;border-bottom:1px solid transparent;color:#fff;transition:background .3s ease,border-color .3s ease}.top.is-scrolled{border-bottom-color:rgba(255,255,255,.09);background:linear-gradient(180deg,rgba(8,8,8,.72),rgba(8,8,8,.45));backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.brand{display:flex;align-items:center;gap:11px;text-decoration:none;font-size:16px;font-weight:900}.brand [data-logo]{width:26px;display:block}.brand i{width:25px;height:25px;display:grid;place-items:center;border:1px solid #fff;font-style:normal}.brand b{font-size:16px;font-weight:900}.brand small{display:inline;font-size:16px;font-weight:900;color:inherit;letter-spacing:0;text-transform:none}.brand small:before{content:"·";color:#90908d;margin:0 9px}.profile{margin-left:auto;padding:9px 13px;border:1px solid #393939;border-radius:99px;text-decoration:none;font-size:11px;font-weight:800}.merchant{position:relative;min-height:clamp(330px,48vw,610px);display:flex;align-items:flex-end;padding:clamp(24px,5vw,68px);overflow:hidden;color:#fff;background:#111 var(--hero,linear-gradient(135deg,#252525,#090909)) center/cover}.merchant-wash{position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.93),rgba(0,0,0,.12) 75%),linear-gradient(90deg,rgba(0,0,0,.5),transparent)}.merchant-copy,.merchant-stats{position:relative;z-index:1}.merchant-copy{max-width:850px}.merchant-copy p,.eyebrow{margin:0;color:var(--orange);font-size:10px;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.merchant-copy h1{max-width:900px;margin:14px 0 20px;font-family:var(--jw-font-display,"Syne Variable",system-ui);font-size:clamp(54px,10vw,140px);font-weight:620;line-height:.78;letter-spacing:-.065em}.merchant-copy>span{color:#d1d1ce;font-size:clamp(14px,2vw,18px)}.merchant-stats{display:flex;align-items:stretch;gap:8px;margin-left:auto}.merchant-stats>div,.merchant-stats>a{min-width:120px;display:flex;flex-direction:column;justify-content:flex-end;padding:16px;border:1px solid rgba(255,255,255,.22);background:rgba(0,0,0,.45);backdrop-filter:blur(12px);text-decoration:none}.merchant-stats strong{font-size:28px}.merchant-stats span{color:#aeadab;font-size:10px;text-transform:uppercase}.merchant-stats>a{justify-content:space-between;background:var(--orange);border-color:var(--orange);font-size:13px;font-weight:900}.merchant-stats b{text-align:right;font-size:20px}.modules-head{display:flex;justify-content:space-between;gap:40px;align-items:end;padding:clamp(46px,7vw,92px) clamp(18px,4vw,56px) 30px}.modules-head h2{max-width:760px;margin:10px 0 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(38px,6vw,78px);font-weight:620;line-height:.9;letter-spacing:-.055em}.modules-head>p{max-width:390px;margin:0;color:#686864;line-height:1.55}.modules{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:0 clamp(18px,4vw,56px) clamp(60px,8vw,110px)}.module{min-height:290px;display:flex;flex-direction:column;justify-content:space-between;padding:22px;border:1px solid var(--line);background:#fff;transition:transform .18s,border-color .18s}.module:hover{transform:translateY(-3px);border-color:#aaa}.module-top,.module-actions{display:flex;justify-content:space-between;align-items:center;gap:12px}.module-icon{width:45px;height:45px;display:grid;place-items:center;background:var(--black);color:#fff;font-size:19px;font-weight:900}.module-audience,.always{color:#858580;font-size:9px;font-weight:850;letter-spacing:.1em;text-transform:uppercase}.module h2{margin:0 0 9px;font-family:var(--jw-font-display,"Syne Variable");font-size:36px;letter-spacing:-.05em}.module p{margin:0;color:#72726e;font-size:14px}.module-actions{padding-top:19px;border-top:1px solid var(--line)}.module-actions>a{font-size:13px;font-weight:900;text-decoration:none}.module-actions>a span{color:var(--orange);margin-left:5px}.switch{position:relative;width:48px;height:28px}.switch input{position:absolute;opacity:0}.switch>span{position:absolute;inset:0;border-radius:99px;background:#d5d5d0;cursor:pointer}.switch>span:after{content:"";position:absolute;left:4px;top:4px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 2px 5px rgba(0,0,0,.2);transition:.2s}.switch input:checked+span{background:var(--orange)}.switch input:checked+span:after{transform:translateX(20px)}.switch input:disabled+span{cursor:not-allowed;opacity:.5}.module.soon{background:#ededE9}.module.soon .module-icon{background:#aaa}.module.soon .module-actions>a{pointer-events:none;color:#999}footer{display:flex;gap:14px;align-items:center;padding:22px clamp(18px,4vw,56px);background:var(--black);color:#8d8d89;font-size:10px}footer span{text-transform:uppercase;letter-spacing:.12em}footer code{overflow:hidden;text-overflow:ellipsis}.loading,.failure{min-height:100vh;display:grid;place-content:center;text-align:center;padding:25px;background:var(--black);color:#fff}.loading>span,.failure>span{font-size:70px}.failure h1{max-width:760px;margin:16px auto;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(44px,8vw,90px);line-height:.9}.failure>p:not(.eyebrow){color:#999}.failure form{width:min(520px,90vw);display:flex;margin:28px auto 0}.failure input{min-width:0;flex:1;padding:16px;border:1px solid #333;background:#111;color:#fff}.failure button{border:0;padding:0 18px;background:var(--orange);color:#fff;font-weight:900}.toast{position:fixed;z-index:10;right:18px;bottom:18px;padding:13px 17px;background:#167a62;color:#fff;font-size:12px;font-weight:850;opacity:0;transform:translateY(12px);transition:.2s}.toast.show{opacity:1;transform:none}.toast.error{background:#b22} +.free-head{background:var(--black);color:#fff}.free-head>p{color:#aaa}.free-links{display:grid;grid-template-columns:repeat(6,1fr);gap:1px;padding:0 clamp(18px,4vw,56px) clamp(62px,7vw,90px);background:var(--black)}.free-link{min-height:180px;display:flex;flex-direction:column;justify-content:space-between;gap:20px;padding:22px;background:#151515;color:#fff;text-decoration:none;transition:background .18s,transform .18s}.free-link:hover{z-index:1;background:#202020;transform:translateY(-3px)}.free-icon{width:42px;height:42px;display:grid;place-items:center;border:1px solid #414141;color:var(--orange);font-size:14px;font-weight:900}.free-link h2{margin:0 0 7px;font-family:var(--jw-font-display,"Syne Variable");font-size:25px;letter-spacing:-.04em}.free-link p{margin:0;color:#8d8d89;font-size:12px}.free-link>b{align-self:flex-end;color:var(--orange);font-size:20px}.business-head{padding-top:clamp(62px,8vw,110px)} +@media(max-width:1050px){.free-links{grid-template-columns:repeat(3,1fr)}.modules{grid-template-columns:repeat(2,1fr)}.merchant{align-items:flex-end}.merchant-stats{position:absolute;right:24px;top:24px}.merchant-stats>div{display:none}} +@media(max-width:680px){.top{height:66px}.profile{font-size:10px}.merchant{min-height:380px;padding:24px 18px}.merchant-copy h1{font-size:17vw}.merchant-copy>span{display:block;max-width:280px}.merchant-stats{right:18px;top:18px}.merchant-stats>a{min-width:105px;height:80px;padding:12px}.modules-head{display:block;padding-top:48px}.modules-head>p{margin-top:20px}.free-links{grid-template-columns:1fr 1fr}.free-link{min-height:160px;padding:18px}.free-link:first-child{grid-column:1/-1}.modules{grid-template-columns:1fr}.module{min-height:230px}.failure form{display:grid}.failure button{min-height:52px}} +@media(max-width:680px){.merchant{min-height:560px;display:flex;flex-direction:column;align-items:stretch;justify-content:flex-end;padding:100px 18px 24px}.merchant-copy{width:100%}.merchant-copy h1{font-size:17vw}.merchant-copy>span{max-width:100%}.merchant-stats{position:relative;inset:auto;width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:22px 0 0}.merchant-stats>div{display:flex;min-width:0;min-height:76px;padding:12px}.merchant-stats>a{min-width:0;height:auto;min-height:76px;padding:12px}.merchant-stats strong{font-size:22px}.merchant-stats span{font-size:8px}.merchant-stats>a{font-size:11px}} +.brand [data-logo]{width:var(--jw-app-icon-size)!important;height:var(--jw-app-icon-size)!important} +.stats-shortcut{width:38px;height:38px;display:grid;place-items:center;flex:0 0 auto;margin-left:auto;border:1px solid #393939;border-radius:50%;color:#fff;text-decoration:none;transition:background .18s,border-color .18s,transform .18s}.stats-shortcut+.profile{margin-left:0}.stats-shortcut svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.stats-shortcut:hover{border-color:var(--orange);background:var(--orange);transform:translateY(-1px)}@media(max-width:680px){.stats-shortcut{width:36px;height:36px}.profile{display:none}} diff --git a/docker/justworks/business/public/assets/business.js b/docker/justworks/business/public/assets/business.js new file mode 100644 index 00000000..64826f29 --- /dev/null +++ b/docker/justworks/business/public/assets/business.js @@ -0,0 +1,89 @@ +import { mountLogo } from "./ds/logo.js"; +import { startNavScroll } from "./ds/header.js"; +import { mountMobileChrome, base } from "./ds/frame.js"; +import { npubFrom, prod } from "./ds/env.js"; +import { qrButton, mountModuleQrs } from "./module-qr.js?v=3"; +import { freeDefinitions, businessDefinitions, moduleDestination, businessUrl } from './business-destinations.js?v=1'; +const app = document.querySelector("#app"), + toast = document.querySelector("#toast"); +const npub = npubFrom(); +const esc = (value) => + String(value ?? "").replace( + /[&<>"']/g, + (char) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + char + ], + ); +function freeCard([id, name, copy, icon], data) { + const url = data.links?.[id] || "#"; + const qr = url === '#' ? '' : qrButton(id, name, url, {free: true}); + return ``; +} +function moduleCard( + [id, name, copy, audience, icon, configurable, target = id], + data, +) { + const {state, paymentSetup, disabled, url, qrUrl} = moduleDestination( + [id, name, copy, audience, icon, configurable, target], data, npub); + const qr = disabled ? '' : qrButton(id, name, qrUrl, {hidden: configurable && !state.enabled}); + return `
${icon}${paymentSetup ? "Setup required" : esc(audience)}

${esc(name)}

${paymentSetup ? "Add the merchant’s Lightning address before accepting payments." : esc(copy)}

${qr}
${paymentSetup ? "Change Lightning address" : "Open"} ↗${configurable && !paymentSetup ? `` : paymentSetup ? 'Locked' : 'Operations'}
`; +} +function render(data) { + const merchant = data.merchant, + image = data.images[0] || ""; + document.title = `${merchant.name} · Just Works Business`; + app.innerHTML = `
Just WorksBusinessView nsite ↗

${esc([merchant.category, merchant.area].filter(Boolean).join(" · "))}

${esc(merchant.name)}

Free public presence. Business tools when you need them.
${data.counts.orders}Open orders
${data.counts.payments}Payments
QR in DIN A4 ↗

Free links

Included with Just Works.

Your merchant identity, website and discovery pages stay free.

Business modules

Tools that run the place.

Every module carries this merchant’s npub. Switch it on, share its link, or put it behind a QR code.

${businessDefinitions.map((item) => moduleCard(item, data)).join("")}
npub${esc(npub)}
`; + document + .querySelectorAll("[data-module]") + .forEach((input) => input.addEventListener("change", save)); + document + .querySelectorAll("[data-logo]") + .forEach((host) => mountLogo(host, { icon: true })); + startNavScroll(document.querySelector("[data-topbar]")); + mountModuleQrs(app); +} +async function save() { + const input = this; + input.disabled = true; + const modules = Object.fromEntries( + [...document.querySelectorAll("[data-module]")].map((item) => [ + item.dataset.module, + item.checked, + ]), + ); + try { + const response = await fetch("/api/business-config", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ npub, modules }), + }); + if (!response.ok) + throw new Error((await response.json()).error || "Could not save"); + const qr = input.closest('.module').querySelector('[data-module-qr]'); + if (qr) qr.hidden = !input.checked; + toast.textContent = `${input.checked ? "Activated" : "Disabled"} ${input.closest(".module").querySelector("h2").textContent}`; + toast.classList.add("show"); + setTimeout(() => toast.classList.remove("show"), 1500); + } catch (error) { + input.checked = !input.checked; + toast.textContent = error.message; + toast.classList.add("show", "error"); + setTimeout(() => toast.classList.remove("show", "error"), 2200); + } finally { + input.disabled = false; + } +} +function fail(message) { + app.innerHTML = `
∩

Business setup

${esc(message)}

Open this page with the merchant’s npub.

`; +} +if (!npub) fail("Which merchant is this?"); +else + fetch(`/api/business?npub=${encodeURIComponent(npub)}`, { cache: "no-store" }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Merchant unavailable"); + render(data); + mountMobileChrome(app); + }) + .catch((error) => fail(error.message)); diff --git a/docker/justworks/business/public/assets/config.css b/docker/justworks/business/public/assets/config.css new file mode 100644 index 00000000..a9dd43a0 --- /dev/null +++ b/docker/justworks/business/public/assets/config.css @@ -0,0 +1 @@ +.module-console{margin-bottom:16px;padding:clamp(22px,3vw,32px);border:1px solid var(--line);border-radius:24px;background:var(--panel)}.module-console>header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start}.module-console h2{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(30px,4vw,48px);letter-spacing:-.05em;margin:8px 0}.module-console header p:last-child{color:var(--body);margin:0}.module-config-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-top:24px}.module-config{display:grid;grid-template-columns:42px 1fr auto;gap:10px;padding:14px;border:1px solid var(--line);border-radius:16px;background:var(--soft)}.module-config-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--panel);font-size:18px;font-weight:900}.module-config h3{margin:2px 0;font-size:14px}.module-config p{margin:0;color:var(--muted);font-size:10px}.module-config a{display:block;margin-top:8px;color:var(--text);font-size:10px;font-weight:800;text-decoration:none}.module-config .switch{width:42px;height:24px;margin:0}.module-config .switch span:after{width:16px;height:16px}.module-config .switch input:checked+span:after{transform:translateX(18px)}.module-config>small{grid-column:1/-1;color:var(--muted);font-size:9px;font-weight:850;text-transform:uppercase;letter-spacing:.08em}@media(max-width:1050px){.module-config-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:800px){.module-console>header{flex-direction:column}.module-config-grid{grid-template-columns:1fr}} diff --git a/docker/justworks/business/public/assets/dashboard.js b/docker/justworks/business/public/assets/dashboard.js new file mode 100644 index 00000000..495c23f7 --- /dev/null +++ b/docker/justworks/business/public/assets/dashboard.js @@ -0,0 +1,23 @@ +import { mountThemeToggle } from './ds/theme.js'; +import { mountLogo } from './ds/logo.js'; +import { link } from './ds/frame.js'; +import { prod } from './ds/env.js'; +mountThemeToggle(document.querySelector('#themeToggle')); +for (const host of document.querySelectorAll('[data-logo]')) mountLogo(host, { icon: host.dataset.logo === 'icon' }); + +const base = location.pathname.split('/').slice(0,-1).join('/') || ''; +const api = (path) => `${base}/api${path}`; +const els={enabled:document.querySelector('#enabled'),moduleState:document.querySelector('#moduleState'),moduleGrid:document.querySelector('#moduleGrid'),tableCount:document.querySelector('#tableCount'),products:document.querySelector('#products'),payments:document.querySelector('#payments'),orders:document.querySelector('#orders'),save:document.querySelector('#save'),toast:document.querySelector('#toast'),tableUrl:document.querySelector('#tableUrl'),pulse:document.querySelector('#pulse')}; +const moduleMeta={order:['Order to Table','Customer ordering','↗'],pay:['Pay Bill','Settle an open order','€'],payandgo:['Just Pay','Pay a paper receipt','€'],booking:['Bookings','Now or later','⌁'],screen:['4K Screen','Pictures and activity','▣'],gallery:['Gallery','Merchant discovery','□'],events:['Events','Nostr events','◌']}; +function moduleRows(modules){els.moduleGrid.innerHTML=Object.entries(moduleMeta).map(([id,[name,copy,icon]])=>{const item=modules[id]||{};const url=prod?(id==='booking'?'https://pos.justworks.cash/':`https://${id}.justworks.cash/`):link(id);return ``}).join('');document.querySelectorAll('[data-module]').forEach(input=>input.onchange=()=>{if(input.dataset.module==='order')els.enabled.checked=input.checked;save(true)})} +let state=null,lastOrder='',configVersion=0,toggleSaving=false; +function money(c){return `€${(c/100).toFixed(2)}`} +function productRows(products){els.products.innerHTML=products.map(p=>`
`).join('')} +function orderRows(orders){if(!orders.length){els.orders.innerHTML='
Waiting for the first table order…
';return}const labels={accepted:'Confirm',delivered:'Delivered',closed:'Close'};els.orders.innerHTML=orders.map(o=>`
Table ${o.table_number}${o.status}
${o.items.map(i=>`${i.qty}× ${i.name}`).join(' · ')}
${o.note?`
“${o.note}”
`:''}
${new Date(o.created_at).toLocaleTimeString()}
${['accepted','delivered','closed'].map(s=>``).join('')}
`).join('');document.querySelectorAll('[data-order]').forEach(b=>b.onclick=()=>updateOrder(b.dataset.order,b.dataset.status))} +function paymentRows(payments){els.payments.innerHTML=payments.slice(0,3).map(p=>`
Table ${p.table_number} paid€${(p.amount_cents/100).toFixed(2)} · ${p.amount_sats.toLocaleString()} sats
`).join('');document.querySelectorAll('[data-payment]').forEach(b=>b.onclick=()=>acknowledgePayment(b.dataset.payment,b))} +function showModuleState(enabled,saving=false){els.moduleState.textContent=saving?'saving…':enabled?'active':'disabled';els.moduleState.style.color=enabled?'var(--green)':'var(--muted)'} +async function load(initial=false){const requestedVersion=configVersion;const res=await fetch(api('/dashboard'),{cache:'no-store'});const next=await res.json();state=next;if(requestedVersion===configVersion&&!toggleSaving){els.enabled.checked=state.module_enabled;showModuleState(state.module_enabled)}els.tableCount.value=state.table_count;if(initial){productRows(state.products);moduleRows(state.modules)}paymentRows(state.payments||[]);orderRows(state.orders);const table=state.tables.find(t=>t.table_number===21)||state.tables[0];els.tableUrl.textContent=prod?`https://order.justworks.cash/?table=${encodeURIComponent(table.token)}`:`${location.origin}${link('order')}${location.search?'&':'?'}table=${encodeURIComponent(table.token)}`;if(state.orders[0]?.id&&lastOrder&&lastOrder!==state.orders[0].id){els.pulse.textContent='new order';els.pulse.style.color='var(--green)';setTimeout(()=>{els.pulse.textContent='connected';els.pulse.style.color=''},1800)}lastOrder=state.orders[0]?.id||''} +async function save(showToast=true,enabled=els.enabled.checked){const products=state.products.map(p=>{const name=document.querySelector(`[data-id="${p.id}"][data-field="name"]`).value;const price=document.querySelector(`[data-id="${p.id}"][data-field="price"]`).value;return{id:p.id,name,price_cents:Math.round(Number(price)*100)}});const modules=Object.fromEntries([...document.querySelectorAll('[data-module]')].map(input=>[input.dataset.module,{enabled:input.checked,config:state.modules[input.dataset.module]?.config||{}}]));modules.order.enabled=enabled;showModuleState(enabled,true);await fetch(api('/config'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({module_enabled:enabled,table_count:Number(els.tableCount.value),products,modules})});els.enabled.checked=enabled;showModuleState(enabled);if(showToast){els.toast.textContent='Configuration saved';els.toast.classList.add('show');setTimeout(()=>els.toast.classList.remove('show'),1300)}await load(true)} +async function updateOrder(id,status){await fetch(api(`/orders/${id}/status`),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});await load()} +async function acknowledgePayment(id,button){button.disabled=true;button.textContent='Clearing…';const res=await fetch(api(`/payments/${id}/acknowledge`),{method:'POST'});if(!res.ok){button.disabled=false;button.textContent='Try again';return}document.querySelector(`[data-payment-card="${id}"]`)?.classList.add('leaving');setTimeout(()=>load(),260)} +els.save.onclick=()=>save(true);els.enabled.onchange=async()=>{const enabled=els.enabled.checked;configVersion+=1;toggleSaving=true;els.enabled.disabled=true;try{await save(false,enabled);els.toast.textContent=enabled?'Napplet enabled':'Napplet disabled';els.toast.classList.add('show');setTimeout(()=>els.toast.classList.remove('show'),1300)}finally{els.enabled.checked=enabled;showModuleState(enabled);els.enabled.disabled=false;toggleSaving=false}};load(true);setInterval(()=>load().catch(()=>{els.pulse.textContent='reconnecting'}),1200); diff --git a/docker/justworks/business/public/assets/design-system.css b/docker/justworks/business/public/assets/design-system.css new file mode 100644 index 00000000..5c14231a --- /dev/null +++ b/docker/justworks/business/public/assets/design-system.css @@ -0,0 +1,8 @@ +/* Just Works design system: load this single file before page CSS. */ +@import url("./tokens.css"); +@import url("./ds/base.css"); +@import url("./ds.css"); +@import url("./ds/components.css"); +@import url("./ds/topbar.css"); +@import url("./ds/mobile.css"); +@import url("./ds/presets.css"); diff --git a/docker/justworks/business/public/assets/ds.css b/docker/justworks/business/public/assets/ds.css new file mode 100644 index 00000000..acb558ef --- /dev/null +++ b/docker/justworks/business/public/assets/ds.css @@ -0,0 +1,141 @@ +/* + * Design-system chrome shared with cash-just-works: the three-way theme + * toggle and the brand mark. Ported verbatim from public/css/components.css + * in the cash-just-works repo — colors resolve through tokens.css, so this + * follows the same light/dark rules as the source site. + */ + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--jw-line); + border-radius: var(--jw-radius-pill); + background: var(--jw-surface-sunken); +} + +.theme-toggle button { + width: 32px; + height: 28px; + display: inline-grid; + place-items: center; + border: 0; + border-radius: var(--jw-radius-pill); + background: transparent; + color: var(--jw-text-muted); + cursor: pointer; + transition: + background var(--jw-duration-base) var(--jw-ease-out), + color var(--jw-duration-base) var(--jw-ease-out); +} + +.theme-toggle button:hover { color: var(--jw-text-primary); } + +.theme-toggle button[aria-pressed="true"] { + background: var(--jw-surface); + color: var(--jw-text-primary); + box-shadow: var(--jw-shadow-xs); +} + +.theme-toggle button:focus-visible { + outline: 2px solid var(--jw-field-border-focus); + outline-offset: 1px; +} + +.theme-toggle svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Brand mark: one continuous line, path swapped by assets/ds/logo.js. */ + +.jw-mark { + width: var(--jw-mark-width, 2.1em); + height: auto; + flex: none; + display: block; + overflow: visible; +} + +.jw-mark-portal { + width: auto; + height: var(--jw-mark-height, 1.35em); +} + +.jw-mark-portal { filter: var(--jw-portal-depth); } + +.jw-portal-frame { fill: var(--jw-text-primary); } +.jw-portal-head { stop-color: var(--jw-text-subtle); } +.jw-portal-floor { stop-color: transparent; } + +.jw-mark-path { + fill: none; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.jw-mark-stop-a { stop-color: var(--jw-accent); } +.jw-mark-stop-b { stop-color: var(--jw-highlight); } + +.brand .jw-mark { margin-right: -0.15em; } + +/* App icon: the mark in a translucent rimmed container. Fixed dark ground in + both themes on purpose (see tokens.css). */ + +.jw-app-icon { + position: relative; + width: var(--jw-app-icon-size); + height: var(--jw-app-icon-size); + flex: none; + display: grid; + place-items: center; + border-radius: var(--jw-app-icon-radius); + background: var(--jw-app-icon-bg); + -webkit-backdrop-filter: blur(var(--jw-app-icon-blur)); + backdrop-filter: blur(var(--jw-app-icon-blur)); +} + +.jw-app-icon::before { + content: ""; + position: absolute; + inset: 0; + padding: var(--jw-app-icon-rim-width); + border-radius: inherit; + background: linear-gradient( + 135deg, + var(--jw-app-icon-rim-bright) 0%, + var(--jw-app-icon-rim-fade) 32%, + var(--jw-app-icon-rim-fade) 68%, + var(--jw-app-icon-rim-bright) 100% + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.jw-app-icon .jw-mark.jw-mark-portal, +.jw-app-icon .jw-mark.jw-mark-line { + width: auto; + height: calc(var(--jw-app-icon-size) * 0.52); + transform: translateY(calc(var(--jw-app-icon-size) * -0.025)); +} + +.brand .jw-app-icon { margin-right: var(--jw-space-1); } + +.jw-app-icon.lg { --jw-app-icon-size: 76px; --jw-app-icon-rim-width: 1.5px; } +.jw-app-icon.sm { --jw-app-icon-size: 26px; } + +.nav-actions { display: flex; align-items: center; gap: 14px; } diff --git a/docker/justworks/business/public/assets/ds/base.css b/docker/justworks/business/public/assets/ds/base.css new file mode 100644 index 00000000..db740195 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/base.css @@ -0,0 +1,2 @@ +*,*::before,*::after{box-sizing:border-box}html{min-height:100%;background:var(--jw-bg);color-scheme:light}html[data-theme="dark"]{color-scheme:dark}body{min-height:100vh;margin:0;background:var(--jw-bg);color:var(--jw-text-primary);font-family:var(--jw-font-sans);font-size:1rem;line-height:1.5;text-rendering:optimizeLegibility}button,input,textarea,select{font:inherit}input,textarea,select{font-size:1rem}button,a{touch-action:manipulation}img,svg{max-width:100%}a{color:inherit}button{cursor:pointer}:is(a,button,input,textarea,select,[tabindex]):focus-visible{outline:2px solid var(--jw-field-border-focus);outline-offset:3px}body.creating{overflow:hidden}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto!important}*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}} +#themeToggle{display:none!important} diff --git a/docker/justworks/business/public/assets/ds/components.css b/docker/justworks/business/public/assets/ds/components.css new file mode 100644 index 00000000..0b8aa5e2 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/components.css @@ -0,0 +1,14 @@ +/* Brand */ +.jw-brand,.jw-topbar .brand{display:inline-flex;align-items:center;gap:var(--jw-space-3);color:inherit;text-decoration:none;white-space:nowrap}.jw-brand>[data-logo],.jw-topbar .brand>[data-logo],.brand>[data-logo]{width:var(--jw-app-icon-size);height:var(--jw-app-icon-size);display:block;flex:none}.jw-brand b,.jw-topbar .brand b{font-size:1rem;font-weight:var(--jw-weight-black);line-height:1}.jw-brand small,.jw-topbar .brand small{display:inline!important;margin:0!important;color:inherit!important;font:inherit!important;letter-spacing:0!important;text-transform:none!important}.jw-brand small::before,.jw-topbar .brand small::before{content:"·";margin:0 var(--jw-space-2);color:var(--jw-text-muted)} +/* Buttons */ +.jw-button{min-height:var(--jw-button-height);display:inline-flex;align-items:center;justify-content:center;gap:var(--jw-space-2);padding:var(--jw-button-padding);border:1px solid var(--jw-line-strong);border-radius:var(--jw-button-radius);background:var(--jw-surface);color:var(--jw-text-primary);font-weight:var(--jw-button-weight);text-decoration:none;transition:background var(--jw-duration-base) var(--jw-ease-out),color var(--jw-duration-base) var(--jw-ease-out),transform var(--jw-duration-fast) var(--jw-ease-out)}.jw-button:hover{background:var(--jw-surface-sunken)}.jw-button:active{transform:translateY(1px)}.jw-button--primary{border-color:var(--jw-button-primary-fill);background:var(--jw-button-primary-fill);color:var(--jw-button-primary-text)}.jw-button--primary:hover{background:var(--jw-button-primary-fill-hover)}.jw-button--pill{border-radius:var(--jw-radius-pill)}.jw-button--compact{min-height:42px;padding-inline:var(--jw-space-4);font-size:.875rem}.jw-icon-button{width:44px;height:44px;display:inline-grid;place-items:center;padding:0;border:1px solid var(--jw-line);border-radius:50%;background:var(--jw-surface);color:var(--jw-text-primary)} +/* Cards and panels */ +.jw-card{padding:var(--jw-card-padding);border:1px solid var(--jw-card-border);border-radius:var(--jw-card-radius);background:var(--jw-card-bg);box-shadow:var(--jw-shadow-sm)}.jw-card--flat{box-shadow:none}.jw-card--sunken{background:var(--jw-surface-sunken)}.jw-card--glass{background:var(--jw-surface-glass);-webkit-backdrop-filter:blur(var(--jw-topbar-blur));backdrop-filter:blur(var(--jw-topbar-blur))} +/* Forms */ +.jw-field{display:grid;gap:var(--jw-space-2);margin-top:var(--jw-space-5)}.jw-field>span,.jw-label{color:var(--jw-field-label);font-size:.8125rem;font-weight:var(--jw-weight-bold);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}.jw-field :is(input,textarea,select),.jw-input{width:100%;min-height:var(--jw-field-height);padding:var(--jw-space-3) var(--jw-space-4);border:1px solid var(--jw-field-border);border-radius:var(--jw-field-radius);background:var(--jw-field-bg);color:var(--jw-field-text);font-size:1rem;outline:0}.jw-field textarea{min-height:112px;resize:vertical}.jw-field :is(input,textarea,select):focus,.jw-input:focus{border-color:var(--jw-field-border-focus);box-shadow:var(--jw-field-ring)}.jw-help{margin:var(--jw-space-2) 0 0;color:var(--jw-text-muted);font-size:.8125rem;line-height:1.5} +/* Chips and status */ +.jw-chip{min-height:42px;display:inline-flex;align-items:center;justify-content:center;padding:0 var(--jw-space-4);border:1px solid var(--jw-chip-border);border-radius:var(--jw-radius-pill);background:var(--jw-chip-bg);color:var(--jw-chip-text);font-size:.8125rem;font-weight:var(--jw-weight-bold)}.jw-chip[aria-pressed="true"],.jw-chip.is-active{background:var(--jw-text-primary);color:var(--jw-bg)}.jw-badge{display:inline-flex;align-items:center;gap:var(--jw-space-2);padding:var(--jw-space-2) var(--jw-space-3);border:1px solid currentColor;border-radius:var(--jw-radius-pill);font-size:.8125rem;font-weight:var(--jw-weight-bold)}.jw-badge--success{color:var(--jw-success)}.jw-badge--warning{color:var(--jw-warning)}.jw-badge--danger{color:var(--jw-danger)} +/* Async states */ +.jw-state{min-height:50vh;display:grid;place-content:center;justify-items:center;gap:var(--jw-space-3);padding:var(--jw-space-6);text-align:center}.jw-state__mark{font-size:clamp(3rem,8vw,5rem)}.jw-state h1{max-width:15ch;margin:0;font-family:var(--jw-font-display);font-size:clamp(2.5rem,7vw,5.5rem);line-height:.9;letter-spacing:-.055em}.jw-state p{max-width:42rem;margin:0;color:var(--jw-text-muted);font-size:1rem}.jw-state--page{min-height:100vh;background:var(--jw-black);color:var(--jw-white)} +/* Toast */ +.jw-toast{position:fixed;right:var(--jw-space-5);bottom:var(--jw-space-5);z-index:var(--jw-z-toast);padding:var(--jw-space-3) var(--jw-space-4);border-radius:var(--jw-radius-md);background:var(--jw-text-primary);color:var(--jw-bg);box-shadow:var(--jw-shadow-lg);font-size:.875rem;font-weight:var(--jw-weight-bold)} diff --git a/docker/justworks/business/public/assets/ds/env.js b/docker/justworks/business/public/assets/ds/env.js new file mode 100644 index 00000000..b71bc944 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/env.js @@ -0,0 +1,5 @@ +// Shared environment helpers. In production (justworks.cash) pages behave as deployed; +// anywhere else (local dev) pages default to the demo merchant and local routes. +export const prod=location.hostname.endsWith('justworks.cash'); +export const DEMO_NPUB='npub15yfu23xhew6d3d2dqs6ntt5w8myj6p5dj9m0d2trz00au5kv0rnsdhw6za'; +export const npubFrom=(search=location.search)=>new URLSearchParams(search).get('npub')?.trim()||''; diff --git a/docker/justworks/business/public/assets/ds/frame.js b/docker/justworks/business/public/assets/ds/frame.js new file mode 100644 index 00000000..add36046 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/frame.js @@ -0,0 +1,47 @@ +import {mountLogo} from './logo.js'; +import {startNavScroll} from './header.js'; + +const prod=location.hostname.endsWith('justworks.cash'); +export const base=location.pathname.startsWith('/business-demo')?'/business-demo':''; +const search=location.search; +export const link=id=>prod?`${id==='business'?'https://business.justworks.cash/':`https://${id}.justworks.cash/`}${search}`:({business:`${base}/dashboard${search}`,pos:`${base}/pos${search}`,order:`${base}/order${search}`,book:`${base}/book${search}`,booking:`${base}/book${search}`,screen:`${base}/screen${search}`,gallery:`${base}/gallery${search}`})[id]||`${base}/portal?surface=${id}`; +const links=[['order','Order'],['pay','Pay Bill'],['book','Book'],['gallery','Explore']]; +const menuSvg=''; +const closeSvg=''; +const upSvg=''; +const topbar=(label,endHtml='')=>``; + +function mountChromeControls(app){ + const existing=app.querySelector('.mobile-menu'); + if(existing)return existing; + app.insertAdjacentHTML('beforeend',``); + const trigger=app.querySelector('.mobile-menu-button'); + const menu=app.querySelector('.mobile-menu'); + const close=app.querySelector('.mobile-menu-close'); + const back=app.querySelector('.back-to-top'); + let previousFocus=null; + const focusable=()=>[...menu.querySelectorAll('a,button,[tabindex]:not([tabindex="-1"])')]; + const setMenu=open=>{menu.classList.toggle('open',open);menu.setAttribute('aria-hidden',String(!open));trigger.setAttribute('aria-expanded',String(open));document.body.classList.toggle('creating',open);if(open){previousFocus=document.activeElement;close.focus()}else if(previousFocus instanceof HTMLElement){previousFocus.focus()}}; + trigger.addEventListener('click',()=>setMenu(true)); + close.addEventListener('click',()=>setMenu(false)); + menu.addEventListener('click',event=>{if(event.target.closest('a'))setMenu(false)}); + menu.addEventListener('keydown',event=>{if(event.key==='Escape'){setMenu(false);return}if(event.key!=='Tab')return;const items=focusable();const first=items[0],last=items.at(-1);if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus()}}); + back.addEventListener('click',()=>scrollTo({top:0,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'})); + let request=0; + const update=()=>{request=0;back.classList.toggle('show',scrollY>innerHeight)}; + addEventListener('scroll',()=>{if(!request)request=requestAnimationFrame(update)},{passive:true}); + addEventListener('resize',()=>{if(innerWidth>980&&menu.classList.contains('open'))setMenu(false)},{passive:true}); + update(); + return menu; +} + +function initialize(app,grounded=false){ + app.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true})); + mountChromeControls(app); + const bar=app.querySelector('[data-topbar]'); + if(grounded)bar?.classList.add('is-scrolled');else startNavScroll(bar); +} + +export function mountMobileChrome(app){return mountChromeControls(app)} +export function mountAppChrome(app,{label='',endHtml='',grounded=false}={}){app.classList.toggle('chrome-grounded',grounded);app.insertAdjacentHTML('afterbegin',topbar(label,endHtml));initialize(app,grounded)} +export function frame(app,{label='',surface='',html='',nav=true}){app.innerHTML=nav?`${topbar(label)}
${html}
`:`
${html}
`;if(nav)initialize(app);else app.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}))} diff --git a/docker/justworks/business/public/assets/ds/header.js b/docker/justworks/business/public/assets/ds/header.js new file mode 100644 index 00000000..79f7786b --- /dev/null +++ b/docker/justworks/business/public/assets/ds/header.js @@ -0,0 +1,24 @@ +/* + * Top bar scroll state — shared with the main site's behaviour. + * Transparent while parked; past the first 80px the bar earns the + * translucent ground, hairline and blur. Toggles `is-scrolled`. + */ +const SCROLL_START = 80; + +export function startNavScroll(bar = document.querySelector("[data-topbar]")) { + if (!bar) return () => {}; + let frame = 0; + const apply = () => { + frame = 0; + bar.classList.toggle("is-scrolled", window.scrollY > SCROLL_START); + }; + const onScroll = () => { + if (!frame) frame = requestAnimationFrame(apply); + }; + apply(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", onScroll); + if (frame) cancelAnimationFrame(frame); + }; +} diff --git a/docker/justworks/business/public/assets/ds/logo.js b/docker/justworks/business/public/assets/ds/logo.js new file mode 100644 index 00000000..5a203e52 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/logo.js @@ -0,0 +1,198 @@ +/* + * The Just Works mark. + * + * One continuous line, drawn as a single closed path, with no beginning and no + * end. As it travels it settles through three states: a plain loop, a section + * squared into a card outline (payments), and a section drawn out into a + * speech bubble (social). The resting state carries a pointer notch on the + * left lobe (website), so the mark reads correctly even with motion disabled. + * + * The path is the ONLY thing that changes. Stroke, gradient, size and caps are + * styled in components.css from tokens, so the mark follows the theme. + * + * Rendered from here rather than written into each page, so the topbar, the + * footer and /design all draw the same mark from one source. + */ + +/* + * All four states share the same anchor budget and the same left-lobe opening, + * so the morph travels rather than jumps. Authored on a 56x28 field with the + * crossing point at the centre. + */ +export const LOGO_STATES = { + /* The loop at rest, with the pointer notch on the left lobe. */ + loop: "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C20.5 20.2 15 21 13.5 14Z", + /* Right lobe squared into a card. */ + card: "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30 19.5 33 19.5L41 19.5C43 19.5 44 18.5 44 16.5L44 11.5C44 9.5 43 8.5 41 8.5L33 8.5C30 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14Z", + /* Right lobe drawn out into a speech bubble, tail dropping below. */ + bubble: "M13.5 14C13.5 7.5 20 7 27 14C29 16 29 18.5 30.5 19.5C31.6 20.3 31 23.4 32.6 22.3C34 21.3 34.6 19.5 36.6 19.5L39 19.5C42 19.5 44 17 44 14C44 11 42 8.5 39 8.5L33 8.5C29.5 8.5 29 11.5 27 14C20.5 20.2 15 21 13.5 14Z", + /* Lab 07: the right lobe opens into a round doorway. The resting mark for + now, per the direction of 2026-08-18, until the lab work settles. */ + portal: "M13.5 14C13.5 7.5 20 7 27 14C30.5 17.5 34.5 19.5 37.5 18C35.5 15.5 35 13.5 36.5 12C38 10.5 41 10.5 42 12.5C43 14.5 42 16 40.5 17.5C43 17.5 44 14.5 43 11.5C41.5 7.5 34 6.5 30 10.5C28.5 12 28 13 27 14C20.5 20.2 15 21 13.5 14Z", + /* Left lobe pulled into a cursor arrow. */ + cursor: "M13.5 14C10.6 8.2 9.8 4.6 11.6 4.2C13.4 3.8 20.6 9.6 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C23 16.6 18.6 21.6 17.1 22.6C15.6 23.6 14.6 19.8 13.5 14Z" +}; + +/* + * The lab: further single-line ideas, same method as LOGO_STATES — one + * continuous closed path on the 56x28 field, each working a different motif + * into the loop's travel. Rendered in /design for comparison; not part of the + * live cycle. Keep adding here rather than replacing. + */ +export const LOGO_LAB = { + /* Payments and Bitcoin. */ + "sat-symbol": "M13.5 14C13.5 7.5 20 7 27 14C30 17 30 19 34 19L40 19M34 9L40 9M31 14L43 14C43 14 43.5 10 41.5 8.5C38 6.5 31 9.5 27 14C20.5 20.2 15 21 13.5 14Z", + "coin-edge": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 35 18.5C39 18.5 42.5 16.5 42.5 13.5C42.5 10.5 39 8.5 35 8.5C32 8.5 30 11 27 14C24 17 21 19.5 17.5 19.5C14.5 19.5 13.5 17 13.5 14Z", + "zap-bolt": "M13.5 14C13.5 7.5 20 7 27 14C30 17 33 18 35 15.5L32 15.5L38 8.5L36 13L39.5 13C41.5 13 42 10 40 8.5C36.5 6 30.5 10 27 14C20.5 20.2 15 21 13.5 14Z", + "lightning-path": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C25 16 23.5 14.5 22 16.5C20.5 18.5 19 16.5 17.5 18.5C15.5 21 13.5 17.5 13.5 14Z", + "banknote": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30 18.5 33 18.5L42 18.5C43.5 18.5 44 18 44 16.5L44 11.5C44 10 43.5 9.5 42 9.5L33 9.5C30 9.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM38.5 11.7A2.3 2.3 0 1 0 38.5 16.3A2.3 2.3 0 1 0 38.5 11.7Z", + /* Identity and keys. */ + "key-bit": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18 35 18L37 18L37 20L39 20L39 18L41 18L41 20.5L43.5 20.5L43.5 16C43.5 16 42 15.5 40 13C37.5 10 34 7.5 31 9.5C28.5 11 28.5 12.5 27 14C20.5 20.2 15 21 13.5 14Z", + "portal": "M13.5 14C13.5 7.5 20 7 27 14C30.5 17.5 34.5 19.5 37.5 18C35.5 15.5 35 13.5 36.5 12C38 10.5 41 10.5 42 12.5C43 14.5 42 16 40.5 17.5C43 17.5 44 14.5 43 11.5C41.5 7.5 34 6.5 30 10.5C28.5 12 28 13 27 14C20.5 20.2 15 21 13.5 14Z", + "signature": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30.5 17 32 15.5C33.5 14 32 12 33.5 10.5C35 9 36.5 11 38 9.5C39.5 8 41 9.5 42.5 8C43.5 7 44 8.5 43 10C41 13 37 19 33 19C30 19 29 16 27 14C20.5 20.2 15 21 13.5 14Z", + /* Web and social. */ + "at-sign": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 19 35.5 19C39.5 19 43 16.5 43 13C43 9.5 40.5 8 38 8C35.5 8 33.5 9.5 33.5 12C33.5 14.5 35.5 16 37.5 15.5C39.5 15 40 13 39.5 11.5C39 13.5 38 14.5 36.5 14C35 13.5 35.5 10.5 38 10.5C40 10.5 41 12 41 13.5C41 15.5 39 17 36.5 17C33.5 17 31 15 29.5 12.5C28.5 11 28 12.8 27 14C20.5 20.2 15 21 13.5 14Z", + "pin-drop": "M13.5 14C13.5 7.5 20 7 27 14C30 17 33.5 20.5 35.5 22C37.5 20.5 41 16.5 41 12.5C41 9 38.5 7 35.5 7C32.5 7 31 9 29.5 11.5C28.7 12.8 28 13 27 14C20.5 20.2 15 21 13.5 14ZM35.5 10.5A2 2 0 1 0 35.5 14.5A2 2 0 1 0 35.5 10.5Z", + "heart-zap": "M13.5 14C13.5 7.5 20 7 27 14C29 16 31 18.5 34.5 21C38 18.5 43 14.5 43 11C43 8.5 41 7.5 39.5 7.5C37.5 7.5 36.5 9 34.5 11C32.5 9 31.5 7.5 29.5 7.5C28.5 7.5 27.5 8 27 9.5C26 12 24 16 20 18.5C16.5 20.5 13.5 17.5 13.5 14Z", + "follow-plus": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18 34.5 18L34.5 21L37.5 21L37.5 18L40.5 18L40.5 15L37.5 15L37.5 12L34.5 12L34.5 15C32 15 30 12 27 14C20.5 20.2 15 21 13.5 14Z", + /* Continuity and craft. */ + "mobius": "M13.5 14C13.5 7.5 21 6 27 14C33 22 40.5 20.5 41.5 14C42.5 7 35 6.5 29 12C24 16.5 18 22 14.5 18C12.5 15.8 13 15 13.5 14Z", + "knot": "M13.5 14C13.5 8 19 6.5 24 10.5C27 13 25 17.5 28.5 19C32 20.5 35 18 34.5 14.5C34 11 30 10 27 14C24.5 17.5 21 21.5 16.5 20C13.5 19 13.5 16 13.5 14Z", + "orbit-dot": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C20.5 20.2 15 21 13.5 14ZM45 8A1.8 1.8 0 1 0 45 11.6A1.8 1.8 0 1 0 45 8Z", + "spark-tail": "M13.5 14C13.5 7.5 20 7 27 14C33 20 39 21 41 17C42 15 41 13.5 43 12.5C44.5 11.8 45 10 44 9C42.5 7.5 40 8 38.5 9.5C34.5 6.5 30 10.5 27 14C20.5 20.2 15 21 13.5 14Z", + "wave-crest": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 34 16.5C36 14.5 34.5 12 36.5 10C38.5 8 41.5 8.5 42.5 11C43.5 13.5 42 16 39.5 17.5C42.5 17.5 44.5 14.5 43.5 11C42 6.5 36 6.5 33 10C31 12.3 28.7 12.3 27 14C20.5 20.2 15 21 13.5 14Z", + "double-cross": "M13.5 14C13.5 8 19 6.5 24.5 11C27 13 29 15 31.5 17C34.5 19.5 38.5 20 41 17.5C43.5 15 42.5 11 39.5 9C36.5 7 33 8.5 30.5 11C28 13.5 25 16.5 21 18.5C16.5 20.5 13.5 17.5 13.5 14Z", + "qr-corner": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30.5 18.5 33.5 18.5L43.5 18.5L43.5 8.5L33.5 8.5C30.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM36 11L41 11L41 16L36 16Z", + "storefront": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5L42 12.5C43.5 12 44 10.5 43 9.5C42 8.3 40 8.5 36.5 8.5C33 8.5 31.5 8.5 30.5 9.5C29.5 10.5 30 12 31.5 12.5L31.5 15C31 13 29 12 27 14C20.5 20.2 15 21 13.5 14Z", + /* Batch two — 2026-08-18. */ + "relay-waves": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 34 17C32.5 15.5 32.5 12.5 34 11C35 13 37 13.5 38 12C36.5 10.5 37 8.5 39 8C41.5 7.4 43.5 9.5 43.5 12C43.5 16.5 38 20 33 17.5C30.5 16.2 29 15.8 27 14C20.5 20.2 15 21 13.5 14Z", + "antenna-mast": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 32 18 35 18L36.5 18L36.5 9.5C35 9 34.5 7 36 6C37.5 5 39.5 6 39.5 7.5C39.5 8.5 39 9.2 38 9.5L38 18L40 18C42.5 18 43.5 15.5 42.5 13C44.5 14 44.5 17.5 42 19C38.5 21 32 19 29 15.5C28.2 14.6 27.7 14.6 27 14C20.5 20.2 15 21 13.5 14Z", + "chain-link": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.5 32.5 17.5C34.5 17.5 36 16 36 14C36 12 34.5 10.5 32.5 10.5C31 10.5 30 11.5 29 13C30 10 32.5 8 35.5 9C38.5 10 39.5 13.5 38 16C39.5 17 42 16.5 43 14.5C43.5 17.5 40 19.5 37 18.5C33.5 17.4 31 14 29.5 12.5C28.6 13.1 27.8 13.4 27 14C20.5 20.2 15 21 13.5 14Z", + "menu-book": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L36.5 18.5L36.5 9.5C34.5 8.5 31.5 9 30 10.5C29.2 11.5 28.2 12.6 27 14C20.5 20.2 15 21 13.5 14ZM38 18.5L42.5 18.5C43.5 18.5 44 18 44 17L44 10.5C44 9.5 43.5 9 42.5 9L38 9Z", + "cup-steam": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18 32 18.5C34.5 19 37.5 19 39.5 18C41.5 17 42 15 42 13L33 13C33 11.5 33.5 10 35 9C34 7.5 34.5 6 36 5.5C35.5 7 36.5 7.5 36.5 9L42 9C43.5 9 44 10 43.5 11.5L42.9 13.4C44.5 13.6 45 15 44 16C43.4 16.6 42.5 16.6 41.8 16.2C40.5 18.8 36.5 20.5 32.5 19.5C29.5 18.7 28.5 15.6 27 14C20.5 20.2 15 21 13.5 14Z", + "receipt": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 19L32 20.5L33.8 19.3L35.6 20.5L37.4 19.3L39.2 20.5L41 19.3L42.5 20.3L42.5 9.5C42.5 8.5 42 8 41 8L34 8C31 8 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM35 12L40 12M35 15L40 15", + "price-tag": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 32 19.5 34.5 20.5C35.5 21 36.5 20.8 37.5 19.8L42.8 14.5C43.8 13.5 43.8 12.2 42.8 11.2L38.5 7.5C36 7 32 7.5 30 10C28.8 11.5 28 13 27 14C20.5 20.2 15 21 13.5 14ZM39 11A1.5 1.5 0 1 0 39 14A1.5 1.5 0 1 0 39 11Z", + "calendar-day": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5C43.5 18.5 44 18 44 16.5L44 10.5C44 9 43.5 8.5 42 8.5L40.5 8.5L40.5 7L38.5 7L38.5 8.5L35 8.5L35 7L33 7L33 8.7C30.8 9.6 29.2 11.6 27 14C20.5 20.2 15 21 13.5 14ZM32 12L44 12", + "clock-open": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 18.5 35 18.5C39 18.5 42 16.5 42 13.5C42 10.5 39.5 8.5 36.5 8.5C33.5 8.5 31.5 10.5 29.5 12.5C28.6 13.3 27.9 13.2 27 14C20.5 20.2 15 21 13.5 14ZM36.5 10.5L36.5 13.5L39 15", + "compass-needle": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C41.9 9 37.5 7.2 33 10L36.5 12.5L31 17L29.5 12.2C28.6 12.9 27.8 13.4 27 14C20.5 20.2 15 21 13.5 14Z", + "north-arrow": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32.5 19.5 35.5 21.5L37.5 12.5L42 14.5C41 10 36.5 6.5 32 8.5C29.8 9.6 28.5 12 27 14C20.5 20.2 15 21 13.5 14Z", + "camera-eye": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32.5 18.5L41 18.5C42.5 18.5 43.5 17.5 43.5 16L43.5 11C43.5 9.5 42.5 8.5 41 8.5L39.5 8.5L38.5 7L35.5 7L34.5 8.5C31.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM37 10.5A3 3 0 1 0 37 16.5A3 3 0 1 0 37 10.5Z", + "mic-live": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 18 32.5 18.5L34.5 18.5L34.5 16.8C32.5 16.3 31.5 14.8 31.5 13L33 13C33 14.5 34.5 15.5 36 15.5C37.5 15.5 39 14.5 39 13L40.5 13C40.5 14.8 39.5 16.3 37.5 16.8L37.5 18.5C40 18.5 42 17 42.5 14.5C44 15.5 43.5 18 41 19.3C37.5 21 31 19.5 29 15.8C28.3 14.9 27.7 14.6 27 14C20.5 20.2 15 21 13.5 14ZM34 9.5C34 8 35 7 36 7C37 7 38 8 38 9.5L38 12C38 13.5 37 14 36 14C35 14 34 13.5 34 12Z", + "note-beam": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31 18 33 17.5L33 10L41 8L41 15.5C42.5 15.8 43.5 17 43 18.3C42.3 19.8 40 20 39.3 18.5C38.9 17.6 39.3 16.6 40.3 16.2L40.3 10.5L33.7 12.2L33.7 18C34.2 19.8 32 21 30.3 20C28.8 19 28.8 17 30.3 16.2C29 15.5 28 14.8 27 14C20.5 20.2 15 21 13.5 14Z", + "ticket-stub": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5C43 18.5 43.5 18 43.5 17C42.5 17 41.8 16.2 41.8 15.2C41.8 14.2 42.5 13.5 43.5 13.5L43.5 10C43.5 9 43 8.5 42 8.5L33.5 8.5C30.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM37.5 8.5L37.5 18.5", + "bell-ring": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.5 32 18L32 19C32 20 32.8 20.8 33.8 20.8C34.8 20.8 35.6 20 35.6 19L41.5 19C41.5 17.5 40 17 40 14.5C40 11 38 8.5 35 8.5C32 8.5 30.8 11 29.3 12.7C28.5 13.4 27.7 13.4 27 14C20.5 20.2 15 21 13.5 14ZM30 19L41.5 19", + "star-review": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 18.5 33.5 20L35.5 15.5L31.5 12.5L36.5 12.5L38 8L39.5 12.5L44.5 12.5L40.5 15.5L42 20.2L38 17.4C36 19 33 20.7 30 18C28.9 17 28 15 27 14C20.5 20.2 15 21 13.5 14Z", + "check-badge": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.8 32.3 18.4C32.1 19.4 32.8 20.3 33.9 20.3C34.9 20.3 35.6 19.5 35.5 18.5C37 18.2 38.4 17.3 39.2 16C40.2 16.4 41.4 16 41.9 15C42.4 14 42 12.9 41.1 12.4C41.2 10.8 40.5 9.3 39.2 8.4C36.4 6.5 32.5 7.8 30.5 10.7C29.4 12.2 28.2 13 27 14C20.5 20.2 15 21 13.5 14ZM33.5 13.5L35.5 15.5L39.5 11.5", + "handshake-loop": "M13.5 14C13.5 8 18.5 6.5 23.5 10C26 11.7 28.5 13.8 31 15.8C33 17.5 36 18 38.5 16.5C41.5 14.7 42 11 39.5 9C37 7 33.5 8 31.5 10.5C30 12.4 28 15.5 24.5 17.8C20 20.8 13.5 19 13.5 14Z", + "ribbon-award": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31 18 33.5 18.5L32.5 21.5L35 20L37.5 21.5L36.5 18.5C39.5 18.5 42 16.5 42 13.5C42 10 39.5 8 36.5 8C33 8 31 10.5 29.2 12.7C28.4 13.4 27.7 13.4 27 14C20.5 20.2 15 21 13.5 14ZM35 11A2.6 2.6 0 1 0 35 16.2A2.6 2.6 0 1 0 35 11Z" +}; + +/* Parked while the mark rests on `portal`; restore the startLogoMotion + calls in application.js and design.js to revive the cycle. */ +/* + * Portal at small sizes: the full portal's inner spiral cannot resolve under + * ~40px, so icon-scale marks use this variant — the same continuous line, + * with the doorway drawn as a literal opening in the right lobe. One line, + * one gap: the portal. + */ +export const LOGO_PORTAL_SMALL = "M41.5 16.5C40 20.8 33.5 20.5 27 14C20 7 13.5 7.5 13.5 14C13.5 21 20.5 20.2 27 14C34.5 7 42 7.5 41.7 11.5"; + +const ORDER = ["portal", "loop", "card", "bubble", "cursor"]; + +/* + * THE MARK — "07 Portal", from the logo exploration deck: a doorway whose far + * side is lit. The frame is a heavy silhouette at maximum contrast so it + * survives any ground; all of the depth lives in the interior gradient, + * running from the ramp's mid tone at the head to its faintest at the floor. + * The frame's legs run to the floor with no bottom bar: the door does not + * close behind you. + * + * Colours come from CSS (.jw-portal-*), so the mark follows the theme. + */ +export const LOGO_MARKUP = ` +`.trim(); + +/* The one-line loop markup, kept for the /design specimens and the lab. */ +export const LOGO_LINE_MARKUP = ` +`.trim(); + +/* Each mounted mark needs its own gradient id, or the second one on the page + would reference the first one's definition. */ +let gradientSeed = 0; + +/** + * Renders the mark into `host` and returns the created . + * Pass `{ icon: true }` to wrap it in the app-icon container. + * Motion is opt-in via startLogoMotion so a static mark costs nothing. + */ +export function mountLogo(host, { icon = false, variant = "portal" } = {}) { + if (!host) return null; + + /* "portal" is the brand mark (the lit doorway). "line" is the one-line loop + used by the /design specimens and the lab. `icon` wraps either in the + app-icon container from components.css. */ + const markup = variant === "line" ? LOGO_LINE_MARKUP : LOGO_MARKUP; + host.innerHTML = icon ? `${markup}` : markup; + + const svg = host.querySelector(".jw-mark"); + gradientSeed += 1; + + if (variant === "line") { + const gradient = svg.querySelector(".jw-mark-gradient"); + const path = svg.querySelector(".jw-mark-path"); + const id = `jw-mark-gradient-${gradientSeed}`; + gradient.id = id; + path.setAttribute("stroke", `url(#${id})`); + } else { + const glow = svg.querySelector(".jw-portal-glow"); + const id = `jw-portal-glow-${gradientSeed}`; + glow.id = id; + svg.querySelector(".jw-portal-light").setAttribute("fill", `url(#${id})`); + } + + return svg; +} + +/** + * Cycles the mark through its states. Returns a teardown. + * + * `gsap` and `MorphSVGPlugin` are passed in rather than imported so this stays + * usable without motion: if either is missing the mark simply rests on `loop`. + */ +export function startLogoMotion(svg, { gsap, MorphSVGPlugin, hold = 2.4, morph = 1.1 } = {}) { + if (!svg || !gsap || !MorphSVGPlugin) return () => {}; + + const path = svg.querySelector(".jw-mark-path"); + if (!path) return () => {}; + + const timeline = gsap.timeline({ repeat: -1, defaults: { ease: "power2.inOut" } }); + + for (let index = 1; index <= ORDER.length; index += 1) { + /* Wraps back to `loop` on the last step, so the cycle closes seamlessly. */ + const next = ORDER[index % ORDER.length]; + timeline.to(path, { + duration: morph, + morphSVG: { shape: LOGO_STATES[next], shapeIndex: 0 } + }, `+=${hold}`); + } + + return () => timeline.kill(); +} diff --git a/docker/justworks/business/public/assets/ds/mobile.css b/docker/justworks/business/public/assets/ds/mobile.css new file mode 100644 index 00000000..853ad3b2 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/mobile.css @@ -0,0 +1 @@ +.button{position:relative;isolation:isolate;min-height:var(--jw-button-height);display:inline-flex;align-items:center;justify-content:center;gap:var(--jw-space-2);padding:var(--jw-button-padding);border:0;border-radius:var(--jw-button-radius);background:transparent;color:var(--jw-button-secondary-text);font-weight:var(--jw-button-weight);cursor:pointer}.button::before{content:"";position:absolute;inset:0;z-index:-1;padding:var(--jw-button-rim-width);border-radius:inherit;background:linear-gradient(135deg,var(--jw-button-rim-bright) 0%,transparent var(--jw-button-rim-gap-start),transparent var(--jw-button-rim-gap-end),var(--jw-button-rim-bright) 100%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;pointer-events:none}.button::after{content:"";position:absolute;inset:var(--jw-button-rim-width);z-index:-2;border-radius:calc(var(--jw-button-radius) - var(--jw-button-rim-width));background:var(--jw-button-secondary-fill)}.button:hover:not(:disabled)::after{background:var(--jw-button-secondary-fill-hover)}.button.icon.ring{width:var(--jw-ring-button-size);min-width:var(--jw-ring-button-size);height:var(--jw-ring-button-size);min-height:var(--jw-ring-button-size);padding:0;border-radius:var(--jw-radius-pill);color:var(--jw-text-primary)}.button.icon.ring::before{padding:var(--jw-app-icon-rim-width);background:linear-gradient(135deg,var(--jw-app-icon-rim-bright) 0%,var(--jw-app-icon-rim-fade) 32%,var(--jw-app-icon-rim-fade) 68%,var(--jw-app-icon-rim-bright) 100%)}.button.icon.ring::after{inset:var(--jw-app-icon-rim-width);border-radius:var(--jw-radius-pill);background:var(--jw-bg)}.mobile-menu-button{display:none;position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:max(var(--jw-space-5),env(safe-area-inset-bottom));z-index:var(--jw-z-overlay);--jw-ring-button-size:54px}.mobile-menu{position:fixed;inset:0;z-index:var(--jw-z-modal);display:none;flex-direction:column;overflow:auto;padding:max(var(--jw-space-6),env(safe-area-inset-top)) max(var(--jw-space-6),env(safe-area-inset-right)) max(var(--jw-space-6),env(safe-area-inset-bottom)) max(var(--jw-space-6),env(safe-area-inset-left));background:var(--jw-modal-bg);-webkit-backdrop-filter:blur(26px);backdrop-filter:blur(26px)}.mobile-menu.open{display:flex}.mobile-menu-close{position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:max(var(--jw-space-5),env(safe-area-inset-bottom));--jw-ring-button-size:54px}.mobile-menu-items{position:static!important;inset:auto!important;z-index:auto!important;width:auto!important;height:auto!important;flex:1;display:grid!important;align-content:center;justify-items:start;gap:var(--jw-space-6);padding:0!important;border:0!important;background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;pointer-events:auto!important}.mobile-menu-items a{color:var(--jw-text-primary);font-family:var(--jw-font-display);font-size:clamp(2.2rem,10vw,3.4rem);font-weight:700;line-height:1.3;text-decoration:none}.mobile-menu-foot{display:flex;align-items:center;justify-content:space-between;gap:var(--jw-space-4);padding-top:var(--jw-space-5);margin-bottom:calc(54px + var(--jw-space-6));border-top:1px solid var(--jw-line)}.mobile-menu-foot>span{color:var(--jw-text-muted);font-size:.8125rem;font-weight:var(--jw-weight-bold);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}.mobile-menu-button svg,.mobile-menu-close svg,.back-to-top svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.back-to-top{display:none;position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:calc(max(var(--jw-space-5),env(safe-area-inset-bottom)) + 66px);z-index:var(--jw-z-overlay);--jw-ring-button-size:44px;opacity:0;visibility:hidden;transform:translateY(var(--jw-space-2));transition:opacity var(--jw-duration-base) var(--jw-ease-out),transform var(--jw-duration-base) var(--jw-ease-out),visibility var(--jw-duration-base)}.back-to-top.show{opacity:1;visibility:visible;transform:none}@media(max-width:980px){.mobile-menu-button,.back-to-top{display:inline-flex}}@media(max-height:520px) and (orientation:landscape){.mobile-menu-items{align-content:start;padding-top:var(--jw-space-10)!important}.mobile-menu-items a{font-size:2rem}.mobile-menu-foot{margin-top:var(--jw-space-6)}} diff --git a/docker/justworks/business/public/assets/ds/presets.css b/docker/justworks/business/public/assets/ds/presets.css new file mode 100644 index 00000000..8cf7c3b4 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/presets.css @@ -0,0 +1,2 @@ +/* Layout presets for page composition. */ +.jw-container{width:min(var(--jw-container),calc(100% - 2.5rem));margin-inline:auto}.jw-container--narrow{width:min(var(--jw-container-narrow),calc(100% - 2.5rem));margin-inline:auto}.jw-page{padding-block:clamp(3rem,7vw,7rem)}.jw-stack{display:grid;gap:var(--jw-stack-gap,var(--jw-space-5))}.jw-cluster{display:flex;align-items:center;flex-wrap:wrap;gap:var(--jw-cluster-gap,var(--jw-space-3))}.jw-split{display:grid;grid-template-columns:minmax(0,.9fr) minmax(22rem,1.1fr);align-items:start;gap:clamp(2rem,8vw,7rem)}.jw-split__copy{position:sticky;top:calc(var(--jw-topbar-height) + var(--jw-space-10))}.jw-split__copy h1{max-width:10ch;margin:var(--jw-space-3) 0 var(--jw-space-5);font-family:var(--jw-font-display);font-size:clamp(3rem,7vw,6rem);font-weight:650;line-height:.9;letter-spacing:-.06em}.jw-split__copy p{max-width:38rem;color:var(--jw-text-body);font-size:1.125rem;line-height:1.6}.jw-grid{display:grid;grid-template-columns:repeat(var(--jw-grid-columns,3),minmax(0,1fr));gap:var(--jw-grid-gap,var(--jw-space-4))}.jw-hero{position:relative;min-height:clamp(22rem,52vw,42rem);display:flex;align-items:flex-end;padding:clamp(2rem,7vw,6rem);overflow:hidden}.jw-hero__content{position:relative;z-index:1;max-width:60rem}.jw-hero h1{margin:var(--jw-space-4) 0;font-family:var(--jw-font-display);font-size:clamp(3.5rem,10vw,9rem);font-weight:650;line-height:.8;letter-spacing:-.07em}.jw-eyebrow{margin:0;color:var(--jw-accent);font-size:.8125rem;font-weight:var(--jw-weight-black);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}@media(max-width:980px){.jw-split{grid-template-columns:1fr}.jw-split__copy{position:static}.jw-grid{--jw-grid-columns:2}}@media(max-width:640px){.jw-container,.jw-container--narrow{width:min(100% - 2rem,var(--jw-container))}.jw-grid{--jw-grid-columns:1}.jw-hero{padding-inline:var(--jw-space-5)}} diff --git a/docker/justworks/business/public/assets/ds/theme.js b/docker/justworks/business/public/assets/ds/theme.js new file mode 100644 index 00000000..2438e829 --- /dev/null +++ b/docker/justworks/business/public/assets/ds/theme.js @@ -0,0 +1,119 @@ +/* + * Theme controller. + * + * Three preferences: "system" (default), "light", "dark". The choice is + * stored in localStorage and applied as `data-theme` on ; "system" + * removes the attribute so the `prefers-color-scheme` block in tokens.css + * takes over. A small inline snippet in each page's applies the stored + * value before first paint, so this module only has to own the control. + */ + +export const THEME_STORAGE_KEY = "jw-theme"; +const THEMES = ["system", "light", "dark"]; + +/* Must match --jw-bg in tokens.css for each theme. */ +const BROWSER_CHROME = { light: "#fafafa", dark: "#0b100f" }; + +const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); +const listeners = new Set(); + +function readStored() { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + return THEMES.includes(stored) ? stored : "system"; + } catch { + /* Private mode or blocked storage: fall back to the system preference. */ + return "system"; + } +} + +/** The preference the user chose ("system" | "light" | "dark"). */ +export function getPreference() { + return readStored(); +} + +/** The theme actually on screen ("light" | "dark"), resolving "system". */ +export function getResolvedTheme(preference = readStored()) { + if (preference !== "system") return preference; + return darkQuery.matches ? "dark" : "light"; +} + +export function applyTheme(preference) { + const next = THEMES.includes(preference) ? preference : "system"; + const root = document.documentElement; + + if (next === "system") root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", next); + + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute("content", BROWSER_CHROME[getResolvedTheme(next)]); + + try { + localStorage.setItem(THEME_STORAGE_KEY, next); + } catch { + /* Not fatal: the theme still applies for this page view. */ + } + + for (const listener of listeners) listener(next, getResolvedTheme(next)); + return next; +} + +/** Subscribe to preference changes. Returns an unsubscribe function. */ +export function onThemeChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/* Keep the browser chrome correct when the OS flips while set to "system". */ +darkQuery.addEventListener("change", () => { + if (readStored() === "system") applyTheme("system"); +}); + +const ICONS = { + system: '', + light: '', + dark: '' +}; + +const LABELS = { system: "Match system theme", light: "Light theme", dark: "Dark theme" }; + +/** + * Renders the segmented theme control into `container` and keeps its pressed + * state in sync with the current preference. + */ +export function mountThemeToggle(container) { + if (!container) return null; + + container.classList.add("theme-toggle"); + container.setAttribute("role", "group"); + container.setAttribute("aria-label", "Color theme"); + container.innerHTML = THEMES.map((theme) => ` + + `).join(""); + + const buttons = [...container.querySelectorAll("[data-theme-option]")]; + + function sync(preference = readStored()) { + for (const button of buttons) { + button.setAttribute("aria-pressed", String(button.dataset.themeOption === preference)); + } + } + + container.addEventListener("click", (event) => { + const button = event.target.closest("[data-theme-option]"); + if (button) applyTheme(button.dataset.themeOption); + }); + + onThemeChange(sync); + sync(); + return { sync }; +} + +/** + * The snippet that must run before first paint. Kept here so the inline copy + * in each page's has a single documented source; if you change it, + * update index.html and design/index.html to match. + */ +export const PREPAINT_SNIPPET = `(function(){try{var t=localStorage.getItem("${THEME_STORAGE_KEY}");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}})()`; diff --git a/docker/justworks/business/public/assets/ds/topbar.css b/docker/justworks/business/public/assets/ds/topbar.css new file mode 100644 index 00000000..2386ad8a --- /dev/null +++ b/docker/justworks/business/public/assets/ds/topbar.css @@ -0,0 +1 @@ +[data-topbar]{position:fixed;inset:0 0 auto;z-index:var(--jw-z-overlay);height:76px;display:flex;align-items:center;gap:var(--jw-space-6);padding:0 clamp(1.125rem,5vw,4.375rem);border-bottom:1px solid transparent;background:transparent;color:var(--jw-text-primary);pointer-events:none;transition:background var(--jw-duration-base) var(--jw-ease-out),border-color var(--jw-duration-base) var(--jw-ease-out),box-shadow var(--jw-duration-base) var(--jw-ease-out)}[data-topbar]>*{pointer-events:auto}[data-topbar].is-scrolled{border-bottom-color:var(--jw-topbar-scrolled-border);background:var(--jw-topbar-scrolled-bg);box-shadow:var(--jw-topbar-scrolled-shadow);-webkit-backdrop-filter:blur(var(--jw-topbar-blur));backdrop-filter:blur(var(--jw-topbar-blur))}[data-theme="dark"] [data-topbar].is-scrolled{border-bottom-color:rgba(255,255,255,.06);background:linear-gradient(180deg,rgba(10,10,10,.72),rgba(10,10,10,.42))}.jw-topbar-links,.jw-topbar .links{display:flex;align-items:center;gap:var(--jw-space-2);margin-left:auto}.jw-topbar-links a,.jw-topbar .links a{min-height:42px;display:inline-flex;align-items:center;padding:0 var(--jw-space-3);border-radius:var(--jw-radius-sm);color:var(--jw-text-body);text-decoration:none;font-size:.875rem;font-weight:var(--jw-weight-bold)}.jw-topbar-links a:hover,.jw-topbar .links a:hover{background:var(--jw-surface-sunken);color:var(--jw-text-primary)}.jw-topbar-end{display:flex;align-items:center;gap:var(--jw-space-3)}.chrome-grounded{padding-top:76px}.jw-chrome--inverse{color:var(--jw-white)}.jw-chrome--inverse .jw-brand small::before{color:rgba(255,255,255,.62)}@media(max-width:980px){[data-topbar]{height:66px;padding-inline:var(--jw-space-5)}.chrome-grounded{padding-top:66px}.jw-topbar-links,.jw-topbar .links,.jw-topbar-end{display:none}}@media(max-width:420px){.jw-brand b,.jw-topbar .brand b{font-size:.875rem}.jw-brand small,.jw-topbar .brand small{font-size:.875rem!important}.jw-brand small::before,.jw-topbar .brand small::before{margin-inline:var(--jw-space-1)}} diff --git a/docker/justworks/business/public/assets/experiences.css b/docker/justworks/business/public/assets/experiences.css new file mode 100644 index 00000000..a3756fd0 --- /dev/null +++ b/docker/justworks/business/public/assets/experiences.css @@ -0,0 +1,5 @@ +.experience-showcase{margin-top:clamp(70px,10vw,130px);padding-top:clamp(30px,5vw,56px);border-top:1px solid var(--line)} +.experience-head{display:flex;align-items:flex-start;justify-content:space-between;gap:30px;margin-bottom:28px}.experience-head>div{max-width:850px}.experience-head h2{margin:10px 0 14px;font-size:clamp(38px,6vw,68px);font-weight:650;letter-spacing:-.055em;line-height:.94}.experience-head p:last-child{max-width:700px;margin:0;color:var(--body);font-size:17px;line-height:1.55}.experience-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.journey-card,.nostr-card{min-width:0;padding:clamp(18px,3vw,30px);border:1px solid var(--line);border-radius:var(--jw-radius-xl,30px);background:var(--panel);box-shadow:var(--jw-shadow-md,0 15px 42px rgba(0,0,0,.07))}.experience-label{display:flex;align-items:center;gap:12px;margin-bottom:22px}.experience-label>span{width:38px;height:38px;display:grid;place-items:center;border:1px solid var(--line);border-radius:12px;background:var(--soft);color:var(--muted);font-size:10px;font-weight:900}.experience-label strong,.experience-label small{display:block}.experience-label strong{font-size:14px}.experience-label small{margin-top:2px;color:var(--muted);font-size:11px} +.status-device{position:relative;overflow:hidden;min-height:610px;padding:20px clamp(18px,4vw,42px) 28px;border-radius:24px;background:#0d0f0e;color:#fafafa;box-shadow:inset 0 0 0 1px rgba(255,255,255,.09),0 24px 54px rgba(0,0,0,.15)}.status-device:before{content:"";position:absolute;width:330px;height:330px;left:50%;top:70px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(244,81,30,.18),transparent 66%);pointer-events:none}.device-bar{position:relative;display:flex;align-items:center;justify-content:center;gap:9px;color:rgba(250,250,250,.55);font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.device-bar i{width:4px;height:4px;border-radius:50%;background:var(--green)}.status-orbit{position:relative;width:112px;height:112px;margin:64px auto 30px;display:grid;place-items:center;border:1px solid rgba(255,255,255,.14);border-radius:50%}.status-orbit:before,.status-orbit:after{content:"";position:absolute;inset:10px;border:1px solid rgba(255,255,255,.08);border-radius:50%}.status-orbit:after{inset:-9px;border-color:rgba(244,81,30,.25);border-top-color:var(--accent);animation:status-spin 4s linear infinite}.status-orbit span{position:absolute;inset:25px;border-radius:50%;background:var(--green);box-shadow:0 0 40px rgba(8,128,107,.38)}.status-orbit svg{position:relative;width:30px;z-index:1;fill:none;stroke:#fff;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}.status-kicker{margin:0;text-align:center;color:var(--green);font-size:10px;font-weight:900;letter-spacing:.13em;text-transform:uppercase}.status-device h3{max-width:470px;margin:10px auto 14px;text-align:center;font-family:var(--jw-font-display,"Syne Variable",sans-serif);font-size:clamp(32px,4vw,48px);font-weight:650;line-height:.96;letter-spacing:-.045em}.status-device>p:not(.status-kicker){max-width:430px;margin:0 auto;text-align:center;color:rgba(250,250,250,.6);font-size:13px;line-height:1.55}.order-summary{display:flex;justify-content:space-between;gap:20px;margin:30px 0 24px;padding:15px 0;border-top:1px solid rgba(255,255,255,.1);border-bottom:1px solid rgba(255,255,255,.1);font-size:13px}.journey-steps{display:grid;grid-template-columns:auto 1fr auto 1fr auto;align-items:start;margin:10px 0 28px}.journey-steps>div{display:grid;justify-items:center;gap:7px;text-align:center}.journey-steps i{width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:rgba(250,250,250,.1);font-style:normal;font-size:10px}.journey-steps .done i{background:var(--green)}.journey-steps .current i{background:var(--accent);box-shadow:0 0 0 7px rgba(244,81,30,.13);animation:status-pulse 1.8s ease-in-out infinite}.journey-steps b{height:1px;margin-top:13px;background:var(--green)}.journey-steps b.active{background:linear-gradient(90deg,var(--green),var(--accent))}.journey-steps span{font-size:9px;font-weight:850}.journey-steps small{display:block;margin-top:3px;color:rgba(250,250,250,.4);font-size:8px}.status-action{width:100%;height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border:1px solid rgba(255,255,255,.18);border-radius:13px;background:rgba(255,255,255,.06);color:#fafafa;font-weight:850}.status-action span{font-size:18px} +.nostr-feed{overflow:hidden;border:1px solid var(--line);border-radius:24px;background:var(--soft)}.nostr-feed>header{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:11px;padding:18px}.nostr-avatar{width:44px;height:44px;display:grid;place-items:center;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--gold));color:#fff;font-weight:900}.nostr-feed header strong,.nostr-feed header small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nostr-feed header strong{font-size:13px}.nostr-feed header strong em{color:var(--green);font-style:normal}.nostr-feed header small{margin-top:3px;color:var(--muted);font-size:9px}.nostr-feed header button{min-height:34px;padding:0 12px;border:1px solid var(--line-strong);border-radius:10px;background:var(--panel);color:var(--text);font-size:10px;font-weight:900}.feed-copy{padding:0 18px 16px}.feed-copy p{margin:0 0 7px;font-size:14px;line-height:1.5}.feed-copy span{color:var(--muted);font-size:9px}.feed-mosaic{display:grid;grid-template-columns:repeat(2,1fr);grid-template-rows:245px 165px;gap:3px;background:var(--line)}.feed-mosaic figure{min-width:0;margin:0;overflow:hidden;background:#222}.feed-mosaic .wide{grid-column:1/-1}.feed-mosaic img{width:100%;height:100%;display:block;object-fit:cover;transition:transform .5s ease}.feed-mosaic figure:hover img{transform:scale(1.035)}.nostr-feed>footer{display:flex;align-items:center;gap:6px;padding:13px 15px;border-bottom:1px solid var(--line)}.nostr-feed>footer button{border:0;background:transparent;color:var(--text);font-size:12px;font-weight:800}.nostr-feed>footer button span{color:var(--muted);font-size:9px}.signed-mark{margin-left:auto;color:var(--green);font-size:8px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.feed-review{display:grid;grid-template-columns:34px 1fr;gap:10px;padding:16px 18px;background:var(--panel)}.nostr-avatar.small{width:34px;height:34px;font-size:11px}.feed-review strong{font-size:11px}.feed-review p{margin:5px 0;color:var(--body);font-size:11px;line-height:1.45}.feed-review small{color:var(--muted);font-size:8px} +@keyframes status-spin{to{transform:rotate(360deg)}}@keyframes status-pulse{50%{box-shadow:0 0 0 12px rgba(244,81,30,0)}}@media(prefers-reduced-motion:reduce){.status-orbit:after,.journey-steps .current i{animation:none}}@media(max-width:800px){.experience-head{flex-direction:column}.experience-grid{grid-template-columns:1fr}.status-device{min-height:580px}}@media(max-width:460px){.journey-card,.nostr-card{padding:14px}.status-device{padding-inline:15px}.feed-mosaic{grid-template-rows:190px 120px}.nostr-feed>header{grid-template-columns:40px minmax(0,1fr) auto;padding:14px}.nostr-avatar{width:40px;height:40px}} diff --git a/docker/justworks/business/public/assets/gallery.css b/docker/justworks/business/public/assets/gallery.css new file mode 100644 index 00000000..40dc27c1 --- /dev/null +++ b/docker/justworks/business/public/assets/gallery.css @@ -0,0 +1 @@ +:root{color-scheme:light;--bg:var(--jw-bg,#f6f6f3);--panel:var(--jw-surface,#fff);--soft:var(--jw-surface-sunken,#eceeea);--line:var(--jw-line,rgba(10,10,10,.11));--text:var(--jw-text-primary,#0a0a0a);--body:var(--jw-text-body,#454545);--muted:var(--jw-text-muted,#747773);--accent:var(--jw-accent,#f4511e);--green:var(--jw-success,#08806b);font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html{background:var(--bg);scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text)}button,input{font:inherit}.gallery-nav{height:78px;display:flex;align-items:center;gap:20px;padding:0 clamp(18px,4vw,64px);border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--bg) 86%,transparent);backdrop-filter:blur(20px);position:sticky;top:0;z-index:20}.gallery-brand{display:flex;align-items:center;gap:11px;color:var(--text);text-decoration:none}.gallery-brand>[data-logo]{width:34px}.gallery-brand b{font-size:16px}.gallery-brand small{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}.map-link{margin-left:auto;display:flex;gap:12px;padding:10px 14px;border:1px solid var(--line);border-radius:999px;color:var(--text);background:var(--panel);text-decoration:none;font-size:12px;font-weight:800}.map-link:hover span{transform:translate(2px,-2px)}.map-link span{transition:.2s}main{width:min(1500px,calc(100% - 42px));margin:auto;padding-bottom:100px}.gallery-hero{min-height:460px;display:flex;align-items:flex-end;justify-content:space-between;gap:60px;padding:clamp(60px,9vw,145px) 0 clamp(48px,7vw,90px)}.hero-word p{margin:0;color:var(--accent);font-size:10px;font-weight:900;letter-spacing:.18em;text-transform:uppercase}.hero-word h1{font-family:var(--jw-font-display,"Syne Variable",system-ui);font-size:clamp(64px,10vw,150px);font-weight:620;line-height:.77;letter-spacing:-.075em;margin:24px 0 0}.hero-word em{color:var(--accent);font-style:normal}.hero-note{width:min(360px,30vw);padding-bottom:12px}.hero-note span{display:inline-block;padding:8px 12px;border:1px solid var(--line);border-radius:999px;font-size:10px;font-weight:850;text-transform:uppercase;letter-spacing:.08em}.hero-note p{color:var(--body);font-size:17px;line-height:1.55}.filter-dock{position:sticky;top:92px;z-index:15;display:grid;grid-template-columns:minmax(260px,1fr) auto;gap:10px;padding:12px;border:1px solid var(--line);border-radius:22px;background:color-mix(in srgb,var(--bg) 88%,transparent);box-shadow:0 15px 50px rgba(0,0,0,.07);backdrop-filter:blur(22px)}.search-wrap{height:52px;display:flex;align-items:center;gap:10px;padding:0 14px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}.search-wrap>span{font-size:25px}.search-wrap input{min-width:0;flex:1;border:0;outline:0;background:none;color:var(--text);font-size:15px}.search-wrap button{width:30px;height:30px;border:0;border-radius:50%;background:var(--soft);color:var(--muted);cursor:pointer}.payment-tabs{display:flex;padding:4px;border-radius:14px;background:var(--soft)}.payment-tabs button{border:0;border-radius:11px;padding:0 15px;background:transparent;color:var(--muted);font-size:12px;font-weight:850;cursor:pointer}.payment-tabs button span{margin-left:4px;font-size:10px}.payment-tabs button.active{background:var(--text);color:var(--bg);box-shadow:0 5px 16px rgba(0,0,0,.15)}.quick-filters{grid-column:1/-1;display:flex;gap:7px;overflow:auto;padding:1px 0 0;scrollbar-width:none}.quick-filters::-webkit-scrollbar{display:none}.quick-filters button{flex:none;padding:9px 13px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--body);font-size:11px;font-weight:800;cursor:pointer}.quick-filters button.active{border-color:var(--accent);color:var(--accent);background:color-mix(in srgb,var(--accent) 7%,var(--panel))}.result-bar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:42px 2px 24px}.result-bar strong,.result-bar span{display:block}.result-bar strong{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(28px,4vw,46px);letter-spacing:-.05em}.result-bar span{margin-top:5px;color:var(--muted);font-size:11px}.result-bar button{padding:9px 13px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--text);font-size:11px;font-weight:800;cursor:pointer}.masonry{columns:4 280px;column-gap:14px}.place-card{position:relative;break-inside:avoid;margin:0 0 14px;border:1px solid var(--line);border-radius:22px;overflow:hidden;background:var(--panel);box-shadow:0 5px 18px rgba(0,0,0,.035);opacity:0;transform:translateY(18px);animation:reveal .55s cubic-bezier(.2,.7,.2,1) forwards;animation-delay:var(--delay)}@keyframes reveal{to{opacity:1;transform:none}}.place-card>a{display:block;color:inherit;text-decoration:none}.place-card figure{position:relative;margin:0;height:290px;overflow:hidden;background:#1b1e1d}.place-card.tall figure{height:430px}.place-card.landscape figure{height:230px}.place-card img{width:100%;height:100%;object-fit:cover;transition:transform .7s cubic-bezier(.2,.7,.2,1),filter .5s}.place-card:hover img{transform:scale(1.035);filter:saturate(1.08)}.image-wash{position:absolute;inset:0;background:linear-gradient(180deg,transparent 50%,rgba(0,0,0,.62))}.pay-mark{position:absolute;left:13px;top:13px;padding:7px 10px;border-radius:999px;color:#fff;background:rgba(9,10,10,.78);backdrop-filter:blur(10px);font-size:9px;font-weight:900;letter-spacing:.04em}.pay-mark.bitcoin{background:rgba(8,128,107,.9)}.card-copy{position:relative;padding:18px 18px 20px}.card-copy p{margin:0 0 6px;color:var(--accent);font-size:9px;font-weight:900;text-transform:uppercase;letter-spacing:.1em}.card-copy h2{max-width:90%;margin:0;font-family:var(--jw-font-display,"Syne Variable");font-size:25px;font-weight:650;line-height:1.02;letter-spacing:-.04em}.arrow{position:absolute;right:17px;top:18px;width:32px;height:32px;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;transition:.2s}.place-card:hover .arrow{transform:translate(2px,-2px);background:var(--text);color:var(--bg)}.rating{display:block;margin-top:11px;color:var(--body);font-size:11px}.card-tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:14px}.card-tags span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:5px 8px;border-radius:999px;background:var(--soft);color:var(--muted);font-size:8px;font-weight:750}.fallback{height:320px!important;display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(145deg,color-mix(in srgb,var(--accent) 18%,var(--soft)),var(--soft));color:var(--text)}.fallback>span{font-family:var(--jw-font-display,"Syne Variable");font-size:80px;font-weight:650;letter-spacing:-.08em}.fallback small{text-transform:uppercase;letter-spacing:.16em;font-weight:900}.broken-image figure:not(.fallback){display:none}.empty{padding:100px 20px;text-align:center}.empty>span{font-size:55px}.empty h2{font-family:var(--jw-font-display,"Syne Variable");font-size:42px;letter-spacing:-.05em}.empty p{color:var(--muted)}.empty button{padding:12px 17px;border:0;border-radius:999px;background:var(--text);color:var(--bg);font-weight:850}#sentinel{height:2px}@media(max-width:850px){main{width:min(100% - 24px,1500px)}.gallery-nav{height:68px;padding:0 14px}.map-link{padding:8px 11px}.map-link span{display:none}.gallery-hero{min-height:380px;display:block;padding-top:70px}.hero-word h1{font-size:clamp(60px,19vw,100px)}.hero-note{width:auto;margin-top:35px}.filter-dock{top:76px;grid-template-columns:1fr;padding:9px}.payment-tabs{height:48px}.payment-tabs button{flex:1}.masonry{columns:2 160px;column-gap:9px}.place-card{margin-bottom:9px;border-radius:16px}.place-card figure{height:220px}.place-card.tall figure{height:310px}.place-card.landscape figure{height:170px}.card-copy{padding:13px}.card-copy h2{font-size:20px}.arrow{display:none}.card-tags{display:none}.fallback{height:240px!important}.fallback>span{font-size:58px}.result-bar{padding-top:30px}.result-bar button{display:none}}@media(max-width:430px){.gallery-brand small{font-size:8px}.map-link{font-size:10px}.masonry{columns:2 145px}} diff --git a/docker/justworks/business/public/assets/gallery.js b/docker/justworks/business/public/assets/gallery.js new file mode 100644 index 00000000..5e4d8961 --- /dev/null +++ b/docker/justworks/business/public/assets/gallery.js @@ -0,0 +1,250 @@ +import { mountLogo } from "./ds/logo.js"; +import { trackGallery } from "./general-analytics.js"; +document + .querySelectorAll("[data-logo]") + .forEach((el) => mountLogo(el, { icon: true })); +document.querySelector("#themeToggle")?.remove(); +const $ = (id) => document.querySelector(id); +const gallery = $("#gallery"), + search = $("#search"), + empty = $("#empty"); +const categoryNames = { + restaurant: "Restaurant", + cafe: "Café", + bar: "Bar", + bakery: "Bakery", + stay: "Stay", + hotel: "Stay", + shop: "Shop", + market: "Market", + beauty: "Beauty", + barber: "Barber", + activity: "Experience", + other: "Local place", +}; +const mealTerms = { + breakfast: ["breakfast", "brunch", "bakery", "cafe", "coffee"], + lunch: ["lunch", "restaurant", "cafe", "takeaway"], + dinner: ["dinner", "restaurant", "bar", "evening"], + coffee: ["coffee", "cafe", "bakery"], + drinks: ["drinks", "bar", "wine", "cocktail", "beer", "poncha"], + stay: ["stay", "hotel", "hostel", "guesthouse", "accommodation"], + shop: ["shop", "market", "store", "retail"], +}; +let places = [], + filtered = [], + payment = "all", + quick = "", + shown = 0, + seed = 0; +const PAGE = 36; +const esc = (v) => + String(v ?? "").replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ], + ); +const words = (p) => + [p.name, p.category, p.area, p.address, ...(p.tags || [])] + .join(" ") + .toLowerCase(); +const photo = (p) => (p.images || []).find(Boolean) || p.image || ""; +function pseudo(value) { + let n = 0; + for (const c of value) n = (n * 31 + c.charCodeAt(0)) >>> 0; + return n; +} +function isOpen(p) { + if (!p.hours) return false; + const text = p.hours.toLowerCase(); + if (/open 24|24\/7/.test(text)) return true; + if (/closed/.test(text) && !/[–-]/.test(text)) return false; + return true; +} +function matchQuick(p) { + if (!quick) return true; + if (quick === "open") return isOpen(p); + const hay = words(p); + return (mealTerms[quick] || [quick]).some((term) => hay.includes(term)); +} +function card(p, index) { + const image = photo(p), + height = ["portrait", "landscape", "tall"][(pseudo(p.slug) + seed) % 3], + bitcoin = p.payment === "bitcoin", + url = p.justworks || p.nsite || p.website || "#"; + return ``; +} +function apply(reset = true) { + const term = search.value.trim().toLowerCase(); + filtered = places.filter( + (p) => + (payment === "all" || p.payment === payment) && + matchQuick(p) && + (!term || words(p).includes(term)), + ); + filtered.sort( + (a, b) => + (photo(b) ? 1 : 0) - (photo(a) ? 1 : 0) || + (b.rating || 0) - (a.rating || 0), + ); + if (seed) + filtered.sort( + (a, b) => (pseudo(a.slug + seed) % 997) - (pseudo(b.slug + seed) % 997), + ); + if (reset) { + shown = 0; + gallery.innerHTML = ""; + } + renderMore(); + $("#resultCount").textContent = + `${filtered.length} ${filtered.length === 1 ? "place" : "places"}`; + $("#resultContext").textContent = + [ + payment !== "all" + ? payment === "bitcoin" + ? "Bitcoin accepted" + : "Fiat payment" + : "", + quick && quick !== "open" + ? quick[0].toUpperCase() + quick.slice(1) + : quick === "open" + ? "Published hours available" + : "", + term ? `matching “${term}”` : "", + ] + .filter(Boolean) + .join(" · ") || "Real merchants from Just Works Maps"; + empty.hidden = filtered.length > 0; + gallery.hidden = !filtered.length; + history.replaceState( + null, + "", + `?${new URLSearchParams({ ...(term ? { q: term } : {}), ...(payment !== "all" ? { pay: payment } : {}), ...(quick ? { filter: quick } : {}) })}`, + ); +} +function renderMore() { + const batch = filtered.slice(shown, shown + PAGE); + gallery.insertAdjacentHTML( + "beforeend", + batch.map((p, i) => card(p, shown + i)).join(""), + ); + shown += batch.length; + document + .querySelectorAll("img") + .forEach( + (img) => + (img.onerror = () => + img.closest(".place-card")?.classList.add("broken-image")), + ); +} +function selectPayment(value) { + payment = value; + document + .querySelectorAll("[data-payment]") + .forEach((b) => b.classList.toggle("active", b.dataset.payment === value)); + apply(); + trackGallery("surface_interaction", `payment_filter:${value}`); +} +function selectQuick(value) { + quick = value; + document + .querySelectorAll("[data-query]") + .forEach((b) => b.classList.toggle("active", b.dataset.query === value)); + apply(); + if (value) trackGallery("surface_interaction", `filter:${value}`); +} +function clear() { + search.value = ""; + payment = "all"; + quick = ""; + seed = 0; + document + .querySelectorAll("[data-payment]") + .forEach((b) => b.classList.toggle("active", b.dataset.payment === "all")); + document + .querySelectorAll("[data-query]") + .forEach((b) => b.classList.toggle("active", !b.dataset.query)); + apply(); +} +document + .querySelectorAll("[data-payment]") + .forEach((b) => (b.onclick = () => selectPayment(b.dataset.payment))); +document + .querySelectorAll("[data-query]") + .forEach((b) => (b.onclick = () => selectQuick(b.dataset.query))); +let searchTimer; +search.oninput = () => { + apply(); + clearTimeout(searchTimer); + if (search.value.trim()) + searchTimer = setTimeout( + () => trackGallery("surface_interaction", "search"), + 700, + ); +}; +gallery.addEventListener("click", (event) => { + const card = event.target.closest(".place-card"); + if (card) + trackGallery( + "surface_interaction", + `place_open:${card.dataset.category || "other"}`, + ); +}); +$("#clearSearch").onclick = () => { + search.value = ""; + apply(); + search.focus(); +}; +$("#clearAll").onclick = clear; +$("#shuffle").onclick = () => { + seed = Date.now(); + apply(); + trackGallery("surface_interaction", "shuffle"); +}; +new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && shown < filtered.length) renderMore(); + }, + { rootMargin: "800px" }, +).observe($("#sentinel")); +async function boot() { + const data = await fetch("/api/gallery", { cache: "no-store" }).then((r) => { + if (!r.ok) throw new Error("data"); + return r.json(); + }); + places = data.places || []; + $("#totalCount").textContent = `${places.length} independent places`; + $("#allCount").textContent = places.length; + $("#bitcoinCount").textContent = places.filter( + (p) => p.payment === "bitcoin", + ).length; + $("#fiatCount").textContent = places.filter( + (p) => p.payment === "fiat", + ).length; + const params = new URLSearchParams(location.search); + search.value = params.get("q") || ""; + payment = params.get("pay") || "all"; + quick = params.get("filter") || ""; + document + .querySelectorAll("[data-payment]") + .forEach((b) => + b.classList.toggle("active", b.dataset.payment === payment), + ); + document + .querySelectorAll("[data-query]") + .forEach((b) => b.classList.toggle("active", b.dataset.query === quick)); + apply(); + trackGallery("surface_view", "open"); +} +boot().catch(() => { + $("#resultCount").textContent = "Gallery temporarily unavailable"; + empty.hidden = false; + gallery.hidden = true; +}); diff --git a/docker/justworks/business/public/assets/general-analytics.js b/docker/justworks/business/public/assets/general-analytics.js new file mode 100644 index 00000000..26dd9e14 --- /dev/null +++ b/docker/justworks/business/public/assets/general-analytics.js @@ -0,0 +1,12 @@ +const endpoint = "https://justworks.cash/api/analytics/event"; + +export function trackGallery(type = "surface_interaction", action = "open") { + try { + const body = JSON.stringify({ surface: "gallery", type, action }); + if ( + navigator.sendBeacon?.(endpoint, new Blob([body], { type: "text/plain" })) + ) + return; + fetch(endpoint, { method: "POST", body, keepalive: true }).catch(() => {}); + } catch {} +} diff --git a/docker/justworks/business/public/assets/login-panel.css b/docker/justworks/business/public/assets/login-panel.css new file mode 100644 index 00000000..08b54ad9 --- /dev/null +++ b/docker/justworks/business/public/assets/login-panel.css @@ -0,0 +1 @@ +.jw-login-open{margin-left:10px;padding:10px 14px;border:1px solid #444;border-radius:99px;background:#fff;color:#080808;font:inherit;font-size:10px;font-weight:900;cursor:pointer}.jw-login-open span{color:#777;font-weight:700}.jw-login{width:min(590px,calc(100% - 24px));max-height:calc(100dvh - 24px);padding:clamp(26px,5vw,48px);border:0;border-radius:0;background:#f5f5f1;color:#0a0a0a;box-shadow:0 35px 100px rgba(0,0,0,.45)}.jw-login::backdrop{background:rgba(0,0,0,.75);backdrop-filter:blur(5px)}.jw-login-close{position:absolute;right:18px;top:16px;width:38px;height:38px;border:1px solid #d4d4cf;background:transparent;font-size:25px}.jw-kicker{margin:0;color:#ff4f1f;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.jw-login h2{max-width:470px;margin:13px 0 15px;font-family:var(--jw-font-display,"Syne Variable",system-ui);font-size:clamp(42px,7vw,64px);font-weight:620;line-height:.86;letter-spacing:-.055em}.jw-intro{max-width:460px;margin:0 0 28px;color:#73736f;line-height:1.5}.jw-login-methods{display:grid;gap:7px}.jw-login-methods>a,.jw-classic>a,.jw-classic>button{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:17px;border:1px solid #d7d7d2;background:#fff;color:#111;text-decoration:none}.jw-login-methods .jw-primary{background:#080808;color:#fff}.jw-login b,.jw-login small{display:block}.jw-login b{font-size:14px}.jw-login small{margin-top:4px;color:#888;font-size:10px}.jw-login i{color:#ff4f1f;font-style:normal;font-weight:900}.jw-more{width:100%;display:flex;justify-content:space-between;margin-top:15px;padding:14px 0;border:0;border-bottom:1px solid #d7d7d2;background:transparent;text-align:left;font-weight:850}.jw-classic{display:grid;gap:7px;padding-top:10px}.jw-classic>button{width:100%;font:inherit;text-align:left}.jw-classic>label{display:grid;grid-template-columns:1fr auto;gap:10px;padding:17px;border:1px solid #d7d7d2;background:#fff}.jw-classic>label>span{grid-column:1/-1}.jw-classic input{min-width:0;padding:11px;border:1px solid #ccc;background:#f4f4f0;font:inherit;font-size:12px}.jw-classic label button{border:0;background:#080808;color:#fff;font-weight:850}.jw-login-status{min-height:18px;margin:8px 0 0;color:#087c67;font-size:11px}.jw-login-status.error{color:#b32}.jw-login footer{display:flex;justify-content:space-between;gap:15px;margin-top:24px;padding-top:18px;border-top:1px solid #d7d7d2;color:#888;font-size:9px;text-transform:uppercase}.jw-login footer a{text-decoration:none}.jw-dev-note{display:inline-block;margin-left:6px;padding:2px 7px;border:1px solid var(--accent,#e8dd2f);border-radius:99px;color:var(--fg,#0a0a0a);font-size:10px;letter-spacing:.04em;text-transform:uppercase}@media(max-width:620px){.jw-login-open span{display:none}.jw-login{max-height:100dvh;margin:0 0 0 auto;padding:28px 20px}.jw-login h2{font-size:44px}.jw-classic>label{grid-template-columns:1fr}.jw-classic label button{min-height:44px}.jw-login footer{display:grid}} diff --git a/docker/justworks/business/public/assets/login-panel.js b/docker/justworks/business/public/assets/login-panel.js new file mode 100644 index 00000000..7281d3a0 --- /dev/null +++ b/docker/justworks/business/public/assets/login-panel.js @@ -0,0 +1,32 @@ +import {npubFrom,prod} from './ds/env.js'; +import {transferSession} from './business-sso.js'; +let npub=npubFrom(); +let mounted=false; +const sessionKey=`jw.business.login.${npub}`; +function mount(){if(mounted)return;const header=document.querySelector('.top,body>main header,header');if(!header)return;mounted=true;const trigger=document.createElement('button');trigger.className='jw-login-open';trigger.type='button';trigger.innerHTML='Login with login.justworks.cash';header.append(trigger);sessionStorage.removeItem(sessionKey);sessionStorage.removeItem('jw.business.signer');refreshSession(trigger);document.body.insertAdjacentHTML('beforeend',``);const dialog=document.querySelector('.jw-login'),status=dialog.querySelector('.jw-login-status'),expertStatus=dialog.querySelector('.jw-expert-status');if(!prod){dialog.querySelector('[name="ownerIdentifier"]').value='demo';dialog.querySelector('[name="ownerPassword"]').value='demo';dialog.querySelector('.jw-intro').insertAdjacentHTML('beforeend',' Dev · demo login prefilled')}trigger.onclick=()=>dialog.showModal();dialog.querySelector('.jw-login-close').onclick=()=>dialog.close();dialog.onclick=e=>{if(e.target===dialog)dialog.close()};dialog.querySelector('.jw-more').onclick=function(){const classic=dialog.querySelector('.jw-classic'),open=classic.hidden;classic.hidden=!open;this.setAttribute('aria-expanded',String(open));this.querySelector('span').textContent=open?'−':'+'};dialog.querySelector('.jw-simple-login').onsubmit=event=>login(event,status);dialog.querySelector('.jw-nsec-login').onsubmit=event=>login(event,expertStatus);dialog.querySelector('[data-nip07]').onclick=async()=>{try{if(!window.nostr)throw new Error('No Nostr browser extension found.');const pubkey=await window.nostr.getPublicKey();sessionStorage.setItem('jw.business.signer',JSON.stringify({type:'nip07',pubkey}));expertStatus.textContent=`Extension connected · ${pubkey.slice(0,12)}…`;expertStatus.classList.remove('error')}catch(error){expertStatus.textContent=error.message;expertStatus.classList.add('error')}};dialog.querySelector('[data-bunker-connect]').onclick=()=>{const value=dialog.querySelector('[data-bunker]').value.trim();if(!/^(bunker|nostrconnect):\/\//i.test(value)){expertStatus.textContent='Enter a valid bunker:// or nostrconnect:// URI.';expertStatus.classList.add('error');return}window.jwBusinessSigner={type:'nip46',uri:value};expertStatus.textContent='Bunker connected for this page only.';expertStatus.classList.remove('error')}} +async function login(event,status){event.preventDefault();const form=event.currentTarget,button=form.querySelector('button[type="submit"]');button.disabled=true;status.textContent='Checking your Just Works Login…';status.classList.remove('error');const values=Object.fromEntries(new FormData(form));try{const response=await fetch('/api/business-owner-login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,...values})}),data=await response.json();if(!response.ok)throw new Error(data.error||'Login failed');form.querySelectorAll('input[type="password"]').forEach(input=>input.value='');status.textContent=`Logged in to ${data.merchant}.`;button.innerHTML='Logged in ✓';await refreshSession(document.querySelector('.jw-login-open'));setTimeout(()=>document.querySelector('.jw-login').close(),900)}catch(error){status.textContent=error.message;status.classList.add('error');button.disabled=false;button.innerHTML='Try again ↻'}finally{form.querySelectorAll('input[type="password"]').forEach(input=>input.value='')}} +async function refreshSession(trigger){ + try { + const response=await fetch('/api/business-session',{cache:'no-store',credentials:'same-origin'}); + const data=await response.json(); + if(!npub&&data.legacy_npub)npub=data.legacy_npub; + const authenticated=data.authenticated&&data.session?.npub===npub; + trigger.innerHTML=authenticated?'Logged in Just Works':'Login with login.justworks.cash'; + let logout=document.querySelector('.jw-logout'); + if(authenticated&&!logout){ + logout=document.createElement('button');logout.type='button';logout.className='jw-logout';logout.textContent='Logout'; + trigger.after(logout); + logout.onclick=async()=>{await fetch('/api/business-logout',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});logout.remove();await refreshSession(trigger)}; + } + if(!authenticated&&logout)logout.remove(); + const form=document.querySelector('.jw-simple-login'); + let transfer=document.querySelector('.jw-session-transfer'); + if(data.sso_enabled&&!authenticated&&npub&&!location.pathname.endsWith('/business-sso')&&form&&!transfer){ + transfer=document.createElement('button');transfer.type='button';transfer.className='jw-session-transfer';transfer.textContent='Use existing Business session';form.before(transfer); + transfer.onclick=async()=>{transfer.disabled=true;const status=document.querySelector('.jw-login-status');try{await transferSession(npub,status)}catch(error){status.textContent=error.message;transfer.disabled=false}}; + } + if((authenticated||!data.sso_enabled)&&transfer)transfer.remove(); + window.dispatchEvent(new CustomEvent('jw-session-changed',{detail:{authenticated,npub}})); + } catch { trigger.innerHTML='Login with login.justworks.cash'; } +} +const observer=new MutationObserver(()=>{mount();if(mounted)observer.disconnect()});observer.observe(document.documentElement,{childList:true,subtree:true});mount(); diff --git a/docker/justworks/business/public/assets/login-simple.css b/docker/justworks/business/public/assets/login-simple.css new file mode 100644 index 00000000..b0bad60c --- /dev/null +++ b/docker/justworks/business/public/assets/login-simple.css @@ -0,0 +1 @@ +.jw-classic[hidden]{display:none}.jw-simple-login{display:grid;gap:12px}.jw-simple-login label{color:#777;font-size:9px;font-weight:900;letter-spacing:.11em;text-transform:uppercase}.jw-simple-login input{width:100%;display:block;margin-top:6px;padding:14px;border:1px solid #d3d3ce;background:#fff;font:inherit;font-size:15px}.jw-simple-login>button,.jw-nsec-login>button{display:flex;justify-content:space-between;padding:15px 17px;border:0;background:#080808;color:#fff;font-weight:900}.jw-simple-login button span{color:#ff4f1f}.jw-simple-login button:disabled,.jw-nsec-login button:disabled{opacity:.6}.jw-nsec-login{display:grid;gap:7px}.jw-nsec-login>label{display:block!important}.jw-nsec-login>button{width:100%}.jw-expert-status{min-height:18px;margin:8px 0 0;color:#087c67;font-size:11px}.jw-expert-status.error{color:#b32} diff --git a/docker/justworks/business/public/assets/module-qr.css b/docker/justworks/business/public/assets/module-qr.css new file mode 100644 index 00000000..8e23a88b --- /dev/null +++ b/docker/justworks/business/public/assets/module-qr.css @@ -0,0 +1,37 @@ +.module-summary{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0} +.module-summary>div{min-width:0} +.module-summary h2{font-size:clamp(26px,2.5vw,36px)} +.module-qr{flex:0 0 58px;display:grid;justify-items:center;gap:2px;padding:4px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);cursor:pointer;font-family:inherit;transition:border-color .18s,box-shadow .18s} +.module-qr img{display:block;width:48px;height:48px} +.module-qr span{font-size:9px;font-weight:800;letter-spacing:.1em} +.free-link{min-width:0;min-height:240px} +.free-link-top{display:flex;align-items:center;justify-content:space-between;gap:8px} +.free-link-top .free-icon{flex:0 0 42px} +.free-link .module-qr{border-color:#555} +.free-link h2{overflow-wrap:anywhere;line-height:1.15} +.free-open{display:flex;align-items:center;justify-content:space-between;color:#fff;text-decoration:none;font-size:12px;font-weight:900} +.free-open b{color:var(--orange);font-size:20px} +.free-open:focus-visible{outline:3px solid var(--orange);outline-offset:3px} +@media(max-width:680px){.free-link{min-height:230px;padding:14px;gap:16px}.free-link h2{font-size:22px}.free-link:first-child{grid-column:auto}} +.module-qr:hover{border-color:var(--orange);box-shadow:0 0 0 2px #ff4f1f1a} +.module-qr:focus-visible,.module-qr-dialog :focus-visible{outline:3px solid var(--orange);outline-offset:3px} +.module-qr[hidden],.module-qr-dialog [hidden]{display:none!important} +.module-qr-dialog{width:min(520px,calc(100% - 28px));max-height:calc(100dvh - 28px);margin:auto;padding:30px;border:1px solid var(--line);border-radius:16px;background:#fff;color:var(--ink);box-shadow:0 24px 100px #0005;overflow:auto;text-align:center} +.module-qr-dialog::backdrop{background:#080808b3;backdrop-filter:blur(4px)} +.module-qr-dialog .eyebrow{margin:6px 32px 12px} +.module-qr-dialog h2{margin:0;font-family:var(--jw-font-display,"Syne Variable",system-ui);font-size:34px;letter-spacing:-.04em} +.module-qr-dialog #module-qr-description{margin:10px 0 16px;color:#666;font-size:14px;line-height:1.5} +.module-qr-close{position:absolute;top:8px;right:8px;width:44px;height:44px;border:0;border-radius:50%;background:#f5f5f1;font-size:28px;cursor:pointer} +.module-qr-image{min-height:220px;display:grid;place-items:center} +.module-qr-image img{width:100%;max-width:360px;height:auto;aspect-ratio:1;display:block} +.module-qr-image p:empty{display:none} +.module-qr-image p{color:#777;font-size:14px} +.module-qr-url{display:block;margin:14px 0 20px;overflow-wrap:anywhere;font-size:12px;line-height:1.6;text-underline-offset:3px} +.module-qr-tools{display:flex;flex-wrap:wrap;justify-content:center;gap:8px} +.module-qr-tools button,.module-qr-tools a{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:10px 14px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-family:inherit;font-size:12px;font-weight:700;text-decoration:none;cursor:pointer} +.module-qr-tools [data-qr-copy]{background:var(--black);border-color:var(--black);color:#fff} +.module-qr-tools button:disabled{opacity:.4;cursor:default} +.module-qr-feedback{min-height:20px;margin:12px 0 0;color:#666;font-size:12px} +body:has(.module-qr-dialog[open]){overflow:hidden} +@media(max-width:680px){.module-summary h2{font-size:32px}.module-qr-dialog{padding:24px 18px}.module-qr-dialog h2{font-size:30px}} +@media print{body:has(.module-qr-dialog[open])>*:not(.module-qr-dialog){display:none!important}.module-qr-dialog[open]{position:static;width:100%;max-height:none;overflow:visible;border:0;box-shadow:none}.module-qr-dialog::backdrop{background:none}.module-qr-close,.module-qr-tools,.module-qr-feedback{display:none!important}.module-qr-image img{width:360px;max-width:100%}} diff --git a/docker/justworks/business/public/assets/module-qr.js b/docker/justworks/business/public/assets/module-qr.js new file mode 100644 index 00000000..7638a5c9 --- /dev/null +++ b/docker/justworks/business/public/assets/module-qr.js @@ -0,0 +1,104 @@ +export function qrImageUrl(text) { + const prefix = location.pathname.startsWith('/business-demo/') ? '/business-demo' : ''; + return `${prefix}/api/qr?text=${encodeURIComponent(text)}`; +} + +// QR payloads contain only the public destination and merchant context. +export function moduleQrUrl(destination, npub) { + const url = new URL(destination, location.origin); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid module URL'); + const surface = url.searchParams.get('surface'); + url.username = ''; + url.password = ''; + url.search = ''; + url.hash = ''; + if (surface) url.searchParams.set('surface', surface); + url.searchParams.set('npub', npub); + return url.href; +} + +const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + +// Both public links and business modules share the exact same QR control. +// Free-link URLs are already resolved by the API: preserve them verbatim. +export function qrButton(id, name, url, {free = false, hidden = false} = {}) { + return ``; +} + +export function mountModuleQrs(root) { + const dialog = document.createElement('dialog'); + dialog.className = 'module-qr-dialog'; + dialog.setAttribute('aria-labelledby', 'module-qr-title'); + dialog.setAttribute('aria-describedby', 'module-qr-description'); + dialog.innerHTML = `

Quick access

Scan to open this merchant’s module on your phone.

Download QR

`; + document.body.append(dialog); + const title = dialog.querySelector('h2'); + const img = dialog.querySelector('img'); + const status = dialog.querySelector('.module-qr-image [role=status]'); + const urlLink = dialog.querySelector('.module-qr-url'); + const feedback = dialog.querySelector('.module-qr-feedback'); + const download = dialog.querySelector('[data-qr-download]'); + const print = dialog.querySelector('[data-qr-print]'); + img.onload = () => { + img.hidden = false; + status.textContent = ''; + download.hidden = false; + print.disabled = false; + }; + img.onerror = () => { + img.hidden = true; + status.textContent = 'QR code could not load. You can still open or copy the link.'; + download.hidden = true; + print.disabled = true; + }; + root.addEventListener('click', (event) => { + const button = event.target.closest('[data-qr-trigger]'); + if (!button || button.hidden) return; + const { qrUrl, qrName, qrTrigger } = button.dataset; + dialog.querySelector('#module-qr-description').textContent = button.hasAttribute('data-free-qr') + ? 'Scan to open this merchant’s public page on your phone.' + : 'Scan to open this merchant’s module on your phone.'; + title.textContent = qrName; + urlLink.href = qrUrl; + urlLink.textContent = qrUrl; + feedback.textContent = ''; + img.hidden = true; + status.textContent = 'Loading QR code…'; + print.disabled = true; + download.hidden = true; + img.alt = `QR code for ${qrName}`; + img.src = qrImageUrl(qrUrl); + download.href = img.src; + download.download = `justworks-${qrTrigger}-qr.svg`; + dialog.showModal(); + }); + dialog.querySelector('.module-qr-close').onclick = () => dialog.close(); + dialog.addEventListener('click', (event) => { + if (event.target !== dialog) return; + const bounds = dialog.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close(); + }); + // Native modal dialogs restore focus on close. Do not also restore it from + // the queued `close` event: that can steal focus from the next QR trigger. + dialog.addEventListener('keydown', (event) => { + if (event.key !== 'Tab') return; + const controls = [...dialog.querySelectorAll('button:not(:disabled), a[href]')].filter(node => !node.hidden); + const first = controls[0], last = controls.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }); + dialog.querySelector('[data-qr-copy]').onclick = async () => { + try { + await navigator.clipboard.writeText(urlLink.href); + feedback.textContent = 'Link copied.'; + } catch { + feedback.textContent = 'Could not copy automatically. Select and copy the link above.'; + } + }; + print.onclick = () => window.print(); +} diff --git a/docker/justworks/business/public/assets/modules.css b/docker/justworks/business/public/assets/modules.css new file mode 100644 index 00000000..212b916e --- /dev/null +++ b/docker/justworks/business/public/assets/modules.css @@ -0,0 +1,5 @@ +:root{color-scheme:light;--bg:var(--jw-bg,#fafafa);--panel:var(--jw-surface,#fff);--soft:var(--jw-surface-sunken,#f1f3f2);--line:var(--jw-line,rgba(10,10,10,.1));--text:var(--jw-text-primary,#0a0a0a);--body:var(--jw-text-body,#454545);--muted:var(--jw-text-muted,#737373);--accent:var(--jw-accent,#f4511e);--green:var(--jw-success,#08806b);font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);padding-top:82px}button,input{font:inherit}nav{height:82px;padding:0 clamp(18px,5vw,70px);display:flex;align-items:center;gap:30px;border-bottom:1px solid transparent;background:transparent;position:fixed;top:0;left:0;right:0;z-index:60;backdrop-filter:none;transition:background .3s,border-color .3s,backdrop-filter .3s;pointer-events:none}nav.is-scrolled{background:color-mix(in srgb,var(--panel) 86%,transparent);border-bottom-color:var(--line);backdrop-filter:blur(18px)}nav>*{pointer-events:auto}.brand{display:flex;align-items:center;gap:10px;color:var(--text);text-decoration:none}.brand>[data-logo]{width:34px}.brand b{font-size:16px;font-weight:900}.brand small{display:inline;font-size:16px;font-weight:900;color:var(--text);letter-spacing:0;text-transform:none}.brand small:before{content:"·";color:var(--muted);margin:0 9px}.links{display:flex;gap:6px;margin-left:auto}.links a{padding:8px 12px;color:var(--body);text-decoration:none;font-size:13px;font-weight:750;border-radius:99px}.links a:hover{background:var(--soft)}.surface{width:min(1380px,calc(100% - 40px));margin:auto;padding:clamp(38px,6vw,80px) 0 90px}.eyebrow{color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:10px;font-weight:900;margin:0}.head{display:flex;justify-content:space-between;align-items:flex-end;gap:30px;margin-bottom:30px}.head h1,.gallery-head h1,.pay-copy h1,.center h1{font-family:var(--jw-font-display,"Syne Variable",system-ui);font-weight:650;font-size:clamp(44px,7vw,88px);line-height:.9;letter-spacing:-.06em;margin:12px 0}.online{color:var(--green);font-weight:850}.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:12px}.metrics article,.panel{padding:24px;border:1px solid var(--line);border-radius:22px;background:var(--panel)}.metrics span,.metrics small{display:block;color:var(--muted);font-size:11px;font-weight:750}.metrics strong{display:block;font-size:40px;letter-spacing:-.05em;margin:10px 0}.pos-grid{display:grid;grid-template-columns:1.6fr .8fr;gap:12px}.panel h2{font-family:var(--jw-font-display,"Syne Variable");font-size:30px;margin:7px 0}.queue{display:grid;gap:8px;margin-top:20px}.queue-item{display:grid;grid-template-columns:52px 1fr auto auto;align-items:center;gap:13px;padding:12px;border-radius:16px;background:var(--soft)}.queue-item>i{width:48px;height:48px;display:grid;place-items:center;border-radius:13px;background:var(--text);color:var(--bg);font-style:normal;font-weight:900}.queue-item strong,.queue-item small{display:block}.queue-item small{color:var(--muted);margin-top:3px}.queue-item time{color:var(--muted);font-size:12px}.queue-item button{border:0;border-radius:99px;background:var(--accent);color:#fff;padding:9px 14px;font-weight:850}.quick{display:flex;flex-direction:column}.quick>a,.quick>button{display:flex;align-items:center;gap:14px;padding:14px 0;border:0;border-bottom:1px solid var(--line);color:var(--text);background:none;text-align:left;text-decoration:none}.quick b{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--soft)}.quick strong,.quick small{display:block}.quick small{color:var(--muted);margin-top:3px}.pay-shell{min-height:calc(100vh - 250px);display:grid;grid-template-columns:1.1fr .8fr;gap:clamp(40px,8vw,120px);align-items:center}.pay-copy>p:last-child{max-width:610px;color:var(--body);font-size:18px;line-height:1.6}.pay-card{padding:clamp(25px,4vw,42px);border:1px solid var(--line);border-radius:28px;background:var(--panel);box-shadow:0 28px 80px rgba(0,0,0,.1)}.merchant{display:flex;align-items:center;gap:12px;padding-bottom:24px;border-bottom:1px solid var(--line)}.merchant>span{width:46px;height:46px;display:grid;place-items:center;border-radius:50%;background:var(--text);color:var(--bg);font-weight:900}.merchant strong,.merchant small{display:block}.merchant small{color:var(--muted)}.pay-card label{display:block;margin:24px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;font-size:10px;font-weight:900}.amount{display:grid;grid-template-columns:auto 1fr;align-items:center;padding:8px 15px;border:1px solid var(--line);border-radius:15px;background:var(--soft)}.amount>span{font-size:25px}.amount input{min-width:0;width:100%;border:0;outline:0;background:none;color:var(--text);font-size:38px;font-weight:800;padding:6px 10px}.amount small{grid-column:2;color:var(--muted)}.tips{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.tips button{border:1px solid var(--line);border-radius:11px;padding:11px 4px;background:var(--soft);color:var(--text);font-weight:800}.tips button.active{background:var(--text);color:var(--bg)}.receipt{display:flex;justify-content:space-between;margin:20px 0;padding:16px;background:var(--soft);border-radius:13px}.primary{width:100%;display:flex;justify-content:space-between;margin-top:24px;padding:16px 18px;border:0;border-radius:14px;background:var(--accent);color:#fff;font-weight:900}.primary.success{background:var(--green)}.secure{text-align:center;color:var(--muted);font-size:11px}.signage{position:fixed;inset:0;overflow:hidden;background:#000;color:#fff}.signage>img,.shade{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.shade{background:linear-gradient(90deg,rgba(0,0,0,.88),rgba(0,0,0,.16)),linear-gradient(0deg,rgba(0,0,0,.55),transparent)}.signage header{position:absolute;z-index:1;left:5vw;right:5vw;top:4vw;display:flex;align-items:center;gap:14px;font-size:22px}.signage header [data-logo]{width:40px}.signage header time{margin-left:auto}.sign-copy{position:absolute;z-index:1;left:5vw;bottom:8vw}.sign-copy>p{text-transform:uppercase;letter-spacing:.2em}.sign-copy h1{font-family:var(--jw-font-display,"Syne Variable");font-size:7vw;line-height:.82;letter-spacing:-.07em;margin:20px 0 50px}.ticker{display:flex;gap:8px}.ticker span{padding:12px 16px;border:1px solid rgba(255,255,255,.25);border-radius:99px;background:rgba(0,0,0,.3)}.signage aside{position:absolute;z-index:1;right:5vw;bottom:8vw;text-align:center}.signage aside div{width:14vw;height:14vw;display:grid;place-items:center;background:#fff;color:#000;font-size:11vw;margin:10px 0}.gallery-head>p:last-of-type{color:var(--body);font-size:18px}.filters{display:flex;gap:7px;flex-wrap:wrap;margin:30px 0}.filters button{border:1px solid var(--line);border-radius:99px;background:var(--panel);color:var(--text);padding:10px 15px;font-weight:750}.filters button.active{background:var(--text);color:var(--bg)}.gallery-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.merchant-card{display:grid;grid-template-columns:1.1fr 1fr;overflow:hidden;border:1px solid var(--line);border-radius:24px;background:var(--panel)}.merchant-card figure{position:relative;min-height:290px;margin:0}.merchant-card img{width:100%;height:100%;object-fit:cover}.merchant-card figure span{position:absolute;left:14px;top:14px;background:#fff;color:#111;border-radius:99px;padding:7px 10px;font-size:10px;font-weight:900}.merchant-card>div{padding:27px}.merchant-card p{color:var(--accent);font-size:10px;text-transform:uppercase;letter-spacing:.12em;font-weight:900}.merchant-card h2{font-family:var(--jw-font-display,"Syne Variable");font-size:30px;line-height:1;margin:12px 0}.merchant-card small{color:var(--muted)}.merchant-card footer,.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:24px}.merchant-card footer span,.tags span{padding:6px 9px;border-radius:99px;background:var(--soft);font-size:10px;font-weight:800}.center{max-width:850px;margin:10vh auto;text-align:center}.orbit{width:90px;height:90px;display:grid;place-items:center;margin:0 auto 35px;border:1px solid var(--line);border-radius:50%;font-size:40px;box-shadow:0 0 0 18px var(--soft)}.center>p:not(.eyebrow){max-width:650px;margin:22px auto;color:var(--body);font-size:18px;line-height:1.6}.center .tags{justify-content:center;margin:35px}.center a{color:var(--text);font-weight:850}@media(max-width:850px){.surface{width:min(100% - 26px,1380px)}.links{display:none}.metrics,.gallery-grid{grid-template-columns:1fr}.pos-grid,.pay-shell{grid-template-columns:1fr}.head{align-items:flex-start;flex-direction:column}.merchant-card{grid-template-columns:1fr}.merchant-card figure{min-height:230px}.queue-item{grid-template-columns:46px 1fr auto}.queue-item time{display:none}.sign-copy h1{font-size:12vw}.signage aside{display:none}.ticker{flex-direction:column;align-items:flex-start}.ticker span:nth-child(n+2){display:none}} +.loading{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:6px;background:#0a0a0a;color:#fafafa}.loading span{font-size:56px}.loading p{margin:0;color:#999;font-size:13px}.field{display:block;margin:20px 0 0}.field>span{display:block;margin-bottom:8px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;font-size:10px;font-weight:900}.field input,.field textarea{width:100%;padding:14px 15px;border:1px solid var(--line);border-radius:15px;background:var(--soft);color:var(--text);font-size:15px;outline:none}.field input:focus,.field textarea:focus{border-color:var(--text)}.field textarea{min-height:96px;resize:vertical}.stepper{display:flex;align-items:center;gap:18px;margin-top:8px;padding:8px 14px;border:1px solid var(--line);border-radius:15px;background:var(--soft);width:max-content}.stepper button{width:36px;height:36px;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--text);font-size:19px;cursor:pointer;transition:.2s}.stepper button:hover{background:var(--soft)}.stepper strong{min-width:30px;text-align:center;font-size:22px;font-weight:800}.chips{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:8px}.chips button{border:1px solid var(--line);border-radius:11px;padding:11px 4px;background:var(--soft);color:var(--text);font-weight:800;font-size:13px;cursor:pointer;transition:.2s}.chips button:hover{background:var(--panel)}.chips button.active{background:var(--text);color:var(--bg)}.privacy{text-align:center;color:var(--muted);font-size:11px;line-height:1.5;margin-top:16px}.form-card{padding:clamp(24px,4vw,38px)}.book-grid{display:grid;grid-template-columns:.85fr 1.15fr;gap:12px;align-items:start}.venue{margin:0;border:1px solid var(--line);border-radius:22px;overflow:hidden;background:var(--soft)}.venue img{width:100%;height:100%;min-height:420px;object-fit:cover;display:block}.menu-item{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px;padding:13px 15px;border-radius:16px;background:var(--soft);margin-bottom:8px}.menu-item strong,.menu-item small{display:block}.menu-item strong{font-weight:750;font-size:15px}.menu-item small{color:var(--muted);margin-top:3px;font-size:12px}.menu-item .step{display:flex;align-items:center;gap:10px}.menu-item .step b{min-width:18px;text-align:center;font-weight:800}.menu-item .step button{width:34px;height:34px;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--text);font-size:18px;cursor:pointer;transition:.2s}.menu-item .step button:hover{background:var(--soft)}.tagline{max-width:640px;color:var(--body);font-size:17px;line-height:1.6;margin:0 0 26px}.live-pill{display:inline-flex;gap:8px;align-items:center;padding:8px 13px;border:1px solid var(--green);border-radius:99px;color:var(--green);font-size:10px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.journey{display:grid;gap:9px;max-width:430px;margin:32px auto 8px;text-align:left}.journey-step{display:flex;gap:12px;align-items:center;padding:13px 15px;border:1px solid var(--line);border-radius:14px;background:var(--soft);color:var(--muted);font-weight:750;font-size:14px}.journey-step i{width:26px;height:26px;display:grid;place-items:center;border:1px solid currentColor;border-radius:50%;font-style:normal;font-size:11px;font-weight:900;flex-shrink:0}.journey-step.done{color:var(--green)}.journey-step.current{color:var(--text);border-color:var(--text);background:var(--panel)} +.booking-mode{grid-template-columns:repeat(2,1fr);margin-bottom:6px}.form-card{width:100%} +.order-menu{display:grid;gap:8px;margin:22px 0}.order-menu .eyebrow{margin-bottom:4px}.order-menu .menu-item{margin-bottom:0} +.order-status-card{display:block}.order-status-card .live-pill{margin:24px 0 18px}.order-status-card h2{margin:10px 0 14px;font-family:var(--jw-font-display,"Syne Variable",system-ui);font-size:clamp(36px,5vw,58px);font-weight:650;line-height:.95;letter-spacing:-.05em}.status-copy{margin:0 0 8px;color:var(--body);font-size:16px;line-height:1.55}.order-reference{display:block;color:var(--muted);font-size:13px}.order-status-card .link-button{margin-top:22px} diff --git a/docker/justworks/business/public/assets/modules.js b/docker/justworks/business/public/assets/modules.js new file mode 100644 index 00000000..290e3cb2 --- /dev/null +++ b/docker/justworks/business/public/assets/modules.js @@ -0,0 +1,28 @@ +import {frame as shellFrame} from './ds/frame.js'; +const ids=['business','pos','order','pay','payandgo','screen','gallery','events']; +const query=new URLSearchParams(location.search); +const npub=query.get('npub')?.trim()||''; +const host=location.hostname.split('.')[0]; +const surface=ids.includes(host)?host:(query.get('surface')||'pos'); +const prod=location.hostname.endsWith('justworks.cash'); +const link=id=>prod?`https://${id}.justworks.cash/`:`/portal?surface=${id}`; +const imgs=['https://justworks.cash/assets/examples/tiles/localpub-venue-1.webp','https://justworks.cash/assets/examples/tiles/localpub-venue-3.webp','https://justworks.cash/assets/examples/tiles/localpub-venue-5.webp']; +const app=document.querySelector('#app');let state; +const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const frame=(html,label=surface,nav=true)=>shellFrame(app,{html,label,surface,nav}); +function off(id){const m=state.modules[id];return m&&!m.enabled?`

Module disabled

${id} is currently off.

The merchant can activate it in Business.

Open Business
`:''} +function sample(){return `
08
Table 82× Coral · 1× Nikita
⌁
Anna · 4 guestsArriving in 10 minutes
`} +function orderCard(o){return `
${o.table_number}
Table ${o.table_number}${o.items.map(x=>`${x.qty}× ${esc(x.name)}`).join(' · ')}
`} +function pos(){const orders=state.orders||[];frame(`

LocalPub Madeira · Staff tablet

Service, without the shouting.

● Live
Open orders${orders.length}Order to table
Bookings42 arriving soon
Paid today286ksats settled

Live queue

Orders & bookings

${orders.length?orders.map(orderCard).join(''):sample()}
`,'POS');document.querySelectorAll('[data-order]').forEach(b=>b.onclick=async()=>{await fetch(`/api/orders/${b.dataset.order}/status`,{method:'POST',headers:{'content-type':'application/json'},body:'{"status":"accepted"}'});boot()})} +function euro(cents){return `€${(cents/100).toFixed(2)}`} +function orderTotal(order){return (order?.items||[]).reduce((sum,item)=>sum+Number(item.price_cents||0)*Number(item.qty||0),0)} +function invoiceView(payment){return `

Invoice ready

${payment.amount_sats.toLocaleString()} sats

${payment.qr_svg}
Open Lightning wallet ↗
Waiting for payment${payment.verifiable?'This page checks Lightning automatically.':'Keep this page open while the merchant confirms.'}
`} +function paidView(payment){const proof=payment.receipt_id?`Verified Lightning proof${esc(payment.receipt_id.slice(0,12))}…${esc(payment.receipt_id.slice(-8))}↗`:'

Payment confirmed by the provider

';return ``} +async function createQuickInvoice(amountCents,comment){const response=await fetch('/api/quick-pay',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,amount_cents:amountCents,comment})});const value=await response.json();if(!response.ok)throw new Error(value.error||'Invoice unavailable');return value} +async function paymentStatus(id){const response=await fetch(`/api/quick-pay-status?npub=${encodeURIComponent(npub)}&id=${encodeURIComponent(id)}`,{cache:'no-store'});const value=await response.json();if(!response.ok)throw new Error(value.error||'Payment status unavailable');return value} +function pay(go=false){const id=go?'payandgo':'pay';if(off(id)){frame(off(id),go?'Just Pay':'Pay Bill');return}const merchant=state.merchant||{},profile=state.profile||{},orders=state.orders||[];let pollTimer;const initialTable=query.get('table')||'';frame(`

${go?'Pay & go':'Pay your table'}

${go?'Receipt in. Wallet out.':'Find it. Scan it. Paid.'}

${go?'Enter the exact receipt total and pay. No account.':'Enter your table number. We find the open order and create one Lightning invoice.'}

${esc((merchant.name||'JW').split(/\s+/).slice(0,2).map(x=>x[0]).join('').toUpperCase())}
${esc(merchant.name||'Merchant')}${esc(profile.lud16||state.lightning_address||'Lightning')}
${go?'€':'#'}${go?'Add the amount printed on the receipt.':'Enter your table to find its open order.'}
${go?'':``}

QR code + one-tap wallet link

`,go?'Just Pay':'Pay Bill');const input=document.querySelector('#payAmount'),button=document.querySelector('#payButton'),status=document.querySelector('#payStatus'),receipt=document.querySelector('#receipt'),result=document.querySelector('#invoiceResult');let selectedOrder=null;const findOrder=()=>{if(go)return;const table=Number.parseInt(input.value,10);selectedOrder=orders.find(order=>Number(order.table_number)===table&&order.status!=='closed')||null;const cents=orderTotal(selectedOrder);receipt.classList.toggle('hidden',!selectedOrder);receipt.innerHTML=selectedOrder?`${selectedOrder.items.map(item=>`${item.qty}× ${esc(item.name)}`).join(' · ')}${euro(cents)}`:'';document.querySelector('#amountHint').textContent=selectedOrder?(cents?`Open order · ${euro(cents)}`:'This order has no priced items.'):'No open order found for this table.'};if(!go){input.addEventListener('input',findOrder);if(initialTable)findOrder()}button.onclick=async()=>{let cents;if(go){const raw=Number.parseFloat(input.value.replace(',','.'));if(!Number.isFinite(raw)||raw<=0){status.textContent='Enter the receipt total.';status.classList.add('error');input.focus();return}cents=Math.round(raw*100)}else{findOrder();cents=orderTotal(selectedOrder);if(!selectedOrder||!cents){status.textContent=selectedOrder?'This order has no payable amount.':'Enter a table with an open order.';status.classList.add('error');input.focus();return}}button.disabled=true;button.innerHTML='Creating invoice…';status.textContent='Connecting to the merchant’s Lightning address…';status.classList.remove('error');try{const payment=await createQuickInvoice(cents,go?'Pay & Go':`Table ${selectedOrder.table_number}`);result.innerHTML=invoiceView(payment);document.querySelector('.copy-invoice').onclick=async event=>{await navigator.clipboard.writeText(payment.bolt11);event.currentTarget.textContent='Copied ✓'};button.classList.add('hidden');status.textContent=`${euro(payment.amount_cents)} · ready to pay`;const check=async()=>{try{const current=await paymentStatus(payment.id);if(current.status!=='paid')return;clearInterval(pollTimer);status.classList.add('hidden');result.innerHTML=paidView(current);document.querySelector('.paid-view').scrollIntoView({behavior:'smooth',block:'center'})}catch(error){status.textContent='Still checking Lightning…'}};document.querySelector('.check-payment').onclick=check;if(payment.verifiable)pollTimer=setInterval(check,2500);document.querySelector('.invoice-view').scrollIntoView({behavior:'smooth',block:'nearest'})}catch(error){status.textContent=error.message;status.classList.add('error');button.disabled=false;button.innerHTML='Try again →'}}} +function screen(){if(off('screen')){frame(off('screen'),'Screen');return}frame(`
LocalPub Madeira

Tonight in Madeira

Fresh poncha.
Cold Coral.
Good people.

21,000 sats tippedTable 8 · order confirmedNext · Sunset Sessions
`,'Screen',false)} +const card=(name,type,img,rating)=>`
Open now

${type}

${name}

★ ${rating} · Bitcoin accepted
OrderBookPay
`; +function gallery(){if(off('gallery')){frame(off('gallery'),'Gallery');return}frame(``,'Gallery');document.querySelectorAll('.filters button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.filters button').forEach(x=>x.classList.remove('active'));b.classList.add('active')})} +function events(){frame(`
⌁

Nostr-native discovery

Events are coming soon.

Signed events from Just Works merchants—shown like the gallery, owned by the people publishing them.

Merchant eventsNostr signedLocal discovery
Explore merchants instead →
`,'Events')} +async function boot(){if(surface==='order')return location.replace('/nsite'+location.search);if(surface==='business')return location.replace(link('business'));if(['pay','payandgo'].includes(surface)){if(!npub)throw new Error('Merchant missing');const [business,posState]=await Promise.all([fetch(`/api/business?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const v=await r.json();if(!r.ok)throw new Error(v.error);return v}),fetch(`/api/pay-orders?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const v=await r.json();if(!r.ok)throw new Error(v.error);return v})]);state={...business,...posState};return pay(surface==='payandgo')}state=await fetch('/api/dashboard',{cache:'no-store'}).then(r=>r.json());({pos,screen,gallery,events}[surface]||pos)()}boot().catch(error=>frame(`

Payment temporarily unavailable.

${esc(error.message)}

`)); diff --git a/docker/justworks/business/public/assets/nsite-live.js b/docker/justworks/business/public/assets/nsite-live.js new file mode 100644 index 00000000..e0c40d01 --- /dev/null +++ b/docker/justworks/business/public/assets/nsite-live.js @@ -0,0 +1,34 @@ +const prefix = location.pathname.split('/').slice(0, -1).join('/'); +const mount = document.querySelector('#mount'); +let syncing = false; +addEventListener('message',event=>{const frame=mount.querySelector('iframe');if(!frame||event.source!==frame.contentWindow||event.data?.type!=='napplet.resize')return;const height=Math.max(320,Math.min(4000,Number(event.data.height)||0));frame.style.height=`${height}px`}); + +async function tableToken() { + const existing = new URLSearchParams(location.search).get('table'); + if (existing) return existing; + const dashboard = await fetch(`${prefix}/api/dashboard`, { cache: 'no-store' }).then(response => response.json()); + const table = dashboard.tables.find(item => item.table_number === 21) || dashboard.tables[0]; + history.replaceState(null, '', `${location.pathname}?table=${encodeURIComponent(table.token)}`); + return table.token; +} + +async function syncActivation() { + if (syncing) return; + syncing = true; + try { + const config = await fetch(`${prefix}/api/config`, { cache: 'no-store' }).then(response => response.json()); + const frame = mount.querySelector('iframe'); + if (!config.module_enabled && frame) { + mount.className = 'napplet off'; + mount.textContent = 'Order to Table is currently disabled by the merchant.'; + } else if (config.module_enabled && !frame) { + const token = await tableToken(); + mount.className = 'napplet'; + mount.innerHTML = ``; + } + } finally { + syncing = false; + } +} + +window.setInterval(() => syncActivation().catch(() => {}), 700); diff --git a/docker/justworks/business/public/assets/order-simple.css b/docker/justworks/business/public/assets/order-simple.css new file mode 100644 index 00000000..79e5f6cf --- /dev/null +++ b/docker/justworks/business/public/assets/order-simple.css @@ -0,0 +1,4 @@ +html{background:#0a0a0a}body{min-height:100vh;min-height:100dvh;background:#0a0a0a}.menu.intro{display:none}.merchant-hero{min-height:156px;padding:18px 20px 22px;background-position:center 44%;overflow:hidden}.merchant-hero .wash{background:linear-gradient(90deg,rgba(0,0,0,.9),rgba(0,0,0,.35)),linear-gradient(0deg,rgba(0,0,0,.72),transparent 70%)}.merchant-hero nav{min-height:30px}.merchant-hero nav a{font-size:14px}.merchant-hero nav small{font-size:8px}.hero-copy{margin-top:auto}.hero-copy p,.hero-copy>span{display:none}.hero-copy h1{max-width:92%;margin:18px 0 0;font-size:clamp(32px,10vw,46px);line-height:.9;letter-spacing:-.055em}.order-shell{width:100%;display:block;margin:0;padding:0;background:var(--panel)}.menu:not(.intro){padding:28px 20px 0}.menu:not(.intro) h2{font-size:34px}.order-form{position:static;width:100%;padding:28px 20px max(24px,env(safe-area-inset-bottom));border:0;border-radius:0;background:var(--panel);box-shadow:none}.order-form h2{max-width:390px;margin:10px 0 25px;font-size:clamp(38px,11vw,48px);line-height:.92}.order-form label{margin-top:17px}.order-form input,.order-form textarea{padding:15px;border-radius:11px;font-size:16px}.order-form textarea{min-height:116px}.order-form #submit{min-height:58px;align-items:center;margin-top:22px;border-radius:11px;font-size:15px}.privacy{margin:13px 0 0}.confirmed{background:var(--bg)} +@media(min-width:760px){body{padding:24px;background:#0a0a0a}#app:not(:has(>.loading)):not(:has(>.error)):not(:has(>.confirmed)){width:min(1180px,100%);min-height:min(760px,calc(100dvh - 48px));margin:auto;display:grid;grid-template-columns:minmax(320px,43%) minmax(420px,57%);overflow:hidden;border:1px solid rgba(255,255,255,.12);border-radius:28px;background:#fff;box-shadow:0 35px 100px rgba(0,0,0,.38)}.merchant-hero{min-height:100%;padding:28px 32px 38px;background-position:center}.merchant-hero .wash{background:linear-gradient(0deg,rgba(0,0,0,.85),rgba(0,0,0,.08) 68%),linear-gradient(90deg,rgba(0,0,0,.4),transparent)}.merchant-hero nav a{font-size:17px}.merchant-hero nav small{font-size:9px}.hero-copy p,.hero-copy>span{display:block}.hero-copy p{color:#ff6b35}.hero-copy h1{max-width:100%;margin:18px 0 20px;font-size:clamp(50px,5.4vw,82px);line-height:.84}.hero-copy>span{max-width:330px;color:rgba(255,255,255,.76);font-size:16px}.order-shell{min-height:100%;display:flex;flex-direction:column;justify-content:center;overflow:auto}.menu:not(.intro){padding:42px 50px 0}.products{max-height:32vh;overflow:auto}.order-form{max-width:640px;margin:auto;padding:48px clamp(42px,5vw,76px)}.order-form h2{max-width:520px;font-size:clamp(48px,4.5vw,68px)}.order-form input,.order-form textarea{padding:16px}.order-form textarea{min-height:126px}.order-form #submit{min-height:62px}.menu:not(.intro)+.order-form{margin-top:0;padding-top:30px}} +@media(min-width:1400px){body{display:grid;place-items:center}#app:not(:has(>.loading)):not(:has(>.error)):not(:has(>.confirmed)){min-height:min(800px,calc(100dvh - 64px))}} +@media(max-width:420px){.merchant-hero{min-height:138px;padding-top:max(16px,env(safe-area-inset-top))}.order-form h2{font-size:38px}.order-form{padding-top:24px}.order-form textarea{min-height:104px}} diff --git a/docker/justworks/business/public/assets/order-status.css b/docker/justworks/business/public/assets/order-status.css new file mode 100644 index 00000000..2f51ee49 --- /dev/null +++ b/docker/justworks/business/public/assets/order-status.css @@ -0,0 +1,24 @@ +.status-view{min-height:100dvh;display:grid;grid-template-columns:minmax(330px,42%) 1fr;background:var(--bg)} +.status-merchant{position:relative;min-height:100%;display:flex;flex-direction:column;justify-content:space-between;padding:30px clamp(25px,4vw,58px) 55px;color:#fff;background:#111 var(--hero,linear-gradient(135deg,#252525,#080808)) center/cover} +.status-merchant>.wash{background:linear-gradient(0deg,rgba(0,0,0,.92),rgba(0,0,0,.08) 70%)} +.status-merchant>a,.status-merchant>div:last-child{position:relative;z-index:1} +.status-merchant>a{color:#fff;text-decoration:none;font-weight:900} +.status-merchant>div:last-child p{color:var(--accent);font-size:10px;font-weight:900;letter-spacing:.16em;text-transform:uppercase} +.status-merchant h2{margin:12px 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(52px,7vw,100px);line-height:.82;letter-spacing:-.065em} +.status-merchant>div:last-child>span{color:#ccc} +.status-view .confirmed{min-height:100%;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;text-align:left;padding:clamp(35px,8vw,120px)} +.status-view .confirmed h1{max-width:760px;margin:18px 0 24px;font-size:clamp(58px,7vw,110px);line-height:.84} +.status-view .confirmed>p:not(.eyebrow){margin:0;color:var(--muted);font-size:17px;line-height:1.5} +.status-view .confirmed small{font-size:11px} +.live-pill{display:flex;align-items:center;gap:8px;margin-bottom:38px;padding:9px 12px;border:1px solid var(--line);border-radius:99px;font-size:10px;font-weight:900;letter-spacing:.09em;text-transform:uppercase} +.live-pill i{width:8px;height:8px;border:0;border-radius:50%;background:var(--green);box-shadow:0 0 0 5px rgba(8,128,107,.12);animation:pulse 1.8s infinite} +.status-view .journey{width:100%;max-width:none;display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:48px 0 42px;text-align:left} +.journey-step{position:relative;padding-top:32px;border-top:2px solid #d7d8d4;color:#9a9d99;font-size:11px;font-weight:850;text-transform:uppercase;letter-spacing:.06em} +.status-view .journey-step i{position:absolute;top:-10px;left:0;width:19px;height:19px;display:grid;place-items:center;border:2px solid #c5c8c4;border-radius:50%;background:var(--bg);font-size:10px;font-style:normal} +.journey-step.done{border-color:var(--green);color:var(--text)} +.status-view .journey-step.done i{border-color:var(--green);background:var(--green);color:#fff} +.status-view .journey-step.current i{box-shadow:0 0 0 7px rgba(8,128,107,.12)} +.again{width:min(320px,100%);display:flex;justify-content:space-between;padding:17px 19px;background:var(--text);color:#fff!important;text-decoration:none;font-weight:900} +.again span{color:var(--accent)} +@keyframes pulse{50%{opacity:.45}} +@media(max-width:760px){.status-view{display:block;background:#0a0a0a}.status-merchant{min-height:210px;padding:22px 20px;background-position:center 45%}.status-merchant h2{font-size:42px}.status-view .confirmed{min-height:calc(100dvh - 210px);padding:38px 20px max(28px,env(safe-area-inset-bottom));background:var(--bg)}.status-view .confirmed h1{margin-left:0;font-size:14vw}.live-pill{margin-bottom:30px}.status-view .journey{grid-template-columns:repeat(3,1fr);margin:38px 0 32px}.journey-step{padding-top:27px;font-size:9px}.again{width:100%}} diff --git a/docker/justworks/business/public/assets/order.css b/docker/justworks/business/public/assets/order.css new file mode 100644 index 00000000..ae8bf321 --- /dev/null +++ b/docker/justworks/business/public/assets/order.css @@ -0,0 +1 @@ +:root{--bg:#f7f7f4;--panel:#fff;--soft:#eef0ed;--line:rgba(10,10,10,.11);--text:#0a0a0a;--body:#454545;--muted:#737773;--accent:#f4511e;--green:#08806b;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,textarea{font:inherit}.merchant-hero{position:relative;min-height:62svh;display:flex;flex-direction:column;padding:26px clamp(18px,5vw,70px) clamp(45px,7vw,90px);color:#fff;background:#151817 var(--hero,linear-gradient(135deg,#26332e,#080909)) center/cover}.wash{position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.9),rgba(0,0,0,.35) 62%,rgba(0,0,0,.1)),linear-gradient(0deg,rgba(0,0,0,.7),transparent 55%)}nav,.hero-copy{position:relative;z-index:1}nav{display:flex;justify-content:space-between;align-items:center}nav a{color:#fff;text-decoration:none;font-weight:900;font-size:18px}nav a:first-letter{display:inline-block;border:1px solid #fff}nav small{text-transform:uppercase;letter-spacing:.14em;font-size:9px;font-weight:850}.hero-copy{margin-top:auto;max-width:1000px}.hero-copy p,.eyebrow{margin:0;color:var(--accent);font-size:10px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.hero-copy h1,.order-shell h2,.confirmed h1{font-family:var(--jw-font-display,"Syne Variable",system-ui);font-weight:630;letter-spacing:-.06em}.hero-copy h1{font-size:clamp(65px,10vw,150px);line-height:.78;margin:20px 0 28px}.hero-copy>span{color:rgba(255,255,255,.72);font-size:18px}.order-shell{width:min(1200px,calc(100% - 32px));margin:auto;padding:clamp(55px,8vw,110px) 0;display:grid;grid-template-columns:1.1fr .8fr;gap:clamp(35px,8vw,100px);align-items:start}.order-shell h2{font-size:clamp(38px,5vw,66px);line-height:.95;margin:12px 0 24px}.intro>p:last-child{max-width:600px;color:var(--body);font-size:18px;line-height:1.65}.products{display:grid;gap:9px}.product{display:flex;justify-content:space-between;gap:20px;padding:18px;border:1px solid var(--line);border-radius:17px;background:var(--panel)}.product h3{margin:0 0 5px}.product p{margin:0;color:var(--muted);font-size:12px}.product strong{display:block;margin-top:8px;color:var(--accent)}.stepper{display:flex;align-items:center;gap:12px}.stepper button{width:34px;height:34px;border:1px solid var(--line);border-radius:50%;background:var(--soft);font-weight:900}.stepper span{min-width:18px;text-align:center;font-weight:900}.order-form{padding:clamp(22px,4vw,38px);border:1px solid var(--line);border-radius:26px;background:var(--panel);box-shadow:0 24px 70px rgba(0,0,0,.08);position:sticky;top:20px}.order-form h2{font-size:42px}.order-form label{display:block;margin-top:20px;color:var(--muted);font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:.1em}.order-form label small{font-weight:600}.order-form input,.order-form textarea{display:block;width:100%;margin-top:7px;padding:13px;border:1px solid var(--line);border-radius:13px;background:var(--soft);color:var(--text);outline:none;text-transform:none;letter-spacing:0;font-size:15px;resize:vertical}.order-form input:focus,.order-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(244,81,30,.14)}#submit{width:100%;display:flex;justify-content:space-between;margin-top:25px;padding:16px 18px;border:0;border-radius:14px;background:var(--accent);color:#fff;font-weight:900}#submit:disabled{opacity:.65}.privacy{text-align:center;color:var(--muted);font-size:9px}.loading,.error,.confirmed{min-height:100vh;display:grid;place-content:center;text-align:center;padding:30px}.loading>span,.error>span{font-size:70px}.error h1,.confirmed h1{max-width:900px;font-size:clamp(48px,7vw,100px);line-height:.88;margin:20px}.error small,.loading p,.confirmed>p{color:var(--muted)}.check{width:90px;height:90px;display:grid;place-items:center;margin:0 auto 25px;border-radius:50%;background:var(--green);color:#fff;font-size:45px}.journey{display:grid;grid-template-columns:18px auto 1fr 18px auto 1fr 18px auto;align-items:center;gap:8px;margin:45px auto;max-width:800px}.journey i{width:16px;height:16px;border:2px solid #bbc0bd;border-radius:50%}.journey i.done{border-color:var(--green);background:var(--green)}.journey b{height:2px;background:#d4d7d5}.confirmed a{color:var(--text);font-weight:850}@media(max-width:760px){.merchant-hero{min-height:52svh}.hero-copy h1{font-size:17vw}.order-shell{grid-template-columns:1fr}.order-form{position:static}.journey{grid-template-columns:14px 1fr;max-width:260px;text-align:left}.journey b{display:none}.confirmed h1{font-size:14vw}} diff --git a/docker/justworks/business/public/assets/order.js b/docker/justworks/business/public/assets/order.js new file mode 100644 index 00000000..e23921be --- /dev/null +++ b/docker/justworks/business/public/assets/order.js @@ -0,0 +1,14 @@ +import {frame} from './ds/frame.js'; +import {npubFrom} from './ds/env.js'; +const app=document.querySelector('#app');const params=new URLSearchParams(location.search);const npub=npubFrom();const prefilledTable=params.get('table')||'';let merchant,quantities={},statusTimer; +const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +function error(message){app.innerHTML=`
⌁

Just Works · Order

${esc(message)}

Use order.justworks.cash/?npub=npub1…
`} +function productCard(item){return ``} +function render(data){merchant=data;document.title=`Order ${data.merchant.name} · Just Works`;frame(app,{label:'Order',surface:'order',html:`

Order to table

What can we bring you?

Order from ${esc(data.merchant.name)} without waiting for the waiter.

↗
${esc(data.merchant.name)}Taking orders
${data.products.length?`

Merchant menu

${data.products.map(productCard).join('')}
`:''}

No account · no app · sent directly to this merchant

`});bind(data.products)} +function bind(products){for(const item of products){quantities[item.id]=0;document.querySelector(`[data-plus="${CSS.escape(item.id)}"]`).onclick=()=>setQty(item.id,quantities[item.id]+1);document.querySelector(`[data-minus="${CSS.escape(item.id)}"]`).onclick=()=>setQty(item.id,quantities[item.id]-1)}document.querySelector('#orderForm').onsubmit=submit} +function setQty(id,value){quantities[id]=Math.max(0,Math.min(20,value));document.querySelector(`[data-qty="${CSS.escape(id)}"]`).textContent=quantities[id]} +const statusCopy={'new':{eyebrow:'Sent to the staff',title:'Your order is on its way.',body:'Waiting for the team to confirm it.',step:0},accepted:{eyebrow:'Confirmed by the staff',title:'They are preparing it.',body:'Your order has been accepted.',step:1},delivered:{eyebrow:'Coming to your table',title:'It is ready for you.',body:'The team is bringing it now.',step:2},closed:{eyebrow:'Order complete',title:'Just Works.',body:'Enjoy—and order again whenever you like.',step:3}}; +function renderStatus(order){const state=statusCopy[order.status]||statusCopy['new'];const card=document.querySelector('#orderForm,.order-status-card');if(!card)return;card.outerHTML=`
✓
${esc(merchant.merchant.name)}Table ${order.table||order.table_number}
Live order status

${esc(state.eyebrow)}

${esc(state.title)}

${esc(state.body)}

Table ${order.table||order.table_number} · ${esc(order.id.toUpperCase())}
${['Sent','Staff confirmed','Coming to table'].map((label,index)=>`
${index${label}
`).join('')}
Add another order +
`} +async function pollStatus(order){try{const response=await fetch(`/api/merchant-order-status?npub=${encodeURIComponent(npub)}&id=${encodeURIComponent(order.id)}`,{cache:'no-store'});if(response.ok){const latest=await response.json();if(latest.status!==order.status){order.status=latest.status;order.table=latest.table_number;renderStatus(order)}}}catch{}if(order.status!=='closed')statusTimer=setTimeout(()=>pollStatus(order),1500)} +async function submit(event){event.preventDefault();const button=document.querySelector('#submit');button.disabled=true;button.innerHTML='Sending to staff…';const items=merchant.products.filter(item=>quantities[item.id]>0).map(item=>({id:item.id,name:item.name,qty:quantities[item.id]}));const response=await fetch('/api/merchant-orders',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,table:Number(document.querySelector('#table').value),request:document.querySelector('#request').value,guest:document.querySelector('#guest').value,items})});const data=await response.json();if(!response.ok){button.disabled=false;button.innerHTML=`${esc(data.error||'Try again')} ↻`;return}renderStatus(data);pollStatus(data)} +if(!npub)error('Add the merchant npub to order.');else fetch(`/api/order-merchant?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const data=await r.json();if(!r.ok)throw new Error(data.error||'Merchant unavailable');return data}).then(render).catch(e=>error(e.message)); diff --git a/docker/justworks/business/public/assets/page.css b/docker/justworks/business/public/assets/page.css new file mode 100644 index 00000000..beb4b3ea --- /dev/null +++ b/docker/justworks/business/public/assets/page.css @@ -0,0 +1,83 @@ +/* + * Just Works page template — local twin of the platform-hosted merchant page + * (justworks.cash/{slug}): the stable address printed on QR codes. Carries the + * module CTAs (Order, Pay Bill, Just Pay, Bookings) and links out to the + * sovereign nsite copy. Same tokens as the dashboard. + */ + +#app{min-height:100vh;display:flex;flex-direction:column;background:var(--jw-bg)} + +/* Platform chrome */ +.page-nav{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px clamp(20px,5vw,56px);border-bottom:1px solid var(--jw-line);background:var(--jw-surface)} +.page-nav .brand{display:flex;align-items:center;gap:10px;color:var(--jw-text-primary);text-decoration:none;font-weight:900} +.page-nav .brand small{display:block;font-size:10px;font-weight:850;letter-spacing:.14em;text-transform:uppercase;color:var(--jw-text-muted)} +.page-nav .chip{border:1px solid var(--jw-line-strong);border-radius:999px;padding:7px 12px;font-size:10px;font-weight:900;letter-spacing:.12em;text-transform:uppercase;color:var(--jw-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:44vw} + +/* Hero */ +.page-hero{position:relative;display:flex;align-items:flex-end;min-height:74svh;padding:clamp(48px,8vw,104px) clamp(20px,6vw,72px);background:var(--jw-surface-sunken);overflow:hidden} +.page-hero[data-hero] .backdrop{position:absolute;inset:0;background:var(--hero) center/cover} +.page-hero .shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(10,10,10,.82),rgba(10,10,10,.3) 46%,rgba(10,10,10,.14))} +.page-hero .copy{position:relative;z-index:1;max-width:860px;color:#fafafa} +.page-hero .tag{display:inline-flex;border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:8px 12px;font-size:10px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.85)} +.page-hero h1{font-family:var(--jw-font-display);font-size:clamp(48px,9vw,120px);font-weight:750;line-height:.84;letter-spacing:-.06em;margin:24px 0} +.page-hero .lead{max-width:600px;font-size:clamp(16px,1.8vw,20px);line-height:1.55;color:rgba(250,250,250,.8);margin:0} +.page-hero .facts{display:flex;flex-wrap:wrap;gap:8px;margin-top:24px} +.page-hero .facts span{border:1px solid rgba(255,255,255,.32);border-radius:999px;padding:7px 12px;font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase;color:rgba(255,255,255,.85)} + +/* Sections */ +main{flex:1} +.eyebrow{color:var(--jw-accent);font-size:10px;font-weight:900;letter-spacing:.17em;text-transform:uppercase;margin:0} +.page-section{border-top:1px solid var(--jw-line);padding:clamp(52px,8vw,104px) clamp(20px,6vw,72px)} +.page-section>div{max-width:1080px;margin:0 auto} +.page-section h2{font-family:var(--jw-font-display);font-size:clamp(34px,5.4vw,64px);font-weight:700;line-height:.94;letter-spacing:-.04em;margin:12px 0 30px;color:var(--jw-text-primary)} + +/* Module cards with QR */ +.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px} +.module-card{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:center;border:1px solid var(--jw-line);border-radius:18px;padding:22px;background:var(--jw-surface)} +.module-card .glyph{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--jw-black,#0a0a0a);color:#fff;font-weight:900;font-size:17px} +.module-card h3{display:flex;align-items:center;gap:10px;font-size:17px;font-weight:850;margin:0;color:var(--jw-text-primary)} +.module-card p{margin:8px 0 0;font-size:13px;line-height:1.5;color:var(--jw-text-muted)} +.module-card .status{font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase;color:var(--jw-text-muted)} +.module-card .status.live{color:var(--jw-accent)} +.module-card .qr{width:96px;border:6px solid #fafafa;border-radius:10px;background:#fafafa} +.module-card .qr img{display:block;width:100%} +.module-card .cta{grid-column:1/-1;display:inline-flex;justify-content:space-between;align-items:center;border:1px solid var(--jw-line-strong);border-radius:999px;padding:11px 16px;color:var(--jw-text-primary);text-decoration:none;font-size:13px;font-weight:850} +.module-card .cta:hover{border-color:var(--jw-text-primary)} + +/* Gallery + facts */ +.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px} +.photo-grid figure{margin:0;border:1px solid var(--jw-line);border-radius:14px;overflow:hidden;background:var(--jw-surface-sunken);aspect-ratio:4/3} +.photo-grid img{display:block;width:100%;height:100%;object-fit:cover} +.facts-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px} +.facts-list div{border:1px solid var(--jw-line);border-radius:14px;padding:16px 18px;background:var(--jw-surface)} +.facts-list dt{font-size:10px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:var(--jw-text-muted)} +.facts-list dd{margin:6px 0 0;font-size:14px;line-height:1.5;color:var(--jw-text-primary)} +.facts-list a{color:var(--jw-text-primary)} + +/* Sovereign copy strip */ +.page-section.dark{border-top:0;background:var(--jw-black,#0a0a0a);color:var(--jw-white,#fafafa);--jw-line:rgba(255,255,255,.12);--jw-text-muted:rgba(250,250,250,.6);--jw-text-primary:var(--jw-white,#fafafa)} +.sovereign{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;max-width:1080px;margin:0 auto} +.sovereign h2{margin:12px 0 8px;color:var(--jw-white,#fafafa)} +.sovereign p{margin:0;max-width:560px;font-size:14px;line-height:1.6;color:rgba(250,250,250,.7)} +.sovereign a.go{display:inline-flex;align-items:center;gap:8px;border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:12px 18px;color:#fafafa;text-decoration:none;font-size:13px;font-weight:850;white-space:nowrap} +.sovereign a.go:hover{border-color:rgba(255,255,255,.6)} + +/* Footer */ +.page-footer{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:24px clamp(20px,6vw,72px);border-top:1px solid var(--jw-line);color:var(--jw-text-muted);font-size:12px} +.page-footer code{font-family:var(--jw-font-mono);font-size:11px} +.page-footer .tagline{display:flex;align-items:center;gap:8px} + +/* Failure */ +.page-fail{min-height:100svh;display:grid;place-content:center;text-align:center;gap:10px;background:var(--jw-bg);color:var(--jw-text-primary)} +.page-fail span{display:inline-grid;place-items:center;width:52px;height:52px;margin:0 auto 6px;border:1px solid var(--jw-line-strong);border-radius:50%;font-size:22px} +.page-fail h1{font-family:var(--jw-font-display);font-size:clamp(30px,5vw,52px);letter-spacing:-.03em;margin:0} +.page-fail small{color:var(--jw-text-muted)} + +@media(max-width:720px){ + .page-nav{padding:14px 16px} + .page-hero{min-height:60svh;padding:36px 16px} + .page-section{padding:48px 16px} + .module-card{grid-template-columns:1fr} + .module-card .qr{width:110px;justify-self:center} + .sovereign{justify-content:center;text-align:center} +} diff --git a/docker/justworks/business/public/assets/page.js b/docker/justworks/business/public/assets/page.js new file mode 100644 index 00000000..01488608 --- /dev/null +++ b/docker/justworks/business/public/assets/page.js @@ -0,0 +1,86 @@ +// Just Works page template — renders the platform-hosted merchant page +// locally from /api/business. This is the design surface for the stable +// justworks.cash/{slug} page: module CTAs, the QR that never moves, and the +// pointer to the sovereign nsite copy rendered at /site. +import {mountLogo} from './ds/logo.js'; +import {link} from './ds/frame.js'; + +const app=document.querySelector('#app'); +const base=location.pathname.split('/').slice(0,-1).join('/'); +const npub=new URLSearchParams(location.search).get('npub')?.trim()||''; +const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +const qr=text=>`${base}/api/qr?text=${encodeURIComponent(text)}`; + +const moduleMeta={ + order:{name:'Order to table',copy:'Scan, order, and pay from the table. No app for customers, no terminal for staff.',glyph:'+',target:'order'}, + pay:{name:'Pay Bill',copy:'Close the table from the customer side. A confirmed Lightning payment settles instantly.',glyph:'€',target:'pay'}, + payandgo:{name:'Just Pay',copy:'Pay a paper receipt in sats. Type the total, get an invoice, done.',glyph:'₿',target:'payandgo'}, + booking:{name:'Bookings',copy:'Book a table now or later, straight from the page.',glyph:'⌁',target:'book'}, +}; + +function fail(message){ + app.innerHTML=`
⌁

${esc(message)}

Use /page?npub=npub1…
`; +} + +function fact(term,value,href){ + return `
${esc(term)}
${href?`${esc(value)} ↗`:esc(value)}
`; +} + +function moduleCard(id,state){ + const meta=moduleMeta[id]; + if(!meta||!state.enabled||state.status==='coming-soon')return ''; + const url=link(meta.target); + return `
+
+

${meta.glyph}${esc(meta.name)} ${state.status==='live'?'Live service':'Preview'}

+

${esc(meta.copy)}

+
+ ${id==='order'?``:''} + Open ${esc(meta.name)} → +
`; +} + +function render(data){ + const merchant=data.merchant,about=(data.profile?.about||'').trim(),hero=data.images[0]||''; + const facts=[merchant.hours,merchant.address,merchant.phone].filter(Boolean); + document.title=`${merchant.name} — Just Works`; + const cards=Object.entries(moduleMeta).map(([id,meta])=>moduleCard(id,data.modules[id]||{})).filter(Boolean).join(''); + app.innerHTML=` + +
+ ${hero?'
':''} +
+
+ ${esc([merchant.category,merchant.area].filter(Boolean).join(' · ')||'Merchant')} +

${esc(merchant.name)}

+ ${about?`

${esc(about)}

`:''} + ${facts.length?`
${facts.map(item=>`${esc(item)}`).join('')}
`:''} +
+
+
+ ${cards?`

Just Works modules

Run the place from the page.

${cards}
`:''} + ${data.images.length>1?``:''} + ${(merchant.address||merchant.hours||merchant.phone||merchant.website)?`

Visit

Find us.

${merchant.address?fact('Address',merchant.address):''}${merchant.hours?fact('Hours',merchant.hours):''}${merchant.phone?fact('Phone',merchant.phone,`tel:${esc(merchant.phone)}`):''}${merchant.website?fact('Website',merchant.website.replace(/^https?:\/\//,''),merchant.website):''}${merchant.rating?fact('Rating',`★ ${merchant.rating} / 5`):''}
`:''} +
+
+

Ownership

+

This page has a sovereign copy.

+

The same page is signed by the merchant and published to Nostr relays and Blossom storage. It resolves even if Just Works disappears.

+
+ View the nsite copy ↗ +
+
+
+ Built with Just Works + ${esc(npub)} +
`; + document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true})); +} + +if(!npub)fail('Add the merchant npub.'); +else fetch(`${base}/api/business?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}) + .then(async response=>{const data=await response.json();if(!response.ok)throw new Error(data.error||'Page unavailable');render(data)}) + .catch(error=>fail(error.message)); diff --git a/docker/justworks/business/public/assets/payments.css b/docker/justworks/business/public/assets/payments.css new file mode 100644 index 00000000..aaba7b7f --- /dev/null +++ b/docker/justworks/business/public/assets/payments.css @@ -0,0 +1 @@ +.payments{display:grid;gap:8px;margin-top:18px}.payment{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 16px;border:1px solid rgba(247,147,26,.28);border-radius:14px;background:rgba(247,147,26,.08);color:var(--body);animation:payment-in .45s ease}.payment>div{display:grid;gap:4px}.payment span{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em}.payment strong{color:var(--text)}.payment-ack{flex:0 0 auto;border:1px solid rgba(255,255,255,.18);border-radius:999px;background:rgba(255,255,255,.08);color:var(--text);padding:8px 12px;font:inherit;font-size:12px;font-weight:800;cursor:pointer;transition:transform .18s ease,background .18s ease,opacity .18s ease}.payment-ack:hover{background:rgba(255,255,255,.15);transform:translateY(-1px)}.payment-ack:disabled{cursor:wait;opacity:.55}.payment.leaving{pointer-events:none;animation:payment-out .28s ease forwards}@keyframes payment-in{from{opacity:0;transform:translateY(-8px)}}@keyframes payment-out{to{opacity:0;transform:translateX(18px) scale(.98)}}@media(max-width:560px){.payment{gap:10px}.payment-ack{padding:7px 11px}} diff --git a/docker/justworks/business/public/assets/pos-dark.css b/docker/justworks/business/public/assets/pos-dark.css new file mode 100644 index 00000000..9be5d180 --- /dev/null +++ b/docker/justworks/business/public/assets/pos-dark.css @@ -0,0 +1,2 @@ +:root{color-scheme:dark;--bg:#080909;--panel:#111312;--soft:#191c1a;--line:rgba(255,255,255,.12);--text:#f7f7f4;--muted:#929894;--accent:#ff5630;--green:#16a085;--blue:#4c7dff}html,body{background:var(--bg);color:var(--text)}body{background:radial-gradient(circle at 50% -20%,#202522 0,#080909 46%)}#app{width:100%;max-width:1680px;margin:auto}.topbar{position:relative;z-index:60;width:100%;background:transparent;border:0;backdrop-filter:none}.brand>span{border-color:rgba(255,255,255,.35)}.stats article,.tabs button,.ticket,.payment-ticket{background:var(--panel);border-color:var(--line);box-shadow:none}.stats strong,.ticket header strong,.ticket-body p,.payment-ticket strong,.empty h2{color:var(--text)}.stats span,.tabs button,.ticket header small,.ticket header time,.ticket-body small,.status,.payment-ticket p,.empty{color:var(--muted)}.tabs{position:sticky;top:76px;z-index:9;padding-top:8px;background:linear-gradient(180deg,var(--bg) 78%,transparent)}.tabs button.active{background:var(--text);color:#080909}.tabs span{background:#dfe3df;color:#080909}.ticket>header,.ticket>footer{border-color:var(--line)}.ticket>footer{background:var(--soft)}.table{background:#292d2a}.ticket footer button,.payment-ticket button{box-shadow:0 8px 22px rgba(255,86,48,.18)}.status-new{border-color:rgba(255,86,48,.42)}.status-new .table{background:var(--accent)}.status-accepted{border-color:rgba(76,125,255,.48)}.status-delivered{border-color:rgba(22,160,133,.52)}.payment-ticket header>span{background:#2b271b}.pending{background:var(--soft);color:var(--muted)}.empty{border-color:rgba(255,255,255,.16)}.toast{background:#f7f7f4;color:#080909}.pos-loading,.pos-error{background:radial-gradient(circle at 50% 40%,#222724,#080909 65%)}@media(max-width:700px){#app{min-height:100svh}.topbar{height:64px}.topbar .brand b{font-size:14px}.connection time{display:none}.stats{gap:7px;padding:9px}.stats article{min-height:58px;padding:9px 12px}.stats strong{font-size:26px}.tabs{top:64px;padding:5px 9px 9px}.tabs button{min-height:43px;min-width:102px;padding-inline:12px}.board{padding:0 9px 12px;gap:9px}.ticket,.payment-ticket{min-height:230px;border-radius:17px}.ticket>header{padding:11px}.ticket-body{padding:14px}.ticket-body p{font-size:18px}.ticket>footer{padding:9px}.ticket footer button,.payment-ticket button{min-height:58px;flex:1;font-size:16px}.empty{min-height:300px;border-radius:18px}}@media(min-width:1101px){.stats,.tabs,.board{padding-left:24px;padding-right:24px}.board{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1500px){.board{grid-template-columns:repeat(4,minmax(0,1fr))}} +.topbar[data-topbar]{position:fixed!important;inset:0 0 auto!important} diff --git a/docker/justworks/business/public/assets/pos.css b/docker/justworks/business/public/assets/pos.css new file mode 100644 index 00000000..74c1f2c9 --- /dev/null +++ b/docker/justworks/business/public/assets/pos.css @@ -0,0 +1,3 @@ +:root{--bg:#e9ece9;--panel:#fff;--soft:#f0f2ef;--line:rgba(10,10,10,.12);--text:#0a0a0a;--muted:#6f746f;--accent:#f4511e;--green:#08806b;--blue:#2764ff;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html,body,#app{min-height:100%;margin:0}body{background:var(--bg);color:var(--text);overscroll-behavior:none;touch-action:manipulation}button{font:inherit}.topbar{height:76px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 24px;background:transparent;border-bottom:1px solid transparent;color:#fff;transition:background .3s,border-color .3s,backdrop-filter .3s;pointer-events:none}.topbar.is-scrolled{background:rgba(10,10,10,.86);border-bottom-color:rgba(255,255,255,.09);backdrop-filter:blur(8px)}.topbar>*{pointer-events:auto}.brand{display:flex;align-items:center;gap:10px}.brand [data-logo]{width:34px;height:34px}.brand b{font-size:16px;font-weight:900}.brand small,.merchant small{color:#979d99;font-size:16px;text-transform:none;letter-spacing:0}.brand small{display:inline;font-weight:900}.brand small:before{content:"·";color:#979d99;margin:0 9px}.brand b,.merchant strong,.merchant small{display:block}.merchant{text-align:center}.merchant strong{font-family:var(--jw-font-display,"Syne Variable");font-size:21px}.connection{justify-self:end;display:grid;grid-template-columns:auto auto;align-items:center;gap:6px;text-align:right}.connection i{width:9px;height:9px;border-radius:50%;background:#18b397;box-shadow:0 0 0 5px rgba(24,179,151,.15)}.connection span{font-size:11px;font-weight:850}.connection time{grid-column:1/-1;color:#979d99;font-size:9px}.connection.offline i{background:#e05b43}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:12px 14px 8px}.stats article{display:flex;align-items:center;justify-content:space-between;min-height:66px;padding:10px 15px;border:1px solid var(--line);border-radius:15px;background:var(--panel)}.stats span{color:var(--muted);font-size:10px;font-weight:850;text-transform:uppercase;letter-spacing:.08em}.stats strong{font-size:30px}.tabs{display:flex;gap:8px;padding:4px 14px 12px}.tabs button{min-height:46px;padding:0 18px;border:1px solid var(--line);border-radius:13px;background:var(--panel);color:var(--muted);font-weight:850}.tabs button.active{background:var(--text);color:#fff}.tabs span{display:inline-grid;place-items:center;min-width:22px;height:22px;margin-left:7px;border-radius:99px;background:var(--soft);color:var(--text);font-size:10px}.tabs small{margin-left:4px;color:var(--accent)}.board{display:none;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:12px;padding:0 14px 18px}.board.active{display:grid}.ticket,.payment-ticket{min-height:260px;display:flex;flex-direction:column;border:1px solid var(--line);border-radius:20px;background:var(--panel);overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,.05)}.ticket>header{display:grid;grid-template-columns:52px 1fr auto;align-items:center;gap:10px;padding:14px;border-bottom:1px solid var(--line)}.table{width:52px;height:52px;display:grid;place-items:center;border-radius:14px;background:var(--text);color:#fff;font-size:20px;font-weight:900}.ticket header small,.ticket header strong{display:block}.ticket header small{color:var(--muted);font-size:9px;text-transform:uppercase}.ticket header strong{font-size:24px}.ticket header time{color:var(--muted);font-size:12px;font-weight:800}.ticket-body{flex:1;padding:16px}.ticket-body p{margin:0;font-size:20px;font-weight:800;line-height:1.28}.ticket-body small{display:block;margin-top:12px;color:var(--muted)}.ticket>footer{display:flex;align-items:center;gap:10px;padding:12px 14px;background:var(--soft)}.status{color:var(--muted);font-size:10px;font-weight:900;text-transform:uppercase}.ticket footer button,.payment-ticket button{min-height:56px;margin-left:auto;padding:0 18px;border:0;border-radius:13px;background:var(--accent);color:#fff;font-weight:900}.ticket footer button b{margin-left:12px}.status-accepted{border-color:rgba(39,100,255,.35)}.status-accepted .table{background:var(--blue)}.status-accepted footer button{background:var(--blue)}.status-delivered{border-color:rgba(8,128,107,.4)}.status-delivered .table{background:var(--green)}.status-delivered footer button{background:var(--green)}.payment-ticket{padding:18px}.payment-ticket header{display:flex;gap:12px;align-items:center}.payment-ticket header>span{width:54px;height:54px;display:grid;place-items:center;border-radius:15px;background:#fff2c7;font-size:26px}.payment-ticket small,.payment-ticket strong{display:block}.payment-ticket strong{font-size:24px}.payment-ticket p{color:var(--muted)}.payment-ticket button{width:100%;margin:auto 0 0;background:var(--green)}.pending{margin-top:auto;padding:18px;border-radius:13px;background:var(--soft);color:var(--muted)}.empty{grid-column:1/-1;min-height:330px;display:grid;place-content:center;text-align:center;border:2px dashed #c8ccc8;border-radius:22px;color:var(--muted)}.empty>span{font-size:48px}.empty h2{margin:12px 0 5px;font-family:var(--jw-font-display,"Syne Variable");font-size:32px;color:var(--text)}.empty p{margin:0}.toast{position:fixed;left:50%;bottom:24px;z-index:20;transform:translate(-50%,20px);padding:14px 20px;border-radius:13px;background:var(--text);color:#fff;font-weight:850;opacity:0;transition:.2s}.toast.show{opacity:1;transform:translate(-50%,0)}.pos-loading,.pos-error{min-height:100vh;display:grid;place-content:center;text-align:center}.pos-loading>span,.pos-error>span{font-size:65px}.pos-error h1{max-width:700px;font-family:var(--jw-font-display,"Syne Variable");font-size:60px;line-height:.9;margin:15px}.pos-loading p,.pos-error small{color:var(--muted)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto;padding:0 14px}.merchant{display:none}.brand small{display:none}.stats{grid-template-columns:repeat(2,1fr)}.tabs{overflow:auto}.tabs button{flex:1;min-width:110px}.board{grid-template-columns:1fr}.ticket{min-height:240px}}@media(min-width:701px) and (max-width:1100px){.board{grid-template-columns:repeat(2,1fr)}.ticket-body p{font-size:18px}}@media(orientation:landscape) and (max-height:700px){.topbar{height:62px}.stats article{min-height:54px}.stats strong{font-size:25px}.ticket{min-height:225px}.empty{min-height:250px}} +body{padding-top:76px} +@media(max-width:700px){body{padding-top:64px}} diff --git a/docker/justworks/business/public/assets/pos.js b/docker/justworks/business/public/assets/pos.js new file mode 100644 index 00000000..67ba3983 --- /dev/null +++ b/docker/justworks/business/public/assets/pos.js @@ -0,0 +1,23 @@ +import {mountAppControls} from './app-mode.js?v=1'; +import {mountLogo} from './ds/logo.js'; +import {mountMobileChrome} from './ds/frame.js'; +import {npubFrom} from './ds/env.js'; +const app=document.querySelector('#app');const params=new URLSearchParams(location.search);const npub=npubFrom();let merchant,state,timer,busy=false,lastIds='',lastBookingIds=''; +const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const ago=stamp=>{const s=Math.max(0,Math.floor((Date.now()-new Date(stamp).getTime())/1000));return s<60?`${s}s`:s<3600?`${Math.floor(s/60)}m`:`${Math.floor(s/3600)}h`}; +function error(message){app.innerHTML=`
⌁

Just Works POS

${esc(message)}

Use pos.justworks.cash/?npub=npub1…
`} +function shell(){app.innerHTML=`
Just WorksCounter POS
${esc(merchant.name)}${esc([merchant.category,merchant.area].filter(Boolean).join(' · '))}
Live
Waiting0
Preparing0
Ready0
Payments0
`;document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}));mountMobileChrome(app);mountAppControls(document.querySelector(".pos-app-controls"));if(location.hash==='#bookings')showTab('bookings');tick();setInterval(tick,10000)} +function tick(){const c=document.querySelector('#clock');if(c)c.textContent=new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})} +function showTab(id){document.querySelectorAll('[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab===id));document.querySelectorAll('.board').forEach(b=>b.classList.toggle('active',b.id===id))} +function orderCard(order){const next={new:['Accept','accepted'],accepted:['Mark delivered','delivered'],delivered:['Close table','closed']}[order.status];const request=order.items.map(item=>`${item.qty>1?`${item.qty}× `:''}${esc(item.name)}`).join(' · ');return `
${order.table_number}
Table${order.table_number}

${request}

${order.note?`${esc(order.note)}`:''}
${order.status==='new'?'Waiting':order.status==='accepted'?'Preparing':'Delivered'}${next?``:''}
`} +function paymentCard(payment){return `
€
Table ${payment.table_number}${payment.amount_sats.toLocaleString()} sats

€${(payment.amount_cents/100).toFixed(2)} · ${payment.status}

${payment.status==='paid'?``:'Waiting for Lightning settlement…'}
`} +function bookingCard(booking){const next={new:['Confirm booking','confirmed'],confirmed:['Mark seated','seated'],seated:['Close','closed']}[booking.status];const time=new Date(booking.scheduled_at);return `
${booking.guests}
${booking.mode==='now'?'Arriving soon':'Reserved'}${time.toLocaleDateString([],{weekday:'short',day:'numeric',month:'short'})} · ${time.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}

${esc(booking.guest_name)} · ${booking.guests} ${booking.guests===1?'guest':'guests'}

${booking.notes?`${esc(booking.notes)}`:''}
${booking.status}${next?``:''}
`} +function render(){const orders=state.orders||[],payments=state.payments||[],bookings=state.bookings||[];document.querySelector('#waitingCount').textContent=orders.filter(o=>o.status==='new').length;document.querySelector('#preparingCount').textContent=orders.filter(o=>o.status==='accepted').length;document.querySelector('#readyCount').textContent=orders.filter(o=>o.status==='delivered').length;document.querySelector('#paymentCount').textContent=payments.length;document.querySelector('#orderBadge').textContent=orders.length;document.querySelector('#payBadge').textContent=payments.length;document.querySelector('#bookingBadge').textContent=bookings.length;document.querySelector('#orders').innerHTML=orders.length?orders.map(orderCard).join(''):`
✓

No open orders.

New table orders appear here automatically.

`;document.querySelector('#payments').innerHTML=payments.length?payments.map(paymentCard).join(''):`
€

No pending payments.

Pay Bill and Just Pay confirmations appear here.

`;document.querySelector('#bookings').innerHTML=bookings.length?bookings.map(bookingCard).join(''):`
□

No upcoming bookings.

Table reservations appear here automatically.

`;document.querySelectorAll('[data-order]').forEach(b=>b.onclick=()=>updateOrder(b.dataset.order,b.dataset.status));document.querySelectorAll('[data-payment]').forEach(b=>b.onclick=()=>ack(b.dataset.payment));document.querySelectorAll('[data-booking]').forEach(b=>b.onclick=()=>updateBooking(b.dataset.booking,b.dataset.status));const ids=orders.map(o=>o.id).join(',');if(lastIds&&ids!==lastIds&&orders.some(o=>!lastIds.includes(o.id)))notify('New order received');lastIds=ids;const bookingIds=bookings.map(item=>item.id).join(',');if(lastBookingIds&&bookingIds!==lastBookingIds&&bookings.some(item=>!lastBookingIds.includes(item.id)))notify('New booking received');lastBookingIds=bookingIds} +function notify(text){const t=document.querySelector('#toast');t.textContent=text;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1800)} +async function updateOrder(id,status){if(busy)return;busy=true;try{const r=await fetch(`/api/orders/${id}/status`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});if(!r.ok)throw new Error();notify(status==='accepted'?'Order accepted':status==='delivered'?'Marked delivered':'Table closed');await load()}catch{notify('Could not update order')}finally{busy=false}} +async function ack(id){if(busy)return;busy=true;try{const response=await fetch(`/api/payments/${id}/acknowledge`,{method:'POST'});if(!response.ok)throw new Error();notify('Payment cleared');await load()}catch{notify('Could not acknowledge payment. Check your login.')}finally{busy=false}} +async function updateBooking(id,status){if(busy)return;busy=true;try{const response=await fetch(`/api/bookings/${id}/status`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});if(!response.ok)throw new Error();notify(status==='confirmed'?'Booking confirmed':status==='seated'?'Guests seated':'Booking closed');await load()}catch{notify('Could not update booking')}finally{busy=false}} +function unavailable(message){const connection=document.querySelector('#connection');connection?.classList.add('offline');connection?.querySelector('span').replaceChildren(message);if(!document.querySelector('#orders'))return;state={orders:[],payments:[],bookings:[]};render();document.querySelector('#orders').innerHTML=message==='Login required'?'

Merchant login required

Use Login above to connect your Business session.

':'

Waiting for connection

Live orders and payments are unavailable. Nothing is saved for later.

'} +window.addEventListener('offline',()=>unavailable('Offline')); +async function load(){const connection=document.querySelector('#connection');try{if(!navigator.onLine)throw new Error('Offline');const response=await fetch(`/api/pos?npub=${encodeURIComponent(npub)}`,{cache:'no-store'});if(!response.ok)throw new Error(response.status===403?'Login required':'Reconnecting');const data=await response.json();if(!navigator.onLine)throw new Error('Offline');state=data;render();connection?.classList.remove('offline');connection?.querySelector('span').replaceChildren('Live')}catch(error){unavailable(error.message==='Login required'?'Login required':navigator.onLine?'Reconnecting':'Offline')}} +async function boot(){if(!npub)return error('Add the merchant npub to start.');const data=await fetch(`/api/order-merchant?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const d=await r.json();if(!r.ok)throw new Error(d.error);return d});merchant=data.merchant;shell();tick();await load();timer=setInterval(load,1500)}boot().catch(e=>error(e.message||'Merchant unavailable')); diff --git a/docker/justworks/business/public/assets/profile-mobile.css b/docker/justworks/business/public/assets/profile-mobile.css new file mode 100644 index 00000000..a8348a0f --- /dev/null +++ b/docker/justworks/business/public/assets/profile-mobile.css @@ -0,0 +1 @@ +@media(max-width:720px){.submit-row{position:static;display:block;margin:10px 0 0;padding:18px}.submit-row button{width:100%;margin-top:12px}} diff --git a/docker/justworks/business/public/assets/profile.css b/docker/justworks/business/public/assets/profile.css new file mode 100644 index 00000000..2de2ab71 --- /dev/null +++ b/docker/justworks/business/public/assets/profile.css @@ -0,0 +1,8 @@ +:root{--orange:#ff4f1f;--black:#080808;--ink:#101010;--muted:#72726e;--line:#dcdcd7;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html{background:var(--black)}body{margin:0;background:#f5f5f1;color:var(--ink)}a{color:inherit}header{height:76px;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);background:var(--black);color:#fff}header>a:last-child{margin-left:auto;padding:9px 13px;border:1px solid #393939;border-radius:99px;text-decoration:none;font-size:11px;font-weight:800}.brand{display:flex;align-items:center;gap:10px;text-decoration:none;font-weight:900}.brand [data-logo]{width:26px}.brand b{font-size:16px;font-weight:900}.brand small{display:inline;font-size:16px;font-weight:900;color:inherit;letter-spacing:0;text-transform:none}.brand small:before{content:"·";color:#90908d;margin:0 9px}.profile-hero{position:relative;min-height:clamp(280px,38vw,500px);display:flex;align-items:flex-end;padding:clamp(28px,5vw,68px);overflow:hidden;color:#fff;background:#111 var(--hero,linear-gradient(135deg,#252525,#090909)) center/cover}.wash{position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.93),rgba(0,0,0,.08) 75%)}.profile-hero>div:last-child{position:relative;z-index:1}.profile-hero p,.form-head p{margin:0;color:var(--orange);font-size:10px;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.profile-hero h1{margin:12px 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(52px,9vw,120px);line-height:.8;letter-spacing:-.06em}.profile-hero span{display:block;max-width:80vw;overflow:hidden;text-overflow:ellipsis;color:#aaa;font-size:10px}form{padding:clamp(46px,7vw,90px) clamp(18px,4vw,56px)}.form-head{display:flex;justify-content:space-between;align-items:end;gap:30px;margin-bottom:36px}.form-head h2{max-width:780px;margin:10px 0 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(42px,6vw,78px);line-height:.88;letter-spacing:-.055em}.form-head>span{max-width:350px;color:var(--muted);line-height:1.5}.fields{display:grid;grid-template-columns:1fr 1fr;gap:10px}.fields>section{padding:clamp(22px,3vw,36px);border:1px solid var(--line);background:#fff}.fields h3{margin:0 0 26px;font-family:var(--jw-font-display,"Syne Variable");font-size:30px;letter-spacing:-.04em}.fields label{display:block;margin-top:17px;color:#777;font-size:9px;font-weight:900;letter-spacing:.11em;text-transform:uppercase}.fields input,.fields textarea{width:100%;margin-top:7px;padding:14px;border:1px solid var(--line);border-radius:0;background:#f1f1ed;color:#111;font:inherit;font-size:15px;outline:none;text-transform:none;letter-spacing:0;resize:vertical}.fields input:focus,.fields textarea:focus{border-color:var(--orange);box-shadow:0 0 0 3px rgba(255,79,31,.12)}.fields input[readonly]{color:#888}.lightning{border-color:#f2a38d!important}.hint{margin:16px 0 0;color:#858580;font-size:11px;line-height:1.5}.or{height:22px;display:flex;align-items:center;margin:12px 0 -5px;color:#aaa;font-size:9px;text-transform:uppercase}.or:before,.or:after{content:"";height:1px;flex:1;background:var(--line)}.or span{padding:0 10px}.submit-row{display:flex;justify-content:space-between;align-items:center;gap:25px;margin-top:18px;padding:22px;background:var(--black);color:#fff}.submit-row p{margin:0;color:#aaa;font-size:12px}.submit-row p.error{color:#ff8b6b}.submit-row button{min-width:220px;display:flex;justify-content:space-between;padding:16px 18px;border:0;background:var(--orange);color:#fff;font-weight:900}.submit-row button:disabled{opacity:.6}.loading,.failure{min-height:100vh;display:grid;place-content:center;text-align:center;padding:25px;background:var(--black);color:#fff}.loading span,.failure span{font-size:70px}.failure h1{max-width:760px;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(44px,8vw,90px)}@media(max-width:720px){header{height:66px}.profile-hero{min-height:260px}.fields{grid-template-columns:1fr}.form-head{display:block}.form-head>span{display:block;margin-top:18px}.submit-row{position:sticky;bottom:0;display:block;margin-left:-18px;margin-right:-18px;padding:16px 18px}.submit-row button{width:100%;margin-top:12px}.profile-hero h1{font-size:16vw}} +.profile-back{padding:9px 13px;border:1px solid rgba(255,255,255,.18);border-radius:99px;color:inherit;text-decoration:none;font-size:11px;font-weight:800} +.jw-topbar{color:#fff} +.jw-topbar .brand small,.jw-topbar .brand small:before{color:#90908d!important} +.jw-topbar .links a{color:rgba(255,255,255,.76)} +.jw-topbar .links a:hover{background:rgba(255,255,255,.08)} +[data-topbar].is-scrolled{border-bottom-color:rgba(255,255,255,.09);background:linear-gradient(180deg,rgba(8,8,8,.72),rgba(8,8,8,.45));-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)} +.jw-topbar .brand>[data-logo]{width:var(--jw-app-icon-size)!important;height:var(--jw-app-icon-size)!important;margin:0!important} diff --git a/docker/justworks/business/public/assets/profile.js b/docker/justworks/business/public/assets/profile.js new file mode 100644 index 00000000..31bf83ae --- /dev/null +++ b/docker/justworks/business/public/assets/profile.js @@ -0,0 +1,12 @@ +import {mountLogo} from './ds/logo.js'; +import {startNavScroll} from './ds/header.js'; +import {link,mountAppChrome} from './ds/frame.js'; +import {npubFrom,prod} from './ds/env.js'; +const app=document.querySelector('#app'); +const npub=npubFrom(); +const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +function fail(message){app.innerHTML=`
∩

Business profile

${esc(message)}

Back to Business
`} +function field(label,id,value,type='text',extra=''){return ``} +function render(data){const profile=data.profile||{},image=profile.banner||data.images[0]||'';document.title=`${data.merchant.name} · Business profile`;app.innerHTML=`
Just WorksBusiness profileBack to modules ↙

Nostr merchant identity

${esc(data.merchant.name)}

${esc(npub)}

Public profile

What customers see.

Saving republishes the signed Nostr profile and nsite.

Identity

${field('Display name','displayName',profile.display_name||data.merchant.name)}${field('Nostr name','nostrName',profile.name||'','text','readonly')}${field('Public website','website',profile.website||data.links.justworks,'url','readonly')}

Images

${field('Profile picture URL','picture',profile.picture||'','url')}${field('Banner image URL','banner',profile.banner||'','url')}

Use public HTTPS image URLs. These images appear in the signed Nostr profile.

Payments

${field('Lightning address','lud16',profile.lud16||'','email','placeholder="merchant@example.com" required')}

Pay Bill and Just Pay remain locked while this uses the default @npub.cash address.

Owner authorization

${field('Owner login','ownerIdentifier',prod?'':'demo','text','autocomplete="username"')}${field('Owner password','ownerPassword',prod?'':'demo','password','autocomplete="current-password"')}
or
${field('Owner key','ownerKey','','password','placeholder="nsec1… or ncryptsec1…" autocomplete="off"')}

Credentials are used only for this save and publish request. Just Works Business does not store them.

Ready to update the signed merchant profile.

`;document.querySelector('#profileForm').onsubmit=save;document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}))} +async function save(event){event.preventDefault();const button=document.querySelector('#save'),status=document.querySelector('#status');button.disabled=true;status.textContent='Signing and publishing the Nostr profile…';const profile={display_name:document.querySelector('#displayName').value,about:document.querySelector('#about').value,picture:document.querySelector('#picture').value,banner:document.querySelector('#banner').value,lud16:document.querySelector('#lud16').value};const body={npub,profile,ownerIdentifier:document.querySelector('#ownerIdentifier').value,ownerPassword:document.querySelector('#ownerPassword').value,ownerKey:document.querySelector('#ownerKey').value};try{const response=await fetch('/api/business-profile',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}),data=await response.json();if(!response.ok)throw new Error(data.error||'Could not publish');document.querySelector('#ownerPassword').value='';document.querySelector('#ownerKey').value='';status.textContent='Published. Pay Bill and Just Pay are now available.';button.innerHTML='Published ✓';setTimeout(()=>location.href=link('business'),1200)}catch(error){status.textContent=error.message;status.classList.add('error');button.disabled=false;button.innerHTML='Try again ↻'}finally{document.querySelector('#ownerPassword').value='';document.querySelector('#ownerKey').value=''}} +if(!npub)fail('Add the merchant npub.');else fetch(`/api/business?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async response=>{const data=await response.json();if(!response.ok)throw new Error(data.error||'Profile unavailable');render(data);app.querySelector(':scope > header')?.remove();mountAppChrome(app,{label:'Business profile',endHtml:`Back to modules ↙`})}).catch(error=>fail(error.message)); diff --git a/docker/justworks/business/public/assets/pwa/offline.html b/docker/justworks/business/public/assets/pwa/offline.html new file mode 100644 index 00000000..d2ca0040 --- /dev/null +++ b/docker/justworks/business/public/assets/pwa/offline.html @@ -0,0 +1 @@ +Just Works · OfflineJUST WORKS

Let’s reconnect.

You’re offline. Orders, payments and live activity are unavailable. Nothing is being submitted or saved for later.

Reconnect, then reload to return to this merchant.

Try again diff --git a/docker/justworks/business/public/assets/pwa/pos-192.png b/docker/justworks/business/public/assets/pwa/pos-192.png new file mode 100644 index 00000000..50c31f31 Binary files /dev/null and b/docker/justworks/business/public/assets/pwa/pos-192.png differ diff --git a/docker/justworks/business/public/assets/pwa/pos-512.png b/docker/justworks/business/public/assets/pwa/pos-512.png new file mode 100644 index 00000000..469ea1da Binary files /dev/null and b/docker/justworks/business/public/assets/pwa/pos-512.png differ diff --git a/docker/justworks/business/public/assets/pwa/pos.svg b/docker/justworks/business/public/assets/pwa/pos.svg new file mode 100644 index 00000000..a814e029 --- /dev/null +++ b/docker/justworks/business/public/assets/pwa/pos.svg @@ -0,0 +1 @@ +POS diff --git a/docker/justworks/business/public/assets/pwa/screen-192.png b/docker/justworks/business/public/assets/pwa/screen-192.png new file mode 100644 index 00000000..57b52b66 Binary files /dev/null and b/docker/justworks/business/public/assets/pwa/screen-192.png differ diff --git a/docker/justworks/business/public/assets/pwa/screen-512.png b/docker/justworks/business/public/assets/pwa/screen-512.png new file mode 100644 index 00000000..da4471ce Binary files /dev/null and b/docker/justworks/business/public/assets/pwa/screen-512.png differ diff --git a/docker/justworks/business/public/assets/pwa/screen.svg b/docker/justworks/business/public/assets/pwa/screen.svg new file mode 100644 index 00000000..57e72e77 --- /dev/null +++ b/docker/justworks/business/public/assets/pwa/screen.svg @@ -0,0 +1 @@ +SCREEN diff --git a/docker/justworks/business/public/assets/pwa/worker.js b/docker/justworks/business/public/assets/pwa/worker.js new file mode 100644 index 00000000..dd8d01e8 --- /dev/null +++ b/docker/justworks/business/public/assets/pwa/worker.js @@ -0,0 +1,24 @@ +// APP is supplied by the dedicated root worker. Never intercept non-GET writes, +// APIs, identities, QR endpoints, merchant photos or third-party requests. +const VERSION = 'v1'; +const PREFIX = `jw-${self.APP}-shell-`; +const CACHE = `${PREFIX}${VERSION}`; +const OFFLINE = '/assets/pwa/offline.html'; +const SHELL = [OFFLINE, `/assets/pwa/${self.APP}-192.png`, `/assets/pwa/${self.APP}-512.png`]; +self.addEventListener('install', event => { + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL))); + // No skipWaiting: an update never reloads an in-progress POS interaction. +}); +self.addEventListener('activate', event => { + event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())); +}); +self.addEventListener('fetch', event => { + const request = event.request, url = new URL(request.url); + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + if (request.mode === 'navigate') { + // Network-only HTML; the only fallback contains no merchant or live data. + event.respondWith(fetch(request, {cache: 'no-store'}).catch(async () => (await caches.match(OFFLINE, {cacheName: CACHE})) || Response.error())); + } else if (!url.search && SHELL.includes(url.pathname)) { + event.respondWith(caches.open(CACHE).then(async cache => (await cache.match(request)) || fetch(request))); + } +}); diff --git a/docker/justworks/business/public/assets/qr-sheet.css b/docker/justworks/business/public/assets/qr-sheet.css new file mode 100644 index 00000000..d943d2ef --- /dev/null +++ b/docker/justworks/business/public/assets/qr-sheet.css @@ -0,0 +1,46 @@ +@font-face{font-family:Geist;src:url('/fonts/geist-latin-wght-normal.woff2') format('woff2');font-weight:100 900;font-display:swap} +@font-face{font-family:Syne;src:url('/fonts/syne-latin-wght-normal.woff2') format('woff2');font-weight:400 800;font-display:swap} +*{box-sizing:border-box} +:root{font-family:Geist,Arial,sans-serif;color:#151515;background:#eeeFEB;font-synthesis:none} +body{margin:0} +[hidden]{display:none!important} +.sheet-tools{background:#090909;color:white;display:flex;align-items:center;gap:20px;padding:18px max(24px,calc((100vw - 980px)/2));font-size:13px} +.sheet-tools>a{color:white;text-decoration:none;white-space:nowrap} +.sheet-tools>span{font-weight:650;margin-right:auto} +.sheet-tools button{border:0;background:#ff5a31;color:#111;border-radius:5px;padding:13px 17px;font:inherit;font-weight:700;cursor:pointer;white-space:nowrap} +.sheet-tools button:disabled{opacity:.5;cursor:wait} +a:focus-visible,button:focus-visible{outline:3px solid #ff5a31;outline-offset:4px} +.print-help,.status{max-width:794px;margin:22px auto 0;padding:0 16px;font-size:12px;line-height:1.6;color:#555} +.print-help strong{color:#111;display:block;font-size:17px;margin-bottom:5px} +.status{margin-top:12px;font-size:11px} +.status.error{color:#9d2615;font-size:15px} +.sheet{width:210mm;min-height:297mm;margin:22px auto 44px;background:white;padding:10mm;box-shadow:0 12px 50px #00000012;color:#111} +.sheet-heading{display:flex;align-items:center;justify-content:space-between;gap:5mm;height:20mm;margin-bottom:4mm} +.eyebrow{font-size:8pt;font-weight:750;text-transform:uppercase;letter-spacing:.15em;margin:0 0 2mm;color:#bd3b19} +h1{font-family:Syne,Geist,sans-serif;font-size:24pt;line-height:1.05;margin:0;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.sheet-heading>div{min-width:0;max-width:125mm} +.sheet-caption{font-size:8pt;line-height:1.6;text-align:right;flex-shrink:0;margin:0} +.sheet-caption span{color:#666;font-size:7pt} +.labels{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:48mm;gap:0} +.qr-label{border:1px dashed #b5b5b5;text-align:center;display:flex;flex-direction:column;align-items:center;padding:2mm 2mm 1.5mm;break-inside:avoid;overflow:hidden;min-width:0} +.label-merchant{font-size:6.5pt;line-height:1.15;margin:0 0 .8mm;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#555} +.qr-label h2{font-family:Syne,Geist,sans-serif;font-size:11pt;line-height:1.1;font-weight:750;margin:0 0 .6mm;max-width:100%} +.qr-label a{display:block;width:33mm;height:33mm;padding:2mm;background:#fff;flex-shrink:0} +.qr-label img{display:block;width:100%;height:100%;object-fit:contain} +.label-caption{font-size:6pt;margin:.6mm 0 0;line-height:1.1;color:#555} +.sheet-footer{display:flex;justify-content:space-between;gap:4mm;padding-top:4mm;font-size:6pt;color:#666;line-height:1.3} +.sheet-footer>span:first-child{font-weight:800;letter-spacing:.13em;color:#111} +@media screen and (max-width:830px){.sheet{width:calc(100% - 28px);min-height:0;padding:20px}.sheet-heading{height:auto;min-height:70px;margin-bottom:15px}.sheet-heading>div{max-width:60%}h1{font-size:28px}.sheet-caption{font-size:10px}.sheet-caption span{font-size:9px}.labels{grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:auto}.qr-label{min-height:185px;padding:10px 4px}.sheet-footer{font-size:8px;flex-wrap:wrap}.sheet-tools{padding:14px;gap:12px;flex-wrap:wrap}.sheet-tools>span{font-size:11px}.sheet-tools button{font-size:12px}.eyebrow{font-size:9px}.sheet-heading{gap:10px}} +@media screen and (max-width:380px){.labels{grid-template-columns:1fr}.sheet-heading{display:block}.sheet-heading>div{max-width:100%}.sheet-caption{text-align:left;margin-top:10px}} +@page{size:A4 portrait;margin:10mm} +@media print{ + :root,body{background:#fff} + .sheet-tools,.print-help,.status{display:none!important} + .sheet{width:190mm;min-height:0;margin:0;padding:0;box-shadow:none} + .sheet-heading{height:20mm} + .labels{grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:48mm} + .sheet-footer{padding-top:4mm} + .sheet:not([data-ready="true"]){display:none!important} + body:has(.sheet:not([data-ready="true"])) .status{display:block!important;color:#111} + a{color:inherit;text-decoration:none} +} diff --git a/docker/justworks/business/public/assets/qr-sheet.js b/docker/justworks/business/public/assets/qr-sheet.js new file mode 100644 index 00000000..fd792cef --- /dev/null +++ b/docker/justworks/business/public/assets/qr-sheet.js @@ -0,0 +1,66 @@ +import { qrImageUrl } from './module-qr.js?v=3'; +import { businessUrl, sheetDestinations, validNpub } from './business-destinations.js?v=1'; + +const status = document.querySelector('#status'); +const sheet = document.querySelector('#sheet'); +const print = document.querySelector('#print'); +const labels = document.querySelector('#labels'); +const npub = new URLSearchParams(location.search).get('npub')?.trim(); +const fail = message => { + status.textContent = message; + status.classList.add('error'); + print.disabled = true; +}; + +async function openSheet() { + if (!validNpub(npub)) throw new Error('Open this QR sheet from your Business dashboard with a valid merchant npub.'); + // Do not propagate unrelated URL parameters or fragments into links or prints. + history.replaceState(null, '', `${location.pathname}?npub=${encodeURIComponent(npub)}`); + document.querySelector('#back').href = businessUrl(npub); + const response = await fetch(`/api/business?npub=${encodeURIComponent(npub)}`, {cache: 'no-store'}); + if (!response.ok) throw new Error('The merchant could not be loaded. Return to Business or reload to try again.'); + const data = await response.json(); + if (data.merchant?.npub !== npub) throw new Error('Merchant identity could not be verified. Open the sheet again from Business.'); + const destinations = sheetDestinations(data, npub); + document.title = `${data.merchant.name} · QR in DIN A4 · Just Works`; + document.querySelector('#merchant-name').textContent = data.merchant.name; + document.querySelector('#label-count').textContent = `${destinations.length} merchant links · Print & cut`; + const loading = destinations.map(({id, title, url}) => { + const target = new URL(url, location.origin); + if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password) throw new Error('A merchant link is unavailable. Return to Business to check the links.'); + const card = document.createElement('article'); + card.className = 'qr-label'; + card.dataset.destination = id; + const name = document.createElement('p'); + name.className = 'label-merchant'; + name.textContent = data.merchant.name; + const heading = document.createElement('h2'); + heading.textContent = title; + const anchor = document.createElement('a'); + anchor.href = target.href; + anchor.setAttribute('aria-label', `Open ${title}`); + const image = document.createElement('img'); + image.alt = `${title} QR code for ${data.merchant.name}`; + image.width = image.height = 320; + const ready = new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = () => reject(new Error('Some QR codes could not load. Reload this page before printing.')); + }); + image.src = qrImageUrl(target.href); + anchor.append(image); + const caption = document.createElement('p'); + caption.className = 'label-caption'; + caption.textContent = ['business', 'pos', 'stats'].includes(id) ? 'Team access · Just Works' : 'Scan to open · Just Works'; + card.append(name, heading, anchor, caption); + labels.append(card); + return ready; + }); + sheet.hidden = false; + await Promise.all(loading); + await document.fonts.ready; + status.textContent = 'Ready to print. All QR codes loaded.'; + print.disabled = false; + sheet.dataset.ready = 'true'; +} +print.addEventListener('click', () => { if (!print.disabled) window.print(); }); +openSheet().catch(fail); diff --git a/docker/justworks/business/public/assets/quick-pay.css b/docker/justworks/business/public/assets/quick-pay.css new file mode 100644 index 00000000..989f08e6 --- /dev/null +++ b/docker/justworks/business/public/assets/quick-pay.css @@ -0,0 +1,5 @@ +/* The payment action is the product, especially on a phone. */ +.hidden{display:none!important}.secure.error{color:#d7462d}.primary:disabled{opacity:.62;cursor:wait}.quick-pay .pay-card{max-width:540px;width:100%;justify-self:end}.invoice-view{display:grid;gap:12px;margin-top:24px;text-align:center}.invoice-view h2{margin:0;font-family:var(--jw-font-display,"Syne Variable");font-size:34px}.invoice-qr{width:min(100%,340px);margin:auto;padding:14px;border-radius:18px;background:#fff}.invoice-qr svg{display:block;width:100%;height:auto}.open-wallet,.copy-invoice{min-height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border:0;border-radius:14px;font-weight:900;text-decoration:none}.open-wallet{background:var(--accent);color:#fff}.copy-invoice{justify-content:center;background:var(--soft);color:var(--text)}.invoice-view>small{color:var(--muted)} +.settlement-wait{display:grid;grid-template-columns:14px 1fr;gap:2px 10px;align-items:center;margin-top:4px;padding:15px;border:1px solid var(--line);border-radius:14px;text-align:left;background:var(--soft)}.settlement-wait i{grid-row:1/3;width:11px;height:11px;border-radius:50%;background:#f7931a;box-shadow:0 0 0 0 rgba(247,147,26,.5);animation:settlement-pulse 1.5s infinite}.settlement-wait strong{font-size:13px}.settlement-wait small{color:var(--muted);font-size:11px}.check-payment{min-height:46px;border:1px solid var(--line);border-radius:13px;background:transparent;color:var(--text);font-weight:850}.paid-view{display:grid;justify-items:center;gap:10px;margin-top:24px;padding:28px 20px;border:1px solid rgba(22,160,133,.38);border-radius:24px;background:radial-gradient(circle at 50% 4%,rgba(22,160,133,.2),transparent 40%),#0d1411;color:#fff;text-align:center;box-shadow:0 28px 70px rgba(0,0,0,.28)}.paid-mark{width:84px;height:84px;display:grid;place-items:center;border:2px solid #5ee495;border-radius:50%;background:rgba(94,228,149,.12);color:#5ee495;font-size:44px;font-weight:950}.paid-view .eyebrow{margin:8px 0 0;color:#5ee495}.paid-view h2{margin:0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(54px,15vw,84px);line-height:.85;letter-spacing:-.06em}.paid-amount{display:grid;margin:7px 0 2px}.paid-amount strong{font-size:30px}.paid-amount span{color:#5ee495;font-size:17px;font-weight:850}.paid-merchant{margin:4px 0 8px;color:#b7c2bc}.paid-merchant strong{display:block;margin-top:2px;color:#fff;font-size:18px}.payment-proof{width:100%;display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:14px;border:1px solid rgba(94,228,149,.28);border-radius:14px;background:rgba(94,228,149,.08);color:#fff;text-align:left;text-decoration:none}.payment-proof span{font-size:12px;font-weight:850}.payment-proof code{color:#8dddb4;font-size:10px}.payment-proof b{color:#5ee495}.payment-proof.unavailable{display:block;color:#b7c2bc;text-align:center}.paid-meta{width:100%;display:flex;justify-content:space-between;padding-top:14px;border-top:1px solid rgba(255,255,255,.12);color:#8d9992;font-size:11px}.paid-meta time{color:#fff;font-weight:750}.show-staff{margin:8px 0 0!important;padding:10px 15px;border-radius:99px;background:#fff;color:#0a0a0a!important;font-size:12px;font-weight:900}.secure.hidden{display:none}@keyframes settlement-pulse{70%{box-shadow:0 0 0 9px rgba(247,147,26,0)}} +@media(max-width:850px){body{padding-top:64px}.surface.pay,.surface.payandgo{width:100%;padding:0}.quick-pay{display:block;min-height:auto}.quick-pay .pay-copy{padding:30px 18px 22px}.quick-pay .pay-copy h1{font-size:42px;margin:10px 0}.quick-pay .pay-copy>p:last-child{font-size:15px;margin-bottom:0}.quick-pay .pay-card{max-width:none;padding:24px 18px 34px;border-left:0;border-right:0;border-radius:0;box-shadow:none}.quick-pay .merchant{padding-bottom:18px}.quick-pay .amount input{font-size:34px}.quick-pay .tips button{min-height:48px}.quick-pay .primary{min-height:58px}.invoice-qr{width:min(100%,320px)}.paid-view{min-height:70svh;margin-inline:-5px;padding:28px 16px}.payment-proof{grid-template-columns:1fr auto}.payment-proof b{display:none}} +.quick-pay:has(.paid-view) .pay-copy{display:none}.pay-card:has(.paid-view)>:not(#invoiceResult){display:none}.pay-card:has(.paid-view){padding-top:8px} diff --git a/docker/justworks/business/public/assets/screen.css b/docker/justworks/business/public/assets/screen.css new file mode 100644 index 00000000..d2adc1c0 --- /dev/null +++ b/docker/justworks/business/public/assets/screen.css @@ -0,0 +1,16 @@ +:root{font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif);color:#fff;background:#050606}*{box-sizing:border-box}html,body,#screen{width:100%;height:100%;margin:0}body{color:#fff;background:#050606}button{font:inherit}a,button{-webkit-tap-highlight-color:transparent}a:focus-visible,button:focus-visible{outline:4px solid #ff996f;outline-offset:6px} +.stage{position:relative;isolation:isolate;width:100%;height:100%;min-height:650px;overflow:hidden;background:#101915;display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,23vw);grid-template-rows:auto minmax(0,1fr) auto auto;gap:2.5vh 3vw;padding:3vh 4vw 2vh}.slides,.shade{position:absolute;inset:0;z-index:-1}.slide{position:absolute;inset:-2%;width:104%;height:104%;object-fit:cover;opacity:0;transform:scale(1.03);transition:opacity 1.8s ease,transform 10s linear;filter:saturate(1.04)}.slide.active{opacity:1;transform:scale(1.1)}.shade{background:linear-gradient(90deg,#000d,#0007 65%,#0004),linear-gradient(0deg,#000c,transparent 70%)} +.stage>header{grid-column:1/-1;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:3vw;min-width:0}.brand{display:flex;align-items:center;gap:12px}.brand b,.brand small,.merchant strong,.merchant small{display:block}.brand [data-logo]{width:clamp(32px,2.3vw,88px);height:clamp(32px,2.3vw,88px)}.brand b{font-size:clamp(14px,1.05vw,40px);font-weight:850}.brand small,.merchant small{color:#ffffffbf;font-size:clamp(12px,.85vw,33px);margin-top:4px}.merchant{text-align:center;max-width:36vw;min-width:0}.merchant strong{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(19px,1.3vw,50px);overflow-wrap:anywhere}header time{justify-self:end;font-size:clamp(18px,1.4vw,54px);font-weight:750;font-variant-numeric:tabular-nums} +.screen-actions{grid-column:1;grid-row:2;align-self:center;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:2vh 2vw;align-items:center}.qr-intro{min-width:0}.eyebrow,.identity>p{text-transform:uppercase;letter-spacing:.2em;font-size:clamp(11px,.8vw,31px);font-weight:800;color:#ff996f;margin:0 0 2vh}.qr-intro h2{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(40px,4.6vw,177px);font-weight:650;letter-spacing:-.055em;line-height:1.02;margin:0 0 2.5vh}.qr-invitation{max-width:21em;color:#d5dfd8;font-size:clamp(16px,1.25vw,48px);line-height:1.5;margin:0}.qr-cards{grid-column:2;grid-row:1/3;min-width:0}.screen-qr{display:flex;flex-direction:column;align-items:center;padding:clamp(14px,1.3vw,50px);border-radius:clamp(20px,1.4vw,54px);background:#fff;color:#101713;text-align:center;text-decoration:none;box-shadow:0 16px 70px #0003;min-width:0}.screen-qr>span{font-weight:850;font-size:clamp(25px,2.3vw,88px);letter-spacing:-.04em;margin-bottom:1vh}.screen-qr img{display:block;width:100%;height:auto;aspect-ratio:1}.screen-qr img[hidden]{display:none}.screen-qr small{font-size:clamp(14px,1.1vw,42px);margin-top:1vh;color:#465149}.stage[data-view=idle] .screen-qr:not(.is-current){display:none}.stage[data-view=idle][data-rotation=running] .screen-qr.is-current{animation:qr-arrive .6s ease-out}.qr-controls{grid-column:1;display:flex;flex-direction:column;align-items:flex-start;gap:16px}.qr-choices{display:flex;gap:clamp(8px,1vw,38px);flex-wrap:wrap}.qr-choices button{border:0;border-top:2px solid #ffffff4d;padding:12px 0 0;color:#c4cec7;background:none;cursor:pointer;font-weight:750;font-size:clamp(14px,1.05vw,40px)}.qr-choices button span{display:block;font-size:.65em;letter-spacing:.1em;margin-bottom:7px;color:#b9c4bd}.qr-choices button[aria-pressed=true]{border-color:#ff996f;color:#fff}.qr-pause{background:none;border:1px solid #ffffff45;border-radius:30px;color:#d5dfd8;padding:8px 14px;cursor:pointer;font-size:clamp(12px,.7vw,27px)} +.live-feed{grid-column:1;grid-row:2;min-height:0;min-width:0;display:flex;flex-direction:column;gap:1.6vh}.stage[data-view=idle] .live-feed{display:none}.feed-heading,.feed-heading>div{display:flex;align-items:center;gap:12px}.feed-heading{justify-content:space-between;gap:16px}.feed-heading h2{font-size:clamp(24px,2vw,77px);letter-spacing:-.035em;margin:0}.live-dot{width:clamp(8px,.6vw,23px);height:clamp(8px,.6vw,23px);flex-shrink:0;border-radius:50%;background:#90e8b9}.feed-intro{color:#c6d2cb;font-size:clamp(12px,.85vw,33px)}.feed-footnote{font-size:clamp(11px,.75vw,29px);color:#b7c4bc;margin:0}.feed-connection{font-size:inherit;color:#a3f2c6;letter-spacing:.04em}.feed-connection.offline{color:#ffce96}#activity-items{flex:1;min-height:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:minmax(0,1fr);gap:1.5vh 1vw}.activity-card{display:flex;flex-direction:column;justify-content:center;gap:1.3vh;position:relative;overflow:hidden;padding:clamp(18px,1.7vw,65px);border:1px solid #ffffff30;background:#0c1813eb;border-radius:clamp(20px,1.3vw,50px);line-height:1.2;animation:arrive .35s ease-out}.activity-icon{width:clamp(35px,2.4vw,92px);height:clamp(35px,2.4vw,92px);display:grid;place-items:center;border-radius:25%;background:#ff694125;color:#ff996f;font-size:clamp(23px,1.8vw,69px)}.justpay .activity-icon{background:#83e5b321;color:#9ef3c7}.tip .activity-icon{background:#b8a0ff24;color:#d8caff}.activity-label{display:flex;justify-content:space-between;gap:10px;font-size:clamp(13px,1vw,38px);color:#bac8bf}.activity-label>span{color:#e5efe8}.activity-card h3{font-size:clamp(30px,2.7vw,104px);letter-spacing:-.035em;margin:.25em 0;overflow-wrap:anywhere}.activity-card p{margin:0;font-size:clamp(19px,1.5vw,58px);color:#e1e9e4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;line-height:1.3}.activity-card>div{min-width:0}.justpay h3,.tip h3{font-size:clamp(28px,2.25vw,86px)}.justpay p,.tip p{font-size:clamp(30px,2.8vw,108px);font-weight:800;color:#fff;letter-spacing:-.03em}.event-lifetime{position:absolute;bottom:0;left:0;width:100%;height:3px;background:#a9eac354;transform-origin:left;animation:expire var(--remaining) linear forwards}.stage[data-activity-count="1"] #activity-items{grid-template-columns:1fr}.stage[data-activity-count="1"] .activity-card h3{font-size:clamp(44px,5vw,192px)}.stage[data-activity-count="1"] .activity-card p{font-size:clamp(24px,2.5vw,96px)} +.stage[data-view=active] .screen-actions{grid-column:2;align-self:center;display:block}.stage[data-view=active] .qr-intro,.stage[data-view=active] .qr-controls{display:none}.stage[data-view=active] .qr-cards{display:grid;gap:1.5vh}.stage[data-view=active] .screen-qr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:0 1vw;padding:clamp(12px,.8vw,31px);border-radius:clamp(16px,1vw,38px);animation:none}.stage[data-view=active] .screen-qr img{grid-column:1;grid-row:1/3}.stage[data-view=active] .screen-qr>span{font-size:clamp(22px,1.6vw,61px);align-self:end;grid-column:2}.stage[data-view=active] .screen-qr small{font-size:clamp(12px,.85vw,33px);align-self:start;grid-column:2;margin:0} +.nostr-note{grid-column:2;grid-row:2;align-self:center;min-width:0;padding:clamp(18px,1.3vw,50px);border:1px solid #ffffff29;border-radius:clamp(20px,1vw,38px);background:#080d0cbb;backdrop-filter:blur(18px)}.note-head{display:flex;align-items:center;gap:10px}.note-head>span{width:clamp(30px,2vw,77px);height:clamp(30px,2vw,77px);display:grid;place-items:center;border-radius:50%;background:#7a40ff}.note-head strong,.note-head small{display:block}.note-head strong{font-size:clamp(14px,1vw,38px)}.note-head small,.npub{color:#c4cbc7;font-size:clamp(11px,.7vw,27px)}.nostr-note>p{margin:1.4vh 0;font-size:clamp(16px,1.25vw,48px);line-height:1.45;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}.stage[data-view=active] .nostr-note{grid-row:3;padding:0;border:0;background:none;backdrop-filter:none}.stage[data-view=active] .nostr-note .note-head>span,.stage[data-view=active] .nostr-note .note-head small,.stage[data-view=active] .nostr-note .npub{display:none}.stage[data-view=active] .nostr-note>p{font-size:clamp(12px,.8vw,31px);-webkit-line-clamp:1;margin:7px 0 0} +.identity{grid-column:1;grid-row:3;min-width:0}.identity>p{margin:0}.identity h1{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(40px,5vw,192px);font-weight:620;line-height:1.05;letter-spacing:-.055em;margin:1vh 0 1.5vh;text-shadow:0 8px 40px #0006;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.stage[data-view=active] .identity h1{font-size:clamp(30px,2.4vw,92px);margin:.6vh 0 1vh}.stage[data-view=active] .identity>p{display:none}.progress{display:flex;gap:7px;width:min(480px,38vw)}.progress i{height:3px;flex:1;background:#ffffff4d}.progress i.active{background:#fff}.stage>footer{grid-column:1/-1;display:flex;justify-content:space-between;align-items:center;gap:15px;color:#c5cbc7;font-size:clamp(10px,.65vw,25px);text-transform:uppercase;letter-spacing:.1em}.screen-loading,.screen-error{height:100%;display:grid;place-content:center;text-align:center;background:radial-gradient(circle at 50% 40%,#232826,#050606 60%)}.loading-mark,.screen-error>span{font-size:70px}.screen-error h1{max-width:800px;font-size:clamp(36px,6vw,90px);margin:18px}.screen-error p,.screen-error small,.screen-loading p{color:#b7c4bc} +@keyframes arrive{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@keyframes qr-arrive{from{opacity:.65;transform:translateY(5px)}to{opacity:1;transform:none}}@keyframes expire{to{transform:scaleX(0)}} +@media(max-width:1100px),(max-height:760px){.stage{height:auto;min-height:100%;display:flex;flex-direction:column;gap:28px;padding:25px 24px;overflow:hidden}.stage>header{grid-template-columns:1fr auto;gap:16px}.merchant{grid-column:1/-1;grid-row:2;justify-self:start;text-align:left;max-width:100%}.brand small{display:none}.screen-actions{width:100%;gap:24px}.qr-intro h2{font-size:clamp(40px,5.5vw,64px)}.qr-cards{width:100%;max-width:390px;justify-self:end}.screen-qr>span{font-size:30px}.qr-controls{gap:16px}.qr-choices button{font-size:16px}.nostr-note{width:100%;max-width:600px;align-self:flex-end}.identity h1{font-size:clamp(40px,8vw,75px)}.stage>footer{font-size:9px;line-height:1.5;flex-wrap:wrap}.stage[data-view=active] .live-feed{order:1;min-height:0}.stage[data-view=active] .screen-actions{order:2;width:100%}.stage[data-view=active] .nostr-note{order:3;width:100%;align-self:flex-start}.stage[data-view=active] .identity{order:4}.stage>footer{order:5}.stage[data-view=active] .qr-cards{max-width:none;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.stage[data-view=active] .screen-qr{display:flex;padding:12px;gap:6px}.stage[data-view=active] .screen-qr img{order:1;width:min(100%,160px)}.stage[data-view=active] .screen-qr>span{align-self:center;order:0;margin:0}.stage[data-view=active] .screen-qr small{align-self:center;order:2}#activity-items{gap:14px;grid-auto-rows:auto}.activity-card{padding:22px;min-height:220px}.activity-card h3{font-size:36px}.activity-card p{font-size:22px}.justpay p,.tip p{font-size:36px}.feed-footnote{margin-top:10px}} +@media(max-width:600px){.stage{padding:24px 20px;gap:24px}.screen-actions{display:flex;flex-direction:column;align-items:stretch;gap:20px}.qr-intro h2{font-size:42px;margin:0 0 14px}.qr-intro h2 br{display:none}.eyebrow{margin-bottom:12px}.qr-invitation{font-size:16px}.qr-cards{max-width:340px;align-self:center}.screen-qr{padding:20px}.qr-controls{align-items:stretch}.qr-choices{justify-content:space-between}.qr-pause{align-self:flex-start}.nostr-note{padding:20px}.identity h1{font-size:42px}.stage>footer{flex-direction:column;align-items:flex-start;gap:8px}#activity-items{grid-template-columns:1fr}.activity-card{min-height:190px;padding:24px}.feed-heading{align-items:flex-start}.feed-heading h2{font-size:26px}.feed-intro{display:none}.stage[data-view=active] .qr-cards{gap:8px}.stage[data-view=active] .screen-qr{padding:8px;border-radius:12px}.stage[data-view=active] .screen-qr>span{font-size:18px}.stage[data-view=active] .screen-qr small{font-size:10px}.stage[data-activity-count="1"] .activity-card h3{font-size:44px}.stage[data-activity-count="1"] .activity-card p{font-size:24px}} +@media(prefers-reduced-motion:reduce){.slide,.slide.active{transition:none;transform:none}.activity-card,.event-lifetime,.stage[data-view=idle] .screen-qr.is-current{animation:none}.stage[data-view=idle] .screen-actions{display:flex;flex-direction:column;align-items:stretch;gap:3vh}.stage[data-view=idle] .qr-intro h2{font-size:clamp(36px,3vw,115px);margin-bottom:1vh}.stage[data-view=idle] .qr-intro h2 br{display:none}.stage[data-view=idle] .qr-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1vw;max-width:none}.stage[data-view=idle] .screen-qr{display:flex;padding:clamp(12px,1vw,38px)}.stage[data-view=idle] .screen-qr>span{font-size:clamp(22px,1.8vw,69px)}.stage[data-view=idle] .qr-controls{display:none}} +.brand .jw-app-icon{width:100%;height:100%} +.activity-announcement{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} +@media(prefers-reduced-motion:reduce){.stage[data-view=idle] .screen-qr:not(.is-current){display:flex}} +@media(prefers-reduced-motion:reduce) and (max-width:600px){.stage[data-view=idle] .qr-cards{grid-template-columns:1fr;gap:16px;max-width:300px}.stage[data-view=idle] .screen-qr{padding:22px}.stage[data-view=idle] .screen-qr>span{font-size:30px}} diff --git a/docker/justworks/business/public/assets/screen.js b/docker/justworks/business/public/assets/screen.js new file mode 100644 index 00000000..2ac950d0 --- /dev/null +++ b/docker/justworks/business/public/assets/screen.js @@ -0,0 +1,168 @@ +import {mountAppControls} from './app-mode.js?v=1'; +import {mountLogo} from './ds/logo.js'; +import {npubFrom} from './ds/env.js'; + +const app = document.querySelector('#screen'); +const npub = npubFrom(); +const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +const apiBase = location.pathname.startsWith('/business-demo') ? '/business-demo' : ''; +const ago = stamp => { + const seconds = Math.max(0, Math.floor((Date.now() - stamp * 1000) / 1000)); + return seconds < 60 ? 'just now' : seconds < 3600 ? `${Math.floor(seconds / 60)}m ago` : `${Math.floor(seconds / 3600)}h ago`; +}; + +function error(message) { + app.innerHTML = `
⌁

Just Works Screen

${esc(message)}

Use screen.justworks.cash/?npub=npub1…
`; +} + +function qrCard(id, label, url) { + // Only server-resolved public destinations; never forward the screen query or hash. + const target = new URL(url); + if (!['https:', 'http:'].includes(target.protocol)) throw new Error('Invalid screen destination'); + return `${label}QR code: ${label}Scan to ${id === 'order' ? 'order' : id === 'justpay' ? 'pay' : 'tip'} ↗`; +} + +function render(data) { + const {merchant, images, latest, screen_links: links} = data; + const safeImages = images || []; + app.innerHTML = `
+
+
Just WorksLive merchant screen
${esc(merchant.name)}${esc([merchant.category, merchant.area].filter(Boolean).join(' · '))}
+ +

Happening now

Orders · Payments · Tips

Only the last 2 minutes · completed orders leave automatically

+
Latest on Nostr${latest ? `${ago(latest.created_at)} · signed event` : 'Merchant feed'}

${latest ? esc(latest.content.slice(0, 220)) : 'No signed text post from this merchant yet.'}

${esc(merchant.npub.slice(0, 18))}…${esc(merchant.npub.slice(-8))}
+

Now at

${esc(merchant.name)}

+
Pictures from the merchant’s nsiteConnecting…₿ Payments · Orders · Nostr-owned

`; + document.querySelectorAll('[data-logo]').forEach(host => mountLogo(host, {icon: true})); + document.querySelectorAll('.screen-qr img').forEach(img => img.addEventListener('error', () => { + img.hidden = true; + img.closest('a').querySelector('small').textContent = 'QR unavailable · open link ↗'; + })); + mountAppControls(document.querySelector(".screen-app-controls")); + startSlideshow(safeImages.length); + startActivity(startQRTheatre()); +} + +function startQRTheatre() { + const stage = document.querySelector('.stage'), actions = document.querySelector('.screen-actions'); + const cards = [...actions.querySelectorAll('.screen-qr')], choices = [...actions.querySelectorAll('[data-qr-choice]')]; + const pause = actions.querySelector('.qr-pause'), motion = matchMedia('(prefers-reduced-motion: reduce)'); + let current = 0, timer, paused = false, active = false; + const choose = index => { + current = index; + cards.forEach((card, i) => card.classList.toggle('is-current', i === current)); + choices.forEach((button, i) => button.setAttribute('aria-pressed', String(i === current))); + }; + const sync = () => { + clearTimeout(timer); + const running = !active && !motion.matches && !paused && !actions.contains(document.activeElement); + stage.dataset.rotation = running ? 'running' : 'paused'; + if (running) timer = setTimeout(() => {choose((current + 1) % cards.length); sync();}, 12000); + }; + choices.forEach((button, i) => button.addEventListener('click', () => {choose(i); sync();})); + pause.addEventListener('click', () => { + paused = !paused; + pause.setAttribute('aria-pressed', String(paused)); + pause.textContent = paused ? 'Resume rotation' : 'Pause rotation'; + sync(); + }); + actions.addEventListener('focusin', sync); + actions.addEventListener('focusout', () => queueMicrotask(sync)); + motion.addEventListener('change', () => { + if (motion.matches && actions.querySelector('.qr-controls').contains(document.activeElement)) cards[current].focus(); + // When leaving reduced motion, retain whichever of the three links has focus. + const focused = cards.indexOf(document.activeElement); + if (focused !== -1) choose(focused); + sync(); + }); + return count => { + const nextActive = count > 0; + // Keep keyboard focus on a usable link when a control leaves the layout. + if (nextActive && actions.querySelector('.qr-controls').contains(document.activeElement)) cards[current].focus(); + if (!nextActive && active) { + const focused = cards.indexOf(document.activeElement); + if (focused !== -1) choose(focused); + } + stage.dataset.view = nextActive ? 'active' : 'idle'; + stage.dataset.activityCount = String(count); + if (nextActive !== active || !stage.dataset.rotation) {active = nextActive; sync();} + }; +} + +function startSlideshow(count) { + let current = 0; + const slides = [...document.querySelectorAll('.slide')], dots = [...document.querySelectorAll('.progress i')]; + const clock = document.querySelector('#clock'); + const tick = () => {const date = new Date(); clock.textContent = date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); clock.dateTime = date.toISOString();}; + tick(); + setInterval(tick, 10000); + if (count > 1) setInterval(() => { + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + slides[current].classList.remove('active'); dots[current].classList.remove('active'); + current = (current + 1) % count; + slides[current].classList.add('active'); dots[current].classList.add('active'); + }, 9000); +} + +function startActivity(setView) { + const list = document.querySelector('#activity-items'), connection = document.querySelector('.feed-connection'); + const announcement = document.querySelector('.activity-announcement'); + let events = [], lastHTML = '', connected = false, tipSource = false; + let expiryTimer; + let serverTime = Date.now(), receivedAt = performance.now(); + const draw = () => { + const current = serverTime + performance.now() - receivedAt; + events = events.filter(item => Date.parse(item.expires_at) > current); + setView(Math.min(events.length, 4)); + clearTimeout(expiryTimer); + if (events.length) expiryTimer = setTimeout(draw, Math.max(1, Math.ceil(Math.min(...events.map(item => Date.parse(item.expires_at))) - current))); + const html = events.slice(0, 4).map(item => { + const order = item.type === 'order', tip = item.type === 'tip'; + const title = order ? `Table ${item.table}` : tip ? 'Thank you for the tip!' : 'JustPay received'; + const details = order ? (item.items?.length ? item.items.map(product => `${product.qty} × ${product.name}`).join(' · ') : 'New order · details with the team') : tip ? `${Number(item.amount_sats).toLocaleString()} sats` : new Intl.NumberFormat('en', {style:'currency',currency:'EUR'}).format(item.amount_cents / 100); + const remaining = Math.max(0, Date.parse(item.expires_at) - current); + return `
${order ? 'Order to table' : tip ? 'Nostr tip' : 'Payment'}${order ? item.status === 'accepted' ? 'Preparing' : 'New' : 'Received'}

${esc(title)}

${esc(details)}

`; + }).join(''); + // Avoid repeat live-region announcements on every timer tick. + const signature = events.slice(0, 4).map(event => JSON.stringify(event)).join('|') + connected; + if (signature !== lastHTML) { + list.innerHTML = html; lastHTML = signature; + // This live region stays in the accessibility tree even while the empty + // visual feed is hidden, so the first incoming activity is announced too. + announcement.textContent = events.length ? [...list.children].map(card => card.innerText).join('. ') : 'No recent activity. Scan to order, pay or tip.'; + } + connection.textContent = connected ? tipSource ? 'Live' : 'Live · tips reconnecting' : 'Reconnecting'; + connection.classList.toggle('offline', !connected); + }; + const poll = async () => { + const requestedAt = performance.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6500); + try { + const response = await fetch(`${apiBase}/api/screen-activity?npub=${encodeURIComponent(npub)}`, {cache: 'no-store', signal: controller.signal}); + if (!response.ok) throw new Error('Activity unavailable'); + const data = await response.json(); + const reference = Date.parse(data.server_time); + if (!Number.isFinite(reference) || !Array.isArray(data.events)) throw new Error('Invalid activity response'); + receivedAt = performance.now(); + // Conservatively include request travel time; latency must not extend the TTL. + serverTime = reference + receivedAt - requestedAt; + events = data.events.filter(item => ['order', 'justpay', 'tip'].includes(item.type) && + Date.parse(item.occurred_at) <= reference && Date.parse(item.expires_at) > reference && + Date.parse(item.expires_at) - Date.parse(item.occurred_at) <= 120000 && + (item.type !== 'order' || ['new', 'accepted'].includes(item.status))); + connected = true; tipSource = data.tips_available === true; + } catch { + // A completed order may have disappeared while offline. Do not keep stale tickets. + events = []; connected = false; + } finally { + clearTimeout(timeout); draw(); setTimeout(poll, 4000); + } + }; + poll(); +} + +if (!npub) error('Add the merchant npub to start.'); +else fetch(`${apiBase}/api/screen?npub=${encodeURIComponent(npub)}`, {cache: 'no-store'}) + .then(async response => {const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Merchant unavailable'); return data;}) + .then(render).catch(failure => error(failure.message)); diff --git a/docker/justworks/business/public/assets/shell.js b/docker/justworks/business/public/assets/shell.js new file mode 100644 index 00000000..f0c5cd65 --- /dev/null +++ b/docker/justworks/business/public/assets/shell.js @@ -0,0 +1,9 @@ +const prefix=location.pathname.split('/').slice(0,-1).join('/'); +const token=new URLSearchParams(location.search).get('table')||''; +const stage=document.querySelector('#stage'); +const pending=new Map();let frame; +function injectedShim(config){return `
⌁

Opening table booking…

diff --git a/docker/justworks/business/public/business-sso.html b/docker/justworks/business/public/business-sso.html new file mode 100644 index 00000000..af156584 --- /dev/null +++ b/docker/justworks/business/public/business-sso.html @@ -0,0 +1,3 @@ + +Connect Business session · Just Works +

Connect your Business session

Continue with your existing Just Works identity. Your private key and Core credentials are not shared with the destination.

Business access

Checking your session…

diff --git a/docker/justworks/business/public/dashboard.html b/docker/justworks/business/public/dashboard.html new file mode 100644 index 00000000..cbc65ac9 --- /dev/null +++ b/docker/justworks/business/public/dashboard.html @@ -0,0 +1 @@ +Just Works Business
⌁

Opening merchant business…

Saved
diff --git a/docker/justworks/business/public/fonts/geist-latin-ext-wght-normal.woff2 b/docker/justworks/business/public/fonts/geist-latin-ext-wght-normal.woff2 new file mode 100644 index 00000000..ba90e209 Binary files /dev/null and b/docker/justworks/business/public/fonts/geist-latin-ext-wght-normal.woff2 differ diff --git a/docker/justworks/business/public/fonts/geist-latin-wght-normal.woff2 b/docker/justworks/business/public/fonts/geist-latin-wght-normal.woff2 new file mode 100644 index 00000000..991445d7 Binary files /dev/null and b/docker/justworks/business/public/fonts/geist-latin-wght-normal.woff2 differ diff --git a/docker/justworks/business/public/fonts/syne-latin-wght-normal.woff2 b/docker/justworks/business/public/fonts/syne-latin-wght-normal.woff2 new file mode 100644 index 00000000..e311600b Binary files /dev/null and b/docker/justworks/business/public/fonts/syne-latin-wght-normal.woff2 differ diff --git a/docker/justworks/business/public/gallery.html b/docker/justworks/business/public/gallery.html new file mode 100644 index 00000000..87fe0d78 --- /dev/null +++ b/docker/justworks/business/public/gallery.html @@ -0,0 +1 @@ +Just Works Gallery — Madeira
⌕
Loading places…Real merchants from Just Works Maps
diff --git a/docker/justworks/business/public/nsite.html b/docker/justworks/business/public/nsite.html new file mode 100644 index 00000000..f16e443a --- /dev/null +++ b/docker/justworks/business/public/nsite.html @@ -0,0 +1,12 @@ +LocalPub Madeira — Just Works + +
Madeira · Local venue

LocalPub
Madeira

Poncha, people, and a page the business owns. Order from your table without waiting for the waiter.

Good drinks.
No platform lock-in.

The menu and story remain part of the merchant’s sovereign page. Interactive services arrive as isolated napplets and disappear cleanly when disabled.

Order to Table

Sandboxed NIP-5D napplet
Loading merchant configuration…
LocalPub MadeiraBuilt with Just Works · Payments optional · Ownership isn’t
diff --git a/docker/justworks/business/public/order.html b/docker/justworks/business/public/order.html new file mode 100644 index 00000000..703114da --- /dev/null +++ b/docker/justworks/business/public/order.html @@ -0,0 +1 @@ +Order with Just Works
⌁

Opening merchant order…

diff --git a/docker/justworks/business/public/page.html b/docker/justworks/business/public/page.html new file mode 100644 index 00000000..99ac3f33 --- /dev/null +++ b/docker/justworks/business/public/page.html @@ -0,0 +1 @@ +Merchant page — Just Works
diff --git a/docker/justworks/business/public/portal.html b/docker/justworks/business/public/portal.html new file mode 100644 index 00000000..7bc8e4fe --- /dev/null +++ b/docker/justworks/business/public/portal.html @@ -0,0 +1 @@ +Just Works
diff --git a/docker/justworks/business/public/pos-sw.js b/docker/justworks/business/public/pos-sw.js new file mode 100644 index 00000000..ed597779 --- /dev/null +++ b/docker/justworks/business/public/pos-sw.js @@ -0,0 +1,2 @@ +self.APP = 'pos'; +importScripts('/assets/pwa/worker.js?v=1'); diff --git a/docker/justworks/business/public/pos.html b/docker/justworks/business/public/pos.html new file mode 100644 index 00000000..a1ace65b --- /dev/null +++ b/docker/justworks/business/public/pos.html @@ -0,0 +1 @@ +Just Works POS
⌁

Connecting merchant POS…

diff --git a/docker/justworks/business/public/pos.webmanifest b/docker/justworks/business/public/pos.webmanifest new file mode 100644 index 00000000..f18a900f --- /dev/null +++ b/docker/justworks/business/public/pos.webmanifest @@ -0,0 +1 @@ +{"id":"/?app=pos","name":"Just Works POS","short_name":"JW POS","description":"Orders, payments and bookings for your merchant.","start_url":"/","scope":"/","display":"standalone","display_override":["fullscreen","standalone"],"background_color":"#080909","theme_color":"#080909","icons":[{"src":"/assets/pwa/pos-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/assets/pwa/pos-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]} diff --git a/docker/justworks/business/public/profile.html b/docker/justworks/business/public/profile.html new file mode 100644 index 00000000..baba23f5 --- /dev/null +++ b/docker/justworks/business/public/profile.html @@ -0,0 +1 @@ +Business profile · Just Works
⌁

Opening business profile…

diff --git a/docker/justworks/business/public/qr-sheet.html b/docker/justworks/business/public/qr-sheet.html new file mode 100644 index 00000000..bd885fcd --- /dev/null +++ b/docker/justworks/business/public/qr-sheet.html @@ -0,0 +1,26 @@ + + + + + + + + QR in DIN A4 · Just Works + + + + + +

Opening merchant QR sheet…

+
+

Just Works · Quick access

Scan. Open. Just works.

+
+
JUST WORKSPublic links · No login credentials in these codesA4 · 210 × 297 mm
+
+ + + diff --git a/docker/justworks/business/public/screen-sw.js b/docker/justworks/business/public/screen-sw.js new file mode 100644 index 00000000..1a1e4e2d --- /dev/null +++ b/docker/justworks/business/public/screen-sw.js @@ -0,0 +1,2 @@ +self.APP = 'screen'; +importScripts('/assets/pwa/worker.js?v=1'); diff --git a/docker/justworks/business/public/screen.html b/docker/justworks/business/public/screen.html new file mode 100644 index 00000000..54530cd0 --- /dev/null +++ b/docker/justworks/business/public/screen.html @@ -0,0 +1 @@ +Just Works Screen
⌁

Loading merchant screen…

diff --git a/docker/justworks/business/public/screen.webmanifest b/docker/justworks/business/public/screen.webmanifest new file mode 100644 index 00000000..14f20078 --- /dev/null +++ b/docker/justworks/business/public/screen.webmanifest @@ -0,0 +1 @@ +{"id":"/?app=screen","name":"Just Works Screen","short_name":"JW Screen","description":"Your merchant’s live display and quick-access QR codes.","start_url":"/","scope":"/","display":"standalone","display_override":["fullscreen","standalone"],"background_color":"#050606","theme_color":"#050606","icons":[{"src":"/assets/pwa/screen-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/assets/pwa/screen-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]} diff --git a/docker/justworks/business/public/shell.html b/docker/justworks/business/public/shell.html new file mode 100644 index 00000000..29a329ca --- /dev/null +++ b/docker/justworks/business/public/shell.html @@ -0,0 +1 @@ +JustWorks Napplet Shell
Loading signed napplet artifact…
diff --git a/docker/justworks/business/public/site.html b/docker/justworks/business/public/site.html new file mode 100644 index 00000000..63f56ddc --- /dev/null +++ b/docker/justworks/business/public/site.html @@ -0,0 +1 @@ +Merchant site — Just Works
diff --git a/docker/justworks/business/public/stats.html b/docker/justworks/business/public/stats.html new file mode 100644 index 00000000..fd7f562b --- /dev/null +++ b/docker/justworks/business/public/stats.html @@ -0,0 +1,22 @@ + + + + + + + + Stats · Just Works Business + + + + + +
+
+ ∩ +

Reading this merchant’s signals…

+
+
+ + + diff --git a/docker/justworks/business/screen_activity.py b/docker/justworks/business/screen_activity.py new file mode 100644 index 00000000..9a1465b2 --- /dev/null +++ b/docker/justworks/business/screen_activity.py @@ -0,0 +1,111 @@ +"""Small public signage projection. No staff data, customer text or payment proofs.""" +import json +import threading +import time +from collections import OrderedDict +from datetime import datetime, timezone + +TTL = 120 +_contexts = OrderedDict() +_lock = threading.Lock() +_workers = threading.BoundedSemaphore(2) + + +def timestamp(value): + try: + parsed = datetime.fromisoformat(str(value).replace('Z', '+00:00')) + if parsed.tzinfo is None: + return None + return parsed.timestamp() + except (ValueError, TypeError, OverflowError): + return None + + +def iso(value): + return datetime.fromtimestamp(value, timezone.utc).isoformat(timespec='seconds') + + +def configure(npub, nsite): + """Only called with server-resolved Core metadata, never client-supplied labels.""" + sections = nsite.get('page', {}).get('sections', []) + menu = next((s for s in sections if s.get('type') == 'menu' and s.get('enabled')), {}) + catalog = {str(item.get('id') or f'item-{i}')[:40]: str(item.get('title') or item.get('name') or 'Item')[:80] + for i, item in enumerate(menu.get('items', [])[:50])} + identity = nsite.get('identity', {}) + lud16 = str(identity.get('profile', {}).get('lud16') or identity.get('npubCashAddress') or f'{npub}@npub.cash').strip() + with _lock: + context = _contexts.setdefault(npub, {'tips': [], 'checked': 0, 'busy': False, 'available': False}) + if context.get('lud16') != lud16: + context.update(tips=[], checked=0, available=False) + context.update(catalog=catalog, lud16=lud16) + _contexts.move_to_end(npub) + while len(_contexts) > 64: + _contexts.popitem(last=False) + + +def tips(npub, load, current): + """Network work stays off the polling path; bounded concurrency/cache, fail closed.""" + with _lock: + context = _contexts.get(npub) + if not context: + return [], False + if not context['busy'] and time.monotonic() - context['checked'] >= 15 and _workers.acquire(blocking=False): + context['busy'] = True + lud16 = context['lud16'] + + def refresh(): + try: + result = load(npub, lud16) + with _lock: + if _contexts.get(npub) is context and context['lud16'] == lud16: + context.update(tips=result[:30], available=True) + except Exception: + with _lock: + context.update(tips=[], available=False) + finally: + with _lock: + context.update(checked=time.monotonic(), busy=False) + _workers.release() + + threading.Thread(target=refresh, daemon=True).start() + return list(context['tips']), context['available'] + + +def projection(conn, npub, load_tips, current=None): + current = time.time() if current is None else current + cutoff = iso(current - TTL) + events = [] + + def event(kind, stamp, **fields): + occurred = timestamp(stamp) + if occurred is not None and current - TTL < occurred <= current: + events.append({'type': kind, 'occurred_at': iso(occurred), 'expires_at': iso(occurred + TTL), **fields}) + + with _lock: + catalog = dict(_contexts.get(npub, {}).get('catalog', {})) + catalog.update({row['id']: row['name'][:80] for row in conn.execute( + 'SELECT id,name FROM products WHERE merchant_id=? AND enabled=1', (npub,))}) + for row in conn.execute("SELECT table_number,items_json,status,created_at FROM orders WHERE merchant_id=? AND status IN ('new','accepted') AND created_at>? ORDER BY created_at DESC LIMIT 30", (npub, cutoff)): + try: + raw = json.loads(row['items_json']) + raw = raw if isinstance(raw, list) else [] + except (ValueError, TypeError): + raw = [] + items = [] + for item in raw[:30]: + if not isinstance(item, dict) or not isinstance(item.get('id'), str) or item['id'] == 'request' or item['id'] not in catalog: + continue + qty = item.get('qty') + if isinstance(qty, int) and not isinstance(qty, bool) and 0 < qty <= 20: + items.append({'name': catalog[item['id']], 'qty': qty}) + event('order', row['created_at'], table=row['table_number'], items=items[:4], status=row['status']) + for row in conn.execute("SELECT amount_cents,amount_sats,settled_at FROM quick_payments WHERE merchant_id=? AND status='paid' AND settled_at>? ORDER BY settled_at DESC LIMIT 30", (npub, cutoff)): + event('justpay', row['settled_at'], amount_cents=row['amount_cents'], amount_sats=row['amount_sats']) + recent_tips, available = tips(npub, load_tips, current) + for tip in recent_tips: + # Explicit allowlist even for our own helper; no receipt ids or payer identities. + sats = tip.get('sats') + if isinstance(sats, int) and not isinstance(sats, bool) and sats > 0: + event('tip', tip.get('occurred_at'), amount_sats=sats) + events.sort(key=lambda item: item['occurred_at'], reverse=True) + return {'events': events[:30], 'server_time': iso(current), 'ttl_seconds': TTL, 'tips_available': available} diff --git a/docker/justworks/business/server.py b/docker/justworks/business/server.py new file mode 100644 index 00000000..a6a6829b --- /dev/null +++ b/docker/justworks/business/server.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python3 +import json +import re +import ipaddress +import mimetypes +import os +import secrets +import socket +import sqlite3 +import subprocess +import threading +import business_security as security +import business_rollout as rollout +import screen_activity +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from http.cookies import CookieError +from pathlib import Path +from urllib.parse import parse_qs, urlparse +from urllib.parse import urlencode +from urllib.error import HTTPError +from urllib.request import Request, urlopen, build_opener, HTTPRedirectHandler +from urllib.parse import quote + +ROOT = Path(__file__).resolve().parent +PUBLIC = ROOT / "public" +NAPLET_DIST = ROOT / "napplets/order-to-table/dist" +DATA_DIR = Path(os.environ.get("JWB_DATA_DIR", ROOT / "data")) +DB_FILE = DATA_DIR / "business.sqlite3" +HOST = os.environ.get("JWB_HOST", "127.0.0.1") +PORT = int(os.environ.get("JWB_PORT", "4310")) +BASE = os.environ.get("JWB_BASE_PATH", "/business-demo").rstrip("/") +LOCK = threading.RLock() +PAYMENT_HELPER = ROOT / "payment-helper.cjs" +PAYMENT_RELAYS = ["wss://relay.primal.net", "wss://nos.lol"] +PAYMENT_RECIPIENT = "f81611363554b64306467234d7396ec88455707633f54738f6c4683535098cd3" +LNURL_URL = "https://primal.net/.well-known/lnurlp/fucking" +DEV_LIGHTNING_ADDRESS = "fucking@primal.net" # dev stand-in matching LNURL_URL, used when a merchant has no real lud16 +LNURL_CALLBACK = "https://primal.net/lnurlp/fucking/callback" +MODULE_DEFAULTS = { + "order": (1, "live"), "pay": (1, "live"), "payandgo": (1, "preview"), + "booking": (1, "live"), "screen": (1, "preview"), + "gallery": (1, "preview"), "stats": (1, "live"), "events": (0, "coming-soon"), +} + + +def now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def db(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_FILE, timeout=10) + conn.row_factory = sqlite3.Row + security.migrate(conn) + conn.execute("PRAGMA journal_mode=WAL") + conn.executescript(""" + CREATE TABLE IF NOT EXISTS merchant ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, module_enabled INTEGER NOT NULL, + table_count INTEGER NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS products ( + id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, name TEXT NOT NULL, + price_cents INTEGER NOT NULL, enabled INTEGER NOT NULL, sort_order INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS tables ( + merchant_id TEXT NOT NULL, table_number INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, + PRIMARY KEY(merchant_id, table_number) + ); + CREATE TABLE IF NOT EXISTS orders ( + id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, table_number INTEGER NOT NULL, + items_json TEXT NOT NULL, note TEXT NOT NULL, status TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, order_id TEXT NOT NULL, merchant_id TEXT NOT NULL, + table_number INTEGER NOT NULL, amount_cents INTEGER NOT NULL, + amount_sats INTEGER NOT NULL, bolt11 TEXT NOT NULL, zap_pubkey TEXT NOT NULL, + status TEXT NOT NULL, receipt_id TEXT, created_at TEXT NOT NULL, settled_at TEXT + ); + CREATE TABLE IF NOT EXISTS quick_payments ( + id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, merchant_name TEXT NOT NULL, + amount_cents INTEGER NOT NULL, amount_sats INTEGER NOT NULL, bolt11 TEXT NOT NULL, + zap_pubkey TEXT, recipient TEXT, status TEXT NOT NULL, receipt_id TEXT, + created_at TEXT NOT NULL, settled_at TEXT + ); + CREATE TABLE IF NOT EXISTS bookings ( + id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, guest_name TEXT NOT NULL, + guests INTEGER NOT NULL, scheduled_at TEXT NOT NULL, mode TEXT NOT NULL, + notes TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS merchant_modules ( + merchant_id TEXT NOT NULL, module_id TEXT NOT NULL, enabled INTEGER NOT NULL, + status TEXT NOT NULL, config_json TEXT NOT NULL, updated_at TEXT NOT NULL, + PRIMARY KEY(merchant_id,module_id) + ); + """) + legacy = rollout.legacy_merchant(conn) + conn.execute("INSERT OR IGNORE INTO merchant VALUES (?,'LocalPub Madeira — Napplet Demo',1,30,?)", (legacy, now())) + defaults = [("poncha", "Poncha Fisherman", 350, 1), ("nikita", "Nikita", 450, 2), ("coral", "Coral", 250, 3)] + for pid, name, cents, order in defaults: + conn.execute("INSERT OR IGNORE INTO products VALUES (?, ?, ?, ?, 1, ?)", (pid, legacy, name, cents, order)) + for table in range(1, 31): + conn.execute("INSERT OR IGNORE INTO tables VALUES (?, ?, ?)", (legacy, table, secrets.token_urlsafe(18))) + for module_id, (enabled, status) in MODULE_DEFAULTS.items(): + conn.execute("INSERT OR IGNORE INTO merchant_modules VALUES (?,?,?,?,?,?)", + (legacy, module_id, enabled, status, '{}', now())) + conn.execute("UPDATE merchant_modules SET status='live' WHERE module_id='booking' AND status='preview'") + conn.commit() + return conn + + +def helper(payload): + result = subprocess.run(["node", str(PAYMENT_HELPER)], input=json.dumps(payload), text=True, capture_output=True, timeout=12, check=True) + return json.loads(result.stdout) + + +def fetch_json(url): + request = Request(url, headers={"User-Agent": "JustWorks-Business/0.1"}) + with urlopen(request, timeout=10) as response: + return json.loads(response.read()) + + +def screen_tips(npub, lightning_address): + """Read-only LNURL metadata + signed NIP-57 announcements; no invoice creation.""" + if lightning_address.count('@') != 1: + raise ValueError('invalid Lightning address') + name, host = lightning_address.split('@') + if not name or not host or any(char in host for char in '/?#:@'): + raise ValueError('invalid Lightning address') + endpoint = assert_public_https(f'https://{host}/.well-known/lnurlp/{quote(name, safe="")}') + + class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + raise ValueError('LNURL redirect is not permitted for screen metadata') + + with build_opener(NoRedirect).open(Request(endpoint, headers={'User-Agent': 'JustWorks-Screen/1'}), timeout=4) as response: + raw = response.read(65537) + if len(raw) > 65536: + raise ValueError('LNURL metadata too large') + pay = json.loads(raw) + provider = pay.get('nostrPubkey') + if pay.get('tag') != 'payRequest' or pay.get('allowsNostr') is not True or not isinstance(provider, str) or len(provider) != 64 or any(c not in '0123456789abcdef' for c in provider): + raise ValueError('No verified Nostr receipt source') + return helper({'action': 'screen-tips', 'npub': npub, 'provider': provider, 'relays': PAYMENT_RELAYS})['tips'] + + +def assert_public_https(url): + parsed = urlparse(url) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("Lightning provider returned an unsafe URL") + addresses = socket.getaddrinfo(parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM) + if not addresses or any(not ipaddress.ip_address(item[4][0]).is_global for item in addresses): + raise ValueError("Lightning provider must use a public host") + return url + + +def lightning_invoice(lightning_address, amount_msat, comment=""): + if "@" not in lightning_address: + raise ValueError("Merchant Lightning address is invalid") + name, host = lightning_address.rsplit("@", 1) + if not name or not host or any(char in host for char in "/?#"): + raise ValueError("Merchant Lightning address is invalid") + endpoint = assert_public_https(f"https://{host}/.well-known/lnurlp/{name}") + pay = fetch_json(endpoint) + if pay.get("tag") != "payRequest" or not pay.get("callback"): + raise ValueError("Lightning address does not support payments") + minimum, maximum = int(pay.get("minSendable", 0)), int(pay.get("maxSendable", 0)) + if amount_msat < minimum or (maximum and amount_msat > maximum): + raise ValueError(f"Amount must be between {max(1, minimum // 1000)} and {maximum // 1000} sats") + callback = assert_public_https(str(pay["callback"])) + # Some LNURL providers reject otherwise valid NIP-57 requests when their + # signed content contains a literal percent sign. Keep the human meaning + # while using a provider-safe comment for both the zap and callback. + safe_comment = str(comment or "").replace("%", " percent").strip() + params = {"amount": amount_msat} + proof = None + recipient = str(pay.get("nostrPubkey") or "").strip() + if pay.get("allowsNostr") is True and len(recipient) == 64: + proof = helper({"action": "create", "lnurlUrl": endpoint, "relays": PAYMENT_RELAYS, + "amountMsat": amount_msat, "recipient": recipient, + "content": safe_comment or "Just Works payment"}) + params["nostr"] = json.dumps(proof["event"], separators=(",", ":")) + params["lnurl"] = proof["lnurl"] + if safe_comment and int(pay.get("commentAllowed", 0)): + params["comment"] = safe_comment[:int(pay["commentAllowed"])] + separator = "&" if "?" in callback else "?" + try: + invoice = fetch_json(f"{callback}{separator}{urlencode(params)}") + except HTTPError: + if not proof: + raise + # Invoice creation is the primary action. If a provider advertises + # NIP-57 but fails its zap path, retry as standard LNURL-pay and mark + # the result non-verifiable instead of blocking the customer. + proof = None + fallback = {key: value for key, value in params.items() if key not in {"nostr", "lnurl"}} + invoice = fetch_json(f"{callback}{separator}{urlencode(fallback)}") + if invoice.get("status") == "ERROR" or not invoice.get("pr"): + raise ValueError(invoice.get("reason", "Lightning invoice unavailable")) + return {"bolt11": invoice["pr"], "zap_pubkey": proof["pubkey"] if proof else None, + "recipient": recipient if proof else None, "verifiable": bool(proof)} + + +def remote_json(url, method="GET", payload=None, timeout=60): + data = json.dumps(payload).encode() if payload is not None else None + request = Request(url, data=data, method=method, headers={"User-Agent": "JustWorks-Business/0.1", "Content-Type": "application/json"}) + try: + with urlopen(request, timeout=timeout) as response: + return json.loads(response.read()) + except HTTPError as error: + try: message = json.loads(error.read()).get("error") + except Exception: message = None + raise ValueError(message or f"Just Works profile request failed ({error.code})") from error + + +def resolve_merchant_npub(npub): + npub = security.canonical_npub(npub) + # Core resolves every published website; the regional directory is optional + # presentation metadata, never a merchant allowlist or ownership proof. + try: + nsite = fetch_json(f"https://justworks.cash/api/nsites/{npub}").get("nsite", {}) + except HTTPError as error: + if error.code == 404: return None, None + raise + slug = nsite.get("id", "") + if (nsite.get("npub") != npub or not isinstance(slug, str) + or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug) + or nsite.get("nsite", {}).get("deploymentStatus") != "published"): + return None, None + place = {"npub": npub, "slug": slug, "name": nsite.get("name") or slug, + "justworks": f"https://justworks.cash/{slug}"} + return place, nsite + + +def merchant_payload(conn, include_tokens=False): + merchant = dict(conn.execute("SELECT * FROM merchant WHERE id=?", (rollout.legacy_merchant(conn),)).fetchone()) + merchant["module_enabled"] = bool(merchant["module_enabled"]) + merchant["products"] = [dict(row) for row in conn.execute("SELECT id,name,price_cents,enabled,sort_order FROM products WHERE merchant_id=? ORDER BY sort_order", (merchant["id"],))] + for product in merchant["products"]: + product["enabled"] = bool(product["enabled"]) + merchant["modules"] = {} + for row in conn.execute("SELECT module_id,enabled,status,config_json FROM merchant_modules WHERE merchant_id=?", (merchant["id"],)): + merchant["modules"][row["module_id"]] = {"enabled": bool(row["enabled"]), "status": row["status"], "config": json.loads(row["config_json"] or '{}')} + if include_tokens: + merchant["tables"] = [dict(row) for row in conn.execute("SELECT table_number,token FROM tables WHERE merchant_id=? ORDER BY table_number", (merchant["id"],))] + return merchant + + +def merchant_modules(conn, merchant_id): + for module_id, (enabled, status) in MODULE_DEFAULTS.items(): + conn.execute("INSERT OR IGNORE INTO merchant_modules VALUES (?,?,?,?,?,?)", + (merchant_id, module_id, enabled, status, '{}', now())) + conn.commit() + return {row["module_id"]: {"enabled": bool(row["enabled"]), "status": row["status"], "config": json.loads(row["config_json"] or '{}')} + for row in conn.execute("SELECT module_id,enabled,status,config_json FROM merchant_modules WHERE merchant_id=?", (merchant_id,))} + + +class Handler(BaseHTTPRequestHandler): + server_version = "JustWorksBusiness/0.1" + + def log_message(self, fmt, *args): + # Query strings may contain table tokens. Do not log request lines or bodies. + print(json.dumps({"ts": now(), "method": self.command, "status": str(args[1]) if len(args) > 1 else ""})) + + def send_json(self, status, value): + mutation = getattr(self, "authorized_mutation", None) + if mutation and 200 <= status < 300: + with LOCK, db() as conn: + security.audit(conn, "mutation", self.route()[0], *mutation) + self.authorized_mutation = None + raw = json.dumps(value, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + if getattr(self, "session_cookie", None): + self.send_header("Set-Cookie", self.session_cookie) + if getattr(self, "exchange_cookie", None): + self.send_header("Set-Cookie", self.exchange_cookie) + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(raw) + + def redirect(self, location): + self.send_response(302) + self.send_header("Location", location) + self.send_header("Cache-Control", "public,max-age=3600") + self.end_headers() + + def body(self): + size = int(self.headers.get("Content-Length", "0")) + if size < 0 or size > 64 * 1024: + raise ValueError("body too large") + value = json.loads(self.rfile.read(size) or b"{}") + if not isinstance(value, dict): raise ValueError("JSON object required") + return value + + def route(self): + parsed = urlparse(self.path) + path = parsed.path + if BASE and path.startswith(BASE): + path = path[len(BASE):] or "/" + return path, parse_qs(parsed.query) + + def surface(self): + host = self.headers.get("Host", "").split(":", 1)[0].lower() + return host.removesuffix(".justworks.cash") if host.endswith(".justworks.cash") else "" + + def is_local(self): + if os.environ.get("JWB_ARCHIPELAGO") == "1": return False + # Anything not served behind the production subdomains is a dev server. + host = self.headers.get("Host", "").split(":", 1)[0].lower() + return not host.endswith(".justworks.cash") + + def static(self, file, cache="no-store"): + file = file.resolve() + allowed = [PUBLIC.resolve(), NAPLET_DIST.resolve()] + if not any(file == root or root in file.parents for root in allowed) or not file.is_file(): + self.send_error(404) + return + raw = file.read_bytes() + if os.environ.get("JWB_ARCHIPELAGO") == "1" and file.suffix == ".html": + html = raw.decode().replace('"/assets/', '"/business-demo/assets/').replace('"/fonts/', '"/business-demo/fonts/') + html = html.replace('', '', 1) + raw = html.encode() + mime = mimetypes.guess_type(file.name)[0] or "application/octet-stream" + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", cache) + self.send_header("X-Content-Type-Options", "nosniff") + if file.name == "business-sso.html": + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + self.end_headers() + self.wfile.write(raw) + + def nsite(self): + file = PUBLIC / "nsite.html" + html = file.read_text() + html = html.replace("", f'') + raw = html.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(raw) + + def app_manifest(self, app, query): + manifest = json.loads((PUBLIC / f"{app}.webmanifest").read_text()) + npub = (query.get("npub") or [""])[0] + if npub: + try: npub = security.canonical_npub(npub) + except ValueError: return self.send_json(400, {"error": "invalid npub"}) + manifest["start_url"] = f"/?npub={npub}" + manifest["id"] += f"&npub={npub}" + raw = json.dumps(manifest, separators=(",", ":")).encode() + self.send_response(200) + self.send_header("Content-Type", "application/manifest+json") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + self.session_cookie = self.exchange_cookie = self.authorized_mutation = None + path, query = self.route() + if path == "/api/business-session" or path in security.PRIVILEGED_GET: + with LOCK, db() as conn: + if path == "/api/business-session": + current = security.session(conn, self.headers) + return self.send_json(200, {"authenticated": bool(current), "session": current, "mode": security.mode(), "sso_enabled": rollout.enabled() and ('https://' + self.headers.get('Host', '').lower()) in rollout.origins(), "legacy_npub": rollout.legacy_merchant(conn) if rollout.legacy_merchant(conn) != 'localpub-demo' else None}) + allowed, _ = security.authorize(conn, "GET", path, {key: value[0] for key, value in query.items()}, self.headers) + if not allowed: return self.send_json(403, {"error": "Merchant session required for this account"}) + if path == "/healthz": return self.send_json(200, {"ok": True}) + if path == "/api/config": + with LOCK, db() as conn: return self.send_json(200, merchant_payload(conn)) + if path == "/api/dashboard": + with LOCK, db() as conn: + payload = merchant_payload(conn, True) + payload["orders"] = [dict(row) | {"items": json.loads(row["items_json"])} for row in conn.execute("SELECT * FROM orders WHERE merchant_id=? AND status NOT IN ('delivered','closed') ORDER BY created_at DESC LIMIT 50", (rollout.legacy_merchant(conn),))] + for item in payload["orders"]: item.pop("items_json", None) + payload["payments"] = [dict(row) for row in conn.execute("SELECT id,order_id,table_number,amount_cents,amount_sats,status,created_at,settled_at FROM payments WHERE merchant_id=? AND status='paid' ORDER BY settled_at DESC LIMIT 20", (rollout.legacy_merchant(conn),))] + return self.send_json(200, payload) + if path == "/api/business": + npub = (query.get("npub") or [""])[0].strip() + try: + place, nsite = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + with LOCK, db() as conn: + modules = merchant_modules(conn, npub) + counts = { + "orders": conn.execute("SELECT COUNT(*) FROM orders WHERE merchant_id=? AND status!='closed'", (npub,)).fetchone()[0], + "payments": conn.execute("SELECT COUNT(*) FROM payments WHERE merchant_id=? AND status NOT IN ('acknowledged')", (npub,)).fetchone()[0], + "bookings": conn.execute("SELECT COUNT(*) FROM bookings WHERE merchant_id=? AND status NOT IN ('seated','closed','cancelled')", (npub,)).fetchone()[0], + } + current = security.session(conn, self.headers) + if rollout.effective_mode(conn, "GET", "/api/business", npub) == "enforce" and (not current or current["npub"] != npub): + counts = {key: 0 for key in counts} + images = list(dict.fromkeys([nsite.get("media", {}).get("heroImage"), *nsite.get("media", {}).get("galleryImages", []), *place.get("images", [])])) + images = [image for image in images if image and not any(marker in image.lower() for marker in ("logo", "icon", "static.cdninstagram.com"))] + merchant_name = nsite.get("name") or place.get("name") + query_string = urlencode({"q": merchant_name}) + map_query_string = urlencode({"q": merchant_name, "place": place.get("slug")}) + links = { + "justworks": place.get("justworks") or f"https://justworks.cash/{place.get('slug')}", + "tip": f"https://justworks.cash/tip/?{urlencode({'id': place.get('slug')})}", + "nsite": nsite.get("nsite", {}).get("gatewayUrl"), + "nostr": nsite.get("identity", {}).get("primalUrl") or f"https://njump.me/{npub}", + "maps": f"https://maps.justworks.cash/?{map_query_string}", + "gallery": f"https://gallery.justworks.cash/?{query_string}", + } + if self.is_local(): + # Dev: keep merchants on local surfaces — /page renders the + # platform-hosted Just Works page, /site renders the nsite + # copy the product generates and publishes to Nostr. + links["justworks"] = f"{BASE}/page?npub={npub}" + links["nsite"] = f"{BASE}/site?npub={npub}" + links["gallery"] = f"{BASE}/gallery?{query_string}" + profile = nsite.get("identity", {}).get("profile", {}) + lightning_address = str(profile.get("lud16") or "").strip() + payment_ready = bool(lightning_address and not lightning_address.lower().endswith("@npub.cash")) + if self.is_local() and not payment_ready: + # Dev only: stand in a working Lightning address so Pay / Pay & Go are unlocked. + lightning_address = DEV_LIGHTNING_ADDRESS + payment_ready = True + return self.send_json(200, {"merchant": {"name": merchant_name, "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category")}, "images": images[:8], "links": links, "profile": profile, "lightning_address": lightning_address, "payment_ready": payment_ready, "modules": modules, "counts": counts}) + except ValueError as error: return self.send_json(400, {"error": str(error)}) + except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"}) + if path == "/api/nsite-page": + npub = (query.get("npub") or [""])[0].strip() + try: + place, nsite = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + sections = nsite.get("page", {}).get("sections", []) + menu = next((section for section in sections if section.get("type") == "menu" and section.get("enabled")), {}) + products = [{"id": str(item.get("id") or f"item-{index}")[:40], "name": str(item.get("title") or item.get("name") or "Item")[:80], "description": str(item.get("body") or item.get("description") or "")[:180], "price": str(item.get("price") or "")[:30]} for index, item in enumerate(menu.get("items", [])[:60])] + assets = [item.get("url") for item in nsite.get("page", {}).get("assets", []) if item.get("url")] + sources = [image for source in nsite.get("sources", []) for image in source.get("images", [])] + media = nsite.get("media", {}) + images = [image for image in dict.fromkeys([media.get("heroImage"), *media.get("galleryImages", []), *assets, *sources, *place.get("images", [])]) if image and not any(marker in image.lower() for marker in ("logo", "icon", "static.cdninstagram.com"))][:12] + profile = nsite.get("identity", {}).get("profile", {}) + sources = [{"name": str(source.get("title") or source.get("host") or "Source")[:80], "host": str(source.get("host") or "")[:60], "url": str(source.get("url") or "")[:300], "description": str(source.get("description") or "")[:180], "photos": len(source.get("images", []))} for source in nsite.get("sources", [])][:6] + return self.send_json(200, { + "merchant": {"name": nsite.get("name") or place.get("name"), "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category"), "address": place.get("address"), "hours": place.get("hours"), "phone": place.get("phone"), "website": place.get("website"), "tags": [tag for tag in place.get("tags", [])[:8] if tag], "rating": place.get("rating")}, + "about": str(profile.get("about") or "").strip()[:600], + "products": products, + "images": images, + "sources": sources, + "gateway": nsite.get("nsite", {}).get("gatewayUrl", ""), + "links": {"business": f"{BASE}/dashboard?npub={npub}", "page": f"{BASE}/page?npub={npub}"}, + }) + except ValueError as error: return self.send_json(400, {"error": str(error)}) + except Exception as error: return self.send_json(502, {"error": f"site unavailable: {error}"}) + if path == "/api/qr": + text = (query.get("text") or [""])[0].strip() + if not text or len(text) > 500: return self.send_json(400, {"error": "provide qr text"}) + try: + raw = helper({"action": "qr", "text": text})["svg"].encode() + self.send_response(200) + self.send_header("Content-Type", "image/svg+xml") + self.send_header("Cache-Control", "public,max-age=3600") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + except Exception as error: return self.send_json(502, {"error": f"qr unavailable: {error}"}) + if path == "/api/gallery": + try: + payload = fetch_json("https://justworks.cash/data/regions/madeira.json") + return self.send_json(200, {"name": payload.get("name", "Madeira"), "places": payload.get("places", [])}) + except Exception as error: + return self.send_json(502, {"error": f"gallery data unavailable: {error}"}) + if path == "/api/screen": + npub = (query.get("npub") or [""])[0].strip() + try: + npub = security.canonical_npub(npub) + place, nsite = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + screen_activity.configure(npub, nsite) + assets = [item.get("url") for item in nsite.get("page", {}).get("assets", []) if item.get("url")] + sources = [image for source in nsite.get("sources", []) for image in source.get("images", [])] + images = list(dict.fromkeys([nsite.get("media", {}).get("heroImage"), *nsite.get("media", {}).get("galleryImages", []), *assets, *sources, *place.get("images", [])])) + images = [image for image in images if image and not any(marker in image.lower() for marker in ("static.cdninstagram.com", "facebook.com/images", "logo", "icon"))][:18] + images.sort(key=lambda image: (image.lower().split("?", 1)[0].endswith(".png"), "folha" in image.lower())) + order_url = f"http://{self.headers.get('Host', '127.0.0.1:4310')}{BASE}/order?npub={npub}" if self.is_local() else f"https://order.justworks.cash/?npub={npub}" + justpay_url = f"http://{self.headers.get('Host', '127.0.0.1:4310')}{BASE}/portal?{urlencode({'surface': 'payandgo', 'npub': npub})}" if self.is_local() else f"https://payandgo.justworks.cash/?npub={npub}" + tip_url = f"https://justworks.cash/tip/?{urlencode({'id': place['slug']})}" + qr = helper({"action": "qr", "text": order_url})["svg"] + try: latest = helper({"action": "latest", "npub": npub, "relays": PAYMENT_RELAYS})["event"] + except Exception: latest = None + return self.send_json(200, {"merchant": {"name": nsite.get("name") or place.get("name"), "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category")}, "images": images, "latest": latest, "order_url": order_url, "qr_svg": qr, "screen_links": {"order": order_url, "justpay": justpay_url, "tip": tip_url}}) + except ValueError as error: + return self.send_json(400, {"error": str(error)}) + except Exception as error: + return self.send_json(502, {"error": f"screen data unavailable: {error}"}) + if path == "/api/screen-activity": + try: + npub = security.canonical_npub((query.get('npub') or [''])[0].strip()) + except ValueError: + return self.send_json(400, {'error': 'provide a valid merchant npub'}) + with LOCK, db() as conn: + payload = screen_activity.projection(conn, npub, screen_tips) + return self.send_json(200, payload) + if path == "/api/order-merchant": + npub = (query.get("npub") or [""])[0].strip() + try: + place, nsite = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + sections = nsite.get("page", {}).get("sections", []) + menu = next((section for section in sections if section.get("type") == "menu" and section.get("enabled")), {}) + products = [{"id": str(item.get("id") or f"item-{index}")[:40], "name": str(item.get("title") or item.get("name") or "Item")[:80], "description": str(item.get("body") or item.get("description") or "")[:180], "price": str(item.get("price") or "")[:30]} for index, item in enumerate(menu.get("items", [])[:50])] + images = list(dict.fromkeys([nsite.get("media", {}).get("heroImage"), *nsite.get("media", {}).get("galleryImages", []), *place.get("images", [])])) + images = [image for image in images if image and not any(marker in image.lower() for marker in ("logo", "icon", "static.cdninstagram.com"))] + images.sort(key=lambda image: image.lower().split("?", 1)[0].endswith(".png")) + return self.send_json(200, {"merchant": {"name": nsite.get("name") or place.get("name"), "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category")}, "images": images[:8], "products": products}) + except ValueError as error: return self.send_json(400, {"error": str(error)}) + except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"}) + if path == "/api/merchant-order-status": + npub = (query.get("npub") or [""])[0].strip() + order_id = (query.get("id") or [""])[0].strip() + if not npub.startswith("npub1") or len(npub) > 100 or not order_id: + return self.send_json(400, {"error": "provide the merchant and order"}) + with LOCK, db() as conn: + row = conn.execute("SELECT id,table_number,status,updated_at FROM orders WHERE id=? AND merchant_id=?", (order_id, npub)).fetchone() + if not row: return self.send_json(404, {"error": "order not found"}) + return self.send_json(200, dict(row)) + if path == "/api/quick-pay-status": + npub = (query.get("npub") or [""])[0].strip() + payment_id = (query.get("id") or [""])[0].strip() + if not npub.startswith("npub1") or len(npub) > 100 or len(payment_id) != 32: + return self.send_json(400, {"error": "provide the merchant and payment"}) + with LOCK, db() as conn: + row = conn.execute("SELECT * FROM quick_payments WHERE id=? AND merchant_id=?", (payment_id, npub)).fetchone() + if not row: return self.send_json(404, {"error": "payment not found"}) + payment = dict(row) + if payment["status"] == "pending" and payment["zap_pubkey"] and payment["recipient"]: + try: + created_at = int(datetime.fromisoformat(payment["created_at"]).timestamp()) + check = helper({"action": "check", "relays": PAYMENT_RELAYS, "recipient": payment["recipient"], + "pubkey": payment["zap_pubkey"], "since": created_at}) + if check["paid"]: + settled_at = now() + with LOCK, db() as conn: + conn.execute("UPDATE quick_payments SET status='paid',receipt_id=?,settled_at=? WHERE id=?", + (check["receipt"], settled_at, payment_id)) + conn.commit() + payment.update(status="paid", receipt_id=check["receipt"], settled_at=settled_at) + except Exception: + pass + fields = ("id", "merchant_name", "amount_cents", "amount_sats", "status", "receipt_id", "created_at", "settled_at") + return self.send_json(200, {key: payment[key] for key in fields} | {"verifiable": bool(payment["zap_pubkey"])}) + if path == "/api/pay-orders": + # Existing customer receipt discovery, without staff notes, guest bookings or payments. + npub = (query.get("npub") or [""])[0].strip() + try: security.canonical_npub(npub) + except ValueError: return self.send_json(400, {"error": "provide a valid npub"}) + with LOCK, db() as conn: + orders = [{"table_number": row["table_number"], "status": row["status"], + "items": [{key: item.get(key) for key in ("name", "qty", "price_cents")} for item in json.loads(row["items_json"])]} + for row in conn.execute("SELECT table_number,status,items_json FROM orders WHERE merchant_id=? AND status!='closed' ORDER BY created_at", (npub,))] + return self.send_json(200, {"orders": orders}) + if path == "/api/pos": + npub = (query.get("npub") or [""])[0].strip() + if not npub.startswith("npub1") or len(npub) > 100: + return self.send_json(400, {"error": "provide a valid npub"}) + with LOCK, db() as conn: + orders = [dict(row) | {"items": json.loads(row["items_json"])} for row in conn.execute("SELECT * FROM orders WHERE merchant_id=? AND status!='closed' ORDER BY created_at", (npub,))] + for item in orders: item.pop("items_json", None) + payments = [dict(row) for row in conn.execute("SELECT id,order_id,table_number,amount_cents,amount_sats,status,created_at,settled_at FROM payments WHERE merchant_id=? AND status!='acknowledged' ORDER BY created_at", (npub,))] + bookings = [dict(row) for row in conn.execute("SELECT * FROM bookings WHERE merchant_id=? AND status NOT IN ('closed','cancelled') ORDER BY scheduled_at", (npub,))] + return self.send_json(200, {"orders": orders, "payments": payments, "bookings": bookings, "server_time": now()}) + if path == "/api/table": + token = (query.get("token") or [""])[0] + with LOCK, db() as conn: + row = conn.execute("SELECT table_number FROM tables WHERE token=?", (token,)).fetchone() + if not row: return self.send_json(404, {"error": "invalid table token"}) + config = merchant_payload(conn) + if not config["module_enabled"]: return self.send_json(403, {"error": "module disabled"}) + return self.send_json(200, {"merchant": config["name"], "table": row["table_number"], "products": [p for p in config["products"] if p["enabled"]]}) + if path == "/api/order": + token = (query.get("token") or [""])[0] + order_id = (query.get("id") or [""])[0] + with LOCK, db() as conn: + row = conn.execute("""SELECT o.id,o.table_number,o.status,o.updated_at + FROM orders o JOIN tables t ON t.merchant_id=o.merchant_id AND t.table_number=o.table_number + WHERE o.id=? AND t.token=?""", (order_id, token)).fetchone() + if not row: return self.send_json(404, {"error": "order not found"}) + return self.send_json(200, dict(row)) + if path == "/api/payment": + token = (query.get("token") or [""])[0] + payment_id = (query.get("id") or [""])[0] + with LOCK, db() as conn: + row = conn.execute("""SELECT p.* FROM payments p JOIN tables t ON t.merchant_id=p.merchant_id AND t.table_number=p.table_number + WHERE p.id=? AND t.token=?""", (payment_id, token)).fetchone() + if not row: return self.send_json(404, {"error": "payment not found"}) + payment = dict(row) + if payment["status"] == "pending": + check = helper({"action": "check", "relays": PAYMENT_RELAYS, "recipient": PAYMENT_RECIPIENT, "pubkey": payment["zap_pubkey"], "since": int(datetime.fromisoformat(payment["created_at"]).timestamp())}) + if check["paid"]: + with LOCK, db() as conn: + conn.execute("UPDATE payments SET status='paid',receipt_id=?,settled_at=? WHERE id=?", (check["receipt"], now(), payment_id)) + conn.commit() + payment["status"], payment["receipt_id"], payment["settled_at"] = "paid", check["receipt"], now() + return self.send_json(200, {key: payment[key] for key in ("id", "order_id", "table_number", "amount_cents", "amount_sats", "bolt11", "status", "receipt_id", "created_at", "settled_at")}) + if path == "/napplet/order-to-table/index.html": return self.static(NAPLET_DIST / "index.html", "public,max-age=300") + if path == "/assets/examples/localpub-venue.webp": return self.redirect("https://justworks.cash/assets/examples/localpub-venue.webp") + if path == "/nsite": return self.nsite() + if path == "/business-sso": return self.static(PUBLIC / "business-sso.html") + if path in ("/pos-sw.js", "/screen-sw.js"): + return self.static(PUBLIC / path[1:], "no-cache") + if path in ("/pos.webmanifest", "/screen.webmanifest"): + return self.app_manifest(path[1:].split(".")[0], query) + surface_pages = { + "business": "dashboard.html", "pos": "pos.html", "order": "order.html", "book": "book.html", + "pay": "portal.html", "payandgo": "portal.html", "screen": "screen.html", + "gallery": "gallery.html", "events": "portal.html", + } + pages = {"/qr-sheet": "qr-sheet.html", "/": surface_pages.get(self.surface(), "dashboard.html"), "/dashboard": "dashboard.html", "/profile": "profile.html", "/stats": "stats.html", "/embed": "shell.html", "/portal": "portal.html", "/gallery": "gallery.html", "/screen": "screen.html", "/order": "order.html", "/book": "book.html", "/pos": "pos.html", "/site": "site.html", "/page": "page.html"} + if path in pages: return self.static(PUBLIC / pages[path]) + if path.startswith("/assets/"): return self.static(PUBLIC / path.removeprefix("/"), "public,max-age=3600") + if path.startswith("/fonts/"): return self.static(PUBLIC / path.removeprefix("/"), "public,max-age=86400") + self.send_error(404) + + def do_POST(self): + self.session_cookie = self.exchange_cookie = self.authorized_mutation = None + path, _ = self.route() + try: body = self.body() + except Exception as error: return self.send_json(400, {"error": str(error)}) + if path.startswith("/api/business-sso/"): + if not rollout.enabled(): return self.send_json(404, {"error": "Session exchange is not enabled"}) + with LOCK, db() as conn: + if not security.rate_allowed(self.client_address[0], "/api/business-owner-login", {}, self.headers): + return self.send_json(429, {"error": "Too many exchange requests"}) + try: + if path == "/api/business-sso/start": + result, self.exchange_cookie = rollout.start(conn, body, self.headers) + elif path in {"/api/business-sso/details", "/api/business-sso/approve"}: + result = rollout.source_details(conn, body, self.headers, path.endswith("approve")) + elif path == "/api/business-sso/complete": + result, self.session_cookie = rollout.complete(conn, body, self.headers) + self.exchange_cookie = rollout.exchange_cookie() + else: return self.send_json(404, {"error": "Unknown session action"}) + return self.send_json(200, result) + except (ValueError, KeyError, CookieError): + security.audit(conn, "exchange_denied", path, reason="invalid_or_expired_exchange") + return self.send_json(403, {"error": "Session exchange could not be verified; log in and try again"}) + self.authorized_mutation = None + self.session_cookie = None + if path in {"/api/business-owner-login", "/api/orders", "/api/merchant-orders", "/api/bookings", "/api/payments", "/api/quick-pay"}: + with LOCK: + if not security.rate_allowed(self.client_address[0], path, body, self.headers): + return self.send_json(429, {"error": "Too many requests; try again shortly"}) + if path in {"/api/business-owner-login", "/api/business-logout", "/api/business-revoke"} and not security.same_origin(self.headers): + return self.send_json(403, {"error": "Same-origin request required"}) + with LOCK, db() as conn: + if path in {"/api/business-logout", "/api/business-revoke"}: + current = security.revoke(conn, self.headers, path.endswith("revoke")) + security.audit(conn, "logout", path, actor=current["npub"] if current else "") + self.session_cookie = security.cookie_header() + return self.send_json(200, {"ok": True}) + allowed, context = security.authorize(conn, "POST", path, body, self.headers) + if not allowed: return self.send_json(403, {"error": "Merchant session required for this account"}) + self.authorized_mutation = context + if path == "/api/config": + with LOCK, db() as conn: + enabled = 1 if body.get("module_enabled") else 0 + count = max(1, min(100, int(body.get("table_count", 30)))) + legacy = rollout.legacy_merchant(conn) + conn.execute("UPDATE merchant SET module_enabled=?,table_count=?,updated_at=? WHERE id=?", (enabled, count, now(), legacy)) + products = body.get("products") or [] + for index, product in enumerate(products[:50]): + pid = str(product.get("id") or f"item-{index}")[:40] + name = str(product.get("name") or "Item")[:80] + cents = max(0, min(1000000, int(product.get("price_cents", 0)))) + conn.execute("INSERT INTO products VALUES (?, ?, ?, ?, 1, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,price_cents=excluded.price_cents,sort_order=excluded.sort_order WHERE products.merchant_id=excluded.merchant_id", (pid, legacy, name, cents, index)) + for table in range(1, count + 1): conn.execute("INSERT OR IGNORE INTO tables VALUES (?, ?, ?)", (legacy, table, secrets.token_urlsafe(18))) + for module_id, value in (body.get("modules") or {}).items(): + if module_id not in MODULE_DEFAULTS: continue + enabled = 1 if value.get("enabled") else 0 + config = json.dumps(value.get("config") or {}, separators=(",", ":")) + conn.execute("UPDATE merchant_modules SET enabled=?,config_json=?,updated_at=? WHERE merchant_id=? AND module_id=?", (enabled, config, now(), legacy, module_id)) + conn.commit() + return self.send_json(200, merchant_payload(conn, True)) + if path == "/api/business-config": + npub = str(body.get("npub", "")).strip() + try: + place, _ = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + except ValueError as error: return self.send_json(400, {"error": str(error)}) + except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"}) + requested = body.get("modules") or {} + with LOCK, db() as conn: + merchant_modules(conn, npub) + for module_id, value in requested.items(): + if module_id not in MODULE_DEFAULTS or MODULE_DEFAULTS[module_id][1] == "coming-soon": continue + conn.execute("UPDATE merchant_modules SET enabled=?,updated_at=? WHERE merchant_id=? AND module_id=?", (1 if value else 0, now(), npub, module_id)) + conn.commit() + return self.send_json(200, {"ok": True, "modules": merchant_modules(conn, npub)}) + if path == "/api/business-owner-login": + npub = str(body.get("npub", "")).strip() + try: + place, _ = resolve_merchant_npub(npub) + if not place: raise ValueError("merchant_not_found") + if os.environ.get("JWB_ALLOW_DEMO_LOGIN") == "1" and security.mode() != "enforce" and str(body.get("ownerIdentifier", "")).strip().lower() == "demo": + # Dev shortcut: prefilled demo login resolves locally without remote credentials. + return self.send_json(200, {"ok": True, "merchant": place.get("name"), "npub": npub, "dev": True}) + credentials = {key: str(body.get(key, "")).strip() for key in ("ownerKey", "ownerIdentifier", "ownerPassword") if body.get(key)} + if not credentials.get("ownerKey") and not (credentials.get("ownerIdentifier") and credentials.get("ownerPassword")): + raise ValueError("credentials_required") + result = remote_json(f"https://justworks.cash/api/nsites/{place['slug']}/login", "POST", credentials) + owner = result.get("nsite", {}) + if owner.get("npub") != npub: + raise ValueError("ownership_mismatch") + with LOCK, db() as conn: + self.session_cookie, expires = security.issue(conn, npub, self.headers) + rollout.verification(conn, npub, place['slug']) + security.audit(conn, "login_success", path, npub, npub) + return self.send_json(200, {"ok": True, "merchant": owner.get("name") or place.get("name"), "npub": npub, "expires_at": expires}) + except Exception: + self.session_cookie = None + with LOCK, db() as conn: security.audit(conn, "login_failure", path, reason="verification_failed") + return self.send_json(401, {"error": "Just Works login could not be verified"}) + if path == "/api/business-profile": + npub = str(body.get("npub", "")).strip() + try: + place, _ = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + profile = body.get("profile") or {} + lightning_address = str(profile.get("lud16", "")).strip().lower() + if not lightning_address or "@" not in lightning_address or lightning_address.endswith("@npub.cash"): + return self.send_json(400, {"error": "Enter the merchant’s own Lightning address, not the default npub.cash address"}) + credentials = {key: str(body.get(key, "")).strip() for key in ("ownerKey", "ownerIdentifier", "ownerPassword") if body.get(key)} + if not credentials.get("ownerKey") and not (credentials.get("ownerIdentifier") and credentials.get("ownerPassword")): + return self.send_json(400, {"error": "Enter the Just Works owner login or owner key"}) + if os.environ.get("JWB_ALLOW_DEMO_LOGIN") == "1" and security.mode() != "enforce" and credentials.get("ownerIdentifier", "").lower() == "demo": + # Dev shortcut: accept the save locally without republishing the production nsite. + dev_profile = {"display_name": str(profile.get("display_name", ""))[:120], "about": str(profile.get("about", ""))[:1200], "picture": str(profile.get("picture", ""))[:1000], "banner": str(profile.get("banner", ""))[:1000], "lud16": lightning_address, "website": place.get("justworks") or ""} + return self.send_json(200, {"ok": True, "profile": dev_profile, "published": True, "dev": True}) + update = { + **credentials, + "name": str(profile.get("display_name", ""))[:120], + "note": str(profile.get("about", ""))[:1200], + "payment": lightning_address, + "avatarImage": str(profile.get("picture", ""))[:1000], + "heroImage": str(profile.get("banner", ""))[:1000], + "stage": "Business profile updated", + } + remote_json(f"https://justworks.cash/api/nsites/{place['slug']}", "PATCH", update) + published = remote_json(f"https://justworks.cash/api/nsites/{place['slug']}/deploy", "POST", credentials, 90) + return self.send_json(200, {"ok": True, "profile": published.get("nsite", {}).get("identity", {}).get("profile", {}), "published": True}) + except ValueError as error: return self.send_json(400, {"error": str(error)}) + except Exception as error: return self.send_json(502, {"error": f"profile unavailable: {error}"}) + if path == "/api/orders": + token, items = str(body.get("token", "")), body.get("items") or [] + note = str(body.get("note", ""))[:240] + with LOCK, db() as conn: + table = conn.execute("SELECT table_number,merchant_id FROM tables WHERE token=?", (token,)).fetchone() + if not table: return self.send_json(404, {"error": "invalid table token"}) + merchant = conn.execute("SELECT module_enabled FROM merchant WHERE id=?", (table['merchant_id'],)).fetchone() + if not merchant["module_enabled"]: return self.send_json(403, {"error": "module disabled"}) + clean = [] + for item in items[:20]: + product = conn.execute("SELECT id,name,price_cents FROM products WHERE id=? AND merchant_id=? AND enabled=1", (str(item.get("id", "")), table['merchant_id'])).fetchone() + qty = max(0, min(20, int(item.get("qty", 0)))) + if product and qty: clean.append(dict(product) | {"qty": qty}) + if not clean: return self.send_json(400, {"error": "order is empty"}) + oid, timestamp = secrets.token_hex(5), now() + conn.execute("INSERT INTO orders VALUES (?, ?, ?, ?, ?, 'new', ?, ?)", (oid, table['merchant_id'], table["table_number"], json.dumps(clean), note, timestamp, timestamp)) + conn.commit() + total_cents = sum(item["price_cents"] * item["qty"] for item in clean) + return self.send_json(201, {"id": oid, "table": table["table_number"], "status": "new", "total_cents": total_cents}) + if path == "/api/merchant-orders": + npub = str(body.get("npub", "")).strip() + request_text = str(body.get("request", "")).strip()[:500] + guest = str(body.get("guest", "")).strip()[:80] + try: table_number = max(1, min(999, int(body.get("table", 1)))) + except (TypeError, ValueError): return self.send_json(400, {"error": "enter a valid table number"}) + selected = body.get("items") or [] + if not request_text and not selected: return self.send_json(400, {"error": "add an order or request"}) + try: + place, nsite = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + except ValueError as error: return self.send_json(400, {"error": str(error)}) + except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"}) + items = [{"id": str(item.get("id", "item"))[:40], "name": str(item.get("name", "Item"))[:80], "qty": max(1, min(20, int(item.get("qty", 1)))), "price_cents": 0} for item in selected[:30]] + if request_text: items.append({"id": "request", "name": request_text, "qty": 1, "price_cents": 0}) + oid, timestamp = secrets.token_hex(5), now() + note = f"Guest: {guest}" if guest else "" + with LOCK, db() as conn: + conn.execute("INSERT INTO orders VALUES (?,?,?,?,?,'new',?,?)", (oid, npub, table_number, json.dumps(items), note, timestamp, timestamp)) + conn.commit() + return self.send_json(201, {"id": oid, "merchant": nsite.get("name") or place.get("name"), "table": table_number, "status": "new", "created_at": timestamp}) + if path == "/api/bookings": + npub = str(body.get("npub", "")).strip() + name = str(body.get("name", "")).strip()[:80] + notes = str(body.get("notes", "")).strip()[:240] + mode = str(body.get("mode", "later")) + try: guests = max(1, min(20, int(body.get("guests", 2)))) + except (TypeError, ValueError): return self.send_json(400, {"error": "enter a valid guest count"}) + if not name: return self.send_json(400, {"error": "tell the merchant who is booking"}) + if mode not in {"now", "later"}: return self.send_json(400, {"error": "choose now or later"}) + try: + scheduled = datetime.fromisoformat(str(body.get("scheduled_at", "")).replace("Z", "+00:00")) + if scheduled.tzinfo is None: scheduled = scheduled.replace(tzinfo=timezone.utc) + current = datetime.now(timezone.utc) + if scheduled < current.replace(microsecond=0) and (current - scheduled).total_seconds() > 120: return self.send_json(400, {"error": "choose a future time"}) + if scheduled < current: scheduled = current + scheduled_at = scheduled.astimezone(timezone.utc).isoformat(timespec="seconds") + place, nsite = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + except ValueError as error: return self.send_json(400, {"error": str(error) or "choose a valid time"}) + except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"}) + booking_id, timestamp = secrets.token_hex(6), now() + with LOCK, db() as conn: + conn.execute("INSERT INTO bookings VALUES (?,?,?,?,?,?,?,'new',?,?)", (booking_id, npub, name, guests, scheduled_at, mode, notes, timestamp, timestamp)) + conn.commit() + return self.send_json(201, {"id": booking_id, "merchant": nsite.get("name") or place.get("name"), "name": name, "guests": guests, "scheduled_at": scheduled_at, "status": "new"}) + if path == "/api/quick-pay": + npub = str(body.get("npub", "")).strip() + try: + amount_cents = int(body.get("amount_cents", 0)) + if amount_cents < 1 or amount_cents > 10_000_000: + raise ValueError("Enter an amount between €0.01 and €100,000") + place, nsite = resolve_merchant_npub(npub) + if not place: return self.send_json(404, {"error": "merchant npub not found"}) + lightning_address = str(nsite.get("identity", {}).get("profile", {}).get("lud16") or "").strip() + if not lightning_address: raise ValueError("This merchant has no Lightning address") + prices = fetch_json("https://mempool.space/api/v1/prices") + amount_sats = max(1, round(amount_cents * 1_000_000 / int(prices["EUR"]))) + merchant_name = nsite.get("name") or place.get("name") + invoice = lightning_invoice(lightning_address, amount_sats * 1000, str(body.get("comment", ""))) + payment_id, created_at = secrets.token_hex(16), now() + with LOCK, db() as conn: + conn.execute("INSERT INTO quick_payments VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (payment_id, npub, merchant_name, amount_cents, amount_sats, invoice["bolt11"], + invoice["zap_pubkey"], invoice["recipient"], "pending", None, created_at, None)) + conn.commit() + qr_svg = helper({"action": "qr", "text": invoice["bolt11"]})["svg"] + return self.send_json(201, {"id": payment_id, "merchant": merchant_name, "amount_cents": amount_cents, + "amount_sats": amount_sats, "bolt11": invoice["bolt11"], "qr_svg": qr_svg, + "status": "pending", "verifiable": invoice["verifiable"], "created_at": created_at}) + except ValueError as error: return self.send_json(400, {"error": str(error)}) + except HTTPError as error: return self.send_json(502, {"error": f"Lightning provider rejected the request ({error.code})"}) + except Exception as error: return self.send_json(502, {"error": f"Invoice unavailable: {error}"}) + if path == "/api/payments" and os.environ.get("JWB_ARCHIPELAGO") == "1": + return self.send_json(503, {"error": "Legacy order checkout is not connected to your wallet. Use the merchant website payment page."}) + if path == "/api/payments": + token, order_id = str(body.get("token", "")), str(body.get("order_id", "")) + with LOCK, db() as conn: + row = conn.execute("""SELECT o.*,t.token FROM orders o JOIN tables t ON t.merchant_id=o.merchant_id AND t.table_number=o.table_number + WHERE o.id=? AND t.token=?""", (order_id, token)).fetchone() + if not row: return self.send_json(404, {"error": "order not found"}) + existing = conn.execute("SELECT * FROM payments WHERE order_id=? ORDER BY created_at DESC LIMIT 1", (order_id,)).fetchone() + if existing and existing["status"] == "pending": return self.send_json(200, dict(existing)) + items = json.loads(row["items_json"]) + amount_cents = sum(item["price_cents"] * item["qty"] for item in items) + prices = fetch_json("https://mempool.space/api/v1/prices") + amount_sats = max(1, round(amount_cents * 1_000_000 / int(prices["EUR"]))) + created_at = now() + zap = helper({"action": "create", "lnurlUrl": LNURL_URL, "relays": PAYMENT_RELAYS, "amountMsat": amount_sats * 1000, "recipient": PAYMENT_RECIPIENT, "table": row["table_number"]}) + params = urlencode({"amount": amount_sats * 1000, "nostr": json.dumps(zap["event"], separators=(",", ":")), "lnurl": zap["lnurl"], "comment": f"Just Works Table {row['table_number']}"}) + invoice = fetch_json(f"{LNURL_CALLBACK}?{params}") + if invoice.get("status") == "ERROR" or not invoice.get("pr"): return self.send_json(502, {"error": invoice.get("reason", "invoice unavailable")}) + payment_id = secrets.token_hex(6) + with LOCK, db() as conn: + conn.execute("INSERT INTO payments VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (payment_id, order_id, row["merchant_id"], row["table_number"], amount_cents, amount_sats, invoice["pr"], zap["pubkey"], "pending", None, created_at, None)) + conn.commit() + return self.send_json(201, {"id": payment_id, "order_id": order_id, "table_number": row["table_number"], "amount_cents": amount_cents, "amount_sats": amount_sats, "bolt11": invoice["pr"], "status": "pending"}) + if path.startswith("/api/orders/") and path.endswith("/status"): + oid = path.split("/")[3] + status = str(body.get("status", "")) + if status not in {"new", "accepted", "delivered", "closed"}: return self.send_json(400, {"error": "invalid status"}) + with LOCK, db() as conn: + changed = conn.execute("UPDATE orders SET status=?,updated_at=? WHERE id=?", (status, now(), oid)).rowcount + conn.commit() + return self.send_json(200 if changed else 404, {"ok": bool(changed), "status": status}) + if path.startswith("/api/payments/") and path.endswith("/acknowledge"): + payment_id = path.split("/")[3] + with LOCK, db() as conn: + changed = conn.execute("UPDATE payments SET status='acknowledged' WHERE id=? AND status='paid'", (payment_id,)).rowcount + conn.commit() + return self.send_json(200 if changed else 404, {"ok": bool(changed), "status": "acknowledged"}) + if path.startswith("/api/bookings/") and path.endswith("/status"): + booking_id = path.split("/")[3] + status = str(body.get("status", "")) + if status not in {"new", "confirmed", "seated", "closed", "cancelled"}: return self.send_json(400, {"error": "invalid status"}) + with LOCK, db() as conn: + changed = conn.execute("UPDATE bookings SET status=?,updated_at=? WHERE id=?", (status, now(), booking_id)).rowcount + conn.commit() + return self.send_json(200 if changed else 404, {"ok": bool(changed), "status": status}) + self.send_error(404) + + +if __name__ == "__main__": + security.mode() + rollout.policy() + rollout.origins() + with db(): pass + print(f"JustWorks Business listening on http://{HOST}:{PORT}{BASE}/") + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/docker/justworks/payments.py b/docker/justworks/payments.py new file mode 100644 index 00000000..c04e3f5b --- /dev/null +++ b/docker/justworks/payments.py @@ -0,0 +1,90 @@ +"""Owner-authorized, fixed-origin payment destination update. No secret storage.""" +import json +import re +import threading +from urllib.request import Request, build_opener, HTTPRedirectHandler + +CORE = 'https://justworks.cash' +LOCK = threading.Lock() + + +class PublishUnverified(Exception): + pass + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def request_core(method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + request = Request(CORE + path, data=data, method=method, + headers={'Content-Type': 'application/json', 'Accept': 'application/json', + 'User-Agent': 'Archipelago-JustWorks/0.1'}) + with build_opener(NoRedirect).open(request, timeout=100 if path.endswith('/deploy') else 20) as response: + raw = response.read(2 * 1024 * 1024 + 1) + if len(raw) > 2 * 1024 * 1024: + raise ValueError('Response too large') + return json.loads(raw) + + +def checked_input(body): + if not isinstance(body, dict): + raise ValueError('Invalid request') + slug = body.get('siteId', '') + address = body.get('address', '') + if not isinstance(slug, str) or not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', slug) or len(slug) > 160 or slug.startswith(('nsec1', 'ncryptsec1')): + raise ValueError('Invalid website') + # This integration connects only the native wallet address returned by the OS. + if not isinstance(address, str) or not re.fullmatch(r'[a-z0-9._+-]{1,100}@minibits\.cash', address): + raise ValueError('Invalid native wallet address') + credentials = body.get('credentials') + if not isinstance(credentials, dict): + raise ValueError('Owner sign-in required') + allowed = ('ownerIdentifier', 'ownerPassword', 'ownerKey', 'password') + clean = {} + for key in allowed: + value = credentials.get(key, '') + if not isinstance(value, str) or len(value) > 2048: + raise ValueError('Invalid owner sign-in') + if value: + clean[key] = value + if not clean.get('ownerKey') and not (clean.get('ownerIdentifier') and clean.get('ownerPassword')): + raise ValueError('Owner sign-in required') + return slug, address, clean + + +def destination_matches(record, slug, address): + return (isinstance(record, dict) and record.get('id') == slug + and record.get('nsite', {}).get('deploymentStatus') == 'published' + and record.get('identity', {}).get('profile', {}).get('lud16') == address) + + +def connect_payment(body): + slug, address, credentials = checked_input(body) + if not LOCK.acquire(blocking=False): + raise ValueError('Another update is in progress. Wait and retry.') + try: + # Confirm ownership before attempting a change. Never keep the returned + # owner session or any recovery material from Core's response. + owner = request_core('POST', f'/api/nsites/{slug}/login', credentials).get('nsite', {}) + if owner.get('id') != slug: + raise ValueError('Owner sign-in did not match this website') + try: + # Only payment changes; do not replay or overwrite other profile fields. + request_core('PATCH', f'/api/nsites/{slug}', {**credentials, 'payment': address}) + published = request_core('POST', f'/api/nsites/{slug}/deploy', credentials) + if not published.get('artifact', {}).get('verified') or not destination_matches(published.get('nsite'), slug, address): + raise PublishUnverified() + # This is the public record consumed by the hosted customer tip page. + public = request_core('GET', f'/api/nsites/{slug}').get('nsite') + if not destination_matches(public, slug, address): + raise PublishUnverified() + except Exception as error: + raise PublishUnverified('The address may be saved, but publication could not be verified. Retry to publish and check again.') from error + return {'verified': True, 'siteId': slug, 'address': address, + 'url': f'{CORE}/{slug}'} + finally: + credentials.clear() + LOCK.release() diff --git a/docker/justworks/public/assets/bridge.js b/docker/justworks/public/assets/bridge.js new file mode 100644 index 00000000..efa8c78f --- /dev/null +++ b/docker/justworks/public/assets/bridge.js @@ -0,0 +1,44 @@ +// Connector for the native OS guide. No Just Works UI is replaced. +const base = new URL('..', import.meta.url).pathname.replace(/\/?$/, '/'); +let parentOrigin; +try { parentOrigin = new URL(document.referrer).origin; } catch { /* standalone */ } +if (parentOrigin && new URL(parentOrigin).hostname === location.hostname && new URL(parentOrigin).protocol === location.protocol && window.parent !== window) { + window.addEventListener('message', async event => { + if (event.source !== parent || event.origin !== parentOrigin || typeof event.data.requestId !== 'string') return; + const requestId = event.data.requestId; + try { + if (event.data.type === 'justworks:get-connection') { + const siteId = localStorage.getItem('archipelago-justworks-site-v1') || ''; + let npub = ''; + if (siteId) { + const response = await fetch(`${base}api/site?url=${encodeURIComponent(siteId)}`, {cache:'no-store', signal:AbortSignal.timeout(12000)}); + const site = await response.json(); + if (response.ok && site.id === siteId) npub = site.npub; + } + parent.postMessage({type:'justworks:connection', requestId, siteId, npub}, parentOrigin); + return; + } + if (event.data.type === 'justworks:business-login') { + const response = await fetch(`${base}business-demo/api/business-owner-login`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(event.data.payload), signal:AbortSignal.timeout(60000)}); + const result = await response.json(); + parent.postMessage({type:'justworks:business-result', requestId, ok:response.ok && result.ok === true, npub:result.npub, error:result.error}, parentOrigin); + return; + } + if (event.data.type === 'justworks:connect-payment') { + const response = await fetch(`${base}api/payment-connect`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(event.data.payload), signal:AbortSignal.timeout(170000)}); + const result = await response.json(); + parent.postMessage({type:'justworks:payment-result', requestId, ...result}, parentOrigin); + return; + } + if (event.data.type !== 'justworks:check-site' || typeof event.data.url !== 'string') return; + const response = await fetch(`${base}api/site?url=${encodeURIComponent(event.data.url)}`, {cache:'no-store', signal:AbortSignal.timeout(15000)}); + const site = await response.json(); + if (!response.ok || site.published !== true) throw new Error(site.error || 'Publish your website in Just Works, then try again.'); + let remembered = true; + try { localStorage.setItem('archipelago-justworks-site-v1', site.id); } catch { remembered = false; } + parent.postMessage({type:'justworks:site-connected', requestId, site:{id:site.id,published:true}, remembered}, parentOrigin); + } catch (error) { + parent.postMessage({type:'justworks:site-error', requestId, error:error.message}, parentOrigin); + } + }); +} diff --git a/docker/justworks/public/assets/design-system.css b/docker/justworks/public/assets/design-system.css new file mode 100644 index 00000000..5c14231a --- /dev/null +++ b/docker/justworks/public/assets/design-system.css @@ -0,0 +1,8 @@ +/* Just Works design system: load this single file before page CSS. */ +@import url("./tokens.css"); +@import url("./ds/base.css"); +@import url("./ds.css"); +@import url("./ds/components.css"); +@import url("./ds/topbar.css"); +@import url("./ds/mobile.css"); +@import url("./ds/presets.css"); diff --git a/docker/justworks/public/assets/ds.css b/docker/justworks/public/assets/ds.css new file mode 100644 index 00000000..acb558ef --- /dev/null +++ b/docker/justworks/public/assets/ds.css @@ -0,0 +1,141 @@ +/* + * Design-system chrome shared with cash-just-works: the three-way theme + * toggle and the brand mark. Ported verbatim from public/css/components.css + * in the cash-just-works repo — colors resolve through tokens.css, so this + * follows the same light/dark rules as the source site. + */ + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--jw-line); + border-radius: var(--jw-radius-pill); + background: var(--jw-surface-sunken); +} + +.theme-toggle button { + width: 32px; + height: 28px; + display: inline-grid; + place-items: center; + border: 0; + border-radius: var(--jw-radius-pill); + background: transparent; + color: var(--jw-text-muted); + cursor: pointer; + transition: + background var(--jw-duration-base) var(--jw-ease-out), + color var(--jw-duration-base) var(--jw-ease-out); +} + +.theme-toggle button:hover { color: var(--jw-text-primary); } + +.theme-toggle button[aria-pressed="true"] { + background: var(--jw-surface); + color: var(--jw-text-primary); + box-shadow: var(--jw-shadow-xs); +} + +.theme-toggle button:focus-visible { + outline: 2px solid var(--jw-field-border-focus); + outline-offset: 1px; +} + +.theme-toggle svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Brand mark: one continuous line, path swapped by assets/ds/logo.js. */ + +.jw-mark { + width: var(--jw-mark-width, 2.1em); + height: auto; + flex: none; + display: block; + overflow: visible; +} + +.jw-mark-portal { + width: auto; + height: var(--jw-mark-height, 1.35em); +} + +.jw-mark-portal { filter: var(--jw-portal-depth); } + +.jw-portal-frame { fill: var(--jw-text-primary); } +.jw-portal-head { stop-color: var(--jw-text-subtle); } +.jw-portal-floor { stop-color: transparent; } + +.jw-mark-path { + fill: none; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.jw-mark-stop-a { stop-color: var(--jw-accent); } +.jw-mark-stop-b { stop-color: var(--jw-highlight); } + +.brand .jw-mark { margin-right: -0.15em; } + +/* App icon: the mark in a translucent rimmed container. Fixed dark ground in + both themes on purpose (see tokens.css). */ + +.jw-app-icon { + position: relative; + width: var(--jw-app-icon-size); + height: var(--jw-app-icon-size); + flex: none; + display: grid; + place-items: center; + border-radius: var(--jw-app-icon-radius); + background: var(--jw-app-icon-bg); + -webkit-backdrop-filter: blur(var(--jw-app-icon-blur)); + backdrop-filter: blur(var(--jw-app-icon-blur)); +} + +.jw-app-icon::before { + content: ""; + position: absolute; + inset: 0; + padding: var(--jw-app-icon-rim-width); + border-radius: inherit; + background: linear-gradient( + 135deg, + var(--jw-app-icon-rim-bright) 0%, + var(--jw-app-icon-rim-fade) 32%, + var(--jw-app-icon-rim-fade) 68%, + var(--jw-app-icon-rim-bright) 100% + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.jw-app-icon .jw-mark.jw-mark-portal, +.jw-app-icon .jw-mark.jw-mark-line { + width: auto; + height: calc(var(--jw-app-icon-size) * 0.52); + transform: translateY(calc(var(--jw-app-icon-size) * -0.025)); +} + +.brand .jw-app-icon { margin-right: var(--jw-space-1); } + +.jw-app-icon.lg { --jw-app-icon-size: 76px; --jw-app-icon-rim-width: 1.5px; } +.jw-app-icon.sm { --jw-app-icon-size: 26px; } + +.nav-actions { display: flex; align-items: center; gap: 14px; } diff --git a/docker/justworks/public/assets/ds/base.css b/docker/justworks/public/assets/ds/base.css new file mode 100644 index 00000000..db740195 --- /dev/null +++ b/docker/justworks/public/assets/ds/base.css @@ -0,0 +1,2 @@ +*,*::before,*::after{box-sizing:border-box}html{min-height:100%;background:var(--jw-bg);color-scheme:light}html[data-theme="dark"]{color-scheme:dark}body{min-height:100vh;margin:0;background:var(--jw-bg);color:var(--jw-text-primary);font-family:var(--jw-font-sans);font-size:1rem;line-height:1.5;text-rendering:optimizeLegibility}button,input,textarea,select{font:inherit}input,textarea,select{font-size:1rem}button,a{touch-action:manipulation}img,svg{max-width:100%}a{color:inherit}button{cursor:pointer}:is(a,button,input,textarea,select,[tabindex]):focus-visible{outline:2px solid var(--jw-field-border-focus);outline-offset:3px}body.creating{overflow:hidden}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto!important}*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}} +#themeToggle{display:none!important} diff --git a/docker/justworks/public/assets/ds/components.css b/docker/justworks/public/assets/ds/components.css new file mode 100644 index 00000000..0b8aa5e2 --- /dev/null +++ b/docker/justworks/public/assets/ds/components.css @@ -0,0 +1,14 @@ +/* Brand */ +.jw-brand,.jw-topbar .brand{display:inline-flex;align-items:center;gap:var(--jw-space-3);color:inherit;text-decoration:none;white-space:nowrap}.jw-brand>[data-logo],.jw-topbar .brand>[data-logo],.brand>[data-logo]{width:var(--jw-app-icon-size);height:var(--jw-app-icon-size);display:block;flex:none}.jw-brand b,.jw-topbar .brand b{font-size:1rem;font-weight:var(--jw-weight-black);line-height:1}.jw-brand small,.jw-topbar .brand small{display:inline!important;margin:0!important;color:inherit!important;font:inherit!important;letter-spacing:0!important;text-transform:none!important}.jw-brand small::before,.jw-topbar .brand small::before{content:"·";margin:0 var(--jw-space-2);color:var(--jw-text-muted)} +/* Buttons */ +.jw-button{min-height:var(--jw-button-height);display:inline-flex;align-items:center;justify-content:center;gap:var(--jw-space-2);padding:var(--jw-button-padding);border:1px solid var(--jw-line-strong);border-radius:var(--jw-button-radius);background:var(--jw-surface);color:var(--jw-text-primary);font-weight:var(--jw-button-weight);text-decoration:none;transition:background var(--jw-duration-base) var(--jw-ease-out),color var(--jw-duration-base) var(--jw-ease-out),transform var(--jw-duration-fast) var(--jw-ease-out)}.jw-button:hover{background:var(--jw-surface-sunken)}.jw-button:active{transform:translateY(1px)}.jw-button--primary{border-color:var(--jw-button-primary-fill);background:var(--jw-button-primary-fill);color:var(--jw-button-primary-text)}.jw-button--primary:hover{background:var(--jw-button-primary-fill-hover)}.jw-button--pill{border-radius:var(--jw-radius-pill)}.jw-button--compact{min-height:42px;padding-inline:var(--jw-space-4);font-size:.875rem}.jw-icon-button{width:44px;height:44px;display:inline-grid;place-items:center;padding:0;border:1px solid var(--jw-line);border-radius:50%;background:var(--jw-surface);color:var(--jw-text-primary)} +/* Cards and panels */ +.jw-card{padding:var(--jw-card-padding);border:1px solid var(--jw-card-border);border-radius:var(--jw-card-radius);background:var(--jw-card-bg);box-shadow:var(--jw-shadow-sm)}.jw-card--flat{box-shadow:none}.jw-card--sunken{background:var(--jw-surface-sunken)}.jw-card--glass{background:var(--jw-surface-glass);-webkit-backdrop-filter:blur(var(--jw-topbar-blur));backdrop-filter:blur(var(--jw-topbar-blur))} +/* Forms */ +.jw-field{display:grid;gap:var(--jw-space-2);margin-top:var(--jw-space-5)}.jw-field>span,.jw-label{color:var(--jw-field-label);font-size:.8125rem;font-weight:var(--jw-weight-bold);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}.jw-field :is(input,textarea,select),.jw-input{width:100%;min-height:var(--jw-field-height);padding:var(--jw-space-3) var(--jw-space-4);border:1px solid var(--jw-field-border);border-radius:var(--jw-field-radius);background:var(--jw-field-bg);color:var(--jw-field-text);font-size:1rem;outline:0}.jw-field textarea{min-height:112px;resize:vertical}.jw-field :is(input,textarea,select):focus,.jw-input:focus{border-color:var(--jw-field-border-focus);box-shadow:var(--jw-field-ring)}.jw-help{margin:var(--jw-space-2) 0 0;color:var(--jw-text-muted);font-size:.8125rem;line-height:1.5} +/* Chips and status */ +.jw-chip{min-height:42px;display:inline-flex;align-items:center;justify-content:center;padding:0 var(--jw-space-4);border:1px solid var(--jw-chip-border);border-radius:var(--jw-radius-pill);background:var(--jw-chip-bg);color:var(--jw-chip-text);font-size:.8125rem;font-weight:var(--jw-weight-bold)}.jw-chip[aria-pressed="true"],.jw-chip.is-active{background:var(--jw-text-primary);color:var(--jw-bg)}.jw-badge{display:inline-flex;align-items:center;gap:var(--jw-space-2);padding:var(--jw-space-2) var(--jw-space-3);border:1px solid currentColor;border-radius:var(--jw-radius-pill);font-size:.8125rem;font-weight:var(--jw-weight-bold)}.jw-badge--success{color:var(--jw-success)}.jw-badge--warning{color:var(--jw-warning)}.jw-badge--danger{color:var(--jw-danger)} +/* Async states */ +.jw-state{min-height:50vh;display:grid;place-content:center;justify-items:center;gap:var(--jw-space-3);padding:var(--jw-space-6);text-align:center}.jw-state__mark{font-size:clamp(3rem,8vw,5rem)}.jw-state h1{max-width:15ch;margin:0;font-family:var(--jw-font-display);font-size:clamp(2.5rem,7vw,5.5rem);line-height:.9;letter-spacing:-.055em}.jw-state p{max-width:42rem;margin:0;color:var(--jw-text-muted);font-size:1rem}.jw-state--page{min-height:100vh;background:var(--jw-black);color:var(--jw-white)} +/* Toast */ +.jw-toast{position:fixed;right:var(--jw-space-5);bottom:var(--jw-space-5);z-index:var(--jw-z-toast);padding:var(--jw-space-3) var(--jw-space-4);border-radius:var(--jw-radius-md);background:var(--jw-text-primary);color:var(--jw-bg);box-shadow:var(--jw-shadow-lg);font-size:.875rem;font-weight:var(--jw-weight-bold)} diff --git a/docker/justworks/public/assets/ds/frame.js b/docker/justworks/public/assets/ds/frame.js new file mode 100644 index 00000000..195d1275 --- /dev/null +++ b/docker/justworks/public/assets/ds/frame.js @@ -0,0 +1,51 @@ +import {mountLogo} from './logo.js'; +import {startNavScroll} from './header.js'; + +const prod=location.hostname.endsWith('justworks.cash'); +export const base=location.pathname.startsWith('/business-demo')?'/business-demo':''; +const search=location.search; +export const link=id=>prod?`${id==='business'?'https://business.justworks.cash/':`https://${id}.justworks.cash/`}${search}`:({business:`${base}/dashboard${search}`,pos:`${base}/pos${search}`,order:`${base}/order${search}`,book:`${base}/book${search}`,booking:`${base}/book${search}`,screen:`${base}/screen${search}`,gallery:`${base}/gallery${search}`})[id]||`${base}/portal?surface=${id}`; +// Archipelago navigation adapter; visual components remain upstream. +let links=[['order','Order'],['pay','Pay Bill'],['book','Book'],['gallery','Explore']]; +let brandHref; +export function configureNavigation(options){links=options.items;brandHref=options.brandHref} +const destination=id=>brandHref ? id : link(id); +const menuSvg=''; +const closeSvg=''; +const upSvg=''; +const topbar=(label,endHtml='')=>``; + +function mountChromeControls(app){ + const existing=app.querySelector('.mobile-menu'); + if(existing)return existing; + app.insertAdjacentHTML('beforeend',``); + const trigger=app.querySelector('.mobile-menu-button'); + const menu=app.querySelector('.mobile-menu'); + const close=app.querySelector('.mobile-menu-close'); + const back=app.querySelector('.back-to-top'); + let previousFocus=null; + const focusable=()=>[...menu.querySelectorAll('a,button,[tabindex]:not([tabindex="-1"])')]; + const setMenu=open=>{menu.classList.toggle('open',open);menu.setAttribute('aria-hidden',String(!open));trigger.setAttribute('aria-expanded',String(open));document.body.classList.toggle('creating',open);if(open){previousFocus=document.activeElement;close.focus()}else if(previousFocus instanceof HTMLElement){previousFocus.focus()}}; + trigger.addEventListener('click',()=>setMenu(true)); + close.addEventListener('click',()=>setMenu(false)); + menu.addEventListener('click',event=>{if(event.target.closest('a'))setMenu(false)}); + menu.addEventListener('keydown',event=>{if(event.key==='Escape'){setMenu(false);return}if(event.key!=='Tab')return;const items=focusable();const first=items[0],last=items.at(-1);if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus()}}); + back.addEventListener('click',()=>scrollTo({top:0,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'})); + let request=0; + const update=()=>{request=0;back.classList.toggle('show',scrollY>innerHeight)}; + addEventListener('scroll',()=>{if(!request)request=requestAnimationFrame(update)},{passive:true}); + addEventListener('resize',()=>{if(innerWidth>980&&menu.classList.contains('open'))setMenu(false)},{passive:true}); + update(); + return menu; +} + +function initialize(app,grounded=false){ + app.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true})); + mountChromeControls(app); + const bar=app.querySelector('[data-topbar]'); + if(grounded)bar?.classList.add('is-scrolled');else startNavScroll(bar); +} + +export function mountMobileChrome(app){return mountChromeControls(app)} +export function mountAppChrome(app,{label='',endHtml='',grounded=false}={}){app.classList.toggle('chrome-grounded',grounded);app.insertAdjacentHTML('afterbegin',topbar(label,endHtml));initialize(app,grounded)} +export function frame(app,{label='',surface='',html='',nav=true}){app.innerHTML=nav?`${topbar(label)}
${html}
`:`
${html}
`;if(nav)initialize(app);else app.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}))} diff --git a/docker/justworks/public/assets/ds/header.js b/docker/justworks/public/assets/ds/header.js new file mode 100644 index 00000000..79f7786b --- /dev/null +++ b/docker/justworks/public/assets/ds/header.js @@ -0,0 +1,24 @@ +/* + * Top bar scroll state — shared with the main site's behaviour. + * Transparent while parked; past the first 80px the bar earns the + * translucent ground, hairline and blur. Toggles `is-scrolled`. + */ +const SCROLL_START = 80; + +export function startNavScroll(bar = document.querySelector("[data-topbar]")) { + if (!bar) return () => {}; + let frame = 0; + const apply = () => { + frame = 0; + bar.classList.toggle("is-scrolled", window.scrollY > SCROLL_START); + }; + const onScroll = () => { + if (!frame) frame = requestAnimationFrame(apply); + }; + apply(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", onScroll); + if (frame) cancelAnimationFrame(frame); + }; +} diff --git a/docker/justworks/public/assets/ds/logo.js b/docker/justworks/public/assets/ds/logo.js new file mode 100644 index 00000000..5a203e52 --- /dev/null +++ b/docker/justworks/public/assets/ds/logo.js @@ -0,0 +1,198 @@ +/* + * The Just Works mark. + * + * One continuous line, drawn as a single closed path, with no beginning and no + * end. As it travels it settles through three states: a plain loop, a section + * squared into a card outline (payments), and a section drawn out into a + * speech bubble (social). The resting state carries a pointer notch on the + * left lobe (website), so the mark reads correctly even with motion disabled. + * + * The path is the ONLY thing that changes. Stroke, gradient, size and caps are + * styled in components.css from tokens, so the mark follows the theme. + * + * Rendered from here rather than written into each page, so the topbar, the + * footer and /design all draw the same mark from one source. + */ + +/* + * All four states share the same anchor budget and the same left-lobe opening, + * so the morph travels rather than jumps. Authored on a 56x28 field with the + * crossing point at the centre. + */ +export const LOGO_STATES = { + /* The loop at rest, with the pointer notch on the left lobe. */ + loop: "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C20.5 20.2 15 21 13.5 14Z", + /* Right lobe squared into a card. */ + card: "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30 19.5 33 19.5L41 19.5C43 19.5 44 18.5 44 16.5L44 11.5C44 9.5 43 8.5 41 8.5L33 8.5C30 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14Z", + /* Right lobe drawn out into a speech bubble, tail dropping below. */ + bubble: "M13.5 14C13.5 7.5 20 7 27 14C29 16 29 18.5 30.5 19.5C31.6 20.3 31 23.4 32.6 22.3C34 21.3 34.6 19.5 36.6 19.5L39 19.5C42 19.5 44 17 44 14C44 11 42 8.5 39 8.5L33 8.5C29.5 8.5 29 11.5 27 14C20.5 20.2 15 21 13.5 14Z", + /* Lab 07: the right lobe opens into a round doorway. The resting mark for + now, per the direction of 2026-08-18, until the lab work settles. */ + portal: "M13.5 14C13.5 7.5 20 7 27 14C30.5 17.5 34.5 19.5 37.5 18C35.5 15.5 35 13.5 36.5 12C38 10.5 41 10.5 42 12.5C43 14.5 42 16 40.5 17.5C43 17.5 44 14.5 43 11.5C41.5 7.5 34 6.5 30 10.5C28.5 12 28 13 27 14C20.5 20.2 15 21 13.5 14Z", + /* Left lobe pulled into a cursor arrow. */ + cursor: "M13.5 14C10.6 8.2 9.8 4.6 11.6 4.2C13.4 3.8 20.6 9.6 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C23 16.6 18.6 21.6 17.1 22.6C15.6 23.6 14.6 19.8 13.5 14Z" +}; + +/* + * The lab: further single-line ideas, same method as LOGO_STATES — one + * continuous closed path on the 56x28 field, each working a different motif + * into the loop's travel. Rendered in /design for comparison; not part of the + * live cycle. Keep adding here rather than replacing. + */ +export const LOGO_LAB = { + /* Payments and Bitcoin. */ + "sat-symbol": "M13.5 14C13.5 7.5 20 7 27 14C30 17 30 19 34 19L40 19M34 9L40 9M31 14L43 14C43 14 43.5 10 41.5 8.5C38 6.5 31 9.5 27 14C20.5 20.2 15 21 13.5 14Z", + "coin-edge": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 35 18.5C39 18.5 42.5 16.5 42.5 13.5C42.5 10.5 39 8.5 35 8.5C32 8.5 30 11 27 14C24 17 21 19.5 17.5 19.5C14.5 19.5 13.5 17 13.5 14Z", + "zap-bolt": "M13.5 14C13.5 7.5 20 7 27 14C30 17 33 18 35 15.5L32 15.5L38 8.5L36 13L39.5 13C41.5 13 42 10 40 8.5C36.5 6 30.5 10 27 14C20.5 20.2 15 21 13.5 14Z", + "lightning-path": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C25 16 23.5 14.5 22 16.5C20.5 18.5 19 16.5 17.5 18.5C15.5 21 13.5 17.5 13.5 14Z", + "banknote": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30 18.5 33 18.5L42 18.5C43.5 18.5 44 18 44 16.5L44 11.5C44 10 43.5 9.5 42 9.5L33 9.5C30 9.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM38.5 11.7A2.3 2.3 0 1 0 38.5 16.3A2.3 2.3 0 1 0 38.5 11.7Z", + /* Identity and keys. */ + "key-bit": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18 35 18L37 18L37 20L39 20L39 18L41 18L41 20.5L43.5 20.5L43.5 16C43.5 16 42 15.5 40 13C37.5 10 34 7.5 31 9.5C28.5 11 28.5 12.5 27 14C20.5 20.2 15 21 13.5 14Z", + "portal": "M13.5 14C13.5 7.5 20 7 27 14C30.5 17.5 34.5 19.5 37.5 18C35.5 15.5 35 13.5 36.5 12C38 10.5 41 10.5 42 12.5C43 14.5 42 16 40.5 17.5C43 17.5 44 14.5 43 11.5C41.5 7.5 34 6.5 30 10.5C28.5 12 28 13 27 14C20.5 20.2 15 21 13.5 14Z", + "signature": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30.5 17 32 15.5C33.5 14 32 12 33.5 10.5C35 9 36.5 11 38 9.5C39.5 8 41 9.5 42.5 8C43.5 7 44 8.5 43 10C41 13 37 19 33 19C30 19 29 16 27 14C20.5 20.2 15 21 13.5 14Z", + /* Web and social. */ + "at-sign": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 19 35.5 19C39.5 19 43 16.5 43 13C43 9.5 40.5 8 38 8C35.5 8 33.5 9.5 33.5 12C33.5 14.5 35.5 16 37.5 15.5C39.5 15 40 13 39.5 11.5C39 13.5 38 14.5 36.5 14C35 13.5 35.5 10.5 38 10.5C40 10.5 41 12 41 13.5C41 15.5 39 17 36.5 17C33.5 17 31 15 29.5 12.5C28.5 11 28 12.8 27 14C20.5 20.2 15 21 13.5 14Z", + "pin-drop": "M13.5 14C13.5 7.5 20 7 27 14C30 17 33.5 20.5 35.5 22C37.5 20.5 41 16.5 41 12.5C41 9 38.5 7 35.5 7C32.5 7 31 9 29.5 11.5C28.7 12.8 28 13 27 14C20.5 20.2 15 21 13.5 14ZM35.5 10.5A2 2 0 1 0 35.5 14.5A2 2 0 1 0 35.5 10.5Z", + "heart-zap": "M13.5 14C13.5 7.5 20 7 27 14C29 16 31 18.5 34.5 21C38 18.5 43 14.5 43 11C43 8.5 41 7.5 39.5 7.5C37.5 7.5 36.5 9 34.5 11C32.5 9 31.5 7.5 29.5 7.5C28.5 7.5 27.5 8 27 9.5C26 12 24 16 20 18.5C16.5 20.5 13.5 17.5 13.5 14Z", + "follow-plus": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18 34.5 18L34.5 21L37.5 21L37.5 18L40.5 18L40.5 15L37.5 15L37.5 12L34.5 12L34.5 15C32 15 30 12 27 14C20.5 20.2 15 21 13.5 14Z", + /* Continuity and craft. */ + "mobius": "M13.5 14C13.5 7.5 21 6 27 14C33 22 40.5 20.5 41.5 14C42.5 7 35 6.5 29 12C24 16.5 18 22 14.5 18C12.5 15.8 13 15 13.5 14Z", + "knot": "M13.5 14C13.5 8 19 6.5 24 10.5C27 13 25 17.5 28.5 19C32 20.5 35 18 34.5 14.5C34 11 30 10 27 14C24.5 17.5 21 21.5 16.5 20C13.5 19 13.5 16 13.5 14Z", + "orbit-dot": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C20.5 20.2 15 21 13.5 14ZM45 8A1.8 1.8 0 1 0 45 11.6A1.8 1.8 0 1 0 45 8Z", + "spark-tail": "M13.5 14C13.5 7.5 20 7 27 14C33 20 39 21 41 17C42 15 41 13.5 43 12.5C44.5 11.8 45 10 44 9C42.5 7.5 40 8 38.5 9.5C34.5 6.5 30 10.5 27 14C20.5 20.2 15 21 13.5 14Z", + "wave-crest": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 34 16.5C36 14.5 34.5 12 36.5 10C38.5 8 41.5 8.5 42.5 11C43.5 13.5 42 16 39.5 17.5C42.5 17.5 44.5 14.5 43.5 11C42 6.5 36 6.5 33 10C31 12.3 28.7 12.3 27 14C20.5 20.2 15 21 13.5 14Z", + "double-cross": "M13.5 14C13.5 8 19 6.5 24.5 11C27 13 29 15 31.5 17C34.5 19.5 38.5 20 41 17.5C43.5 15 42.5 11 39.5 9C36.5 7 33 8.5 30.5 11C28 13.5 25 16.5 21 18.5C16.5 20.5 13.5 17.5 13.5 14Z", + "qr-corner": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30.5 18.5 33.5 18.5L43.5 18.5L43.5 8.5L33.5 8.5C30.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM36 11L41 11L41 16L36 16Z", + "storefront": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5L42 12.5C43.5 12 44 10.5 43 9.5C42 8.3 40 8.5 36.5 8.5C33 8.5 31.5 8.5 30.5 9.5C29.5 10.5 30 12 31.5 12.5L31.5 15C31 13 29 12 27 14C20.5 20.2 15 21 13.5 14Z", + /* Batch two — 2026-08-18. */ + "relay-waves": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 34 17C32.5 15.5 32.5 12.5 34 11C35 13 37 13.5 38 12C36.5 10.5 37 8.5 39 8C41.5 7.4 43.5 9.5 43.5 12C43.5 16.5 38 20 33 17.5C30.5 16.2 29 15.8 27 14C20.5 20.2 15 21 13.5 14Z", + "antenna-mast": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 32 18 35 18L36.5 18L36.5 9.5C35 9 34.5 7 36 6C37.5 5 39.5 6 39.5 7.5C39.5 8.5 39 9.2 38 9.5L38 18L40 18C42.5 18 43.5 15.5 42.5 13C44.5 14 44.5 17.5 42 19C38.5 21 32 19 29 15.5C28.2 14.6 27.7 14.6 27 14C20.5 20.2 15 21 13.5 14Z", + "chain-link": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.5 32.5 17.5C34.5 17.5 36 16 36 14C36 12 34.5 10.5 32.5 10.5C31 10.5 30 11.5 29 13C30 10 32.5 8 35.5 9C38.5 10 39.5 13.5 38 16C39.5 17 42 16.5 43 14.5C43.5 17.5 40 19.5 37 18.5C33.5 17.4 31 14 29.5 12.5C28.6 13.1 27.8 13.4 27 14C20.5 20.2 15 21 13.5 14Z", + "menu-book": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L36.5 18.5L36.5 9.5C34.5 8.5 31.5 9 30 10.5C29.2 11.5 28.2 12.6 27 14C20.5 20.2 15 21 13.5 14ZM38 18.5L42.5 18.5C43.5 18.5 44 18 44 17L44 10.5C44 9.5 43.5 9 42.5 9L38 9Z", + "cup-steam": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18 32 18.5C34.5 19 37.5 19 39.5 18C41.5 17 42 15 42 13L33 13C33 11.5 33.5 10 35 9C34 7.5 34.5 6 36 5.5C35.5 7 36.5 7.5 36.5 9L42 9C43.5 9 44 10 43.5 11.5L42.9 13.4C44.5 13.6 45 15 44 16C43.4 16.6 42.5 16.6 41.8 16.2C40.5 18.8 36.5 20.5 32.5 19.5C29.5 18.7 28.5 15.6 27 14C20.5 20.2 15 21 13.5 14Z", + "receipt": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 19L32 20.5L33.8 19.3L35.6 20.5L37.4 19.3L39.2 20.5L41 19.3L42.5 20.3L42.5 9.5C42.5 8.5 42 8 41 8L34 8C31 8 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM35 12L40 12M35 15L40 15", + "price-tag": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 32 19.5 34.5 20.5C35.5 21 36.5 20.8 37.5 19.8L42.8 14.5C43.8 13.5 43.8 12.2 42.8 11.2L38.5 7.5C36 7 32 7.5 30 10C28.8 11.5 28 13 27 14C20.5 20.2 15 21 13.5 14ZM39 11A1.5 1.5 0 1 0 39 14A1.5 1.5 0 1 0 39 11Z", + "calendar-day": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5C43.5 18.5 44 18 44 16.5L44 10.5C44 9 43.5 8.5 42 8.5L40.5 8.5L40.5 7L38.5 7L38.5 8.5L35 8.5L35 7L33 7L33 8.7C30.8 9.6 29.2 11.6 27 14C20.5 20.2 15 21 13.5 14ZM32 12L44 12", + "clock-open": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 18.5 35 18.5C39 18.5 42 16.5 42 13.5C42 10.5 39.5 8.5 36.5 8.5C33.5 8.5 31.5 10.5 29.5 12.5C28.6 13.3 27.9 13.2 27 14C20.5 20.2 15 21 13.5 14ZM36.5 10.5L36.5 13.5L39 15", + "compass-needle": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C41.9 9 37.5 7.2 33 10L36.5 12.5L31 17L29.5 12.2C28.6 12.9 27.8 13.4 27 14C20.5 20.2 15 21 13.5 14Z", + "north-arrow": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32.5 19.5 35.5 21.5L37.5 12.5L42 14.5C41 10 36.5 6.5 32 8.5C29.8 9.6 28.5 12 27 14C20.5 20.2 15 21 13.5 14Z", + "camera-eye": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32.5 18.5L41 18.5C42.5 18.5 43.5 17.5 43.5 16L43.5 11C43.5 9.5 42.5 8.5 41 8.5L39.5 8.5L38.5 7L35.5 7L34.5 8.5C31.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM37 10.5A3 3 0 1 0 37 16.5A3 3 0 1 0 37 10.5Z", + "mic-live": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 18 32.5 18.5L34.5 18.5L34.5 16.8C32.5 16.3 31.5 14.8 31.5 13L33 13C33 14.5 34.5 15.5 36 15.5C37.5 15.5 39 14.5 39 13L40.5 13C40.5 14.8 39.5 16.3 37.5 16.8L37.5 18.5C40 18.5 42 17 42.5 14.5C44 15.5 43.5 18 41 19.3C37.5 21 31 19.5 29 15.8C28.3 14.9 27.7 14.6 27 14C20.5 20.2 15 21 13.5 14ZM34 9.5C34 8 35 7 36 7C37 7 38 8 38 9.5L38 12C38 13.5 37 14 36 14C35 14 34 13.5 34 12Z", + "note-beam": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31 18 33 17.5L33 10L41 8L41 15.5C42.5 15.8 43.5 17 43 18.3C42.3 19.8 40 20 39.3 18.5C38.9 17.6 39.3 16.6 40.3 16.2L40.3 10.5L33.7 12.2L33.7 18C34.2 19.8 32 21 30.3 20C28.8 19 28.8 17 30.3 16.2C29 15.5 28 14.8 27 14C20.5 20.2 15 21 13.5 14Z", + "ticket-stub": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5C43 18.5 43.5 18 43.5 17C42.5 17 41.8 16.2 41.8 15.2C41.8 14.2 42.5 13.5 43.5 13.5L43.5 10C43.5 9 43 8.5 42 8.5L33.5 8.5C30.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM37.5 8.5L37.5 18.5", + "bell-ring": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.5 32 18L32 19C32 20 32.8 20.8 33.8 20.8C34.8 20.8 35.6 20 35.6 19L41.5 19C41.5 17.5 40 17 40 14.5C40 11 38 8.5 35 8.5C32 8.5 30.8 11 29.3 12.7C28.5 13.4 27.7 13.4 27 14C20.5 20.2 15 21 13.5 14ZM30 19L41.5 19", + "star-review": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 18.5 33.5 20L35.5 15.5L31.5 12.5L36.5 12.5L38 8L39.5 12.5L44.5 12.5L40.5 15.5L42 20.2L38 17.4C36 19 33 20.7 30 18C28.9 17 28 15 27 14C20.5 20.2 15 21 13.5 14Z", + "check-badge": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.8 32.3 18.4C32.1 19.4 32.8 20.3 33.9 20.3C34.9 20.3 35.6 19.5 35.5 18.5C37 18.2 38.4 17.3 39.2 16C40.2 16.4 41.4 16 41.9 15C42.4 14 42 12.9 41.1 12.4C41.2 10.8 40.5 9.3 39.2 8.4C36.4 6.5 32.5 7.8 30.5 10.7C29.4 12.2 28.2 13 27 14C20.5 20.2 15 21 13.5 14ZM33.5 13.5L35.5 15.5L39.5 11.5", + "handshake-loop": "M13.5 14C13.5 8 18.5 6.5 23.5 10C26 11.7 28.5 13.8 31 15.8C33 17.5 36 18 38.5 16.5C41.5 14.7 42 11 39.5 9C37 7 33.5 8 31.5 10.5C30 12.4 28 15.5 24.5 17.8C20 20.8 13.5 19 13.5 14Z", + "ribbon-award": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31 18 33.5 18.5L32.5 21.5L35 20L37.5 21.5L36.5 18.5C39.5 18.5 42 16.5 42 13.5C42 10 39.5 8 36.5 8C33 8 31 10.5 29.2 12.7C28.4 13.4 27.7 13.4 27 14C20.5 20.2 15 21 13.5 14ZM35 11A2.6 2.6 0 1 0 35 16.2A2.6 2.6 0 1 0 35 11Z" +}; + +/* Parked while the mark rests on `portal`; restore the startLogoMotion + calls in application.js and design.js to revive the cycle. */ +/* + * Portal at small sizes: the full portal's inner spiral cannot resolve under + * ~40px, so icon-scale marks use this variant — the same continuous line, + * with the doorway drawn as a literal opening in the right lobe. One line, + * one gap: the portal. + */ +export const LOGO_PORTAL_SMALL = "M41.5 16.5C40 20.8 33.5 20.5 27 14C20 7 13.5 7.5 13.5 14C13.5 21 20.5 20.2 27 14C34.5 7 42 7.5 41.7 11.5"; + +const ORDER = ["portal", "loop", "card", "bubble", "cursor"]; + +/* + * THE MARK — "07 Portal", from the logo exploration deck: a doorway whose far + * side is lit. The frame is a heavy silhouette at maximum contrast so it + * survives any ground; all of the depth lives in the interior gradient, + * running from the ramp's mid tone at the head to its faintest at the floor. + * The frame's legs run to the floor with no bottom bar: the door does not + * close behind you. + * + * Colours come from CSS (.jw-portal-*), so the mark follows the theme. + */ +export const LOGO_MARKUP = ` +`.trim(); + +/* The one-line loop markup, kept for the /design specimens and the lab. */ +export const LOGO_LINE_MARKUP = ` +`.trim(); + +/* Each mounted mark needs its own gradient id, or the second one on the page + would reference the first one's definition. */ +let gradientSeed = 0; + +/** + * Renders the mark into `host` and returns the created . + * Pass `{ icon: true }` to wrap it in the app-icon container. + * Motion is opt-in via startLogoMotion so a static mark costs nothing. + */ +export function mountLogo(host, { icon = false, variant = "portal" } = {}) { + if (!host) return null; + + /* "portal" is the brand mark (the lit doorway). "line" is the one-line loop + used by the /design specimens and the lab. `icon` wraps either in the + app-icon container from components.css. */ + const markup = variant === "line" ? LOGO_LINE_MARKUP : LOGO_MARKUP; + host.innerHTML = icon ? `${markup}` : markup; + + const svg = host.querySelector(".jw-mark"); + gradientSeed += 1; + + if (variant === "line") { + const gradient = svg.querySelector(".jw-mark-gradient"); + const path = svg.querySelector(".jw-mark-path"); + const id = `jw-mark-gradient-${gradientSeed}`; + gradient.id = id; + path.setAttribute("stroke", `url(#${id})`); + } else { + const glow = svg.querySelector(".jw-portal-glow"); + const id = `jw-portal-glow-${gradientSeed}`; + glow.id = id; + svg.querySelector(".jw-portal-light").setAttribute("fill", `url(#${id})`); + } + + return svg; +} + +/** + * Cycles the mark through its states. Returns a teardown. + * + * `gsap` and `MorphSVGPlugin` are passed in rather than imported so this stays + * usable without motion: if either is missing the mark simply rests on `loop`. + */ +export function startLogoMotion(svg, { gsap, MorphSVGPlugin, hold = 2.4, morph = 1.1 } = {}) { + if (!svg || !gsap || !MorphSVGPlugin) return () => {}; + + const path = svg.querySelector(".jw-mark-path"); + if (!path) return () => {}; + + const timeline = gsap.timeline({ repeat: -1, defaults: { ease: "power2.inOut" } }); + + for (let index = 1; index <= ORDER.length; index += 1) { + /* Wraps back to `loop` on the last step, so the cycle closes seamlessly. */ + const next = ORDER[index % ORDER.length]; + timeline.to(path, { + duration: morph, + morphSVG: { shape: LOGO_STATES[next], shapeIndex: 0 } + }, `+=${hold}`); + } + + return () => timeline.kill(); +} diff --git a/docker/justworks/public/assets/ds/mobile.css b/docker/justworks/public/assets/ds/mobile.css new file mode 100644 index 00000000..853ad3b2 --- /dev/null +++ b/docker/justworks/public/assets/ds/mobile.css @@ -0,0 +1 @@ +.button{position:relative;isolation:isolate;min-height:var(--jw-button-height);display:inline-flex;align-items:center;justify-content:center;gap:var(--jw-space-2);padding:var(--jw-button-padding);border:0;border-radius:var(--jw-button-radius);background:transparent;color:var(--jw-button-secondary-text);font-weight:var(--jw-button-weight);cursor:pointer}.button::before{content:"";position:absolute;inset:0;z-index:-1;padding:var(--jw-button-rim-width);border-radius:inherit;background:linear-gradient(135deg,var(--jw-button-rim-bright) 0%,transparent var(--jw-button-rim-gap-start),transparent var(--jw-button-rim-gap-end),var(--jw-button-rim-bright) 100%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;pointer-events:none}.button::after{content:"";position:absolute;inset:var(--jw-button-rim-width);z-index:-2;border-radius:calc(var(--jw-button-radius) - var(--jw-button-rim-width));background:var(--jw-button-secondary-fill)}.button:hover:not(:disabled)::after{background:var(--jw-button-secondary-fill-hover)}.button.icon.ring{width:var(--jw-ring-button-size);min-width:var(--jw-ring-button-size);height:var(--jw-ring-button-size);min-height:var(--jw-ring-button-size);padding:0;border-radius:var(--jw-radius-pill);color:var(--jw-text-primary)}.button.icon.ring::before{padding:var(--jw-app-icon-rim-width);background:linear-gradient(135deg,var(--jw-app-icon-rim-bright) 0%,var(--jw-app-icon-rim-fade) 32%,var(--jw-app-icon-rim-fade) 68%,var(--jw-app-icon-rim-bright) 100%)}.button.icon.ring::after{inset:var(--jw-app-icon-rim-width);border-radius:var(--jw-radius-pill);background:var(--jw-bg)}.mobile-menu-button{display:none;position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:max(var(--jw-space-5),env(safe-area-inset-bottom));z-index:var(--jw-z-overlay);--jw-ring-button-size:54px}.mobile-menu{position:fixed;inset:0;z-index:var(--jw-z-modal);display:none;flex-direction:column;overflow:auto;padding:max(var(--jw-space-6),env(safe-area-inset-top)) max(var(--jw-space-6),env(safe-area-inset-right)) max(var(--jw-space-6),env(safe-area-inset-bottom)) max(var(--jw-space-6),env(safe-area-inset-left));background:var(--jw-modal-bg);-webkit-backdrop-filter:blur(26px);backdrop-filter:blur(26px)}.mobile-menu.open{display:flex}.mobile-menu-close{position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:max(var(--jw-space-5),env(safe-area-inset-bottom));--jw-ring-button-size:54px}.mobile-menu-items{position:static!important;inset:auto!important;z-index:auto!important;width:auto!important;height:auto!important;flex:1;display:grid!important;align-content:center;justify-items:start;gap:var(--jw-space-6);padding:0!important;border:0!important;background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;pointer-events:auto!important}.mobile-menu-items a{color:var(--jw-text-primary);font-family:var(--jw-font-display);font-size:clamp(2.2rem,10vw,3.4rem);font-weight:700;line-height:1.3;text-decoration:none}.mobile-menu-foot{display:flex;align-items:center;justify-content:space-between;gap:var(--jw-space-4);padding-top:var(--jw-space-5);margin-bottom:calc(54px + var(--jw-space-6));border-top:1px solid var(--jw-line)}.mobile-menu-foot>span{color:var(--jw-text-muted);font-size:.8125rem;font-weight:var(--jw-weight-bold);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}.mobile-menu-button svg,.mobile-menu-close svg,.back-to-top svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.back-to-top{display:none;position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:calc(max(var(--jw-space-5),env(safe-area-inset-bottom)) + 66px);z-index:var(--jw-z-overlay);--jw-ring-button-size:44px;opacity:0;visibility:hidden;transform:translateY(var(--jw-space-2));transition:opacity var(--jw-duration-base) var(--jw-ease-out),transform var(--jw-duration-base) var(--jw-ease-out),visibility var(--jw-duration-base)}.back-to-top.show{opacity:1;visibility:visible;transform:none}@media(max-width:980px){.mobile-menu-button,.back-to-top{display:inline-flex}}@media(max-height:520px) and (orientation:landscape){.mobile-menu-items{align-content:start;padding-top:var(--jw-space-10)!important}.mobile-menu-items a{font-size:2rem}.mobile-menu-foot{margin-top:var(--jw-space-6)}} diff --git a/docker/justworks/public/assets/ds/presets.css b/docker/justworks/public/assets/ds/presets.css new file mode 100644 index 00000000..8cf7c3b4 --- /dev/null +++ b/docker/justworks/public/assets/ds/presets.css @@ -0,0 +1,2 @@ +/* Layout presets for page composition. */ +.jw-container{width:min(var(--jw-container),calc(100% - 2.5rem));margin-inline:auto}.jw-container--narrow{width:min(var(--jw-container-narrow),calc(100% - 2.5rem));margin-inline:auto}.jw-page{padding-block:clamp(3rem,7vw,7rem)}.jw-stack{display:grid;gap:var(--jw-stack-gap,var(--jw-space-5))}.jw-cluster{display:flex;align-items:center;flex-wrap:wrap;gap:var(--jw-cluster-gap,var(--jw-space-3))}.jw-split{display:grid;grid-template-columns:minmax(0,.9fr) minmax(22rem,1.1fr);align-items:start;gap:clamp(2rem,8vw,7rem)}.jw-split__copy{position:sticky;top:calc(var(--jw-topbar-height) + var(--jw-space-10))}.jw-split__copy h1{max-width:10ch;margin:var(--jw-space-3) 0 var(--jw-space-5);font-family:var(--jw-font-display);font-size:clamp(3rem,7vw,6rem);font-weight:650;line-height:.9;letter-spacing:-.06em}.jw-split__copy p{max-width:38rem;color:var(--jw-text-body);font-size:1.125rem;line-height:1.6}.jw-grid{display:grid;grid-template-columns:repeat(var(--jw-grid-columns,3),minmax(0,1fr));gap:var(--jw-grid-gap,var(--jw-space-4))}.jw-hero{position:relative;min-height:clamp(22rem,52vw,42rem);display:flex;align-items:flex-end;padding:clamp(2rem,7vw,6rem);overflow:hidden}.jw-hero__content{position:relative;z-index:1;max-width:60rem}.jw-hero h1{margin:var(--jw-space-4) 0;font-family:var(--jw-font-display);font-size:clamp(3.5rem,10vw,9rem);font-weight:650;line-height:.8;letter-spacing:-.07em}.jw-eyebrow{margin:0;color:var(--jw-accent);font-size:.8125rem;font-weight:var(--jw-weight-black);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}@media(max-width:980px){.jw-split{grid-template-columns:1fr}.jw-split__copy{position:static}.jw-grid{--jw-grid-columns:2}}@media(max-width:640px){.jw-container,.jw-container--narrow{width:min(100% - 2rem,var(--jw-container))}.jw-grid{--jw-grid-columns:1}.jw-hero{padding-inline:var(--jw-space-5)}} diff --git a/docker/justworks/public/assets/ds/theme.js b/docker/justworks/public/assets/ds/theme.js new file mode 100644 index 00000000..2438e829 --- /dev/null +++ b/docker/justworks/public/assets/ds/theme.js @@ -0,0 +1,119 @@ +/* + * Theme controller. + * + * Three preferences: "system" (default), "light", "dark". The choice is + * stored in localStorage and applied as `data-theme` on ; "system" + * removes the attribute so the `prefers-color-scheme` block in tokens.css + * takes over. A small inline snippet in each page's applies the stored + * value before first paint, so this module only has to own the control. + */ + +export const THEME_STORAGE_KEY = "jw-theme"; +const THEMES = ["system", "light", "dark"]; + +/* Must match --jw-bg in tokens.css for each theme. */ +const BROWSER_CHROME = { light: "#fafafa", dark: "#0b100f" }; + +const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); +const listeners = new Set(); + +function readStored() { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + return THEMES.includes(stored) ? stored : "system"; + } catch { + /* Private mode or blocked storage: fall back to the system preference. */ + return "system"; + } +} + +/** The preference the user chose ("system" | "light" | "dark"). */ +export function getPreference() { + return readStored(); +} + +/** The theme actually on screen ("light" | "dark"), resolving "system". */ +export function getResolvedTheme(preference = readStored()) { + if (preference !== "system") return preference; + return darkQuery.matches ? "dark" : "light"; +} + +export function applyTheme(preference) { + const next = THEMES.includes(preference) ? preference : "system"; + const root = document.documentElement; + + if (next === "system") root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", next); + + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute("content", BROWSER_CHROME[getResolvedTheme(next)]); + + try { + localStorage.setItem(THEME_STORAGE_KEY, next); + } catch { + /* Not fatal: the theme still applies for this page view. */ + } + + for (const listener of listeners) listener(next, getResolvedTheme(next)); + return next; +} + +/** Subscribe to preference changes. Returns an unsubscribe function. */ +export function onThemeChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/* Keep the browser chrome correct when the OS flips while set to "system". */ +darkQuery.addEventListener("change", () => { + if (readStored() === "system") applyTheme("system"); +}); + +const ICONS = { + system: '', + light: '', + dark: '' +}; + +const LABELS = { system: "Match system theme", light: "Light theme", dark: "Dark theme" }; + +/** + * Renders the segmented theme control into `container` and keeps its pressed + * state in sync with the current preference. + */ +export function mountThemeToggle(container) { + if (!container) return null; + + container.classList.add("theme-toggle"); + container.setAttribute("role", "group"); + container.setAttribute("aria-label", "Color theme"); + container.innerHTML = THEMES.map((theme) => ` + + `).join(""); + + const buttons = [...container.querySelectorAll("[data-theme-option]")]; + + function sync(preference = readStored()) { + for (const button of buttons) { + button.setAttribute("aria-pressed", String(button.dataset.themeOption === preference)); + } + } + + container.addEventListener("click", (event) => { + const button = event.target.closest("[data-theme-option]"); + if (button) applyTheme(button.dataset.themeOption); + }); + + onThemeChange(sync); + sync(); + return { sync }; +} + +/** + * The snippet that must run before first paint. Kept here so the inline copy + * in each page's has a single documented source; if you change it, + * update index.html and design/index.html to match. + */ +export const PREPAINT_SNIPPET = `(function(){try{var t=localStorage.getItem("${THEME_STORAGE_KEY}");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}})()`; diff --git a/docker/justworks/public/assets/ds/topbar.css b/docker/justworks/public/assets/ds/topbar.css new file mode 100644 index 00000000..2386ad8a --- /dev/null +++ b/docker/justworks/public/assets/ds/topbar.css @@ -0,0 +1 @@ +[data-topbar]{position:fixed;inset:0 0 auto;z-index:var(--jw-z-overlay);height:76px;display:flex;align-items:center;gap:var(--jw-space-6);padding:0 clamp(1.125rem,5vw,4.375rem);border-bottom:1px solid transparent;background:transparent;color:var(--jw-text-primary);pointer-events:none;transition:background var(--jw-duration-base) var(--jw-ease-out),border-color var(--jw-duration-base) var(--jw-ease-out),box-shadow var(--jw-duration-base) var(--jw-ease-out)}[data-topbar]>*{pointer-events:auto}[data-topbar].is-scrolled{border-bottom-color:var(--jw-topbar-scrolled-border);background:var(--jw-topbar-scrolled-bg);box-shadow:var(--jw-topbar-scrolled-shadow);-webkit-backdrop-filter:blur(var(--jw-topbar-blur));backdrop-filter:blur(var(--jw-topbar-blur))}[data-theme="dark"] [data-topbar].is-scrolled{border-bottom-color:rgba(255,255,255,.06);background:linear-gradient(180deg,rgba(10,10,10,.72),rgba(10,10,10,.42))}.jw-topbar-links,.jw-topbar .links{display:flex;align-items:center;gap:var(--jw-space-2);margin-left:auto}.jw-topbar-links a,.jw-topbar .links a{min-height:42px;display:inline-flex;align-items:center;padding:0 var(--jw-space-3);border-radius:var(--jw-radius-sm);color:var(--jw-text-body);text-decoration:none;font-size:.875rem;font-weight:var(--jw-weight-bold)}.jw-topbar-links a:hover,.jw-topbar .links a:hover{background:var(--jw-surface-sunken);color:var(--jw-text-primary)}.jw-topbar-end{display:flex;align-items:center;gap:var(--jw-space-3)}.chrome-grounded{padding-top:76px}.jw-chrome--inverse{color:var(--jw-white)}.jw-chrome--inverse .jw-brand small::before{color:rgba(255,255,255,.62)}@media(max-width:980px){[data-topbar]{height:66px;padding-inline:var(--jw-space-5)}.chrome-grounded{padding-top:66px}.jw-topbar-links,.jw-topbar .links,.jw-topbar-end{display:none}}@media(max-width:420px){.jw-brand b,.jw-topbar .brand b{font-size:.875rem}.jw-brand small,.jw-topbar .brand small{font-size:.875rem!important}.jw-brand small::before,.jw-topbar .brand small::before{margin-inline:var(--jw-space-1)}} diff --git a/docker/justworks/public/assets/early-theme.js b/docker/justworks/public/assets/early-theme.js new file mode 100644 index 00000000..e7b5f954 --- /dev/null +++ b/docker/justworks/public/assets/early-theme.js @@ -0,0 +1,4 @@ +try { + const theme = localStorage.getItem('jw-theme'); + if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme; +} catch { /* System theme also works without storage. */ } diff --git a/docker/justworks/public/assets/launcher.css b/docker/justworks/public/assets/launcher.css new file mode 100644 index 00000000..01c7c83f --- /dev/null +++ b/docker/justworks/public/assets/launcher.css @@ -0,0 +1,49 @@ +[hidden]{display:none!important} +.launcher{padding-top:calc(var(--jw-topbar-height) + var(--jw-space-16));padding-bottom:var(--jw-space-8)} +/* The desktop chrome uses 5vw gutters; keep both launch cards within them. + Below the chrome's mobile breakpoint the existing spacing stays unchanged. */ +@media(min-width:981px){.launcher{width:min(var(--jw-container),calc(100% - 2 * clamp(1.125rem,5vw,4.375rem)))}} +.intro{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--jw-space-8);margin-bottom:var(--jw-space-10)} +.intro h1,.setup-intro h1{font-family:var(--jw-font-display);font-size:clamp(3rem,6vw,5.8rem);line-height:.98;letter-spacing:-.065em;margin:var(--jw-space-4) 0 0;font-weight:650} +.intro-note{color:var(--jw-text-body);line-height:1.7;margin:0 0 var(--jw-space-2);max-width:23rem} +.launch-options{--jw-grid-columns:2;--jw-grid-gap:var(--jw-space-5)} +.launch-card{padding:var(--jw-space-6);display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--jw-shadow-sm)} +.card-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--jw-space-4)} +.card-arrow{font-size:1.5rem;color:var(--jw-text-muted)} +.website-art,.business-art{height:235px;margin:var(--jw-space-6) 0;display:grid;place-items:center;background:var(--jw-surface-sunken);border-radius:var(--jw-radius-md);overflow:hidden;padding:var(--jw-space-6)} +.mini-browser{width:86%;max-width:320px;background:var(--jw-bg);border:1px solid var(--jw-line-strong);border-radius:var(--jw-radius-md);transform:rotate(-4deg);box-shadow:var(--jw-shadow-lg);overflow:hidden} +.mini-toolbar{height:30px;display:flex;align-items:center;gap:var(--jw-space-1);padding-inline:var(--jw-space-3);border-bottom:1px solid var(--jw-line)} +.mini-toolbar>span{width:5px;height:5px;border-radius:var(--jw-radius-pill);background:var(--jw-line-strong)} +.mini-toolbar>b{font-size:13px;font-weight:400;margin-left:var(--jw-space-2);color:var(--jw-text-muted)} +.mini-page{position:relative;padding:var(--jw-space-5)} +.mini-page-copy{position:relative;font-family:var(--jw-font-display);font-size:clamp(1.35rem,2vw,1.8rem);line-height:1.05;letter-spacing:-.04em;font-weight:650} +.mini-sun{position:absolute;right:var(--jw-space-5);top:var(--jw-space-5);width:65px;height:65px;border-radius:50%;background:var(--jw-accent-soft)} +.mini-lines{width:55%;height:14px;border-block:3px solid var(--jw-line);margin-block:var(--jw-space-3)} +.mini-cta{font-size:13px;font-weight:650;color:var(--jw-accent-strong)} +.mini-operations{width:90%;max-width:330px;display:grid;gap:var(--jw-space-2);transform:rotate(3deg)} +.mini-operation{display:grid;grid-template-columns:38px 1fr;column-gap:var(--jw-space-3);align-items:center;background:var(--jw-bg);border:1px solid var(--jw-line);border-radius:var(--jw-radius-md);padding:var(--jw-space-3);box-shadow:var(--jw-shadow-sm)} +.mini-operation:nth-child(2){transform:translateX(12px)} +.mini-operation>b{font-size:14px}.mini-operation>span:last-child{font-size:13px;color:var(--jw-text-muted)} +.operation-mark{grid-row:span 2;display:grid;place-items:center;width:38px;height:38px;background:var(--jw-accent-soft);border-radius:var(--jw-radius-sm);color:var(--jw-accent-strong);font-size:1.25rem} +.card-copy h2{font-family:var(--jw-font-display);font-size:2.4rem;line-height:1.05;letter-spacing:-.04em;margin:0 0 var(--jw-space-3)} +.card-copy p{color:var(--jw-text-body);margin:0 0 var(--jw-space-6);max-width:32rem;line-height:1.65} +.launch-card>.jw-button{margin-top:auto;justify-content:space-between}.launch-card>.jw-help{margin-top:var(--jw-space-3)} +.connected-strip{display:flex;align-items:center;justify-content:space-between;gap:var(--jw-space-5);padding-block:var(--jw-space-8);border-bottom:1px solid var(--jw-line)} +.connected-strip p{margin:var(--jw-space-2) 0 0;color:var(--jw-text-muted);font-size:.875rem}.connected-strip>div{min-width:0} +.site-address{overflow-wrap:anywhere}.status-dot{display:inline-block;width:8px;height:8px;background:var(--jw-accent);border-radius:50%;margin-right:var(--jw-space-3)} +.launcher-footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:var(--jw-space-3);color:var(--jw-text-muted);font-size:.8125rem;padding-top:var(--jw-space-6)} +@media(max-width:980px){.intro{display:block}.intro-note{margin-top:var(--jw-space-5)}.connected-strip{align-items:flex-start;flex-direction:column}.launcher-footer{padding-bottom:var(--jw-space-16)}} +.launch-options:not(:has(#business-card:not([hidden]))){--jw-grid-columns:1;max-width:42rem} + +#hosted-surface{position:fixed;inset:0;display:flex;flex-direction:column;background:var(--jw-bg)} +#hosted-site{width:100%;flex:1;min-height:0;border:0} +#back-to-launcher{font:inherit;text-align:left;padding:12px 20px;border:0;border-bottom:1px solid var(--jw-line);background:var(--jw-bg);color:var(--jw-text-primary);cursor:pointer} + +.hosted-navigation{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 12px;border-bottom:1px solid var(--jw-line)} +.hosted-navigation #back-to-launcher{margin-right:auto;padding:10px 0;border:0;font-size:13px} +#hosted-switch{position:absolute;left:max(var(--jw-space-5),env(safe-area-inset-left));bottom:max(var(--jw-space-5),env(safe-area-inset-bottom));z-index:2;--jw-ring-button-size:54px;width:auto;min-width:54px;gap:9px;padding:0 18px;text-decoration:none;font-size:13px;font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.08);-webkit-backdrop-filter:blur(var(--jw-app-icon-blur));backdrop-filter:blur(var(--jw-app-icon-blur))} +#hosted-switch svg{width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} +#hosted-switch:focus-visible{outline:2px solid var(--jw-accent);outline-offset:4px} +.hosted-navigation:not(:has(#back-to-launcher:not([hidden]))){display:none} +@media(max-width:600px){#hosted-switch{width:54px;padding:0}#switch-label{display:none}} +@media(prefers-reduced-motion:no-preference){#hosted-switch{transition:transform .18s ease,box-shadow .18s ease}#hosted-switch:hover{transform:translateY(-2px);box-shadow:0 6px 24px rgba(0,0,0,.12)}#hosted-switch:active{transform:translateY(0)}} diff --git a/docker/justworks/public/assets/launcher.js b/docker/justworks/public/assets/launcher.js new file mode 100644 index 00000000..96c70054 --- /dev/null +++ b/docker/justworks/public/assets/launcher.js @@ -0,0 +1,66 @@ +import { configureNavigation, mountAppChrome } from './ds/frame.js'; +const $ = id => document.getElementById(id); +const base = new URL('..', import.meta.url).pathname.replace(/\/?$/, '/'); +const key = 'archipelago-justworks-site-v1'; +let connectedSite; +let businessOpen = false; +configureNavigation({brandHref: base, items: [[base, 'Your launcher']]}); +mountAppChrome($('app'), {label: 'Archipelago'}); +function showLauncher() { + if (!connectedSite) return; + $('hosted-surface').hidden = true; + $('app').hidden = false; +} +function openHosted(url, title) { + const target = new URL(url, location.href); + businessOpen = target.origin === location.origin && target.pathname.startsWith('/business-demo/'); + $('switch-label').textContent = businessOpen ? 'Website' : 'Business'; + $('switch-building').toggleAttribute('hidden', businessOpen); + $('switch-person').toggleAttribute('hidden', !businessOpen); + const switchLabel = businessOpen ? 'Switch to customer website' : 'Switch to Business tools'; + $('hosted-switch').setAttribute('aria-label', switchLabel); + $('hosted-switch').title = switchLabel; + $('hosted-switch').href = businessOpen ? (connectedSite?.editorUrl || 'https://justworks.cash/') : (connectedSite?.businessUrl || '/business-demo/'); + $('app').hidden = true; + $('hosted-site').src = url; + $('hosted-site').title = title; + $('back-to-launcher').hidden = !connectedSite; + $('hosted-surface').hidden = false; +} +$('hosted-switch').addEventListener('click', event => { + event.preventDefault(); + openHosted($('hosted-switch').href, businessOpen ? 'Just Works website' : 'Just Works business tools'); +}); +$('back-to-launcher').addEventListener('click', showLauncher); +$('website-launch').addEventListener('click', event => { + event.preventDefault(); + openHosted(connectedSite?.editorUrl || 'https://justworks.cash/', 'Just Works website'); +}); +$('business-launch').addEventListener('click', event => { + event.preventDefault(); + if (connectedSite) openHosted(connectedSite.businessUrl, 'Just Works business tools'); +}); +async function refresh() { + $('business-card').hidden = true; + try { + const saved = localStorage.getItem(key); + if (!saved) return; + const response = await fetch(`${base}api/site?url=${encodeURIComponent(saved)}`, {cache:'no-store', signal:AbortSignal.timeout(15000)}); + const site = await response.json(); + if (!response.ok || site.published !== true) return; + connectedSite = site; + $('website-launch').href = site.editorUrl; + $('website-launch').textContent = 'Open your website'; + $('business-card').hidden = false; + $('business-launch').href = site.businessUrl; + $('business-launch').textContent = 'Open business tools'; + $('business-help').textContent = site.businessAvailable === true ? 'Your merchant dashboard, POS and signage.' : 'Your website is ready. Business tools depend on availability in Just Works.'; + $('connection-title').textContent = site.name; + $('connection-description').textContent = site.url; + $('forget').hidden = false; + showLauncher(); + } catch { /* Keep the original Just Works first screen available. */ } +} +$('forget').addEventListener('click', () => { localStorage.removeItem(key); location.reload(); }); +window.addEventListener('storage', () => { if (!connectedSite) void refresh(); }); +void refresh(); diff --git a/docker/justworks/public/assets/tokens.css b/docker/justworks/public/assets/tokens.css new file mode 100644 index 00000000..75822d0b --- /dev/null +++ b/docker/justworks/public/assets/tokens.css @@ -0,0 +1,553 @@ +/* + * Just Works design tokens — the single source of truth for color, type, + * space, radius, shadow, and motion. Everything else in the site (base.css, + * components.css, home.css, cms.css, public-page.css) and the /design viewer + * read from here. Change a value in this file and both the live site and the + * design system move together. + * + * Three tiers, in order: + * 1. Primitives --jw-- raw palette, identical in every theme + * 2. Semantic --jw-bg, --jw-text-*, --jw-accent... theme-dependent roles + * 3. Component --jw-button-*, --jw-field-*... what components actually use + * + * Components must consume tier 3 (or tier 2), never tier 1 directly, so a + * theme swap reaches every surface. + * + * THEMING: light is the default; dark is declared twice on purpose, once for + * `prefers-color-scheme: dark` (system setting, works with JS disabled) and + * once for `[data-theme="dark"]` (explicit toggle). The two blocks must stay + * in sync — `npm run check` fails the build if they drift apart. + */ + +/* --------------------------------------------------------------------------- + * 1. PRIMITIVES — the raw brand palette. Theme-independent. + * ------------------------------------------------------------------------ */ + +:root { + /* Mint — the primary brand hue. */ + --jw-mint-50: #e8faf6; + --jw-mint-300: #55d6bd; + --jw-mint-400: #20c5a8; + --jw-mint-500: #12a98e; + --jw-mint-600: #08806b; + + /* Sun — warmth, highlights, the "create" energy. */ + --jw-sun-50: #fff8e3; + --jw-sun-300: #ffd463; + --jw-sun-400: #ffcc4d; + --jw-sun-600: #cd8f08; + + /* Coral — alerts, destructive states, accent contrast. */ + --jw-coral-50: #ffecec; + --jw-coral-300: #ff8585; + --jw-coral-500: #ed3c3c; + + /* Blue — links, information, the Nostr/identity surfaces. */ + --jw-blue-50: #eaf0ff; + --jw-blue-100: #cfdcff; + --jw-blue-300: #6f95ff; + --jw-blue-400: #2764ff; + + /* Canonical white and black. Every white or black surface, fill, and piece + of text in the system resolves to one of these two — never to #fff or + #000 — so the whole site moves if they change. */ + --jw-white: #fafafa; + --jw-black: #0a0a0a; + + /* Neutral ramp between them. */ + --jw-neutral-0: var(--jw-white); + --jw-neutral-100: #f2f4f3; + --jw-neutral-1000: var(--jw-black); + + /* Ember — the accent. A vermilion red one step from Bitcoin orange: + provenance without impersonating the coin itself. */ + --jw-ember-100: #ffe1d6; + --jw-ember-200: #ffb49a; + --jw-ember-300: #ff7a52; + --jw-ember-400: #f4511e; + --jw-ember-600: #b0300a; + + /* Bitcoin orange. Payment rails only — never decorative. */ + --jw-bitcoin: #f7931a; +} + +/* --------------------------------------------------------------------------- + * 2. SEMANTIC — light theme (the default). + * ------------------------------------------------------------------------ */ + +:root { + color-scheme: light; + + /* Ground and surfaces, back to front. */ + --jw-bg: var(--jw-white); + --jw-bg-gradient: linear-gradient(180deg, var(--jw-white) 0%, #f4f5f4 46%, #eef0ef 78%, #e9ebea 100%); + --jw-surface: var(--jw-white); + --jw-surface-raised: var(--jw-white); + --jw-surface-sunken: var(--jw-neutral-100); + --jw-surface-veil: rgba(250, 250, 250, 0.78); + --jw-surface-glass: rgba(250, 250, 250, 0.86); + --jw-scrim: rgba(10, 10, 10, 0.42); + + /* Text, strongest to faintest. */ + --jw-text-primary: var(--jw-black); + --jw-text-strong: #1f2422; + --jw-text-body: #454545; + --jw-text-muted: #737373; + --jw-text-subtle: #8f9995; + --jw-text-inverse: var(--jw-white); + --jw-text-on-accent: var(--jw-black); + + /* Lines and dividers. */ + --jw-line: rgba(10, 10, 10, 0.1); + --jw-line-strong: rgba(10, 10, 10, 0.2); + --jw-line-faint: rgba(10, 10, 10, 0.06); + + /* Accents. `accent` is the one the eye should follow. */ + --jw-accent: var(--jw-ember-400); + --jw-accent-strong: var(--jw-ember-600); + --jw-accent-soft: var(--jw-ember-100); + --jw-accent-contrast: var(--jw-white); + --jw-accent-ring: rgba(244, 81, 30, 0.22); + --jw-highlight: var(--jw-sun-400); + --jw-highlight-soft: var(--jw-sun-50); + --jw-info: var(--jw-blue-400); + --jw-info-soft: var(--jw-blue-50); + + /* Status. */ + --jw-success: var(--jw-mint-600); + --jw-success-soft: var(--jw-mint-50); + --jw-warning: var(--jw-sun-600); + --jw-warning-soft: var(--jw-sun-50); + --jw-danger: var(--jw-coral-500); + --jw-danger-soft: var(--jw-coral-50); + + /* Shadows carry the light theme's depth. */ + --jw-shadow-color: rgba(0, 0, 0, 0.12); + --jw-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06); + --jw-shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.045); + --jw-shadow-md: 0 14px 34px rgba(0, 0, 0, 0.055); + --jw-shadow-lg: 0 18px 54px rgba(0, 0, 0, 0.08); + --jw-shadow-xl: 0 28px 80px rgba(0, 0, 0, 0.16); + --jw-shadow-accent: 0 14px 34px rgba(244, 81, 30, 0.16); + + /* Ambient shadow behind the hero device: enough depth to lift a dark + object off either ground without reading as a glow. */ + --jw-device-shadow: + 0 4px 10px rgba(0, 0, 0, 0.18), + 0 30px 60px rgba(0, 0, 0, 0.26); + + /* The brand mark's conic sweep, so the logo follows the theme. */ + --jw-mark-sweep: conic-gradient(from 200deg, #ffcc4d, #ff5d5d, #2764ff, #20c5a8, #ffcc4d); + --jw-mark-inset: var(--jw-white); +} + +/* --------------------------------------------------------------------------- + * 2b. SEMANTIC — dark theme, applied by system preference. + * + * Keep every declaration below identical to the [data-theme="dark"] block. + * ------------------------------------------------------------------------ */ + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + + --jw-bg: var(--jw-black); + --jw-bg-gradient: linear-gradient(180deg, var(--jw-black), var(--jw-black)); + --jw-surface: rgba(255, 255, 255, 0.04); + --jw-surface-raised: rgba(255, 255, 255, 0.07); + --jw-surface-sunken: rgba(0, 0, 0, 0.28); + --jw-surface-veil: rgba(18, 18, 18, 0.72); + --jw-surface-glass: rgba(10, 10, 10, 0.82); + --jw-scrim: rgba(0, 0, 0, 0.66); + + --jw-text-primary: var(--jw-white); + --jw-text-strong: rgba(250, 250, 250, 0.92); + --jw-text-body: rgba(250, 250, 250, 0.72); + --jw-text-muted: rgba(250, 250, 250, 0.56); + --jw-text-subtle: rgba(250, 250, 250, 0.44); + --jw-text-inverse: var(--jw-black); + --jw-text-on-accent: #052e27; + + --jw-line: rgba(255, 255, 255, 0.12); + --jw-line-strong: rgba(255, 255, 255, 0.24); + --jw-line-faint: rgba(255, 255, 255, 0.07); + + --jw-accent: var(--jw-ember-300); + --jw-accent-strong: var(--jw-ember-200); + --jw-accent-soft: rgba(244, 81, 30, 0.16); + --jw-accent-contrast: var(--jw-black); + --jw-accent-ring: rgba(255, 122, 82, 0.28); + --jw-highlight: var(--jw-sun-300); + --jw-highlight-soft: rgba(255, 204, 77, 0.14); + --jw-info: var(--jw-blue-300); + --jw-info-soft: rgba(39, 100, 255, 0.16); + + --jw-success: var(--jw-mint-300); + --jw-success-soft: rgba(32, 197, 168, 0.14); + --jw-warning: var(--jw-sun-300); + --jw-warning-soft: rgba(255, 204, 77, 0.14); + --jw-danger: var(--jw-coral-300); + --jw-danger-soft: rgba(255, 93, 93, 0.16); + + --jw-shadow-color: rgba(0, 0, 0, 0.6); + --jw-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --jw-shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.34); + --jw-shadow-md: 0 14px 34px rgba(0, 0, 0, 0.42); + --jw-shadow-lg: 0 18px 54px rgba(0, 0, 0, 0.5); + --jw-shadow-xl: 0 28px 80px rgba(0, 0, 0, 0.62); + --jw-shadow-accent: 0 14px 34px rgba(244, 81, 30, 0.22); + + --jw-topbar-scrolled-bg: linear-gradient(180deg, rgba(10, 10, 10, 0.6), rgba(10, 10, 10, 0.3)); + --jw-topbar-scrolled-border: rgba(255, 255, 255, 0.06); + --jw-topbar-scrolled-shadow: none; + --jw-button-primary-fill: var(--jw-white); + --jw-button-primary-text: var(--jw-black); + --jw-button-primary-fill-hover: #ececec; + --jw-button-secondary-fill: var(--jw-black); + --jw-button-secondary-fill-hover: #171717; + --jw-app-icon-bg: rgba(10, 10, 10, 0.72); + --jw-app-icon-rim-bright: rgba(255, 255, 255, 0.85); + --jw-app-icon-rim-fade: rgba(10, 10, 10, 0.04); + --jw-modal-bg: rgba(17, 17, 17, 0.78); + --jw-portal-depth: drop-shadow(0 0 5px rgba(250, 250, 250, 0.22)); + --jw-device-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.07), + 0 6px 14px rgba(0, 0, 0, 0.5), + 0 34px 70px rgba(0, 0, 0, 0.6); + --jw-mark-sweep: conic-gradient(from 200deg, #ffd463, #ff8585, #6f95ff, #55d6bd, #ffd463); + --jw-mark-inset: var(--jw-black); + } +} + +/* --------------------------------------------------------------------------- + * 2c. SEMANTIC — dark theme, applied by explicit toggle. + * + * Keep every declaration below identical to the prefers-color-scheme block. + * ------------------------------------------------------------------------ */ + +:root[data-theme="dark"] { + color-scheme: dark; + + --jw-bg: var(--jw-black); + --jw-bg-gradient: linear-gradient(180deg, var(--jw-black), var(--jw-black)); + --jw-surface: rgba(255, 255, 255, 0.04); + --jw-surface-raised: rgba(255, 255, 255, 0.07); + --jw-surface-sunken: rgba(0, 0, 0, 0.28); + --jw-surface-veil: rgba(18, 18, 18, 0.72); + --jw-surface-glass: rgba(10, 10, 10, 0.82); + --jw-scrim: rgba(0, 0, 0, 0.66); + + --jw-text-primary: var(--jw-white); + --jw-text-strong: rgba(250, 250, 250, 0.92); + --jw-text-body: rgba(250, 250, 250, 0.72); + --jw-text-muted: rgba(250, 250, 250, 0.56); + --jw-text-subtle: rgba(250, 250, 250, 0.44); + --jw-text-inverse: var(--jw-black); + --jw-text-on-accent: #052e27; + + --jw-line: rgba(255, 255, 255, 0.12); + --jw-line-strong: rgba(255, 255, 255, 0.24); + --jw-line-faint: rgba(255, 255, 255, 0.07); + + --jw-accent: var(--jw-ember-300); + --jw-accent-strong: var(--jw-ember-200); + --jw-accent-soft: rgba(244, 81, 30, 0.16); + --jw-accent-contrast: var(--jw-black); + --jw-accent-ring: rgba(255, 122, 82, 0.28); + --jw-highlight: var(--jw-sun-300); + --jw-highlight-soft: rgba(255, 204, 77, 0.14); + --jw-info: var(--jw-blue-300); + --jw-info-soft: rgba(39, 100, 255, 0.16); + + --jw-success: var(--jw-mint-300); + --jw-success-soft: rgba(32, 197, 168, 0.14); + --jw-warning: var(--jw-sun-300); + --jw-warning-soft: rgba(255, 204, 77, 0.14); + --jw-danger: var(--jw-coral-300); + --jw-danger-soft: rgba(255, 93, 93, 0.16); + + --jw-shadow-color: rgba(0, 0, 0, 0.6); + --jw-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --jw-shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.34); + --jw-shadow-md: 0 14px 34px rgba(0, 0, 0, 0.42); + --jw-shadow-lg: 0 18px 54px rgba(0, 0, 0, 0.5); + --jw-shadow-xl: 0 28px 80px rgba(0, 0, 0, 0.62); + --jw-shadow-accent: 0 14px 34px rgba(244, 81, 30, 0.22); + + --jw-topbar-scrolled-bg: linear-gradient(180deg, rgba(10, 10, 10, 0.6), rgba(10, 10, 10, 0.3)); + --jw-topbar-scrolled-border: rgba(255, 255, 255, 0.06); + --jw-topbar-scrolled-shadow: none; + --jw-button-primary-fill: var(--jw-white); + --jw-button-primary-text: var(--jw-black); + --jw-button-primary-fill-hover: #ececec; + --jw-button-secondary-fill: var(--jw-black); + --jw-button-secondary-fill-hover: #171717; + --jw-app-icon-bg: rgba(10, 10, 10, 0.72); + --jw-app-icon-rim-bright: rgba(255, 255, 255, 0.85); + --jw-app-icon-rim-fade: rgba(10, 10, 10, 0.04); + --jw-modal-bg: rgba(17, 17, 17, 0.78); + --jw-portal-depth: drop-shadow(0 0 5px rgba(250, 250, 250, 0.22)); + --jw-device-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.07), + 0 6px 14px rgba(0, 0, 0, 0.5), + 0 34px 70px rgba(0, 0, 0, 0.6); + --jw-mark-sweep: conic-gradient(from 200deg, #ffd463, #ff8585, #6f95ff, #55d6bd, #ffd463); + --jw-mark-inset: var(--jw-black); +} + +/* --------------------------------------------------------------------------- + * 3. TYPE, SPACE, SHAPE, MOTION — theme-independent. + * ------------------------------------------------------------------------ */ + +:root { + /* Geist carries the headings; Inter carries everything else. Swap this one + token to change every heading on the site and in /design. */ + --jw-font-display: "Syne Variable", "Geist Variable", Inter, ui-sans-serif, system-ui, sans-serif; + --jw-font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --jw-font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + + /* Static steps for UI. */ + --jw-text-2xs: 0.72rem; + --jw-text-xs: 0.78rem; + --jw-text-sm: 0.88rem; + --jw-text-base: 1rem; + --jw-text-lg: 1.125rem; + --jw-text-xl: 1.25rem; + --jw-text-2xl: 1.5rem; + --jw-text-3xl: 1.875rem; + + /* Fluid steps for editorial headlines. The vw term dominates from phone to + desktop so poster type scales with the viewport rather than stepping. */ + --jw-text-title: clamp(1.7rem, 3vw, 2.6rem); + --jw-text-headline: clamp(2.1rem, 4.6vw, 3.6rem); + --jw-text-display: clamp(2.45rem, 6.5vw, 5rem); + + /* Syne hangs deep descenders; 0.96 clipped them. */ + --jw-leading-tight: 1.06; + --jw-leading-snug: 1.12; + --jw-leading-normal: 1.45; + --jw-leading-relaxed: 1.6; + + --jw-weight-normal: 450; + --jw-weight-medium: 560; + --jw-weight-semibold: 700; + --jw-weight-bold: 820; + --jw-weight-black: 900; + + --jw-tracking-eyebrow: 0.09em; + --jw-tracking-tight: -0.01em; + + /* Space scale — 4px base. */ + --jw-space-1: 4px; + --jw-space-2: 8px; + --jw-space-3: 12px; + --jw-space-4: 16px; + --jw-space-5: 20px; + --jw-space-6: 24px; + --jw-space-8: 32px; + --jw-space-10: 40px; + --jw-space-12: 48px; + --jw-space-16: 64px; + --jw-space-20: 80px; + --jw-space-24: 96px; + --jw-section-gap: clamp(64px, 9vw, 128px); + + --jw-radius-xs: 4px; + --jw-radius-sm: 6px; + --jw-radius-md: 8px; + --jw-radius-lg: 14px; + --jw-radius-xl: 22px; + --jw-radius-pill: 999px; + + /* Fixed at 1220px through laptop widths, then grows with the window to a + 1560px ceiling so large displays do not strand the content in gutters. + The outer min() keeps the side gutter on small screens. */ + --jw-container: min(calc(100% - 32px), clamp(1220px, 82vw, 1560px)); + --jw-container-narrow: min(calc(100% - 32px), 880px); + --jw-hitbox-min: 42px; + + /* Motion. GSAP reads the durations/eases through motion.js so CSS + transitions and scripted tweens stay on the same curve. */ + --jw-duration-fast: 120ms; + --jw-duration-base: 180ms; + --jw-duration-slow: 340ms; + --jw-duration-slower: 620ms; + --jw-ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --jw-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --jw-ease-spring: cubic-bezier(0.2, 0.8, 0.2, 1); + + --jw-z-base: 1; + --jw-z-sticky: 10; + --jw-z-overlay: 60; + --jw-z-modal: 70; + --jw-z-toast: 80; +} + +/* --------------------------------------------------------------------------- + * 4. COMPONENT TOKENS + * + * What components.css actually reads. Every value resolves to a semantic + * token, so these follow the theme automatically and can be retargeted + * (e.g. point --jw-button-primary-bg at --jw-info) without touching CSS + * rules. This is the layer to tweak when a component needs to change. + * ------------------------------------------------------------------------ */ + +:root { + /* Buttons. + There are two: primary and secondary. Both are rimmed rather than + filled — a two-colour gradient painted onto the border only, with the + middle left transparent so whatever is behind shows through. Hierarchy + comes from which two colours the rim uses, not from a fill. */ + --jw-button-height: var(--jw-hitbox-min); + --jw-button-radius: var(--jw-radius-md); + --jw-button-padding: 0 var(--jw-space-6); + --jw-button-weight: var(--jw-weight-bold); + /* Matched to the ring/app-icon rim the menu button wears. */ + --jw-button-rim-width: 1px; + /* The rim is bright at the two diagonal corners and drops out entirely + across the middle, so the page shows through the gap. Same very light + grey in both themes. */ + --jw-button-rim-bright: #e6e6e6; + --jw-button-rim-gap-start: 27%; + --jw-button-rim-gap-end: 73%; + + /* Primary inverts the page: black on light, white on dark. */ + --jw-button-primary-fill: var(--jw-black); + --jw-button-primary-text: var(--jw-white); + --jw-button-primary-fill-hover: #1c1c1c; + + --jw-button-secondary-fill: var(--jw-white); + --jw-button-secondary-text: var(--jw-text-body); + --jw-button-secondary-fill-hover: var(--jw-neutral-100); + + /* Ghost — a quiet inline action: no rim, no fill until hovered. */ + --jw-button-ghost-text: var(--jw-text-muted); + --jw-button-ghost-text-hover: var(--jw-text-primary); + --jw-button-ghost-fill-hover: var(--jw-surface-veil); + + /* Fields. */ + --jw-field-height: 50px; + --jw-field-radius: var(--jw-radius-md); + --jw-field-bg: var(--jw-surface); + --jw-field-text: var(--jw-text-primary); + --jw-field-placeholder: var(--jw-text-subtle); + --jw-field-border: var(--jw-line); + --jw-field-border-focus: var(--jw-info); + --jw-field-ring: 0 0 0 4px var(--jw-accent-ring); + --jw-field-shadow: var(--jw-shadow-sm); + --jw-field-label: var(--jw-text-body); + --jw-field-sheen: var(--jw-line); + + /* Cards and panels. */ + --jw-card-bg: var(--jw-surface); + --jw-card-border: var(--jw-line); + --jw-card-radius: var(--jw-radius-md); + --jw-card-shadow: var(--jw-shadow-lg); + --jw-card-padding: var(--jw-space-5); + --jw-modal-padding: clamp(20px, 3vw, 30px); + /* Dialog glass: translucent over the scrim's blur, opaque enough to read. */ + --jw-modal-bg: rgba(252, 252, 252, 0.94); + /* Depth under the brand mark: a soft contact shadow on light. */ + --jw-portal-depth: drop-shadow(0 1px 1.5px rgba(10, 10, 10, 0.28)); + + /* Chips. */ + --jw-chip-bg: var(--jw-surface-veil); + --jw-chip-text: var(--jw-text-strong); + --jw-chip-border: var(--jw-line); + --jw-chip-dot: var(--jw-accent); + + /* Navigation chrome. */ + --jw-topbar-height: 64px; + --jw-topbar-bg: var(--jw-surface-glass); + --jw-topbar-border: var(--jw-line); + /* Applied only once the page has scrolled past --jw-topbar-scroll-start. */ + --jw-topbar-scrolled-bg: linear-gradient(180deg, rgba(250, 250, 250, 0.6), rgba(250, 250, 250, 0.3)); + --jw-topbar-scrolled-border: rgba(10, 10, 10, 0.06); + --jw-topbar-scrolled-shadow: none; + --jw-topbar-blur: 8px; + + /* Brand mark. */ + --jw-mark-size: 30px; + --jw-mark-radius: var(--jw-radius-md); + --jw-mark-ring: 6px; +} + +/* --------------------------------------------------------------------------- + * 5. LEGACY BRIDGE + * + * base.css, cms.css and public-page.css were written against these short + * names before the token layer existed. Mapping them here means the existing + * site inherits theming for free, and new work should reach for --jw-* names. + * ------------------------------------------------------------------------ */ + +:root { + --ink: var(--jw-text-primary); + --paper: var(--jw-bg); + --card: var(--jw-surface); + --muted: var(--jw-text-muted); + --line: var(--jw-line); + --mint: var(--jw-accent); + --green: var(--jw-accent-strong); + --sun: var(--jw-highlight); + --coral: var(--jw-danger); + --blue: var(--jw-info); + --shadow: var(--jw-shadow-color); + --soft-shadow: var(--jw-shadow-lg); + --radius: var(--jw-radius-md); +} + +/* --------------------------------------------------------------------------- + * 6. APP ICON + * + * The mark in its container. Light theme: light ground, dark rim. Dark theme + * overrides these to the inverse in the theme blocks above. + * ------------------------------------------------------------------------ */ + +:root { + --jw-app-icon-size: 38px; + /* Roughly the iOS squircle ratio. */ + --jw-app-icon-radius: 24%; + --jw-app-icon-bg: rgba(250, 250, 250, 0.72); + --jw-app-icon-blur: 10px; + --jw-app-icon-rim-width: 1px; + --jw-app-icon-rim-bright: rgba(10, 10, 10, 0.6); + --jw-app-icon-rim-fade: rgba(10, 10, 10, 0.04); + + /* Circular icon actions (dialog close, help). */ + --jw-ring-button-size: 44px; +} + +/* --------------------------------------------------------------------------- + * 7. SELF-HOSTED FONTS + * + * The same files justworks.cash serves, so every surface here — dashboard, + * module pages, and the locally generated merchant site — renders the real + * Syne headings and Geist body instead of falling back to system fonts. + * Paths are relative to this stylesheet, so they work under any base path. + * ------------------------------------------------------------------------ */ + +@font-face { + font-family: "Syne Variable"; + src: url("../fonts/syne-latin-wght-normal.woff2") format("woff2-variations"); + font-weight: 400 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Geist Variable"; + src: url("../fonts/geist-latin-wght-normal.woff2") format("woff2-variations"); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Geist Variable"; + src: url("../fonts/geist-latin-ext-wght-normal.woff2") format("woff2-variations"); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7, U+02CC, U+02CE-02D7, U+02DD, U+02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} diff --git a/docker/justworks/public/bridge.html b/docker/justworks/public/bridge.html new file mode 100644 index 00000000..643e6511 --- /dev/null +++ b/docker/justworks/public/bridge.html @@ -0,0 +1 @@ +Just Works connection diff --git a/docker/justworks/public/fonts/Geist-LICENSE b/docker/justworks/public/fonts/Geist-LICENSE new file mode 100644 index 00000000..98835ac3 --- /dev/null +++ b/docker/justworks/public/fonts/Geist-LICENSE @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) Geist-Italic[wght].ttf: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docker/justworks/public/fonts/Syne-LICENSE b/docker/justworks/public/fonts/Syne-LICENSE new file mode 100644 index 00000000..174eaa8c --- /dev/null +++ b/docker/justworks/public/fonts/Syne-LICENSE @@ -0,0 +1,93 @@ +Copyright 2019 The Syne Project Authors (https://gitlab.com/bonjour-monde/fonderie/syne-typeface) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docker/justworks/public/fonts/geist-latin-ext-wght-normal.woff2 b/docker/justworks/public/fonts/geist-latin-ext-wght-normal.woff2 new file mode 100644 index 00000000..ba90e209 Binary files /dev/null and b/docker/justworks/public/fonts/geist-latin-ext-wght-normal.woff2 differ diff --git a/docker/justworks/public/fonts/geist-latin-wght-normal.woff2 b/docker/justworks/public/fonts/geist-latin-wght-normal.woff2 new file mode 100644 index 00000000..991445d7 Binary files /dev/null and b/docker/justworks/public/fonts/geist-latin-wght-normal.woff2 differ diff --git a/docker/justworks/public/fonts/syne-latin-wght-normal.woff2 b/docker/justworks/public/fonts/syne-latin-wght-normal.woff2 new file mode 100644 index 00000000..e311600b Binary files /dev/null and b/docker/justworks/public/fonts/syne-latin-wght-normal.woff2 differ diff --git a/docker/justworks/public/index.html b/docker/justworks/public/index.html new file mode 100644 index 00000000..8d3c1fe9 --- /dev/null +++ b/docker/justworks/public/index.html @@ -0,0 +1,50 @@ + + + + + + + Just Works · Archipelago + + + + + + +
Business
+ + + diff --git a/docker/justworks/public/licenses/JustWorks-LICENSE b/docker/justworks/public/licenses/JustWorks-LICENSE new file mode 100644 index 00000000..02fe5437 --- /dev/null +++ b/docker/justworks/public/licenses/JustWorks-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Napplet contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker/justworks/server.py b/docker/justworks/server.py new file mode 100644 index 00000000..792a90b7 --- /dev/null +++ b/docker/justworks/server.py @@ -0,0 +1,151 @@ +"""Small fixed-origin adapter to Just Works Core; no credentials are stored.""" +import json +import mimetypes +import os +import re +from payments import connect_payment, PublishUnverified +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urlparse +from urllib.request import build_opener, HTTPRedirectHandler, Request +import http.client + +PUBLIC = Path(__file__).resolve().parent / "public" +CORE = "https://justworks.cash" +MAX_RESPONSE = 2 * 1024 * 1024 + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def site_id(value): + value = value.strip() + if value.startswith("https://"): + url = urlparse(value) + if url.netloc != "justworks.cash" or url.query or url.fragment: + raise ValueError("Use the public justworks.cash page link, without extra parameters.") + value = url.path.strip("/") + if value.startswith(("nsec1", "ncryptsec1")) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value) or len(value) > 160: + raise ValueError("Enter your Just Works page link or page name.") + return value + + +def read_core(path, timeout=10): + request = Request(f"{CORE}{path}", headers={"Accept": "application/json", "User-Agent": "Archipelago-JustWorks/0.1"}) + with build_opener(NoRedirect).open(request, timeout=timeout) as response: + raw = response.read(MAX_RESPONSE + 1) + if len(raw) > MAX_RESPONSE: + raise ValueError("The site response was too large. Try again later.") + return json.loads(raw) + + +def read_site(value): + locator = site_id(value) + record = read_core(f"/api/nsites/{locator}").get("nsite", {}) + npub = record.get("npub", "") + slug = site_id(record.get("id", "")) + if (locator != slug and locator != npub) or not re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}", npub): + raise ValueError("Just Works returned an incomplete site. Open its editor and try again.") + if record.get("nsite", {}).get("deploymentStatus") != "published": + raise ValueError("This site is not published yet. Publish it in the Just Works editor, then check again.") + business_available = True # Isolated Business resolves the same published Core record. + # Deliberately do not relay owner material, arbitrary URLs, or upstream HTML. + return {"id": slug, "name": str(record.get("name") or slug)[:160], "npub": npub, + "published": True, "url": f"{CORE}/{slug}", + "editorUrl": f"{CORE}/cms?id={slug}", + "businessUrl": f"/business-demo/?npub={npub}", + "businessAvailable": business_available} + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass # Avoid recording site queries and app-gate tokens. + + def send(self, status, body, content_type="application/json; charset=utf-8"): + if not isinstance(body, bytes): + body = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "strict-origin-when-cross-origin") + self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' https://justworks.cash https://business.justworks.cash; object-src 'none'; base-uri 'none'; form-action 'self'") + self.end_headers() + self.wfile.write(body) + + def proxy_business(self): + try: size = int(self.headers.get("Content-Length", "0")) + except ValueError: return self.send(400, {"error":"Invalid request size"}) + if size < 0 or size > 1024 * 1024: + return self.send(413, {"error":"Request too large"}) + if self.command != "GET": + origin = urlparse(self.headers.get("Origin", "")) + if origin.scheme not in ("http", "https") or origin.netloc != self.headers.get("Host"): + return self.send(403, {"error":"Open Business inside Archipelago"}) + headers = {key:self.headers[key] for key in ("Host", "Origin", "Cookie", "Content-Type", "Sec-Fetch-Site") if key in self.headers} + conn = http.client.HTTPConnection("127.0.0.1",4310,timeout=100) + try: + conn.request(self.command,self.path,body=self.rfile.read(size) if size else None,headers=headers) + response=conn.getresponse() + body=response.read(8*1024*1024+1) + if len(body)>8*1024*1024: return self.send(502,{"error":"Business response too large"}) + self.send_response(response.status) + for key,value in response.getheaders(): + if key.lower() in {"content-type","set-cookie","cache-control","x-content-type-options"}: self.send_header(key,value) + self.send_header("Content-Length",str(len(body))) + self.end_headers(); self.wfile.write(body) + except (OSError, http.client.HTTPException): self.send(502,{"error":"Business tools are unavailable. Try again shortly."}) + finally: conn.close() + + def do_POST(self): + if self.path.startswith("/business-demo/"): return self.proxy_business() + if self.path != "/api/payment-connect": + return self.send(404, {"error": "Not found"}) + origin = urlparse(self.headers.get("Origin", "")) + if origin.scheme not in ("http", "https") or origin.netloc != self.headers.get("Host") or origin.path or origin.query or origin.fragment: + return self.send(403, {"error": "Open setup from your Archipelago dashboard."}) + if self.headers.get("Content-Type", "").split(";")[0] != "application/json": + return self.send(415, {"error": "JSON required"}) + try: + size = int(self.headers.get("Content-Length", "0")) + if size < 1 or size > 8192: + return self.send(413, {"error": "Invalid request size"}) + body = json.loads(self.rfile.read(size)) + return self.send(200, connect_payment(body)) + except PublishUnverified: + return self.send(502, {"error": "The address may be saved, but publication could not be verified. Retry to publish and check again.", "verified": False}) + except HTTPError as error: + return self.send(401 if error.code in (401, 403) else 502, {"error": "Just Works could not authorize this update. Check your owner login and website, then retry."}) + except (ValueError, TypeError, AttributeError): + return self.send(422, {"error": "Check the connected website, native wallet address and owner sign-in."}) + except (OSError, URLError): + return self.send(502, {"error": "Cannot reach Just Works. Try again shortly."}) + + def do_GET(self): + path = urlparse(self.path) + if path.path.startswith("/business-demo/"): return self.proxy_business() + if path.path == "/healthz": + return self.send(200, {"ok": True}) + if path.path == "/api/site": + try: + return self.send(200, read_site(parse_qs(path.query).get("url", [""])[0])) + except HTTPError as error: + message = "That page was not found. Copy its public link from Just Works." if error.code == 404 else "Just Works is unavailable. Your saved link has not changed; try again shortly." + return self.send(404 if error.code == 404 else 502, {"error": message}) + except (ValueError, TypeError, AttributeError): + # Validation failures must not expose the remote response body. + return self.send(422, {"error": "Check your Just Works page link and make sure the site is published, then try again."}) + except (OSError, URLError): + return self.send(502, {"error": "Cannot reach Just Works. Check your connection and try again."}) + file = (PUBLIC / ("index.html" if path.path == "/" else path.path.lstrip("/"))).resolve() + if not file.is_relative_to(PUBLIC) or not file.is_file(): + return self.send(404, {"error": "Not found"}) + return self.send(200, file.read_bytes(), mimetypes.guess_type(file.name)[0] or "application/octet-stream") + + +if __name__ == "__main__": + ThreadingHTTPServer((os.environ.get("JW_HOST", "0.0.0.0"), int(os.environ.get("JW_PORT", "8340"))), Handler).serve_forever() diff --git a/docker/justworks/test_payments.py b/docker/justworks/test_payments.py new file mode 100644 index 00000000..b7f5b86a --- /dev/null +++ b/docker/justworks/test_payments.py @@ -0,0 +1,76 @@ +import unittest +import http.client +import threading +from http.server import ThreadingHTTPServer +import server +from unittest.mock import patch +from urllib.error import HTTPError +import payments + +BODY = {'siteId':'bakery', 'address':'mywallet@minibits.cash', 'credentials':{'ownerIdentifier':'owner', 'ownerPassword':'fake-test-password'}} +def record(address='mywallet@minibits.cash'): + return {'id':'bakery', 'nsite':{'deploymentStatus':'published'}, 'identity':{'profile':{'lud16':address}}} + +class PaymentTests(unittest.TestCase): + def test_saves_only_address_and_checks_published_destination(self): + replies = [{'nsite':{'id':'bakery'}}, {}, {'artifact':{'verified':True}, 'nsite':record()}, {'nsite':record()}] + with patch('payments.request_core', side_effect=replies) as call: + result = payments.connect_payment(BODY) + self.assertTrue(result['verified']) + update = call.call_args_list[1] + self.assertEqual(update.args[:2], ('PATCH','/api/nsites/bakery')) + self.assertEqual(set(update.args[2]), {'payment','ownerIdentifier','ownerPassword'}) + self.assertEqual(update.args[2]['payment'], BODY['address']) + self.assertEqual(call.call_args_list[-1].args, ('GET','/api/nsites/bakery')) + self.assertNotIn('password', str(result)) + + def test_rejected_owner_never_changes_or_publishes(self): + with patch('payments.request_core', side_effect=HTTPError('https://justworks.cash',401,'unauthorized',{},None)) as call: + with self.assertRaises(HTTPError): payments.connect_payment(BODY) + self.assertEqual(call.call_count,1) + + def test_wrong_website_owner_never_changes(self): + with patch('payments.request_core', return_value={'nsite':{'id':'other'}}) as call: + with self.assertRaises(ValueError): payments.connect_payment(BODY) + self.assertEqual(call.call_count,1) + + def test_partial_publish_and_wrong_destination_never_succeed(self): + for deploy, public in [({}, {'nsite':record()}), + ({'artifact':{'verified':True},'nsite':record('other@minibits.cash')},{'nsite':record()}), + ({'artifact':{'verified':True},'nsite':record()},{'nsite':record('other@minibits.cash')}), + (OSError('timeout'), {})]: + with self.subTest(deploy=deploy), patch('payments.request_core', side_effect=[{'nsite':{'id':'bakery'}},{},deploy,public]): + with self.assertRaises(payments.PublishUnverified): payments.connect_payment(BODY) + + def test_invalid_input_never_reaches_upstream(self): + for body in [None, {}, {**BODY,'siteId':'../admin'}, {**BODY,'address':'someone@evil.test'}, {**BODY,'credentials':{}}, {**BODY,'credentials':{'ownerKey':['invalid']}}]: + with self.subTest(body=body), patch('payments.request_core') as call: + with self.assertRaises(ValueError): payments.connect_payment(body) + call.assert_not_called() + + def test_concurrent_updates_are_rejected_without_writes(self): + with payments.LOCK, patch('payments.request_core') as call: + with self.assertRaises(ValueError): payments.connect_payment(BODY) + call.assert_not_called() + +class PaymentHttpTests(unittest.TestCase): + def test_post_requires_exact_origin_json_and_bounded_body(self): + httpd = ThreadingHTTPServer(('127.0.0.1', 0), server.Handler) + worker = threading.Thread(target=httpd.serve_forever, daemon=True); worker.start() + host = f'127.0.0.1:{httpd.server_port}' + try: + with patch('server.connect_payment', return_value={'verified':True}) as connect: + for origin, content_type, body, expected in [ + ('https://evil.test','application/json','{}',403), + ('','application/json','{}',403), + ('http://'+host,'text/plain','{}',415), + ('http://'+host,'application/json','x'*8193,413), + ('http://'+host,'application/json','{}',200)]: + client = http.client.HTTPConnection(host) + client.request('POST','/api/payment-connect',body,{'Origin':origin,'Content-Type':content_type}) + response=client.getresponse(); self.assertEqual(response.status,expected); response.read(); client.close() + self.assertEqual(connect.call_count,1) + finally: + httpd.shutdown(); httpd.server_close(); worker.join() + +if __name__ == '__main__': unittest.main() diff --git a/docker/justworks/test_server.py b/docker/justworks/test_server.py new file mode 100644 index 00000000..b542a90a --- /dev/null +++ b/docker/justworks/test_server.py @@ -0,0 +1,66 @@ +import io +import json +import unittest +from unittest.mock import patch +import server + +NPUB = 'npub1' + 'q' * 58 + + +class SiteTests(unittest.TestCase): + def test_only_canonical_core_page_or_slug_is_accepted(self): + self.assertEqual(server.site_id(' https://justworks.cash/test-bakery/ '), 'test-bakery') + self.assertEqual(server.site_id('test-bakery'), 'test-bakery') + for invalid in ['http://127.0.0.1/', 'https://justworks.cash.evil/test', + 'https://justworks.cash@evil/test', 'https://justworks.cash:444/test', + 'https://justworks.cash/test?ownerKey=secret', '../api', 'a/b', + 'https://justworks.cash/%2e%2e/secret', 'x' * 161, 'nsec1secret', 'ncryptsec1secret']: + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + server.site_id(invalid) + + def test_returns_only_public_links_without_owner_material(self): + record = {'id': 'bakery', 'npub': NPUB, 'name': '', + 'nsite': {'deploymentStatus': 'published'}, 'ownerKey': 'secret', + 'qrUrl': 'https://evil.test', 'editorUrl': 'javascript:alert(1)'} + with patch('server.build_opener') as opener: + opener.return_value.open.side_effect = [io.BytesIO(json.dumps({'nsite': record}).encode()), + io.BytesIO(json.dumps({'places': [{'npub': NPUB, 'slug': 'bakery'}]}).encode())] + result = server.read_site('bakery') + request = opener.return_value.open.call_args_list[0].args[0] + self.assertEqual(request.full_url, 'https://justworks.cash/api/nsites/bakery') + self.assertEqual(result['url'], 'https://justworks.cash/bakery') + self.assertEqual(result['editorUrl'], 'https://justworks.cash/cms?id=bakery') + self.assertNotIn('secret', json.dumps(result)) + self.assertNotIn('evil.test', json.dumps(result)) + self.assertTrue(result['businessAvailable']) + + def test_business_lookup_does_not_require_regional_directory(self): + record = {'id': 'bakery', 'npub': NPUB, 'nsite': {'deploymentStatus': 'published'}} + with patch('server.read_core', return_value={'nsite':record}) as read: + result = server.read_site('bakery') + self.assertTrue(result['businessAvailable']) + self.assertEqual(result['businessUrl'], '/business-demo/?npub='+NPUB) + read.assert_called_once() + + def test_incomplete_failed_and_mismatched_sites_never_complete_setup(self): + for record in [None, {}, {'id': 'bakery', 'npub': NPUB, 'status': 'active'}, + {'id': 'bakery', 'npub': NPUB, 'nsite': {'deploymentStatus': 'relay-publish-failed'}}, + {'id': 'another-page', 'npub': NPUB, 'nsite': {'deploymentStatus': 'published'}}]: + with self.subTest(record=record), patch('server.build_opener') as opener: + opener.return_value.open.return_value = io.BytesIO(json.dumps({'nsite': record}).encode()) + with self.assertRaises((ValueError, AttributeError)): + server.read_site('bakery') + + def test_npub_resolves_to_its_canonical_site_without_accepting_mismatch(self): + record = {'id':'bakery','npub':NPUB,'nsite':{'deploymentStatus':'published'}} + with patch('server.read_core', side_effect=[{'nsite':record},{'places':[]}]): + self.assertEqual(server.read_site(NPUB)['id'],'bakery') + + def test_no_remote_redirects_and_bounded_response(self): + self.assertIsNone(server.NoRedirect().redirect_request(None, None, 302, '', {}, 'http://127.0.0.1')) + with patch('server.build_opener') as opener: + opener.return_value.open.return_value = io.BytesIO(b' ' * (server.MAX_RESPONSE + 1)) + with self.assertRaises(ValueError): server.read_site('bakery') + + +if __name__ == '__main__': unittest.main() diff --git a/docs/justworks-deployment-handoff.txt b/docs/justworks-deployment-handoff.txt new file mode 100644 index 00000000..d6cb10b1 --- /dev/null +++ b/docs/justworks-deployment-handoff.txt @@ -0,0 +1,109 @@ +Just Works app + dashboard setup: deployment-agent handoff +2026-10-08 + +Integration boundary +This proposal is based on main 8d175972. That main already contains the qualified +native identity import implementation (fe398df8 + 7f03994e, merged through +8d175972). Do not reapply a1bb9981 or beb8e296 from the original app branch. +This proposal adds the remaining app/dashboard integration, the FIPS port8340 +mapping, generated app-session metadata and the optional catalog entry. +No existing signer implementation is changed by this proposal. + +The application payload matches the yaya demo at original app commit4ae5fff0; +changes during review preparation are this handoff, README corrections and +trailing blank-line cleanup in three MIT notices. Original Just Works projects +and hosted services were not modified. Vendored Business code and licenses are +inside docker/justworks; no separate upstream PR/deployment is necessary. + +User experience +First launch embeds the existing hosted website creator. A connected published +site unlocks the Website/Business chooser. The lower-left ring control always +allows switching, including before setup, to avoid a dead end. Business resolves +the connected public npub through hosted Core. All views remain inside the +Archipelago iframe/host tab. Desktop chooser gutters follow the header; mobile +spacing is unchanged. The dashboard icon is the normalized white portal SVG. +Dashboard > Setup > Instant business site checks the page and connects the native +ecash receiving address using owner-authorized hosted Core APIs. + +Receiving uses wallet.ecash-lnaddress and its existing hosted address/mint +service. No LND/Bitcoin dependency or extra wallet app is introduced. This is +not a self-hosted mint. Success requires publication verification and a fresh +public record matching the selected address. Legacy Business order checkout +with hardcoded payment destinations is blocked; it is not certified by this PR. +The hosted CMS retains its separate sign-in. A website owner's nsec may be +imported once as a separate native Business identity. Local Business and wallet +setup reuse it with node-password authorization and explicit Nostr-key sharing +consent. No node wallet key is sent to hosted Core. + +Ingest into the next normal deployment +1. Review/merge once on ngit, then mirror the exact accepted commits to Gitea. + The explicitly requested Gitea PR is a cross-linked review mirror; do not + independently squash or merge it to produce a different history. +2. Build the current combined platform source. Do not deploy an old demo backend + or replace newer work with the binary recorded below. Include the Just Works + FIPS port mapping and existing native-import RPCs. +3. Build the current combined neode-ui. Include the native guide/components, + generated host-frame metadata, white SVG and catalog entry. Do not copy the + older preview UI over newer platform/IndeeHub changes. +4. Ship apps/justworks and docker/justworks into both active runtime and boot + runtime using the standard deployment pipeline. Build the image locally as + localhost/archipelago-justworks:0.1.0. A running image with that same tag is not + proof it contains these sources: confirm its image ID before recreating ONLY + Just Works. Respect stopped/uninstalled app decisions. +5. Preserve /var/lib/archipelago/justworks and all existing native identities. + The app requires the /data bind mount, 256Mi memory and writable persistent + storage for container UID65534. Use the runtime's rootless UID mapping when + establishing directory ownership; do not hardcode a host UID on other nodes. +6. Rebuild/reload manifests as usual and validate gated port8340, /healthz, + iframe launch and launch-in-tab. Dashboard guide must not complete merely + from opening an app or submitting failed credentials. +7. Test native import/signing only with a disposable identity; remove only that + fixture. Preserve all original identity/default hashes. Never publish an + owner payment change or send a payment as an unattended test. + +Existing yaya demo checkpoint (historical evidence, recheck before deployment) +The app is installed with persistent data and the preview dashboard on18342. +Active context: /opt/archipelago/docker/justworks +Boot context: /opt/archipelago/web-ui/archipelago-runtime/docker/justworks +Existing persistence override: + ~/.config/containers/systemd/justworks.container.d/10-business-data.conf +It resets Volume= and binds /var/lib/archipelago/justworks:/data:rw because the +previous backend cached the old manifest. Preserve it until generated effective +configuration has the correct bind; do not drop the volume during an update. +App image at the verified demo checkpoint: + 3954d0ff0f82841b98900a5b58807374d63b706ddb26c0a27e28852ecbd2ebda +Rollback app image tag: localhost/archipelago-justworks:before-gutters +Signer binary deployed during this task: + e3daed8b26359855c58883fd331a68b5a04f1b9211046c744a5935f0abcd46b6 +Signer backup/receipt/rollback directory: + /var/lib/archipelago/support/justworks-native-signer-20261008T172934Z +Deployment notes on yaya: /opt/archipelago/justworks-deployment-status.txt +This binary was qualified against the previous production inputs, but latest +main is now newer: rebuild current source rather than redeploying this binary. + +Evidence +Qualified native-import backend: isolated suite2033 passed,0 failed,5 ignored; +locked release build passed. Real disposable import, duplicate reuse, independent +signature verification and export match passed; fixture removed. Existing +identity/default/session files, UI/operator-intent files and every app container +ID/start time were unchanged after deployment and settling. Embedded IndeeHub +maintenance helper stayed byte-identical. +Live native setup: Chromium/Firefox/WebKit, desktop and phone, all6 cases passed +8 checks each. Live Business QR follow-up: Chromium phone,30 checks passed. +Desktop chooser gutters: all6 engine/profile cases,22 checks over9 widths. +Public customer flow: published merchant page's iframe links to the proper Tip +page; actual checkout fetched the native receiving address and got LNURL-pay +payRequest. No invoice/payment was sent. Linux WebKit is not physical iOS testing. + +Local private evidence is retained outside git in +~/.local/share/archy-justworks-deployment/ and browser-check report directories. +Temporary authenticated browser cookies were removed. No credentials, nsecs, +wallet records, browser traces or deployment binaries belong in this proposal. +No OTA/ISO/catalog release was published; unrelated release acceptance remains open. + +Review branch revalidation on main8d175972 +Adapter tests13 passed; manifest16 passed with0 warnings; strict release catalog +check reported0 drift/missing entries; targeted guide/store tests26 passed on +Node24.20.0; frontend typecheck and production build passed. An initial test run +on system Node26 failed because its experimental global localStorage shadows the +browser test environment; the supported Node24 run passed without source edits. diff --git a/neode-ui/Dockerfile.backend b/neode-ui/Dockerfile.backend index 05f69ce0..7f61efe3 100644 --- a/neode-ui/Dockerfile.backend +++ b/neode-ui/Dockerfile.backend @@ -21,6 +21,7 @@ COPY docker/bitcoin-ui /docker/bitcoin-ui COPY docker/electrs-ui /docker/electrs-ui COPY docker/lnd-ui /docker/lnd-ui COPY docker/fedimint-ui /docker/fedimint-ui +COPY docker/justworks/public /docker/justworks/public COPY demo/files /demo/files COPY demo/content /demo/content # Peer catalog media (posters/covers/photos for content.browse-peer mocks) — diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 7628123e..642cea6b 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -1578,6 +1578,8 @@ function demoFederationNodes() { // shell's bundled assets (qrcode.js, css, bg images, icons) resolve via their // relative paths. The data endpoints each shell polls are mocked separately. const DOCKER_UI = path.join(__dirname, '..', 'docker') +app.use('/app/justworks', express.static(path.join(DOCKER_UI, 'justworks/public'))) +app.get('/app/justworks/setup', (_req, res) => res.sendFile(path.join(DOCKER_UI, 'justworks/public/index.html'))) for (const [prefixes, dir] of [ [['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'], [['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-ui'], @@ -1967,6 +1969,11 @@ app.post('/rpc/v1', (req, res) => { }) } + // The dashboard uses this read-only RPC to validate its session on reload. + // Keep demo deep links (including setup guides) compatible with that probe. + case 'system.get-hostname': + return res.json({ result: { hostname: 'archipelago-demo', mdns_hostname: 'archipelago-demo.local', lan_ip: '127.0.0.1' } }) + case 'auth.login': { const { password } = params diff --git a/neode-ui/public/assets/img/app-icons/justworks.svg b/neode-ui/public/assets/img/app-icons/justworks.svg new file mode 100644 index 00000000..95d23cda --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/justworks.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index b37c4146..74f16fa2 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -673,6 +673,18 @@ "tier": "optional", "icon": "/assets/img/app-icons/angor-green.png", "repoUrl": "https://github.com/hoytech/strfry" + }, + { + "id": "justworks", + "title": "Just Works", + "version": "0.1.0", + "description": "Turn your existing business links into a website with Just Works. Open your website editor and business tools from one lightweight launcher. Uses the hosted Just Works services; an internet connection is required.", + "icon": "/assets/img/app-icons/justworks.svg", + "author": "Just Works contributors", + "category": "business", + "tier": "optional", + "repoUrl": "https://github.com/bencoin21/justworks.cash", + "dockerImage": "localhost/archipelago-justworks:0.1.0" } ] } diff --git a/neode-ui/src/components/EasyHome.vue b/neode-ui/src/components/EasyHome.vue index a722d304..78cc3f7d 100644 --- a/neode-ui/src/components/EasyHome.vue +++ b/neode-ui/src/components/EasyHome.vue @@ -65,6 +65,7 @@ const goalStatuses = goalStore.goalStatuses /** Map appId to its icon file path under /assets/img/app-icons/ */ const APP_ICON_MAP: Record = { + justworks: '/assets/img/app-icons/justworks.svg', 'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp', lnd: '/assets/img/app-icons/lnd.png', 'btcpay-server': '/assets/img/app-icons/btcpay-server.png', diff --git a/neode-ui/src/composables/useDemoIntro.ts b/neode-ui/src/composables/useDemoIntro.ts index 93161d76..b04c65df 100644 --- a/neode-ui/src/composables/useDemoIntro.ts +++ b/neode-ui/src/composables/useDemoIntro.ts @@ -38,6 +38,7 @@ const DEMO_PROXY_PORTS: Record = { // Apps loaded in the in-app iframe via a same-origin path served by the mock // backend (per-app mock UIs and placeholder dashboards). const DEMO_MOCK_UI: Record = { + justworks: '/app/justworks/', mempool: '/app/mempool/', 'mempool-web': '/app/mempool/', 'bitcoin-knots': '/app/bitcoin-knots/', diff --git a/neode-ui/src/data/goals.ts b/neode-ui/src/data/goals.ts index b81ab119..46ac9285 100644 --- a/neode-ui/src/data/goals.ts +++ b/neode-ui/src/data/goals.ts @@ -22,6 +22,44 @@ const FUND_WALLET_STEP: GoalStep = { } export const GOALS: GoalDefinition[] = [ + { + id: 'instant-business-site', + title: 'Instant business site', + subtitle: 'Turn your existing business links into a website with Just Works', + icon: 'shop', + category: 'commerce', + requiredApps: ['justworks'], + steps: [ + { + id: 'install-justworks', + title: 'Install Just Works', + description: 'A lightweight home for your website and business tools. Uses the existing Just Works services; no Bitcoin node or channel setup is needed.', + appId: 'justworks', + action: 'install', + isAutomatic: true, + }, + { + id: 'connect-justworks-site', + title: 'Create or connect your business site', + description: 'Start with your existing website or social links, create your page in Just Works, then connect its public link. This step finishes when Just Works confirms the page is published. Owner login stays with Just Works.', + appId: 'justworks', + action: 'configure', + completion: 'app', + isAutomatic: false, + }, + { + id: 'connect-justworks-payments', + title: 'Receive payments in your Archipelago wallet', + description: 'Use this node’s Lightning address on your hosted website. Customer payments become ecash in your Archipelago wallet. No Bitcoin node or LND setup is required.', + appId: 'justworks', + action: 'configure', + completion: 'app', + isAutomatic: false, + }, + ], + estimatedTime: '~5–10 min', + difficulty: 'beginner', + }, { id: 'open-a-shop', title: 'Open a Shop', diff --git a/neode-ui/src/types/goals.ts b/neode-ui/src/types/goals.ts index e1e6f701..46a381a1 100644 --- a/neode-ui/src/types/goals.ts +++ b/neode-ui/src/types/goals.ts @@ -27,6 +27,10 @@ export interface GoalStep { icon?: string /** Custom label for the step's CTA button (configure steps) */ ctaLabel?: string + /** Path inside the app for a guided setup entry point. */ + appPath?: string + /** App-owned verification must acknowledge completion, rather than a click. */ + completion?: 'app' } export type GoalStatus = 'not-started' | 'in-progress' | 'completed' | 'error' diff --git a/neode-ui/src/views/GoalDetail.vue b/neode-ui/src/views/GoalDetail.vue index 41036292..f9fa0115 100644 --- a/neode-ui/src/views/GoalDetail.vue +++ b/neode-ui/src/views/GoalDetail.vue @@ -145,6 +145,14 @@ + +