Guias

Uma plataforma como hiperlink

Ao terminar este guia, uma plataforma web que já roda no domínio dela, com servidor e login próprios, aparece no menu do ambiente, abre numa janela e fala com o ambiente pela mesma ponte de um vssh-app: avisos, diálogos, os arquivos que a pessoa escolhe, a cor de destaque. Ela continua publicada como sempre foi, sem pacote e sem instalação numa estação.

O ambiente chama isso de hiperlink. É a família certa quando a plataforma já existe e serve muita gente de um lugar só, como uma biblioteca de referências ou um hub de notebooks. Um app que roda na estação da pessoa, como ela, continua sendo um vssh-app.

O manifesto

O ambiente lê o manifesto PWA do site: a página aponta para ele com <link rel="manifest">, ou ele mora em /manifest.webmanifest ou /manifest.json na raiz da origem. Do manifesto saem o nome, a descrição, a URL de início, o ícone, os atalhos e a cor. O que é do ambiente vai num membro vssh, com os nomes e as formas do vssh-app.json:

{
  "name": "Cadernos",
  "description": "Os cadernos do laboratório.",
  "start_url": "/",
  "icons": [{ "src": "/icone.svg", "type": "image/svg+xml", "sizes": "any" }],
  "shortcuts": [{ "name": "Projetos", "url": "/projetos" }],
  "theme_color": "#0f766e",
  "vssh": {
    "category": "Office",
    "window": { "width": 1100, "height": 720 },
    "provides": [],
    "requires": [],
    "opens": { "urls": ["cadernos.exemplo.org"] }
  }
}

Os atalhos entram no botão direito do ícone, e só os da origem do manifesto. opens.urls faz um link para aquele host, clicado em qualquer janela do ambiente, abrir na janela da plataforma. Um campo do membro vssh fora da forma do vssh-app.json some sem erro, como num app.

Cada campo, com as faixas aceitas, a escolha do ícone, os tetos da busca e a tabela que leva as categories do W3C a uma seção do menu, está na referência O manifesto de um hiperlink.

O quadro

A janela é um quadro do portal do ambiente, e a plataforma precisa aceitá-lo:

Content-Security-Policy: frame-ancestors https://<portal>

Com frame-ancestors, o navegador ignora o X-Frame-Options; sem ele, um X-Frame-Options: DENY ou SAMEORIGIN recusa o quadro. O painel do admin mede isso e mostra numa pílula ao lado da plataforma (abre no VSSH, recusa o quadro, pede login, sem resposta).

O cookie de sessão da plataforma acompanha o quadro quando a plataforma e o portal estão no mesmo site, como cadernos.exemplo.org e vssh.exemplo.org. Em sites diferentes, o cookie precisa de SameSite=None; Secure, e um navegador que bloqueia cookie de terceiros o recusa assim mesmo.

A página de login do provedor de identidade costuma recusar o quadro. Dentro da janela, a plataforma recebe a pessoa pela ponte (ver A identidade).

No cliente de desktop a janela não é um quadro: a plataforma abre num <webview>, como documento de cima, numa partição só dos hiperlinks. Ali o frame-ancestors não se aplica, o cookie da plataforma é de primeira parte, e a página de login do provedor abre dentro da janela. O SDK fala com o shell pela ponte que o cliente põe na página (window.vsshPonteDoCliente), e a plataforma não muda uma linha para isso. Cada mensagem leva a origem lida fora do alcance da página, e o shell recusa o que não vem da origem da plataforma. Um window.open que o SDK não desviou vai ao navegador do sistema, e o menu de reserva do cliente (recortar, copiar, colar) não abre num campo da plataforma.

O cadastro

O admin do ambiente cola a URL da plataforma na página Hiperlinks do painel: a página dela ou o próprio manifesto. Quem busca é o servidor do portal, e um site sem manifesto legível é recusado com o motivo. O id nasce ali, escolhido pelo admin ou tirado do primeiro rótulo do host (cadernos de cadernos.exemplo.org), com a forma de um id de app e começando por letra. Numa estação com um vssh-app instalado com o mesmo id, quem aparece é o app.

Depois o admin dá acesso aos vínculos (os grupos do provedor de identidade) que devem ver a plataforma. Um hiperlink vale em toda estação, porque não depende de servidor Linux, e sem vínculo nenhum só o admin o vê. Na ficha, ele preenche o slug da aplicação da plataforma no Authentik, que a identidade confere. "Reler o manifesto" refaz a leitura depois de uma mudança no site; o ambiente não relê sozinho.

A ficha mede, quando abre, se a página de início aceita o quadro. O portal a pede sem cookie e lê os cabeçalhos pela regra do navegador:

pílulao que a plataforma respondeu
abre no VSSHa página, com o portal no frame-ancestors ou sem recusa nenhuma
recusa o quadroX-Frame-Options: DENY ou SAMEORIGIN, ou um frame-ancestors sem o portal
pede loginum redirecionamento para outra origem, que costuma ser o login da plataforma
sem respostanada, um 404 ou um 5xx

A janela

A pessoa abre a plataforma como abre um app: menu iniciar, Launchpad, barra, atalho na área de trabalho, Alt+Tab. A janela carrega a URL da plataforma direto no navegador da pessoa, sem start, sem stop e sem o proxy do portal no meio.

pedidopara onde a janela vai
abrira URL de início
um atalho do manifestoo caminho dele, na origem da plataforma
um link de um host de opens.urlso link, quando ele é da origem da plataforma
qualquer um deles com a janela já abertaa mesma janela navega até lá; sem destino próprio, ela fica onde está

Um destino de outra origem nunca entra na janela. O menu do cabeçalho não tem "Ver log do backend" nem "Permissões de arquivo", que são de um app com processo na estação, e tem "Abrir no navegador", que leva o endereço da janela a uma aba fora do ambiente. Com a plataforma fora do ar, ou recusando o quadro, a janela mostra a página de erro do próprio navegador e continua de pé.

O SDK

A plataforma carrega o SDK web do portal, e não de uma cópia:

<script src="https://<portal>/sdk/vssh.js"></script>

O portal serve /sdk/<arquivo> sem sessão, com Cross-Origin-Resource-Policy: cross-origin e Access-Control-Allow-Origin: *, e com os mesmos bytes do _sdk/ de um app. O cabeçalho é o que deixa a página da plataforma carregar o script, e o CORS é o que deixa o @font-face do Tuff buscar as fontes.

O SDK reconhece que veio de outra origem e, antes de falar a ponte, aperta a mão do shell: manda uma mensagem ao quadro pai, com o alvo na origem do portal, e espera a resposta por até cinco segundos. Até lá, vssh.noAmbiente é false, e as chamadas feitas esperam a resposta para seguir pela ponte ou pelo equivalente do navegador. vssh.pronto resolve quando o SDK sabe:

if (await vssh.pronto) {
  vssh.avisos.avisar('Projeto 7 sincronizado', 'Cadernos');
} else {
  // numa aba comum, ou num quadro de outro site
}

Com a resposta, o SDK passa a fazer o que faz num vssh-app: os links que iriam para uma aba nova abrem no navegador do ambiente, o menu de contexto do navegador fica de fora (um campo editável o mantém), o document.title vira o título da janela, e a mídia entra no mixer. Num quadro de outro site, ou sem resposta, nada disso acontece, e a página segue como numa aba comum.

O que a ponte alcança

Uma plataforma recebe a ponte de um vssh-app com um recorte. A referência diz, em cada verbo e em cada evento, se ele vale num hiperlink, e vssh.app.capacidades() devolve a lista já recortada.

fica de forapor quê
vssh.segredoso cofre vai para o processo do app na estação, e a plataforma não tem processo lá
vssh.salaso provedor abre o WebSocket na origem do portal, e a plataforma tem backend próprio
vssh.configuracoesa seção de um app em Configurações roda o script dele no shell
vssh.gpua GPU é da estação
a área de transferência de arquivosela tem os caminhos que a pessoa copiou, sem passar pelo seletor

Um verbo fora do recorte responde com a promessa rejeitada, e a mensagem do erro é o motivo.

A identidade

vssh.app.identidade() responde { bilhete, expira }: um JWT que o portal assina para a origem da plataforma, com a pessoa dentro. A página o entrega ao backend dela, que o confere e abre a sessão própria, no lugar do redirecionamento ao login:

if (await vssh.pronto) {
  const { bilhete } = await vssh.app.identidade();
  // A rota de entrada é da plataforma, com o nome que ela quiser.
  const r = await fetch('/api/auth/vssh', { method: 'POST', headers: { authorization: `Bearer ${bilhete}` } });
  if (r.ok) location.reload();
}

O backend confere o bilhete como confere o id_token do provedor, pela chave pública do portal em https://<portal>/.well-known/jwks.json:

o que conferirvalor
a assinaturaRS256, com a chave do kid do cabeçalho
typ no cabeçalhovssh-bilhete+jwt, para um bilhete nunca passar por id_token
issa origem do portal
auda origem da plataforma
exp60 s depois do iat
jtiguardado até o exp, para recusar o mesmo bilhete duas vezes

O sub é o uid do Authentik, o mesmo que o login da plataforma recebe quando o provedor dela usa o modo hashed_user_id; com ele, a sessão aberta pelo bilhete é a da mesma pessoa. O bilhete traz também email, name, preferred_username e groups.

O portal só emite para quem vê o hiperlink e passa na política da aplicação da plataforma no Authentik, a mesma que o login confere. Fora do ambiente, identidade() recusa, e a plataforma segue com o login dela. Dentro dele, a promessa rejeitada traz o motivo:

recusaquando
404a pessoa não vê o hiperlink
403a política da aplicação no Authentik não deixa a pessoa entrar
409a ficha não tem o slug da aplicação
503falta ao portal a chave do bilhete, o endereço ou o token do Authentik, ou o Authentik não respondeu em 4 s

Uma conta de serviço não recebe bilhete.

Oferecer uma capacidade

Uma plataforma oferece uma capacidade como um app oferece (ver chamar outro app), com provides e capacidades no membro vssh. O pedido de outro app chega do servidor do portal, com o bilhete da pessoa no Authorization e o id de quem pediu em X-Vssh-Chamador, só nas rotas debaixo do prefixo declarado. O backend confere o bilhete como na entrada, e responde como responderia à própria página. A resposta volta a quem pediu como a de um app: status, tipo e corpo. Como o pedido sai do servidor do portal, a plataforma não precisa de CORS para ele.

o relévalor
métodosGET, POST, PUT, PATCH e DELETE; outro responde 405
caminhosó debaixo dos prefixos de capacidades, conferido depois de resolver .. e %2e%2e; fora deles, 403
prazo30 s, e 504 quando passa; 502 quando a plataforma não responde
respostaaté 10 MB
corpo JSONaté 100 kB, o teto dos parsers do portal

Arquivos

A plataforma alcança o que a pessoa escolhe no seletor, e só isso. Escolher concede o caminho, como num app (ver consentimento), e os verbos de abrir, abrir com, abrir a pasta, arrastar, copiar e imprimir também pedem um caminho concedido:

const caminho = await vssh.arquivos.escolherArquivo('Importar caderno', 'Notebooks (*.ipynb)');
if (caminho) {
  const texto = await vssh.arquivos.ler(caminho);
  importar(texto);
  vssh.arquivos.abrirPasta(caminho.replace(/\/[^/]+$/, ''));
}

O arquivo vem pela ponte, do servidor da pessoa, para a página. O backend da plataforma não alcança a estação; quem leva os bytes até ele é a página, se for o caso.

Duas coisas de um vssh-app ficam de fora. vssh.arquivos.urlFor lança, porque o portal só serve o arquivo à origem dele: leia com lerBytes e monte o Blob na página. E o SDK não troca a API de arquivos do navegador: showOpenFilePicker e showDirectoryPicker continuam abrindo o computador da pessoa, como em qualquer site.

A aparência

O Tuff vem do mesmo lugar que o SDK, https://<portal>/sdk/tuff/<arquivo>, com as folhas, as fontes e os ícones de _sdk/tuff/. A cor de destaque que a pessoa escolheu chega pela ponte, e vssh.aparencia a entrega como entrega a um app:

const pintar = (tokens) => {
  for (const [nome, valor] of Object.entries(tokens || {})) document.documentElement.style.setProperty(nome, valor);
};
vssh.pronto.then(() => pintar(vssh.aparencia.tokens()));
vssh.aparencia.aoMudar(pintar);

aoMudar chama de volta também quando o aperto de mão traz a cor, depois da carga.

Testar

Abra a plataforma numa aba comum: await vssh.pronto dá false, e cada verbo cai no equivalente do navegador ou numa recusa, sem lançar na carga. Dentro do ambiente, o console do quadro mostra a ponte: um verbo recusado pelo recorte responde com o motivo, e um que o shell não conhece também.