@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/README.md ADDED
@@ -0,0 +1,172 @@
1
+ <p align="center">
2
+ <img src="fse-ascii.png" alt="formseal">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/badge/encryption-X25519-10b981?style=flat-square&labelColor=1e293b&borderRadius=6px">
7
+ <img src="https://img.shields.io/badge/decryption-offline%20only-f59e0b?style=flat-square&labelColor=1e293b&borderRadius=6px">
8
+ <img src="https://img.shields.io/badge/backend-blind-6366f1?style=flat-square&labelColor=1e293b&borderRadius=6px">
9
+ <img src="https://img.shields.io/npm/v/@formseal/embed?style=flat-square&label=npm&labelColor=fff&color=cb0000&borderRadius=6px">
10
+ <img src="https://img.shields.io/badge/license-MIT-fc8181?style=flat-square&labelColor=1e293b&borderRadius=6px">
11
+ </p>
12
+
13
+ <p align="center">
14
+ A server-blind, browser-native encrypted form poster.
15
+ </p>
16
+
17
+ ---
18
+
19
+ Form submissions are encrypted in the browser using X25519 sealed boxes before reaching any endpoint. The backend receives and stores opaque ciphertext only. Decryption is operator-controlled and happens offline, with your private key.
20
+
21
+ formseal is not a hosted service, dashboard, or SaaS product. It is a drop-in client-side utility.
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install -g @formseal/embed
29
+ fse init
30
+ ```
31
+
32
+ Scaffolds `./formseal-embed/` into your project, then:
33
+
34
+ ```bash
35
+ fse configure quick
36
+ ```
37
+
38
+ You'll be prompted for your POST endpoint and public key. See [Getting started](./docs/getting-started.md) for key generation.
39
+
40
+ > If you prefer not to install globally, `npx @formseal/embed init` works — but you'll need to edit `fse.config.js` manually instead of using the CLI.
41
+
42
+ ---
43
+
44
+ ## Security guarantee
45
+
46
+ > If the POST endpoint is fully compromised, seized, or maliciously operated, previously submitted form data remains confidential.
47
+
48
+ Encryption happens in the browser. The backend stores ciphertext only. Decryption keys never exist in the backend environment. A backend compromise yields no recoverable plaintext.
49
+
50
+ ---
51
+
52
+ ## Threat model
53
+
54
+ formseal is for environments where:
55
+
56
+ - The hosting provider or backend may be compromised
57
+ - The backend must be treated as hostile
58
+ - Data seizure is a realistic concern
59
+ - Retroactive disclosure must be prevented
60
+
61
+ The priority is **backward confidentiality** — protecting already-submitted data — not convenience or real-time administration.
62
+
63
+ ---
64
+
65
+ ## How it works
66
+
67
+ On submit, formseal:
68
+
69
+ 1. Collects field values from your form by `name` attribute
70
+ 2. Validates them against your field rules
71
+ 3. Seals the payload with `crypto_box_seal` (Curve25519 + XSalsa20-Poly1305)
72
+ 4. POSTs raw ciphertext to your configured endpoint
73
+
74
+ Your endpoint stores the ciphertext. Only the holder of the private key can decrypt it.
75
+
76
+ ---
77
+
78
+ ## Wire up your HTML
79
+
80
+ ```html
81
+ <form id="contact-form">
82
+
83
+ <!-- honeypot — hide off-screen with CSS -->
84
+ <input type="text" name="_hp" tabindex="-1" autocomplete="off"
85
+ style="position:absolute;left:-9999px;opacity:0;height:0;">
86
+
87
+ <input type="text" name="name">
88
+ <span data-fse-error="name"></span>
89
+
90
+ <input type="email" name="email">
91
+ <span data-fse-error="email"></span>
92
+
93
+ <textarea name="message"></textarea>
94
+ <span data-fse-error="message"></span>
95
+
96
+ <button type="submit" id="contact-submit">Send message</button>
97
+ </form>
98
+
99
+ <div id="contact-status"></div>
100
+
101
+ <script>
102
+ window.fseCallbacks = {
103
+ onSuccess: function(response) {},
104
+ onError: function(error) {},
105
+ };
106
+ </script>
107
+
108
+ <script src="/formseal-embed/globals.js"></script>
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Payload format
114
+
115
+ ```json
116
+ {
117
+ "version": "fse.v1.0",
118
+ "origin": "contact-form",
119
+ "id": "<uuid>",
120
+ "submitted_at": "<iso8601>",
121
+ "client_tz": "Europe/London",
122
+ "data": {
123
+ "name": "...",
124
+ "email": "...",
125
+ "message": "..."
126
+ }
127
+ }
128
+ ```
129
+
130
+ The entire object is sealed with `crypto_box_seal`. Your endpoint receives raw ciphertext as the request body.
131
+
132
+ ---
133
+
134
+ ## CSS hooks
135
+
136
+ | Selector | When |
137
+ |---|---|
138
+ | `[data-fse-error="field"]` | Populated with a validation error |
139
+ | `[aria-invalid="true"]` | Set on invalid inputs |
140
+ | `[data-fse-status="success"]` | Set on status element on success |
141
+ | `[data-fse-status="error"]` | Set on status element on error |
142
+
143
+ ---
144
+
145
+ ## What formseal does not do
146
+
147
+ - No admin dashboard or inbox UI
148
+ - No hosted service
149
+ - No bundler or build step required
150
+ - No npm dependencies at runtime
151
+
152
+ These are intentional.
153
+
154
+ ---
155
+
156
+ ## Documentation
157
+
158
+ - [Getting started](./docs/getting-started.md)
159
+ - [Concepts → How it works](./docs/concepts/how-it-works.md)
160
+ - [Concepts → Security](./docs/concepts/security.md)
161
+ - [Integration → HTML](./docs/integration/html.md)
162
+ - [Integration → Fields](./docs/integration/fields.md)
163
+ - [Integration → JavaScript](./docs/integration/javascript.md)
164
+ - [Deployment → Endpoint](./docs/deployment/endpoint.md)
165
+ - [Deployment → Decryption](./docs/deployment/decryption.md)
166
+ - [Deployment → Versioning](./docs/deployment/versioning.md)
167
+
168
+ ---
169
+
170
+ ## License
171
+
172
+ MIT
File without changes
@@ -0,0 +1,24 @@
1
+ # commands/about.py
2
+ # About command - shows logo and info
3
+
4
+ from ui.output import br, link, O, R, W, D
5
+ from logo import LOGO
6
+
7
+
8
+ def run():
9
+ br()
10
+ lines = LOGO.strip().split("\n")
11
+ for i, line in enumerate(lines):
12
+ if i == 0:
13
+ line = " " + line # add leading space to first line
14
+ print(f"{O}{line}{R}")
15
+ br()
16
+ print(f" {W}Client-side encrypted contact forms.{R}")
17
+ br()
18
+ print(f" {D}Author:{R} grayguava")
19
+ print(f" {D}License:{R} MIT")
20
+ br()
21
+ print(f" {D}GitHub links:")
22
+ link("https://github.com/grayguava/formseal-embed")
23
+ link("https://github.com/grayguava/formseal-embed/docs")
24
+ br()
@@ -0,0 +1,292 @@
1
+ # commands/configure.py
2
+ # Configure formseal-embed settings.
3
+ # Usage:
4
+ # fse configure quick - set endpoint + key
5
+ # fse configure field add <name> [options]
6
+ # fse configure field remove <name>
7
+ # fse configure field required <name> <true|false>
8
+ # fse configure field maxLength <name> <number>
9
+ # fse configure field type <name> <email|tel|text>
10
+
11
+ import re
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from ui.output import br, rule, row, code, fail, C, G, Y, S, W, R, D
16
+
17
+ CONFIG_PATH = Path.cwd() / "formseal-embed" / "config" / "fse.config.js"
18
+
19
+ MARKERS = {
20
+ "endpoint": "endpoint:",
21
+ "publicKey": "publicKey:",
22
+ }
23
+
24
+
25
+ def _prompt(label: str, hint: str) -> str:
26
+ try:
27
+ return input(f" {D}{label}:{R} ").strip()
28
+ except (KeyboardInterrupt, EOFError):
29
+ br()
30
+ return ""
31
+
32
+
33
+ def _patch(field: str, value: str):
34
+ marker = MARKERS.get(field)
35
+ if not marker or not value:
36
+ return False
37
+
38
+ if not CONFIG_PATH.exists():
39
+ return False
40
+
41
+ lines = CONFIG_PATH.read_text(encoding="utf-8").splitlines(keepends=True)
42
+ matched = False
43
+ updated = []
44
+
45
+ for line in lines:
46
+ if marker in line and "://" not in marker:
47
+ matched = True
48
+ line = re.sub(r':\s*"[^"]*"', f': "{value}"', line)
49
+ updated.append(line)
50
+
51
+ if matched:
52
+ CONFIG_PATH.write_text("".join(updated), encoding="utf-8")
53
+ return matched
54
+
55
+
56
+ def _normalize_endpoint(url: str) -> str:
57
+ url = url.strip()
58
+ if not url.startswith("http://") and not url.startswith("https://"):
59
+ url = "https://" + url
60
+ return url
61
+
62
+
63
+ def _load_config() -> dict:
64
+ if not CONFIG_PATH.exists():
65
+ return {}
66
+ content = CONFIG_PATH.read_text(encoding="utf-8")
67
+ match = re.search(r'var FSE = (\{[\s\S]*?\});', content)
68
+ if not match:
69
+ return {}
70
+ try:
71
+ return json.loads(match.group(1))
72
+ except json.JSONDecodeError:
73
+ return {}
74
+
75
+
76
+ def _save_config(cfg: dict):
77
+ lines = CONFIG_PATH.read_text(encoding="utf-8").splitlines(keepends=True)
78
+ out = []
79
+ for line in lines:
80
+ if line.strip().startswith("fields:") and "{" in line:
81
+ out.append(line)
82
+ break
83
+ out.append(line)
84
+ else:
85
+ out.append(f" fields: {json.dumps(cfg.get('fields', {}), indent=4)},\n")
86
+ CONFIG_PATH.write_text("".join(out), encoding="utf-8")
87
+ return
88
+
89
+ indent = " "
90
+ fields_json = json.dumps(cfg.get("fields", {}), indent=4)
91
+ fields_json = "\n".join(indent + line for line in fields_json.splitlines())
92
+ out.append(fields_json + ",\n")
93
+
94
+ while lines and not lines[-1].strip().startswith("}"):
95
+ lines.pop()
96
+ out.extend(lines)
97
+
98
+ CONFIG_PATH.write_text("".join(out), encoding="utf-8")
99
+
100
+
101
+ def run(subcommand: str, args: list):
102
+ if not subcommand:
103
+ fail("Usage: fse configure <quick|field>")
104
+
105
+ if not CONFIG_PATH.exists():
106
+ fail(
107
+ "formseal-embed/config/fse.config.js not found.\n"
108
+ f" Run {W}fse init{R} first."
109
+ )
110
+
111
+ if subcommand in ("quick", "q"):
112
+ _run_quick()
113
+ elif subcommand in ("field", "fields", "f"):
114
+ _run_field(args)
115
+ else:
116
+ fail(f"Unknown subcommand: {subcommand}\n" +
117
+ f" Use {W}fse configure quick{R} or {W}fse configure field{R}")
118
+
119
+
120
+ def _run_quick():
121
+ br()
122
+ print(f"{C} \u250c\u2500 {R}{W}formseal-embed{R} {G}quick configure{R}")
123
+ print(G + " " + "\u2500" * 52 + R)
124
+ br()
125
+
126
+ endpoint = _prompt("POST endpoint", ":")
127
+ key = _prompt("X25519 public key (base64url)", ":")
128
+
129
+ br()
130
+ updated = False
131
+ if endpoint:
132
+ endpoint = _normalize_endpoint(endpoint)
133
+ if _patch("endpoint", endpoint):
134
+ updated = True
135
+ if key:
136
+ if _patch("publicKey", key):
137
+ updated = True
138
+
139
+ if updated:
140
+ print(f" {S}*{R} {G}Updated!{R}")
141
+ print(G + " " + "\u2500" * 52 + R)
142
+
143
+ if endpoint:
144
+ row(">", "POST API", endpoint)
145
+ if key:
146
+ row(">", "X25519 Key", key[:24] + "..." if len(key) > 24 else key)
147
+
148
+
149
+ def _run_field(args: list):
150
+ if not args:
151
+ fail("Usage: fse configure field <add|remove|required|maxLength|type>")
152
+
153
+ action = args[0]
154
+
155
+ if action == "add":
156
+ _field_add(args[1:])
157
+ elif action in ("remove", "rm", "delete"):
158
+ _field_remove(args[1:])
159
+ elif action == "required":
160
+ _field_required(args[1:])
161
+ elif action in ("maxlength", "maxLength"):
162
+ _field_maxlength(args[1:])
163
+ elif action in ("type",):
164
+ _field_type(args[1:])
165
+ else:
166
+ fail(f"Unknown field action: {action}\n" +
167
+ f" Use add, remove, required, maxLength, or type")
168
+
169
+
170
+ def _field_add(args: list):
171
+ if not args:
172
+ fail("Usage: fse configure field add <name> [required:true] [maxLength:n] [type:email]")
173
+
174
+ name = args[0]
175
+ cfg = _load_config()
176
+ fields = cfg.get("fields", {})
177
+
178
+ if name in fields:
179
+ fail(f"Field {W}{name}{R} already exists.")
180
+
181
+ field = {}
182
+ for opt in args[1:]:
183
+ if ":" in opt:
184
+ k, v = opt.split(":", 1)
185
+ if k == "required":
186
+ field["required"] = v.lower() == "true"
187
+ elif k in ("maxLength", "maxlength"):
188
+ try:
189
+ field["maxLength"] = int(v)
190
+ except ValueError:
191
+ fail(f"Invalid maxLength: {v}")
192
+ elif k in ("type",):
193
+ if v not in ("email", "tel", "text"):
194
+ fail(f"Invalid type: {v}. Use email, tel, or text.")
195
+ field["type"] = v
196
+
197
+ fields[name] = field
198
+ cfg["fields"] = fields
199
+ _save_config(cfg)
200
+
201
+ br()
202
+ print(f" {G}Added field:{R} {name}")
203
+ if field:
204
+ for k, v in field.items():
205
+ row("", k, str(v))
206
+
207
+
208
+ def _field_remove(args: list):
209
+ if not args:
210
+ fail("Usage: fse configure field remove <name>")
211
+
212
+ name = args[0]
213
+ cfg = _load_config()
214
+ fields = cfg.get("fields", {})
215
+
216
+ if name not in fields:
217
+ fail(f"Field {W}{name}{R} not found.")
218
+
219
+ del fields[name]
220
+ cfg["fields"] = fields
221
+ _save_config(cfg)
222
+
223
+ br()
224
+ print(f" {G}Removed field:{R} {name}")
225
+
226
+
227
+ def _field_required(args: list):
228
+ if len(args) != 2:
229
+ fail("Usage: fse configure field required <name> <true|false>")
230
+
231
+ name, value = args
232
+ if value not in ("true", "false"):
233
+ fail("Use true or false")
234
+
235
+ cfg = _load_config()
236
+ fields = cfg.get("fields", {})
237
+
238
+ if name not in fields:
239
+ fail(f"Field {W}{name}{R} not found.")
240
+
241
+ fields[name]["required"] = value == "true"
242
+ cfg["fields"] = fields
243
+ _save_config(cfg)
244
+
245
+ br()
246
+ row(">", f"{name}.required", value)
247
+
248
+
249
+ def _field_maxlength(args: list):
250
+ if len(args) != 2:
251
+ fail("Usage: fse configure field maxLength <name> <number>")
252
+
253
+ name, value = args
254
+ try:
255
+ maxlen = int(value)
256
+ except ValueError:
257
+ fail(f"Invalid number: {value}")
258
+
259
+ cfg = _load_config()
260
+ fields = cfg.get("fields", {})
261
+
262
+ if name not in fields:
263
+ fail(f"Field {W}{name}{R} not found.")
264
+
265
+ fields[name]["maxLength"] = maxlen
266
+ cfg["fields"] = fields
267
+ _save_config(cfg)
268
+
269
+ br()
270
+ row(">", f"{name}.maxLength", str(maxlen))
271
+
272
+
273
+ def _field_type(args: list):
274
+ if len(args) != 2:
275
+ fail("Usage: fse configure field type <name> <email|tel|text>")
276
+
277
+ name, value = args
278
+ if value not in ("email", "tel", "text"):
279
+ fail(f"Invalid type: {value}. Use email, tel, or text.")
280
+
281
+ cfg = _load_config()
282
+ fields = cfg.get("fields", {})
283
+
284
+ if name not in fields:
285
+ fail(f"Field {W}{name}{R} not found.")
286
+
287
+ fields[name]["type"] = value
288
+ cfg["fields"] = fields
289
+ _save_config(cfg)
290
+
291
+ br()
292
+ row(">", f"{name}.type", value)
@@ -0,0 +1,29 @@
1
+ # commands/help.py
2
+ # Help command - shows all available commands
3
+
4
+ from ui.output import br, rule, cmd_line, link, C, G, Y, M, W, D, R
5
+
6
+
7
+ def run():
8
+ br()
9
+ print(f"{C} \u250c\u2500 {R}{W}formseal-embed{R} {G}@formseal/embed{R}")
10
+ print(G + " " + "\u2500" * 52 + R)
11
+ print(f" {G}>>{R} {Y}Commands{R}")
12
+ print(G + " " + "\u2500" * 52 + R)
13
+ cmd_line("fse init", "scaffold ./formseal-embed/ into current directory")
14
+ cmd_line("fse configure quick", "set endpoint and public key")
15
+ cmd_line("fse configure field add", "add a field")
16
+ cmd_line("fse configure field remove", "remove a field")
17
+ cmd_line("fse configure field required", "set field required")
18
+ cmd_line("fse configure field maxLength", "set field max length")
19
+ cmd_line("fse configure field type", "set field type (email|tel|text)")
20
+ br()
21
+ print(f" {G}>>{R} {M}coming soon{R}")
22
+ print(G + " " + "\u2500" * 52 + R)
23
+ cmd_line("fse doctor", "validate config and schema")
24
+ br()
25
+ print(f" {G}>>{R} {Y}docs{R}")
26
+ print(G + " " + "\u2500" * 52 + R)
27
+ print(f" {G}Docs:{R}")
28
+ link("https://github.com/grayguava/formseal-embed/docs")
29
+ br()
@@ -0,0 +1,119 @@
1
+ # commands/init.py
2
+ # Scaffolds ./formseal-embed/ into the current working directory.
3
+ # After scaffolding, prompts for endpoint and public key (both skippable).
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from ui.output import br, rule, row, code, link, fail, C, G, Y, S, W, R, D
9
+
10
+ # MASCOT: from ui.mascot import on_init
11
+
12
+ SRC = Path(__file__).resolve().parent.parent.parent / "src"
13
+ DEST = Path.cwd() / "formseal-embed"
14
+
15
+
16
+ def _prompt(label: str, hint: str) -> str:
17
+ try:
18
+ return input(f" {D}{label}:{R} ").strip()
19
+ except (KeyboardInterrupt, EOFError):
20
+ br()
21
+ return ""
22
+
23
+
24
+ def _confirm(prompt: str) -> bool:
25
+ try:
26
+ return input(f" {W}{prompt}{R} ").strip().lower() == "y"
27
+ except (KeyboardInterrupt, EOFError):
28
+ br()
29
+ return False
30
+
31
+
32
+ def _patch_config(field: str, value: str):
33
+ import re
34
+ from commands.configure import MARKERS
35
+
36
+ marker = MARKERS.get(field)
37
+ if not marker or not value:
38
+ return False
39
+
40
+ config = DEST / "config" / "fse.config.js"
41
+ if not config.exists():
42
+ return False
43
+
44
+ lines = config.read_text(encoding="utf-8").splitlines(keepends=True)
45
+ updated = []
46
+ matched = False
47
+ for line in lines:
48
+ if marker in line and "://" not in marker:
49
+ matched = True
50
+ line = re.sub(r':\s*"[^"]*"', f': "{value}"', line)
51
+ updated.append(line)
52
+
53
+ if matched:
54
+ config.write_text("".join(updated), encoding="utf-8")
55
+ return matched
56
+
57
+
58
+ def _normalize_endpoint(url: str) -> str:
59
+ url = url.strip()
60
+ if not url.startswith("http://") and not url.startswith("https://"):
61
+ url = "https://" + url
62
+ return url
63
+
64
+
65
+ def run():
66
+ if DEST.exists():
67
+ fail(
68
+ "./formseal-embed/ already exists.\n"
69
+ " Remove it first if you want a fresh scaffold."
70
+ )
71
+
72
+ if not SRC.exists():
73
+ fail(
74
+ f"Source files not found at {SRC}.\n"
75
+ " Is the package installed correctly?"
76
+ )
77
+
78
+ shutil.copytree(SRC, DEST)
79
+
80
+ br()
81
+ print(f"{C} \u250c\u2500 {R}{W}formseal-embed{R} {S}initialized{R}")
82
+ print(G + " " + "\u2500" * 52 + R)
83
+ br()
84
+
85
+ do_config = _confirm("Configure now?")
86
+
87
+ if do_config:
88
+ br()
89
+ endpoint = _prompt("POST endpoint", "https://your-api.example.com/submit")
90
+ key = _prompt("X25519 public key (base64url)", "base64url x25519 public key")
91
+
92
+ br()
93
+ updated = False
94
+ if endpoint:
95
+ endpoint = _normalize_endpoint(endpoint)
96
+ if _patch_config("endpoint", endpoint):
97
+ updated = True
98
+ if key:
99
+ if _patch_config("publicKey", key):
100
+ updated = True
101
+
102
+ if updated:
103
+ print(f" {S}*{R} {G}Set!{R}")
104
+ print(G + " " + "\u2500" * 52 + R)
105
+
106
+ if endpoint:
107
+ row(">", "POST API", endpoint)
108
+ if key:
109
+ row(">", "X25519 Key", key[:24] + "..." if len(key) > 24 else key)
110
+
111
+ br()
112
+
113
+ br()
114
+ print(f" {G}\u2192{R} {Y}Next steps{R}")
115
+ print(G + " " + "\u2500" * 52 + R)
116
+ br()
117
+ print(f" {G}Wire up in your HTML:{R}")
118
+ code('<script src="/formseal-embed/globals.js"></script>')
119
+ br()