feat: persist and synchronize favorites (#14)Unverified
5ff79d1 parent: f7d01bd modified
packages/companion/src/configuration.js +362 -33 | @@ -1,10 +1,22 @@ | ||
| 1 | 1 | "use strict"; |
| 2 | 2 | |
| 3 | 3 | 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 | +]; | |
| 6 | 17 | var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"]; |
| 7 | 18 | var MAX_CLOSE_RESPONSE_LENGTH = 32768; |
| 19 | +var MAX_SAFE_INTEGER = 9007199254740991; | |
| 8 | 20 | var FAVORITE_STRING_KEYS = [ |
| 9 | 21 | "id", |
| 10 | 22 | "serviceId", |
| @@ -18,8 +30,18 @@ var FAVORITE_STRING_KEYS = [ | ||
| 18 | 30 | ]; |
| 19 | 31 | |
| 20 | 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 | 45 | function isFavoriteSecretFree(favorite, firstSecret, secondSecret) { |
| 24 | 46 | var field; |
| 25 | 47 | var value; |
| @@ -40,6 +62,45 @@ function areFavoritesSecretFree(favorites, firstSecret, secondSecret) { | ||
| 40 | 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 | 104 | function normalizeLanguage(value) { |
| 44 | 105 | if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN; |
| 45 | 106 | return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR |
| @@ -61,10 +122,23 @@ function activeWatchLanguage(Pebble) { | ||
| 61 | 122 | } |
| 62 | 123 | } |
| 63 | 124 | |
| 64 | - | |
| 65 | 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 | 140 | return contracts.isObject(value) |
| 67 | - && contracts.hasOnlyKeys(value, RECORD_KEYS) | |
| 141 | + && contracts.hasOnlyKeys(value, LEGACY_CONFIG_RECORD_KEYS) | |
| 68 | 142 | && value.schemaVersion === contracts.SCHEMA_VERSION |
| 69 | 143 | && contracts.isFavoriteList(value.favorites) |
| 70 | 144 | && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey)) |
| @@ -79,50 +153,292 @@ function isConfigurationUpdate(value) { | ||
| 79 | 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 | 188 | return { |
| 84 | 189 | 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 | |
| 87 | 193 | }; |
| 88 | 194 | } |
| 89 | 195 | |
| 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; | |
| 92 | 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 | 225 | 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); | |
| 97 | 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) { | |
| 104 | - if (!isStoredConfiguration(configuration)) return false; | |
| 234 | +function verifiedRemove(storage, key) { | |
| 235 | + var previous; | |
| 236 | + var removeAttempted = false; | |
| 105 | 237 | 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); | |
| 108 | 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 | 420 | function applyConfigurationUpdate(current, update) { |
| 114 | - var apiKey; | |
| 421 | + var primApiKey; | |
| 422 | + var keyStatus; | |
| 115 | 423 | var replacementKey = null; |
| 116 | 424 | if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null; |
| 117 | 425 | 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 | + } | |
| 122 | 437 | return { |
| 123 | 438 | schemaVersion: contracts.SCHEMA_VERSION, |
| 124 | 439 | favorites: update.favorites.map(contracts.copyFavorite), |
| 125 | - apiKey: apiKey | |
| 440 | + 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 | 474 | 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), | |
| 161 | 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 | 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 | 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 | 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 | 493 | MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, |
| 175 | 494 | emptyConfiguration: emptyConfiguration, |
| 495 | + emptyResults: emptyResults, | |
| 176 | 496 | isStoredConfiguration: isStoredConfiguration, |
| 497 | + isLegacyConfiguration: isLegacyConfiguration, | |
| 177 | 498 | isConfigurationUpdate: isConfigurationUpdate, |
| 499 | + isStoredResults: isStoredResults, | |
| 178 | 500 | isFavoriteSecretFree: isFavoriteSecretFree, |
| 179 | 501 | areFavoritesSecretFree: areFavoritesSecretFree, |
| 180 | 502 | normalizeLanguage: normalizeLanguage, |
| 181 | 503 | activeWatchLanguage: activeWatchLanguage, |
| 182 | 504 | loadConfiguration: loadConfiguration, |
| 183 | 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 | 513 | applyConfigurationUpdate: applyConfigurationUpdate, |
| 185 | 514 | parseCloseFragment: parseCloseFragment, |
| 186 | 515 | 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.FR | 106 | 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_VERSION | 142 | && 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.apiKey | 191 | + 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: apiKey | 440 | + 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) { | ||
| 170 | 170 | var size; |
| 171 | 171 | if (typeof value !== "string") return false; |
| 172 | 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 | 176 | 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) { | ||
| 50 | 50 | function Companion(options) { |
| 51 | 51 | options = options || {}; |
| 52 | 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 | 57 | requiredAdapter(typeof options.XHR === "function", "XHR adapter is required"); |
| 55 | 58 | requiredAdapter(options.clock |
| 56 | 59 | && typeof options.clock.now === "function", "clock adapter is required"); |
| @@ -68,9 +71,21 @@ function Companion(options) { | ||
| 68 | 71 | this._fixture = options.fixture || null; |
| 69 | 72 | this._fixtureEnabled = false; |
| 70 | 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 | 78 | 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 | + }; | |
| 74 | 89 | this._requestGenerations = Object.create(null); |
| 75 | 90 | this._inFlightRequests = Object.create(null); |
| 76 | 91 | this._requestGeneration = 0; |
| @@ -126,9 +141,6 @@ Companion.prototype._refreshWatchState = function () { | ||
| 126 | 141 | var configured = this._configuration.favorites.map(contracts.copyFavorite); |
| 127 | 142 | this._fixtureEnabled = this._fixture !== null && configured.length === 0; |
| 128 | 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 | 146 | Companion.prototype._nextSequenceId = function () { |
| @@ -138,43 +150,104 @@ Companion.prototype._nextSequenceId = function () { | ||
| 138 | 150 | }; |
| 139 | 151 | |
| 140 | 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 | 164 | this._queue.enqueue(codec.encodeConfiguration( |
| 143 | 165 | this._nextSequenceId(), |
| 144 | 166 | this._watchFavorites, |
| 145 | - this._keyStatus, | |
| 167 | + keyStatus, | |
| 146 | 168 | configuration.activeWatchLanguage(this._Pebble) |
| 147 | 169 | )); |
| 148 | 170 | }; |
| 149 | 171 | |
| 150 | 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 | 186 | this._refreshWatchState(); |
| 153 | 187 | this._sendConfiguration(); |
| 154 | 188 | }; |
| 155 | 189 | |
| 156 | 190 | 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); | |
| 159 | 196 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); |
| 160 | 197 | }; |
| 161 | 198 | |
| 162 | 199 | Companion.prototype._onWebviewClosed = function (event) { |
| 163 | - var update = configuration.parseCloseFragment(event && event.response); | |
| 200 | + var update; | |
| 164 | 201 | var next; |
| 202 | + var pending; | |
| 203 | + var pruned; | |
| 204 | + if (!this._configurationValid) return; | |
| 205 | + update = configuration.parseCloseFragment(event && event.response); | |
| 165 | 206 | if (update === null) return; |
| 166 | 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 | 237 | this._configuration = next; |
| 169 | - this._cache = Object.create(null); | |
| 238 | + this._results = pruned; | |
| 170 | 239 | this._requestGenerations = Object.create(null); |
| 240 | + this._credentialBlocked = next.primApiKey !== null | |
| 241 | + && next.keyStatus === contracts.KEY_STATUS.INVALID; | |
| 171 | 242 | this._refreshWatchState(); |
| 172 | 243 | this._sendConfiguration(); |
| 173 | 244 | }; |
| 174 | 245 | |
| 175 | 246 | 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; | |
| 178 | 251 | if (this._findFavorite(request.favoriteId) === null) { |
| 179 | 252 | this._sendError(request, "INVALID_SERVICE"); |
| 180 | 253 | return; |
| @@ -191,20 +264,22 @@ Companion.prototype._findFavorite = function (favoriteId) { | ||
| 191 | 264 | }; |
| 192 | 265 | |
| 193 | 266 | 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; | |
| 198 | 277 | }; |
| 199 | 278 | |
| 200 | 279 | 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); | |
| 207 | 281 | }; |
| 282 | + | |
| 208 | 283 | Companion.prototype._beginRequest = function (favoriteId) { |
| 209 | 284 | this._requestGeneration += 1; |
| 210 | 285 | this._requestGenerations[favoriteId] = this._requestGeneration; |
| @@ -215,27 +290,36 @@ Companion.prototype._isLatestRequest = function (favoriteId, generation) { | ||
| 215 | 290 | return this._requestGenerations[favoriteId] === generation; |
| 216 | 291 | }; |
| 217 | 292 | |
| 218 | - | |
| 219 | 293 | Companion.prototype._dispatchRequest = function (request) { |
| 220 | - var generation = this._beginRequest(request.favoriteId); | |
| 221 | 294 | var cached = this._cachedResult(request); |
| 295 | + var cachedMessages; | |
| 222 | 296 | var fixtureResult; |
| 223 | 297 | var favorite; |
| 298 | + var generation; | |
| 299 | + var age; | |
| 300 | + var refreshRequired; | |
| 224 | 301 | 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; | |
| 227 | 311 | } |
| 228 | 312 | if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) { |
| 229 | 313 | fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId); |
| 230 | - this._cacheResult(fixtureResult); | |
| 231 | 314 | this._queue.enqueue(codec.encodeResult(fixtureResult)); |
| 232 | 315 | return; |
| 233 | 316 | } |
| 234 | - if (this._configuration.apiKey === null) { | |
| 317 | + if (this._configuration.primApiKey === null) { | |
| 235 | 318 | this._sendError(request, "API_KEY_REQUIRED"); |
| 236 | 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 | 323 | this._sendError(request, "API_KEY_INVALID"); |
| 240 | 324 | return; |
| 241 | 325 | } |
| @@ -244,6 +328,7 @@ Companion.prototype._dispatchRequest = function (request) { | ||
| 244 | 328 | this._sendError(request, "INVALID_SERVICE"); |
| 245 | 329 | return; |
| 246 | 330 | } |
| 331 | + generation = this._beginRequest(request.favoriteId); | |
| 247 | 332 | this._fetch(request, favorite, generation); |
| 248 | 333 | }; |
| 249 | 334 | |
| @@ -251,8 +336,8 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 251 | 336 | var self = this; |
| 252 | 337 | var xhr; |
| 253 | 338 | 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(); | |
| 256 | 341 | if (this._backendUrl.indexOf("https://") !== 0) { |
| 257 | 342 | this._sendError(request, "SOURCE_UNAVAILABLE"); |
| 258 | 343 | return; |
| @@ -260,8 +345,12 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 260 | 345 | |
| 261 | 346 | function once(callback) { |
| 262 | 347 | return function () { |
| 348 | + var latency; | |
| 263 | 349 | if (settled) return; |
| 264 | 350 | settled = true; |
| 351 | + latency = Math.max(0, self._clock.now() - startedAt); | |
| 352 | + self._metrics.lastLatencyMs = latency; | |
| 353 | + self._metrics.totalLatencyMs += latency; | |
| 265 | 354 | if (self._inFlightRequests[generation] === xhr) { |
| 266 | 355 | delete self._inFlightRequests[generation]; |
| 267 | 356 | } |
| @@ -274,17 +363,24 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 274 | 363 | xhr = new this._XHR(); |
| 275 | 364 | xhr.open("POST", this._backendUrl, true); |
| 276 | 365 | xhr.timeout = contracts.LIMITS.httpTimeoutMs; |
| 277 | - xhr.setRequestHeader("Authorization", "Bearer " + key); | |
| 278 | 366 | xhr.setRequestHeader("Content-Type", "application/json"); |
| 279 | 367 | xhr.setRequestHeader("Accept", "application/json"); |
| 280 | 368 | 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)) { | |
| 285 | 378 | key = null; |
| 379 | + this._sendError(request, "API_KEY_REQUIRED"); | |
| 286 | 380 | return; |
| 287 | 381 | } |
| 382 | + xhr.setRequestHeader("Authorization", "Bearer " + key); | |
| 383 | + this._metrics.requests += 1; | |
| 288 | 384 | this._inFlightRequests[generation] = xhr; |
| 289 | 385 | xhr.send(JSON.stringify({ |
| 290 | 386 | schemaVersion: contracts.SCHEMA_VERSION, |
| @@ -300,6 +396,7 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 300 | 396 | } |
| 301 | 397 | if (!settled) { |
| 302 | 398 | settled = true; |
| 399 | + this._metrics.failures += 1; | |
| 303 | 400 | if (this._isLatestRequest(request.favoriteId, generation)) { |
| 304 | 401 | this._sendError(request, "SOURCE_UNAVAILABLE"); |
| 305 | 402 | } |
| @@ -326,14 +423,21 @@ Companion.prototype._handleResponse = function (request, xhr) { | ||
| 326 | 423 | if (!contracts.isDepartureResult(body) |
| 327 | 424 | || body.requestId !== request.requestId |
| 328 | 425 | || body.favoriteId !== request.favoriteId) { |
| 426 | + this._metrics.failures += 1; | |
| 329 | 427 | this._sendError(request, "INVALID_RESPONSE"); |
| 330 | 428 | return; |
| 331 | 429 | } |
| 430 | + this._metrics.successes += 1; | |
| 332 | 431 | this._cacheResult(body); |
| 333 | 432 | this._queue.enqueue(codec.encodeResult(body)); |
| 334 | 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 | 441 | if (body !== null && contracts.isErrorResult(body)) { |
| 338 | 442 | if (body.requestId !== request.requestId |
| 339 | 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 | 496 | Companion.prototype._sendErrorResult = function (error) { |
| 497 | + var invalid; | |
| 393 | 498 | 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 | + }; | |
| 399 | 524 | }; |
| 400 | 525 | |
| 401 | 526 | 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.clock | 58 | 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.requestId | 424 | || 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.requestId | 442 | 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 @@ | ||
| 3 | 3 | var test = require("node:test"); |
| 4 | 4 | var assert = require("node:assert/strict"); |
| 5 | 5 | var vm = require("node:vm"); |
| 6 | +var path = require("node:path"); | |
| 7 | +var url = require("node:url"); | |
| 6 | 8 | var companion = require("../src"); |
| 7 | 9 | var fixture = require("../../../fixtures/departures/foundation.json"); |
| 8 | 10 | var fakes = require("./fakes"); |
| @@ -14,13 +16,17 @@ var T = contracts.MESSAGE_TYPE; | ||
| 14 | 16 | var TEST_KEY = "test-personal-prim-key"; |
| 15 | 17 | |
| 16 | 18 | 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; | |
| 21 | 23 | var defer; |
| 22 | 24 | var instance; |
| 23 | 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 | 30 | defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true); |
| 25 | 31 | instance = companion.createCompanion({ |
| 26 | 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 | 68 | target.Pebble.emit("appmessage", { |
| 63 | 69 | payload: codec.encodeRequest({ |
| 64 | 70 | requestId: requestId, |
| 65 | 71 | favoriteId: fixture.favorite.id, |
| 66 | - trigger: contracts.REQUEST_TRIGGER.APP_OPEN | |
| 72 | + trigger: typeof trigger === "number" ? trigger : contracts.REQUEST_TRIGGER.APP_OPEN | |
| 67 | 73 | }) |
| 68 | 74 | }); |
| 69 | 75 | } |
| @@ -94,6 +100,17 @@ function responseFor(requestId) { | ||
| 94 | 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 | 114 | test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () { |
| 98 | 115 | var target = harness(); |
| 99 | 116 | var stored; |
| @@ -101,10 +118,11 @@ test("close fragment atomically stores one favorites-and-key record and exposes | ||
| 101 | 118 | var pageState; |
| 102 | 119 | |
| 103 | 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 | 122 | 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); | |
| 108 | 126 | assert.deepEqual(stored.favorites, [fixture.favorite]); |
| 109 | 127 | |
| 110 | 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 | 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 | 166 | loaded = configuration.loadConfiguration(storage); |
| 149 | 167 | |
| 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); | |
| 152 | 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 | 176 | ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) { |
| 154 | 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 | 200 | closeWith(target, configurationPayload("REPLACE", TEST_KEY)); |
| 178 | 201 | 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]); | |
| 182 | 206 | |
| 183 | 207 | 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]); | |
| 187 | 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 | 240 | test("untrusted webview responses are bounded and cannot persist a key inside favorites", function () { |
| 190 | 241 | var target = harness(); |
| 191 | 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 | 263 | closeWith(target, keepLeak); |
| 213 | 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 | 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 | 434 | assert.equal(target.xhr.instances.length, 2); |
| 384 | 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 | 749 | test("a late older response cannot overwrite or emit after the newest request for a favorite", function () { |
| 387 | 750 | var target = harness(); |
| 388 | 751 | 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_OPEN | 72 | + 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) { | ||
| 83 | 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 | 90 | FakeStorage.prototype.keys = function () { |
| 87 | 91 | return Object.keys(this.values); |
| 88 | 92 | }; |
| @@ -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 @@ | ||
| 91 | 91 | <h3>Privacy and data flow</h3> |
| 92 | 92 | <p>This app page sets no cookies and uses no analytics or telemetry.</p> |
| 93 | 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 | 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 | 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 | 97 | <h3>Data and credits</h3> |
| @@ -112,6 +113,7 @@ | ||
| 112 | 113 | <h3>Vie privée et circulation des données</h3> |
| 113 | 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 | 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 | 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 | 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 | 119 | <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) { | ||
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 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 | 55 | export function isLineColor(value) { |
| @@ -81,7 +84,7 @@ export const COPY = { | ||
| 81 | 84 | keyRemovePending: "The saved key will be removed.", |
| 82 | 85 | keyReplacementPending: "A replacement key is ready to save.", |
| 83 | 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 | 88 | favoritesTitle: "Favorite departures", |
| 86 | 89 | favoritesEmpty: "No favorites yet. Search for a stop to add one.", |
| 87 | 90 | favoriteAddTitle: "Add a favorite", |
| @@ -129,7 +132,7 @@ export const COPY = { | ||
| 129 | 132 | keyRemovePending: "La clé enregistrée sera supprimée.", |
| 130 | 133 | keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.", |
| 131 | 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 | 136 | favoritesTitle: "Départs favoris", |
| 134 | 137 | favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", |
| 135 | 138 | favoriteAddTitle: "Ajouter un favori", |
| @@ -379,7 +382,7 @@ export function reduceConfigState(state, action) { | ||
| 379 | 382 | // ApiKeyUpdate carries the value only for REPLACE. |
| 380 | 383 | |
| 381 | 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 | 386 | if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong"; |
| 384 | 387 | return null; |
| 385 | 388 | } |
| @@ -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", | ||
| 196 | 196 | const key = "stored-personal-key"; |
| 197 | 197 | const url = companionConfiguration.configurationUrl( |
| 198 | 198 | "https://config.example.test/index.html", |
| 199 | - { schemaVersion: 1, favorites: fixtureList, apiKey: key }, | |
| 199 | + { schemaVersion: 1, favorites: fixtureList, primApiKey: key, keyStatus: 1 }, | |
| 200 | 200 | "fr_FR", |
| 201 | 201 | ); |
| 202 | 202 | assert.equal(typeof url, "string"); |
| @@ -286,7 +286,7 @@ test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => { | ||
| 286 | 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 | 290 | assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null); |
| 291 | 291 | assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong"); |
| 292 | 292 | assert.equal(apiKeyError("é".repeat(256)), null); |
| @@ -294,6 +294,7 @@ test("replace validation rejects oversized or line-bearing keys in both locales" | ||
| 294 | 294 | assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong"); |
| 295 | 295 | assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline"); |
| 296 | 296 | assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline"); |
| 297 | + assert.equal(apiKeyError("key\u0001header"), "keyErrorNewline"); | |
| 297 | 298 | |
| 298 | 299 | const tooLong = planConfigResult({ |
| 299 | 300 | hasKey: false, |
| @@ -566,6 +567,8 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and | ||
| 566 | 567 | "localStorage", |
| 567 | 568 | "no analytics or telemetry", |
| 568 | 569 | "aucun cookie", |
| 570 | + "survives app updates and uninstall/reinstall", | |
| 571 | + "Désinstaller l’application de la montre", | |
| 569 | 572 | "Référentiel des lignes", |
| 570 | 573 | "conditions d’utilisation IDFM/PRIM", |
| 571 | 574 | "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 { | ||
| 226 | 226 | } |
| 227 | 227 | |
| 228 | 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 | 235 | 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 | ||
| 95 | 95 | assert.equal(isFavorite({ ...favorite, unknown: true }), false); |
| 96 | 96 | assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false); |
| 97 | 97 | assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false); |
| 98 | + assert.equal(isFavorite({ ...favorite, stopLabel: "unsafe\u0001label" }), false); | |
| 98 | 99 | assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false); |
| 99 | 100 | assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false); |
| 100 | 101 | assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false); |
| @@ -137,6 +138,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules | ||
| 137 | 138 | assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true); |
| 138 | 139 | assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false); |
| 139 | 140 | assert.equal(isPersonalApiKey("key\nheader"), false); |
| 141 | + assert.equal(isPersonalApiKey("key\u0001header"), false); | |
| 140 | 142 | }); |
| 141 | 143 | |
| 142 | 144 | 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) { | ||
| 145 | 145 | export function boundedString(value, maximum) { |
| 146 | 146 | if (typeof value !== "string") return false; |
| 147 | 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 | 151 | 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 @@ | ||
| 1 | 1 | import { QUEUE_STATE } from "./message-queue.js"; |
| 2 | 2 | import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js"; |
| 3 | +import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js"; | |
| 3 | 4 | |
| 4 | 5 | function eventName(received) { |
| 5 | 6 | if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED"; |
| @@ -24,9 +25,12 @@ export function createController(options) { | ||
| 24 | 25 | const clock = options.clock; |
| 25 | 26 | const queue = options.queue; |
| 26 | 27 | const model = options.model; |
| 28 | + const storage = options.storage || null; | |
| 27 | 29 | const view = options.view; |
| 28 | 30 | const receiver = new ProtocolReceiver(); |
| 31 | + let committedConfiguration = null; | |
| 29 | 32 | let sequence = 0; |
| 33 | + let restoredRequestPending = false; | |
| 30 | 34 | let started = false; |
| 31 | 35 | let closed = false; |
| 32 | 36 | |
| @@ -60,7 +64,16 @@ export function createController(options) { | ||
| 60 | 64 | start() { |
| 61 | 65 | if (closed || started) return controller; |
| 62 | 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 | 77 | return controller; |
| 65 | 78 | }, |
| 66 | 79 | |
| @@ -68,8 +81,21 @@ export function createController(options) { | ||
| 68 | 81 | if (closed) return RECEIVE_RESULT.REJECTED; |
| 69 | 82 | const received = receiver.receive(message); |
| 70 | 83 | const event = eventName(received); |
| 84 | + if (event === "CONFIG_COMMITTED") restoredRequestPending = false; | |
| 71 | 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 | 99 | render(); |
| 74 | 100 | if (effect.requestFixture) requestFixture(); |
| 75 | 101 | } |
| @@ -77,7 +103,13 @@ export function createController(options) { | ||
| 77 | 103 | }, |
| 78 | 104 | |
| 79 | 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 | 113 | receiver.cancelExpectedResponse(); |
| 82 | 114 | model.markSendFailure(); |
| 83 | 115 | 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({ | ||
| 45 | 45 | clock: { now: Date.now }, |
| 46 | 46 | queue, |
| 47 | 47 | model: new WatchModel(), |
| 48 | + storage: localStorage, | |
| 48 | 49 | view |
| 49 | 50 | }); |
| 50 | 51 | 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 | view | 49 | view |
| 49 | }); | 50 | }); |
| 50 | controller.start(); | 51 | controller.start(); |
modified
packages/watch/src/embeddedjs/manifest.json +1 -0 | @@ -11,6 +11,7 @@ | ||
| 11 | 11 | "./protocol", |
| 12 | 12 | "./message-queue", |
| 13 | 13 | "./localization", |
| 14 | + "./storage", | |
| 14 | 15 | "./ui", |
| 15 | 16 | "./contracts" |
| 16 | 17 | ] |
| @@ -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) { | ||
| 262 | 262 | } |
| 263 | 263 | |
| 264 | 264 | export class ProtocolReceiver { |
| 265 | - constructor() { | |
| 265 | + constructor(initialConfiguration = null) { | |
| 266 | 266 | this.configuration = null; |
| 267 | 267 | this.result = null; |
| 268 | 268 | this.error = null; |
| 269 | 269 | this.configStage = null; |
| 270 | 270 | this.resultStage = null; |
| 271 | 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 | 284 | expectResponse(requestId, favoriteId) { |
| 275 | 285 | if (!boundedString(requestId, LIMITS.idUtf8Bytes) |
| 276 | 286 | || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false; |
| 277 | - this.expected = { requestId, favoriteId }; | |
| 287 | + this.expected = { requestId, favoriteId, refresh: false }; | |
| 288 | + this.refreshCandidate = null; | |
| 278 | 289 | this.resultStage = null; |
| 279 | 290 | return true; |
| 280 | 291 | } |
| 281 | 292 | |
| 282 | 293 | cancelExpectedResponse() { |
| 283 | 294 | this.expected = null; |
| 295 | + this.refreshCandidate = null; | |
| 284 | 296 | this.resultStage = null; |
| 285 | 297 | } |
| 286 | 298 | |
| @@ -366,6 +378,24 @@ export class ProtocolReceiver { | ||
| 366 | 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 | 399 | if (type === MESSAGE_TYPE.RESULT_BEGIN) { |
| 370 | 400 | if (!this.expected |
| 371 | 401 | || this.expected.requestId !== requestId |
| @@ -423,9 +453,14 @@ export class ProtocolReceiver { | ||
| 423 | 453 | this.resultStage = null; |
| 424 | 454 | return RECEIVE_RESULT.REJECTED; |
| 425 | 455 | } |
| 456 | + const refresh = this.expected.refresh; | |
| 426 | 457 | this.result = copyResult(stage); |
| 427 | 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 | 464 | return RECEIVE_RESULT.RESULT_COMMITTED; |
| 430 | 465 | } |
| 431 | 466 | |
| @@ -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.expected | 400 | if (!this.expected |
| 371 | || this.expected.requestId !== requestId | 401 | || 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 = [ | ||
| 33 | 33 | "./protocol", |
| 34 | 34 | "./message-queue", |
| 35 | 35 | "./localization", |
| 36 | + "./storage", | |
| 36 | 37 | "./ui", |
| 37 | 38 | "./contracts" |
| 38 | 39 | ]; |
| @@ -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", () = | ||
| 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 | 244 | test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => { |
| 221 | 245 | const receiver = new ProtocolReceiver(); |
| 222 | 246 | commitConfiguration(receiver); |
| @@ -252,3 +276,32 @@ test("matching error commits without destroying the prior complete result", () = | ||
| 252 | 276 | 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 | +}); | |
| @@ -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: 30 | 276 | 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"; | ||
| 4 | 4 | const require = createRequire(import.meta.url); |
| 5 | 5 | const codec = require("../packages/companion/src/codec.js"); |
| 6 | 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 | 12 | const fixture = JSON.parse(readFileSync( |
| 8 | 13 | new URL("../fixtures/departures/foundation.json", import.meta.url), |
| 9 | 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 | 20 | function dictionarySize(dictionary) { |
| 13 | 21 | const sizes = Object.values(dictionary).map((value) => ( |
| 14 | 22 | typeof value === "string" ? Buffer.byteLength(value, "utf8") + 1 : 4 |
| @@ -16,33 +24,297 @@ function dictionarySize(dictionary) { | ||
| 16 | 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 | 86 | const request = codec.encodeRequest({ |
| 20 | 87 | requestId: fixture.result.requestId, |
| 21 | 88 | favoriteId: fixture.favorite.id, |
| 22 | 89 | trigger: contracts.REQUEST_TRIGGER.APP_OPEN, |
| 23 | 90 | }); |
| 24 | 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 | 127 | const messages = [request, ...response]; |
| 128 | +const configurationSizes = configurationMessages.map(dictionarySize); | |
| 26 | 129 | const sizes = messages.map(dictionarySize); |
| 27 | 130 | const bytes = sizes.reduce((total, size) => total + size, 0); |
| 28 | 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 | 270 | const report = { |
| 30 | 271 | 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, | |
| 46 | 318 | }; |
| 47 | 319 | console.log(JSON.stringify(report, null, 2)); |
| 48 | 320 | 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 : 4 | 22 | 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 <= 7 | 283 | + phoneRecordBytes, |
| 43 | - && bytes <= 2048 | 284 | + watchRecordBytes, |
| 44 | - && sizes[0] <= 192 | 285 | + 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, [ | ||
| 19 | 19 | "packages/companion/test/protocol.test.js", |
| 20 | 20 | "packages/companion/test/companion.test.js", |
| 21 | 21 | "packages/watch/test/manifest.test.js", |
| 22 | + "packages/watch/test/controller-storage.test.js", | |
| 22 | 23 | "packages/watch/test/protocol.test.js", |
| 23 | 24 | "packages/watch/test/message-queue.test.js", |
| 24 | 25 | "packages/watch/test/model.test.js", |
| 26 | + "packages/watch/test/storage.test.js", | |
| 25 | 27 | "packages/watch/test/localization.test.js", |
| 26 | 28 | "packages/watch/test/fixture-boot.test.js", |
| 27 | 29 | ]); |
| @@ -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 | ]); |