anthodev/lapin-futepublic Fork
⑂ 5ff79d1
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: persist and synchronize favorites (#14)Unverified

Cédric Anthony committed 2026-09-02T00:28:35+02:00 Browse files
5ff79d1 parent: f7d01bd
modified packages/companion/src/configuration.js +362 -33
@@ -1,10 +1,22 @@
11 "use strict";
22
33 var contracts = require("./contracts");
4-var STORAGE_KEY = "lapin-fute.configuration.v1";
5-var RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"];
4+var CONFIG_STORAGE_KEY = "lapinFuteConfig";
5+var LEGACY_CONFIG_STORAGE_KEY = "lapin-fute.configuration.v1";
6+var RESULTS_STORAGE_KEY = "lapinFuteResults";
7+var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus";
8+var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus"];
9+var LEGACY_CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"];
10+var RESULTS_RECORD_KEYS = ["schemaVersion", "results"];
11+var RESULT_ENTRY_KEYS = ["favoriteId", "storedAt", "result"];
12+var INVALID_KEY_STATUS_RECORD_KEYS = [
13+ "schemaVersion",
14+ "keyStatus",
15+ "configurationFingerprint"
16+];
617 var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"];
718 var MAX_CLOSE_RESPONSE_LENGTH = 32768;
19+var MAX_SAFE_INTEGER = 9007199254740991;
820 var FAVORITE_STRING_KEYS = [
921 "id",
1022 "serviceId",
@@ -18,8 +30,18 @@ var FAVORITE_STRING_KEYS = [
1830 ];
1931
2032 function emptyConfiguration() {
21- return { schemaVersion: contracts.SCHEMA_VERSION, favorites: [], apiKey: null };
33+ return {
34+ schemaVersion: contracts.SCHEMA_VERSION,
35+ favorites: [],
36+ primApiKey: null,
37+ keyStatus: contracts.KEY_STATUS.MISSING
38+ };
39+}
40+
41+function emptyResults() {
42+ return { schemaVersion: contracts.SCHEMA_VERSION, results: [] };
2243 }
44+
2345 function isFavoriteSecretFree(favorite, firstSecret, secondSecret) {
2446 var field;
2547 var value;
@@ -40,6 +62,45 @@ function areFavoritesSecretFree(favorites, firstSecret, secondSecret) {
4062 return true;
4163 }
4264
65+function fingerprintStep(hash, code) {
66+ hash ^= code & 255;
67+ hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
68+ hash ^= code >>> 8;
69+ return (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
70+}
71+
72+function fingerprintHex(value) {
73+ return ("00000000" + value.toString(16)).slice(-8);
74+}
75+
76+function configurationFingerprint(value) {
77+ var forward = 2166136261;
78+ var reverse = 3335557771;
79+ var index;
80+ var source;
81+ if (!isStoredConfiguration(value) || value.primApiKey === null) return null;
82+ source = JSON.stringify({
83+ schemaVersion: value.schemaVersion,
84+ favorites: value.favorites.map(contracts.copyFavorite),
85+ primApiKey: value.primApiKey
86+ });
87+ for (index = 0; index < source.length; index += 1) {
88+ forward = fingerprintStep(forward, source.charCodeAt(index));
89+ reverse = fingerprintStep(reverse, source.charCodeAt(source.length - index - 1));
90+ }
91+ source = null;
92+ return fingerprintHex(forward) + fingerprintHex(reverse);
93+}
94+
95+function invalidKeyStatusMatches(value, marker) {
96+ return contracts.isObject(marker)
97+ && contracts.hasOnlyKeys(marker, INVALID_KEY_STATUS_RECORD_KEYS)
98+ && marker.schemaVersion === contracts.SCHEMA_VERSION
99+ && marker.keyStatus === contracts.KEY_STATUS.INVALID
100+ && /^[0-9a-f]{16}$/.test(marker.configurationFingerprint)
101+ && marker.configurationFingerprint === configurationFingerprint(value);
102+}
103+
43104 function normalizeLanguage(value) {
44105 if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN;
45106 return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR
@@ -61,10 +122,23 @@ function activeWatchLanguage(Pebble) {
61122 }
62123 }
63124
64-
65125 function isStoredConfiguration(value) {
126+ if (!contracts.isObject(value)
127+ || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS)
128+ || value.schemaVersion !== contracts.SCHEMA_VERSION
129+ || !contracts.isFavoriteList(value.favorites)) return false;
130+ if (value.primApiKey === null) {
131+ return value.keyStatus === contracts.KEY_STATUS.MISSING;
132+ }
133+ return contracts.isPersonalApiKey(value.primApiKey)
134+ && (value.keyStatus === contracts.KEY_STATUS.CONFIGURED
135+ || value.keyStatus === contracts.KEY_STATUS.INVALID)
136+ && areFavoritesSecretFree(value.favorites, value.primApiKey, null);
137+}
138+
139+function isLegacyConfiguration(value) {
66140 return contracts.isObject(value)
67- && contracts.hasOnlyKeys(value, RECORD_KEYS)
141+ && contracts.hasOnlyKeys(value, LEGACY_CONFIG_RECORD_KEYS)
68142 && value.schemaVersion === contracts.SCHEMA_VERSION
69143 && contracts.isFavoriteList(value.favorites)
70144 && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey))
@@ -79,50 +153,292 @@ function isConfigurationUpdate(value) {
79153 && contracts.isApiKeyUpdate(value.apiKeyUpdate);
80154 }
81155
82-function copyConfiguration(configuration) {
156+function isStoredAt(value) {
157+ return typeof value === "number"
158+ && isFinite(value)
159+ && Math.floor(value) === value
160+ && value >= 0
161+ && value <= MAX_SAFE_INTEGER;
162+}
163+
164+function isResultEntry(value) {
165+ return contracts.isObject(value)
166+ && contracts.hasOnlyKeys(value, RESULT_ENTRY_KEYS)
167+ && contracts.boundedString(value.favoriteId, contracts.LIMITS.idUtf8Bytes)
168+ && isStoredAt(value.storedAt)
169+ && contracts.isDepartureResult(value.result)
170+ && value.favoriteId === value.result.favoriteId;
171+}
172+
173+function isStoredResults(value) {
174+ var seen = Object.create(null);
175+ if (!contracts.isObject(value)
176+ || !contracts.hasOnlyKeys(value, RESULTS_RECORD_KEYS)
177+ || value.schemaVersion !== contracts.SCHEMA_VERSION
178+ || !Array.isArray(value.results)
179+ || value.results.length > contracts.LIMITS.favorites) return false;
180+ return value.results.every(function (entry) {
181+ if (!isResultEntry(entry) || seen[entry.favoriteId]) return false;
182+ seen[entry.favoriteId] = true;
183+ return true;
184+ });
185+}
186+
187+function copyConfiguration(value) {
83188 return {
84189 schemaVersion: contracts.SCHEMA_VERSION,
85- favorites: configuration.favorites.map(contracts.copyFavorite),
86- apiKey: configuration.apiKey
190+ favorites: value.favorites.map(contracts.copyFavorite),
191+ primApiKey: value.primApiKey,
192+ keyStatus: value.keyStatus
87193 };
88194 }
89195
90-function loadConfiguration(storage) {
91- var parsed;
196+function copyResultEntry(entry, requestId) {
197+ return {
198+ favoriteId: entry.favoriteId,
199+ storedAt: entry.storedAt,
200+ result: contracts.copyDepartureResult(entry.result, requestId)
201+ };
202+}
203+
204+function copyResults(value) {
205+ return {
206+ schemaVersion: contracts.SCHEMA_VERSION,
207+ results: value.results.map(function (entry) { return copyResultEntry(entry); })
208+ };
209+}
210+
211+function verifiedWrite(storage, key, value) {
212+ var previous;
92213 var serialized;
214+ var writeAttempted = false;
215+ try {
216+ previous = storage.getItem(key);
217+ serialized = JSON.stringify(value);
218+ writeAttempted = true;
219+ storage.setItem(key, serialized);
220+ if (storage.getItem(key) === serialized) return true;
221+ } catch (ignored) {
222+ // Restore the prior bytes below when the adapter remains writable.
223+ }
224+ if (!writeAttempted) return false;
93225 try {
94- serialized = storage.getItem(STORAGE_KEY);
95- if (typeof serialized !== "string" || serialized.length === 0) return emptyConfiguration();
96- parsed = JSON.parse(serialized);
226+ if (previous === null || typeof previous === "undefined") storage.removeItem(key);
227+ else storage.setItem(key, previous);
97228 } catch (ignored) {
98- return emptyConfiguration();
229+ // The caller retains its prior in-memory state and fails closed.
99230 }
100- return isStoredConfiguration(parsed) ? copyConfiguration(parsed) : emptyConfiguration();
231+ return false;
101232 }
102233
103-function saveConfiguration(storage, configuration) {
104- if (!isStoredConfiguration(configuration)) return false;
234+function verifiedRemove(storage, key) {
235+ var previous;
236+ var removeAttempted = false;
105237 try {
106- storage.setItem(STORAGE_KEY, JSON.stringify(configuration));
107- return true;
238+ previous = storage.getItem(key);
239+ if (previous === null || typeof previous === "undefined") return true;
240+ removeAttempted = true;
241+ storage.removeItem(key);
242+ if (storage.getItem(key) === null) return true;
243+ } catch (ignored) {
244+ // Restore the prior bytes below when the adapter remains writable.
245+ }
246+ if (!removeAttempted) return false;
247+ try {
248+ storage.setItem(key, previous);
249+ } catch (ignored) {
250+ // A remaining marker is fail-closed; an uncertain removal is never trusted.
251+ }
252+ return false;
253+}
254+
255+function parseStored(storage, key) {
256+ var serialized;
257+ try {
258+ serialized = storage.getItem(key);
259+ } catch (ignored) {
260+ return { present: true, value: null };
261+ }
262+ if (serialized === null || typeof serialized === "undefined") {
263+ return { present: false, value: null };
264+ }
265+ if (typeof serialized !== "string" || serialized.length === 0) {
266+ return { present: true, value: null };
267+ }
268+ try {
269+ return { present: true, value: JSON.parse(serialized) };
270+ } catch (ignored) {
271+ return { present: true, value: null };
272+ }
273+}
274+
275+function removeStored(storage, key) {
276+ try {
277+ if (typeof storage.removeItem === "function") storage.removeItem(key);
108278 } catch (ignored) {
109- return false;
279+ // A later successful load retries legacy cleanup.
280+ }
281+}
282+
283+function loadConfiguration(storage) {
284+ var current = parseStored(storage, CONFIG_STORAGE_KEY);
285+ var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value;
286+ var legacy;
287+ var loaded;
288+ var migrated;
289+ if (current.present) {
290+ if (!isStoredConfiguration(current.value)) return null;
291+ removeStored(storage, LEGACY_CONFIG_STORAGE_KEY);
292+ loaded = copyConfiguration(current.value);
293+ if (invalidKeyStatusMatches(loaded, invalidMarker)) {
294+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
295+ }
296+ return loaded;
297+ }
298+ legacy = parseStored(storage, LEGACY_CONFIG_STORAGE_KEY);
299+ if (!legacy.present) return emptyConfiguration();
300+ if (!isLegacyConfiguration(legacy.value)) return null;
301+ migrated = {
302+ schemaVersion: contracts.SCHEMA_VERSION,
303+ favorites: legacy.value.favorites.map(contracts.copyFavorite),
304+ primApiKey: legacy.value.apiKey,
305+ keyStatus: legacy.value.apiKey === null
306+ ? contracts.KEY_STATUS.MISSING
307+ : contracts.KEY_STATUS.CONFIGURED
308+ };
309+ if (verifiedWrite(storage, CONFIG_STORAGE_KEY, migrated)) {
310+ removeStored(storage, LEGACY_CONFIG_STORAGE_KEY);
311+ }
312+ loaded = copyConfiguration(migrated);
313+ if (invalidKeyStatusMatches(loaded, invalidMarker)) {
314+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
110315 }
316+ return loaded;
317+}
318+
319+function saveConfiguration(storage, value) {
320+ return isStoredConfiguration(value)
321+ && verifiedWrite(storage, CONFIG_STORAGE_KEY, copyConfiguration(value));
322+}
323+
324+function saveInvalidConfiguration(storage, value) {
325+ var fingerprint;
326+ var markerSaved;
327+ var configurationSaved;
328+ if (!isStoredConfiguration(value)
329+ || value.primApiKey === null
330+ || value.keyStatus !== contracts.KEY_STATUS.INVALID) return false;
331+ fingerprint = configurationFingerprint(value);
332+ markerSaved = verifiedWrite(storage, INVALID_KEY_STATUS_STORAGE_KEY, {
333+ schemaVersion: contracts.SCHEMA_VERSION,
334+ keyStatus: contracts.KEY_STATUS.INVALID,
335+ configurationFingerprint: fingerprint
336+ });
337+ fingerprint = null;
338+ configurationSaved = saveConfiguration(storage, value);
339+ if (configurationSaved) verifiedRemove(storage, INVALID_KEY_STATUS_STORAGE_KEY);
340+ return markerSaved || configurationSaved;
341+}
342+
343+function clearInvalidKeyStatus(storage) {
344+ return verifiedRemove(storage, INVALID_KEY_STATUS_STORAGE_KEY);
345+}
346+
347+function pruneResults(value, favorites) {
348+ var byFavorite = Object.create(null);
349+ var normalized = emptyResults();
350+ if (!isStoredResults(value) || !contracts.isFavoriteList(favorites)) return normalized;
351+ value.results.forEach(function (entry) {
352+ byFavorite[entry.favoriteId] = entry;
353+ });
354+ favorites.forEach(function (favorite) {
355+ if (byFavorite[favorite.id]) normalized.results.push(copyResultEntry(byFavorite[favorite.id]));
356+ });
357+ return normalized;
358+}
359+
360+function loadResults(storage, favorites) {
361+ var stored = parseStored(storage, RESULTS_STORAGE_KEY);
362+ var normalized;
363+ if (!stored.present || !isStoredResults(stored.value)) return emptyResults();
364+ normalized = pruneResults(stored.value, favorites);
365+ if (JSON.stringify(normalized) !== JSON.stringify(stored.value)) {
366+ verifiedWrite(storage, RESULTS_STORAGE_KEY, normalized);
367+ }
368+ return normalized;
369+}
370+
371+function saveResults(storage, value) {
372+ return isStoredResults(value)
373+ && verifiedWrite(storage, RESULTS_STORAGE_KEY, copyResults(value));
374+}
375+
376+function putResult(value, favorites, result, storedAt) {
377+ var next;
378+ var found = false;
379+ var favoriteExists = false;
380+ if (!isStoredResults(value)
381+ || !contracts.isFavoriteList(favorites)
382+ || !contracts.isDepartureResult(result)
383+ || !isStoredAt(storedAt)) return null;
384+ favorites.forEach(function (favorite) {
385+ if (favorite.id === result.favoriteId) favoriteExists = true;
386+ });
387+ if (!favoriteExists) return null;
388+ next = pruneResults(value, favorites);
389+ next.results = next.results.map(function (entry) {
390+ if (entry.favoriteId !== result.favoriteId) return entry;
391+ found = true;
392+ return {
393+ favoriteId: result.favoriteId,
394+ storedAt: storedAt,
395+ result: contracts.copyDepartureResult(result)
396+ };
397+ });
398+ if (!found) {
399+ next.results.push({
400+ favoriteId: result.favoriteId,
401+ storedAt: storedAt,
402+ result: contracts.copyDepartureResult(result)
403+ });
404+ next = pruneResults(next, favorites);
405+ }
406+ return next;
407+}
408+
409+function findResult(value, favoriteId, requestId) {
410+ var index;
411+ if (!isStoredResults(value)) return null;
412+ for (index = 0; index < value.results.length; index += 1) {
413+ if (value.results[index].favoriteId === favoriteId) {
414+ return copyResultEntry(value.results[index], requestId);
415+ }
416+ }
417+ return null;
111418 }
112419
113420 function applyConfigurationUpdate(current, update) {
114- var apiKey;
421+ var primApiKey;
422+ var keyStatus;
115423 var replacementKey = null;
116424 if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null;
117425 if (update.apiKeyUpdate.action === "REPLACE") replacementKey = update.apiKeyUpdate.value;
118- if (!areFavoritesSecretFree(update.favorites, current.apiKey, replacementKey)) return null;
119- apiKey = current.apiKey;
120- if (replacementKey !== null) apiKey = replacementKey;
121- if (update.apiKeyUpdate.action === "REMOVE") apiKey = null;
426+ if (!areFavoritesSecretFree(update.favorites, current.primApiKey, replacementKey)) return null;
427+ primApiKey = current.primApiKey;
428+ keyStatus = current.keyStatus;
429+ if (replacementKey !== null) {
430+ primApiKey = replacementKey;
431+ keyStatus = contracts.KEY_STATUS.CONFIGURED;
432+ }
433+ if (update.apiKeyUpdate.action === "REMOVE") {
434+ primApiKey = null;
435+ keyStatus = contracts.KEY_STATUS.MISSING;
436+ }
122437 return {
123438 schemaVersion: contracts.SCHEMA_VERSION,
124439 favorites: update.favorites.map(contracts.copyFavorite),
125- apiKey: apiKey
440+ primApiKey: primApiKey,
441+ keyStatus: keyStatus
126442 };
127443 }
128444
@@ -154,33 +470,46 @@ function parseCloseFragment(response) {
154470 };
155471 }
156472
157-function configurationPageState(configuration, language) {
473+function configurationPageState(value, language) {
158474 return {
159- hasKey: configuration.apiKey !== null,
160- favorites: configuration.favorites.map(contracts.copyFavorite),
475+ hasKey: value.primApiKey !== null,
476+ favorites: value.favorites.map(contracts.copyFavorite),
161477 language: typeof language === "string" && language.length > 0 ? language : "en"
162478 };
163479 }
164480
165-function configurationUrl(baseUrl, configuration, language) {
481+function configurationUrl(baseUrl, value, language) {
166482 var withoutFragment;
167- if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(configuration)) return null;
483+ if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(value)) return null;
168484 withoutFragment = baseUrl.split("#")[0];
169- return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(configuration, language)));
485+ return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(value, language)));
170486 }
171487
172488 module.exports = {
173- STORAGE_KEY: STORAGE_KEY,
489+ CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY,
490+ LEGACY_CONFIG_STORAGE_KEY: LEGACY_CONFIG_STORAGE_KEY,
491+ RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY,
492+ INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY,
174493 MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH,
175494 emptyConfiguration: emptyConfiguration,
495+ emptyResults: emptyResults,
176496 isStoredConfiguration: isStoredConfiguration,
497+ isLegacyConfiguration: isLegacyConfiguration,
177498 isConfigurationUpdate: isConfigurationUpdate,
499+ isStoredResults: isStoredResults,
178500 isFavoriteSecretFree: isFavoriteSecretFree,
179501 areFavoritesSecretFree: areFavoritesSecretFree,
180502 normalizeLanguage: normalizeLanguage,
181503 activeWatchLanguage: activeWatchLanguage,
182504 loadConfiguration: loadConfiguration,
183505 saveConfiguration: saveConfiguration,
506+ saveInvalidConfiguration: saveInvalidConfiguration,
507+ clearInvalidKeyStatus: clearInvalidKeyStatus,
508+ loadResults: loadResults,
509+ saveResults: saveResults,
510+ pruneResults: pruneResults,
511+ putResult: putResult,
512+ findResult: findResult,
184513 applyConfigurationUpdate: applyConfigurationUpdate,
185514 parseCloseFragment: parseCloseFragment,
186515 configurationPageState: configurationPageState,
@@ -1,10 +1,22 @@
1 "use strict";1 "use strict";
2 2
3 var contracts = require("./contracts");3 var contracts = require("./contracts");
4-var STORAGE_KEY = "lapin-fute.configuration.v1";4+var CONFIG_STORAGE_KEY = "lapinFuteConfig";
5-var RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"];5+var LEGACY_CONFIG_STORAGE_KEY = "lapin-fute.configuration.v1";
6+var RESULTS_STORAGE_KEY = "lapinFuteResults";
7+var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus";
8+var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus"];
9+var LEGACY_CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"];
10+var RESULTS_RECORD_KEYS = ["schemaVersion", "results"];
11+var RESULT_ENTRY_KEYS = ["favoriteId", "storedAt", "result"];
12+var INVALID_KEY_STATUS_RECORD_KEYS = [
13+ "schemaVersion",
14+ "keyStatus",
15+ "configurationFingerprint"
16+];
6 var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"];17 var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"];
7 var MAX_CLOSE_RESPONSE_LENGTH = 32768;18 var MAX_CLOSE_RESPONSE_LENGTH = 32768;
19+var MAX_SAFE_INTEGER = 9007199254740991;
8 var FAVORITE_STRING_KEYS = [20 var FAVORITE_STRING_KEYS = [
9 "id",21 "id",
10 "serviceId",22 "serviceId",
@@ -18,8 +30,18 @@ var FAVORITE_STRING_KEYS = [
18 ];30 ];
19 31
20 function emptyConfiguration() {32 function emptyConfiguration() {
21- return { schemaVersion: contracts.SCHEMA_VERSION, favorites: [], apiKey: null };33+ return {
34+ schemaVersion: contracts.SCHEMA_VERSION,
35+ favorites: [],
36+ primApiKey: null,
37+ keyStatus: contracts.KEY_STATUS.MISSING
38+ };
39+}
40+
41+function emptyResults() {
42+ return { schemaVersion: contracts.SCHEMA_VERSION, results: [] };
22 }43 }
44+
23 function isFavoriteSecretFree(favorite, firstSecret, secondSecret) {45 function isFavoriteSecretFree(favorite, firstSecret, secondSecret) {
24 var field;46 var field;
25 var value;47 var value;
@@ -40,6 +62,45 @@ function areFavoritesSecretFree(favorites, firstSecret, secondSecret) {
40 return true;62 return true;
41 }63 }
42 64
65+function fingerprintStep(hash, code) {
66+ hash ^= code & 255;
67+ hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
68+ hash ^= code >>> 8;
69+ return (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
70+}
71+
72+function fingerprintHex(value) {
73+ return ("00000000" + value.toString(16)).slice(-8);
74+}
75+
76+function configurationFingerprint(value) {
77+ var forward = 2166136261;
78+ var reverse = 3335557771;
79+ var index;
80+ var source;
81+ if (!isStoredConfiguration(value) || value.primApiKey === null) return null;
82+ source = JSON.stringify({
83+ schemaVersion: value.schemaVersion,
84+ favorites: value.favorites.map(contracts.copyFavorite),
85+ primApiKey: value.primApiKey
86+ });
87+ for (index = 0; index < source.length; index += 1) {
88+ forward = fingerprintStep(forward, source.charCodeAt(index));
89+ reverse = fingerprintStep(reverse, source.charCodeAt(source.length - index - 1));
90+ }
91+ source = null;
92+ return fingerprintHex(forward) + fingerprintHex(reverse);
93+}
94+
95+function invalidKeyStatusMatches(value, marker) {
96+ return contracts.isObject(marker)
97+ && contracts.hasOnlyKeys(marker, INVALID_KEY_STATUS_RECORD_KEYS)
98+ && marker.schemaVersion === contracts.SCHEMA_VERSION
99+ && marker.keyStatus === contracts.KEY_STATUS.INVALID
100+ && /^[0-9a-f]{16}$/.test(marker.configurationFingerprint)
101+ && marker.configurationFingerprint === configurationFingerprint(value);
102+}
103+
43 function normalizeLanguage(value) {104 function normalizeLanguage(value) {
44 if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN;105 if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN;
45 return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR106 return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR
@@ -61,10 +122,23 @@ function activeWatchLanguage(Pebble) {
61 }122 }
62 }123 }
63 124
64-
65 function isStoredConfiguration(value) {125 function isStoredConfiguration(value) {
126+ if (!contracts.isObject(value)
127+ || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS)
128+ || value.schemaVersion !== contracts.SCHEMA_VERSION
129+ || !contracts.isFavoriteList(value.favorites)) return false;
130+ if (value.primApiKey === null) {
131+ return value.keyStatus === contracts.KEY_STATUS.MISSING;
132+ }
133+ return contracts.isPersonalApiKey(value.primApiKey)
134+ && (value.keyStatus === contracts.KEY_STATUS.CONFIGURED
135+ || value.keyStatus === contracts.KEY_STATUS.INVALID)
136+ && areFavoritesSecretFree(value.favorites, value.primApiKey, null);
137+}
138+
139+function isLegacyConfiguration(value) {
66 return contracts.isObject(value)140 return contracts.isObject(value)
67- && contracts.hasOnlyKeys(value, RECORD_KEYS)141+ && contracts.hasOnlyKeys(value, LEGACY_CONFIG_RECORD_KEYS)
68 && value.schemaVersion === contracts.SCHEMA_VERSION142 && value.schemaVersion === contracts.SCHEMA_VERSION
69 && contracts.isFavoriteList(value.favorites)143 && contracts.isFavoriteList(value.favorites)
70 && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey))144 && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey))
@@ -79,50 +153,292 @@ function isConfigurationUpdate(value) {
79 && contracts.isApiKeyUpdate(value.apiKeyUpdate);153 && contracts.isApiKeyUpdate(value.apiKeyUpdate);
80 }154 }
81 155
82-function copyConfiguration(configuration) {156+function isStoredAt(value) {
157+ return typeof value === "number"
158+ && isFinite(value)
159+ && Math.floor(value) === value
160+ && value >= 0
161+ && value <= MAX_SAFE_INTEGER;
162+}
163+
164+function isResultEntry(value) {
165+ return contracts.isObject(value)
166+ && contracts.hasOnlyKeys(value, RESULT_ENTRY_KEYS)
167+ && contracts.boundedString(value.favoriteId, contracts.LIMITS.idUtf8Bytes)
168+ && isStoredAt(value.storedAt)
169+ && contracts.isDepartureResult(value.result)
170+ && value.favoriteId === value.result.favoriteId;
171+}
172+
173+function isStoredResults(value) {
174+ var seen = Object.create(null);
175+ if (!contracts.isObject(value)
176+ || !contracts.hasOnlyKeys(value, RESULTS_RECORD_KEYS)
177+ || value.schemaVersion !== contracts.SCHEMA_VERSION
178+ || !Array.isArray(value.results)
179+ || value.results.length > contracts.LIMITS.favorites) return false;
180+ return value.results.every(function (entry) {
181+ if (!isResultEntry(entry) || seen[entry.favoriteId]) return false;
182+ seen[entry.favoriteId] = true;
183+ return true;
184+ });
185+}
186+
187+function copyConfiguration(value) {
83 return {188 return {
84 schemaVersion: contracts.SCHEMA_VERSION,189 schemaVersion: contracts.SCHEMA_VERSION,
85- favorites: configuration.favorites.map(contracts.copyFavorite),190+ favorites: value.favorites.map(contracts.copyFavorite),
86- apiKey: configuration.apiKey191+ primApiKey: value.primApiKey,
192+ keyStatus: value.keyStatus
87 };193 };
88 }194 }
89 195
90-function loadConfiguration(storage) {196+function copyResultEntry(entry, requestId) {
91- var parsed;197+ return {
198+ favoriteId: entry.favoriteId,
199+ storedAt: entry.storedAt,
200+ result: contracts.copyDepartureResult(entry.result, requestId)
201+ };
202+}
203+
204+function copyResults(value) {
205+ return {
206+ schemaVersion: contracts.SCHEMA_VERSION,
207+ results: value.results.map(function (entry) { return copyResultEntry(entry); })
208+ };
209+}
210+
211+function verifiedWrite(storage, key, value) {
212+ var previous;
92 var serialized;213 var serialized;
214+ var writeAttempted = false;
215+ try {
216+ previous = storage.getItem(key);
217+ serialized = JSON.stringify(value);
218+ writeAttempted = true;
219+ storage.setItem(key, serialized);
220+ if (storage.getItem(key) === serialized) return true;
221+ } catch (ignored) {
222+ // Restore the prior bytes below when the adapter remains writable.
223+ }
224+ if (!writeAttempted) return false;
93 try {225 try {
94- serialized = storage.getItem(STORAGE_KEY);226+ if (previous === null || typeof previous === "undefined") storage.removeItem(key);
95- if (typeof serialized !== "string" || serialized.length === 0) return emptyConfiguration();227+ else storage.setItem(key, previous);
96- parsed = JSON.parse(serialized);
97 } catch (ignored) {228 } catch (ignored) {
98- return emptyConfiguration();229+ // The caller retains its prior in-memory state and fails closed.
99 }230 }
100- return isStoredConfiguration(parsed) ? copyConfiguration(parsed) : emptyConfiguration();231+ return false;
101 }232 }
102 233
103-function saveConfiguration(storage, configuration) {234+function verifiedRemove(storage, key) {
104- if (!isStoredConfiguration(configuration)) return false;235+ var previous;
236+ var removeAttempted = false;
105 try {237 try {
106- storage.setItem(STORAGE_KEY, JSON.stringify(configuration));238+ previous = storage.getItem(key);
107- return true;239+ if (previous === null || typeof previous === "undefined") return true;
240+ removeAttempted = true;
241+ storage.removeItem(key);
242+ if (storage.getItem(key) === null) return true;
243+ } catch (ignored) {
244+ // Restore the prior bytes below when the adapter remains writable.
245+ }
246+ if (!removeAttempted) return false;
247+ try {
248+ storage.setItem(key, previous);
249+ } catch (ignored) {
250+ // A remaining marker is fail-closed; an uncertain removal is never trusted.
251+ }
252+ return false;
253+}
254+
255+function parseStored(storage, key) {
256+ var serialized;
257+ try {
258+ serialized = storage.getItem(key);
259+ } catch (ignored) {
260+ return { present: true, value: null };
261+ }
262+ if (serialized === null || typeof serialized === "undefined") {
263+ return { present: false, value: null };
264+ }
265+ if (typeof serialized !== "string" || serialized.length === 0) {
266+ return { present: true, value: null };
267+ }
268+ try {
269+ return { present: true, value: JSON.parse(serialized) };
270+ } catch (ignored) {
271+ return { present: true, value: null };
272+ }
273+}
274+
275+function removeStored(storage, key) {
276+ try {
277+ if (typeof storage.removeItem === "function") storage.removeItem(key);
108 } catch (ignored) {278 } catch (ignored) {
109- return false;279+ // A later successful load retries legacy cleanup.
280+ }
281+}
282+
283+function loadConfiguration(storage) {
284+ var current = parseStored(storage, CONFIG_STORAGE_KEY);
285+ var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value;
286+ var legacy;
287+ var loaded;
288+ var migrated;
289+ if (current.present) {
290+ if (!isStoredConfiguration(current.value)) return null;
291+ removeStored(storage, LEGACY_CONFIG_STORAGE_KEY);
292+ loaded = copyConfiguration(current.value);
293+ if (invalidKeyStatusMatches(loaded, invalidMarker)) {
294+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
295+ }
296+ return loaded;
297+ }
298+ legacy = parseStored(storage, LEGACY_CONFIG_STORAGE_KEY);
299+ if (!legacy.present) return emptyConfiguration();
300+ if (!isLegacyConfiguration(legacy.value)) return null;
301+ migrated = {
302+ schemaVersion: contracts.SCHEMA_VERSION,
303+ favorites: legacy.value.favorites.map(contracts.copyFavorite),
304+ primApiKey: legacy.value.apiKey,
305+ keyStatus: legacy.value.apiKey === null
306+ ? contracts.KEY_STATUS.MISSING
307+ : contracts.KEY_STATUS.CONFIGURED
308+ };
309+ if (verifiedWrite(storage, CONFIG_STORAGE_KEY, migrated)) {
310+ removeStored(storage, LEGACY_CONFIG_STORAGE_KEY);
311+ }
312+ loaded = copyConfiguration(migrated);
313+ if (invalidKeyStatusMatches(loaded, invalidMarker)) {
314+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
110 }315 }
316+ return loaded;
317+}
318+
319+function saveConfiguration(storage, value) {
320+ return isStoredConfiguration(value)
321+ && verifiedWrite(storage, CONFIG_STORAGE_KEY, copyConfiguration(value));
322+}
323+
324+function saveInvalidConfiguration(storage, value) {
325+ var fingerprint;
326+ var markerSaved;
327+ var configurationSaved;
328+ if (!isStoredConfiguration(value)
329+ || value.primApiKey === null
330+ || value.keyStatus !== contracts.KEY_STATUS.INVALID) return false;
331+ fingerprint = configurationFingerprint(value);
332+ markerSaved = verifiedWrite(storage, INVALID_KEY_STATUS_STORAGE_KEY, {
333+ schemaVersion: contracts.SCHEMA_VERSION,
334+ keyStatus: contracts.KEY_STATUS.INVALID,
335+ configurationFingerprint: fingerprint
336+ });
337+ fingerprint = null;
338+ configurationSaved = saveConfiguration(storage, value);
339+ if (configurationSaved) verifiedRemove(storage, INVALID_KEY_STATUS_STORAGE_KEY);
340+ return markerSaved || configurationSaved;
341+}
342+
343+function clearInvalidKeyStatus(storage) {
344+ return verifiedRemove(storage, INVALID_KEY_STATUS_STORAGE_KEY);
345+}
346+
347+function pruneResults(value, favorites) {
348+ var byFavorite = Object.create(null);
349+ var normalized = emptyResults();
350+ if (!isStoredResults(value) || !contracts.isFavoriteList(favorites)) return normalized;
351+ value.results.forEach(function (entry) {
352+ byFavorite[entry.favoriteId] = entry;
353+ });
354+ favorites.forEach(function (favorite) {
355+ if (byFavorite[favorite.id]) normalized.results.push(copyResultEntry(byFavorite[favorite.id]));
356+ });
357+ return normalized;
358+}
359+
360+function loadResults(storage, favorites) {
361+ var stored = parseStored(storage, RESULTS_STORAGE_KEY);
362+ var normalized;
363+ if (!stored.present || !isStoredResults(stored.value)) return emptyResults();
364+ normalized = pruneResults(stored.value, favorites);
365+ if (JSON.stringify(normalized) !== JSON.stringify(stored.value)) {
366+ verifiedWrite(storage, RESULTS_STORAGE_KEY, normalized);
367+ }
368+ return normalized;
369+}
370+
371+function saveResults(storage, value) {
372+ return isStoredResults(value)
373+ && verifiedWrite(storage, RESULTS_STORAGE_KEY, copyResults(value));
374+}
375+
376+function putResult(value, favorites, result, storedAt) {
377+ var next;
378+ var found = false;
379+ var favoriteExists = false;
380+ if (!isStoredResults(value)
381+ || !contracts.isFavoriteList(favorites)
382+ || !contracts.isDepartureResult(result)
383+ || !isStoredAt(storedAt)) return null;
384+ favorites.forEach(function (favorite) {
385+ if (favorite.id === result.favoriteId) favoriteExists = true;
386+ });
387+ if (!favoriteExists) return null;
388+ next = pruneResults(value, favorites);
389+ next.results = next.results.map(function (entry) {
390+ if (entry.favoriteId !== result.favoriteId) return entry;
391+ found = true;
392+ return {
393+ favoriteId: result.favoriteId,
394+ storedAt: storedAt,
395+ result: contracts.copyDepartureResult(result)
396+ };
397+ });
398+ if (!found) {
399+ next.results.push({
400+ favoriteId: result.favoriteId,
401+ storedAt: storedAt,
402+ result: contracts.copyDepartureResult(result)
403+ });
404+ next = pruneResults(next, favorites);
405+ }
406+ return next;
407+}
408+
409+function findResult(value, favoriteId, requestId) {
410+ var index;
411+ if (!isStoredResults(value)) return null;
412+ for (index = 0; index < value.results.length; index += 1) {
413+ if (value.results[index].favoriteId === favoriteId) {
414+ return copyResultEntry(value.results[index], requestId);
415+ }
416+ }
417+ return null;
111 }418 }
112 419
113 function applyConfigurationUpdate(current, update) {420 function applyConfigurationUpdate(current, update) {
114- var apiKey;421+ var primApiKey;
422+ var keyStatus;
115 var replacementKey = null;423 var replacementKey = null;
116 if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null;424 if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null;
117 if (update.apiKeyUpdate.action === "REPLACE") replacementKey = update.apiKeyUpdate.value;425 if (update.apiKeyUpdate.action === "REPLACE") replacementKey = update.apiKeyUpdate.value;
118- if (!areFavoritesSecretFree(update.favorites, current.apiKey, replacementKey)) return null;426+ if (!areFavoritesSecretFree(update.favorites, current.primApiKey, replacementKey)) return null;
119- apiKey = current.apiKey;427+ primApiKey = current.primApiKey;
120- if (replacementKey !== null) apiKey = replacementKey;428+ keyStatus = current.keyStatus;
121- if (update.apiKeyUpdate.action === "REMOVE") apiKey = null;429+ if (replacementKey !== null) {
430+ primApiKey = replacementKey;
431+ keyStatus = contracts.KEY_STATUS.CONFIGURED;
432+ }
433+ if (update.apiKeyUpdate.action === "REMOVE") {
434+ primApiKey = null;
435+ keyStatus = contracts.KEY_STATUS.MISSING;
436+ }
122 return {437 return {
123 schemaVersion: contracts.SCHEMA_VERSION,438 schemaVersion: contracts.SCHEMA_VERSION,
124 favorites: update.favorites.map(contracts.copyFavorite),439 favorites: update.favorites.map(contracts.copyFavorite),
125- apiKey: apiKey440+ primApiKey: primApiKey,
441+ keyStatus: keyStatus
126 };442 };
127 }443 }
128 444
@@ -154,33 +470,46 @@ function parseCloseFragment(response) {
154 };470 };
155 }471 }
156 472
157-function configurationPageState(configuration, language) {473+function configurationPageState(value, language) {
158 return {474 return {
159- hasKey: configuration.apiKey !== null,475+ hasKey: value.primApiKey !== null,
160- favorites: configuration.favorites.map(contracts.copyFavorite),476+ favorites: value.favorites.map(contracts.copyFavorite),
161 language: typeof language === "string" && language.length > 0 ? language : "en"477 language: typeof language === "string" && language.length > 0 ? language : "en"
162 };478 };
163 }479 }
164 480
165-function configurationUrl(baseUrl, configuration, language) {481+function configurationUrl(baseUrl, value, language) {
166 var withoutFragment;482 var withoutFragment;
167- if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(configuration)) return null;483+ if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(value)) return null;
168 withoutFragment = baseUrl.split("#")[0];484 withoutFragment = baseUrl.split("#")[0];
169- return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(configuration, language)));485+ return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(value, language)));
170 }486 }
171 487
172 module.exports = {488 module.exports = {
173- STORAGE_KEY: STORAGE_KEY,489+ CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY,
490+ LEGACY_CONFIG_STORAGE_KEY: LEGACY_CONFIG_STORAGE_KEY,
491+ RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY,
492+ INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY,
174 MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH,493 MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH,
175 emptyConfiguration: emptyConfiguration,494 emptyConfiguration: emptyConfiguration,
495+ emptyResults: emptyResults,
176 isStoredConfiguration: isStoredConfiguration,496 isStoredConfiguration: isStoredConfiguration,
497+ isLegacyConfiguration: isLegacyConfiguration,
177 isConfigurationUpdate: isConfigurationUpdate,498 isConfigurationUpdate: isConfigurationUpdate,
499+ isStoredResults: isStoredResults,
178 isFavoriteSecretFree: isFavoriteSecretFree,500 isFavoriteSecretFree: isFavoriteSecretFree,
179 areFavoritesSecretFree: areFavoritesSecretFree,501 areFavoritesSecretFree: areFavoritesSecretFree,
180 normalizeLanguage: normalizeLanguage,502 normalizeLanguage: normalizeLanguage,
181 activeWatchLanguage: activeWatchLanguage,503 activeWatchLanguage: activeWatchLanguage,
182 loadConfiguration: loadConfiguration,504 loadConfiguration: loadConfiguration,
183 saveConfiguration: saveConfiguration,505 saveConfiguration: saveConfiguration,
506+ saveInvalidConfiguration: saveInvalidConfiguration,
507+ clearInvalidKeyStatus: clearInvalidKeyStatus,
508+ loadResults: loadResults,
509+ saveResults: saveResults,
510+ pruneResults: pruneResults,
511+ putResult: putResult,
512+ findResult: findResult,
184 applyConfigurationUpdate: applyConfigurationUpdate,513 applyConfigurationUpdate: applyConfigurationUpdate,
185 parseCloseFragment: parseCloseFragment,514 parseCloseFragment: parseCloseFragment,
186 configurationPageState: configurationPageState,515 configurationPageState: configurationPageState,
modified packages/companion/src/contracts.js +1 -1
@@ -170,7 +170,7 @@ function boundedString(value, maximum) {
170170 var size;
171171 if (typeof value !== "string") return false;
172172 size = utf8Bytes(value);
173- return size >= 1 && size <= maximum;
173+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
174174 }
175175
176176 function isLineColor(value) {
@@ -170,7 +170,7 @@ function boundedString(value, maximum) {
170 var size;170 var size;
171 if (typeof value !== "string") return false;171 if (typeof value !== "string") return false;
172 size = utf8Bytes(value);172 size = utf8Bytes(value);
173- return size >= 1 && size <= maximum;173+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
174 }174 }
175 175
176 function isLineColor(value) {176 function isLineColor(value) {
modified packages/companion/src/index.js +170 -45
@@ -50,7 +50,10 @@ function isFixture(value) {
5050 function Companion(options) {
5151 options = options || {};
5252 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
53- requiredAdapter(options.storage && typeof options.storage.getItem === "function" && typeof options.storage.setItem === "function", "storage adapter is required");
53+ requiredAdapter(options.storage
54+ && typeof options.storage.getItem === "function"
55+ && typeof options.storage.setItem === "function"
56+ && typeof options.storage.removeItem === "function", "storage adapter is required");
5457 requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");
5558 requiredAdapter(options.clock
5659 && typeof options.clock.now === "function", "clock adapter is required");
@@ -68,9 +71,21 @@ function Companion(options) {
6871 this._fixture = options.fixture || null;
6972 this._fixtureEnabled = false;
7073 this._configuration = configuration.loadConfiguration(this._storage);
74+ this._configurationValid = this._configuration !== null;
75+ this._credentialBlocked = this._configurationValid
76+ && this._configuration.keyStatus === contracts.KEY_STATUS.INVALID;
77+ if (!this._configurationValid) this._configuration = configuration.emptyConfiguration();
7178 this._watchFavorites = [];
72- this._keyStatus = contracts.KEY_STATUS.MISSING;
73- this._cache = Object.create(null);
79+ this._results = configuration.emptyResults();
80+ this._metrics = {
81+ requests: 0,
82+ cacheHits: 0,
83+ cacheMisses: 0,
84+ successes: 0,
85+ failures: 0,
86+ totalLatencyMs: 0,
87+ lastLatencyMs: 0
88+ };
7489 this._requestGenerations = Object.create(null);
7590 this._inFlightRequests = Object.create(null);
7691 this._requestGeneration = 0;
@@ -126,9 +141,6 @@ Companion.prototype._refreshWatchState = function () {
126141 var configured = this._configuration.favorites.map(contracts.copyFavorite);
127142 this._fixtureEnabled = this._fixture !== null && configured.length === 0;
128143 this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
129- this._keyStatus = this._fixtureEnabled || this._configuration.apiKey !== null
130- ? contracts.KEY_STATUS.CONFIGURED
131- : contracts.KEY_STATUS.MISSING;
132144 };
133145
134146 Companion.prototype._nextSequenceId = function () {
@@ -138,43 +150,104 @@ Companion.prototype._nextSequenceId = function () {
138150 };
139151
140152 Companion.prototype._sendConfiguration = function () {
141- if (!configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.apiKey, null)) return;
153+ var keyStatus = this._fixtureEnabled
154+ ? contracts.KEY_STATUS.CONFIGURED
155+ : (this._credentialBlocked
156+ ? contracts.KEY_STATUS.INVALID
157+ : this._configuration.keyStatus);
158+ if (!this._configurationValid
159+ || !configuration.areFavoritesSecretFree(
160+ this._watchFavorites,
161+ this._configuration.primApiKey,
162+ null
163+ )) return;
142164 this._queue.enqueue(codec.encodeConfiguration(
143165 this._nextSequenceId(),
144166 this._watchFavorites,
145- this._keyStatus,
167+ keyStatus,
146168 configuration.activeWatchLanguage(this._Pebble)
147169 ));
148170 };
149171
150172 Companion.prototype._onReady = function () {
151- this._configuration = configuration.loadConfiguration(this._storage);
173+ var wasBlocked = this._credentialBlocked;
174+ var loaded = configuration.loadConfiguration(this._storage);
175+ if (loaded === null) {
176+ this._configurationValid = false;
177+ return;
178+ }
179+ if (wasBlocked && loaded.primApiKey !== null) {
180+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
181+ }
182+ this._configuration = loaded;
183+ this._configurationValid = true;
184+ this._credentialBlocked = loaded.keyStatus === contracts.KEY_STATUS.INVALID;
185+ this._results = configuration.loadResults(this._storage, loaded.favorites);
152186 this._refreshWatchState();
153187 this._sendConfiguration();
154188 };
155189
156190 Companion.prototype._onShowConfiguration = function () {
157- var language = configuration.activeWatchLanguage(this._Pebble);
158- var url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
191+ var language;
192+ var url;
193+ if (!this._configurationValid) return;
194+ language = configuration.activeWatchLanguage(this._Pebble);
195+ url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
159196 if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);
160197 };
161198
162199 Companion.prototype._onWebviewClosed = function (event) {
163- var update = configuration.parseCloseFragment(event && event.response);
200+ var update;
164201 var next;
202+ var pending;
203+ var pruned;
204+ if (!this._configurationValid) return;
205+ update = configuration.parseCloseFragment(event && event.response);
165206 if (update === null) return;
166207 next = configuration.applyConfigurationUpdate(this._configuration, update);
167- if (next === null || !configuration.saveConfiguration(this._storage, next)) return;
208+ if (next === null) return;
209+ if (update.apiKeyUpdate.action === "REPLACE") {
210+ if (this._credentialBlocked
211+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
212+ pending = {
213+ schemaVersion: contracts.SCHEMA_VERSION,
214+ favorites: next.favorites.map(contracts.copyFavorite),
215+ primApiKey: next.primApiKey,
216+ keyStatus: contracts.KEY_STATUS.INVALID
217+ };
218+ if (!configuration.saveConfiguration(this._storage, pending)) return;
219+ if (!configuration.clearInvalidKeyStatus(this._storage)
220+ || !configuration.saveConfiguration(this._storage, next)) {
221+ next = pending;
222+ }
223+ } else if (!configuration.clearInvalidKeyStatus(this._storage)
224+ || !configuration.saveConfiguration(this._storage, next)) {
225+ return;
226+ }
227+ } else {
228+ if (!configuration.saveConfiguration(this._storage, next)) return;
229+ if (update.apiKeyUpdate.action === "REMOVE") {
230+ configuration.clearInvalidKeyStatus(this._storage);
231+ }
232+ }
233+ pruned = configuration.pruneResults(this._results, next.favorites);
234+ if (JSON.stringify(pruned) !== JSON.stringify(this._results)) {
235+ configuration.saveResults(this._storage, pruned);
236+ }
168237 this._configuration = next;
169- this._cache = Object.create(null);
238+ this._results = pruned;
170239 this._requestGenerations = Object.create(null);
240+ this._credentialBlocked = next.primApiKey !== null
241+ && next.keyStatus === contracts.KEY_STATUS.INVALID;
171242 this._refreshWatchState();
172243 this._sendConfiguration();
173244 };
174245
175246 Companion.prototype._onAppMessage = function (event) {
176- var request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
177- if (request === null || request.trigger !== contracts.REQUEST_TRIGGER.APP_OPEN) return;
247+ var request;
248+ if (!this._configurationValid) return;
249+ request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
250+ if (request === null) return;
178251 if (this._findFavorite(request.favoriteId) === null) {
179252 this._sendError(request, "INVALID_SERVICE");
180253 return;
@@ -191,20 +264,22 @@ Companion.prototype._findFavorite = function (favoriteId) {
191264 };
192265
193266 Companion.prototype._cacheResult = function (result) {
194- this._cache[result.favoriteId] = {
195- storedAt: this._clock.now(),
196- result: contracts.copyDepartureResult(result)
197- };
267+ var next = configuration.putResult(
268+ this._results,
269+ this._configuration.favorites,
270+ result,
271+ Math.floor(this._clock.now())
272+ );
273+ if (next === null) return false;
274+ if (!configuration.saveResults(this._storage, next)) return false;
275+ this._results = next;
276+ return true;
198277 };
199278
200279 Companion.prototype._cachedResult = function (request) {
201- var cached = this._cache[request.favoriteId];
202- var age;
203- if (!cached) return null;
204- age = this._clock.now() - cached.storedAt;
205- if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) return null;
206- return contracts.copyDepartureResult(cached.result, request.requestId);
280+ return configuration.findResult(this._results, request.favoriteId, request.requestId);
207281 };
282+
208283 Companion.prototype._beginRequest = function (favoriteId) {
209284 this._requestGeneration += 1;
210285 this._requestGenerations[favoriteId] = this._requestGeneration;
@@ -215,27 +290,36 @@ Companion.prototype._isLatestRequest = function (favoriteId, generation) {
215290 return this._requestGenerations[favoriteId] === generation;
216291 };
217292
218-
219293 Companion.prototype._dispatchRequest = function (request) {
220- var generation = this._beginRequest(request.favoriteId);
221294 var cached = this._cachedResult(request);
295+ var cachedMessages;
222296 var fixtureResult;
223297 var favorite;
298+ var generation;
299+ var age;
300+ var refreshRequired;
224301 if (cached !== null) {
225- this._queue.enqueue(codec.encodeResult(cached));
226- return;
302+ this._metrics.cacheHits += 1;
303+ age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt);
304+ refreshRequired = !(age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000);
305+ cachedMessages = codec.encodeResult(cached.result);
306+ if (refreshRequired) cachedMessages.push(codec.encodeRequest(request));
307+ this._queue.enqueue(cachedMessages);
308+ if (!refreshRequired) return;
309+ } else {
310+ this._metrics.cacheMisses += 1;
227311 }
228312 if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
229313 fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
230- this._cacheResult(fixtureResult);
231314 this._queue.enqueue(codec.encodeResult(fixtureResult));
232315 return;
233316 }
234- if (this._configuration.apiKey === null) {
317+ if (this._configuration.primApiKey === null) {
235318 this._sendError(request, "API_KEY_REQUIRED");
236319 return;
237320 }
238- if (this._keyStatus === contracts.KEY_STATUS.INVALID) {
321+ if (this._credentialBlocked
322+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
239323 this._sendError(request, "API_KEY_INVALID");
240324 return;
241325 }
@@ -244,6 +328,7 @@ Companion.prototype._dispatchRequest = function (request) {
244328 this._sendError(request, "INVALID_SERVICE");
245329 return;
246330 }
331+ generation = this._beginRequest(request.favoriteId);
247332 this._fetch(request, favorite, generation);
248333 };
249334
@@ -251,8 +336,8 @@ Companion.prototype._fetch = function (request, favorite, generation) {
251336 var self = this;
252337 var xhr;
253338 var settled = false;
254- var key = this._configuration.apiKey;
255- if (!configuration.isFavoriteSecretFree(favorite, key, null)) return;
339+ var key = null;
340+ var startedAt = this._clock.now();
256341 if (this._backendUrl.indexOf("https://") !== 0) {
257342 this._sendError(request, "SOURCE_UNAVAILABLE");
258343 return;
@@ -260,8 +345,12 @@ Companion.prototype._fetch = function (request, favorite, generation) {
260345
261346 function once(callback) {
262347 return function () {
348+ var latency;
263349 if (settled) return;
264350 settled = true;
351+ latency = Math.max(0, self._clock.now() - startedAt);
352+ self._metrics.lastLatencyMs = latency;
353+ self._metrics.totalLatencyMs += latency;
265354 if (self._inFlightRequests[generation] === xhr) {
266355 delete self._inFlightRequests[generation];
267356 }
@@ -274,17 +363,24 @@ Companion.prototype._fetch = function (request, favorite, generation) {
274363 xhr = new this._XHR();
275364 xhr.open("POST", this._backendUrl, true);
276365 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
277- xhr.setRequestHeader("Authorization", "Bearer " + key);
278366 xhr.setRequestHeader("Content-Type", "application/json");
279367 xhr.setRequestHeader("Accept", "application/json");
280368 xhr.onload = once(function () { self._handleResponse(request, xhr); });
281- xhr.onerror = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
282- xhr.ontimeout = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
283- xhr.onabort = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
284- if (!this._isLatestRequest(request.favoriteId, generation)) {
369+ xhr.onerror = once(function () {
370+ self._metrics.failures += 1;
371+ self._sendError(request, "SOURCE_UNAVAILABLE");
372+ });
373+ xhr.ontimeout = xhr.onerror;
374+ xhr.onabort = xhr.onerror;
375+ if (!this._isLatestRequest(request.favoriteId, generation)) return;
376+ key = this._configuration.primApiKey;
377+ if (!contracts.isPersonalApiKey(key)) {
285378 key = null;
379+ this._sendError(request, "API_KEY_REQUIRED");
286380 return;
287381 }
382+ xhr.setRequestHeader("Authorization", "Bearer " + key);
383+ this._metrics.requests += 1;
288384 this._inFlightRequests[generation] = xhr;
289385 xhr.send(JSON.stringify({
290386 schemaVersion: contracts.SCHEMA_VERSION,
@@ -300,6 +396,7 @@ Companion.prototype._fetch = function (request, favorite, generation) {
300396 }
301397 if (!settled) {
302398 settled = true;
399+ this._metrics.failures += 1;
303400 if (this._isLatestRequest(request.favoriteId, generation)) {
304401 this._sendError(request, "SOURCE_UNAVAILABLE");
305402 }
@@ -326,14 +423,21 @@ Companion.prototype._handleResponse = function (request, xhr) {
326423 if (!contracts.isDepartureResult(body)
327424 || body.requestId !== request.requestId
328425 || body.favoriteId !== request.favoriteId) {
426+ this._metrics.failures += 1;
329427 this._sendError(request, "INVALID_RESPONSE");
330428 return;
331429 }
430+ this._metrics.successes += 1;
332431 this._cacheResult(body);
333432 this._queue.enqueue(codec.encodeResult(body));
334433 return;
335434 }
336435
436+ this._metrics.failures += 1;
437+ if (status === 401 || status === 403) {
438+ this._sendError(request, "API_KEY_INVALID");
439+ return;
440+ }
337441 if (body !== null && contracts.isErrorResult(body)) {
338442 if (body.requestId !== request.requestId
339443 || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {
@@ -390,12 +494,33 @@ Companion.prototype._sendError = function (request, code, retryAfterSeconds) {
390494 };
391495
392496 Companion.prototype._sendErrorResult = function (error) {
497+ var invalid;
393498 this._queue.enqueue([codec.encodeError(error)]);
394- if (error.code === "API_KEY_INVALID") {
395- this._keyStatus = contracts.KEY_STATUS.INVALID;
396- this._cache = Object.create(null);
397- this._sendConfiguration();
398- }
499+ if (error.code !== "API_KEY_INVALID"
500+ || this._configuration.primApiKey === null
501+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return;
502+ invalid = {
503+ schemaVersion: contracts.SCHEMA_VERSION,
504+ favorites: this._configuration.favorites.map(contracts.copyFavorite),
505+ primApiKey: this._configuration.primApiKey,
506+ keyStatus: contracts.KEY_STATUS.INVALID
507+ };
508+ configuration.saveInvalidConfiguration(this._storage, invalid);
509+ this._configuration = invalid;
510+ this._credentialBlocked = true;
511+ this._sendConfiguration();
512+};
513+
514+Companion.prototype.metrics = function () {
515+ return {
516+ requests: this._metrics.requests,
517+ cacheHits: this._metrics.cacheHits,
518+ cacheMisses: this._metrics.cacheMisses,
519+ successes: this._metrics.successes,
520+ failures: this._metrics.failures,
521+ totalLatencyMs: this._metrics.totalLatencyMs,
522+ lastLatencyMs: this._metrics.lastLatencyMs
523+ };
399524 };
400525
401526 function createCompanion(options) {
@@ -50,7 +50,10 @@ function isFixture(value) {
50 function Companion(options) {50 function Companion(options) {
51 options = options || {};51 options = options || {};
52 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");52 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
53- requiredAdapter(options.storage && typeof options.storage.getItem === "function" && typeof options.storage.setItem === "function", "storage adapter is required");53+ requiredAdapter(options.storage
54+ && typeof options.storage.getItem === "function"
55+ && typeof options.storage.setItem === "function"
56+ && typeof options.storage.removeItem === "function", "storage adapter is required");
54 requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");57 requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");
55 requiredAdapter(options.clock58 requiredAdapter(options.clock
56 && typeof options.clock.now === "function", "clock adapter is required");59 && typeof options.clock.now === "function", "clock adapter is required");
@@ -68,9 +71,21 @@ function Companion(options) {
68 this._fixture = options.fixture || null;71 this._fixture = options.fixture || null;
69 this._fixtureEnabled = false;72 this._fixtureEnabled = false;
70 this._configuration = configuration.loadConfiguration(this._storage);73 this._configuration = configuration.loadConfiguration(this._storage);
74+ this._configurationValid = this._configuration !== null;
75+ this._credentialBlocked = this._configurationValid
76+ && this._configuration.keyStatus === contracts.KEY_STATUS.INVALID;
77+ if (!this._configurationValid) this._configuration = configuration.emptyConfiguration();
71 this._watchFavorites = [];78 this._watchFavorites = [];
72- this._keyStatus = contracts.KEY_STATUS.MISSING;79+ this._results = configuration.emptyResults();
73- this._cache = Object.create(null);80+ this._metrics = {
81+ requests: 0,
82+ cacheHits: 0,
83+ cacheMisses: 0,
84+ successes: 0,
85+ failures: 0,
86+ totalLatencyMs: 0,
87+ lastLatencyMs: 0
88+ };
74 this._requestGenerations = Object.create(null);89 this._requestGenerations = Object.create(null);
75 this._inFlightRequests = Object.create(null);90 this._inFlightRequests = Object.create(null);
76 this._requestGeneration = 0;91 this._requestGeneration = 0;
@@ -126,9 +141,6 @@ Companion.prototype._refreshWatchState = function () {
126 var configured = this._configuration.favorites.map(contracts.copyFavorite);141 var configured = this._configuration.favorites.map(contracts.copyFavorite);
127 this._fixtureEnabled = this._fixture !== null && configured.length === 0;142 this._fixtureEnabled = this._fixture !== null && configured.length === 0;
128 this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;143 this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
129- this._keyStatus = this._fixtureEnabled || this._configuration.apiKey !== null
130- ? contracts.KEY_STATUS.CONFIGURED
131- : contracts.KEY_STATUS.MISSING;
132 };144 };
133 145
134 Companion.prototype._nextSequenceId = function () {146 Companion.prototype._nextSequenceId = function () {
@@ -138,43 +150,104 @@ Companion.prototype._nextSequenceId = function () {
138 };150 };
139 151
140 Companion.prototype._sendConfiguration = function () {152 Companion.prototype._sendConfiguration = function () {
141- if (!configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.apiKey, null)) return;153+ var keyStatus = this._fixtureEnabled
154+ ? contracts.KEY_STATUS.CONFIGURED
155+ : (this._credentialBlocked
156+ ? contracts.KEY_STATUS.INVALID
157+ : this._configuration.keyStatus);
158+ if (!this._configurationValid
159+ || !configuration.areFavoritesSecretFree(
160+ this._watchFavorites,
161+ this._configuration.primApiKey,
162+ null
163+ )) return;
142 this._queue.enqueue(codec.encodeConfiguration(164 this._queue.enqueue(codec.encodeConfiguration(
143 this._nextSequenceId(),165 this._nextSequenceId(),
144 this._watchFavorites,166 this._watchFavorites,
145- this._keyStatus,167+ keyStatus,
146 configuration.activeWatchLanguage(this._Pebble)168 configuration.activeWatchLanguage(this._Pebble)
147 ));169 ));
148 };170 };
149 171
150 Companion.prototype._onReady = function () {172 Companion.prototype._onReady = function () {
151- this._configuration = configuration.loadConfiguration(this._storage);173+ var wasBlocked = this._credentialBlocked;
174+ var loaded = configuration.loadConfiguration(this._storage);
175+ if (loaded === null) {
176+ this._configurationValid = false;
177+ return;
178+ }
179+ if (wasBlocked && loaded.primApiKey !== null) {
180+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
181+ }
182+ this._configuration = loaded;
183+ this._configurationValid = true;
184+ this._credentialBlocked = loaded.keyStatus === contracts.KEY_STATUS.INVALID;
185+ this._results = configuration.loadResults(this._storage, loaded.favorites);
152 this._refreshWatchState();186 this._refreshWatchState();
153 this._sendConfiguration();187 this._sendConfiguration();
154 };188 };
155 189
156 Companion.prototype._onShowConfiguration = function () {190 Companion.prototype._onShowConfiguration = function () {
157- var language = configuration.activeWatchLanguage(this._Pebble);191+ var language;
158- var url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);192+ var url;
193+ if (!this._configurationValid) return;
194+ language = configuration.activeWatchLanguage(this._Pebble);
195+ url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
159 if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);196 if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);
160 };197 };
161 198
162 Companion.prototype._onWebviewClosed = function (event) {199 Companion.prototype._onWebviewClosed = function (event) {
163- var update = configuration.parseCloseFragment(event && event.response);200+ var update;
164 var next;201 var next;
202+ var pending;
203+ var pruned;
204+ if (!this._configurationValid) return;
205+ update = configuration.parseCloseFragment(event && event.response);
165 if (update === null) return;206 if (update === null) return;
166 next = configuration.applyConfigurationUpdate(this._configuration, update);207 next = configuration.applyConfigurationUpdate(this._configuration, update);
167- if (next === null || !configuration.saveConfiguration(this._storage, next)) return;208+ if (next === null) return;
209+ if (update.apiKeyUpdate.action === "REPLACE") {
210+ if (this._credentialBlocked
211+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
212+ pending = {
213+ schemaVersion: contracts.SCHEMA_VERSION,
214+ favorites: next.favorites.map(contracts.copyFavorite),
215+ primApiKey: next.primApiKey,
216+ keyStatus: contracts.KEY_STATUS.INVALID
217+ };
218+ if (!configuration.saveConfiguration(this._storage, pending)) return;
219+ if (!configuration.clearInvalidKeyStatus(this._storage)
220+ || !configuration.saveConfiguration(this._storage, next)) {
221+ next = pending;
222+ }
223+ } else if (!configuration.clearInvalidKeyStatus(this._storage)
224+ || !configuration.saveConfiguration(this._storage, next)) {
225+ return;
226+ }
227+ } else {
228+ if (!configuration.saveConfiguration(this._storage, next)) return;
229+ if (update.apiKeyUpdate.action === "REMOVE") {
230+ configuration.clearInvalidKeyStatus(this._storage);
231+ }
232+ }
233+ pruned = configuration.pruneResults(this._results, next.favorites);
234+ if (JSON.stringify(pruned) !== JSON.stringify(this._results)) {
235+ configuration.saveResults(this._storage, pruned);
236+ }
168 this._configuration = next;237 this._configuration = next;
169- this._cache = Object.create(null);238+ this._results = pruned;
170 this._requestGenerations = Object.create(null);239 this._requestGenerations = Object.create(null);
240+ this._credentialBlocked = next.primApiKey !== null
241+ && next.keyStatus === contracts.KEY_STATUS.INVALID;
171 this._refreshWatchState();242 this._refreshWatchState();
172 this._sendConfiguration();243 this._sendConfiguration();
173 };244 };
174 245
175 Companion.prototype._onAppMessage = function (event) {246 Companion.prototype._onAppMessage = function (event) {
176- var request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));247+ var request;
177- if (request === null || request.trigger !== contracts.REQUEST_TRIGGER.APP_OPEN) return;248+ if (!this._configurationValid) return;
249+ request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
250+ if (request === null) return;
178 if (this._findFavorite(request.favoriteId) === null) {251 if (this._findFavorite(request.favoriteId) === null) {
179 this._sendError(request, "INVALID_SERVICE");252 this._sendError(request, "INVALID_SERVICE");
180 return;253 return;
@@ -191,20 +264,22 @@ Companion.prototype._findFavorite = function (favoriteId) {
191 };264 };
192 265
193 Companion.prototype._cacheResult = function (result) {266 Companion.prototype._cacheResult = function (result) {
194- this._cache[result.favoriteId] = {267+ var next = configuration.putResult(
195- storedAt: this._clock.now(),268+ this._results,
196- result: contracts.copyDepartureResult(result)269+ this._configuration.favorites,
197- };270+ result,
271+ Math.floor(this._clock.now())
272+ );
273+ if (next === null) return false;
274+ if (!configuration.saveResults(this._storage, next)) return false;
275+ this._results = next;
276+ return true;
198 };277 };
199 278
200 Companion.prototype._cachedResult = function (request) {279 Companion.prototype._cachedResult = function (request) {
201- var cached = this._cache[request.favoriteId];280+ return configuration.findResult(this._results, request.favoriteId, request.requestId);
202- var age;
203- if (!cached) return null;
204- age = this._clock.now() - cached.storedAt;
205- if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) return null;
206- return contracts.copyDepartureResult(cached.result, request.requestId);
207 };281 };
282+
208 Companion.prototype._beginRequest = function (favoriteId) {283 Companion.prototype._beginRequest = function (favoriteId) {
209 this._requestGeneration += 1;284 this._requestGeneration += 1;
210 this._requestGenerations[favoriteId] = this._requestGeneration;285 this._requestGenerations[favoriteId] = this._requestGeneration;
@@ -215,27 +290,36 @@ Companion.prototype._isLatestRequest = function (favoriteId, generation) {
215 return this._requestGenerations[favoriteId] === generation;290 return this._requestGenerations[favoriteId] === generation;
216 };291 };
217 292
218-
219 Companion.prototype._dispatchRequest = function (request) {293 Companion.prototype._dispatchRequest = function (request) {
220- var generation = this._beginRequest(request.favoriteId);
221 var cached = this._cachedResult(request);294 var cached = this._cachedResult(request);
295+ var cachedMessages;
222 var fixtureResult;296 var fixtureResult;
223 var favorite;297 var favorite;
298+ var generation;
299+ var age;
300+ var refreshRequired;
224 if (cached !== null) {301 if (cached !== null) {
225- this._queue.enqueue(codec.encodeResult(cached));302+ this._metrics.cacheHits += 1;
226- return;303+ age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt);
304+ refreshRequired = !(age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000);
305+ cachedMessages = codec.encodeResult(cached.result);
306+ if (refreshRequired) cachedMessages.push(codec.encodeRequest(request));
307+ this._queue.enqueue(cachedMessages);
308+ if (!refreshRequired) return;
309+ } else {
310+ this._metrics.cacheMisses += 1;
227 }311 }
228 if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {312 if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
229 fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);313 fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
230- this._cacheResult(fixtureResult);
231 this._queue.enqueue(codec.encodeResult(fixtureResult));314 this._queue.enqueue(codec.encodeResult(fixtureResult));
232 return;315 return;
233 }316 }
234- if (this._configuration.apiKey === null) {317+ if (this._configuration.primApiKey === null) {
235 this._sendError(request, "API_KEY_REQUIRED");318 this._sendError(request, "API_KEY_REQUIRED");
236 return;319 return;
237 }320 }
238- if (this._keyStatus === contracts.KEY_STATUS.INVALID) {321+ if (this._credentialBlocked
322+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
239 this._sendError(request, "API_KEY_INVALID");323 this._sendError(request, "API_KEY_INVALID");
240 return;324 return;
241 }325 }
@@ -244,6 +328,7 @@ Companion.prototype._dispatchRequest = function (request) {
244 this._sendError(request, "INVALID_SERVICE");328 this._sendError(request, "INVALID_SERVICE");
245 return;329 return;
246 }330 }
331+ generation = this._beginRequest(request.favoriteId);
247 this._fetch(request, favorite, generation);332 this._fetch(request, favorite, generation);
248 };333 };
249 334
@@ -251,8 +336,8 @@ Companion.prototype._fetch = function (request, favorite, generation) {
251 var self = this;336 var self = this;
252 var xhr;337 var xhr;
253 var settled = false;338 var settled = false;
254- var key = this._configuration.apiKey;339+ var key = null;
255- if (!configuration.isFavoriteSecretFree(favorite, key, null)) return;340+ var startedAt = this._clock.now();
256 if (this._backendUrl.indexOf("https://") !== 0) {341 if (this._backendUrl.indexOf("https://") !== 0) {
257 this._sendError(request, "SOURCE_UNAVAILABLE");342 this._sendError(request, "SOURCE_UNAVAILABLE");
258 return;343 return;
@@ -260,8 +345,12 @@ Companion.prototype._fetch = function (request, favorite, generation) {
260 345
261 function once(callback) {346 function once(callback) {
262 return function () {347 return function () {
348+ var latency;
263 if (settled) return;349 if (settled) return;
264 settled = true;350 settled = true;
351+ latency = Math.max(0, self._clock.now() - startedAt);
352+ self._metrics.lastLatencyMs = latency;
353+ self._metrics.totalLatencyMs += latency;
265 if (self._inFlightRequests[generation] === xhr) {354 if (self._inFlightRequests[generation] === xhr) {
266 delete self._inFlightRequests[generation];355 delete self._inFlightRequests[generation];
267 }356 }
@@ -274,17 +363,24 @@ Companion.prototype._fetch = function (request, favorite, generation) {
274 xhr = new this._XHR();363 xhr = new this._XHR();
275 xhr.open("POST", this._backendUrl, true);364 xhr.open("POST", this._backendUrl, true);
276 xhr.timeout = contracts.LIMITS.httpTimeoutMs;365 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
277- xhr.setRequestHeader("Authorization", "Bearer " + key);
278 xhr.setRequestHeader("Content-Type", "application/json");366 xhr.setRequestHeader("Content-Type", "application/json");
279 xhr.setRequestHeader("Accept", "application/json");367 xhr.setRequestHeader("Accept", "application/json");
280 xhr.onload = once(function () { self._handleResponse(request, xhr); });368 xhr.onload = once(function () { self._handleResponse(request, xhr); });
281- xhr.onerror = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });369+ xhr.onerror = once(function () {
282- xhr.ontimeout = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });370+ self._metrics.failures += 1;
283- xhr.onabort = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });371+ self._sendError(request, "SOURCE_UNAVAILABLE");
284- if (!this._isLatestRequest(request.favoriteId, generation)) {372+ });
373+ xhr.ontimeout = xhr.onerror;
374+ xhr.onabort = xhr.onerror;
375+ if (!this._isLatestRequest(request.favoriteId, generation)) return;
376+ key = this._configuration.primApiKey;
377+ if (!contracts.isPersonalApiKey(key)) {
285 key = null;378 key = null;
379+ this._sendError(request, "API_KEY_REQUIRED");
286 return;380 return;
287 }381 }
382+ xhr.setRequestHeader("Authorization", "Bearer " + key);
383+ this._metrics.requests += 1;
288 this._inFlightRequests[generation] = xhr;384 this._inFlightRequests[generation] = xhr;
289 xhr.send(JSON.stringify({385 xhr.send(JSON.stringify({
290 schemaVersion: contracts.SCHEMA_VERSION,386 schemaVersion: contracts.SCHEMA_VERSION,
@@ -300,6 +396,7 @@ Companion.prototype._fetch = function (request, favorite, generation) {
300 }396 }
301 if (!settled) {397 if (!settled) {
302 settled = true;398 settled = true;
399+ this._metrics.failures += 1;
303 if (this._isLatestRequest(request.favoriteId, generation)) {400 if (this._isLatestRequest(request.favoriteId, generation)) {
304 this._sendError(request, "SOURCE_UNAVAILABLE");401 this._sendError(request, "SOURCE_UNAVAILABLE");
305 }402 }
@@ -326,14 +423,21 @@ Companion.prototype._handleResponse = function (request, xhr) {
326 if (!contracts.isDepartureResult(body)423 if (!contracts.isDepartureResult(body)
327 || body.requestId !== request.requestId424 || body.requestId !== request.requestId
328 || body.favoriteId !== request.favoriteId) {425 || body.favoriteId !== request.favoriteId) {
426+ this._metrics.failures += 1;
329 this._sendError(request, "INVALID_RESPONSE");427 this._sendError(request, "INVALID_RESPONSE");
330 return;428 return;
331 }429 }
430+ this._metrics.successes += 1;
332 this._cacheResult(body);431 this._cacheResult(body);
333 this._queue.enqueue(codec.encodeResult(body));432 this._queue.enqueue(codec.encodeResult(body));
334 return;433 return;
335 }434 }
336 435
436+ this._metrics.failures += 1;
437+ if (status === 401 || status === 403) {
438+ this._sendError(request, "API_KEY_INVALID");
439+ return;
440+ }
337 if (body !== null && contracts.isErrorResult(body)) {441 if (body !== null && contracts.isErrorResult(body)) {
338 if (body.requestId !== request.requestId442 if (body.requestId !== request.requestId
339 || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {443 || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {
@@ -390,12 +494,33 @@ Companion.prototype._sendError = function (request, code, retryAfterSeconds) {
390 };494 };
391 495
392 Companion.prototype._sendErrorResult = function (error) {496 Companion.prototype._sendErrorResult = function (error) {
497+ var invalid;
393 this._queue.enqueue([codec.encodeError(error)]);498 this._queue.enqueue([codec.encodeError(error)]);
394- if (error.code === "API_KEY_INVALID") {499+ if (error.code !== "API_KEY_INVALID"
395- this._keyStatus = contracts.KEY_STATUS.INVALID;500+ || this._configuration.primApiKey === null
396- this._cache = Object.create(null);501+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return;
397- this._sendConfiguration();502+ invalid = {
398- }503+ schemaVersion: contracts.SCHEMA_VERSION,
504+ favorites: this._configuration.favorites.map(contracts.copyFavorite),
505+ primApiKey: this._configuration.primApiKey,
506+ keyStatus: contracts.KEY_STATUS.INVALID
507+ };
508+ configuration.saveInvalidConfiguration(this._storage, invalid);
509+ this._configuration = invalid;
510+ this._credentialBlocked = true;
511+ this._sendConfiguration();
512+};
513+
514+Companion.prototype.metrics = function () {
515+ return {
516+ requests: this._metrics.requests,
517+ cacheHits: this._metrics.cacheHits,
518+ cacheMisses: this._metrics.cacheMisses,
519+ successes: this._metrics.successes,
520+ failures: this._metrics.failures,
521+ totalLatencyMs: this._metrics.totalLatencyMs,
522+ lastLatencyMs: this._metrics.lastLatencyMs
523+ };
399 };524 };
400 525
401 function createCompanion(options) {526 function createCompanion(options) {
modified packages/companion/test/companion.test.js +383 -20
@@ -3,6 +3,8 @@
33 var test = require("node:test");
44 var assert = require("node:assert/strict");
55 var vm = require("node:vm");
6+var path = require("node:path");
7+var url = require("node:url");
68 var companion = require("../src");
79 var fixture = require("../../../fixtures/departures/foundation.json");
810 var fakes = require("./fakes");
@@ -14,13 +16,17 @@ var T = contracts.MESSAGE_TYPE;
1416 var TEST_KEY = "test-personal-prim-key";
1517
1618 function harness(options) {
17- var Pebble = new fakes.FakePebble(true);
18- var storage = new fakes.FakeStorage();
19- var clock = new fakes.FakeClock();
20- var xhr = fakes.createXHRFactory();
19+ var Pebble;
20+ var storage;
21+ var clock;
22+ var xhr;
2123 var defer;
2224 var instance;
2325 options = options || {};
26+ Pebble = options.Pebble || new fakes.FakePebble(true);
27+ storage = options.storage || new fakes.FakeStorage();
28+ clock = options.clock || new fakes.FakeClock();
29+ xhr = fakes.createXHRFactory();
2430 defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);
2531 instance = companion.createCompanion({
2632 Pebble: Pebble,
@@ -58,12 +64,12 @@ function closeWith(target, payload) {
5864 });
5965 }
6066
61-function sendRequest(target, requestId) {
67+function sendRequest(target, requestId, trigger) {
6268 target.Pebble.emit("appmessage", {
6369 payload: codec.encodeRequest({
6470 requestId: requestId,
6571 favoriteId: fixture.favorite.id,
66- trigger: contracts.REQUEST_TRIGGER.APP_OPEN
72+ trigger: typeof trigger === "number" ? trigger : contracts.REQUEST_TRIGGER.APP_OPEN
6773 })
6874 });
6975 }
@@ -94,6 +100,17 @@ function responseFor(requestId) {
94100 return result;
95101 }
96102
103+async function watchProtocol() {
104+ var protocolPath = path.resolve(__dirname, "../../watch/src/embeddedjs/protocol.js");
105+ return import(url.pathToFileURL(protocolPath).href);
106+}
107+
108+function watchMessage(message) {
109+ return new Map(Object.keys(message).map(function (key) {
110+ return [key, message[key]];
111+ }));
112+}
113+
97114 test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {
98115 var target = harness();
99116 var stored;
@@ -101,10 +118,11 @@ test("close fragment atomically stores one favorites-and-key record and exposes
101118 var pageState;
102119
103120 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
104- assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
121+ assert.deepEqual(target.storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
105122 assert.equal(target.storage.writes.length, 1);
106- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
107- assert.equal(stored.apiKey, TEST_KEY);
123+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
124+ assert.equal(stored.primApiKey, TEST_KEY);
125+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.CONFIGURED);
108126 assert.deepEqual(stored.favorites, [fixture.favorite]);
109127
110128 sentText = JSON.stringify(target.Pebble.sent);
@@ -144,12 +162,17 @@ test("a pre-presentation v1 record keeps its API key and legacy favorite on load
144162 };
145163 var loaded;
146164
147- storage.setItem(configuration.STORAGE_KEY, JSON.stringify(legacyRecord));
165+ storage.setItem(configuration.LEGACY_CONFIG_STORAGE_KEY, JSON.stringify(legacyRecord));
148166 loaded = configuration.loadConfiguration(storage);
149167
150- assert.deepEqual(loaded, legacyRecord);
151- assert.equal(loaded.apiKey, TEST_KEY);
168+ assert.equal(loaded.primApiKey, TEST_KEY);
169+ assert.equal(loaded.keyStatus, contracts.KEY_STATUS.CONFIGURED);
152170 assert.equal(loaded.favorites.length, 1);
171+ assert.equal(
172+ JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY)).primApiKey,
173+ TEST_KEY
174+ );
175+ assert.deepEqual(storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
153176 ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) {
154177 assert.equal(Object.prototype.hasOwnProperty.call(loaded.favorites[0], field), false);
155178 });
@@ -176,16 +199,44 @@ test("KEEP, REPLACE, and REMOVE apply to one atomic phone-local record", functio
176199
177200 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
178201 closeWith(target, configurationPayload("KEEP"));
179- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
180- assert.equal(stored.apiKey, TEST_KEY);
181- assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
202+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
203+ assert.equal(stored.primApiKey, TEST_KEY);
204+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.CONFIGURED);
205+ assert.deepEqual(target.storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
182206
183207 closeWith(target, configurationPayload("REMOVE"));
184- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
185- assert.equal(stored.apiKey, null);
186- assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
208+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
209+ assert.equal(stored.primApiKey, null);
210+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.MISSING);
211+ assert.deepEqual(target.storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
187212 assert.equal(target.storage.writes.length, 3);
188213 });
214+
215+test("failed phone verification rolls back configuration bytes and watch state", function () {
216+ var target = harness();
217+ var beforeBytes;
218+ var beforeMessages;
219+ var originalSetItem;
220+ var failNext = false;
221+
222+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
223+ beforeBytes = target.storage.getItem(configuration.CONFIG_STORAGE_KEY);
224+ beforeMessages = target.Pebble.sent.length;
225+ originalSetItem = target.storage.setItem.bind(target.storage);
226+ target.storage.setItem = function (key, value) {
227+ originalSetItem(key, value);
228+ if (failNext && key === configuration.CONFIG_STORAGE_KEY) {
229+ failNext = false;
230+ target.storage.values[key] = "corrupt";
231+ }
232+ };
233+ failNext = true;
234+ closeWith(target, configurationPayload("REMOVE"));
235+ assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), beforeBytes);
236+ assert.equal(target.Pebble.sent.length, beforeMessages);
237+ sendRequest(target, "rollback-still-configured");
238+ assert.equal(target.xhr.instances.length, 1);
239+});
189240 test("untrusted webview responses are bounded and cannot persist a key inside favorites", function () {
190241 var target = harness();
191242 var replaceLeak = configurationPayload("REPLACE", TEST_KEY);
@@ -211,9 +262,9 @@ test("untrusted webview responses are bounded and cannot persist a key inside fa
211262 ];
212263 closeWith(target, keepLeak);
213264 assert.equal(target.storage.writes.length, 1);
214- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
265+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
215266 assert.equal(JSON.stringify(stored.favorites).includes(TEST_KEY), false);
216- assert.equal(stored.apiKey, TEST_KEY);
267+ assert.equal(stored.primApiKey, TEST_KEY);
217268 });
218269
219270
@@ -383,6 +434,318 @@ test("fresh results are rebound from cache for 60 seconds without a network time
383434 assert.equal(target.xhr.instances.length, 2);
384435 assert.equal(target.clock.delays.length, 0);
385436 });
437+
438+test("selection replays by original fetched age and refreshes at exactly 60 seconds", function () {
439+ var target = harness();
440+ var aged = responseFor("aged-network");
441+ var before;
442+
443+ aged.fetchedAt = Math.floor(target.clock.now() / 1000) - 59;
444+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
445+ sendRequest(target, "aged-network");
446+ target.xhr.instances[0].respond(200, aged);
447+ target.clock.advance(1000);
448+ before = target.Pebble.sent.length;
449+ sendRequest(target, "selection-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
450+ assert.equal(
451+ target.Pebble.sent.length,
452+ before + fixture.result.departures.length + 3
453+ );
454+ assert.equal(target.xhr.instances.length, 2);
455+});
456+
457+test("stale cache commits before the same-request fresh result at the watch receiver", async function () {
458+ var protocol = await watchProtocol();
459+ var receiver = new protocol.ProtocolReceiver();
460+ var target = harness();
461+ var fresh = responseFor("cached-refresh");
462+ var before;
463+ var statuses;
464+
465+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
466+ sendRequest(target, "network-seed");
467+ target.xhr.instances[0].respond(200, responseFor("network-seed"));
468+ target.clock.advance(60000);
469+
470+ assert.equal(receiver.expectResponse("cached-refresh", fixture.favorite.id), true);
471+ before = target.Pebble.sent.length;
472+ sendRequest(target, "cached-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
473+ statuses = target.Pebble.sent.slice(before).map(function (message) {
474+ return receiver.receive(watchMessage(message));
475+ });
476+ assert.equal(statuses.filter(function (status) {
477+ return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;
478+ }).length, 1);
479+ assert.equal(receiver.snapshot().result.fetchedAt, fixture.result.fetchedAt);
480+
481+ fresh.fetchedAt += 60;
482+ before = target.Pebble.sent.length;
483+ target.xhr.instances[1].respond(200, fresh);
484+ statuses = target.Pebble.sent.slice(before).map(function (message) {
485+ return receiver.receive(watchMessage(message));
486+ });
487+ assert.equal(statuses.filter(function (status) {
488+ return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;
489+ }).length, 1);
490+ assert.equal(receiver.snapshot().result.fetchedAt, fresh.fetchedAt);
491+});
492+
493+test("future fetched timestamps cannot extend cache freshness past receipt time", function () {
494+ var target = harness();
495+ var future = responseFor("future-network");
496+
497+ future.fetchedAt = Math.floor(target.clock.now() / 1000) + 3600;
498+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
499+ sendRequest(target, "future-network");
500+ target.xhr.instances[0].respond(200, future);
501+
502+ target.clock.advance(60000);
503+ sendRequest(target, "future-expired", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
504+ assert.equal(target.xhr.instances.length, 2);
505+});
506+
507+test("future storedAt after restart replays cache but cannot suppress refresh", function () {
508+ var storage = new fakes.FakeStorage();
509+ var firstClock = new fakes.FakeClock();
510+ var first = harness({ storage: storage, clock: firstClock });
511+ var future = responseFor("future-stored");
512+ var secondClock;
513+ var second;
514+ var begins;
515+
516+ future.fetchedAt = Math.floor(firstClock.now() / 1000) + 3600;
517+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
518+ sendRequest(first, "future-stored");
519+ first.xhr.instances[0].respond(200, future);
520+ first.companion.stop();
521+
522+ secondClock = new fakes.FakeClock(firstClock.now() - 1000);
523+ second = harness({ storage: storage, clock: secondClock });
524+ second.Pebble.emit("ready");
525+ sendRequest(second, "future-restart");
526+ assert.equal(second.xhr.instances.length, 1);
527+ begins = messagesOfType(second, T.RESULT_BEGIN);
528+ assert.equal(begins[begins.length - 1].REQUEST_ID, "future-restart");
529+ assert.equal(begins[begins.length - 1].FETCHED_AT, future.fetchedAt);
530+});
531+
532+test("a durable result replays across companion restart with original age and no key leak", function () {
533+ var storage = new fakes.FakeStorage();
534+ var clock = new fakes.FakeClock();
535+ var first = harness({ storage: storage, clock: clock });
536+ var second;
537+ var begins;
538+ var storedResults;
539+
540+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
541+ sendRequest(first, "first-request");
542+ first.xhr.instances[0].respond(200, responseFor("first-request"));
543+ storedResults = storage.getItem(configuration.RESULTS_STORAGE_KEY);
544+ assert.equal(typeof storedResults, "string");
545+ assert.equal(storedResults.includes(TEST_KEY), false);
546+
547+ first.companion.stop();
548+ clock.advance(1000);
549+ second = harness({ storage: storage, clock: clock });
550+ second.Pebble.emit("ready");
551+ sendRequest(second, "restart-request");
552+ assert.equal(second.xhr.instances.length, 0);
553+ begins = messagesOfType(second, T.RESULT_BEGIN);
554+ assert.equal(begins[begins.length - 1].REQUEST_ID, "restart-request");
555+ assert.equal(begins[begins.length - 1].FETCHED_AT, fixture.result.fetchedAt);
556+ assert.deepEqual(second.companion.metrics(), {
557+ requests: 0,
558+ cacheHits: 1,
559+ cacheMisses: 0,
560+ successes: 0,
561+ failures: 0,
562+ totalLatencyMs: 0,
563+ lastLatencyMs: 0
564+ });
565+});
566+
567+test("unknown configuration versions preserve stored bytes and prior watch state", function () {
568+ var storage = new fakes.FakeStorage();
569+ var bytes = JSON.stringify({ schemaVersion: 99, favorites: [] });
570+ var target;
571+
572+ storage.setItem(configuration.CONFIG_STORAGE_KEY, bytes);
573+ target = harness({ storage: storage });
574+ target.Pebble.emit("ready");
575+ assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), bytes);
576+ assert.equal(target.Pebble.sent.length, 0);
577+ assert.equal(target.xhr.instances.length, 0);
578+});
579+
580+test("invalid key status persists across restart and replacement re-enables XHR", function () {
581+ var storage = new fakes.FakeStorage();
582+ var first = harness({ storage: storage });
583+ var second;
584+ var stored;
585+
586+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
587+ sendRequest(first, "invalid-first");
588+ first.xhr.instances[0].respond(401, {
589+ schemaVersion: contracts.SCHEMA_VERSION,
590+ requestId: "invalid-first",
591+ favoriteId: fixture.favorite.id,
592+ code: "SOURCE_UNAVAILABLE",
593+ occurredAt: Math.floor(first.clock.now() / 1000)
594+ });
595+ stored = JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY));
596+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.INVALID);
597+ assert.equal(stored.primApiKey, TEST_KEY);
598+
599+ first.companion.stop();
600+ second = harness({ storage: storage });
601+ second.Pebble.emit("ready");
602+ sendRequest(second, "invalid-restart");
603+ assert.equal(second.xhr.instances.length, 0);
604+ closeWith(second, configurationPayload("REPLACE", "replacement-key"));
605+ sendRequest(second, "replacement-request");
606+ assert.equal(second.xhr.instances.length, 1);
607+ assert.equal(second.xhr.instances[0].headers.Authorization, "Bearer replacement-key");
608+});
609+
610+test("failed INVALID config write stays durable and fail-closed across reconstruction", function () {
611+ var storage = new fakes.FakeStorage();
612+ var first = harness({ storage: storage });
613+ var second;
614+ var beforeBytes;
615+ var marker;
616+ var configBegins;
617+ var originalSetItem;
618+ var failNext = false;
619+
620+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
621+ beforeBytes = storage.getItem(configuration.CONFIG_STORAGE_KEY);
622+ originalSetItem = storage.setItem.bind(storage);
623+ storage.setItem = function (key, value) {
624+ originalSetItem(key, value);
625+ if (failNext && key === configuration.CONFIG_STORAGE_KEY) {
626+ failNext = false;
627+ storage.values[key] = "corrupt";
628+ }
629+ };
630+ failNext = true;
631+ sendRequest(first, "invalid-write");
632+ first.xhr.instances[0].respond(401, "");
633+ assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), beforeBytes);
634+ marker = storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY);
635+ assert.equal(typeof marker, "string");
636+ assert.equal(marker.includes(TEST_KEY), false);
637+ configBegins = messagesOfType(first, T.CONFIG_BEGIN);
638+ assert.equal(
639+ configBegins[configBegins.length - 1].KEY_STATUS,
640+ contracts.KEY_STATUS.INVALID
641+ );
642+
643+ first.companion.stop();
644+ second = harness({ storage: storage });
645+ second.Pebble.emit("ready");
646+ configBegins = messagesOfType(second, T.CONFIG_BEGIN);
647+ assert.equal(
648+ configBegins[configBegins.length - 1].KEY_STATUS,
649+ contracts.KEY_STATUS.INVALID
650+ );
651+ sendRequest(second, "invalid-write-restart");
652+ assert.equal(second.xhr.instances.length, 0);
653+ closeWith(second, configurationPayload("KEEP"));
654+ sendRequest(second, "invalid-write-after-keep");
655+ assert.equal(second.xhr.instances.length, 0);
656+
657+ closeWith(second, configurationPayload("REPLACE", "replacement-key"));
658+ assert.equal(storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY), null);
659+ sendRequest(second, "invalid-write-after-replace");
660+ assert.equal(second.xhr.instances.length, 1);
661+ assert.equal(second.xhr.instances[0].headers.Authorization, "Bearer replacement-key");
662+});
663+
664+test("same-key replacement clear interruption remains durably INVALID", function () {
665+ var storage = new fakes.FakeStorage();
666+ var first = harness({ storage: storage });
667+ var second;
668+ var originalRemoveItem;
669+ var originalSetItem;
670+ var failConfigWrite = false;
671+ var failMarkerRemoval = false;
672+ var stored;
673+ var configBegins;
674+
675+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
676+ originalSetItem = storage.setItem.bind(storage);
677+ storage.setItem = function (key, value) {
678+ originalSetItem(key, value);
679+ if (failConfigWrite && key === configuration.CONFIG_STORAGE_KEY) {
680+ failConfigWrite = false;
681+ storage.values[key] = "corrupt";
682+ }
683+ };
684+ failConfigWrite = true;
685+ sendRequest(first, "marker-same-key");
686+ first.xhr.instances[0].respond(401, "");
687+ assert.equal(
688+ typeof storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY),
689+ "string"
690+ );
691+
692+ originalRemoveItem = storage.removeItem.bind(storage);
693+ storage.removeItem = function (key) {
694+ if (failMarkerRemoval && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) {
695+ failMarkerRemoval = false;
696+ throw new Error("simulated interruption");
697+ }
698+ originalRemoveItem(key);
699+ };
700+ failMarkerRemoval = true;
701+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
702+ stored = JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY));
703+ assert.equal(stored.primApiKey, TEST_KEY);
704+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.INVALID);
705+ assert.equal(
706+ typeof storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY),
707+ "string"
708+ );
709+ configBegins = messagesOfType(first, T.CONFIG_BEGIN);
710+ assert.equal(
711+ configBegins[configBegins.length - 1].KEY_STATUS,
712+ contracts.KEY_STATUS.INVALID
713+ );
714+
715+ first.companion.stop();
716+ second = harness({ storage: storage });
717+ second.Pebble.emit("ready");
718+ configBegins = messagesOfType(second, T.CONFIG_BEGIN);
719+ assert.equal(
720+ configBegins[configBegins.length - 1].KEY_STATUS,
721+ contracts.KEY_STATUS.INVALID
722+ );
723+ sendRequest(second, "same-key-after-interruption");
724+ assert.equal(second.xhr.instances.length, 0);
725+
726+ closeWith(second, configurationPayload("REPLACE", TEST_KEY));
727+ sendRequest(second, "same-key-after-success");
728+ assert.equal(second.xhr.instances.length, 1);
729+ assert.equal(second.xhr.instances[0].headers.Authorization, "Bearer " + TEST_KEY);
730+});
731+
732+test("deleting a favorite prunes its durable result without touching config atomicity", function () {
733+ var target = harness();
734+ var removeFavorite = configurationPayload("KEEP");
735+ var stored;
736+
737+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
738+ sendRequest(target, "cache-before-delete");
739+ target.xhr.instances[0].respond(200, responseFor("cache-before-delete"));
740+ removeFavorite.favorites = [];
741+ closeWith(target, removeFavorite);
742+ stored = JSON.parse(target.storage.getItem(configuration.RESULTS_STORAGE_KEY));
743+ assert.deepEqual(stored.results, []);
744+ assert.equal(
745+ JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY)).primApiKey,
746+ TEST_KEY
747+ );
748+});
386749 test("a late older response cannot overwrite or emit after the newest request for a favorite", function () {
387750 var target = harness();
388751 var older = responseFor("older");
@@ -3,6 +3,8 @@
3 var test = require("node:test");3 var test = require("node:test");
4 var assert = require("node:assert/strict");4 var assert = require("node:assert/strict");
5 var vm = require("node:vm");5 var vm = require("node:vm");
6+var path = require("node:path");
7+var url = require("node:url");
6 var companion = require("../src");8 var companion = require("../src");
7 var fixture = require("../../../fixtures/departures/foundation.json");9 var fixture = require("../../../fixtures/departures/foundation.json");
8 var fakes = require("./fakes");10 var fakes = require("./fakes");
@@ -14,13 +16,17 @@ var T = contracts.MESSAGE_TYPE;
14 var TEST_KEY = "test-personal-prim-key";16 var TEST_KEY = "test-personal-prim-key";
15 17
16 function harness(options) {18 function harness(options) {
17- var Pebble = new fakes.FakePebble(true);19+ var Pebble;
18- var storage = new fakes.FakeStorage();20+ var storage;
19- var clock = new fakes.FakeClock();21+ var clock;
20- var xhr = fakes.createXHRFactory();22+ var xhr;
21 var defer;23 var defer;
22 var instance;24 var instance;
23 options = options || {};25 options = options || {};
26+ Pebble = options.Pebble || new fakes.FakePebble(true);
27+ storage = options.storage || new fakes.FakeStorage();
28+ clock = options.clock || new fakes.FakeClock();
29+ xhr = fakes.createXHRFactory();
24 defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);30 defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);
25 instance = companion.createCompanion({31 instance = companion.createCompanion({
26 Pebble: Pebble,32 Pebble: Pebble,
@@ -58,12 +64,12 @@ function closeWith(target, payload) {
58 });64 });
59 }65 }
60 66
61-function sendRequest(target, requestId) {67+function sendRequest(target, requestId, trigger) {
62 target.Pebble.emit("appmessage", {68 target.Pebble.emit("appmessage", {
63 payload: codec.encodeRequest({69 payload: codec.encodeRequest({
64 requestId: requestId,70 requestId: requestId,
65 favoriteId: fixture.favorite.id,71 favoriteId: fixture.favorite.id,
66- trigger: contracts.REQUEST_TRIGGER.APP_OPEN72+ trigger: typeof trigger === "number" ? trigger : contracts.REQUEST_TRIGGER.APP_OPEN
67 })73 })
68 });74 });
69 }75 }
@@ -94,6 +100,17 @@ function responseFor(requestId) {
94 return result;100 return result;
95 }101 }
96 102
103+async function watchProtocol() {
104+ var protocolPath = path.resolve(__dirname, "../../watch/src/embeddedjs/protocol.js");
105+ return import(url.pathToFileURL(protocolPath).href);
106+}
107+
108+function watchMessage(message) {
109+ return new Map(Object.keys(message).map(function (key) {
110+ return [key, message[key]];
111+ }));
112+}
113+
97 test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {114 test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {
98 var target = harness();115 var target = harness();
99 var stored;116 var stored;
@@ -101,10 +118,11 @@ test("close fragment atomically stores one favorites-and-key record and exposes
101 var pageState;118 var pageState;
102 119
103 closeWith(target, configurationPayload("REPLACE", TEST_KEY));120 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
104- assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);121+ assert.deepEqual(target.storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
105 assert.equal(target.storage.writes.length, 1);122 assert.equal(target.storage.writes.length, 1);
106- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));123+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
107- assert.equal(stored.apiKey, TEST_KEY);124+ assert.equal(stored.primApiKey, TEST_KEY);
125+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.CONFIGURED);
108 assert.deepEqual(stored.favorites, [fixture.favorite]);126 assert.deepEqual(stored.favorites, [fixture.favorite]);
109 127
110 sentText = JSON.stringify(target.Pebble.sent);128 sentText = JSON.stringify(target.Pebble.sent);
@@ -144,12 +162,17 @@ test("a pre-presentation v1 record keeps its API key and legacy favorite on load
144 };162 };
145 var loaded;163 var loaded;
146 164
147- storage.setItem(configuration.STORAGE_KEY, JSON.stringify(legacyRecord));165+ storage.setItem(configuration.LEGACY_CONFIG_STORAGE_KEY, JSON.stringify(legacyRecord));
148 loaded = configuration.loadConfiguration(storage);166 loaded = configuration.loadConfiguration(storage);
149 167
150- assert.deepEqual(loaded, legacyRecord);168+ assert.equal(loaded.primApiKey, TEST_KEY);
151- assert.equal(loaded.apiKey, TEST_KEY);169+ assert.equal(loaded.keyStatus, contracts.KEY_STATUS.CONFIGURED);
152 assert.equal(loaded.favorites.length, 1);170 assert.equal(loaded.favorites.length, 1);
171+ assert.equal(
172+ JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY)).primApiKey,
173+ TEST_KEY
174+ );
175+ assert.deepEqual(storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
153 ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) {176 ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) {
154 assert.equal(Object.prototype.hasOwnProperty.call(loaded.favorites[0], field), false);177 assert.equal(Object.prototype.hasOwnProperty.call(loaded.favorites[0], field), false);
155 });178 });
@@ -176,16 +199,44 @@ test("KEEP, REPLACE, and REMOVE apply to one atomic phone-local record", functio
176 199
177 closeWith(target, configurationPayload("REPLACE", TEST_KEY));200 closeWith(target, configurationPayload("REPLACE", TEST_KEY));
178 closeWith(target, configurationPayload("KEEP"));201 closeWith(target, configurationPayload("KEEP"));
179- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));202+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
180- assert.equal(stored.apiKey, TEST_KEY);203+ assert.equal(stored.primApiKey, TEST_KEY);
181- assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);204+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.CONFIGURED);
205+ assert.deepEqual(target.storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
182 206
183 closeWith(target, configurationPayload("REMOVE"));207 closeWith(target, configurationPayload("REMOVE"));
184- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));208+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
185- assert.equal(stored.apiKey, null);209+ assert.equal(stored.primApiKey, null);
186- assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);210+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.MISSING);
211+ assert.deepEqual(target.storage.keys(), [configuration.CONFIG_STORAGE_KEY]);
187 assert.equal(target.storage.writes.length, 3);212 assert.equal(target.storage.writes.length, 3);
188 });213 });
214+
215+test("failed phone verification rolls back configuration bytes and watch state", function () {
216+ var target = harness();
217+ var beforeBytes;
218+ var beforeMessages;
219+ var originalSetItem;
220+ var failNext = false;
221+
222+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
223+ beforeBytes = target.storage.getItem(configuration.CONFIG_STORAGE_KEY);
224+ beforeMessages = target.Pebble.sent.length;
225+ originalSetItem = target.storage.setItem.bind(target.storage);
226+ target.storage.setItem = function (key, value) {
227+ originalSetItem(key, value);
228+ if (failNext && key === configuration.CONFIG_STORAGE_KEY) {
229+ failNext = false;
230+ target.storage.values[key] = "corrupt";
231+ }
232+ };
233+ failNext = true;
234+ closeWith(target, configurationPayload("REMOVE"));
235+ assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), beforeBytes);
236+ assert.equal(target.Pebble.sent.length, beforeMessages);
237+ sendRequest(target, "rollback-still-configured");
238+ assert.equal(target.xhr.instances.length, 1);
239+});
189 test("untrusted webview responses are bounded and cannot persist a key inside favorites", function () {240 test("untrusted webview responses are bounded and cannot persist a key inside favorites", function () {
190 var target = harness();241 var target = harness();
191 var replaceLeak = configurationPayload("REPLACE", TEST_KEY);242 var replaceLeak = configurationPayload("REPLACE", TEST_KEY);
@@ -211,9 +262,9 @@ test("untrusted webview responses are bounded and cannot persist a key inside fa
211 ];262 ];
212 closeWith(target, keepLeak);263 closeWith(target, keepLeak);
213 assert.equal(target.storage.writes.length, 1);264 assert.equal(target.storage.writes.length, 1);
214- stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));265+ stored = JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY));
215 assert.equal(JSON.stringify(stored.favorites).includes(TEST_KEY), false);266 assert.equal(JSON.stringify(stored.favorites).includes(TEST_KEY), false);
216- assert.equal(stored.apiKey, TEST_KEY);267+ assert.equal(stored.primApiKey, TEST_KEY);
217 });268 });
218 269
219 270
@@ -383,6 +434,318 @@ test("fresh results are rebound from cache for 60 seconds without a network time
383 assert.equal(target.xhr.instances.length, 2);434 assert.equal(target.xhr.instances.length, 2);
384 assert.equal(target.clock.delays.length, 0);435 assert.equal(target.clock.delays.length, 0);
385 });436 });
437+
438+test("selection replays by original fetched age and refreshes at exactly 60 seconds", function () {
439+ var target = harness();
440+ var aged = responseFor("aged-network");
441+ var before;
442+
443+ aged.fetchedAt = Math.floor(target.clock.now() / 1000) - 59;
444+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
445+ sendRequest(target, "aged-network");
446+ target.xhr.instances[0].respond(200, aged);
447+ target.clock.advance(1000);
448+ before = target.Pebble.sent.length;
449+ sendRequest(target, "selection-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
450+ assert.equal(
451+ target.Pebble.sent.length,
452+ before + fixture.result.departures.length + 3
453+ );
454+ assert.equal(target.xhr.instances.length, 2);
455+});
456+
457+test("stale cache commits before the same-request fresh result at the watch receiver", async function () {
458+ var protocol = await watchProtocol();
459+ var receiver = new protocol.ProtocolReceiver();
460+ var target = harness();
461+ var fresh = responseFor("cached-refresh");
462+ var before;
463+ var statuses;
464+
465+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
466+ sendRequest(target, "network-seed");
467+ target.xhr.instances[0].respond(200, responseFor("network-seed"));
468+ target.clock.advance(60000);
469+
470+ assert.equal(receiver.expectResponse("cached-refresh", fixture.favorite.id), true);
471+ before = target.Pebble.sent.length;
472+ sendRequest(target, "cached-refresh", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
473+ statuses = target.Pebble.sent.slice(before).map(function (message) {
474+ return receiver.receive(watchMessage(message));
475+ });
476+ assert.equal(statuses.filter(function (status) {
477+ return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;
478+ }).length, 1);
479+ assert.equal(receiver.snapshot().result.fetchedAt, fixture.result.fetchedAt);
480+
481+ fresh.fetchedAt += 60;
482+ before = target.Pebble.sent.length;
483+ target.xhr.instances[1].respond(200, fresh);
484+ statuses = target.Pebble.sent.slice(before).map(function (message) {
485+ return receiver.receive(watchMessage(message));
486+ });
487+ assert.equal(statuses.filter(function (status) {
488+ return status === protocol.RECEIVE_RESULT.RESULT_COMMITTED;
489+ }).length, 1);
490+ assert.equal(receiver.snapshot().result.fetchedAt, fresh.fetchedAt);
491+});
492+
493+test("future fetched timestamps cannot extend cache freshness past receipt time", function () {
494+ var target = harness();
495+ var future = responseFor("future-network");
496+
497+ future.fetchedAt = Math.floor(target.clock.now() / 1000) + 3600;
498+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
499+ sendRequest(target, "future-network");
500+ target.xhr.instances[0].respond(200, future);
501+
502+ target.clock.advance(60000);
503+ sendRequest(target, "future-expired", contracts.REQUEST_TRIGGER.FAVORITE_SELECTION);
504+ assert.equal(target.xhr.instances.length, 2);
505+});
506+
507+test("future storedAt after restart replays cache but cannot suppress refresh", function () {
508+ var storage = new fakes.FakeStorage();
509+ var firstClock = new fakes.FakeClock();
510+ var first = harness({ storage: storage, clock: firstClock });
511+ var future = responseFor("future-stored");
512+ var secondClock;
513+ var second;
514+ var begins;
515+
516+ future.fetchedAt = Math.floor(firstClock.now() / 1000) + 3600;
517+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
518+ sendRequest(first, "future-stored");
519+ first.xhr.instances[0].respond(200, future);
520+ first.companion.stop();
521+
522+ secondClock = new fakes.FakeClock(firstClock.now() - 1000);
523+ second = harness({ storage: storage, clock: secondClock });
524+ second.Pebble.emit("ready");
525+ sendRequest(second, "future-restart");
526+ assert.equal(second.xhr.instances.length, 1);
527+ begins = messagesOfType(second, T.RESULT_BEGIN);
528+ assert.equal(begins[begins.length - 1].REQUEST_ID, "future-restart");
529+ assert.equal(begins[begins.length - 1].FETCHED_AT, future.fetchedAt);
530+});
531+
532+test("a durable result replays across companion restart with original age and no key leak", function () {
533+ var storage = new fakes.FakeStorage();
534+ var clock = new fakes.FakeClock();
535+ var first = harness({ storage: storage, clock: clock });
536+ var second;
537+ var begins;
538+ var storedResults;
539+
540+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
541+ sendRequest(first, "first-request");
542+ first.xhr.instances[0].respond(200, responseFor("first-request"));
543+ storedResults = storage.getItem(configuration.RESULTS_STORAGE_KEY);
544+ assert.equal(typeof storedResults, "string");
545+ assert.equal(storedResults.includes(TEST_KEY), false);
546+
547+ first.companion.stop();
548+ clock.advance(1000);
549+ second = harness({ storage: storage, clock: clock });
550+ second.Pebble.emit("ready");
551+ sendRequest(second, "restart-request");
552+ assert.equal(second.xhr.instances.length, 0);
553+ begins = messagesOfType(second, T.RESULT_BEGIN);
554+ assert.equal(begins[begins.length - 1].REQUEST_ID, "restart-request");
555+ assert.equal(begins[begins.length - 1].FETCHED_AT, fixture.result.fetchedAt);
556+ assert.deepEqual(second.companion.metrics(), {
557+ requests: 0,
558+ cacheHits: 1,
559+ cacheMisses: 0,
560+ successes: 0,
561+ failures: 0,
562+ totalLatencyMs: 0,
563+ lastLatencyMs: 0
564+ });
565+});
566+
567+test("unknown configuration versions preserve stored bytes and prior watch state", function () {
568+ var storage = new fakes.FakeStorage();
569+ var bytes = JSON.stringify({ schemaVersion: 99, favorites: [] });
570+ var target;
571+
572+ storage.setItem(configuration.CONFIG_STORAGE_KEY, bytes);
573+ target = harness({ storage: storage });
574+ target.Pebble.emit("ready");
575+ assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), bytes);
576+ assert.equal(target.Pebble.sent.length, 0);
577+ assert.equal(target.xhr.instances.length, 0);
578+});
579+
580+test("invalid key status persists across restart and replacement re-enables XHR", function () {
581+ var storage = new fakes.FakeStorage();
582+ var first = harness({ storage: storage });
583+ var second;
584+ var stored;
585+
586+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
587+ sendRequest(first, "invalid-first");
588+ first.xhr.instances[0].respond(401, {
589+ schemaVersion: contracts.SCHEMA_VERSION,
590+ requestId: "invalid-first",
591+ favoriteId: fixture.favorite.id,
592+ code: "SOURCE_UNAVAILABLE",
593+ occurredAt: Math.floor(first.clock.now() / 1000)
594+ });
595+ stored = JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY));
596+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.INVALID);
597+ assert.equal(stored.primApiKey, TEST_KEY);
598+
599+ first.companion.stop();
600+ second = harness({ storage: storage });
601+ second.Pebble.emit("ready");
602+ sendRequest(second, "invalid-restart");
603+ assert.equal(second.xhr.instances.length, 0);
604+ closeWith(second, configurationPayload("REPLACE", "replacement-key"));
605+ sendRequest(second, "replacement-request");
606+ assert.equal(second.xhr.instances.length, 1);
607+ assert.equal(second.xhr.instances[0].headers.Authorization, "Bearer replacement-key");
608+});
609+
610+test("failed INVALID config write stays durable and fail-closed across reconstruction", function () {
611+ var storage = new fakes.FakeStorage();
612+ var first = harness({ storage: storage });
613+ var second;
614+ var beforeBytes;
615+ var marker;
616+ var configBegins;
617+ var originalSetItem;
618+ var failNext = false;
619+
620+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
621+ beforeBytes = storage.getItem(configuration.CONFIG_STORAGE_KEY);
622+ originalSetItem = storage.setItem.bind(storage);
623+ storage.setItem = function (key, value) {
624+ originalSetItem(key, value);
625+ if (failNext && key === configuration.CONFIG_STORAGE_KEY) {
626+ failNext = false;
627+ storage.values[key] = "corrupt";
628+ }
629+ };
630+ failNext = true;
631+ sendRequest(first, "invalid-write");
632+ first.xhr.instances[0].respond(401, "");
633+ assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), beforeBytes);
634+ marker = storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY);
635+ assert.equal(typeof marker, "string");
636+ assert.equal(marker.includes(TEST_KEY), false);
637+ configBegins = messagesOfType(first, T.CONFIG_BEGIN);
638+ assert.equal(
639+ configBegins[configBegins.length - 1].KEY_STATUS,
640+ contracts.KEY_STATUS.INVALID
641+ );
642+
643+ first.companion.stop();
644+ second = harness({ storage: storage });
645+ second.Pebble.emit("ready");
646+ configBegins = messagesOfType(second, T.CONFIG_BEGIN);
647+ assert.equal(
648+ configBegins[configBegins.length - 1].KEY_STATUS,
649+ contracts.KEY_STATUS.INVALID
650+ );
651+ sendRequest(second, "invalid-write-restart");
652+ assert.equal(second.xhr.instances.length, 0);
653+ closeWith(second, configurationPayload("KEEP"));
654+ sendRequest(second, "invalid-write-after-keep");
655+ assert.equal(second.xhr.instances.length, 0);
656+
657+ closeWith(second, configurationPayload("REPLACE", "replacement-key"));
658+ assert.equal(storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY), null);
659+ sendRequest(second, "invalid-write-after-replace");
660+ assert.equal(second.xhr.instances.length, 1);
661+ assert.equal(second.xhr.instances[0].headers.Authorization, "Bearer replacement-key");
662+});
663+
664+test("same-key replacement clear interruption remains durably INVALID", function () {
665+ var storage = new fakes.FakeStorage();
666+ var first = harness({ storage: storage });
667+ var second;
668+ var originalRemoveItem;
669+ var originalSetItem;
670+ var failConfigWrite = false;
671+ var failMarkerRemoval = false;
672+ var stored;
673+ var configBegins;
674+
675+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
676+ originalSetItem = storage.setItem.bind(storage);
677+ storage.setItem = function (key, value) {
678+ originalSetItem(key, value);
679+ if (failConfigWrite && key === configuration.CONFIG_STORAGE_KEY) {
680+ failConfigWrite = false;
681+ storage.values[key] = "corrupt";
682+ }
683+ };
684+ failConfigWrite = true;
685+ sendRequest(first, "marker-same-key");
686+ first.xhr.instances[0].respond(401, "");
687+ assert.equal(
688+ typeof storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY),
689+ "string"
690+ );
691+
692+ originalRemoveItem = storage.removeItem.bind(storage);
693+ storage.removeItem = function (key) {
694+ if (failMarkerRemoval && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) {
695+ failMarkerRemoval = false;
696+ throw new Error("simulated interruption");
697+ }
698+ originalRemoveItem(key);
699+ };
700+ failMarkerRemoval = true;
701+ closeWith(first, configurationPayload("REPLACE", TEST_KEY));
702+ stored = JSON.parse(storage.getItem(configuration.CONFIG_STORAGE_KEY));
703+ assert.equal(stored.primApiKey, TEST_KEY);
704+ assert.equal(stored.keyStatus, contracts.KEY_STATUS.INVALID);
705+ assert.equal(
706+ typeof storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY),
707+ "string"
708+ );
709+ configBegins = messagesOfType(first, T.CONFIG_BEGIN);
710+ assert.equal(
711+ configBegins[configBegins.length - 1].KEY_STATUS,
712+ contracts.KEY_STATUS.INVALID
713+ );
714+
715+ first.companion.stop();
716+ second = harness({ storage: storage });
717+ second.Pebble.emit("ready");
718+ configBegins = messagesOfType(second, T.CONFIG_BEGIN);
719+ assert.equal(
720+ configBegins[configBegins.length - 1].KEY_STATUS,
721+ contracts.KEY_STATUS.INVALID
722+ );
723+ sendRequest(second, "same-key-after-interruption");
724+ assert.equal(second.xhr.instances.length, 0);
725+
726+ closeWith(second, configurationPayload("REPLACE", TEST_KEY));
727+ sendRequest(second, "same-key-after-success");
728+ assert.equal(second.xhr.instances.length, 1);
729+ assert.equal(second.xhr.instances[0].headers.Authorization, "Bearer " + TEST_KEY);
730+});
731+
732+test("deleting a favorite prunes its durable result without touching config atomicity", function () {
733+ var target = harness();
734+ var removeFavorite = configurationPayload("KEEP");
735+ var stored;
736+
737+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
738+ sendRequest(target, "cache-before-delete");
739+ target.xhr.instances[0].respond(200, responseFor("cache-before-delete"));
740+ removeFavorite.favorites = [];
741+ closeWith(target, removeFavorite);
742+ stored = JSON.parse(target.storage.getItem(configuration.RESULTS_STORAGE_KEY));
743+ assert.deepEqual(stored.results, []);
744+ assert.equal(
745+ JSON.parse(target.storage.getItem(configuration.CONFIG_STORAGE_KEY)).primApiKey,
746+ TEST_KEY
747+ );
748+});
386 test("a late older response cannot overwrite or emit after the newest request for a favorite", function () {749 test("a late older response cannot overwrite or emit after the newest request for a favorite", function () {
387 var target = harness();750 var target = harness();
388 var older = responseFor("older");751 var older = responseFor("older");
modified packages/companion/test/fakes.js +4 -0
@@ -83,6 +83,10 @@ FakeStorage.prototype.setItem = function (key, value) {
8383 this.writes.push({ key: key, value: String(value) });
8484 };
8585
86+FakeStorage.prototype.removeItem = function (key) {
87+ delete this.values[key];
88+};
89+
8690 FakeStorage.prototype.keys = function () {
8791 return Object.keys(this.values);
8892 };
@@ -83,6 +83,10 @@ FakeStorage.prototype.setItem = function (key, value) {
83 this.writes.push({ key: key, value: String(value) });83 this.writes.push({ key: key, value: String(value) });
84 };84 };
85 85
86+FakeStorage.prototype.removeItem = function (key) {
87+ delete this.values[key];
88+};
89+
86 FakeStorage.prototype.keys = function () {90 FakeStorage.prototype.keys = function () {
87 return Object.keys(this.values);91 return Object.keys(this.values);
88 };92 };
modified packages/config-page/index.html +2 -0
@@ -91,6 +91,7 @@
9191 <h3>Privacy and data flow</h3>
9292 <p>This app page sets no cookies and uses no analytics or telemetry.</p>
9393 <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>
94+ <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p>
9495 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
9596 <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>
9697 <h3>Data and credits</h3>
@@ -112,6 +113,7 @@
112113 <h3>Vie privée et circulation des données</h3>
113114 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
114115 <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>
116+ <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p>
115117 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
116118 <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>
117119 <h3>Données et crédits</h3>
@@ -91,6 +91,7 @@
91 <h3>Privacy and data flow</h3>91 <h3>Privacy and data flow</h3>
92 <p>This app page sets no cookies and uses no analytics or telemetry.</p>92 <p>This app page sets no cookies and uses no analytics or telemetry.</p>
93 <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>93 <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>
94+ <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p>
94 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>95 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
95 <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>96 <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>
96 <h3>Data and credits</h3>97 <h3>Data and credits</h3>
@@ -112,6 +113,7 @@
112 <h3>Vie privée et circulation des données</h3>113 <h3>Vie privée et circulation des données</h3>
113 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>114 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
114 <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>115 <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>
116+ <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p>
115 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>117 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
116 <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>118 <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>
117 <h3>Données et crédits</h3>119 <h3>Données et crédits</h3>
modified packages/config-page/src/config-core.js +7 -4
@@ -46,7 +46,10 @@ export function utf8Bytes(value) {
4646 }
4747
4848 function boundedString(value, maximum) {
49- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
49+ return typeof value === "string"
50+ && utf8Bytes(value) >= 1
51+ && utf8Bytes(value) <= maximum
52+ && !/[\u0000-\u001f\u007f]/u.test(value);
5053 }
5154
5255 export function isLineColor(value) {
@@ -81,7 +84,7 @@ export const COPY = {
8184 keyRemovePending: "The saved key will be removed.",
8285 keyReplacementPending: "A replacement key is ready to save.",
8386 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
84- keyErrorNewline: "The key must not contain line breaks.",
87+ keyErrorNewline: "The key must not contain control characters.",
8588 favoritesTitle: "Favorite departures",
8689 favoritesEmpty: "No favorites yet. Search for a stop to add one.",
8790 favoriteAddTitle: "Add a favorite",
@@ -129,7 +132,7 @@ export const COPY = {
129132 keyRemovePending: "La clé enregistrée sera supprimée.",
130133 keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",
131134 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
132- keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",
135+ keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.",
133136 favoritesTitle: "Départs favoris",
134137 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
135138 favoriteAddTitle: "Ajouter un favori",
@@ -379,7 +382,7 @@ export function reduceConfigState(state, action) {
379382 // ApiKeyUpdate carries the value only for REPLACE.
380383
381384 export function apiKeyError(value) {
382- if (/[\r\n]/u.test(value)) return "keyErrorNewline";
385+ if (/[\u0000-\u001f\u007f]/u.test(value)) return "keyErrorNewline";
383386 if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";
384387 return null;
385388 }
@@ -46,7 +46,10 @@ export function utf8Bytes(value) {
46 }46 }
47 47
48 function boundedString(value, maximum) {48 function boundedString(value, maximum) {
49- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;49+ return typeof value === "string"
50+ && utf8Bytes(value) >= 1
51+ && utf8Bytes(value) <= maximum
52+ && !/[\u0000-\u001f\u007f]/u.test(value);
50 }53 }
51 54
52 export function isLineColor(value) {55 export function isLineColor(value) {
@@ -81,7 +84,7 @@ export const COPY = {
81 keyRemovePending: "The saved key will be removed.",84 keyRemovePending: "The saved key will be removed.",
82 keyReplacementPending: "A replacement key is ready to save.",85 keyReplacementPending: "A replacement key is ready to save.",
83 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",86 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
84- keyErrorNewline: "The key must not contain line breaks.",87+ keyErrorNewline: "The key must not contain control characters.",
85 favoritesTitle: "Favorite departures",88 favoritesTitle: "Favorite departures",
86 favoritesEmpty: "No favorites yet. Search for a stop to add one.",89 favoritesEmpty: "No favorites yet. Search for a stop to add one.",
87 favoriteAddTitle: "Add a favorite",90 favoriteAddTitle: "Add a favorite",
@@ -129,7 +132,7 @@ export const COPY = {
129 keyRemovePending: "La clé enregistrée sera supprimée.",132 keyRemovePending: "La clé enregistrée sera supprimée.",
130 keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",133 keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",
131 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",134 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
132- keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",135+ keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.",
133 favoritesTitle: "Départs favoris",136 favoritesTitle: "Départs favoris",
134 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",137 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
135 favoriteAddTitle: "Ajouter un favori",138 favoriteAddTitle: "Ajouter un favori",
@@ -379,7 +382,7 @@ export function reduceConfigState(state, action) {
379 // ApiKeyUpdate carries the value only for REPLACE.382 // ApiKeyUpdate carries the value only for REPLACE.
380 383
381 export function apiKeyError(value) {384 export function apiKeyError(value) {
382- if (/[\r\n]/u.test(value)) return "keyErrorNewline";385+ if (/[\u0000-\u001f\u007f]/u.test(value)) return "keyErrorNewline";
383 if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";386 if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";
384 return null;387 return null;
385 }388 }
modified packages/config-page/test/config-page.test.ts +5 -2
@@ -196,7 +196,7 @@ test("the config page consumes the companion-produced opening fragment exactly",
196196 const key = "stored-personal-key";
197197 const url = companionConfiguration.configurationUrl(
198198 "https://config.example.test/index.html",
199- { schemaVersion: 1, favorites: fixtureList, apiKey: key },
199+ { schemaVersion: 1, favorites: fixtureList, primApiKey: key, keyStatus: 1 },
200200 "fr_FR",
201201 );
202202 assert.equal(typeof url, "string");
@@ -286,7 +286,7 @@ test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => {
286286 assert.equal(isPersonalApiKey(replaceUpdate.value), true);
287287 });
288288
289-test("replace validation rejects oversized or line-bearing keys in both locales", () => {
289+test("replace validation rejects oversized or control-bearing keys in both locales", () => {
290290 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);
291291 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");
292292 assert.equal(apiKeyError("é".repeat(256)), null);
@@ -294,6 +294,7 @@ test("replace validation rejects oversized or line-bearing keys in both locales"
294294 assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");
295295 assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");
296296 assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");
297+ assert.equal(apiKeyError("key\u0001header"), "keyErrorNewline");
297298
298299 const tooLong = planConfigResult({
299300 hasKey: false,
@@ -566,6 +567,8 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
566567 "localStorage",
567568 "no analytics or telemetry",
568569 "aucun cookie",
570+ "survives app updates and uninstall/reinstall",
571+ "Désinstaller l’application de la montre",
569572 "Référentiel des lignes",
570573 "conditions d’utilisation IDFM/PRIM",
571574 "search terms are sent",
@@ -196,7 +196,7 @@ test("the config page consumes the companion-produced opening fragment exactly",
196 const key = "stored-personal-key";196 const key = "stored-personal-key";
197 const url = companionConfiguration.configurationUrl(197 const url = companionConfiguration.configurationUrl(
198 "https://config.example.test/index.html",198 "https://config.example.test/index.html",
199- { schemaVersion: 1, favorites: fixtureList, apiKey: key },199+ { schemaVersion: 1, favorites: fixtureList, primApiKey: key, keyStatus: 1 },
200 "fr_FR",200 "fr_FR",
201 );201 );
202 assert.equal(typeof url, "string");202 assert.equal(typeof url, "string");
@@ -286,7 +286,7 @@ test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => {
286 assert.equal(isPersonalApiKey(replaceUpdate.value), true);286 assert.equal(isPersonalApiKey(replaceUpdate.value), true);
287 });287 });
288 288
289-test("replace validation rejects oversized or line-bearing keys in both locales", () => {289+test("replace validation rejects oversized or control-bearing keys in both locales", () => {
290 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);290 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);
291 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");291 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");
292 assert.equal(apiKeyError("é".repeat(256)), null);292 assert.equal(apiKeyError("é".repeat(256)), null);
@@ -294,6 +294,7 @@ test("replace validation rejects oversized or line-bearing keys in both locales"
294 assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");294 assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");
295 assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");295 assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");
296 assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");296 assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");
297+ assert.equal(apiKeyError("key\u0001header"), "keyErrorNewline");
297 298
298 const tooLong = planConfigResult({299 const tooLong = planConfigResult({
299 hasKey: false,300 hasKey: false,
@@ -566,6 +567,8 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
566 "localStorage",567 "localStorage",
567 "no analytics or telemetry",568 "no analytics or telemetry",
568 "aucun cookie",569 "aucun cookie",
570+ "survives app updates and uninstall/reinstall",
571+ "Désinstaller l’application de la montre",
569 "Référentiel des lignes",572 "Référentiel des lignes",
570 "conditions d’utilisation IDFM/PRIM",573 "conditions d’utilisation IDFM/PRIM",
571 "search terms are sent",574 "search terms are sent",
modified packages/contracts/src/index.ts +4 -1
@@ -226,7 +226,10 @@ export function utf8Bytes(value: string): number {
226226 }
227227
228228 function boundedString(value: unknown, maximum: number): value is string {
229- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
229+ return typeof value === "string"
230+ && utf8Bytes(value) >= 1
231+ && utf8Bytes(value) <= maximum
232+ && !/[\u0000-\u001f\u007f]/u.test(value);
230233 }
231234
232235 export function isLineColor(value: unknown): value is string {
@@ -226,7 +226,10 @@ export function utf8Bytes(value: string): number {
226 }226 }
227 227
228 function boundedString(value: unknown, maximum: number): value is string {228 function boundedString(value: unknown, maximum: number): value is string {
229- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;229+ return typeof value === "string"
230+ && utf8Bytes(value) >= 1
231+ && utf8Bytes(value) <= maximum
232+ && !/[\u0000-\u001f\u007f]/u.test(value);
230 }233 }
231 234
232 export function isLineColor(value: unknown): value is string {235 export function isLineColor(value: unknown): value is string {
modified packages/contracts/test/contracts.test.ts +2 -0
@@ -95,6 +95,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
9595 assert.equal(isFavorite({ ...favorite, unknown: true }), false);
9696 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
9797 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
98+ assert.equal(isFavorite({ ...favorite, stopLabel: "unsafe\u0001label" }), false);
9899 assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);
99100 assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);
100101 assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);
@@ -137,6 +138,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
137138 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);
138139 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);
139140 assert.equal(isPersonalApiKey("key\nheader"), false);
141+ assert.equal(isPersonalApiKey("key\u0001header"), false);
140142 });
141143
142144 test("symbolic payload aliases retain the frozen explicit numeric order", () => {
@@ -95,6 +95,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
95 assert.equal(isFavorite({ ...favorite, unknown: true }), false);95 assert.equal(isFavorite({ ...favorite, unknown: true }), false);
96 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);96 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
97 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);97 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
98+ assert.equal(isFavorite({ ...favorite, stopLabel: "unsafe\u0001label" }), false);
98 assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);99 assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);
99 assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);100 assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);
100 assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);101 assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);
@@ -137,6 +138,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
137 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);138 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);
138 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);139 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);
139 assert.equal(isPersonalApiKey("key\nheader"), false);140 assert.equal(isPersonalApiKey("key\nheader"), false);
141+ assert.equal(isPersonalApiKey("key\u0001header"), false);
140 });142 });
141 143
142 test("symbolic payload aliases retain the frozen explicit numeric order", () => {144 test("symbolic payload aliases retain the frozen explicit numeric order", () => {
modified packages/watch/src/embeddedjs/contracts.js +1 -1
@@ -145,7 +145,7 @@ export function utf8Bytes(value) {
145145 export function boundedString(value, maximum) {
146146 if (typeof value !== "string") return false;
147147 const size = utf8Bytes(value);
148- return size >= 1 && size <= maximum;
148+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
149149 }
150150
151151 export function uint32(value) {
@@ -145,7 +145,7 @@ export function utf8Bytes(value) {
145 export function boundedString(value, maximum) {145 export function boundedString(value, maximum) {
146 if (typeof value !== "string") return false;146 if (typeof value !== "string") return false;
147 const size = utf8Bytes(value);147 const size = utf8Bytes(value);
148- return size >= 1 && size <= maximum;148+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
149 }149 }
150 150
151 export function uint32(value) {151 export function uint32(value) {
modified packages/watch/src/embeddedjs/controller.js +35 -3
@@ -1,5 +1,6 @@
11 import { QUEUE_STATE } from "./message-queue.js";
22 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3+import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
34
45 function eventName(received) {
56 if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";
@@ -24,9 +25,12 @@ export function createController(options) {
2425 const clock = options.clock;
2526 const queue = options.queue;
2627 const model = options.model;
28+ const storage = options.storage || null;
2729 const view = options.view;
2830 const receiver = new ProtocolReceiver();
31+ let committedConfiguration = null;
2932 let sequence = 0;
33+ let restoredRequestPending = false;
3034 let started = false;
3135 let closed = false;
3236
@@ -60,7 +64,16 @@ export function createController(options) {
6064 start() {
6165 if (closed || started) return controller;
6266 started = true;
63- render();
67+ const configuration = storage ? loadWatchConfiguration(storage) : null;
68+ if (configuration) {
69+ committedConfiguration = configuration;
70+ receiver.restoreConfiguration(configuration);
71+ model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");
72+ restoredRequestPending = true;
73+ render();
74+ } else {
75+ render();
76+ }
6477 return controller;
6578 },
6679
@@ -68,8 +81,21 @@ export function createController(options) {
6881 if (closed) return RECEIVE_RESULT.REJECTED;
6982 const received = receiver.receive(message);
7083 const event = eventName(received);
84+ if (event === "CONFIG_COMMITTED") restoredRequestPending = false;
7185 if (event !== null) {
72- const effect = model.commitProtocol(receiver.snapshot(), event);
86+ let snapshot = receiver.snapshot();
87+ if (event === "CONFIG_COMMITTED" && storage) {
88+ if (!saveWatchConfiguration(storage, snapshot.configuration)) {
89+ receiver.restoreConfiguration(committedConfiguration);
90+ snapshot = receiver.snapshot();
91+ const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92+ render();
93+ if (effect.requestFixture) requestFixture();
94+ return RECEIVE_RESULT.REJECTED;
95+ }
96+ committedConfiguration = snapshot.configuration;
97+ }
98+ const effect = model.commitProtocol(snapshot, event);
7399 render();
74100 if (effect.requestFixture) requestFixture();
75101 }
@@ -77,7 +103,13 @@ export function createController(options) {
77103 },
78104
79105 onQueueState(state) {
80- if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
106+ if (closed || !state) return;
107+ if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {
108+ restoredRequestPending = false;
109+ requestFixture();
110+ return;
111+ }
112+ if (state.type !== QUEUE_STATE.FAILED) return;
81113 receiver.cancelExpectedResponse();
82114 model.markSendFailure();
83115 render();
@@ -1,5 +1,6 @@
1 import { QUEUE_STATE } from "./message-queue.js";1 import { QUEUE_STATE } from "./message-queue.js";
2 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";2 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3+import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
3 4
4 function eventName(received) {5 function eventName(received) {
5 if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";6 if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";
@@ -24,9 +25,12 @@ export function createController(options) {
24 const clock = options.clock;25 const clock = options.clock;
25 const queue = options.queue;26 const queue = options.queue;
26 const model = options.model;27 const model = options.model;
28+ const storage = options.storage || null;
27 const view = options.view;29 const view = options.view;
28 const receiver = new ProtocolReceiver();30 const receiver = new ProtocolReceiver();
31+ let committedConfiguration = null;
29 let sequence = 0;32 let sequence = 0;
33+ let restoredRequestPending = false;
30 let started = false;34 let started = false;
31 let closed = false;35 let closed = false;
32 36
@@ -60,7 +64,16 @@ export function createController(options) {
60 start() {64 start() {
61 if (closed || started) return controller;65 if (closed || started) return controller;
62 started = true;66 started = true;
63- render();67+ const configuration = storage ? loadWatchConfiguration(storage) : null;
68+ if (configuration) {
69+ committedConfiguration = configuration;
70+ receiver.restoreConfiguration(configuration);
71+ model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");
72+ restoredRequestPending = true;
73+ render();
74+ } else {
75+ render();
76+ }
64 return controller;77 return controller;
65 },78 },
66 79
@@ -68,8 +81,21 @@ export function createController(options) {
68 if (closed) return RECEIVE_RESULT.REJECTED;81 if (closed) return RECEIVE_RESULT.REJECTED;
69 const received = receiver.receive(message);82 const received = receiver.receive(message);
70 const event = eventName(received);83 const event = eventName(received);
84+ if (event === "CONFIG_COMMITTED") restoredRequestPending = false;
71 if (event !== null) {85 if (event !== null) {
72- const effect = model.commitProtocol(receiver.snapshot(), event);86+ let snapshot = receiver.snapshot();
87+ if (event === "CONFIG_COMMITTED" && storage) {
88+ if (!saveWatchConfiguration(storage, snapshot.configuration)) {
89+ receiver.restoreConfiguration(committedConfiguration);
90+ snapshot = receiver.snapshot();
91+ const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92+ render();
93+ if (effect.requestFixture) requestFixture();
94+ return RECEIVE_RESULT.REJECTED;
95+ }
96+ committedConfiguration = snapshot.configuration;
97+ }
98+ const effect = model.commitProtocol(snapshot, event);
73 render();99 render();
74 if (effect.requestFixture) requestFixture();100 if (effect.requestFixture) requestFixture();
75 }101 }
@@ -77,7 +103,13 @@ export function createController(options) {
77 },103 },
78 104
79 onQueueState(state) {105 onQueueState(state) {
80- if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;106+ if (closed || !state) return;
107+ if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {
108+ restoredRequestPending = false;
109+ requestFixture();
110+ return;
111+ }
112+ if (state.type !== QUEUE_STATE.FAILED) return;
81 receiver.cancelExpectedResponse();113 receiver.cancelExpectedResponse();
82 model.markSendFailure();114 model.markSendFailure();
83 render();115 render();
modified packages/watch/src/embeddedjs/main.js +1 -0
@@ -45,6 +45,7 @@ controller = createController({
4545 clock: { now: Date.now },
4646 queue,
4747 model: new WatchModel(),
48+ storage: localStorage,
4849 view
4950 });
5051 controller.start();
@@ -45,6 +45,7 @@ controller = createController({
45 clock: { now: Date.now },45 clock: { now: Date.now },
46 queue,46 queue,
47 model: new WatchModel(),47 model: new WatchModel(),
48+ storage: localStorage,
48 view49 view
49 });50 });
50 controller.start();51 controller.start();
modified packages/watch/src/embeddedjs/manifest.json +1 -0
@@ -11,6 +11,7 @@
1111 "./protocol",
1212 "./message-queue",
1313 "./localization",
14+ "./storage",
1415 "./ui",
1516 "./contracts"
1617 ]
@@ -11,6 +11,7 @@
11 "./protocol",11 "./protocol",
12 "./message-queue",12 "./message-queue",
13 "./localization",13 "./localization",
14+ "./storage",
14 "./ui",15 "./ui",
15 "./contracts"16 "./contracts"
16 ]17 ]
modified packages/watch/src/embeddedjs/protocol.js +38 -3
@@ -262,25 +262,37 @@ function copyError(error) {
262262 }
263263
264264 export class ProtocolReceiver {
265- constructor() {
265+ constructor(initialConfiguration = null) {
266266 this.configuration = null;
267267 this.result = null;
268268 this.error = null;
269269 this.configStage = null;
270270 this.resultStage = null;
271271 this.expected = null;
272+ this.refreshCandidate = null;
273+ if (initialConfiguration) this.restoreConfiguration(initialConfiguration);
274+ }
275+
276+ restoreConfiguration(configuration) {
277+ this.configuration = copyConfiguration(configuration);
278+ this.result = null;
279+ this.error = null;
280+ this.discardStaging();
281+ this.cancelExpectedResponse();
272282 }
273283
274284 expectResponse(requestId, favoriteId) {
275285 if (!boundedString(requestId, LIMITS.idUtf8Bytes)
276286 || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
277- this.expected = { requestId, favoriteId };
287+ this.expected = { requestId, favoriteId, refresh: false };
288+ this.refreshCandidate = null;
278289 this.resultStage = null;
279290 return true;
280291 }
281292
282293 cancelExpectedResponse() {
283294 this.expected = null;
295+ this.refreshCandidate = null;
284296 this.resultStage = null;
285297 }
286298
@@ -366,6 +378,24 @@ export class ProtocolReceiver {
366378 return RECEIVE_RESULT.CONFIG_COMMITTED;
367379 }
368380
381+ if (type === MESSAGE_TYPE.REQUEST) {
382+ const candidate = this.refreshCandidate;
383+ if (this.expected
384+ || !candidate
385+ || candidate.requestId !== requestId
386+ || candidate.favoriteId !== decoded.FAVORITE_ID) {
387+ return RECEIVE_RESULT.REJECTED;
388+ }
389+ this.expected = {
390+ requestId,
391+ favoriteId: decoded.FAVORITE_ID,
392+ refresh: true
393+ };
394+ this.refreshCandidate = null;
395+ this.resultStage = null;
396+ return RECEIVE_RESULT.STAGED;
397+ }
398+
369399 if (type === MESSAGE_TYPE.RESULT_BEGIN) {
370400 if (!this.expected
371401 || this.expected.requestId !== requestId
@@ -423,9 +453,14 @@ export class ProtocolReceiver {
423453 this.resultStage = null;
424454 return RECEIVE_RESULT.REJECTED;
425455 }
456+ const refresh = this.expected.refresh;
426457 this.result = copyResult(stage);
427458 this.error = null;
428- this.cancelExpectedResponse();
459+ this.expected = null;
460+ this.resultStage = null;
461+ this.refreshCandidate = refresh
462+ ? null
463+ : { requestId, favoriteId: decoded.FAVORITE_ID };
429464 return RECEIVE_RESULT.RESULT_COMMITTED;
430465 }
431466
@@ -262,25 +262,37 @@ function copyError(error) {
262 }262 }
263 263
264 export class ProtocolReceiver {264 export class ProtocolReceiver {
265- constructor() {265+ constructor(initialConfiguration = null) {
266 this.configuration = null;266 this.configuration = null;
267 this.result = null;267 this.result = null;
268 this.error = null;268 this.error = null;
269 this.configStage = null;269 this.configStage = null;
270 this.resultStage = null;270 this.resultStage = null;
271 this.expected = null;271 this.expected = null;
272+ this.refreshCandidate = null;
273+ if (initialConfiguration) this.restoreConfiguration(initialConfiguration);
274+ }
275+
276+ restoreConfiguration(configuration) {
277+ this.configuration = copyConfiguration(configuration);
278+ this.result = null;
279+ this.error = null;
280+ this.discardStaging();
281+ this.cancelExpectedResponse();
272 }282 }
273 283
274 expectResponse(requestId, favoriteId) {284 expectResponse(requestId, favoriteId) {
275 if (!boundedString(requestId, LIMITS.idUtf8Bytes)285 if (!boundedString(requestId, LIMITS.idUtf8Bytes)
276 || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;286 || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
277- this.expected = { requestId, favoriteId };287+ this.expected = { requestId, favoriteId, refresh: false };
288+ this.refreshCandidate = null;
278 this.resultStage = null;289 this.resultStage = null;
279 return true;290 return true;
280 }291 }
281 292
282 cancelExpectedResponse() {293 cancelExpectedResponse() {
283 this.expected = null;294 this.expected = null;
295+ this.refreshCandidate = null;
284 this.resultStage = null;296 this.resultStage = null;
285 }297 }
286 298
@@ -366,6 +378,24 @@ export class ProtocolReceiver {
366 return RECEIVE_RESULT.CONFIG_COMMITTED;378 return RECEIVE_RESULT.CONFIG_COMMITTED;
367 }379 }
368 380
381+ if (type === MESSAGE_TYPE.REQUEST) {
382+ const candidate = this.refreshCandidate;
383+ if (this.expected
384+ || !candidate
385+ || candidate.requestId !== requestId
386+ || candidate.favoriteId !== decoded.FAVORITE_ID) {
387+ return RECEIVE_RESULT.REJECTED;
388+ }
389+ this.expected = {
390+ requestId,
391+ favoriteId: decoded.FAVORITE_ID,
392+ refresh: true
393+ };
394+ this.refreshCandidate = null;
395+ this.resultStage = null;
396+ return RECEIVE_RESULT.STAGED;
397+ }
398+
369 if (type === MESSAGE_TYPE.RESULT_BEGIN) {399 if (type === MESSAGE_TYPE.RESULT_BEGIN) {
370 if (!this.expected400 if (!this.expected
371 || this.expected.requestId !== requestId401 || this.expected.requestId !== requestId
@@ -423,9 +453,14 @@ export class ProtocolReceiver {
423 this.resultStage = null;453 this.resultStage = null;
424 return RECEIVE_RESULT.REJECTED;454 return RECEIVE_RESULT.REJECTED;
425 }455 }
456+ const refresh = this.expected.refresh;
426 this.result = copyResult(stage);457 this.result = copyResult(stage);
427 this.error = null;458 this.error = null;
428- this.cancelExpectedResponse();459+ this.expected = null;
460+ this.resultStage = null;
461+ this.refreshCandidate = refresh
462+ ? null
463+ : { requestId, favoriteId: decoded.FAVORITE_ID };
429 return RECEIVE_RESULT.RESULT_COMMITTED;464 return RECEIVE_RESULT.RESULT_COMMITTED;
430 }465 }
431 466
added packages/watch/src/embeddedjs/storage.js +251 -0
new file mode 100644
@@ -0,0 +1,251 @@
1+import {
2+ KEY_STATUS,
3+ LIMITS,
4+ SCHEMA_VERSION,
5+ boundedString,
6+ enumHasValue,
7+ isWireLanguage,
8+ utf8Bytes
9+} from "./contracts.js";
10+
11+export const WATCH_CONFIGURATION_KEY = "lapinFuteWatchConfig";
12+export const WATCH_CONFIGURATION_MAX_BYTES = 8192;
13+
14+const STORAGE_FORMAT = "LFW1";
15+const STORAGE_SEPARATOR = "\u001f";
16+const CONFIGURATION_KEYS = Object.freeze(["keyStatus", "language", "favorites"]);
17+const FAVORITE_KEYS = Object.freeze([
18+ "id",
19+ "serviceId",
20+ "stopLabel",
21+ "lineLabel",
22+ "destinationLabel",
23+ "sortOrder"
24+]);
25+
26+function exactKeys(value, required, optional = []) {
27+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
28+ const keys = Object.keys(value);
29+ if (keys.length < required.length || keys.length > required.length + optional.length) {
30+ return false;
31+ }
32+ for (let index = 0; index < required.length; index += 1) {
33+ if (!Object.prototype.hasOwnProperty.call(value, required[index])) return false;
34+ }
35+ for (let index = 0; index < keys.length; index += 1) {
36+ if (required.indexOf(keys[index]) === -1 && optional.indexOf(keys[index]) === -1) {
37+ return false;
38+ }
39+ }
40+ return true;
41+}
42+
43+function validInteger(value) {
44+ return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
45+}
46+
47+function validFavorite(favorite) {
48+ return exactKeys(favorite, FAVORITE_KEYS, ["displayName"])
49+ && boundedString(favorite.id, LIMITS.idUtf8Bytes)
50+ && boundedString(favorite.serviceId, LIMITS.idUtf8Bytes)
51+ && boundedString(favorite.stopLabel, LIMITS.labelUtf8Bytes)
52+ && boundedString(favorite.lineLabel, LIMITS.labelUtf8Bytes)
53+ && boundedString(favorite.destinationLabel, LIMITS.labelUtf8Bytes)
54+ && (favorite.displayName === undefined
55+ || boundedString(favorite.displayName, LIMITS.labelUtf8Bytes))
56+ && validInteger(favorite.sortOrder)
57+ && favorite.sortOrder >= 0
58+ && favorite.sortOrder < LIMITS.favorites;
59+}
60+
61+function validFavorites(favorites) {
62+ if (!Array.isArray(favorites) || favorites.length > LIMITS.favorites) return false;
63+ const ids = [];
64+ for (let index = 0; index < favorites.length; index += 1) {
65+ const favorite = favorites[index];
66+ if (!validFavorite(favorite) || ids.indexOf(favorite.id) !== -1) return false;
67+ ids.push(favorite.id);
68+ }
69+ return true;
70+}
71+
72+function validConfiguration(configuration) {
73+ return exactKeys(configuration, CONFIGURATION_KEYS)
74+ && enumHasValue(KEY_STATUS, configuration.keyStatus)
75+ && isWireLanguage(configuration.language)
76+ && validFavorites(configuration.favorites);
77+}
78+
79+function copyFavorite(favorite) {
80+ const copy = {
81+ id: favorite.id,
82+ serviceId: favorite.serviceId,
83+ stopLabel: favorite.stopLabel,
84+ lineLabel: favorite.lineLabel,
85+ destinationLabel: favorite.destinationLabel,
86+ sortOrder: favorite.sortOrder
87+ };
88+ if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
89+ return copy;
90+}
91+
92+function copyConfiguration(configuration) {
93+ return {
94+ keyStatus: configuration.keyStatus,
95+ language: configuration.language,
96+ favorites: configuration.favorites.map(copyFavorite)
97+ };
98+}
99+
100+function appendToken(parts, value) {
101+ parts.push(String(value), STORAGE_SEPARATOR);
102+}
103+
104+function appendString(parts, value) {
105+ if (value === undefined) {
106+ appendToken(parts, "-");
107+ return;
108+ }
109+ appendToken(parts, value.length);
110+ parts.push(value);
111+}
112+
113+export function serializeWatchConfiguration(configuration) {
114+ if (!validConfiguration(configuration)) return null;
115+ const parts = [];
116+ appendToken(parts, STORAGE_FORMAT);
117+ appendToken(parts, SCHEMA_VERSION);
118+ appendToken(parts, configuration.keyStatus);
119+ appendToken(parts, configuration.language);
120+ appendToken(parts, configuration.favorites.length);
121+ for (let index = 0; index < configuration.favorites.length; index += 1) {
122+ const favorite = configuration.favorites[index];
123+ appendToken(parts, favorite.sortOrder);
124+ appendString(parts, favorite.id);
125+ appendString(parts, favorite.serviceId);
126+ appendString(parts, favorite.displayName);
127+ appendString(parts, favorite.stopLabel);
128+ appendString(parts, favorite.lineLabel);
129+ appendString(parts, favorite.destinationLabel);
130+ }
131+ const serialized = parts.join("");
132+ return utf8Bytes(serialized) <= WATCH_CONFIGURATION_MAX_BYTES ? serialized : null;
133+}
134+
135+function readToken(state) {
136+ if (!state.valid) return null;
137+ const end = state.serialized.indexOf(STORAGE_SEPARATOR, state.offset);
138+ if (end === -1) {
139+ state.valid = false;
140+ return null;
141+ }
142+ const token = state.serialized.slice(state.offset, end);
143+ state.offset = end + 1;
144+ return token;
145+}
146+
147+function readUnsigned(state) {
148+ const token = readToken(state);
149+ if (token === null || !/^(0|[1-9][0-9]*)$/u.test(token)) {
150+ state.valid = false;
151+ return null;
152+ }
153+ const value = Number(token);
154+ if (!validInteger(value) || value < 0) {
155+ state.valid = false;
156+ return null;
157+ }
158+ return value;
159+}
160+
161+function readString(state, optional = false) {
162+ const token = readToken(state);
163+ if (optional && token === "-") return undefined;
164+ if (token === null || !/^(0|[1-9][0-9]*)$/u.test(token)) {
165+ state.valid = false;
166+ return null;
167+ }
168+ const length = Number(token);
169+ const end = state.offset + length;
170+ if (!validInteger(length) || length < 0 || end > state.serialized.length) {
171+ state.valid = false;
172+ return null;
173+ }
174+ const value = state.serialized.slice(state.offset, end);
175+ state.offset = end;
176+ return value;
177+}
178+
179+export function deserializeWatchConfiguration(serialized) {
180+ if (typeof serialized !== "string"
181+ || utf8Bytes(serialized) > WATCH_CONFIGURATION_MAX_BYTES) return null;
182+ const state = { serialized, offset: 0, valid: true };
183+ if (readToken(state) !== STORAGE_FORMAT || readUnsigned(state) !== SCHEMA_VERSION) return null;
184+ const keyStatus = readUnsigned(state);
185+ const language = readToken(state);
186+ const count = readUnsigned(state);
187+ if (!state.valid || count === null || count > LIMITS.favorites) return null;
188+ const configuration = { keyStatus, language, favorites: [] };
189+ for (let index = 0; index < count; index += 1) {
190+ const favorite = {
191+ sortOrder: readUnsigned(state),
192+ id: readString(state),
193+ serviceId: readString(state)
194+ };
195+ const displayName = readString(state, true);
196+ if (displayName !== undefined) favorite.displayName = displayName;
197+ favorite.stopLabel = readString(state);
198+ favorite.lineLabel = readString(state);
199+ favorite.destinationLabel = readString(state);
200+ configuration.favorites.push(favorite);
201+ }
202+ if (!state.valid || state.offset !== serialized.length
203+ || !validConfiguration(configuration)) return null;
204+ return copyConfiguration(configuration);
205+}
206+
207+
208+function canRead(storage) {
209+ return storage && typeof storage.getItem === "function";
210+}
211+
212+function canWrite(storage) {
213+ return canRead(storage)
214+ && typeof storage.setItem === "function"
215+ && typeof storage.removeItem === "function";
216+}
217+
218+export function loadWatchConfiguration(storage) {
219+ if (!canRead(storage)) return null;
220+ try {
221+ return deserializeWatchConfiguration(storage.getItem(WATCH_CONFIGURATION_KEY));
222+ } catch (_) {
223+ return null;
224+ }
225+}
226+
227+export function saveWatchConfiguration(storage, configuration) {
228+ if (!canWrite(storage)) return false;
229+
230+ let previous = null;
231+ let writeAttempted = false;
232+ try {
233+ const serialized = serializeWatchConfiguration(configuration);
234+ if (serialized === null) return false;
235+ previous = storage.getItem(WATCH_CONFIGURATION_KEY);
236+ writeAttempted = true;
237+ storage.setItem(WATCH_CONFIGURATION_KEY, serialized);
238+ if (storage.getItem(WATCH_CONFIGURATION_KEY) === serialized) return true;
239+ } catch (_) {
240+ // Restore the prior bytes below when the adapter is still writable.
241+ }
242+
243+ if (!writeAttempted) return false;
244+ try {
245+ if (previous === null) storage.removeItem(WATCH_CONFIGURATION_KEY);
246+ else storage.setItem(WATCH_CONFIGURATION_KEY, previous);
247+ } catch (_) {
248+ // The caller still rolls back its in-memory configuration.
249+ }
250+ return false;
251+}
new file mode 100644
@@ -0,0 +1,251 @@
1+import {
2+ KEY_STATUS,
3+ LIMITS,
4+ SCHEMA_VERSION,
5+ boundedString,
6+ enumHasValue,
7+ isWireLanguage,
8+ utf8Bytes
9+} from "./contracts.js";
10+
11+export const WATCH_CONFIGURATION_KEY = "lapinFuteWatchConfig";
12+export const WATCH_CONFIGURATION_MAX_BYTES = 8192;
13+
14+const STORAGE_FORMAT = "LFW1";
15+const STORAGE_SEPARATOR = "\u001f";
16+const CONFIGURATION_KEYS = Object.freeze(["keyStatus", "language", "favorites"]);
17+const FAVORITE_KEYS = Object.freeze([
18+ "id",
19+ "serviceId",
20+ "stopLabel",
21+ "lineLabel",
22+ "destinationLabel",
23+ "sortOrder"
24+]);
25+
26+function exactKeys(value, required, optional = []) {
27+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
28+ const keys = Object.keys(value);
29+ if (keys.length < required.length || keys.length > required.length + optional.length) {
30+ return false;
31+ }
32+ for (let index = 0; index < required.length; index += 1) {
33+ if (!Object.prototype.hasOwnProperty.call(value, required[index])) return false;
34+ }
35+ for (let index = 0; index < keys.length; index += 1) {
36+ if (required.indexOf(keys[index]) === -1 && optional.indexOf(keys[index]) === -1) {
37+ return false;
38+ }
39+ }
40+ return true;
41+}
42+
43+function validInteger(value) {
44+ return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
45+}
46+
47+function validFavorite(favorite) {
48+ return exactKeys(favorite, FAVORITE_KEYS, ["displayName"])
49+ && boundedString(favorite.id, LIMITS.idUtf8Bytes)
50+ && boundedString(favorite.serviceId, LIMITS.idUtf8Bytes)
51+ && boundedString(favorite.stopLabel, LIMITS.labelUtf8Bytes)
52+ && boundedString(favorite.lineLabel, LIMITS.labelUtf8Bytes)
53+ && boundedString(favorite.destinationLabel, LIMITS.labelUtf8Bytes)
54+ && (favorite.displayName === undefined
55+ || boundedString(favorite.displayName, LIMITS.labelUtf8Bytes))
56+ && validInteger(favorite.sortOrder)
57+ && favorite.sortOrder >= 0
58+ && favorite.sortOrder < LIMITS.favorites;
59+}
60+
61+function validFavorites(favorites) {
62+ if (!Array.isArray(favorites) || favorites.length > LIMITS.favorites) return false;
63+ const ids = [];
64+ for (let index = 0; index < favorites.length; index += 1) {
65+ const favorite = favorites[index];
66+ if (!validFavorite(favorite) || ids.indexOf(favorite.id) !== -1) return false;
67+ ids.push(favorite.id);
68+ }
69+ return true;
70+}
71+
72+function validConfiguration(configuration) {
73+ return exactKeys(configuration, CONFIGURATION_KEYS)
74+ && enumHasValue(KEY_STATUS, configuration.keyStatus)
75+ && isWireLanguage(configuration.language)
76+ && validFavorites(configuration.favorites);
77+}
78+
79+function copyFavorite(favorite) {
80+ const copy = {
81+ id: favorite.id,
82+ serviceId: favorite.serviceId,
83+ stopLabel: favorite.stopLabel,
84+ lineLabel: favorite.lineLabel,
85+ destinationLabel: favorite.destinationLabel,
86+ sortOrder: favorite.sortOrder
87+ };
88+ if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
89+ return copy;
90+}
91+
92+function copyConfiguration(configuration) {
93+ return {
94+ keyStatus: configuration.keyStatus,
95+ language: configuration.language,
96+ favorites: configuration.favorites.map(copyFavorite)
97+ };
98+}
99+
100+function appendToken(parts, value) {
101+ parts.push(String(value), STORAGE_SEPARATOR);
102+}
103+
104+function appendString(parts, value) {
105+ if (value === undefined) {
106+ appendToken(parts, "-");
107+ return;
108+ }
109+ appendToken(parts, value.length);
110+ parts.push(value);
111+}
112+
113+export function serializeWatchConfiguration(configuration) {
114+ if (!validConfiguration(configuration)) return null;
115+ const parts = [];
116+ appendToken(parts, STORAGE_FORMAT);
117+ appendToken(parts, SCHEMA_VERSION);
118+ appendToken(parts, configuration.keyStatus);
119+ appendToken(parts, configuration.language);
120+ appendToken(parts, configuration.favorites.length);
121+ for (let index = 0; index < configuration.favorites.length; index += 1) {
122+ const favorite = configuration.favorites[index];
123+ appendToken(parts, favorite.sortOrder);
124+ appendString(parts, favorite.id);
125+ appendString(parts, favorite.serviceId);
126+ appendString(parts, favorite.displayName);
127+ appendString(parts, favorite.stopLabel);
128+ appendString(parts, favorite.lineLabel);
129+ appendString(parts, favorite.destinationLabel);
130+ }
131+ const serialized = parts.join("");
132+ return utf8Bytes(serialized) <= WATCH_CONFIGURATION_MAX_BYTES ? serialized : null;
133+}
134+
135+function readToken(state) {
136+ if (!state.valid) return null;
137+ const end = state.serialized.indexOf(STORAGE_SEPARATOR, state.offset);
138+ if (end === -1) {
139+ state.valid = false;
140+ return null;
141+ }
142+ const token = state.serialized.slice(state.offset, end);
143+ state.offset = end + 1;
144+ return token;
145+}
146+
147+function readUnsigned(state) {
148+ const token = readToken(state);
149+ if (token === null || !/^(0|[1-9][0-9]*)$/u.test(token)) {
150+ state.valid = false;
151+ return null;
152+ }
153+ const value = Number(token);
154+ if (!validInteger(value) || value < 0) {
155+ state.valid = false;
156+ return null;
157+ }
158+ return value;
159+}
160+
161+function readString(state, optional = false) {
162+ const token = readToken(state);
163+ if (optional && token === "-") return undefined;
164+ if (token === null || !/^(0|[1-9][0-9]*)$/u.test(token)) {
165+ state.valid = false;
166+ return null;
167+ }
168+ const length = Number(token);
169+ const end = state.offset + length;
170+ if (!validInteger(length) || length < 0 || end > state.serialized.length) {
171+ state.valid = false;
172+ return null;
173+ }
174+ const value = state.serialized.slice(state.offset, end);
175+ state.offset = end;
176+ return value;
177+}
178+
179+export function deserializeWatchConfiguration(serialized) {
180+ if (typeof serialized !== "string"
181+ || utf8Bytes(serialized) > WATCH_CONFIGURATION_MAX_BYTES) return null;
182+ const state = { serialized, offset: 0, valid: true };
183+ if (readToken(state) !== STORAGE_FORMAT || readUnsigned(state) !== SCHEMA_VERSION) return null;
184+ const keyStatus = readUnsigned(state);
185+ const language = readToken(state);
186+ const count = readUnsigned(state);
187+ if (!state.valid || count === null || count > LIMITS.favorites) return null;
188+ const configuration = { keyStatus, language, favorites: [] };
189+ for (let index = 0; index < count; index += 1) {
190+ const favorite = {
191+ sortOrder: readUnsigned(state),
192+ id: readString(state),
193+ serviceId: readString(state)
194+ };
195+ const displayName = readString(state, true);
196+ if (displayName !== undefined) favorite.displayName = displayName;
197+ favorite.stopLabel = readString(state);
198+ favorite.lineLabel = readString(state);
199+ favorite.destinationLabel = readString(state);
200+ configuration.favorites.push(favorite);
201+ }
202+ if (!state.valid || state.offset !== serialized.length
203+ || !validConfiguration(configuration)) return null;
204+ return copyConfiguration(configuration);
205+}
206+
207+
208+function canRead(storage) {
209+ return storage && typeof storage.getItem === "function";
210+}
211+
212+function canWrite(storage) {
213+ return canRead(storage)
214+ && typeof storage.setItem === "function"
215+ && typeof storage.removeItem === "function";
216+}
217+
218+export function loadWatchConfiguration(storage) {
219+ if (!canRead(storage)) return null;
220+ try {
221+ return deserializeWatchConfiguration(storage.getItem(WATCH_CONFIGURATION_KEY));
222+ } catch (_) {
223+ return null;
224+ }
225+}
226+
227+export function saveWatchConfiguration(storage, configuration) {
228+ if (!canWrite(storage)) return false;
229+
230+ let previous = null;
231+ let writeAttempted = false;
232+ try {
233+ const serialized = serializeWatchConfiguration(configuration);
234+ if (serialized === null) return false;
235+ previous = storage.getItem(WATCH_CONFIGURATION_KEY);
236+ writeAttempted = true;
237+ storage.setItem(WATCH_CONFIGURATION_KEY, serialized);
238+ if (storage.getItem(WATCH_CONFIGURATION_KEY) === serialized) return true;
239+ } catch (_) {
240+ // Restore the prior bytes below when the adapter is still writable.
241+ }
242+
243+ if (!writeAttempted) return false;
244+ try {
245+ if (previous === null) storage.removeItem(WATCH_CONFIGURATION_KEY);
246+ else storage.setItem(WATCH_CONFIGURATION_KEY, previous);
247+ } catch (_) {
248+ // The caller still rolls back its in-memory configuration.
249+ }
250+ return false;
251+}
added packages/watch/test/controller-storage.test.js +199 -0
new file mode 100644
@@ -0,0 +1,199 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ KEY_STATUS,
5+ MESSAGE_TYPE,
6+ SCHEMA_VERSION
7+} from "../src/embeddedjs/contracts.js";
8+import { createController } from "../src/embeddedjs/controller.js";
9+import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
10+import WatchModel from "../src/embeddedjs/model.js";
11+import {
12+ WATCH_CONFIGURATION_KEY,
13+ deserializeWatchConfiguration,
14+ serializeWatchConfiguration
15+} from "../src/embeddedjs/storage.js";
16+
17+const OLD_FAVORITE = {
18+ id: "home",
19+ serviceId: "opaque:service:home",
20+ displayName: "Maison",
21+ stopLabel: "Châtelet",
22+ lineLabel: "Métro 1",
23+ destinationLabel: "La Défense",
24+ sortOrder: 0
25+};
26+const NEW_FAVORITES = [{
27+ id: "work",
28+ serviceId: "opaque:service:work",
29+ displayName: "Travail",
30+ stopLabel: "Nation",
31+ lineLabel: "RER A",
32+ destinationLabel: "Cergy",
33+ sortOrder: 0
34+}, {
35+ id: "gym",
36+ serviceId: "opaque:service:gym",
37+ stopLabel: "République",
38+ lineLabel: "Métro 9",
39+ destinationLabel: "Pont de Sèvres",
40+ sortOrder: 1
41+}];
42+
43+class StringStorage {
44+ constructor(value) {
45+ this.value = value;
46+ this.failNextSet = false;
47+ }
48+
49+ getItem(key) {
50+ return key === WATCH_CONFIGURATION_KEY ? this.value : null;
51+ }
52+
53+ setItem(key, value) {
54+ if (key !== WATCH_CONFIGURATION_KEY) return;
55+ if (this.failNextSet) {
56+ this.failNextSet = false;
57+ throw new Error("write failed");
58+ }
59+ this.value = value;
60+ }
61+
62+ removeItem(key) {
63+ if (key === WATCH_CONFIGURATION_KEY) this.value = null;
64+ }
65+}
66+
67+class Queue {
68+ constructor() {
69+ this.messages = [];
70+ }
71+
72+ enqueue(message) {
73+ this.messages.push(message);
74+ return true;
75+ }
76+
77+ close() {}
78+}
79+
80+class View {
81+ constructor() {
82+ this.renders = [];
83+ }
84+
85+ render(snapshot) {
86+ this.renders.push(snapshot);
87+ }
88+
89+ close() {}
90+}
91+
92+function stored(favorites = [OLD_FAVORITE], language = "fr") {
93+ return serializeWatchConfiguration({
94+ keyStatus: KEY_STATUS.CONFIGURED,
95+ language,
96+ favorites
97+ });
98+}
99+
100+function wire(type, entries = []) {
101+ return new Map([
102+ ["SCHEMA_VERSION", SCHEMA_VERSION],
103+ ["MESSAGE_TYPE", type],
104+ ...entries
105+ ]);
106+}
107+
108+function configurationMessages(id, favorites = NEW_FAVORITES, language = "en") {
109+ return [
110+ wire(MESSAGE_TYPE.CONFIG_BEGIN, [
111+ ["REQUEST_ID", id],
112+ ["ITEM_COUNT", favorites.length],
113+ ["KEY_STATUS", KEY_STATUS.CONFIGURED],
114+ ["DISPLAY_NAME", language]
115+ ]),
116+ ...favorites.map((favorite, index) => wire(MESSAGE_TYPE.FAVORITE, [
117+ ["REQUEST_ID", id],
118+ ["ITEM_INDEX", index],
119+ ["FAVORITE_ID", favorite.id],
120+ ["SERVICE_ID", favorite.serviceId],
121+ ...(favorite.displayName === undefined ? [] : [["DISPLAY_NAME", favorite.displayName]]),
122+ ["STOP_LABEL", favorite.stopLabel],
123+ ["LINE_LABEL", favorite.lineLabel],
124+ ["DESTINATION_LABEL", favorite.destinationLabel],
125+ ["SORT_ORDER", favorite.sortOrder]
126+ ])),
127+ wire(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]])
128+ ];
129+}
130+
131+function harness(storage) {
132+ const queue = new Queue();
133+ const view = new View();
134+ const controller = createController({
135+ clock: { now: () => 1_788_000_000_000 },
136+ queue,
137+ model: new WatchModel(),
138+ storage,
139+ view
140+ }).start();
141+ return { controller, queue, view };
142+}
143+
144+test("stored configuration renders offline and refreshes after phone synchronization", () => {
145+ const storage = new StringStorage(stored());
146+ const first = harness(storage);
147+ assert.equal(first.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
148+ assert.equal(first.view.renders.at(-1).language, "fr");
149+ assert.equal(first.queue.messages.length, 0);
150+ first.controller.onQueueState({ type: "writable" });
151+ assert.equal(first.queue.messages.length, 1);
152+
153+ configurationMessages("edit-1").forEach(first.controller.onReadable);
154+ assert.equal(first.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);
155+ assert.deepEqual(deserializeWatchConfiguration(storage.value), {
156+ keyStatus: KEY_STATUS.CONFIGURED,
157+ language: "en",
158+ favorites: NEW_FAVORITES
159+ });
160+
161+ const restarted = harness(storage);
162+ assert.equal(restarted.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);
163+ assert.equal(restarted.view.renders.at(-1).language, "en");
164+ assert.equal(restarted.queue.messages.length, 0);
165+ restarted.controller.onQueueState({ type: "writable" });
166+ assert.equal(restarted.queue.messages.length, 1);
167+});
168+
169+test("every interrupted sequence position preserves the prior durable list on restart", () => {
170+ const messages = configurationMessages("interrupted");
171+ for (let delivered = 0; delivered < messages.length; delivered += 1) {
172+ const previous = stored();
173+ const storage = new StringStorage(previous);
174+ const current = harness(storage);
175+ messages.slice(0, delivered).forEach(current.controller.onReadable);
176+ assert.equal(storage.value, previous, "delivery prefix " + delivered);
177+
178+ const restarted = harness(storage);
179+ assert.equal(restarted.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
180+ assert.equal(restarted.view.renders.at(-1).language, "fr");
181+ }
182+});
183+
184+test("unsupported input and failed persistence roll back to the prior configuration", () => {
185+ const previous = stored();
186+ const storage = new StringStorage(previous);
187+ const target = harness(storage);
188+ const unsupported = wire(MESSAGE_TYPE.CONFIG_BEGIN, []);
189+ unsupported.set("SCHEMA_VERSION", SCHEMA_VERSION + 1);
190+ assert.equal(target.controller.onReadable(unsupported), RECEIVE_RESULT.REJECTED);
191+ assert.equal(storage.value, previous);
192+
193+ storage.failNextSet = true;
194+ const results = configurationMessages("failed-save").map(target.controller.onReadable);
195+ assert.equal(results.at(-1), RECEIVE_RESULT.REJECTED);
196+ assert.equal(storage.value, previous);
197+ assert.equal(target.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
198+ assert.equal(target.view.renders.at(-1).language, "fr");
199+});
new file mode 100644
@@ -0,0 +1,199 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ KEY_STATUS,
5+ MESSAGE_TYPE,
6+ SCHEMA_VERSION
7+} from "../src/embeddedjs/contracts.js";
8+import { createController } from "../src/embeddedjs/controller.js";
9+import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
10+import WatchModel from "../src/embeddedjs/model.js";
11+import {
12+ WATCH_CONFIGURATION_KEY,
13+ deserializeWatchConfiguration,
14+ serializeWatchConfiguration
15+} from "../src/embeddedjs/storage.js";
16+
17+const OLD_FAVORITE = {
18+ id: "home",
19+ serviceId: "opaque:service:home",
20+ displayName: "Maison",
21+ stopLabel: "Châtelet",
22+ lineLabel: "Métro 1",
23+ destinationLabel: "La Défense",
24+ sortOrder: 0
25+};
26+const NEW_FAVORITES = [{
27+ id: "work",
28+ serviceId: "opaque:service:work",
29+ displayName: "Travail",
30+ stopLabel: "Nation",
31+ lineLabel: "RER A",
32+ destinationLabel: "Cergy",
33+ sortOrder: 0
34+}, {
35+ id: "gym",
36+ serviceId: "opaque:service:gym",
37+ stopLabel: "République",
38+ lineLabel: "Métro 9",
39+ destinationLabel: "Pont de Sèvres",
40+ sortOrder: 1
41+}];
42+
43+class StringStorage {
44+ constructor(value) {
45+ this.value = value;
46+ this.failNextSet = false;
47+ }
48+
49+ getItem(key) {
50+ return key === WATCH_CONFIGURATION_KEY ? this.value : null;
51+ }
52+
53+ setItem(key, value) {
54+ if (key !== WATCH_CONFIGURATION_KEY) return;
55+ if (this.failNextSet) {
56+ this.failNextSet = false;
57+ throw new Error("write failed");
58+ }
59+ this.value = value;
60+ }
61+
62+ removeItem(key) {
63+ if (key === WATCH_CONFIGURATION_KEY) this.value = null;
64+ }
65+}
66+
67+class Queue {
68+ constructor() {
69+ this.messages = [];
70+ }
71+
72+ enqueue(message) {
73+ this.messages.push(message);
74+ return true;
75+ }
76+
77+ close() {}
78+}
79+
80+class View {
81+ constructor() {
82+ this.renders = [];
83+ }
84+
85+ render(snapshot) {
86+ this.renders.push(snapshot);
87+ }
88+
89+ close() {}
90+}
91+
92+function stored(favorites = [OLD_FAVORITE], language = "fr") {
93+ return serializeWatchConfiguration({
94+ keyStatus: KEY_STATUS.CONFIGURED,
95+ language,
96+ favorites
97+ });
98+}
99+
100+function wire(type, entries = []) {
101+ return new Map([
102+ ["SCHEMA_VERSION", SCHEMA_VERSION],
103+ ["MESSAGE_TYPE", type],
104+ ...entries
105+ ]);
106+}
107+
108+function configurationMessages(id, favorites = NEW_FAVORITES, language = "en") {
109+ return [
110+ wire(MESSAGE_TYPE.CONFIG_BEGIN, [
111+ ["REQUEST_ID", id],
112+ ["ITEM_COUNT", favorites.length],
113+ ["KEY_STATUS", KEY_STATUS.CONFIGURED],
114+ ["DISPLAY_NAME", language]
115+ ]),
116+ ...favorites.map((favorite, index) => wire(MESSAGE_TYPE.FAVORITE, [
117+ ["REQUEST_ID", id],
118+ ["ITEM_INDEX", index],
119+ ["FAVORITE_ID", favorite.id],
120+ ["SERVICE_ID", favorite.serviceId],
121+ ...(favorite.displayName === undefined ? [] : [["DISPLAY_NAME", favorite.displayName]]),
122+ ["STOP_LABEL", favorite.stopLabel],
123+ ["LINE_LABEL", favorite.lineLabel],
124+ ["DESTINATION_LABEL", favorite.destinationLabel],
125+ ["SORT_ORDER", favorite.sortOrder]
126+ ])),
127+ wire(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]])
128+ ];
129+}
130+
131+function harness(storage) {
132+ const queue = new Queue();
133+ const view = new View();
134+ const controller = createController({
135+ clock: { now: () => 1_788_000_000_000 },
136+ queue,
137+ model: new WatchModel(),
138+ storage,
139+ view
140+ }).start();
141+ return { controller, queue, view };
142+}
143+
144+test("stored configuration renders offline and refreshes after phone synchronization", () => {
145+ const storage = new StringStorage(stored());
146+ const first = harness(storage);
147+ assert.equal(first.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
148+ assert.equal(first.view.renders.at(-1).language, "fr");
149+ assert.equal(first.queue.messages.length, 0);
150+ first.controller.onQueueState({ type: "writable" });
151+ assert.equal(first.queue.messages.length, 1);
152+
153+ configurationMessages("edit-1").forEach(first.controller.onReadable);
154+ assert.equal(first.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);
155+ assert.deepEqual(deserializeWatchConfiguration(storage.value), {
156+ keyStatus: KEY_STATUS.CONFIGURED,
157+ language: "en",
158+ favorites: NEW_FAVORITES
159+ });
160+
161+ const restarted = harness(storage);
162+ assert.equal(restarted.view.renders.at(-1).favorite.id, NEW_FAVORITES[0].id);
163+ assert.equal(restarted.view.renders.at(-1).language, "en");
164+ assert.equal(restarted.queue.messages.length, 0);
165+ restarted.controller.onQueueState({ type: "writable" });
166+ assert.equal(restarted.queue.messages.length, 1);
167+});
168+
169+test("every interrupted sequence position preserves the prior durable list on restart", () => {
170+ const messages = configurationMessages("interrupted");
171+ for (let delivered = 0; delivered < messages.length; delivered += 1) {
172+ const previous = stored();
173+ const storage = new StringStorage(previous);
174+ const current = harness(storage);
175+ messages.slice(0, delivered).forEach(current.controller.onReadable);
176+ assert.equal(storage.value, previous, "delivery prefix " + delivered);
177+
178+ const restarted = harness(storage);
179+ assert.equal(restarted.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
180+ assert.equal(restarted.view.renders.at(-1).language, "fr");
181+ }
182+});
183+
184+test("unsupported input and failed persistence roll back to the prior configuration", () => {
185+ const previous = stored();
186+ const storage = new StringStorage(previous);
187+ const target = harness(storage);
188+ const unsupported = wire(MESSAGE_TYPE.CONFIG_BEGIN, []);
189+ unsupported.set("SCHEMA_VERSION", SCHEMA_VERSION + 1);
190+ assert.equal(target.controller.onReadable(unsupported), RECEIVE_RESULT.REJECTED);
191+ assert.equal(storage.value, previous);
192+
193+ storage.failNextSet = true;
194+ const results = configurationMessages("failed-save").map(target.controller.onReadable);
195+ assert.equal(results.at(-1), RECEIVE_RESULT.REJECTED);
196+ assert.equal(storage.value, previous);
197+ assert.equal(target.view.renders.at(-1).favorite.id, OLD_FAVORITE.id);
198+ assert.equal(target.view.renders.at(-1).language, "fr");
199+});
modified packages/watch/test/manifest.test.js +1 -0
@@ -33,6 +33,7 @@ const expectedModules = [
3333 "./protocol",
3434 "./message-queue",
3535 "./localization",
36+ "./storage",
3637 "./ui",
3738 "./contracts"
3839 ];
@@ -33,6 +33,7 @@ const expectedModules = [
33 "./protocol",33 "./protocol",
34 "./message-queue",34 "./message-queue",
35 "./localization",35 "./localization",
36+ "./storage",
36 "./ui",37 "./ui",
37 "./contracts"38 "./contracts"
38 ];39 ];
modified packages/watch/test/protocol.test.js +53 -0
@@ -217,6 +217,30 @@ test("result stages atomically only for the expected request and favorite", () =
217217 });
218218 });
219219
220+test("matching request handshake permits one cached result replacement", () => {
221+ const receiver = new ProtocolReceiver();
222+ const request = encodeRequest({
223+ requestId: "request-1",
224+ favoriteId: "home",
225+ trigger: REQUEST_TRIGGER.APP_OPEN
226+ });
227+ const freshBegin = resultBegin();
228+ freshBegin.set("FETCHED_AT", 1_788_000_060);
229+
230+ assert.equal(receiver.expectResponse("request-1", "home"), true);
231+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
232+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
233+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
234+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_000);
235+
236+ assert.equal(receiver.receive(request), RECEIVE_RESULT.STAGED);
237+ assert.equal(receiver.receive(freshBegin), RECEIVE_RESULT.STAGED);
238+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
239+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
240+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_060);
241+ assert.equal(receiver.receive(request), RECEIVE_RESULT.REJECTED);
242+});
243+
220244 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
221245 const receiver = new ProtocolReceiver();
222246 commitConfiguration(receiver);
@@ -252,3 +276,32 @@ test("matching error commits without destroying the prior complete result", () =
252276 retryAfterSeconds: 30
253277 });
254278 });
279+
280+test("receiver seeds, restores, and idempotently repeats complete configuration", () => {
281+ const initial = {
282+ keyStatus: KEY_STATUS.CONFIGURED,
283+ language: "fr",
284+ favorites: [{
285+ id: "home",
286+ serviceId: "opaque:service:1",
287+ displayName: "Maison",
288+ stopLabel: "Châtelet",
289+ lineLabel: "Métro 1",
290+ destinationLabel: "La Défense",
291+ sortOrder: 0
292+ }]
293+ };
294+ const receiver = new ProtocolReceiver(initial);
295+ initial.favorites[0].stopLabel = "mutated after construction";
296+ assert.equal(receiver.snapshot().configuration.favorites[0].stopLabel, "Châtelet");
297+
298+ commitConfiguration(receiver);
299+ const committed = receiver.snapshot();
300+ commitConfiguration(receiver);
301+ assert.deepEqual(receiver.snapshot(), committed);
302+
303+ assert.equal(receiver.receive(configBegin("interrupted", 1, "en")), RECEIVE_RESULT.STAGED);
304+ receiver.restoreConfiguration(committed.configuration);
305+ assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED);
306+ assert.deepEqual(receiver.snapshot(), committed);
307+});
@@ -217,6 +217,30 @@ test("result stages atomically only for the expected request and favorite", () =
217 });217 });
218 });218 });
219 219
220+test("matching request handshake permits one cached result replacement", () => {
221+ const receiver = new ProtocolReceiver();
222+ const request = encodeRequest({
223+ requestId: "request-1",
224+ favoriteId: "home",
225+ trigger: REQUEST_TRIGGER.APP_OPEN
226+ });
227+ const freshBegin = resultBegin();
228+ freshBegin.set("FETCHED_AT", 1_788_000_060);
229+
230+ assert.equal(receiver.expectResponse("request-1", "home"), true);
231+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
232+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
233+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
234+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_000);
235+
236+ assert.equal(receiver.receive(request), RECEIVE_RESULT.STAGED);
237+ assert.equal(receiver.receive(freshBegin), RECEIVE_RESULT.STAGED);
238+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
239+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
240+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_060);
241+ assert.equal(receiver.receive(request), RECEIVE_RESULT.REJECTED);
242+});
243+
220 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {244 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
221 const receiver = new ProtocolReceiver();245 const receiver = new ProtocolReceiver();
222 commitConfiguration(receiver);246 commitConfiguration(receiver);
@@ -252,3 +276,32 @@ test("matching error commits without destroying the prior complete result", () =
252 retryAfterSeconds: 30276 retryAfterSeconds: 30
253 });277 });
254 });278 });
279+
280+test("receiver seeds, restores, and idempotently repeats complete configuration", () => {
281+ const initial = {
282+ keyStatus: KEY_STATUS.CONFIGURED,
283+ language: "fr",
284+ favorites: [{
285+ id: "home",
286+ serviceId: "opaque:service:1",
287+ displayName: "Maison",
288+ stopLabel: "Châtelet",
289+ lineLabel: "Métro 1",
290+ destinationLabel: "La Défense",
291+ sortOrder: 0
292+ }]
293+ };
294+ const receiver = new ProtocolReceiver(initial);
295+ initial.favorites[0].stopLabel = "mutated after construction";
296+ assert.equal(receiver.snapshot().configuration.favorites[0].stopLabel, "Châtelet");
297+
298+ commitConfiguration(receiver);
299+ const committed = receiver.snapshot();
300+ commitConfiguration(receiver);
301+ assert.deepEqual(receiver.snapshot(), committed);
302+
303+ assert.equal(receiver.receive(configBegin("interrupted", 1, "en")), RECEIVE_RESULT.STAGED);
304+ receiver.restoreConfiguration(committed.configuration);
305+ assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED);
306+ assert.deepEqual(receiver.snapshot(), committed);
307+});
added packages/watch/test/storage.test.js +124 -0
new file mode 100644
@@ -0,0 +1,124 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { KEY_STATUS, LIMITS, SCHEMA_VERSION } from "../src/embeddedjs/contracts.js";
4+import {
5+ WATCH_CONFIGURATION_KEY,
6+ WATCH_CONFIGURATION_MAX_BYTES,
7+ deserializeWatchConfiguration,
8+ loadWatchConfiguration,
9+ saveWatchConfiguration,
10+ serializeWatchConfiguration
11+} from "../src/embeddedjs/storage.js";
12+
13+const FAVORITE = {
14+ id: "home",
15+ serviceId: "opaque:service:1",
16+ displayName: "Maison",
17+ stopLabel: "Châtelet",
18+ lineLabel: "Métro 1",
19+ destinationLabel: "La Défense",
20+ sortOrder: 0
21+};
22+
23+const CONFIGURATION = {
24+ keyStatus: KEY_STATUS.CONFIGURED,
25+ language: "fr",
26+ favorites: [FAVORITE]
27+};
28+
29+class StringStorage {
30+ constructor(serialized = null) {
31+ this.value = serialized;
32+ this.corruptNextWrite = false;
33+ }
34+
35+ getItem(key) {
36+ return key === WATCH_CONFIGURATION_KEY ? this.value : null;
37+ }
38+
39+ setItem(key, value) {
40+ if (key !== WATCH_CONFIGURATION_KEY) return;
41+ if (this.corruptNextWrite) {
42+ this.corruptNextWrite = false;
43+ this.value = value + "corrupt";
44+ return;
45+ }
46+ this.value = value;
47+ }
48+
49+ removeItem(key) {
50+ if (key === WATCH_CONFIGURATION_KEY) this.value = null;
51+ }
52+}
53+
54+function stored(configuration = CONFIGURATION) {
55+ return serializeWatchConfiguration(configuration);
56+}
57+
58+test("watch configuration round-trips the exact compact durable projection", () => {
59+ const storage = new StringStorage();
60+ assert.equal(saveWatchConfiguration(storage, CONFIGURATION), true);
61+ assert.equal(storage.value, stored());
62+ assert.deepEqual(deserializeWatchConfiguration(storage.value), CONFIGURATION);
63+ assert.deepEqual(loadWatchConfiguration(storage), CONFIGURATION);
64+ assert.equal(storage.value.includes("primApiKey"), false);
65+ assert.equal(storage.value.includes("result"), false);
66+ assert.equal(storage.value.includes("request"), false);
67+});
68+
69+test("maximum contract configuration stays below the 8192-byte Alloy limit", () => {
70+ const configuration = {
71+ keyStatus: KEY_STATUS.CONFIGURED,
72+ language: "fr",
73+ favorites: Array.from({ length: 8 }, (_, index) => ({
74+ id: "i".repeat(LIMITS.idUtf8Bytes - 1) + index,
75+ serviceId: "s".repeat(LIMITS.idUtf8Bytes - 1) + index,
76+ displayName: "n".repeat(LIMITS.labelUtf8Bytes),
77+ stopLabel: "p".repeat(LIMITS.labelUtf8Bytes),
78+ lineLabel: "l".repeat(LIMITS.labelUtf8Bytes),
79+ destinationLabel: "d".repeat(LIMITS.labelUtf8Bytes),
80+ sortOrder: index
81+ }))
82+ };
83+ const serialized = serializeWatchConfiguration(configuration);
84+ assert.notEqual(serialized, null);
85+ assert.ok(new TextEncoder().encode(serialized).byteLength <= WATCH_CONFIGURATION_MAX_BYTES);
86+ assert.deepEqual(deserializeWatchConfiguration(serialized), configuration);
87+});
88+
89+test("corrupt, unsupported, over-bound, and secret-bearing values are rejected", () => {
90+ const serialized = stored();
91+ const invalid = [
92+ "not-compact",
93+ serialized.replace("LFW1\u001f1\u001f", "LFW1\u001f2\u001f"),
94+ serialized + "trailing",
95+ serialized.slice(0, -1)
96+ ];
97+ invalid.forEach((value) => {
98+ assert.equal(loadWatchConfiguration(new StringStorage(value)), null);
99+ });
100+
101+ const storage = new StringStorage(serialized);
102+ assert.equal(saveWatchConfiguration(storage, { ...CONFIGURATION, result: {} }), false);
103+ assert.equal(storage.value, serialized);
104+ assert.equal(serializeWatchConfiguration({
105+ ...CONFIGURATION,
106+ favorites: [{ ...FAVORITE, primApiKey: "must-not-persist" }]
107+ }), null);
108+ assert.equal(serializeWatchConfiguration({
109+ ...CONFIGURATION,
110+ favorites: [{ ...FAVORITE, stopLabel: "x".repeat(193) }]
111+ }), null);
112+});
113+
114+test("failed read-back restores the previous valid bytes", () => {
115+ const previous = stored({
116+ keyStatus: KEY_STATUS.MISSING,
117+ language: "en",
118+ favorites: []
119+ });
120+ const storage = new StringStorage(previous);
121+ storage.corruptNextWrite = true;
122+ assert.equal(saveWatchConfiguration(storage, CONFIGURATION), false);
123+ assert.equal(storage.value, previous);
124+});
new file mode 100644
@@ -0,0 +1,124 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { KEY_STATUS, LIMITS, SCHEMA_VERSION } from "../src/embeddedjs/contracts.js";
4+import {
5+ WATCH_CONFIGURATION_KEY,
6+ WATCH_CONFIGURATION_MAX_BYTES,
7+ deserializeWatchConfiguration,
8+ loadWatchConfiguration,
9+ saveWatchConfiguration,
10+ serializeWatchConfiguration
11+} from "../src/embeddedjs/storage.js";
12+
13+const FAVORITE = {
14+ id: "home",
15+ serviceId: "opaque:service:1",
16+ displayName: "Maison",
17+ stopLabel: "Châtelet",
18+ lineLabel: "Métro 1",
19+ destinationLabel: "La Défense",
20+ sortOrder: 0
21+};
22+
23+const CONFIGURATION = {
24+ keyStatus: KEY_STATUS.CONFIGURED,
25+ language: "fr",
26+ favorites: [FAVORITE]
27+};
28+
29+class StringStorage {
30+ constructor(serialized = null) {
31+ this.value = serialized;
32+ this.corruptNextWrite = false;
33+ }
34+
35+ getItem(key) {
36+ return key === WATCH_CONFIGURATION_KEY ? this.value : null;
37+ }
38+
39+ setItem(key, value) {
40+ if (key !== WATCH_CONFIGURATION_KEY) return;
41+ if (this.corruptNextWrite) {
42+ this.corruptNextWrite = false;
43+ this.value = value + "corrupt";
44+ return;
45+ }
46+ this.value = value;
47+ }
48+
49+ removeItem(key) {
50+ if (key === WATCH_CONFIGURATION_KEY) this.value = null;
51+ }
52+}
53+
54+function stored(configuration = CONFIGURATION) {
55+ return serializeWatchConfiguration(configuration);
56+}
57+
58+test("watch configuration round-trips the exact compact durable projection", () => {
59+ const storage = new StringStorage();
60+ assert.equal(saveWatchConfiguration(storage, CONFIGURATION), true);
61+ assert.equal(storage.value, stored());
62+ assert.deepEqual(deserializeWatchConfiguration(storage.value), CONFIGURATION);
63+ assert.deepEqual(loadWatchConfiguration(storage), CONFIGURATION);
64+ assert.equal(storage.value.includes("primApiKey"), false);
65+ assert.equal(storage.value.includes("result"), false);
66+ assert.equal(storage.value.includes("request"), false);
67+});
68+
69+test("maximum contract configuration stays below the 8192-byte Alloy limit", () => {
70+ const configuration = {
71+ keyStatus: KEY_STATUS.CONFIGURED,
72+ language: "fr",
73+ favorites: Array.from({ length: 8 }, (_, index) => ({
74+ id: "i".repeat(LIMITS.idUtf8Bytes - 1) + index,
75+ serviceId: "s".repeat(LIMITS.idUtf8Bytes - 1) + index,
76+ displayName: "n".repeat(LIMITS.labelUtf8Bytes),
77+ stopLabel: "p".repeat(LIMITS.labelUtf8Bytes),
78+ lineLabel: "l".repeat(LIMITS.labelUtf8Bytes),
79+ destinationLabel: "d".repeat(LIMITS.labelUtf8Bytes),
80+ sortOrder: index
81+ }))
82+ };
83+ const serialized = serializeWatchConfiguration(configuration);
84+ assert.notEqual(serialized, null);
85+ assert.ok(new TextEncoder().encode(serialized).byteLength <= WATCH_CONFIGURATION_MAX_BYTES);
86+ assert.deepEqual(deserializeWatchConfiguration(serialized), configuration);
87+});
88+
89+test("corrupt, unsupported, over-bound, and secret-bearing values are rejected", () => {
90+ const serialized = stored();
91+ const invalid = [
92+ "not-compact",
93+ serialized.replace("LFW1\u001f1\u001f", "LFW1\u001f2\u001f"),
94+ serialized + "trailing",
95+ serialized.slice(0, -1)
96+ ];
97+ invalid.forEach((value) => {
98+ assert.equal(loadWatchConfiguration(new StringStorage(value)), null);
99+ });
100+
101+ const storage = new StringStorage(serialized);
102+ assert.equal(saveWatchConfiguration(storage, { ...CONFIGURATION, result: {} }), false);
103+ assert.equal(storage.value, serialized);
104+ assert.equal(serializeWatchConfiguration({
105+ ...CONFIGURATION,
106+ favorites: [{ ...FAVORITE, primApiKey: "must-not-persist" }]
107+ }), null);
108+ assert.equal(serializeWatchConfiguration({
109+ ...CONFIGURATION,
110+ favorites: [{ ...FAVORITE, stopLabel: "x".repeat(193) }]
111+ }), null);
112+});
113+
114+test("failed read-back restores the previous valid bytes", () => {
115+ const previous = stored({
116+ keyStatus: KEY_STATUS.MISSING,
117+ language: "en",
118+ favorites: []
119+ });
120+ const storage = new StringStorage(previous);
121+ storage.corruptNextWrite = true;
122+ assert.equal(saveWatchConfiguration(storage, CONFIGURATION), false);
123+ assert.equal(storage.value, previous);
124+});
modified scripts/measure-radio.mjs +287 -15
@@ -4,11 +4,19 @@ import { readFileSync } from "node:fs";
44 const require = createRequire(import.meta.url);
55 const codec = require("../packages/companion/src/codec.js");
66 const contracts = require("../packages/companion/src/contracts.js");
7+const configuration = require("../packages/companion/src/configuration.js");
8+import {
9+ serializeWatchConfiguration,
10+ WATCH_CONFIGURATION_MAX_BYTES,
11+} from "../packages/watch/src/embeddedjs/storage.js";
712 const fixture = JSON.parse(readFileSync(
813 new URL("../fixtures/departures/foundation.json", import.meta.url),
914 "utf8"
1015 ));
1116
17+// Wire-size accounting follows Pebble's measured-data formula: one header
18+// byte per dictionary, seven bytes per key, then value bytes (utf8 plus the
19+// c-string terminator for strings, four bytes for numeric values).
1220 function dictionarySize(dictionary) {
1321 const sizes = Object.values(dictionary).map((value) => (
1422 typeof value === "string" ? Buffer.byteLength(value, "utf8") + 1 : 4
@@ -16,33 +24,297 @@ function dictionarySize(dictionary) {
1624 return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);
1725 }
1826
27+function maximumEncodedString(byteLimit, suffix = "") {
28+ return "\\".repeat(byteLimit - Buffer.byteLength(suffix, "utf8")) + suffix;
29+}
30+
31+// Longest contract enum labels maximize the serialized bound.
32+function longestLabel(values) {
33+ return values.reduce((longest, value) => (value.length > longest.length ? value : longest));
34+}
35+
36+// AppMessage capacity actually configured by the watch app. The watch
37+// bootstrap (packages/watch/src/embeddedjs/main.js) constructs
38+// `new Message({ keys: APP_MESSAGE_KEY_MAP, ... })` with no input/output
39+// bound, so the Moddable bridge
40+// (RePebble SDK 4.33.1 toolchain/moddable/build/devices/pebble/modules/
41+// message/pebble-appmessage.c) opens
42+// app_message_open(app_message_inbox_size_maximum(),
43+// app_message_outbox_size_maximum()). On the SDK 4.33.1 firmware those
44+// return 2026 (0x7ea) and 654 (0x28e) bytes for a JS-allowed app; both
45+// constants disassemble identically from the emery and gabbro qemu
46+// *_sdk_debug.elf images. A phone companion advertising the 8k message
47+// capability lifts both maxima to 8200 (0x2008), which this budget does not
48+// assume. AppMessage buffers live in the application heap (pebble.h), inside
49+// the 56 KiB XS arena budget enforced at compile time in mdbl.c.
50+const appMessageInboxBytes = 2026;
51+const appMessageOutboxBytes = 654;
52+const appMessageEightKilobyteBytes = 8200;
53+
54+// Maximal frozen DepartureResult bounds from the contracts validators
55+// (packages/companion/src/contracts.js): requestId/favoriteId at
56+// LIMITS.idUtf8Bytes utf8 bytes, fetchedAt/sourceUpdatedAt at the uint32
57+// maximum accepted by uint32()/optionalUint32(), freshness/status at their
58+// longest enum labels, LIMITS.departures departures with expectedAt/aimedAt
59+// at the uint32 maximum, minutes at the isDeparture floor of -1440,
60+// nextIntervalMinutes at its isDeparture ceiling of 1440, and both optional
61+// fields present.
62+const UINT32_MAX = 4294967295;
63+const maximumDeparture = {
64+ expectedAt: UINT32_MAX,
65+ aimedAt: UINT32_MAX,
66+ minutes: -1440,
67+ status: longestLabel(contracts.DEPARTURE_STATUS),
68+ nextIntervalMinutes: 1440,
69+};
70+
71+function maximumResult(favoriteId) {
72+ return {
73+ schemaVersion: contracts.SCHEMA_VERSION,
74+ requestId: maximumEncodedString(contracts.LIMITS.idUtf8Bytes),
75+ favoriteId,
76+ fetchedAt: UINT32_MAX,
77+ sourceUpdatedAt: UINT32_MAX,
78+ freshness: longestLabel(contracts.FRESHNESS),
79+ departures: Array.from(
80+ { length: contracts.LIMITS.departures },
81+ () => maximumDeparture,
82+ ),
83+ };
84+}
85+
1986 const request = codec.encodeRequest({
2087 requestId: fixture.result.requestId,
2188 favoriteId: fixture.favorite.id,
2289 trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
2390 });
2491 const response = codec.encodeResult(fixture.result);
92+const maximumApiKey = maximumEncodedString(contracts.LIMITS.apiKeyUtf8Bytes);
93+const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_, index) => {
94+ const suffix = String(index).padStart(2, "0");
95+ return {
96+ ...fixture.favorite,
97+ id: maximumEncodedString(contracts.LIMITS.idUtf8Bytes, suffix),
98+ serviceId: maximumEncodedString(contracts.LIMITS.idUtf8Bytes, suffix),
99+ displayName: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
100+ stopLabel: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
101+ lineLabel: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
102+ destinationLabel: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
103+ sortOrder: index,
104+ };
105+});
106+const configurationMessages = codec.encodeConfiguration(
107+ "config-maximum",
108+ maximumFavorites,
109+ contracts.KEY_STATUS.CONFIGURED,
110+ contracts.WIRE_LANGUAGE.FR,
111+);
112+
113+// Maximal lapinFuteResults record per the configuration validators
114+// (packages/companion/src/configuration.js isStoredResults/isResultEntry):
115+// one entry per favorite with favoriteId bounded at LIMITS.idUtf8Bytes,
116+// matching result.favoriteId, and storedAt at Number.MAX_SAFE_INTEGER
117+// (isStoredAt ceiling).
118+const maximumResultEntries = maximumFavorites.map((favorite) => ({
119+ favoriteId: favorite.id,
120+ storedAt: Number.MAX_SAFE_INTEGER,
121+ result: maximumResult(favorite.id),
122+}));
123+if (!maximumResultEntries.every((entry) => contracts.isDepartureResult(entry.result))) {
124+ throw new Error("maximum result must satisfy the frozen DepartureResult contract");
125+}
126+
25127 const messages = [request, ...response];
128+const configurationSizes = configurationMessages.map(dictionarySize);
26129 const sizes = messages.map(dictionarySize);
27130 const bytes = sizes.reduce((total, size) => total + size, 0);
28131 const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));
132+const largestConfigurationDictionaryBytes = Math.max(...configurationSizes);
133+const configurationEncodedBytes = configurationSizes.reduce((total, size) => total + size, 0);
134+const phoneRecordBytes = Buffer.byteLength(JSON.stringify({
135+ schemaVersion: contracts.SCHEMA_VERSION,
136+ favorites: maximumFavorites,
137+ primApiKey: maximumApiKey,
138+ keyStatus: contracts.KEY_STATUS.CONFIGURED,
139+}), "utf8");
140+const watchSerialized = serializeWatchConfiguration({
141+ favorites: maximumFavorites.map((favorite) => ({
142+ id: favorite.id,
143+ serviceId: favorite.serviceId,
144+ displayName: favorite.displayName,
145+ stopLabel: favorite.stopLabel,
146+ lineLabel: favorite.lineLabel,
147+ destinationLabel: favorite.destinationLabel,
148+ sortOrder: favorite.sortOrder,
149+ })),
150+ keyStatus: contracts.KEY_STATUS.CONFIGURED,
151+ language: contracts.WIRE_LANGUAGE.FR,
152+});
153+if (watchSerialized === null) throw new Error("maximum watch configuration exceeds storage");
154+const watchRecordBytes = Buffer.byteLength(watchSerialized, "utf8");
155+const resultsRecordBytes = Buffer.byteLength(JSON.stringify({
156+ schemaVersion: contracts.SCHEMA_VERSION,
157+ results: maximumResultEntries,
158+}), "utf8");
159+// Failure journal (configuration.js saveInvalidConfiguration): the recovery
160+// record is written under lapinFuteInvalidKeyStatus only while an INVALID
161+// configuration write cannot be verified and is removed on success. Its
162+// serialized shape is exactly { schemaVersion, keyStatus: INVALID,
163+// configurationFingerprint }, where the fingerprint is always 16 lowercase
164+// hex characters (two fixed-width 8-character halves; validated by
165+// /^[0-9a-f]{16}$/ in invalidKeyStatusMatches), so the derived maximum is
166+// exact.
167+const invalidKeyStatusRecordBytes = Buffer.byteLength(JSON.stringify({
168+ schemaVersion: contracts.SCHEMA_VERSION,
169+ keyStatus: contracts.KEY_STATUS.INVALID,
170+ configurationFingerprint: "f".repeat(16),
171+}), "utf8");
172+const phoneStorageBytes = phoneRecordBytes + resultsRecordBytes
173+ + invalidKeyStatusRecordBytes;
174+const openingFragmentCharacters = encodeURIComponent(JSON.stringify({
175+ hasKey: true,
176+ favorites: maximumFavorites,
177+ language: contracts.WIRE_LANGUAGE.FR,
178+})).length;
179+const closeFragmentCharacters = encodeURIComponent(JSON.stringify({
180+ schemaVersion: contracts.SCHEMA_VERSION,
181+ favorites: maximumFavorites,
182+ apiKeyUpdate: {
183+ schemaVersion: contracts.SCHEMA_VERSION,
184+ action: "REPLACE",
185+ value: maximumApiKey,
186+ },
187+})).length;
188+
189+// Every bound the gate enforces, each with its repository or platform source.
190+// The pass expression is computed from these values only.
191+const capacities = {
192+ steadyStateMessages: {
193+ value: 7,
194+ source: "README.md quality gates: two-departure refresh at most 7 dictionaries (request + RESULT_BEGIN + LIMITS.departures departures + RESULT_COMMIT)",
195+ },
196+ steadyStateEncodedBytes: {
197+ value: 2048,
198+ source: "README.md quality gates: two-departure refresh at most 2048 encoded bytes",
199+ },
200+ requestBytes: {
201+ value: 192,
202+ source: "README.md quality gates: watch request at most 192 encoded bytes; travels watch-to-phone through the 654-byte outbox",
203+ },
204+ phoneDictionaryBytes: {
205+ value: 768,
206+ source: "README.md quality gates: phone-to-watch dictionary at most 768 encoded bytes; the frozen repository budget, tighter than the 2026-byte watch inbox actually configured by the watch app",
207+ },
208+ configurationMessages: {
209+ value: contracts.LIMITS.favorites + 2,
210+ source: "codec.encodeConfiguration emits CONFIG_BEGIN + LIMITS.favorites favorites + CONFIG_COMMIT, so an atomic batch is LIMITS.favorites + 2 dictionaries",
211+ },
212+ configurationEncodedBytes: {
213+ value: 8192,
214+ source: "frozen v1 configuration-batch budget carried from the foundation measurement plan in this script (power-of-two batch headroom); the binding per-dictionary gate is appMessageInboxBytes",
215+ },
216+ appMessageInboxBytes: {
217+ value: appMessageInboxBytes,
218+ source: "watch inbox capacity configured by the watch app: main.js passes no Message input bound, so Moddable pebble-appmessage.c opens app_message_open(app_message_inbox_size_maximum(), app_message_outbox_size_maximum()); RePebble SDK 4.33.1 emery/gabbro firmware returns 2026 bytes (0x7ea) for a JS-allowed app (disassembled from both qemu *_sdk_debug.elf images; 126 bytes for non-JS apps)",
219+ },
220+ appMessageOutboxBytes: {
221+ value: appMessageOutboxBytes,
222+ source: "watch outbox capacity from app_message_outbox_size_maximum() in the same RePebble SDK 4.33.1 emery/gabbro firmware images: 654 bytes (0x28e) without the 8k phone capability",
223+ },
224+ appMessageEightKilobyteBytes: {
225+ value: appMessageEightKilobyteBytes,
226+ source: "phone companions advertising the 8k message capability (prv_supports_8k) lift both AppMessage maxima to 8200 bytes (0x2008); not assumed by this budget",
227+ },
228+ watchUnsentQueueMessages: {
229+ value: 4,
230+ source: "watch contracts LIMITS.requestQueue enforced by the MessageQueue constructor (packages/watch/src/embeddedjs/message-queue.js); README watch unsent queue gate of four messages",
231+ },
232+ xsArenaBudgetBytes: {
233+ value: 56 * 1024,
234+ source: "mdbl.c XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES = 56 KiB enforced by a compile-time #error; README loaded image and RAM gate with XS arenas at least 25% free; AppMessage inbox/outbox buffers are allocated from this application heap (pebble.h)",
235+ },
236+ phoneRecordBytes: {
237+ value: 16384,
238+ source: "frozen v1 per-record budget from the foundation measurement plan for the lapinFuteConfig localStorage record (configuration.CONFIG_STORAGE_KEY); structural bound is LIMITS.favorites favorites plus LIMITS.apiKeyUtf8Bytes key bytes; far under the webview Web Storage origin quota",
239+ },
240+ watchRecordBytes: {
241+ value: WATCH_CONFIGURATION_MAX_BYTES,
242+ source: "installed RePebble Alloy SDK 4.33.1 storage-pebble.c opens the app SettingsFile with max_used_space 8192; storage.serializeWatchConfiguration enforces the same bound",
243+ },
244+ resultsRecordBytes: {
245+ value: 16384,
246+ source: "frozen v1 per-record budget from the foundation measurement plan for the lapinFuteResults localStorage record (configuration.RESULTS_STORAGE_KEY); structural bound is LIMITS.favorites entries of LIMITS.departures maximal departures",
247+ },
248+ invalidKeyStatusRecordBytes: {
249+ value: invalidKeyStatusRecordBytes,
250+ source: "derived maximum of the lapinFuteInvalidKeyStatus recovery journal (configuration.INVALID_KEY_STATUS_STORAGE_KEY), whose serialized shape is exactly { schemaVersion, keyStatus: INVALID, configurationFingerprint } per configuration.js saveInvalidConfiguration, with the fingerprint fixed at 16 lowercase hex characters (invalidKeyStatusMatches /^[0-9a-f]{16}$/)",
251+ },
252+ phoneStorageBytes: {
253+ value: (16384 * 2) + invalidKeyStatusRecordBytes,
254+ source: "sum of both frozen 16384-byte phone record budgets plus the exact recovery journal bound: total phone-local storage for configuration, cached results, and INVALID recovery",
255+ },
256+ openingFragmentCharacters: {
257+ value: 32768,
258+ source: "packages/config-page/src/config-core.js MAX_OPENING_FRAGMENT_LENGTH = 32768",
259+ },
260+ closeFragmentCharacters: {
261+ value: 32768,
262+ source: "packages/companion/src/configuration.js MAX_CLOSE_RESPONSE_LENGTH = 32768",
263+ },
264+};
265+const budget = Object.fromEntries(Object.entries(capacities).map(([name, entry]) => [
266+ name,
267+ entry.value,
268+]));
269+
29270 const report = {
30271 fixture: "fixtures/departures/foundation.json",
31- messages: messages.length,
32- encodedBytes: bytes,
33- requestBytes: sizes[0],
34- largestPhoneDictionaryBytes,
35- scenario: "steady-state APP_OPEN request plus two-departure result",
36- budget: {
37- messages: 7,
38- encodedBytes: 2048,
39- requestBytes: 192,
40- phoneDictionaryBytes: 768,
41- },
42- pass: messages.length <= 7
43- && bytes <= 2048
44- && sizes[0] <= 192
45- && largestPhoneDictionaryBytes <= 768,
272+ favoriteLimit: contracts.LIMITS.favorites,
273+ steadyState: {
274+ messages: messages.length,
275+ encodedBytes: bytes,
276+ requestBytes: sizes[0],
277+ largestPhoneDictionaryBytes,
278+ },
279+ maximumConfiguration: {
280+ messages: configurationMessages.length,
281+ encodedBytes: configurationEncodedBytes,
282+ largestDictionaryBytes: largestConfigurationDictionaryBytes,
283+ phoneRecordBytes,
284+ watchRecordBytes,
285+ openingFragmentCharacters,
286+ closeFragmentCharacters,
287+ },
288+ maximumResults: {
289+ entries: maximumResultEntries.length,
290+ departuresPerEntry: contracts.LIMITS.departures,
291+ recordBytes: resultsRecordBytes,
292+ },
293+ phoneStorage: {
294+ configurationRecordBytes: phoneRecordBytes,
295+ resultsRecordBytes,
296+ invalidKeyStatusRecordBytes,
297+ totalBytes: phoneStorageBytes,
298+ },
299+ scenario: "steady-state request/result plus a complete maximum-favorite atomic configuration and a maximal cached-results record",
300+ capacities,
301+ budget,
302+ pass: messages.length <= budget.steadyStateMessages
303+ && bytes <= budget.steadyStateEncodedBytes
304+ && sizes[0] <= budget.requestBytes
305+ && largestPhoneDictionaryBytes <= budget.phoneDictionaryBytes
306+ && sizes[0] <= budget.appMessageOutboxBytes
307+ && configurationMessages.length <= budget.configurationMessages
308+ && configurationEncodedBytes <= budget.configurationEncodedBytes
309+ && largestConfigurationDictionaryBytes <= budget.appMessageInboxBytes
310+ && largestConfigurationDictionaryBytes <= budget.phoneDictionaryBytes
311+ && phoneRecordBytes <= budget.phoneRecordBytes
312+ && watchRecordBytes <= budget.watchRecordBytes
313+ && watchRecordBytes <= budget.xsArenaBudgetBytes
314+ && resultsRecordBytes <= budget.resultsRecordBytes
315+ && phoneStorageBytes <= budget.phoneStorageBytes
316+ && openingFragmentCharacters <= budget.openingFragmentCharacters
317+ && closeFragmentCharacters <= budget.closeFragmentCharacters,
46318 };
47319 console.log(JSON.stringify(report, null, 2));
48320 if (!report.pass) process.exit(1);
@@ -4,11 +4,19 @@ import { readFileSync } from "node:fs";
4 const require = createRequire(import.meta.url);4 const require = createRequire(import.meta.url);
5 const codec = require("../packages/companion/src/codec.js");5 const codec = require("../packages/companion/src/codec.js");
6 const contracts = require("../packages/companion/src/contracts.js");6 const contracts = require("../packages/companion/src/contracts.js");
7+const configuration = require("../packages/companion/src/configuration.js");
8+import {
9+ serializeWatchConfiguration,
10+ WATCH_CONFIGURATION_MAX_BYTES,
11+} from "../packages/watch/src/embeddedjs/storage.js";
7 const fixture = JSON.parse(readFileSync(12 const fixture = JSON.parse(readFileSync(
8 new URL("../fixtures/departures/foundation.json", import.meta.url),13 new URL("../fixtures/departures/foundation.json", import.meta.url),
9 "utf8"14 "utf8"
10 ));15 ));
11 16
17+// Wire-size accounting follows Pebble's measured-data formula: one header
18+// byte per dictionary, seven bytes per key, then value bytes (utf8 plus the
19+// c-string terminator for strings, four bytes for numeric values).
12 function dictionarySize(dictionary) {20 function dictionarySize(dictionary) {
13 const sizes = Object.values(dictionary).map((value) => (21 const sizes = Object.values(dictionary).map((value) => (
14 typeof value === "string" ? Buffer.byteLength(value, "utf8") + 1 : 422 typeof value === "string" ? Buffer.byteLength(value, "utf8") + 1 : 4
@@ -16,33 +24,297 @@ function dictionarySize(dictionary) {
16 return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);24 return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);
17 }25 }
18 26
27+function maximumEncodedString(byteLimit, suffix = "") {
28+ return "\\".repeat(byteLimit - Buffer.byteLength(suffix, "utf8")) + suffix;
29+}
30+
31+// Longest contract enum labels maximize the serialized bound.
32+function longestLabel(values) {
33+ return values.reduce((longest, value) => (value.length > longest.length ? value : longest));
34+}
35+
36+// AppMessage capacity actually configured by the watch app. The watch
37+// bootstrap (packages/watch/src/embeddedjs/main.js) constructs
38+// `new Message({ keys: APP_MESSAGE_KEY_MAP, ... })` with no input/output
39+// bound, so the Moddable bridge
40+// (RePebble SDK 4.33.1 toolchain/moddable/build/devices/pebble/modules/
41+// message/pebble-appmessage.c) opens
42+// app_message_open(app_message_inbox_size_maximum(),
43+// app_message_outbox_size_maximum()). On the SDK 4.33.1 firmware those
44+// return 2026 (0x7ea) and 654 (0x28e) bytes for a JS-allowed app; both
45+// constants disassemble identically from the emery and gabbro qemu
46+// *_sdk_debug.elf images. A phone companion advertising the 8k message
47+// capability lifts both maxima to 8200 (0x2008), which this budget does not
48+// assume. AppMessage buffers live in the application heap (pebble.h), inside
49+// the 56 KiB XS arena budget enforced at compile time in mdbl.c.
50+const appMessageInboxBytes = 2026;
51+const appMessageOutboxBytes = 654;
52+const appMessageEightKilobyteBytes = 8200;
53+
54+// Maximal frozen DepartureResult bounds from the contracts validators
55+// (packages/companion/src/contracts.js): requestId/favoriteId at
56+// LIMITS.idUtf8Bytes utf8 bytes, fetchedAt/sourceUpdatedAt at the uint32
57+// maximum accepted by uint32()/optionalUint32(), freshness/status at their
58+// longest enum labels, LIMITS.departures departures with expectedAt/aimedAt
59+// at the uint32 maximum, minutes at the isDeparture floor of -1440,
60+// nextIntervalMinutes at its isDeparture ceiling of 1440, and both optional
61+// fields present.
62+const UINT32_MAX = 4294967295;
63+const maximumDeparture = {
64+ expectedAt: UINT32_MAX,
65+ aimedAt: UINT32_MAX,
66+ minutes: -1440,
67+ status: longestLabel(contracts.DEPARTURE_STATUS),
68+ nextIntervalMinutes: 1440,
69+};
70+
71+function maximumResult(favoriteId) {
72+ return {
73+ schemaVersion: contracts.SCHEMA_VERSION,
74+ requestId: maximumEncodedString(contracts.LIMITS.idUtf8Bytes),
75+ favoriteId,
76+ fetchedAt: UINT32_MAX,
77+ sourceUpdatedAt: UINT32_MAX,
78+ freshness: longestLabel(contracts.FRESHNESS),
79+ departures: Array.from(
80+ { length: contracts.LIMITS.departures },
81+ () => maximumDeparture,
82+ ),
83+ };
84+}
85+
19 const request = codec.encodeRequest({86 const request = codec.encodeRequest({
20 requestId: fixture.result.requestId,87 requestId: fixture.result.requestId,
21 favoriteId: fixture.favorite.id,88 favoriteId: fixture.favorite.id,
22 trigger: contracts.REQUEST_TRIGGER.APP_OPEN,89 trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
23 });90 });
24 const response = codec.encodeResult(fixture.result);91 const response = codec.encodeResult(fixture.result);
92+const maximumApiKey = maximumEncodedString(contracts.LIMITS.apiKeyUtf8Bytes);
93+const maximumFavorites = Array.from({ length: contracts.LIMITS.favorites }, (_, index) => {
94+ const suffix = String(index).padStart(2, "0");
95+ return {
96+ ...fixture.favorite,
97+ id: maximumEncodedString(contracts.LIMITS.idUtf8Bytes, suffix),
98+ serviceId: maximumEncodedString(contracts.LIMITS.idUtf8Bytes, suffix),
99+ displayName: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
100+ stopLabel: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
101+ lineLabel: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
102+ destinationLabel: maximumEncodedString(contracts.LIMITS.labelUtf8Bytes),
103+ sortOrder: index,
104+ };
105+});
106+const configurationMessages = codec.encodeConfiguration(
107+ "config-maximum",
108+ maximumFavorites,
109+ contracts.KEY_STATUS.CONFIGURED,
110+ contracts.WIRE_LANGUAGE.FR,
111+);
112+
113+// Maximal lapinFuteResults record per the configuration validators
114+// (packages/companion/src/configuration.js isStoredResults/isResultEntry):
115+// one entry per favorite with favoriteId bounded at LIMITS.idUtf8Bytes,
116+// matching result.favoriteId, and storedAt at Number.MAX_SAFE_INTEGER
117+// (isStoredAt ceiling).
118+const maximumResultEntries = maximumFavorites.map((favorite) => ({
119+ favoriteId: favorite.id,
120+ storedAt: Number.MAX_SAFE_INTEGER,
121+ result: maximumResult(favorite.id),
122+}));
123+if (!maximumResultEntries.every((entry) => contracts.isDepartureResult(entry.result))) {
124+ throw new Error("maximum result must satisfy the frozen DepartureResult contract");
125+}
126+
25 const messages = [request, ...response];127 const messages = [request, ...response];
128+const configurationSizes = configurationMessages.map(dictionarySize);
26 const sizes = messages.map(dictionarySize);129 const sizes = messages.map(dictionarySize);
27 const bytes = sizes.reduce((total, size) => total + size, 0);130 const bytes = sizes.reduce((total, size) => total + size, 0);
28 const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));131 const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));
132+const largestConfigurationDictionaryBytes = Math.max(...configurationSizes);
133+const configurationEncodedBytes = configurationSizes.reduce((total, size) => total + size, 0);
134+const phoneRecordBytes = Buffer.byteLength(JSON.stringify({
135+ schemaVersion: contracts.SCHEMA_VERSION,
136+ favorites: maximumFavorites,
137+ primApiKey: maximumApiKey,
138+ keyStatus: contracts.KEY_STATUS.CONFIGURED,
139+}), "utf8");
140+const watchSerialized = serializeWatchConfiguration({
141+ favorites: maximumFavorites.map((favorite) => ({
142+ id: favorite.id,
143+ serviceId: favorite.serviceId,
144+ displayName: favorite.displayName,
145+ stopLabel: favorite.stopLabel,
146+ lineLabel: favorite.lineLabel,
147+ destinationLabel: favorite.destinationLabel,
148+ sortOrder: favorite.sortOrder,
149+ })),
150+ keyStatus: contracts.KEY_STATUS.CONFIGURED,
151+ language: contracts.WIRE_LANGUAGE.FR,
152+});
153+if (watchSerialized === null) throw new Error("maximum watch configuration exceeds storage");
154+const watchRecordBytes = Buffer.byteLength(watchSerialized, "utf8");
155+const resultsRecordBytes = Buffer.byteLength(JSON.stringify({
156+ schemaVersion: contracts.SCHEMA_VERSION,
157+ results: maximumResultEntries,
158+}), "utf8");
159+// Failure journal (configuration.js saveInvalidConfiguration): the recovery
160+// record is written under lapinFuteInvalidKeyStatus only while an INVALID
161+// configuration write cannot be verified and is removed on success. Its
162+// serialized shape is exactly { schemaVersion, keyStatus: INVALID,
163+// configurationFingerprint }, where the fingerprint is always 16 lowercase
164+// hex characters (two fixed-width 8-character halves; validated by
165+// /^[0-9a-f]{16}$/ in invalidKeyStatusMatches), so the derived maximum is
166+// exact.
167+const invalidKeyStatusRecordBytes = Buffer.byteLength(JSON.stringify({
168+ schemaVersion: contracts.SCHEMA_VERSION,
169+ keyStatus: contracts.KEY_STATUS.INVALID,
170+ configurationFingerprint: "f".repeat(16),
171+}), "utf8");
172+const phoneStorageBytes = phoneRecordBytes + resultsRecordBytes
173+ + invalidKeyStatusRecordBytes;
174+const openingFragmentCharacters = encodeURIComponent(JSON.stringify({
175+ hasKey: true,
176+ favorites: maximumFavorites,
177+ language: contracts.WIRE_LANGUAGE.FR,
178+})).length;
179+const closeFragmentCharacters = encodeURIComponent(JSON.stringify({
180+ schemaVersion: contracts.SCHEMA_VERSION,
181+ favorites: maximumFavorites,
182+ apiKeyUpdate: {
183+ schemaVersion: contracts.SCHEMA_VERSION,
184+ action: "REPLACE",
185+ value: maximumApiKey,
186+ },
187+})).length;
188+
189+// Every bound the gate enforces, each with its repository or platform source.
190+// The pass expression is computed from these values only.
191+const capacities = {
192+ steadyStateMessages: {
193+ value: 7,
194+ source: "README.md quality gates: two-departure refresh at most 7 dictionaries (request + RESULT_BEGIN + LIMITS.departures departures + RESULT_COMMIT)",
195+ },
196+ steadyStateEncodedBytes: {
197+ value: 2048,
198+ source: "README.md quality gates: two-departure refresh at most 2048 encoded bytes",
199+ },
200+ requestBytes: {
201+ value: 192,
202+ source: "README.md quality gates: watch request at most 192 encoded bytes; travels watch-to-phone through the 654-byte outbox",
203+ },
204+ phoneDictionaryBytes: {
205+ value: 768,
206+ source: "README.md quality gates: phone-to-watch dictionary at most 768 encoded bytes; the frozen repository budget, tighter than the 2026-byte watch inbox actually configured by the watch app",
207+ },
208+ configurationMessages: {
209+ value: contracts.LIMITS.favorites + 2,
210+ source: "codec.encodeConfiguration emits CONFIG_BEGIN + LIMITS.favorites favorites + CONFIG_COMMIT, so an atomic batch is LIMITS.favorites + 2 dictionaries",
211+ },
212+ configurationEncodedBytes: {
213+ value: 8192,
214+ source: "frozen v1 configuration-batch budget carried from the foundation measurement plan in this script (power-of-two batch headroom); the binding per-dictionary gate is appMessageInboxBytes",
215+ },
216+ appMessageInboxBytes: {
217+ value: appMessageInboxBytes,
218+ source: "watch inbox capacity configured by the watch app: main.js passes no Message input bound, so Moddable pebble-appmessage.c opens app_message_open(app_message_inbox_size_maximum(), app_message_outbox_size_maximum()); RePebble SDK 4.33.1 emery/gabbro firmware returns 2026 bytes (0x7ea) for a JS-allowed app (disassembled from both qemu *_sdk_debug.elf images; 126 bytes for non-JS apps)",
219+ },
220+ appMessageOutboxBytes: {
221+ value: appMessageOutboxBytes,
222+ source: "watch outbox capacity from app_message_outbox_size_maximum() in the same RePebble SDK 4.33.1 emery/gabbro firmware images: 654 bytes (0x28e) without the 8k phone capability",
223+ },
224+ appMessageEightKilobyteBytes: {
225+ value: appMessageEightKilobyteBytes,
226+ source: "phone companions advertising the 8k message capability (prv_supports_8k) lift both AppMessage maxima to 8200 bytes (0x2008); not assumed by this budget",
227+ },
228+ watchUnsentQueueMessages: {
229+ value: 4,
230+ source: "watch contracts LIMITS.requestQueue enforced by the MessageQueue constructor (packages/watch/src/embeddedjs/message-queue.js); README watch unsent queue gate of four messages",
231+ },
232+ xsArenaBudgetBytes: {
233+ value: 56 * 1024,
234+ source: "mdbl.c XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES = 56 KiB enforced by a compile-time #error; README loaded image and RAM gate with XS arenas at least 25% free; AppMessage inbox/outbox buffers are allocated from this application heap (pebble.h)",
235+ },
236+ phoneRecordBytes: {
237+ value: 16384,
238+ source: "frozen v1 per-record budget from the foundation measurement plan for the lapinFuteConfig localStorage record (configuration.CONFIG_STORAGE_KEY); structural bound is LIMITS.favorites favorites plus LIMITS.apiKeyUtf8Bytes key bytes; far under the webview Web Storage origin quota",
239+ },
240+ watchRecordBytes: {
241+ value: WATCH_CONFIGURATION_MAX_BYTES,
242+ source: "installed RePebble Alloy SDK 4.33.1 storage-pebble.c opens the app SettingsFile with max_used_space 8192; storage.serializeWatchConfiguration enforces the same bound",
243+ },
244+ resultsRecordBytes: {
245+ value: 16384,
246+ source: "frozen v1 per-record budget from the foundation measurement plan for the lapinFuteResults localStorage record (configuration.RESULTS_STORAGE_KEY); structural bound is LIMITS.favorites entries of LIMITS.departures maximal departures",
247+ },
248+ invalidKeyStatusRecordBytes: {
249+ value: invalidKeyStatusRecordBytes,
250+ source: "derived maximum of the lapinFuteInvalidKeyStatus recovery journal (configuration.INVALID_KEY_STATUS_STORAGE_KEY), whose serialized shape is exactly { schemaVersion, keyStatus: INVALID, configurationFingerprint } per configuration.js saveInvalidConfiguration, with the fingerprint fixed at 16 lowercase hex characters (invalidKeyStatusMatches /^[0-9a-f]{16}$/)",
251+ },
252+ phoneStorageBytes: {
253+ value: (16384 * 2) + invalidKeyStatusRecordBytes,
254+ source: "sum of both frozen 16384-byte phone record budgets plus the exact recovery journal bound: total phone-local storage for configuration, cached results, and INVALID recovery",
255+ },
256+ openingFragmentCharacters: {
257+ value: 32768,
258+ source: "packages/config-page/src/config-core.js MAX_OPENING_FRAGMENT_LENGTH = 32768",
259+ },
260+ closeFragmentCharacters: {
261+ value: 32768,
262+ source: "packages/companion/src/configuration.js MAX_CLOSE_RESPONSE_LENGTH = 32768",
263+ },
264+};
265+const budget = Object.fromEntries(Object.entries(capacities).map(([name, entry]) => [
266+ name,
267+ entry.value,
268+]));
269+
29 const report = {270 const report = {
30 fixture: "fixtures/departures/foundation.json",271 fixture: "fixtures/departures/foundation.json",
31- messages: messages.length,272+ favoriteLimit: contracts.LIMITS.favorites,
32- encodedBytes: bytes,273+ steadyState: {
33- requestBytes: sizes[0],274+ messages: messages.length,
34- largestPhoneDictionaryBytes,275+ encodedBytes: bytes,
35- scenario: "steady-state APP_OPEN request plus two-departure result",276+ requestBytes: sizes[0],
36- budget: {277+ largestPhoneDictionaryBytes,
37- messages: 7,278+ },
38- encodedBytes: 2048,279+ maximumConfiguration: {
39- requestBytes: 192,280+ messages: configurationMessages.length,
40- phoneDictionaryBytes: 768,281+ encodedBytes: configurationEncodedBytes,
41- },282+ largestDictionaryBytes: largestConfigurationDictionaryBytes,
42- pass: messages.length <= 7283+ phoneRecordBytes,
43- && bytes <= 2048284+ watchRecordBytes,
44- && sizes[0] <= 192285+ openingFragmentCharacters,
45- && largestPhoneDictionaryBytes <= 768,286+ closeFragmentCharacters,
287+ },
288+ maximumResults: {
289+ entries: maximumResultEntries.length,
290+ departuresPerEntry: contracts.LIMITS.departures,
291+ recordBytes: resultsRecordBytes,
292+ },
293+ phoneStorage: {
294+ configurationRecordBytes: phoneRecordBytes,
295+ resultsRecordBytes,
296+ invalidKeyStatusRecordBytes,
297+ totalBytes: phoneStorageBytes,
298+ },
299+ scenario: "steady-state request/result plus a complete maximum-favorite atomic configuration and a maximal cached-results record",
300+ capacities,
301+ budget,
302+ pass: messages.length <= budget.steadyStateMessages
303+ && bytes <= budget.steadyStateEncodedBytes
304+ && sizes[0] <= budget.requestBytes
305+ && largestPhoneDictionaryBytes <= budget.phoneDictionaryBytes
306+ && sizes[0] <= budget.appMessageOutboxBytes
307+ && configurationMessages.length <= budget.configurationMessages
308+ && configurationEncodedBytes <= budget.configurationEncodedBytes
309+ && largestConfigurationDictionaryBytes <= budget.appMessageInboxBytes
310+ && largestConfigurationDictionaryBytes <= budget.phoneDictionaryBytes
311+ && phoneRecordBytes <= budget.phoneRecordBytes
312+ && watchRecordBytes <= budget.watchRecordBytes
313+ && watchRecordBytes <= budget.xsArenaBudgetBytes
314+ && resultsRecordBytes <= budget.resultsRecordBytes
315+ && phoneStorageBytes <= budget.phoneStorageBytes
316+ && openingFragmentCharacters <= budget.openingFragmentCharacters
317+ && closeFragmentCharacters <= budget.closeFragmentCharacters,
46 };318 };
47 console.log(JSON.stringify(report, null, 2));319 console.log(JSON.stringify(report, null, 2));
48 if (!report.pass) process.exit(1);320 if (!report.pass) process.exit(1);
modified scripts/test.mjs +2 -0
@@ -19,9 +19,11 @@ run(process.execPath, [
1919 "packages/companion/test/protocol.test.js",
2020 "packages/companion/test/companion.test.js",
2121 "packages/watch/test/manifest.test.js",
22+ "packages/watch/test/controller-storage.test.js",
2223 "packages/watch/test/protocol.test.js",
2324 "packages/watch/test/message-queue.test.js",
2425 "packages/watch/test/model.test.js",
26+ "packages/watch/test/storage.test.js",
2527 "packages/watch/test/localization.test.js",
2628 "packages/watch/test/fixture-boot.test.js",
2729 ]);
@@ -19,9 +19,11 @@ run(process.execPath, [
19 "packages/companion/test/protocol.test.js",19 "packages/companion/test/protocol.test.js",
20 "packages/companion/test/companion.test.js",20 "packages/companion/test/companion.test.js",
21 "packages/watch/test/manifest.test.js",21 "packages/watch/test/manifest.test.js",
22+ "packages/watch/test/controller-storage.test.js",
22 "packages/watch/test/protocol.test.js",23 "packages/watch/test/protocol.test.js",
23 "packages/watch/test/message-queue.test.js",24 "packages/watch/test/message-queue.test.js",
24 "packages/watch/test/model.test.js",25 "packages/watch/test/model.test.js",
26+ "packages/watch/test/storage.test.js",
25 "packages/watch/test/localization.test.js",27 "packages/watch/test/localization.test.js",
26 "packages/watch/test/fixture-boot.test.js",28 "packages/watch/test/fixture-boot.test.js",
27 ]);29 ]);