Files
logos-protocol/cpp/logos_rpc_status.h
Dario LipicarandClaude Opus 4.8 ef24bd70d9 fix(protocol): re-exchange token on provider rejection (#26)
When a provider rejects a call for a stale/unrecognized token it now returns a
structured "unauthorized" sentinel (logos_rpc_status.h) instead of a bare
QVariant(). LogosAPIClient detects it below the typed wrapper, drops the cached
token, re-runs capability_module.requestModule and retries the call once —
closing the gap where a stale token was reused forever (the consumer-latched-dead
failure mode) and lazily recovering the common provider-reload case.

The return VALUE is the only provider->consumer channel available on every
transport (qt_local/qt_remote/plain) without an ABI break, since the QtRO
dispatch slot returns a single QVariant — hence a value sentinel.

Backward compatible:
- OLD consumers convert the sentinel identically to QVariant() for every
  scalar/string/LogosResult return, so they keep seeing today's empty/failed
  result.
- OLD providers return bare QVariant(); a NEW consumer never matches the
  sentinel and so never re-exchanges against them.
The retry is bounded to one attempt and fires ONLY on the explicit sentinel
(never a legitimately-empty result), so no loops and no misfire.

Downstream note: logos-qt-sdk's test_auth_token_enforcement.cpp asserts
!isValid() on unauthorized calls; those become isUnauthorizedSentinel() when it
re-pins (the security property — no provider dispatch — is unchanged).

Tests: tests/protocol/test_token_reexchange.cpp covers provider-side emission,
sync/async re-exchange+retry, bounded retry (no loop), the false-positive guard
(a legit empty return must not re-exchange), and old-consumer decode.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:18:43 -03:00

68 lines
2.7 KiB
C++

#ifndef LOGOS_RPC_STATUS_H
#define LOGOS_RPC_STATUS_H
#include <QVariant>
#include <QVariantMap>
#include <QJsonObject>
#include <QString>
#include <QMetaType>
// A provider returns this reserved single-key map INSTEAD of a bare QVariant()
// when it rejects a call for an unrecognized/stale auth token. Backward
// compatible by construction:
//
// * OLD consumers convert it exactly like a bare QVariant() for every
// scalar / string / LogosResult return (`.toString()` -> "",
// `qvariant_cast<T>` -> T{}, `.value<LogosResult>()` -> default), so they
// keep seeing today's "empty/failed" result — nothing new to handle.
// * NEW consumers (LogosAPIClient) detect it BELOW the generated typed
// wrapper, drop the stale token, re-run capability_module.requestModule and
// retry the call once. The sentinel is stripped before it ever reaches the
// wrapper.
//
// The key is namespaced + double-underscored so it never collides with a real
// field, and the detector requires size()==1 with the exact key AND value, so a
// legitimate map result can't false-match. It is the ONLY provider->consumer
// signal available on every transport (qt_local/qt_remote/plain) without an ABI
// break, because the QtRO dispatch slot returns a single QVariant.
namespace logos {
inline constexpr char kRpcStatusKey[] = "__logos_rpc_status__";
inline constexpr char kRpcStatusUnauthorized[] = "unauthorized";
// The provider-side rejection value. QVariantMap round-trips faithfully on all
// three transports (qt_local pass-through; qt_remote QtRO-serialized; plain via
// qvariant_rpc_value.cpp map<->JSON), so the consumer always sees a QVariantMap.
inline QVariant makeUnauthorizedSentinel()
{
QVariantMap m;
m.insert(QString::fromLatin1(kRpcStatusKey),
QString::fromLatin1(kRpcStatusUnauthorized));
return m;
}
// True only for the exact rejection sentinel. Handles QVariantMap (the normal
// case on every transport) and QJsonObject (defensive: some json_convert paths
// historically produced QJsonObject). Never matches a normal empty result.
inline bool isUnauthorizedSentinel(const QVariant& v)
{
const QString key = QString::fromLatin1(kRpcStatusKey);
const QString want = QString::fromLatin1(kRpcStatusUnauthorized);
switch (v.userType()) {
case QMetaType::QVariantMap: {
const QVariantMap m = v.toMap();
return m.size() == 1 && m.value(key).toString() == want;
}
case QMetaType::QJsonObject: {
const QJsonObject o = v.toJsonObject();
return o.size() == 1 && o.value(key).toString() == want;
}
default:
return false;
}
}
} // namespace logos
#endif // LOGOS_RPC_STATUS_H