Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/website-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@ jobs:
set -euo pipefail
node docs/website/scripts/test-build-first-funnel.mjs docs/website/public
node docs/website/scripts/test-cn1-crisp-events.mjs
node docs/website/scripts/test-acquisition.mjs
python3 -m unittest discover -s scripts/website -p test_social_operations.py
node docs/website/scripts/test-initializr-download-beacon.mjs
node docs/website/scripts/test-initializr-chat-clearance.mjs

Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Agent Instructions

## LinkedIn Page coordination

All Codename One Page publication, including ad hoc promotions, goes through the
existing social publishing chat and its private Page calendar. Do not publish
directly from another task or the legacy browser syndicator. Submit exact-copy
candidates for approval; a repository queue entry is not a calendar reservation
or a new approval. Read `scripts/website/SOCIAL_OPERATIONS.md` before proposing or
executing Page writes. Keep drafts, approvals and calendar data outside this repo.

## Scope
These instructions apply to the entire repository. They are aimed at helping Codex agents build and run the core unit tests that live under `maven/core-unittests`.

Expand Down
84 changes: 84 additions & 0 deletions docs/website/assets/js/cn1-acquisition.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
(() => {
// First tagged acquisition, not internal topic-interest or last-click tracking.
const KEY = "cn1-acquisition-v1";
const TTL = 24 * 60 * 60 * 1000;
const FIELDS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"];
const VALUE = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
const ENTRY_PATHS = new Set(["/console", "/console/", "/console/index.html",
"/secure", "/secure/", "/secure/index.html", "/secure/signup", "/login", "/register"]);
let touch = null;
const decorated = new Map();

function consent() {
const cookie = document.cookie.split(";").map(x => x.trim())
.find(x => x.startsWith("cn1_crisp_consent_v2="));
if (cookie) return cookie.slice(cookie.indexOf("=") + 1) === "accepted";
try { return localStorage.getItem("cn1-crisp-consent-v2") === "accepted"; }
catch (_) { return false; }
}

function valid(candidate) {
return candidate && Number.isFinite(candidate.at) && candidate.at <= Date.now() &&
Date.now() - candidate.at < TTL && candidate.tags &&
["social", "community", "oss", "email"].includes(candidate.tags.utm_medium) &&
VALUE.test(candidate.tags.utm_source || "") && VALUE.test(candidate.tags.utm_campaign || "") &&
Object.entries(candidate.tags).every(([k, v]) => FIELDS.includes(k) && VALUE.test(v));
}

function capture() {
if (!consent()) {
touch = null;
try { sessionStorage.removeItem(KEY); } catch (_) { /* storage unavailable */ }
return null;
}
if (valid(touch)) return touch;
try { touch = JSON.parse(sessionStorage.getItem(KEY)); } catch (_) { touch = null; }
if (valid(touch)) return touch;
touch = null;
const params = new URLSearchParams(window.location.search);
const tags = {};
for (const field of FIELDS) {
if (params.has(field)) tags[field] = params.get(field);
}
const candidate = { at: Date.now(), tags };
if (valid(candidate)) touch = candidate;
try {
if (touch) sessionStorage.setItem(KEY, JSON.stringify(touch));
else sessionStorage.removeItem(KEY);
} catch (_) { /* retain this page's touch when storage is unavailable */ }
return touch;
}

function decorate(link) {
const current = link.getAttribute("href");
const previous = decorated.get(link);
// Other scripts (for example pricing) may have changed the destination.
const original = previous && previous.result === current ? previous.original : current;
if (previous && previous.result === current) link.setAttribute("href", original);
decorated.delete(link);
const acquisition = capture();
if (!acquisition || !original) return;
let url;
try { url = new URL(original, window.location.href); } catch (_) { return; }
if (url.origin !== "https://cloud.codenameone.com" || !ENTRY_PATHS.has(url.pathname)) return;
// Replace the whole acquisition tuple, never mix a later CTA campaign with first touch.
FIELDS.forEach(field => url.searchParams.delete(field));
Object.entries(acquisition.tags).forEach(([key, value]) => url.searchParams.set(key, value));
const result = url.href;
link.setAttribute("href", result);
decorated.set(link, { original, result });
}

function refresh() {
capture();
document.querySelectorAll("a[href]").forEach(decorate);
}
refresh();
// Covers newly created CTAs, pricing switches, keyboard clicks and middle-clicks.
["click", "auxclick", "contextmenu"].forEach(type => document.addEventListener(type, event => {
const link = event.target.closest?.("a[href]");
if (link) decorate(link);
}, true));
window.addEventListener("cn1-consent-changed", refresh);
window.addEventListener("pageshow", refresh);
})();
2 changes: 2 additions & 0 deletions docs/website/assets/js/cn1-crisp.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@

const acceptConsent = () => {
setConsent("accepted");
window.dispatchEvent?.(new Event("cn1-consent-changed"));
closeBanner();
loadCrisp();
reportOssArrival();
Expand All @@ -307,6 +308,7 @@

const declineConsent = () => {
setConsent("declined");
window.dispatchEvent?.(new Event("cn1-consent-changed"));
clearPendingEvents();
closeBanner();
hideCrisp();
Expand Down
2 changes: 2 additions & 0 deletions docs/website/layouts/partials/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ <h3>Community</h3>
{{- $cn1Demo := resources.Get "js/cn1-demo.js" | minify | fingerprint }}
{{- $cn1Pricing := resources.Get "js/cn1-pricing.js" | minify | fingerprint }}
{{- $cn1Crisp := resources.Get "js/cn1-crisp.js" | minify | fingerprint }}
{{- $cn1Acquisition := resources.Get "js/cn1-acquisition.js" | minify | fingerprint }}
{{- $cn1PortStatus := resources.Get "js/cn1-port-status.js" | minify | fingerprint }}
<script defer src="{{ $cn1Site.RelPermalink }}" integrity="{{ $cn1Site.Data.Integrity }}"></script>
{{- if or (hasPrefix (.RelPermalink | default "") "/demo-") (eq .Layout "demos") }}
Expand All @@ -88,6 +89,7 @@ <h3>Community</h3>
<script defer src="{{ $cn1Pricing.RelPermalink }}" integrity="{{ $cn1Pricing.Data.Integrity }}"></script>
{{- end }}
<script defer src="{{ $cn1Crisp.RelPermalink }}" integrity="{{ $cn1Crisp.Data.Integrity }}"></script>
<script defer src="{{ $cn1Acquisition.RelPermalink }}" integrity="{{ $cn1Acquisition.Data.Integrity }}"></script>
{{- if eq .Layout "port-status" -}}
<script defer src="{{ $cn1PortStatus.RelPermalink }}" integrity="{{ $cn1PortStatus.Data.Integrity }}"></script>
{{- end -}}
Expand Down
65 changes: 65 additions & 0 deletions docs/website/scripts/test-acquisition.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../assets/js/cn1-acquisition.js', import.meta.url), 'utf8');
const KEY = 'cn1-acquisition-v1';
const values = new Map();
const store = { getItem: k => values.get(k) || null, setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) };
const tags = '?utm_source=bluesky&utm_medium=social&utm_campaign=social-2026-10&utm_content=2026-10-20-v2-b1';
const link = href => ({ href, getAttribute() { return this.href; }, setAttribute(_,v) { this.href=v; }, closest() { return this; } });
function load(search=tags, consent='accepted', storage=store) {
const links = [link('https://cloud.codenameone.com/console/'),
link('https://cloud.codenameone.com/secure/signup?level=pro&mode=annual#pay'),
link('https://cloud.codenameone.com/register'), link('https://evil.example/console/'),
link('/pricing/'), link('https://cloud.codenameone.com.evil.example/console/'),
link('https://cloud.codenameone.com/api/v2/build?utm_source=untouched')];
const events = {}, winEvents = {};
const document = { cookie: `cn1_crisp_consent_v2=${consent}`, querySelectorAll: () => links,
addEventListener: (k,v) => events[k]=v };
const window = { location: { search, href:'https://www.codenameone.com/blog/demo/'+search },
addEventListener: (k,v) => winEvents[k]=v };
vm.runInNewContext(source, {window,document,sessionStorage:storage,localStorage:store,URL,URLSearchParams,Date,Map,Set});
return {links,events,winEvents,document};
}
function content(l) { return new URL(l.href).searchParams.get('utm_content'); }
values.clear();
let s=load();
assert.equal(content(s.links[0]),'2026-10-20-v2-b1');
assert.equal(new URL(s.links[1].href).searchParams.get('level'),'pro');
assert.equal(new URL(s.links[1].href).hash,'#pay');
assert.equal(content(s.links[2]),'2026-10-20-v2-b1');
assert.equal(s.links[3].href,'https://evil.example/console/');
assert.equal(s.links[4].href,'/pricing/');
assert.equal(s.links[5].href,'https://cloud.codenameone.com.evil.example/console/');
assert.equal(s.links[6].href,'https://cloud.codenameone.com/api/v2/build?utm_source=untouched');
assert.equal(content(load('').links[0]),'2026-10-20-v2-b1','survives internal navigation');
assert.equal(content(load(tags.replace('bluesky','x').replace('-b1','-x1')).links[0]),'2026-10-20-v2-b1','first touch survives later campaign');
s.links[1].href='https://cloud.codenameone.com/secure/signup?level=pro&mode=monthly';
s.events.auxclick({target:s.links[1]});
assert.equal(new URL(s.links[1].href).searchParams.get('mode'),'monthly');
assert.equal(content(s.links[1]),'2026-10-20-v2-b1');
s.document.cookie='cn1_crisp_consent_v2=declined';
s.winEvents['cn1-consent-changed']();
assert.equal(content(s.links[0]),null,'withdrawal restores untagged links');
assert.equal(values.has(KEY),false);
for (const consent of ['declined','']) {
s=load(tags,consent);
assert.equal(content(s.links[0]),null);
assert.equal(values.has(KEY),false);
}
s=load(tags,'');
s.document.cookie='cn1_crisp_consent_v2=accepted';
s.winEvents['cn1-consent-changed']();
assert.equal(content(s.links[0]),'2026-10-20-v2-b1','consent acceptance on landing captures tags');
const old=JSON.parse(values.get(KEY));old.at=Date.now()-25*3600000;values.set(KEY,JSON.stringify(old));
assert.equal(content(load('').links[0]),null,'expired touch not forwarded');
values.clear();
assert.equal(content(load('?utm_source=x&utm_medium=social&utm_campaign=bad%20campaign').links[0]),null);
assert.equal(content(load('?utm_source=codenameone&utm_medium=website&utm_campaign=homepage').links[0]),null,'internal topic click not acquisition');
const blocked={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
assert.equal(content(load(tags,'accepted',blocked).links[0]),'2026-10-20-v2-b1','current-page fallback');
values.clear();
const mixed=load();mixed.links[0].href='https://cloud.codenameone.com/console/?utm_source=other&utm_term=other';
mixed.events.click({target:mixed.links[0]});
assert.equal(new URL(mixed.links[0].href).searchParams.get('utm_term'),null,'never mix attribution tuples');
console.log('Acquisition navigation, consent, expiry, dynamic links and origin-boundary checks passed');
97 changes: 97 additions & 0 deletions scripts/website/SOCIAL_OPERATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Social attribution and Page coordination

## A post to a first successful build

Use `social_campaigns.py link` when preparing a draft, before exact-link approval:

```sh
python3 scripts/website/social_campaigns.py link \
--url https://www.codenameone.com/playground/ --source bluesky \
--campaign social-2026-10 --item 2026-10-20-v2-b1
```

`utm_source` distinguishes the account/channel; `utm_campaign` groups the campaign;
`utm_content` is the unique immutable batch-version/item ID. Keep a private mapping
to the final platform permalink. Never put emails, handles of readers, or user IDs
in these parameters. Revising a reviewed link requires review of that version.
Already published/scheduled items are not silently retagged.

The website's `cn1-acquisition.js` preserves the first tagged acquisition for
24 hours in consent-gated session storage, across internal navigation. It forwards
the tuple only to allowlisted HTTPS BuildCloud entry links, including dynamic
pricing links, preserving the plan and fragment. Internal `utm_medium=website`
topic-interest links cannot replace acquisition. Declining analytics clears the
stored touch and restores decorated links. No consent, blocked storage after
navigation, another device/browser, a closed tab or an expired touch creates a
coverage gap, not evidence of an organic acquisition.

BuildCloud already captures console UTMs in the authentication session and claims
them once for a newly created account (password and federated auth). Its direct
`/login`, `/register` and `/secure/signup` paths must also capture the entry;
deploy the matching BuildCloud change before calling those journeys verified.
Existing-user logins are not new acquisitions. The existing
`/api/v2/console/admin/funnel-cohorts` aggregate groups by source, medium, campaign
and content and joins durable successful build evidence. No new user-level export
or tracking service is needed.

During the authorized overnight analytics session, save the existing read-only
admin aggregate response privately, with collection time and deployment versions.
Then run:

```sh
python3 scripts/website/social_campaigns.py report /private/path/funnel.json --window d30
```

The output retains only aggregate fields. `successful` counts new users in a
signup cohort who have achieved a successful build, even after a failed first
attempt; it is not `firstAttemptSucceeded`, build volume, or a causal result.
Rolling 30/90-day signup cohorts are not seven-day activation event windows.
Report unknown if diagnostics are missing, and flag disagreement with headline
activation rather than silently choosing a favorable count. Declined consent,
missing UTM identities, and historical untagged posts cannot be reconstructed.

## One LinkedIn Page publisher

The existing overnight social publishing workflow owns all Page writes, including
ad hoc promotions. Other tasks and repository social artifacts supply candidates;
they must not publish independently. The legacy local browser driver delegates
LinkedIn to this coordinator before opening a browser. Article syndication is
unchanged. Source-date-only repository tasks are not rolled to a new date or
silently approved by being present in the queue.

Private state lives under the social automation's `resources/` directory:
`linkedin-page-calendar.json` plus the existing approval/publication ledger. The
calendar is not approval evidence. Every action still requires the human approval
in the publishing chat. Scripts and backups may live there; no private state is
committed here.

Before any Page composer or schedule action, within the existing Firefox window:

1. Acquire the existing single-browser-worker lock. Verify the Page identity.
2. Read the actual live Page feed AND all native scheduled posts, other pending
private batches and repository syndication queue/state. Record every relevant
outside post/schedule with its actual public instant and permalink/ID. Resolve
unknown timestamps before proceeding. Previously scheduled items need
verification, never a second publication.
3. Update the calendar using `record`. Confirm cancellations remotely before
recording `cancelled`. A reservation left by a crash remains blocking until
reconciled. Record any collision even if a different producer caused it.
4. Run `audit` with the observation time, evidence file and the inspected time
range. It must cover at least 20 hours before AND after the target publication,
including future native schedules. Never invent a fresh audit from old state.
5. Run `reserve --id BATCH-ITEM --at OFFSET-TIMESTAMP --approval-ref CHAT-EVIDENCE`.
The tool atomically rejects another reservation within 20 hours, stale native
evidence, missed times and duplicate IDs. Planning `check` is not permission
to publish. The normal interval remains 24 hours.
6. Only after success perform the exact approved native action. Immediately
`record --status scheduled|published --url VERIFIED-URL-OR-ID --evidence FILE`
using that same ID/instant and update the publication ledger. If the outcome
is ambiguous, retain the reservation and inspect remotely before retrying.

All commands take `--calendar /private/path/linkedin-page-calendar.json` before
the subcommand. An absent or corrupt calendar fails closed. Do not invent an empty
replacement. The CLI never moves, cancels or publishes remote content. A collision
means defer the candidate for a new approved time, not reshuffle approved posts.
Manual writes outside the coordinator cannot be prevented by a local script; the
next native audit must reconcile them. No browser work is permitted during the
day merely to refresh this calendar.
67 changes: 67 additions & 0 deletions scripts/website/install_linkedin_coordinator_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Install the local legacy-runner LinkedIn handoff; never starts a browser.

The runner lives outside this repository. Keep a private backup and diff before
installing. Article platforms keep their existing behavior; queue/state are untouched.
"""
import argparse
import difflib
from pathlib import Path

MARKER = "LINKEDIN_COORDINATOR_HANDOFF_V1"
MESSAGE = "LinkedIn is owned by the overnight Firefox coordinator; submit this queue item for exact approval there."


def guarded(source):
if MARKER in source:
return source
anchors = [
(' site = task["site"]\n driver_fn = DRIVERS.get(site)',
f' # {MARKER}: never launch the legacy browser for LinkedIn.\n'
' site = task["site"]\n'
' if site == "linkedin":\n'
f' return False, None, {MESSAGE!r}\n'
' driver_fn = DRIVERS.get(site)'),
(' """Publish one due LinkedIn post as Shai or the Codename One page."""\n',
' """Legacy entry retained for callers; all LinkedIn writes are centralized."""\n'
f' raise RuntimeError({MESSAGE!r})\n'),
(' pending = [t for t in unfinished if is_due(t, now)]',
' delegated = [t for t in unfinished if t.get("site") == "linkedin"]\n'
' for t in delegated:\n'
f' print("Delegated " + t["id"] + ": " + {MESSAGE!r})\n'
' unfinished = [t for t in unfinished if t.get("site") != "linkedin"]\n'
' pending = [t for t in unfinished if is_due(t, now)]'),
]
for before, after in anchors:
if source.count(before) != 1:
raise ValueError("Runner changed: inspect before installing; no files modified")
source = source.replace(before, after)
compile(source, '<guarded-runner>', 'exec')
return source


def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('runner', type=Path)
p.add_argument('--backup-dir', type=Path, required=True)
p.add_argument('--apply', action='store_true')
a = p.parse_args()
before = a.runner.read_text()
after = guarded(before)
if before == after:
print('Coordinator handoff already installed')
return
diff = ''.join(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
fromfile=str(a.runner), tofile=str(a.runner)))
if not a.apply:
print(diff)
return
a.backup_dir.mkdir(parents=True, exist_ok=True)
backup = a.backup_dir / 'syndicate_browser.before-linkedin-handoff.py'
with backup.open('x') as f: f.write(before)
(a.backup_dir / 'linkedin-handoff.patch').write_text(diff)
a.runner.write_text(after)
print('Installed coordinator handoff; backup: ' + str(backup))


if __name__ == '__main__': main()
Loading
Loading