@planuze/pack-format
v0.3.12
Published
Formato .plnzpack: manifest Zod, arquivo ZIP, assinaturas Ed25519, criptografia XChaCha20-Poly1305, lockfile e lint.
Readme
@planuze/pack-format
Define e implementa o formato .plnzpack v1 — o contrato de empacotamento dos
packs Planuze (templates, extensions e module-packs). É a fonte única do
manifest, do layout do arquivo, das assinaturas, da criptografia,
do lockfile e do lint. Pacote público (base do SDK de packs, ADR-0429),
sem I/O de rede; roda no builder Node (@planuze/pack-publisher), no app desktop
(@planuze/pack-runtime) e no scanner server-side.
Referência normativa: docs/specs/plnzpack-v1.md.
Layout do .plnzpack
Um .plnzpack é um ZIP com caminhos POSIX relativos (constantes em
archive/constants.ts):
manifest.json # JSON validado por packManifestSchema
manifest.sig # assinatura Ed25519 dos bytes de manifest.json
content/
base.tar.zst # arquivos base (copiados no scaffold) — cifrado
generator.tar.zst # scripts do generator — cifrado
agent.tar.zst # opcional — cifrado
pack.lock # sha256 de cada entrada hasheável
content.sig # assinatura Ed25519 dos bytes de pack.lock
docs/<locale>/<arquivo> # docs EM CLARO (opcional; ADR-0523) — pré-vendaEntradas absolutas, segmentos .., \ e paths com drive Windows são
inválidos (archive/path-safety.ts —
isSafeArchivePath). Os docs top-level (docs/) são o único conjunto variável
e ficam em claro (o registry os serve sem decrypt — pré-venda); tudo mais é um
conjunto fechado.
Reader / Writer
PackArchiveWriter.write(path, input)(archive/writer.ts) monta o ZIP na ordem canônica viaarchiver(nível 6). Os listeners de stream sãoIDisposablee liberados quando a operação settla.PackArchiveReader(path)(archive/reader.ts) lê entrada por entrada viaunzipper, com hard caps anti-DoS (≤20 000 arquivos, ≤64 MiB por entrada, ≤128 MiB total), rejeição de paths inseguros e de entradas duplicadas, e memoização do central directory por instância (ADR-0490 — um install lê ~7 entradas e cada leitura reabria o ZIP).
Ambos archiver e unzipper são CJS e carregados via createRequire
lazy/memoizado: o barrel de pack-format é importado por Workers
(pack-registry), onde import.meta.url é undefined no workerd; o require só
dispara quando o Reader/Writer é de fato instanciado (nunca em Worker).
manifest.json
O manifest é validado por packManifestSchema
(manifest/schemas.ts, Zod 4). parsePackManifest /
parsePackManifestJson (manifest/parse.ts) devolvem
Result<PackManifest, AppError> (§4.6, sem throw).
Kinds e install target
O campo kind discrimina o comportamento do pack, e resolveInstallTarget(manifest)
resolve onde o generator escreve:
| kind | appliesTo | Install target |
|---|---|---|
| template (default) | proibido | raiz do projeto (project-root) |
| extension | obrigatório | src/modules/<id>/ (project-module) |
| module-pack | obrigatório | src/modules/<alvo>/_pkgs/<id>/ (module-subdir; alvo é input do install) |
Type guards isTemplateManifest / isExtensionManifest / isModulePackManifest
para call sites. O superRefine do schema enforça a matriz kind↔appliesTo (ex.:
extension exige appliesTo, proíbe wizard, e appliesTo.packId ≠ id).
distribution (visibilidade e acesso)
packDistributionSchema carrega license (free/paid/private), channels
(stable/beta/canary), tier, requiredCapabilities (gate de acesso por
assinatura é a capability, não o tier ordinal — ADR-0419), category/tags
(taxonomia validada pelo registry no publish) e a visibilidade:
public— todos veem.org-private— só membros dasallowedOrgs[](refine exigeallowedOrgsnão-vazio; ADR-0335).byMyself— só a conta que publicou (refine proíbeallowedOrgs; multi-device account-scoped).
A partir do ADR-0423 a visibility é controle de acesso do servidor (metadata KV mutável); o valor no manifest é apenas o inicial. A assinatura garante o conteúdo, não a visibility.
declarations — o vocabulário CRUD do pack
packDeclarationsSchema é o coração do modelo declarativo. Cada seção é uma lista
com id único (uniqueness enforçada por superRefine) e labelKey i18n:
| Seção | Declara |
|---|---|
| columnTypes | tipos de coluna do editor: valueType, parameters[] (inputs dinâmicos: integer/enum/string/boolean), flags[] (optional/unique/encrypted/hidden/indexed), defaultValue, supports |
| relationTypes | cardinalidade + requiresFkColumn (default por cardinality via relationRequiresFkColumn; m:n usa join table) |
| routeTypes | defaultPath (inicia com /), allowedTargets, supports (auth/where/link/request.{body,query,params,projection}/upload + chaves custom booleanas) |
| validatorMethods | appliesTo[] (deve referenciar columnTypes[].id), parameters[], kind (standard/passthrough) |
| lambdaTypes, triggers, integrations, iamPolicies | packs non-CRUD (AWS Lambdas etc., ADR-0177); lambdaTypes[].requiredIamPolicyIds referenciam iamPolicies[].id |
Cross-validations do superRefine: uniqueness de todos os ids, validatorMethods.appliesTo
→ columnTypes (exceto extension, resolvida pelo composer), lambda→iamPolicy, e
uniqueness de props/steps por módulo.
toolkit — vocabulário de runtime/persistência
packToolkitSchema é o espelho de declarations para o runtime gerado (o que
o core/templates não pode conhecer, §4.18/ADR-0241). Inclui columnTypes[].mapsTo
(tipo Prisma alvo), whereOperators[].prismaOp (o pack mapeia id → operador
Prisma; o core nunca conhece o vocabulário Prisma), validators com nameHints
(auto-complete por token de nome de coluna) e tipos de param extra
collection-ref/column-ref (FK por campo do body, ADR-0274), e defaultColumns
(colunas semeadas em toda coleção: id/timestamps/auth-state).
Outras seções
modules[] (sub-features ativáveis por projeto — ex.: auth com jwt/oauth/session,
com props/envVars/declarations/steps/augmentsAuthCollection),
generator (entrypoint, steps com dependsOn/condition,
promptPatterns, postCreateCommands), agent (opcional), wizard,
uploadModes, crossRelationTypes, actions/collectionActions/moduleActions/
projectActions, runtimeDeps (deps npm do projeto gerado, common + byModule).
Campos setados no build: publicKeyFingerprint (sha256:<hex>),
publisherFingerprint (chain-of-trust per-publisher), contentKeyEscrow (ECIES),
signatureAlgorithm: 'ed25519'.
Assinaturas (Ed25519)
signing/ed25519.ts implementa o esquema Ed25519 puro
do Node 24 (sign(null, bytes, key) / verify(null, bytes, key, sig) — nunca
createSign('SHA512')). Todas as operações devolvem Result<_, PackFormatError>:
generateEd25519KeyPair/exportEd25519KeyPairPem(spki/pkcs8) /import*Pem.fingerprintEd25519PublicKey—sha256:<hex>do DER(spki) (forma canônica cross-runtime).signBuffer/verifyBuffer.createSignedEnvelope/verifySignedEnvelope— payload + assinatura sobre um JSON stável (chaves ordenadas), para metadados assinados fora do pack.createPublisherSigningKey(par + fingerprint, sem I/O) esignChallengeNonceHex(registro de chave por challenge-response).
manifest.sig assina os bytes exatos de manifest.json; content.sig assina os
bytes exatos de pack.lock (que por sua vez hasheia o resto — ver Lock).
Chain-of-trust per-publisher
signing/chain.ts implementa a cadeia da comunidade
(ADR-0021 / ADR-0363 §8): cada publisher tem keypair próprio; quando o publisher é
verificado, a root assina o DER(spki) da pubkey do publisher.
verifyPackChain(packBytes, packSig, publisherKey, rootPubKeyPem) encadeia duas
assinaturas — (1) pack ← pubkey do publisher, (2) pubkey ← root — e devolve um erro
discriminado por step (pack-sig / root-sig / key-unverified).
signPublicKeyWithRoot é o root-sign usado pelo admin.
Criptografia
Dois envelopes assimétricos/simétricos distintos:
Blobs de conteúdo — XChaCha20-Poly1305 (encryption/xchacha.ts,
via @noble/ciphers): chave de 32 bytes, nonce de 24 bytes aleatórios, AAD
recomendado = ${packId}@${version}. encryptContent/decryptContent operam
sobre EncryptedContent; packEncryptedEnvelope/unpackEncryptedEnvelope
serializam para/de o JSON { algorithm, nonce, ciphertext, aad? }.
derivePackContentKey(masterKey, packId, version) é a fonte única da derivação
HKDF-SHA256 (salt ${packId}@${version}, info CONTENT_KEY_INFO) — publish,
consume e escrow DEVEM derivar idêntico, senão a key não decifra.
Escrow da content-key — ECIES (X25519 + HKDF + XChaCha20)
(encryption/escrow.ts): wrapContentKey(contentKey,
escrowPubKey) lacra a content-key aleatória do pack para a pubkey de escrow do
servidor, no formato ecies:v1:<ephPub>:<nonce>:<ct>. Só a privkey de escrow
(custodiada no servidor) desembrulha com unwrapContentKey (ADR-0370). Assim o CLI
do publisher (máquina não-confiável) nunca conhece o segredo de escrow.
generateEscrowKeyPair / escrowPublicKeyFromPrivate completam o par.
pack.lock
lock/compute.ts computa o lock: um mapa arquivo →
sha256(conteúdo) (packLockSchema em lock/schemas.ts):
{ "version": 1, "algorithm": "sha256", "files": { "manifest.json": "…", "content/base.tar.zst": "…" } }- Entradas hasheadas:
manifest.json,manifest.sig,content/base.tar.zst,content/generator.tar.zst,content/agent.tar.zst(se existir) e cada doc top-level em claro (ADR-0523 — assinada viacontent.sig, integridade no consume). pack.lockecontent.signão entram no lock (content.sigassina o lock; incluí-lo criaria ciclo).computePackLockFromEntriesrejeita entradas inesperadas (fail-closed).verifyPackLock(reader)recomputa e compara conjunto de arquivos + hash por arquivo (file_set_mismatch/hash_mismatch).serializePackLockemite JSON com chaves ordenadas (determinístico → assinatura estável).
Lint
lintPack(packDir) (lint/lint-pack.ts) roda antes de
publicar e devolve um LintReport (issues[] com severity
error/warning, errorCount, warningCount; cada issue tem messageKey i18n +
path no manifest). Regras:
| Regra | Arquivo | Severity |
|---|---|---|
| manifest.json existe, é JSON e passa no schema | lint/manifest.ts | error |
| generator.entrypoint é path relativo seguro e existe no disco | lint/generator.ts | error |
| locales/*.json cobre todas as labelKey usadas pelo manifest | lint/locales.ts | error |
| routeTypes.defaultPath sem placeholders desconhecidos | lint/routes.ts | warning |
| publicKeyFingerprint ainda é o placeholder portátil sha256:0…0 (o build injeta o valor real no artefato assinado) | lint/fingerprint.ts | warning |
| docs por locale — cada doc declarada existe e não é stub | lint/docs.ts | error |
Gate de documentação por locale (ADR-0303 + ADR-0723)
lintPackDocs (lint/docs.ts) é a forcing function do
ADR-0723: toda
doc declarada (manifest.doc de overview + modules[].doc) precisa existir em
docs/<locale>/<doc> para cada locale suportado (derivado de locales/*.json)
mais o fallback en-US (o resolvedor de docs cai em en-US quando o locale
específico falta — ADR-0303; sem a doc en-US o fallback resolve vazio). Além da
existência, exige um piso de conteúdo (MIN_DOC_CONTENT_LINES = 10 linhas
não-vazias) — existência sozinha passaria com um stub praticamente vazio. Docs
faltando ou stub viram LintIssue de severity error (bloqueiam o build).
Erros
PackFormatError (errors.ts) estende AppError com kinds de
domínio: pack/invalid_archive_entry, pack/missing_archive_entry,
pack/invalid_signature (target), pack/invalid_lock (entryPath, reason) e
pack/crypto_failed (com CryptoOperation discriminando encrypt/decrypt/
sign/verify/derive_key/generate_key/import_key/export_key). Cada um tem
messageKey: LabelKey para resolução i18n na UI.
API pública (subpath exports)
| Import | Conteúdo |
|---|---|
| @planuze/pack-format | tudo (barrel raiz — src/index.ts) |
| @planuze/pack-format/manifest | schemas + parse do manifest |
| @planuze/pack-format/encryption | XChaCha + escrow |
Boundaries e build
- Gate
check:cruiser(regrapack-format-*): só depende de@planuze/core-kit(+@noble/*,archiver,unzipper,zod). attwvalidapackage.json#exports.
pnpm --filter @planuze/pack-format build # tsc modular → dist/
pnpm --filter @planuze/pack-format test # vitest run
pnpm --filter @planuze/pack-format typecheck # tsc --noEmit