PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Modules / MCP / Support / WriteGuard.php

WriteGuard.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Modules/MCP/Support/WriteGuard.php

706 lines 27.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Support;
4
5 use FluentBooking\Framework\Support\Arr;
6
7 defined('ABSPATH') || exit;
8
9 /**
10 * Safety rails for mutating MCP tools. Annotations are UX hints, not safety —
11 * this is where real protection lives for the writes that touch someone's
12 * calendar (create, reschedule, cancel).
13 *
14 * Two mechanisms:
15 *
16 * 1. Dry-run + confirmation token. A destructive action called with
17 * dry_run:true computes the effect, binds it to BOTH the target's current
18 * state (a fingerprint) AND the exact parameters that were previewed (a
19 * parameter digest), stashes a short-lived record, and returns a preview.
20 * To execute, the caller passes that confirm_token back with the SAME
21 * parameters. If the record changed in the meantime the fingerprint no
22 * longer matches; if the caller changed what it is asking for, the digest
23 * no longer matches. Either way we force a fresh preview — so an agent can
24 * neither act on a booking somebody else already moved, nor execute a
25 * different change from the one a human approved.
26 *
27 * 2. Idempotency keys. The caller passes an idempotency_key; the first
28 * execution for that key is recorded, and a retry with the same key returns
29 * the first result instead of booking or emailing twice. This is the guard
30 * against an agent re-issuing a create after a timeout, so it MUST wrap the
31 * confirm-token check rather than sit inside it — see idempotent().
32 *
33 * Storage is a dedicated options-backed store rather than transients. Two
34 * reasons, both of which the transient API cannot give us:
35 *
36 * - Atomic claim. `INSERT IGNORE` against the unique index on `option_name`
37 * lets exactly one of N concurrent requests claim a key — the primitive core
38 * uses for its own locks. `get_transient()` followed by `delete_transient()`
39 * is a read-then-write race: two agents holding the same token both read it
40 * before either deletes, and both execute. (`add_option()` is not a
41 * substitute; see claim().)
42 * - Durability. An object-cache flush drops transients. Losing a confirm token
43 * degrades safely (a fresh dry-run is required); losing an idempotency
44 * record does not — it degrades into the duplicate write the key existed to
45 * prevent.
46 *
47 * CONTRACT (enforced by scripts/check-mcp-budget.php and
48 * scripts/check-mcp-permissions.php): every ability whose annotations include
49 * `destructive => true` MUST treat `dry_run` as non-mutating for EVERY action it
50 * exposes, and MUST route each mutating action through confirm() before
51 * mutating. `create-booking` and `manage-booking` are the reference
52 * implementations. When adding a new destructive ability — here or in Pro, which
53 * registers under the same namespace via fluent_booking/mcp_loaded — follow this
54 * contract; the permission-matrix gate calls every action of every destructive
55 * tool with dry_run:true and fails the build if any row count moves.
56 *
57 * @since 2.2.6
58 */
59 class WriteGuard
60 {
61 const CONFIRM_TTL = 300; // 5 minutes to confirm a previewed action.
62
63 const IDEM_TTL = 86400; // remember an idempotency key for a day.
64
65 /**
66 * Option-name prefix for the record store. Kept short: option_name is
67 * indexed at 191 characters and every key here ends in an md5.
68 */
69 const STORE_PREFIX = 'fcal_mcp_g_';
70
71 const CONFIRM_NEXT_STEP = 'Call this tool again with EXACTLY the same parameters plus confirm_token (and an idempotency_key) to execute. Changing any parameter invalidates the token.';
72
73 /**
74 * Build a dry-run preview response with a confirmation token bound to both
75 * the target's current state and the parameters being previewed.
76 *
77 * @param string $tool Ability name (namespacing the token).
78 * @param string $entityKey Stable id of the target, e.g. "booking:42".
79 * @param string $fingerprint A string capturing the mutable state we care
80 * about (e.g. "scheduled|2026-09-01 14:00:00").
81 * If this differs at execute time, the token is
82 * rejected.
83 * @param array $preview The human/agent-facing preview payload.
84 * @param string $paramsDigest Digest of the parameters this preview
85 * describes, from paramsDigest(). If the caller
86 * executes with different parameters, the token
87 * is rejected.
88 *
89 * @return array
90 */
91 public static function preview($tool, $entityKey, $fingerprint, array $preview, $paramsDigest = '')
92 {
93 // wp_generate_password draws from wp_rand, which prefers random_int;
94 // wp_generate_uuid4 falls back to mt_rand. For a token that gates a
95 // write, take the stronger source.
96 $token = substr(wp_hash($tool . '|' . $entityKey . '|' . $fingerprint . '|' . wp_generate_password(32, false, false)), 0, 32);
97
98 self::write(self::confirmKey($tool, $entityKey), [
99 'token' => $token,
100 'fingerprint' => (string) $fingerprint,
101 'params' => (string) $paramsDigest,
102 ], self::CONFIRM_TTL);
103
104 return [
105 'dry_run' => true,
106 'preview' => $preview,
107 'confirm_token' => $token,
108 'expires_in_seconds' => self::CONFIRM_TTL,
109 ];
110 }
111
112 /**
113 * Validate a confirm_token against the target's current fingerprint and the
114 * parameters it was minted for.
115 *
116 * @param string $tool
117 * @param string $entityKey
118 * @param string $currentFingerprint
119 * @param string $token
120 * @param string $paramsDigest Digest of the parameters being executed.
121 *
122 * @return true|\WP_Error
123 */
124 public static function confirm($tool, $entityKey, $currentFingerprint, $token, $paramsDigest = '')
125 {
126 if (empty($token)) {
127 return MCPHelper::error(
128 'confirmation_required',
129 __('This action cannot be undone. Call again with dry_run:true to preview it, then pass the returned confirm_token to execute.', 'fluent-booking'),
130 ['next_step' => 'set dry_run:true']
131 );
132 }
133
134 $key = self::confirmKey($tool, $entityKey);
135 $stored = self::read($key);
136
137 if (!is_array($stored) || empty($stored['token'])) {
138 return MCPHelper::error(
139 'confirmation_expired',
140 __('Your confirmation has expired. Run a fresh dry_run to preview and get a new confirm_token.', 'fluent-booking'),
141 ['next_step' => 'set dry_run:true']
142 );
143 }
144
145 if (!hash_equals((string) $stored['token'], (string) $token)) {
146 return MCPHelper::error(
147 'confirmation_invalid',
148 __('The confirm_token does not match. Run a fresh dry_run.', 'fluent-booking'),
149 ['next_step' => 'set dry_run:true']
150 );
151 }
152
153 if ((string) $stored['fingerprint'] !== (string) $currentFingerprint) {
154 self::delete($key);
155 return MCPHelper::error(
156 'state_changed',
157 __('The record changed since you previewed it. Run a fresh dry_run to see the current state before executing.', 'fluent-booking'),
158 ['next_step' => 'set dry_run:true']
159 );
160 }
161
162 // The token authorises the change that was PREVIEWED, not merely the
163 // record it was previewed against. Without this an agent could preview a
164 // cancellation with no refund, then execute the same cancellation with
165 // refund_payment:true on the strength of the operator's approval of the
166 // first one.
167 if ((string) $stored['params'] !== (string) $paramsDigest) {
168 self::delete($key);
169 return MCPHelper::error(
170 'parameters_changed',
171 __('These are not the parameters you previewed. A confirm_token authorises the exact change it was issued for. Run a fresh dry_run with the parameters you actually want.', 'fluent-booking'),
172 ['next_step' => 'set dry_run:true']
173 );
174 }
175
176 // One-shot, and atomically so: two concurrent requests holding the same
177 // token both reach this line, and claim() lets exactly one through.
178 //
179 // Keyed on the TOKEN, not on the entity. Keying it on the entity would
180 // make the marker outlive the token it describes and block the next
181 // legitimately-minted token for the rest of the TTL — so an agent that
182 // previewed and cancelled one booking could not preview and reschedule
183 // the same booking for another five minutes, and would be told its fresh
184 // token was "already used".
185 if (!self::claim(self::usedKey($token), 1, self::CONFIRM_TTL)) {
186 return MCPHelper::error(
187 'confirmation_expired',
188 __('That confirm_token has already been used. Run a fresh dry_run.', 'fluent-booking'),
189 ['next_step' => 'set dry_run:true']
190 );
191 }
192
193 self::delete($key);
194
195 return true;
196 }
197
198 /**
199 * Run $fn at most once per idempotency key (per user + tool + entity). A
200 * repeat call with the same key on the SAME entity returns the first
201 * result. If no key is supplied, $fn runs normally (no dedupe) — keys are
202 * recommended but not forced.
203 *
204 * ORDERING MATTERS. This must be the OUTERMOST wrapper on a destructive
205 * write, with the confirm() check inside $fn. The reverse — confirm() first,
206 * idempotency inside — cannot work: confirm() consumes the token, so the
207 * retry this method exists to absorb is rejected as `confirmation_expired`
208 * before the cached result is ever consulted, and the agent's recovery path
209 * is a fresh dry_run and a second booking.
210 *
211 * The key is entity-scoped so reusing one idempotency_key across different
212 * records (e.g. "cancel-1" for two bookings) can't replay the first
213 * booking's result and silently skip the second mutation.
214 *
215 * WHAT IS STORED is a reference, never the response. The response carries
216 * BookingProjector::full() — unmasked email, phone, country, internal note
217 * and every answer the attendee gave — and wp_options is the table most
218 * likely to end up in a support export or a staging clone, where no
219 * exporter or eraser keyed on the booking tables would ever find it. The
220 * replay callback rebuilds the response from the live record instead.
221 *
222 * @param string $tool
223 * @param string $entityKey
224 * @param string $key
225 * @param callable $fn
226 * @param string $paramsDigest
227 * @param callable|null $replay Rebuilds the response from the stored
228 * reference. Without one a replay returns the
229 * reference itself.
230 *
231 * @return mixed
232 */
233 public static function idempotent($tool, $entityKey, $key, callable $fn, $paramsDigest = '', $replay = null)
234 {
235 if (empty($key)) {
236 return $fn();
237 }
238
239 $cacheKey = self::idemKey($tool, $entityKey, $key);
240 $lockKey = $cacheKey . '_lock';
241
242 $cached = self::read($cacheKey);
243
244 if (is_array($cached) && array_key_exists('ref', $cached)) {
245 // A key identifies one attempt at one change, not a licence to skip
246 // any later change. Reusing a key with DIFFERENT parameters — say a
247 // second reschedule of the same booking to a new time — would
248 // otherwise return the first call's success and quietly perform no
249 // move at all, which is the worst of both worlds: the agent is told
250 // it worked and nothing happened.
251 if ((string) Arr::get($cached, 'params', '') !== (string) $paramsDigest) {
252 return MCPHelper::error(
253 'idempotency_conflict',
254 __('This idempotency_key was already used for a different request. Use a fresh key for a new change; reuse a key only when retrying the identical call.', 'fluent-booking'),
255 ['next_step' => 'retry with a new idempotency_key']
256 );
257 }
258
259 $ref = (array) $cached['ref'];
260
261 if (is_callable($replay)) {
262 $rebuilt = call_user_func($replay, $ref);
263
264 if (is_array($rebuilt)) {
265 return self::flagReplay($rebuilt);
266 }
267 }
268
269 return self::flagReplay(MCPHelper::success($ref));
270 }
271
272 // Claim the key before running, not after. get-then-set would let two
273 // concurrent retries of the same request both miss and both execute,
274 // which is the failure the key exists to prevent.
275 if (!self::claim($lockKey, 1, 120)) {
276 return MCPHelper::error(
277 'in_progress',
278 __('Another call with this idempotency_key is still running. Wait for it to finish rather than retrying — retrying is what this key exists to make safe.', 'fluent-booking'),
279 ['next_step' => 'poll with get-booking, or retry in a few seconds']
280 );
281 }
282
283 // Everything from here to the release is inside try/finally, recording
284 // the result included: a mutation that succeeded and a record that was
285 // never written is exactly the divergence the key exists to prevent, so
286 // a failure to persist has to be reported rather than swallowed.
287 try {
288 $result = $fn();
289
290 if (!is_wp_error($result)) {
291 // Only successful results are recorded — a failure should stay
292 // retryable with the same key.
293 if (!self::write($cacheKey, ['ref' => self::resultRef($result), 'params' => (string) $paramsDigest], self::IDEM_TTL)
294 && is_array($result)) {
295 $result['idempotency_warning'] = __('This change was applied, but the idempotency record could not be stored. Do not retry with the same key — check the result before acting again.', 'fluent-booking');
296 }
297 }
298
299 return $result;
300 } finally {
301 self::delete($lockKey);
302 }
303 }
304
305 /**
306 * Mark a response as a replay, in `meta` and nowhere else.
307 *
308 * The two return paths above used to place it differently — the rebuilt one
309 * merged into the envelope, the reference one into `data` — so an agent
310 * checking one place missed the other and re-issued a write it had already
311 * made, which is the failure the key exists to prevent.
312 *
313 * @param array $response
314 *
315 * @return array
316 */
317 private static function flagReplay($response)
318 {
319 if (!isset($response['meta']) || !is_array($response['meta'])) {
320 $response['meta'] = [];
321 }
322
323 $response['meta']['idempotent_replay'] = true;
324
325 return $response;
326 }
327
328 /**
329 * Reduce a write's response to the identifiers a replay can rebuild from.
330 *
331 * @param mixed $result
332 *
333 * @return array
334 */
335 private static function resultRef($result)
336 {
337 if (!is_array($result)) {
338 return [];
339 }
340
341 $ref = [];
342
343 // A whitelist of identity and outcome fields, all scalar and none of
344 // them attendee data. Anything richer is rebuilt by the replay
345 // callback from the live record.
346 foreach (['id', 'action', 'created', 'message'] as $key) {
347 if (isset($result['data'][$key]) && is_scalar($result['data'][$key])) {
348 $ref[$key] = $result['data'][$key];
349 }
350 }
351
352 $bookingId = Arr::get($result, 'data.booking.id');
353
354 if ($bookingId) {
355 $ref['booking_id'] = (int) $bookingId;
356 }
357
358 return $ref;
359 }
360
361 /**
362 * A stable digest of the parameters that actually change what a call does.
363 *
364 * The three control parameters are excluded by definition: `dry_run` differs
365 * between the preview and the execution, `confirm_token` is absent from the
366 * preview, and `idempotency_key` legitimately varies between a call and its
367 * retry. Everything else is binding.
368 *
369 * @param array $params
370 * @param array $ignore Extra keys to exclude.
371 *
372 * @return string
373 */
374 public static function paramsDigest($params, $ignore = [])
375 {
376 $params = (array) $params;
377
378 foreach (array_merge(['dry_run', 'confirm_token', 'idempotency_key'], (array) $ignore) as $key) {
379 unset($params[$key]);
380 }
381
382 return md5((string) wp_json_encode(self::canonicalize($params)));
383 }
384
385 /**
386 * Recursively sort keys so an agent that emits the same parameters in a
387 * different order still matches its own preview.
388 *
389 * @param mixed $value
390 * @return mixed
391 */
392 private static function canonicalize($value)
393 {
394 if (!is_array($value)) {
395 return is_scalar($value) || $value === null ? $value : (string) wp_json_encode($value);
396 }
397
398 $out = [];
399
400 foreach ($value as $key => $item) {
401 $out[$key] = self::canonicalize($item);
402 }
403
404 // Associative arrays are order-insensitive; lists are not.
405 if (array_keys($out) !== range(0, count($out) - 1)) {
406 ksort($out);
407 }
408
409 return $out;
410 }
411
412 /**
413 * Fingerprint for a booking: everything a caller could act on stale.
414 * Deliberately includes updated_at so an edit we do not otherwise model
415 * (a note change, a payment transition) still invalidates a pending token.
416 *
417 * @param \FluentBooking\App\Models\Booking $booking
418 *
419 * @return string
420 */
421 public static function bookingFingerprint($booking)
422 {
423 return implode('|', [
424 $booking->status,
425 $booking->start_time,
426 $booking->updated_at instanceof \DateTimeInterface
427 ? $booking->updated_at->format('Y-m-d H:i:s')
428 : $booking->updated_at,
429 ]);
430 }
431
432 /**
433 * How many rows one SELECT of the purge reads, and how many such passes it
434 * makes before giving up for the day. The product is the ceiling on a
435 * single run: enough for a busy site, bounded enough that the daily task
436 * cannot outrun a 30-second Action Scheduler tick and be killed mid-sweep.
437 */
438 const PURGE_BATCH = 500;
439
440 const PURGE_MAX_PASSES = 40;
441
442 /**
443 * Drop every expired record. Wired to the daily scheduler — the store is
444 * options-backed, so unlike transients nothing prunes it for us.
445 *
446 * @return int rows removed
447 */
448 public static function purgeExpired()
449 {
450 global $wpdb;
451
452 $removed = 0;
453 $now = time();
454 $offset = 0;
455
456 for ($pass = 0; $pass < self::PURGE_MAX_PASSES; $pass++) {
457 // option_value comes back with the name. Reading it here rather
458 // than calling get_option() per row turns three queries a row into
459 // one query a batch.
460 $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
461 $wpdb->prepare(
462 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s ORDER BY option_id ASC LIMIT %d OFFSET %d",
463 $wpdb->esc_like(self::STORE_PREFIX) . '%',
464 $wpdb->esc_like(SlotLock::PREFIX) . '%',
465 self::PURGE_BATCH,
466 $offset
467 ),
468 ARRAY_A
469 );
470
471 if (!$rows) {
472 break;
473 }
474
475 $expired = [];
476
477 foreach ($rows as $row) {
478 $record = maybe_unserialize($row['option_value']);
479
480 // Delete only rows whose stored expiry is genuinely in the
481 // past, and re-check the value we just read rather than
482 // trusting the name alone: a preview that renewed the record
483 // between the SELECT and the DELETE would otherwise have its
484 // fresh token swept away.
485 if (is_array($record) && !empty($record['expires']) && $record['expires'] >= $now) {
486 continue;
487 }
488
489 // A row with no usable envelope is a leftover from an older
490 // format or a partial write; it can never be honoured, so it
491 // goes too.
492 $expired[] = $row['option_name'];
493 }
494
495 if ($expired) {
496 $placeholders = implode(', ', array_fill(0, count($expired), '%s'));
497
498 $removed += (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
499 $wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name IN ({$placeholders})", $expired) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- %s placeholders built by array_fill(); values passed to prepare()
500 );
501
502 foreach ($expired as $name) {
503 self::forgetCached($name);
504 }
505 }
506
507 // Rows that survived stay in the table, so the next batch has to
508 // start past them rather than re-reading the same live records.
509 $offset += count($rows) - count($expired);
510
511 if (count($rows) < self::PURGE_BATCH) {
512 break;
513 }
514 }
515
516 wp_cache_delete('alloptions', 'options');
517
518 return $removed;
519 }
520
521 /**
522 * Atomic claim: exactly one of N concurrent callers gets true.
523 *
524 * `INSERT IGNORE` against the unique index on `option_name`, which is the
525 * primitive WordPress core itself uses for locking
526 * (`WP_Upgrader::create_lock()`). Notably NOT `add_option()`: that looks
527 * atomic and is not. Core checks existence first and then issues
528 *
529 * INSERT ... ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)
530 *
531 * so a second caller whose row already exists performs an UPDATE, changes
532 * the value (our expiry differs), gets a non-zero affected-row count, and is
533 * told it took the claim. Two callers, two `true`s, no mutual exclusion —
534 * and for a token consumption or a refund that is the whole ballgame.
535 * `INSERT IGNORE` returns 0 rows when the key exists, which is the answer we
536 * actually need.
537 *
538 * @param string $key
539 * @param mixed $value
540 * @param int $ttl
541 *
542 * @return bool true when this caller took the claim
543 */
544 /**
545 * Take a short exclusive window for one repeatable action.
546 *
547 * @param string $key caller-scoped identifier
548 * @param int $ttl seconds the window lasts
549 *
550 * @return bool true when the caller may proceed
551 */
552 public static function cooldown($key, $ttl)
553 {
554 return self::claim(self::STORE_PREFIX . 'cd_' . md5($key), 1, $ttl);
555 }
556
557 private static function claim($key, $value, $ttl)
558 {
559 // A stale claim must not block forever: clear an expired one, then try.
560 // Deliberately before the insert and never after — stealing a claim we
561 // did not place is how one caller frees another caller's live lock.
562 $existing = self::readRaw($key);
563
564 if (is_array($existing) && !empty($existing['expires']) && $existing['expires'] < time()) {
565 self::delete($key);
566 }
567
568 return self::insertIgnore($key, ['value' => $value, 'expires' => time() + (int) $ttl]);
569 }
570
571 /**
572 * @param string $key
573 * @param array $record
574 * @return bool true when the row did not exist and this call created it
575 */
576 private static function insertIgnore($key, $record)
577 {
578 global $wpdb;
579
580 $inserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
581 $wpdb->prepare(
582 "INSERT IGNORE INTO `{$wpdb->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')",
583 $key,
584 maybe_serialize($record)
585 )
586 );
587
588 // The row went in behind the options cache's back, so a `notoptions`
589 // entry saying it does not exist has to go.
590 self::forgetCached($key);
591
592 return (bool) $inserted;
593 }
594
595 /**
596 * Drop an option from the object cache, including the negative cache.
597 *
598 * @param string $key
599 */
600 private static function forgetCached($key)
601 {
602 wp_cache_delete($key, 'options');
603
604 $notoptions = wp_cache_get('notoptions', 'options');
605
606 if (is_array($notoptions) && isset($notoptions[$key])) {
607 unset($notoptions[$key]);
608 wp_cache_set('notoptions', $notoptions, 'options');
609 }
610 }
611
612 /**
613 * The stored record with its envelope, without the expiry check read()
614 * applies. Used where the expiry itself is the thing being inspected.
615 *
616 * @param string $key
617 * @return array|null
618 */
619 private static function readRaw($key)
620 {
621 $record = get_option($key);
622
623 return is_array($record) ? $record : null;
624 }
625
626 /**
627 * @param string $key
628 * @return array|null
629 */
630 private static function read($key)
631 {
632 $record = get_option($key);
633
634 if (!is_array($record) || empty($record['expires'])) {
635 return null;
636 }
637
638 if ($record['expires'] < time()) {
639 delete_option($key);
640 return null;
641 }
642
643 return isset($record['value']) && is_array($record['value']) ? $record['value'] : null;
644 }
645
646 /**
647 * @param string $key
648 * @param array $value
649 * @param int $ttl
650 */
651 private static function write($key, $value, $ttl)
652 {
653 $record = ['value' => $value, 'expires' => time() + (int) $ttl];
654
655 if (self::insertIgnore($key, $record)) {
656 return true;
657 }
658
659 $updated = update_option($key, $record, false);
660
661 self::forgetCached($key);
662
663 // update_option() returns false when the stored value is already
664 // identical, which is a success for our purposes — so confirm by
665 // reading rather than trusting the return.
666 if ($updated) {
667 return true;
668 }
669
670 $stored = self::readRaw($key);
671
672 return is_array($stored) && isset($stored['value']) && $stored['value'] === $value;
673 }
674
675 /**
676 * @param string $key
677 */
678 private static function delete($key)
679 {
680 delete_option($key);
681
682 self::forgetCached($key);
683 }
684
685 private static function confirmKey($tool, $entityKey)
686 {
687 // User-scoped: a token minted by one operator/session can't be consumed
688 // by another, even for the same booking.
689 return self::STORE_PREFIX . 'c' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey);
690 }
691
692 private static function idemKey($tool, $entityKey, $key)
693 {
694 return self::STORE_PREFIX . 'i' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey . '|' . $key);
695 }
696
697 /**
698 * The one-shot marker for a single token. Tokens are already unguessable and
699 * user-scoped, so the token alone identifies the consumption.
700 */
701 private static function usedKey($token)
702 {
703 return self::STORE_PREFIX . 'u_' . md5((string) $token);
704 }
705 }
706