Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
e768dcb489 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>
2026-09-25 11:04:23 -03:00
Cauê Faleiros
122c645f72 fix: stop Site timers from running after a product is closed
The grade check and the layout preview run on short timers. When the
item went to the cart inside that window, no product was open and both
threw in the customer's browser, which also failed the browser tests
intermittently. Each now returns when no product is open. The cart test
waits for the empty state, which is painted on the next animation frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:04:23 -03:00
9 changed files with 208 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

@@ -87,6 +87,8 @@ try{
await waitFor(()=>site.eval(`location.pathname==='/carrinho' && getComputedStyle($('carr')).display!=='none' && getComputedStyle($('foco')).display==='none'`),'cart page');
// Removing an item is one click and can be undone.
await site.click('[data-rmi="0"]');
// The empty state is painted on the next animation frame (site-steps.js).
await waitFor(()=>site.eval(`!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens')`),'empty cart state');
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
await site.click('#desfazerBtn');
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);

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',

View File

@@ -302,6 +302,8 @@ function previaAoVivo(){
return;
}
tMont=setTimeout(()=>{
// o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
if(!modo) return;
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0);

View File

@@ -88,6 +88,8 @@ let tAval=null;
function agendaAvaliacao(){
clearTimeout(tAval);
tAval=setTimeout(()=>{
// o produto pode ter sido fechado nesses 220 ms (item foi para o carrinho)
if(!modo) return;
const r=avaliar();
const el=$('aoVivo');
if(!el) return;