feat: keep the Tiny connection alive and show when it is not
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s

Connecting now asks for offline_access, retrying once without it if Tiny
refuses the scope. Renewal failures are stored: a refused refresh token
marks the connection lost and is not sent again (the Kanban previously
still said "conectado"), a transient failure shows as a warning until the
next renewal, and a session grant with under 12 hours left is flagged.
Tiny errors on the callback return to the Kanban instead of a 422.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-25 11:04:23 -03:00
parent 122c645f72
commit e768dcb489
6 changed files with 202 additions and 30 deletions

View File

@@ -316,12 +316,21 @@ def tiny_test(user=Depends(operator)):
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
error: str = Query('', max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
single-use state an operator created is what authorises it."""
single-use state an operator created is what authorises it. Tiny reports a
refusal (the operator declined, or offline access is not allowed for this
application) with `error` instead of a code."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
if error == 'invalid_scope':
retry = tiny.TinyAuth().without_offline(state)
audit('tiny_offline_refused')
return RedirectResponse(retry, status_code=303)
if error or not code:
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
who = tiny.TinyAuth().complete(code, state)
except tiny.TinyError:
audit('tiny_connect_failed')

View File

@@ -107,6 +107,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Renewal health, cleared by the next successful renewal or connection.
-- refused_at: the provider rejected the refresh token, so only a new
-- connection helps. offline: the grant is not bound to a login session.
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false;
-- Single-use states for an operator-started OAuth connection. They protect the
-- callback, which arrives cross-site without the operator's cookie.
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (

View File

@@ -42,6 +42,9 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
# How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7
STATE_MINUTES = 10
# Renewal runs about every four hours against a one-day refresh token, so
# less than this left means renewals have been failing for hours.
EXPIRY_WARNING = timedelta(hours=12)
# Brazil has had no daylight saving since 2019; the order date is the local day.
BRASILIA = timezone(timedelta(hours=-3))
@@ -86,44 +89,78 @@ class TinyAuth:
self.http = httpx.Client(timeout=20, transport=transport)
self.clock = clock
def authorize_url(self, operator):
def authorize_url(self, operator, offline=True):
"""Start a connection. The state is single-use, short-lived, and only an
authenticated operator can create one, which is what protects the
callback: Tiny's redirect back is cross-site, so the operator's
SameSite=Strict cookie does not travel with it."""
SameSite=Strict cookie does not travel with it.
offline_access asks for a grant that is not bound to a login session,
so the connection lasts as long as it keeps being renewed. Tiny's
documented refresh token otherwise lasts one day."""
state = secrets.token_urlsafe(32)
with self.connect() as c:
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
'redirect_uri': self.redirect_uri, 'scope': 'openid',
'redirect_uri': self.redirect_uri,
'scope': 'openid offline_access' if offline else 'openid',
'state': state})
def _claim(self, c, state):
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
return row['operator']
def complete(self, code, state):
"""Exchange the authorisation code; returns the operator who started it."""
with self.connect() as c:
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
operator = self._claim(c, state)
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
'redirect_uri': self.redirect_uri})
self._store(c, tokens, row['operator'])
return row['operator']
self._store(c, tokens, operator)
return operator
def without_offline(self, state):
"""Tiny refused the offline_access scope for this application: spend the
operator's state and start again with a session-bound grant."""
with self.connect() as c:
operator = self._claim(c, state)
return self.authorize_url(operator, offline=False)
def status(self):
with self.connect() as c:
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
if not row:
return {'connected': False}
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
return {'connected': not expired, 'connected_by': row['connected_by'],
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
now = datetime.now(timezone.utc)
expires = row['refresh_expires_at']
expired = bool(expires and expires <= now)
if row['refused_at']:
problem = 'refused'
elif expired:
problem = 'expired'
elif row['refresh_failed_at']:
problem = 'renewal-failing'
elif expires and expires - now < EXPIRY_WARNING:
problem = 'expiring'
else:
problem = None
return {'connected': not expired and not row['refused_at'], 'problem': problem,
'connected_by': row['connected_by'], 'connected_at': row['connected_at'],
'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'],
'failed_at': row['refresh_failed_at']}
def access_token(self):
"""A valid access token, refreshing (and rotating) under a row lock."""
"""A valid access token, refreshing (and rotating) under a row lock.
A failed renewal is recorded after the lock is released, so the Kanban
can show it. A refused refresh token is never tried again: only a new
connection helps, and retrying it would only repeat the refusal."""
with self.connect() as c:
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
FOR UPDATE''').fetchone()
@@ -132,11 +169,25 @@ class TinyAuth:
now = datetime.now(timezone.utc)
if row['access_expires_at'] > now + timedelta(seconds=60):
return row['access_token']
if row['refused_at']:
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
try:
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
except (TinyError, httpx.HTTPError, ValueError) as exc:
failure = exc
else:
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
# Only against the token that failed: another worker may have renewed since.
with self.connect() as c:
c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s,
refused_at=CASE WHEN %s THEN now() ELSE refused_at END
WHERE provider='tiny' AND refresh_token=%s''',
(str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected),
row['refresh_token']))
raise failure
def _token(self, form):
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
@@ -152,18 +203,21 @@ class TinyAuth:
def _store(self, c, tokens, operator, refreshed=False):
now = datetime.now(timezone.utc)
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
# An offline grant reports refresh_expires_in 0: no fixed end.
refresh_in = tokens.get('refresh_expires_in')
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
offline = 'offline_access' in str(tokens.get('scope') or '').split()
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(), offline=EXCLUDED.offline,
refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL,
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
operator, refreshed, refreshed))
operator, offline, refreshed, refreshed))
# Orders ------------------------------------------------------------------

View File

@@ -278,6 +278,17 @@ From the report already sent. These are dated promises, not backlog.
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
guard by search first and only then by a second delivery. Not yet run
against the client's account.
**Staying connected (2026-09-25):** Tiny documents a 4-hour access token and
a 1-day refresh token; the worker renews about every 4 hours. The connection
now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this
application the callback retries once without it). Renewal failures are
stored: a refused refresh token marks the connection lost and is not retried,
a transient failure shows as a warning until the next success, and a
session grant with under 12 hours left is flagged. Previously a refused
refresh still showed "Tiny conectado". Whether Tiny grants offline access,
and whether a session grant has an undocumented maximum, is only known on
the client's account. There is no alert channel yet (no e-mail; WhatsApp
is fake): problems show on the Kanban only.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job).
**Built (2026-09-24):** each paid item gets a PDF the width of the film and

View File

@@ -33,8 +33,12 @@ def run():
def exercise():
issued = []
server = {'mode': 'session', 'calls': 0}
def token_server(request):
server['calls'] += 1
if server['mode'] == 'down':
return httpx.Response(503, text='unavailable')
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
if form['grant_type'] == 'authorization_code':
@@ -44,8 +48,13 @@ def exercise():
return httpx.Response(400, json={'error': 'invalid_grant'})
n = len(issued) + 1
issued.append(f'refresh-{n}')
if server['mode'] == 'offline':
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0,
'scope': 'openid offline_access profile'})
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
'scope': 'openid profile'})
auth = TinyAuth(transport=httpx.MockTransport(token_server))
assert auth.status() == {'connected': False}
@@ -59,6 +68,7 @@ def exercise():
query = {k: v[0] for k, v in parse_qs(url.query).items()}
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
assert query['scope'] == 'openid offline_access'
try:
auth.complete('good-code', 'forged-state')
raise AssertionError('a state no operator created must be refused')
@@ -72,6 +82,7 @@ def exercise():
pass
status = auth.status()
assert status['connected'] and status['connected_by'] == 'operator@example.test'
assert status['problem'] is None and not status['offline'] and status['expires_at']
assert auth.access_token() == 'access-1'
print('PASS: operator-started state is required, single-use, and stores the connection')
@@ -85,6 +96,23 @@ def exercise():
assert auth.access_token() == 'access-2'
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
# Tiny unreachable: the failure is shown, the connection kept, and the next
# renewal clears it.
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
server['mode'] = 'down'
try:
auth.access_token()
raise AssertionError('an unreachable token server must fail the renewal')
except httpx.HTTPError:
pass
status = auth.status()
assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at']
server['mode'] = 'session'
assert auth.access_token() == 'access-3'
assert auth.status()['problem'] is None
print('PASS: a failed renewal is shown and cleared by the next successful one')
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
with connect() as c:
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
@@ -94,12 +122,63 @@ def exercise():
raise AssertionError('a refused refresh must report the connection as lost')
except TinyNotConnected:
pass
status = auth.status()
assert not status['connected'] and status['problem'] == 'refused'
calls = server['calls']
try:
auth.access_token()
raise AssertionError('a refused connection must stay refused')
except TinyNotConnected:
pass
assert server['calls'] == calls, 'a refused refresh token must not be sent again'
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
(datetime.now(timezone.utc) - timedelta(seconds=1),))
assert not auth.status()['connected']
print('PASS: revoked or expired connections report that Tiny must be connected again')
# Reconnecting with an offline grant: no daily end, and the refusal is cleared.
server['mode'] = 'offline'
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
auth.complete('good-code', state)
status = auth.status()
assert status['connected'] and status['offline'] and status['expires_at'] is None
assert status['problem'] is None
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
auth.access_token()
assert auth.status()['offline'] and auth.status()['expires_at'] is None
print('PASS: an offline grant is stored without a daily expiry and survives renewal')
# Tiny refusing offline_access restarts the authorisation without it, once.
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
retry = parse_qs(urlparse(auth.without_offline(state)).query)
assert retry['scope'] == ['openid'] and retry['state'][0] != state
try:
auth.without_offline(state)
raise AssertionError('the refused state must be spent')
except TinyError:
pass
# A session grant close to its end is flagged while renewals are not happening.
server['mode'] = 'session'
auth.complete('good-code', retry['state'][0])
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'")
status = auth.status()
assert status['connected'] and status['problem'] == 'expiring' and not status['offline']
print('PASS: a refused offline scope falls back once; a connection near its end is flagged')
# Tiny's redirect back with an error instead of a code.
from app.api.operator import tiny_callback
declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed'
assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed'
scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location'])
assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid']
assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed'
print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it')
if __name__ == '__main__':
run()

View File

@@ -483,8 +483,21 @@ if(tinyResult)history.replaceState(null,'',location.pathname);
function tinyHeader(){
const t=board.tiny||{};const el=$('tiny');
if(!t.configured){el.textContent='';return;}
el.textContent=t.connected?'Tiny conectado':'Tiny não conectado';
el.style.setProperty('--dot',t.connected?'var(--fin)':'var(--warn)');
const trouble=!t.connected||t.problem;
el.textContent=!t.connected?'Tiny não conectado':t.problem?'Tiny: verificar conexão':'Tiny conectado';
el.style.setProperty('--dot',trouble?'var(--warn)':'var(--fin)');
}
const TINY_PROBLEMS={refused:'O Tiny recusou a renovação da conexão. Conecte de novo.',
expired:'A conexão com o Tiny expirou. Conecte de novo.',
'renewal-failing':'A última renovação falhou; o sistema tenta de novo sozinho.',
expiring:'A conexão expira em breve e não está sendo renovada.'};
function tinyDetail(t){
if(!t.connected_by)return 'Conecte com uma conta do Tiny.';
const parts=[t.connected_by+' · conectado em '+when(t.connected_at),'renovado em '+when(t.renewed_at),
t.offline?'sem expiração diária':t.expires_at?'válido até '+when(t.expires_at):'',
'envio de pedidos '+(t.orders_enabled?'ativo':'desativado')].filter(Boolean);
const problem=TINY_PROBLEMS[t.problem];
return (problem?problem+(t.failed_at&&t.problem!=='expired'?' (falha em '+when(t.failed_at)+')':'')+' · ':'')+parts.join(' · ');
}
function integration(name,state,tone,text,actions=[]){
const box=node('div',undefined,'integration');
@@ -633,9 +646,8 @@ function renderIntegrations(){
if(t.connected){
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
say(r.ok?'Conexão com o Tiny ok: pedidos, contatos e os 4 produtos conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));}
cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn',
t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'):
'Conecte com uma conta do Tiny.',actions));
cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado',
t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions));
}
const mp=board.providers?.payment;
cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',