@formseal/embed 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/fse.py ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ # fse.py
3
+ # Entry point. Parses args and routes to the correct command.
4
+ # Adding a new command:
5
+ # 1. Create commands/newcommand.py with a run() function
6
+ # 2. Import it below
7
+ # 3. Add a case to the router
8
+
9
+ import sys
10
+ import os
11
+
12
+ if os.name == "nt":
13
+ try:
14
+ os.system("chcp 65001 >nul")
15
+ except:
16
+ pass
17
+
18
+ try:
19
+ sys.stdout.reconfigure(encoding="utf-8")
20
+ sys.stderr.reconfigure(encoding="utf-8")
21
+ except:
22
+ pass
23
+
24
+ sys.path.insert(0, os.path.dirname(__file__))
25
+
26
+ from ui.output import br, rule, cmd_line, link, fail, badge, C, G, Y, M, W, D, R, BOLD
27
+ from logo import LOGO
28
+
29
+ from commands import init as cmd_init
30
+ from commands import configure as cmd_configure
31
+ from commands import help as cmd_help
32
+ from commands import about as cmd_about
33
+
34
+
35
+ # -- intro --
36
+
37
+ def intro():
38
+ br()
39
+ print(f"{C} \u250c\u2500 {R}{W}formseal-embed{R}")
40
+ print(G + " " + "\u2500" * 52 + R)
41
+ br()
42
+ print(f" {G}Start:{R}")
43
+ br()
44
+ print(f" {W}fse init{R}")
45
+ print(f" {D}scaffold project files{R}")
46
+ br()
47
+ print(f" {W}fse configure quick{R}")
48
+ print(f" {D}set endpoint + key{R}")
49
+ br()
50
+ print(f" {G}Help:{R}")
51
+ br()
52
+ print(f" {W}fse --help{R}")
53
+ link("https://github.com/grayguava/formseal-embed/docs")
54
+ br()
55
+
56
+
57
+ # -- router --
58
+
59
+ def main():
60
+ args = sys.argv[1:]
61
+ command = args[0] if args else None
62
+
63
+ match command:
64
+ case "init":
65
+ cmd_init.run()
66
+
67
+ case "configure":
68
+ sub = args[1] if len(args) > 1 else None
69
+ cmd_configure.run(sub, args[2:])
70
+
71
+ case "doctor":
72
+ fail("fse doctor is not yet available.")
73
+
74
+ case None | "fse":
75
+ intro()
76
+
77
+ case "--help" | "-h":
78
+ cmd_help.run()
79
+
80
+ case "--about":
81
+ cmd_about.run()
82
+
83
+ case _:
84
+ fail(
85
+ f"Unknown command: {command}\n"
86
+ f" Run {W}fse --help{R} for usage."
87
+ )
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
package/cli/logo.py ADDED
@@ -0,0 +1,11 @@
1
+ # cli/logo.py
2
+ # ASCII art logo - edit this file to change the banner
3
+
4
+ LOGO = """
5
+ _____ .__ ___. .___
6
+ _╱ ____╲___________ _____ ______ ____ _____ │ │ ____ _____╲_ │__ ____ __│ _╱
7
+ ╲ __╲╱ _ ╲_ __ ╲╱ ╲ ╱ ___╱╱ __ ╲╲__ ╲ │ │ ______ _╱ __ ╲ ╱ ╲│ __ ╲_╱ __ ╲ ╱ __ │
8
+ │ │ ( <_> ) │ ╲╱ Y Y ╲╲___ ╲╲ ___╱ ╱ __ ╲│ │__ ╱_____╱ ╲ ___╱│ Y Y ╲ ╲_╲ ╲ ___╱╱ ╱_╱ │
9
+ │__│ ╲____╱│__│ │__│_│ ╱____ >╲___ >____ ╱____╱ ╲___ >__│_│ ╱___ ╱╲___ >____ │
10
+ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱
11
+ """
package/cli/shim.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { spawnSync } = require("child_process");
5
+ const path = require("path");
6
+
7
+ const MIN_MAJOR = 3;
8
+ const MIN_MINOR = 8;
9
+
10
+ const SCRIPT = path.join(__dirname, "fse.py");
11
+ const ARGS = process.argv.slice(2);
12
+
13
+ function findPython() {
14
+ const candidates = ["py", "python", "python3"];
15
+
16
+ for (const cmd of candidates) {
17
+ const result = spawnSync(cmd, ["--version"], { encoding: "utf8" });
18
+ if (result.status === 0) {
19
+ const output = (result.stdout || result.stderr || "").trim();
20
+ const match = output.match(/Python (\d+)\.(\d+)/);
21
+ if (match) {
22
+ const major = parseInt(match[1], 10);
23
+ const minor = parseInt(match[2], 10);
24
+ if (major === MIN_MAJOR && minor >= MIN_MINOR) return { cmd, version: output };
25
+ if (major > MIN_MAJOR) return { cmd, version: output };
26
+ fail(
27
+ "Python " + MIN_MAJOR + "." + MIN_MINOR + "+ is required.\n" +
28
+ " Found: " + output + "\n" +
29
+ " Install from https://python.org"
30
+ );
31
+ }
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function fail(msg) {
38
+ console.error("\x1b[31m\x1b[1m ERR \x1b[0m " + msg);
39
+ process.exit(1);
40
+ }
41
+
42
+ const python = findPython();
43
+
44
+ if (!python) {
45
+ fail(
46
+ "Python is required to run fse.\n" +
47
+ " Install from https://python.org\n" +
48
+ " Windows: winget install Python.Python.3"
49
+ );
50
+ }
51
+
52
+ const result = spawnSync(python.cmd, [SCRIPT, ...ARGS], {
53
+ stdio: "inherit",
54
+ env: process.env,
55
+ });
56
+
57
+ process.exit(result.status ?? 1);
File without changes
@@ -0,0 +1,79 @@
1
+ # ui/output.py
2
+ # All terminal output primitives. No logic, no commands.
3
+ # Every print in fse goes through here.
4
+
5
+ import os
6
+ import sys
7
+
8
+ if os.name == "nt":
9
+ try:
10
+ os.system("chcp 65001 >nul")
11
+ except:
12
+ pass
13
+
14
+ try:
15
+ sys.stdout.reconfigure(encoding="utf-8")
16
+ sys.stderr.reconfigure(encoding="utf-8")
17
+ except:
18
+ pass
19
+
20
+ # -- ansi colors --
21
+
22
+ RESET = "\x1b[0m"
23
+ BOLD = "\x1b[1m"
24
+ DIM = "\x1b[2m"
25
+
26
+ RED = "\x1b[31m"
27
+ GREEN = "\x1b[32m"
28
+ YELLOW = "\x1b[33m"
29
+ BLUE = "\x1b[34m"
30
+ MAGENTA= "\x1b[35m"
31
+ CYAN = "\x1b[36m"
32
+ WHITE = "\x1b[37m"
33
+ GRAY = "\x1b[90m"
34
+
35
+ # Semantic palette
36
+ B = "\x1b[38;5;109m" # muted green - arguments/placeholders
37
+ C = "\x1b[38;5;117m" # soft cyan - title, links, script tag
38
+ M = "\x1b[38;5;141m" # muted magenta - experimental sections
39
+ G = "\x1b[38;5;244m" # dim gray - descriptions
40
+ Y = "\x1b[38;5;103m" # muted purple - section headers
41
+ S = "\x1b[38;5;112m" # soft green - success states
42
+ O = "\x1b[38;5;130m" # dim orange - logo
43
+ W = WHITE + BOLD # bright white - commands
44
+ D = DIM # dim - secondary
45
+ R = RESET # reset
46
+
47
+
48
+ # -- primitives --
49
+
50
+ def br():
51
+ print()
52
+
53
+ def rule():
54
+ print(G + " " + "\u2500" * 52 + R)
55
+
56
+ def badge(label, color):
57
+ return f"{color}{BOLD} {label} {R}"
58
+
59
+ def fail(msg):
60
+ br()
61
+ print(f"{badge('ERR', RED)} {msg}")
62
+ br()
63
+ raise SystemExit(1)
64
+
65
+ def row(icon, label, value):
66
+ pad = 10
67
+ label = (label + " " * pad)[:pad]
68
+ print(f"{S}{icon}{R} {D}{label}{R} {W}{value}{R}")
69
+
70
+ def cmd_line(command, desc):
71
+ pad = 34
72
+ command = (command + " " * pad)[:pad]
73
+ print(f" {W}{command}{R}{G}{desc}{R}")
74
+
75
+ def code(msg):
76
+ print(f" {C}{msg}{R}")
77
+
78
+ def link(msg):
79
+ print(f" {C}{msg}{R}")
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@formseal/embed",
3
+ "description": "Drop-in client-side encrypted contact form.",
4
+ "license": "MIT",
5
+ "bin": {
6
+ "fse": "./cli/shim.js"
7
+ },
8
+ "files": [
9
+ "src/",
10
+ "cli/"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": [
16
+ "formseal",
17
+ "fsembed",
18
+ "fse",
19
+ "encryption",
20
+ "curve25519",
21
+ "contact-form",
22
+ "libsodium",
23
+ "privacy"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/grayguava/formseal-embed.git"
28
+ },
29
+ "version": "3.1.0"
30
+ }
@@ -0,0 +1,69 @@
1
+ // fse.config.js
2
+ // Unified configuration for formseal-embed.
3
+ //
4
+ // Edit this file or use the CLI:
5
+ // fse configure endpoint <url>
6
+ // fse configure key <base64url>
7
+ // fse configure field add <name> [options]
8
+ // fse configure field remove <name>
9
+ // fse configure field required <name> <true|false>
10
+ // fse configure field maxLength <name> <number>
11
+
12
+ var FSE = {
13
+
14
+ // -- Endpoint --
15
+ // POST target. Receives: { ciphertext: "<base64url>" }
16
+ endpoint: "https://your-api.example.com/submit",
17
+
18
+ // -- Origin --
19
+ // Identifier for this form deployment. Useful when you have multiple forms.
20
+ origin: "contact-form",
21
+
22
+ // -- Encryption --
23
+ // Recipient x25519 public key in base64url (32 bytes)
24
+ publicKey: "PASTE_YOUR_BASE64URL_PUBLIC_KEY_HERE",
25
+
26
+ // -- Form selectors --
27
+ form: "#contact-form",
28
+ submit: "#contact-submit",
29
+ status: "#contact-status",
30
+
31
+ // -- Submit button states --
32
+ submitStates: {
33
+ idle: "Send message",
34
+ sending: "Sending...",
35
+ sent: "Sent",
36
+ },
37
+
38
+ // -- Success behaviour --
39
+ onSuccess: {
40
+ redirect: false,
41
+ redirectUrl: "/thank-you",
42
+ message: "Thanks! Your message has been sent.",
43
+ },
44
+
45
+ // -- Error behaviour --
46
+ onError: {
47
+ message: "Something went wrong. Please try again.",
48
+ },
49
+
50
+ // -- Fields --
51
+ // Key = field name (matches [name] attribute in HTML)
52
+ // Value = validation rules
53
+ fields: {
54
+ name: {
55
+ required: true,
56
+ maxLength: 100,
57
+ },
58
+ email: {
59
+ type: "email",
60
+ required: true,
61
+ maxLength: 200,
62
+ },
63
+ message: {
64
+ required: true,
65
+ maxLength: 1000,
66
+ },
67
+ },
68
+
69
+ };
package/src/globals.js ADDED
@@ -0,0 +1,75 @@
1
+ // globals.js
2
+ // Single entry point. Drop this one script tag in your HTML.
3
+ //
4
+ // Usage:
5
+ // <script src="formseal-embed/globals.js"></script>
6
+
7
+ (function () {
8
+ "use strict";
9
+
10
+ var scripts = document.querySelectorAll("script[src]");
11
+ var selfSrc = "";
12
+ for (var i = 0; i < scripts.length; i++) {
13
+ if (scripts[i].src.indexOf("globals.js") !== -1) {
14
+ selfSrc = scripts[i].src;
15
+ break;
16
+ }
17
+ }
18
+ var base = selfSrc.substring(0, selfSrc.lastIndexOf("/") + 1);
19
+
20
+ var FILES = [
21
+ "vendor/sodium.js",
22
+ "config/fse.config.js",
23
+ "runtime/fse.crypto.js",
24
+ "runtime/fse.payload.js",
25
+ "runtime/fse.validate.js",
26
+ "runtime/fse.form.js",
27
+ ];
28
+
29
+ function loadNext(index) {
30
+ if (index >= FILES.length) {
31
+ try {
32
+ sodium.ready.then(function () {
33
+ try {
34
+ FSEForm.mount();
35
+ } catch (err) {
36
+ console.error("[fse] Mount failed:", err);
37
+ }
38
+ }).catch(function (err) {
39
+ console.error("[fse] sodium.ready failed:", err);
40
+ });
41
+ } catch (err) {
42
+ console.error("[fse] sodium is not available:", err);
43
+ }
44
+ return;
45
+ }
46
+
47
+ var url = base + FILES[index];
48
+
49
+ fetch(url)
50
+ .then(function (res) {
51
+ if (!res.ok) {
52
+ console.error("[fse] " + res.status + " loading " + url + ". Aborting.");
53
+ return null;
54
+ }
55
+ return res.text();
56
+ })
57
+ .then(function (code) {
58
+ if (code === null || code === undefined) return;
59
+ var s = document.createElement("script");
60
+ s.textContent = code;
61
+ document.head.appendChild(s);
62
+ loadNext(index + 1);
63
+ })
64
+ .catch(function (err) {
65
+ console.error("[fse] Failed to load " + url + ":", err);
66
+ });
67
+ }
68
+
69
+ if (document.readyState === "loading") {
70
+ document.addEventListener("DOMContentLoaded", function () { loadNext(0); });
71
+ } else {
72
+ loadNext(0);
73
+ }
74
+
75
+ })();
@@ -0,0 +1,59 @@
1
+ // fse.crypto.js
2
+ // Pure crypto utilities. No DOM access. No config dependencies.
3
+ // Depends on: window.sodium (libsodium-wrappers, must be ready)
4
+
5
+ var FSECrypto = (function () {
6
+
7
+ // -- Encoding helpers --
8
+
9
+ function bytesToBase64url(bytes) {
10
+ return btoa(String.fromCharCode(...bytes))
11
+ .replace(/\+/g, "-")
12
+ .replace(/\//g, "_")
13
+ .replace(/=+$/, "");
14
+ }
15
+
16
+ function base64urlToBytes(b64url) {
17
+ b64url = b64url.replace(/-/g, "+").replace(/_/g, "/");
18
+ const pad = b64url.length % 4;
19
+ if (pad) b64url += "=".repeat(4 - pad);
20
+ const binary = atob(b64url);
21
+ return Uint8Array.from(binary, function (c) { return c.charCodeAt(0); });
22
+ }
23
+
24
+ // -- Encryption --
25
+
26
+ // Seals a JS object as JSON to a recipient's x25519 public key.
27
+ // Returns the ciphertext as a base64url string.
28
+ // recipientPublicKey: base64url-encoded x25519 public key (32 bytes).
29
+ async function sealJSON(obj, publicKey) {
30
+ if (!publicKey) {
31
+ throw new Error(
32
+ "[fse/crypto] publicKey is not set in fse.config.js."
33
+ );
34
+ }
35
+
36
+ await sodium.ready;
37
+
38
+ const pubKey = base64urlToBytes(publicKey);
39
+ if (pubKey.length !== 32) {
40
+ throw new Error(
41
+ "[fse/crypto] publicKey must decode to exactly 32 bytes."
42
+ );
43
+ }
44
+
45
+ const plaintext = new TextEncoder().encode(JSON.stringify(obj));
46
+ const ciphertext = sodium.crypto_box_seal(plaintext, pubKey);
47
+
48
+ return bytesToBase64url(ciphertext);
49
+ }
50
+
51
+ // -- Public API --
52
+
53
+ return {
54
+ sealJSON,
55
+ bytesToBase64url,
56
+ base64urlToBytes,
57
+ };
58
+
59
+ })();
@@ -0,0 +1,181 @@
1
+ // fse.form.js
2
+ // Submit pipeline controller. Touches the DOM only for:
3
+ // - reading field values (by name attribute)
4
+ // - managing submit button state
5
+ // - writing validation errors to [data-fse-error="fieldname"] elements
6
+ // - writing status messages to the statusSelector element
7
+ //
8
+ // The dev owns all form markup. Formseal-embed owns none of it.
9
+ //
10
+ // Depends on: FSE, FSECrypto, FSEPayload, FSEValidate (all globals).
11
+
12
+ var FSEForm = (function () {
13
+
14
+ function requireGlobal(name) {
15
+ if (typeof window[name] === "undefined") {
16
+ throw new Error("[fse/form] " + name + " is not defined.");
17
+ }
18
+ return window[name];
19
+ }
20
+
21
+ function collectData(formEl, fields) {
22
+ var data = {};
23
+ Object.keys(fields).forEach(function (name) {
24
+ var input = formEl.querySelector("[name='" + name + "']");
25
+ if (!input) return;
26
+ data[name] = input.value.trim();
27
+ });
28
+ return data;
29
+ }
30
+
31
+ function clearFieldErrors(fields) {
32
+ Object.keys(fields).forEach(function (name) {
33
+ var el = document.querySelector("[data-fse-error='" + name + "']");
34
+ if (el) el.textContent = "";
35
+ var input = document.querySelector("[name='" + name + "']");
36
+ if (input) input.removeAttribute("aria-invalid");
37
+ });
38
+ }
39
+
40
+ function showFieldErrors(errors) {
41
+ var focused = false;
42
+ errors.forEach(function (err) {
43
+ var el = document.querySelector("[data-fse-error='" + err.name + "']");
44
+ var input = document.querySelector("[name='" + err.name + "']");
45
+ if (el) el.textContent = err.message;
46
+ if (input) {
47
+ input.setAttribute("aria-invalid", "true");
48
+ if (!focused) { input.focus(); focused = true; }
49
+ }
50
+ });
51
+ }
52
+
53
+ function setStatus(cfg, message, isError, responseData) {
54
+ if (cfg.status) {
55
+ var el = document.querySelector(cfg.status);
56
+ if (el) {
57
+ el.textContent = message;
58
+ el.setAttribute("data-fse-status", isError ? "error" : "success");
59
+ }
60
+ }
61
+
62
+ var cb = window.fseCallbacks;
63
+ if (cb) {
64
+ if (!isError && typeof cb.onSuccess === "function") {
65
+ cb.onSuccess(responseData);
66
+ }
67
+ if (isError && typeof cb.onError === "function") {
68
+ cb.onError(new Error(message));
69
+ }
70
+ }
71
+ }
72
+
73
+ function clearStatus(cfg) {
74
+ if (cfg.status) {
75
+ var el = document.querySelector(cfg.status);
76
+ if (el) {
77
+ el.textContent = "";
78
+ el.removeAttribute("data-fse-status");
79
+ }
80
+ }
81
+ }
82
+
83
+ function setButtonState(btn, state, cfg) {
84
+ var states = cfg.submitStates || {};
85
+ switch (state) {
86
+ case "sending":
87
+ btn.disabled = true;
88
+ btn.textContent = states.sending || "Sending...";
89
+ break;
90
+ case "sent":
91
+ btn.disabled = true;
92
+ btn.textContent = states.sent || "Sent";
93
+ break;
94
+ case "idle":
95
+ default:
96
+ btn.disabled = false;
97
+ btn.textContent = states.idle || "Send";
98
+ break;
99
+ }
100
+ }
101
+
102
+ function mount() {
103
+ var cfg = requireGlobal("FSE");
104
+ var fields = cfg.fields || {};
105
+
106
+ var formEl = document.querySelector(cfg.form);
107
+ if (!formEl) {
108
+ console.error("[fse/form] Form not found: " + cfg.form);
109
+ return;
110
+ }
111
+
112
+ var submitBtn = document.querySelector(cfg.submit);
113
+ if (!submitBtn) {
114
+ console.error("[fse/form] Submit button not found: " + cfg.submit);
115
+ return;
116
+ }
117
+
118
+ setButtonState(submitBtn, "idle", cfg);
119
+
120
+ formEl.addEventListener("submit", async function (e) {
121
+ e.preventDefault();
122
+
123
+ var hp = formEl.querySelector("[name='_hp']");
124
+ if (hp && hp.value) return;
125
+
126
+ clearFieldErrors(fields);
127
+ clearStatus(cfg);
128
+
129
+ var data = collectData(formEl, fields);
130
+
131
+ var result = FSEValidate.validate(data);
132
+ if (!result.valid) {
133
+ showFieldErrors(result.errors);
134
+ return;
135
+ }
136
+
137
+ setButtonState(submitBtn, "sending", cfg);
138
+
139
+ try {
140
+ var payload = FSEPayload.build(data);
141
+
142
+ var ciphertext = await FSECrypto.sealJSON(
143
+ payload,
144
+ cfg.publicKey
145
+ );
146
+
147
+ var res = await fetch(cfg.endpoint, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "text/plain" },
150
+ credentials: "omit",
151
+ body: ciphertext,
152
+ });
153
+
154
+ if (!res.ok) {
155
+ throw new Error("Server responded with " + res.status);
156
+ }
157
+
158
+ var responseData = await res.json().catch(function () { return {}; });
159
+
160
+ setButtonState(submitBtn, "sent", cfg);
161
+ formEl.reset();
162
+
163
+ if (cfg.onSuccess.redirect) {
164
+ window.location.href = cfg.onSuccess.redirectUrl;
165
+ } else {
166
+ setStatus(cfg, cfg.onSuccess.message, false, responseData);
167
+ }
168
+
169
+ } catch (err) {
170
+ console.error("[fse/form] Submit error:", err);
171
+ setButtonState(submitBtn, "idle", cfg);
172
+ setStatus(cfg, cfg.onError.message, true, null);
173
+ }
174
+ });
175
+ }
176
+
177
+ return {
178
+ mount,
179
+ };
180
+
181
+ })();
@@ -0,0 +1,25 @@
1
+ // fse.payload.js
2
+ // Assembles the payload envelope { version, id, submitted_at, data }.
3
+ // No DOM access. No crypto. Depends on: FSE (global).
4
+
5
+ var FSEPayload = (function () {
6
+
7
+ function build(data) {
8
+ if (typeof FSE === "undefined") {
9
+ throw new Error("[fse/payload] FSE is not defined.");
10
+ }
11
+
12
+ return {
13
+ version: "fse.v1.0",
14
+ origin: FSE.origin || "contact-form",
15
+ id: crypto.randomUUID(),
16
+ submitted_at: new Date().toISOString(),
17
+ data: data,
18
+ };
19
+ }
20
+
21
+ return {
22
+ build,
23
+ };
24
+
25
+ })();