Skip to content

Commit 831db9d

Browse files
committed
feat(setup): support operator contact domain
1 parent a07d804 commit 831db9d

10 files changed

Lines changed: 290 additions & 8 deletions

defaults/v8s-site-config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"operator": {
1616
"legal_name": "",
1717
"short_domain": "",
18+
"operator_domain": "",
1819
"jurisdiction": "",
1920
"governing_law": "",
2021
"contact_email": "",

docs/adr/0002-site-config-schema-versioning.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Keep `schema_version` at `1.0` for additive, backward-compatible changes.
1616

1717
Increment `schema_version` only when a stored `custom/v8s-site-config.json` file needs a migration or when existing field semantics change incompatibly.
1818

19+
Record additive field changes in `docs/schema-changelog.md`, even when `schema_version` does not change.
20+
1921
When `schema_version` changes, the same change should include:
2022

2123
- the new default schema version in `defaults/v8s-site-config.json`
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 0003. Keep instance-owned changes in custom
2+
3+
Date: 2026-05-26
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
vanityURLs instances need to receive product updates without overwriting local links, policies, branding, and operator settings.
10+
11+
The repository contains product-owned defaults and scripts, instance-owned custom files, and generated build output.
12+
13+
## Decision
14+
15+
Product-owned files live under `defaults/` and `scripts/`.
16+
17+
Instance-owned files live under `custom/`.
18+
19+
Generated output lives under `build/` and `src/`.
20+
21+
The build copies product defaults, overlays `custom/`, then writes generated runtime artifacts. Instance owners should edit `custom/`, not `defaults/` or `build/`, unless they are contributing product changes upstream.
22+
23+
## Consequences
24+
25+
- Instances can upgrade product files while preserving local configuration
26+
- `custom/` is the operational boundary for links, policies, site config, public overrides, and local helper settings
27+
- Build output can be deleted and regenerated
28+
- Documentation can refer to source files without turning generated artifacts into edit targets
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# 0004. Custom policy replaces the default source policy
2+
3+
Date: 2026-05-26
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
`v8s-policies.json` controls destination safety, allow domains, blocked domains, scanner keywords, and generated blocklist sources.
10+
11+
If a custom policy were merged over the default source policy, a removed local policy item could reappear from the product defaults after an upgrade.
12+
13+
## Decision
14+
15+
When `custom/v8s-policies.json` exists, it replaces the default source policy.
16+
17+
Generated feed data may still be merged afterward, and allow-domain entries can force-allow generated blocks.
18+
19+
Legacy `v8s-blocklist.json` paths remain migration-compatible, but new product and instance documentation should use `v8s-policies.json`.
20+
21+
## Consequences
22+
23+
- Instance owners have explicit control over local policy
24+
- Removing a local policy entry is durable
25+
- Product defaults remain useful for new instances
26+
- Generated feeds remain a separate optional layer
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 0005. Support deferred legal pages
2+
3+
Date: 2026-05-26
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
New users need a fast phase-1 path to get a redirector online. Full privacy, terms, and standalone security pages require operator-specific legal decisions that may take longer.
10+
11+
At the same time, a public short-link domain still needs abuse and vulnerability contact paths early.
12+
13+
## Decision
14+
15+
Setup supports deferring full privacy, terms, and standalone security pages with `operator.legal_pages_enabled`.
16+
17+
When full legal pages are deferred:
18+
19+
- Trust & Safety still deploys
20+
- `/.well-known/security.txt` still deploys when security contact data is valid
21+
- privacy, terms, and standalone security pages are skipped until configured
22+
23+
## Consequences
24+
25+
- Quickstart remains focused on first deployment
26+
- Operators can publish basic trust contact paths before finishing full legal review
27+
- Full legal pages still require operator, jurisdiction, contact, and date fields
28+
- Setup can be rerun later to enable the full page set
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# 0006. Use readable random slugs
2+
3+
Date: 2026-05-26
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
The `lnk` CLI can generate short slugs when the user does not provide one. Fully unrestricted random strings can include characters that are visually ambiguous or awkward to read over the phone.
10+
11+
Different teams may also want different generated slug lengths for different tags.
12+
13+
## Decision
14+
15+
Generated slugs use a readable alphabet from `custom/v8s-site-config.json`, falling back to the product default.
16+
17+
The site config also stores:
18+
19+
- `links.random_slug_length`
20+
- `links.random_slug_alphabet`
21+
- `links.tag_random_slug_lengths`
22+
23+
When multiple tags define random slug lengths, `lnk` uses the shortest configured length.
24+
25+
## Consequences
26+
27+
- Generated slugs are easier to read and type
28+
- Instances can keep short defaults while using longer slugs for selected tags
29+
- The schema fields are additive and do not require a `schema_version` bump
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# 0007. Scope scanner keywords to requests
2+
3+
Date: 2026-05-26
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
The policy layer blocks scanner probes such as WordPress and PHP endpoint scans. Some strings, such as `.php`, are unsafe as requested paths on a non-PHP redirector but can be legitimate in long-link destinations.
10+
11+
Blocking those strings everywhere can reject valid redirects.
12+
13+
## Decision
14+
15+
Scanner-probe keywords default to request scope unless explicitly configured otherwise.
16+
17+
Policy entries can set `scope` to control whether a keyword applies to request paths, redirect targets, or both.
18+
19+
## Consequences
20+
21+
- Scanner probes are still blocked before short-link lookup
22+
- Valid long URLs ending in `.php` can be used as redirect targets
23+
- Existing scanner policies remain compatible
24+
- Policy authors can still opt into target blocking for specific keywords
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# 0008. Detach instances and upgrade product files
2+
3+
Date: 2026-05-26
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
Users create their own instance repository from the vanityURLs code. That repository should become theirs, with its own GitHub remote, while still being able to refresh product files from upstream.
10+
11+
## Decision
12+
13+
`npm run detach` removes upstream project metadata that should not belong to a new instance.
14+
15+
`npm run upgrade` refreshes product-owned paths from upstream while protecting instance-owned paths such as `custom/`, `wrangler.toml`, and `.dev.vars`.
16+
17+
The default upgrade path includes product files such as `defaults/`, `scripts/`, `package.json`, `package-lock.json`, `README.md`, `LICENSE`, and `cloudflare-setup.md`.
18+
19+
## Consequences
20+
21+
- New instances can start as independent repositories
22+
- Upgrades can refresh product behavior without overwriting local configuration
23+
- Protected local paths remain the operator's responsibility
24+
- Release metadata and upstream workflow files do not leak into detached instances

docs/schema-changelog.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Schema changelog
2+
3+
This changelog records field-level schema changes for vanityURLs configuration files, including additive changes that do not bump `schema_version`.
4+
5+
`schema_version` is reserved for incompatible stored-config changes that need migration. See `docs/adr/0002-site-config-schema-versioning.md`.
6+
7+
## 2026-05-26
8+
9+
### v8s-site-config.json
10+
11+
Added optional field under `operator`:
12+
13+
- `operator_domain`
14+
15+
Compatibility: additive, no `schema_version` bump
16+
17+
Migration: none required. When `operator.operator_domain` is blank or absent, setup defaults role-based email addresses from the short domain. When it is set, setup defaults operator, privacy, Trust & Safety, and security email addresses from the operator domain.
18+
19+
### v8s-site-config.json
20+
21+
Added optional fields under `links`:
22+
23+
- `random_slug_length`
24+
- `random_slug_alphabet`
25+
- `tag_random_slug_lengths`
26+
27+
Compatibility: additive, no `schema_version` bump
28+
29+
Migration: none required. `lnk` reads missing values from `defaults/v8s-site-config.json`.
30+
31+
### v8s-site-config.json
32+
33+
Added installer-managed branding fields:
34+
35+
- `branding.domain`
36+
- `branding.custom_public`
37+
- `branding.wordmark.black`
38+
- `branding.wordmark.green`
39+
40+
Compatibility: additive, no `schema_version` bump
41+
42+
Migration: none required. Existing instances without `branding` continue to use default public assets or custom public files.
43+
44+
### v8s-site-config.json
45+
46+
Added deferred legal-page mode:
47+
48+
- `operator.legal_pages_enabled`
49+
50+
Compatibility: additive, no `schema_version` bump
51+
52+
Migration: none required. Missing values behave like enabled legal pages only when the operator fields are fully configured.
53+
54+
### v8s-site-config.json
55+
56+
Expanded operator fields used by generated privacy, terms, Trust & Safety, security, and `security.txt` output:
57+
58+
- `operator.legal_name`
59+
- `operator.short_domain`
60+
- `operator.jurisdiction`
61+
- `operator.governing_law`
62+
- `operator.contact_email`
63+
- `operator.privacy_contact`
64+
- `operator.abuse_contact`
65+
- `operator.security_contact`
66+
- `operator.last_updated`
67+
- `operator.umami_geo_ip_mode`
68+
- `operator.analytics_disclosure`
69+
- `operator.analytics_retention`
70+
- `operator.abuse_response_window`
71+
72+
Compatibility: additive, no `schema_version` bump
73+
74+
Migration: none required for plain redirect behavior. Generated legal pages require the relevant fields before launch.
75+
76+
### v8s-local-config.json
77+
78+
Added local helper and local publish configuration:
79+
80+
- `shell_helper`
81+
- `lnk_cli`
82+
- `local_publish`
83+
- `registry`
84+
- `repository`
85+
86+
Compatibility: additive, no `schema_version` bump
87+
88+
Migration: none required. `npm run local-install` writes workstation-specific values.
89+
90+
### v8s-policies.json
91+
92+
Replaced legacy blocklist naming with the broader policy source:
93+
94+
- `defaults`
95+
- `allow_domains`
96+
- `blocked_keywords`
97+
- `block_domains`
98+
- optional `generated_sources`
99+
100+
Compatibility: additive and rename-compatible, no `schema_version` bump
101+
102+
Migration: legacy `v8s-blocklist.json` filenames are still recognized for migration compatibility, but new instances should use `v8s-policies.json`.
103+
104+
### v8s-schedules.json
105+
106+
Added schedule configuration keyed by slug:
107+
108+
- `timezone`
109+
- `default`
110+
- shortcut rules such as `9to5`
111+
- `rules[]` entries with `label`, `timezone`, `days`, `from`, `to`, and `target`
112+
113+
Compatibility: additive, no `schema_version` bump
114+
115+
Migration: none required. Missing custom schedules mean the link uses its normal target.

scripts/install.mjs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,20 +91,22 @@ async function promptForMissing(args) {
9191
args.configureLegalPages = await confirm(rl, "Configure privacy, terms, and security pages now?", configuredOperator.legal_pages_enabled !== false && hasConfiguredLegalPages(configuredOperator));
9292
args.operatorLegalName = await question(rl, "Operator legal name", args.operatorLegalName || configuredOperator.legal_name || "");
9393
args.operatorShortDomain = args.operatorShortDomain || args.domain;
94+
args.operatorDomain = await question(rl, "Operator domain for contact emails", args.operatorDomain || configuredOperator.operator_domain || "");
95+
const operatorEmailDomain = args.operatorDomain || args.domain;
9496
if (args.configureLegalPages) {
9597
args.operatorJurisdiction = await question(rl, "Operator jurisdiction, for example Canada", args.operatorJurisdiction || configuredOperator.jurisdiction || "");
9698
args.operatorGoverningLaw = await question(rl, "Governing law", args.operatorGoverningLaw || configuredOperator.governing_law || args.operatorJurisdiction || "");
97-
args.operatorContactEmail = await question(rl, "Operator contact email", args.operatorContactEmail || configuredOperator.contact_email || defaultContactEmail("hello", args.domain));
98-
args.operatorPrivacyContact = await question(rl, "Privacy contact", args.operatorPrivacyContact || configuredOperator.privacy_contact || defaultContactEmail("privacy", args.domain));
99+
args.operatorContactEmail = await question(rl, "Operator contact email", args.operatorContactEmail || configuredOperator.contact_email || defaultContactEmail("hello", operatorEmailDomain));
100+
args.operatorPrivacyContact = await question(rl, "Privacy contact", args.operatorPrivacyContact || configuredOperator.privacy_contact || defaultContactEmail("privacy", operatorEmailDomain));
99101
} else {
100102
args.operatorJurisdiction = args.operatorJurisdiction || configuredOperator.jurisdiction || "";
101103
args.operatorGoverningLaw = args.operatorGoverningLaw || configuredOperator.governing_law || args.operatorJurisdiction || "";
102104
args.operatorContactEmail = args.operatorContactEmail || configuredOperator.contact_email || "";
103105
args.operatorPrivacyContact = args.operatorPrivacyContact || configuredOperator.privacy_contact || "";
104106
}
105-
args.operatorAbuseContact = await question(rl, "Trust & Safety contact", args.operatorAbuseContact || configuredOperator.abuse_contact || defaultContactEmail("abuse", args.domain));
107+
args.operatorAbuseContact = await question(rl, "Trust & Safety contact", args.operatorAbuseContact || configuredOperator.abuse_contact || defaultContactEmail("abuse", operatorEmailDomain));
106108
args.operatorAbuseResponseWindow = await question(rl, "Trust & Safety response window", args.operatorAbuseResponseWindow || configuredOperator.abuse_response_window || "5 business days");
107-
args.operatorSecurityContact = await question(rl, "Security contact", args.operatorSecurityContact || configuredOperator.security_contact || defaultContactEmail("security", args.domain));
109+
args.operatorSecurityContact = await question(rl, "Security contact", args.operatorSecurityContact || configuredOperator.security_contact || defaultContactEmail("security", operatorEmailDomain));
108110
if (args.configureLegalPages) {
109111
args.operatorLastUpdated = await question(rl, "Legal pages last updated date", args.operatorLastUpdated || configuredOperator.last_updated || gitLastUpdatedDate() || todayIsoDate());
110112
} else {
@@ -292,14 +294,17 @@ function normalizeWordmarkSplit(args) {
292294
}
293295

294296
function normalizeOperator(args) {
295-
const contactEmail = String(args.operatorContactEmail || defaultContactEmail("hello", args.domain)).trim();
296-
const privacyContact = String(args.operatorPrivacyContact || defaultContactEmail("privacy", args.domain)).trim();
297-
const abuseContact = String(args.operatorAbuseContact || defaultContactEmail("abuse", args.domain)).trim();
298-
const securityContact = String(args.operatorSecurityContact || defaultContactEmail("security", args.domain)).trim();
297+
const operatorDomain = normalizeDomain(args.operatorDomain || "");
298+
const emailDomain = operatorDomain || args.domain;
299+
const contactEmail = String(args.operatorContactEmail || defaultContactEmail("hello", emailDomain)).trim();
300+
const privacyContact = String(args.operatorPrivacyContact || defaultContactEmail("privacy", emailDomain)).trim();
301+
const abuseContact = String(args.operatorAbuseContact || defaultContactEmail("abuse", emailDomain)).trim();
302+
const securityContact = String(args.operatorSecurityContact || defaultContactEmail("security", emailDomain)).trim();
299303

300304
return {
301305
legal_name: String(args.operatorLegalName || "").trim(),
302306
short_domain: normalizeDomain(args.operatorShortDomain || args.domain),
307+
operator_domain: operatorDomain,
303308
jurisdiction: String(args.operatorJurisdiction || "").trim(),
304309
governing_law: String(args.operatorGoverningLaw || args.operatorJurisdiction || "").trim(),
305310
contact_email: contactEmail,

0 commit comments

Comments
 (0)