@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 +172 -0
- package/cli/commands/__init__.py +0 -0
- package/cli/commands/about.py +24 -0
- package/cli/commands/configure.py +292 -0
- package/cli/commands/help.py +29 -0
- package/cli/commands/init.py +119 -0
- package/cli/fse.py +91 -0
- package/cli/logo.py +11 -0
- package/cli/shim.js +57 -0
- package/cli/ui/__init__.py +0 -0
- package/cli/ui/output.py +79 -0
- package/package.json +30 -0
- package/src/config/fse.config.js +69 -0
- package/src/globals.js +75 -0
- package/src/runtime/fse.crypto.js +59 -0
- package/src/runtime/fse.form.js +181 -0
- package/src/runtime/fse.payload.js +25 -0
- package/src/runtime/fse.validate.js +66 -0
- package/src/vendor/sodium.js +1 -0
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()
|