Skip to content

Commit af7218a

Browse files
author
DavidQ
committed
Add shareable URL-encoded session links with deterministic decode and validation - PR 11.218
1 parent 74d2430 commit af7218a

4 files changed

Lines changed: 362 additions & 17 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# PR_11_218 Report — V2 Shareable Links (Encoded Session)
2+
3+
## Encode/Decode Results
4+
Implemented share-link support in `workspace-v2`:
5+
6+
### Encode
7+
- Serializes current session payload.
8+
- Encodes payload to URL-safe Base64.
9+
- Produces URL with:
10+
- `?session=<encoded>`
11+
- UI action:
12+
- `Create Share Link`
13+
14+
### Decode
15+
- Detects `session` query param:
16+
- on page load (`decodeSessionParamFromUrl`)
17+
- from manual share URL input (`Apply Share Link`)
18+
- Decodes payload.
19+
- Validates decoded payload is an object.
20+
- Generates new `hostContextId`.
21+
- Writes to `sessionStorage[hostContextId]`.
22+
- Continues standard hostContext flow (`currentSessionPayload` + launch-ready state).
23+
24+
### Invalid Input Handling
25+
- Invalid/malformed share encoding surfaces explicit error:
26+
- `Share session decode failed: ...`
27+
- No silent ignore path for present-but-invalid `session` values.
28+
29+
## Validation Results
30+
Commands run:
31+
1. `node --check tests/runtime/V2ShareLinks.test.mjs`
32+
- Result: **PASS**
33+
2. `node tests/runtime/V2ShareLinks.test.mjs`
34+
- Result: **PASS**
35+
3. `node --check tools/workspace-v2/index.js`
36+
- Result: **PASS**
37+
38+
Runtime output:
39+
- `tmp/v2-share-links-results.json`
40+
- assertions passed:
41+
- payload integrity preserved after encode/decode
42+
- decoded payload written to sessionStorage
43+
- hostContextId assigned
44+
- no syntax errors
45+
46+
## Files Changed
47+
- `tools/workspace-v2/index.html`
48+
- `tools/workspace-v2/index.js`
49+
- `tests/runtime/V2ShareLinks.test.mjs`
50+
- `docs/dev/reports/PR_11_218_report.md`
51+
52+
## No Fallback Confirmation
53+
- No fallback/default/demo payload introduced.
54+
- No server dependency introduced.
55+
- Share decode failures do not auto-recover using hidden data.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { execFileSync } from "node:child_process";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const repoRoot = path.resolve(__dirname, "..", "..");
10+
const fixturePath = path.join(repoRoot, "tests", "fixtures", "v2-tools", "asset-browser-v2.json");
11+
const workspaceHtmlPath = path.join(repoRoot, "tools", "workspace-v2", "index.html");
12+
const workspaceJsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
13+
const resultsPath = path.join(repoRoot, "tmp", "v2-share-links-results.json");
14+
15+
class MemorySessionStorage {
16+
constructor() {
17+
this.values = new Map();
18+
}
19+
20+
setItem(key, value) {
21+
this.values.set(String(key), String(value));
22+
}
23+
24+
getItem(key) {
25+
if (!this.values.has(String(key))) {
26+
return null;
27+
}
28+
return this.values.get(String(key));
29+
}
30+
}
31+
32+
function readText(filePath) {
33+
return fs.readFileSync(filePath, "utf8");
34+
}
35+
36+
function readJson(filePath) {
37+
return JSON.parse(readText(filePath));
38+
}
39+
40+
function checkJsSyntax(jsPath) {
41+
try {
42+
execFileSync(process.execPath, ["--check", jsPath], {
43+
cwd: repoRoot,
44+
stdio: ["ignore", "pipe", "pipe"]
45+
});
46+
return { syntaxValid: true, syntaxError: "" };
47+
} catch (error) {
48+
return {
49+
syntaxValid: false,
50+
syntaxError: (error?.stderr || error?.stdout || error?.message || "").toString().trim()
51+
};
52+
}
53+
}
54+
55+
function generateHostContextId(toolId) {
56+
const randomPart = Math.random().toString(36).slice(2, 10);
57+
return `${toolId}-share-${Date.now()}-${randomPart}`;
58+
}
59+
60+
function encodePayload(payload) {
61+
const json = JSON.stringify(payload);
62+
const bytes = new TextEncoder().encode(json);
63+
let binary = "";
64+
for (let index = 0; index < bytes.length; index += 1) {
65+
binary += String.fromCharCode(bytes[index]);
66+
}
67+
return Buffer.from(binary, "binary").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
68+
}
69+
70+
function decodePayload(encodedPayload) {
71+
const normalized = encodedPayload.replace(/-/g, "+").replace(/_/g, "/");
72+
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
73+
const binary = Buffer.from(`${normalized}${padding}`, "base64").toString("binary");
74+
const bytes = new Uint8Array(binary.length);
75+
for (let index = 0; index < binary.length; index += 1) {
76+
bytes[index] = binary.charCodeAt(index);
77+
}
78+
const json = new TextDecoder().decode(bytes);
79+
return JSON.parse(json);
80+
}
81+
82+
export function run() {
83+
const failures = [];
84+
const fixtureExists = fs.existsSync(fixturePath);
85+
const workspaceHtmlExists = fs.existsSync(workspaceHtmlPath);
86+
const workspaceJsExists = fs.existsSync(workspaceJsPath);
87+
const workspaceHtml = workspaceHtmlExists ? readText(workspaceHtmlPath) : "";
88+
const workspaceJs = workspaceJsExists ? readText(workspaceJsPath) : "";
89+
const { syntaxValid, syntaxError } = checkJsSyntax(workspaceJsPath);
90+
91+
if (!fixtureExists) failures.push("Fixture file missing.");
92+
if (!workspaceHtmlExists) failures.push("workspace-v2/index.html missing.");
93+
if (!workspaceJsExists) failures.push("workspace-v2/index.js missing.");
94+
95+
let payload = null;
96+
if (fixtureExists) {
97+
try {
98+
const fixture = readJson(fixturePath);
99+
payload = fixture.sessionContext;
100+
} catch {
101+
failures.push("Fixture JSON failed to parse.");
102+
}
103+
}
104+
105+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
106+
failures.push("Fixture payload missing/invalid.");
107+
}
108+
109+
let encoded = "";
110+
let decoded = null;
111+
let sessionUrl = "";
112+
let assignedHostContextId = "";
113+
const storage = new MemorySessionStorage();
114+
115+
if (payload) {
116+
try {
117+
encoded = encodePayload(payload);
118+
sessionUrl = `https://example.local/tools/workspace-v2/index.html?session=${encoded}`;
119+
const parsedUrl = new URL(sessionUrl);
120+
decoded = decodePayload(parsedUrl.searchParams.get("session") || "");
121+
assignedHostContextId = generateHostContextId("asset-browser-v2");
122+
storage.setItem(assignedHostContextId, JSON.stringify(decoded));
123+
} catch (error) {
124+
failures.push(`Encode/decode simulation failed: ${error instanceof Error ? error.message : "unknown error"}`);
125+
}
126+
}
127+
128+
const storedSession = assignedHostContextId ? storage.getItem(assignedHostContextId) : null;
129+
const storedParsed = storedSession ? JSON.parse(storedSession) : null;
130+
const payloadIntegrityValid = Boolean(decoded && JSON.stringify(decoded) === JSON.stringify(payload));
131+
const sessionStorageWriteValid = Boolean(storedParsed && JSON.stringify(storedParsed) === JSON.stringify(payload));
132+
const hostContextAssigned = Boolean(assignedHostContextId && assignedHostContextId.trim());
133+
134+
if (!payloadIntegrityValid) failures.push("Decoded payload integrity mismatch.");
135+
if (!sessionStorageWriteValid) failures.push("Decoded payload was not written correctly to sessionStorage.");
136+
if (!hostContextAssigned) failures.push("No hostContextId was assigned during decode flow.");
137+
138+
if (!workspaceHtml.includes('id="workspaceV2ShareUrl"')) failures.push("Share URL field missing in workspace-v2 HTML.");
139+
if (!workspaceHtml.includes('id="workspaceV2CreateShareLinkButton"')) failures.push("Create share link button missing in workspace-v2 HTML.");
140+
if (!workspaceHtml.includes('id="workspaceV2ApplyShareLinkButton"')) failures.push("Apply share link button missing in workspace-v2 HTML.");
141+
if (!workspaceJs.includes("encodeSessionPayload(")) failures.push("workspace-v2 JS missing encodeSessionPayload.");
142+
if (!workspaceJs.includes("decodeSessionPayload(")) failures.push("workspace-v2 JS missing decodeSessionPayload.");
143+
if (!workspaceJs.includes("decodeSessionParamFromUrl()")) failures.push("workspace-v2 JS missing decodeSessionParamFromUrl.");
144+
if (!workspaceJs.includes("shareUrl.searchParams.set(\"session\", encoded);")) failures.push("workspace-v2 JS missing session query assignment.");
145+
if (!workspaceJs.includes("Share session decode failed")) failures.push("workspace-v2 JS missing explicit invalid share decode error.");
146+
if (!syntaxValid) failures.push("workspace-v2/index.js syntax check failed.");
147+
148+
const summary = {
149+
generatedAt: new Date().toISOString(),
150+
fixturePath: path.relative(repoRoot, fixturePath).replace(/\\/g, "/"),
151+
workspaceHtmlPath: path.relative(repoRoot, workspaceHtmlPath).replace(/\\/g, "/"),
152+
workspaceJsPath: path.relative(repoRoot, workspaceJsPath).replace(/\\/g, "/"),
153+
payloadIntegrityValid,
154+
sessionStorageWriteValid,
155+
hostContextAssigned,
156+
sessionUrl,
157+
hostContextId: assignedHostContextId,
158+
syntaxValid,
159+
syntaxError,
160+
failures
161+
};
162+
163+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
164+
fs.writeFileSync(resultsPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
165+
166+
console.log(`v2 share links results: ${resultsPath}`);
167+
assert.equal(failures.length, 0, `V2 share links failures: ${failures.join(" | ")}`);
168+
return summary;
169+
}
170+
171+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
172+
try {
173+
const summary = run();
174+
console.log(JSON.stringify(summary, null, 2));
175+
} catch (error) {
176+
console.error(error);
177+
process.exitCode = 1;
178+
}
179+
}

tools/workspace-v2/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ <h2>Import / Export Session JSON</h2>
4545
</div>
4646
</section>
4747

48+
<section class="hub-panel">
49+
<h2>Share Session Link</h2>
50+
<label for="workspaceV2ShareUrl">Share URL</label>
51+
<textarea id="workspaceV2ShareUrl" rows="4" spellcheck="false" placeholder="https://.../tools/workspace-v2/index.html?session=..."></textarea>
52+
<div>
53+
<button id="workspaceV2CreateShareLinkButton" type="button">Create Share Link</button>
54+
<button id="workspaceV2ApplyShareLinkButton" type="button">Apply Share Link</button>
55+
</div>
56+
</section>
57+
4858
<section class="hub-panel">
4959
<h2>Session Library</h2>
5060
<label for="workspaceV2SessionName">Session Name</label>

0 commit comments

Comments
 (0)