Protocolo de publicação do Lume — v0.1

Especificação pública. Estado: rascunho v0.1 (2026-10-27). Primeira implementação: o site da Shore (www.shore.pt). Pode ser implementada por qualquer site, agência ou CMS. Mudanças incompatíveis só numa versão nova (/v1).

Para que serve

O Lume escreve, revê e aprova notícias e artigos. Este protocolo é a porta pela qual o Lume os entrega a um site. O site continua a mandar:

  • decide que campos, idiomas e categorias existem;
  • escolhe quem pode publicar, através do âmbito do token;
  • guarda tudo no seu próprio backoffice, com a sua auditoria.

O Lume nunca cria autores. Só escolhe entre os que o site expõe.

Princípios

  1. O site declara, o Lume adapta-se. GET /capabilities diz que tipos, campos, limites e blocos existem. O Lume não envia o que o site não aceita.
  2. Conteúdo estruturado, não HTML. O corpo é uma lista de blocos portáveis (§6). O site desenha-os com o seu design.
  3. Nada se publica por engano. Criar e publicar são pedidos separados, com âmbitos separados. Um token só com entries:draft nunca torna nada público.
  4. Nada se duplica. POST /entries exige Idempotency-Key. Repetir o mesmo pedido devolve a mesma entrada.
  5. Erros ambíguos não se repetem às cegas. Depois de um erro 5xx ou de uma resposta que não chegou, o cliente consulta GET /entries/{id} ou repete com a mesma Idempotency-Key.

1. Descoberta

GET https://<site>/.well-known/lume.json (público, sem autenticação)

{
  "protocol": "lume-publishing",
  "version": "0.1",
  "api": "https://www.shore.pt/api/lume/v0",
  "auth": { "type": "bearer" },
  "site": { "name": "Shore", "url": "https://www.shore.pt", "locales": ["pt-PT", "en"], "defaultLocale": "pt-PT" }
}

Os caminhos abaixo são relativos a api.

2. Autenticação e âmbitos

  • Token: cada pedido leva Authorization: Bearer <token>.
    • O token é criado no backoffice do site por um administrador, como uma «ligação», e mostrado uma única vez.
    • O site guarda só um hash e pode revogá-lo a qualquer momento.
  • Âmbitos:
Âmbito Permite
authors:read GET /authors
entries:draft GET /entries, GET /entries/{id}, POST /entries, PATCH /entries/{id}
media:write POST /media
entries:publish POST /entries/{id}/publish e alterar entradas já publicadas
  • Ações em nome da ligação: cada ação fica registada na auditoria do site em nome da ligação, por exemplo um utilizador técnico «Lume».
  • Limite de edição: o cliente só pode alterar entradas que criou com essa ligação.

3. Erros

Todas as respostas de erro têm a mesma forma:

{ "error": { "code": "validation_failed", "message": "O título SEO tem mais de 220 caracteres.", "field": "locales.pt-PT.seo.title" } }
HTTP code Quando
400 bad_request JSON inválido, cabeçalho em falta
401 unauthorized Token em falta, inválido ou revogado
403 forbidden O token não tem o âmbito necessário
404 not_found A entrada não existe ou não pertence à ligação
409 slug_taken, entry_published, conflict Slug ocupado; entrada publicada sem entries:publish; outro conflito
413 payload_too_large Ficheiro acima do limite
415 unsupported_media_type Tipo de ficheiro não aceite
422 validation_failed Campo inválido; field aponta o caminho
5xx internal_error Erro do site. Ambíguo: o pedido pode ou não ter sido aplicado
  • message: é para pessoas, no idioma por omissão do site.
  • field: usa caminhos com pontos, por exemplo locales.en.body.3.text.

4. GET /capabilities

{
  "protocol": "lume-publishing",
  "version": "0.1",
  "site": { "name": "Shore", "url": "https://www.shore.pt", "locales": ["pt-PT", "en"], "defaultLocale": "pt-PT" },
  "scopes": ["authors:read", "entries:draft", "media:write"],
  "types": [
    {
      "type": "news",
      "name": { "pt-PT": "Notícia", "en": "News" },
      "locales": { "required": ["pt-PT", "en"], "aligned": true },
      "slug": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "max": 160, "perLocale": false },
      "categories": [{ "key": "cybersecurity", "name": { "pt-PT": "Cibersegurança", "en": "Cybersecurity" } }],
      "newCategories": false,
      "fields": {
        "title": { "max": 220 },
        "excerpt": { "max": 700 },
        "intro": { "max": 6000 },
        "seo.title": { "max": 220, "recommended": 60 },
        "seo.description": { "max": 500, "recommended": 155 }
      },
      "body": { "blocks": ["heading", "paragraph", "list", "quote", "image"], "headingLevels": [2, 3], "startsWithHeading": true, "maxBlocks": 400, "textMax": 6000 },
      "image": { "requiredToPublish": false, "minWidth": 1200, "types": ["image/jpeg", "image/png", "image/webp"], "maxBytes": 10485760 },
      "author": { "requiredToPublish": true, "mustBePublished": true },
      "reviewer": { "supported": true, "distinctFromAuthor": true },
      "sources": { "stored": true, "displayed": false },
      "preview": false,
      "scheduling": false
    }
  ]
}
  • locales.aligned: true: o corpo de todos os idiomas tem a mesma sequência de blocos (mesmo tipo e, nas listas, o mesmo número de itens). Sites que guardam cada idioma lado a lado precisam disto.
  • body.startsWithHeading: o corpo começa num título de nível 2. O texto antes do primeiro título vai em intro.
  • scopes: são os da ligação que fez o pedido.

5. Autores e entradas existentes

GET /authors

{ "authors": [ { "id": "2c2e…", "name": "Maria Silva", "slug": "maria-silva", "status": "published",
                 "jobTitle": { "pt-PT": "Diretora de Tecnologia", "en": "Technology Director" },
                 "url": "https://www.shore.pt/autores/maria-silva", "image": "https://…" } ] }
  • Estados: status é published, draft ou archived. Só um autor published serve para publicar.

GET /entries?type=news

{ "entries": [ { "id": "…", "type": "news", "status": "published", "slug": "nis2-portugal",
                 "title": { "pt-PT": "…", "en": "…" }, "url": { "pt-PT": "https://…", "en": "https://…" },
                 "publishedAt": "2026-07-25T10:30:00Z", "updatedAt": "…", "managed": false } ] }
  • Para que serve: saber que slugs estão ocupados, sugerir ligações internas e evitar repetir temas.
  • managed: é true nas entradas criadas por esta ligação.

GET /entries/{id}

Devolve { "entry": Entry } (§7). Só funciona em entradas criadas por esta ligação. Inclui alterações feitas no backoffice, por exemplo se alguém publicou à mão.

6. Criar e atualizar

POST /entries

  • Cabeçalho obrigatório: Idempotency-Key: <até 200 caracteres>.
  • Resposta: 201 { "entry": Entry } quando cria. 200, com a mesma entrada, quando a chave já foi usada por esta ligação; nesse caso o corpo é ignorado.
{
  "type": "news",
  "slug": "nis2-o-que-muda-em-2027",
  "category": "cybersecurity",
  "authorId": "2c2e…",
  "reviewerId": null,
  "image": { "mediaId": "news/2026/9f1c….webp", "alt": { "pt-PT": "…", "en": "…" }, "position": "center" },
  "locales": {
    "pt-PT": {
      "title": "…", "excerpt": "…", "intro": "…",
      "body": [
        { "type": "heading", "level": 2, "text": "O que muda" },
        { "type": "paragraph", "text": "…" },
        { "type": "list", "ordered": false, "items": ["…", "…"] },
        { "type": "heading", "level": 3, "text": "…" },
        { "type": "quote", "text": "…", "cite": "…" },
        { "type": "image", "mediaId": "news/2026/…", "alt": "…", "caption": "…" }
      ],
      "seo": { "title": "…", "description": "…" }
    },
    "en": { "…": "…" }
  },
  "sources": [ { "title": "Diretiva (UE) 2022/2555", "url": "https://eur-lex.europa.eu/…" } ],
  "lume": { "publicationId": "…", "approvedBy": "Ana Reis", "aiAssisted": true }
}
  • Texto: é texto simples, sem HTML nem Markdown. Ligações dentro do texto ficam para uma versão futura.
  • Estado: a entrada nasce em rascunho.

PATCH /entries/{id}

  • Corpo: o mesmo de POST, sem type. Substitui o conteúdo inteiro.
  • Estado: não muda (rascunho continua rascunho).
  • Entrada já publicada: atualizá-la muda a página pública. Exige entries:publish; sem ele dá 409 entry_published.

POST /entries/{id}/publish (entries:publish)

  • Corpo: vazio.
  • Resposta: { "entry": Entry }, com status: "published", publishedAt e url.
  • Validação: o site aplica as suas regras de publicação, por exemplo autor publicado; se falharem, devolve 422 com o motivo.
  • Confirmação: o cliente só considera publicado depois de abrir o url e encontrar a página.

7. Entrada (Entry)

{ "id": "…", "type": "news", "status": "draft", "slug": "…",
  "url": { "pt-PT": "https://www.shore.pt/noticias/…", "en": "https://www.shore.pt/en/noticias/…" },
  "publishedAt": null, "updatedAt": "2026-10-27T10:00:00Z", "editUrl": "https://www.shore.pt/backoffice" }
  • status: draft, published ou archived.
  • url: é o endereço que a página terá ou tem. Só responde quando status é published.

8. Ficheiros: POST /media (media:write)

  • Pedido: o corpo é o próprio ficheiro, com Content-Type igual ao tipo real, por exemplo image/webp. O nome original vai, opcionalmente, em X-Filename.
  • Validação: o site confirma a assinatura do ficheiro, não só o cabeçalho.
  • Resposta:
{ "media": { "id": "news/2026/9f1c….webp", "url": "https://…/9f1c….webp", "type": "image/webp", "bytes": 182311 } }
  • Utilização: o id é o que vai em image.mediaId e nos blocos image.
  • Texto alternativo: vai na entrada, em cada idioma, não no ficheiro.

9. Segurança

  • Ligação: só HTTPS.
  • Token: é um segredo. O site guarda só um hash e mostra o prefixo para o identificar.
  • Acesso: o token só vê e altera as suas entradas. Autores e entradas publicadas são de leitura.
  • Registos: o site regista cada escrita na auditoria. O cliente nunca regista o token.

10. Versões futuras (não implementado)

  • Webhook site → Lume (entry.published, entry.updated, entry.unpublished), assinado com HMAC.
  • Ligações e ênfase dentro do texto.
  • Pré-visualização (previewUrl) e agendamento.
  • Slug por idioma e outros tipos: caso de cliente, evento, vaga.