Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
093faf1
Port the GOBL Net CLI from invopop/gobl@net
samlown Jun 4, 2026
ef15b00
README: document the GOBL Net CLI + server
samlown Jun 4, 2026
b1b8f2b
bundle: guard that approved addons are registered
samlown Jun 5, 2026
7b96433
net serve: require inbox aud to equal the inbox owner
samlown Jun 5, 2026
0ce9c00
Merge pull request #1 from invopop/cli-trial
samlown Jun 10, 2026
e0d9355
chore(deps): bump vulnerable dependencies (CRITICAL/HIGH)
samlown Jun 15, 2026
faae6d1
Merge pull request #4 from invopop/chore/vuln-bumps-2026-06
samlown Jun 16, 2026
51eee31
GOBL 0.501.0 released
samlown Jun 16, 2026
34706a5
Merge pull request #5 from invopop/gobl-0.501.0
samlown Jun 16, 2026
ad5a4f1
chore: fix deploy Go version and update vulnerable npm deps
samlown Jun 16, 2026
2cd43ab
Merge pull request #13 from invopop/chore/dep-updates-deploy-go-version
samlown Jun 16, 2026
1c926c5
Bump gobl.fr.ctc to v0.0.4
alvarolivie Jun 17, 2026
af4d4e6
bump gobl.sa.zatca
migueltorresvalls Jun 17, 2026
f5a4135
Merge branch 'gobl-sa-zatca-v0.0.2' into app-546-gobl-fr-ctc-v0.0.4
alvarolivie Jun 18, 2026
2a40056
Merge pull request #14 from invopop/app-546-gobl-fr-ctc-v0.0.4
alvarolivie Jun 18, 2026
28560af
Merge pull request #15 from invopop/gobl-sa-zatca-v0.0.2
samlown Jun 18, 2026
ff01cc5
Add tzdata to container for GOBL normalizations
cavalle Jun 24, 2026
e4a0c25
Add bundle addon-coverage test; bump gobl to v0.502.1
samlown Jul 2, 2026
38ab468
Trim test comments
samlown Jul 6, 2026
a8dbba0
Replace interface{} with any in bulk tests
samlown Jul 6, 2026
b36b1a5
Bump gobl to v0.502.2
samlown Jul 6, 2026
101d596
Merge pull request #17 from invopop/bundle-addon-coverage
samlown Jul 6, 2026
e6ccf68
Merge pull request #16 from invopop/tzdata
cavalle Jul 6, 2026
812fc38
Merge remote-tracking branch 'origin/main' into net
samlown Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Changelog

## [Unreleased]

### Added

- `gobl init <domain>`: scaffolds a per-domain identity under
`~/.config/gobl/<domain>/` (auto-generated keypair + a raw
`party.json` template with a pre-filled `gobl:<domain>` endpoint).
- `gobl net who <address> --from <domain>`: performs an authenticated
mutual party exchange — POSTs a signed request and returns the
target's verified `org.Party` (full envelope, including any
authority countersignatures present).
- `gobl net send <envelope> --to <domain> --from <domain>`:
delivers a signed envelope to a remote `/inbox`.
- `gobl net serve`: HTTPS server with per-key `/.well-known/gobl/keys/<kid>`
lookups, a bulk `/.well-known/jwks.json` endpoint for browser-based
JOSE tooling (`jwt.io`-style verifiers), `/who` (authenticated mutual
party exchange) and `/inbox` (signed envelope delivery). Open CORS
(`Access-Control-Allow-Origin: *` plus OPTIONS preflight → 204) is
enabled so JOSE tooling can fetch the JWKS from a browser context.
Multi-tenant: auto-discovers every `<domain>/` directory under the
config dir and routes by HTTP `Host`. ACME issues for every
discovered domain. Optional per-domain `allow.json` gates `/who` and
`/inbox` by signer address.
- `gobl sign --domain X [--to Y]`: signs with the key from
`~/.config/gobl/<X>/` and stamps `iss=gobl:X` / `aud=gobl:Y` into
the signed payload.
- `gobl verify`: gains `--address` / `--remote` flags for remote key
discovery via the new GOBL Net per-key endpoint.
- Top-level `--json` flag: all operator-facing log output flows
through `log/slog`. With the flag, structured JSON (one entry per
line) replaces the default human-readable text. Logs go to
**stderr**; result output (signed envelopes, `/who` party JSON,
`version` JSON) stays on **stdout**.
- HTTP access logs on `gobl net serve`: structured `http_request`
entries for every request plus handler-specific
`keys.lookup`, `jwks.served`, `who.exchange` / `who.rejected`,
`inbox.accepted` / `inbox.rejected`, `inbox.write_failed` events
with high-signal fields (`caller`, `envelope`, `reason`, `status`,
`duration_ms`). Startup messages (`generated keypair`,
`initialised domain`, `GOBL Net listening`, `ACME enabled`,
`Shutting down`) are also structured.
- CLI errors are emitted as a single `command failed` log entry with
`key` / `message` / `faults` fields.
- On-disk layout for `gobl net serve`:
`<config>/<domain>/{private.jwk, keys/<kid>.json, party.json,
allow.json, inbox/}`. One file per `kid` (filename equals `kid`,
validated at startup) — the model maps 1-to-1 to a future
row-per-kid database. Rotation is filesystem ops.

### Changed

- `gobl net serve` `/inbox`: an envelope MUST now be signed with an
`aud` equal to the inbox owner's address. Envelopes signed without
an audience, or bound to a different audience, are rejected with
`401 Unauthorized` (access log `inbox.rejected` carries
`reason=aud_missing` or `reason=aud_mismatch`). This prevents a
valid envelope from being replayed against multiple inboxes —
signers must know the recipient at sign time. `gobl sign --domain
X --to Y` already stamps `aud=gobl:Y` into the signed payload, so
the operator workflow is unchanged; callers that previously sent
audience-less envelopes to an inbox MUST start setting `--to`.
- `gobl keygen`: deprecated in favour of `gobl init <domain>`.
- `gobl net serve --keys` → `--keys-dir`. The on-disk layout for
published keys is now `<domain>/keys/<kid>.json` (one file per
`kid`) instead of a single `<domain>/keys.json` JWKS.
- The CLI now requires the post-GOBL-Net core
(`github.com/invopop/gobl@net`): the signed payload is
`{uuid, dig, iss, aud, iat}`, key IDs are UUIDv7, and the per-key
endpoint replaces the old bulk `/keys` endpoint.

### Security

- `gobl net serve` `/inbox` handler re-parses the document UUID with
`uuid.Parse` before writing the envelope to disk, as a
defence-in-depth check against path traversal. UUIDs already pass
`env.Validate()` + `uuid.HasTimestamp` + the strict 36-char
`[0-9a-f-]` format check from `google/uuid`, but the re-parse keeps
the filesystem write site self-contained.
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /usr/local/bin/gobl.dev ./cmd/gobl.dev

FROM alpine:3.21
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /usr/local/bin/gobl.dev /usr/local/bin/gobl.dev
ENTRYPOINT ["gobl.dev"]
186 changes: 185 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ Commands:
| `gobl sign` | Sign an envelope with a JWK private key. |
| `gobl verify` | Verify an envelope's signatures. |
| `gobl replicate` | Clone a document with a fresh UUID. |
| `gobl keygen` | Generate an ES256 key pair. |
| `gobl keygen` | Generate an ES256 key pair. *(Deprecated: prefer `gobl init`.)* |
| `gobl init` | Scaffold a GOBL Net domain identity under `~/.config/gobl/<domain>/` (keypair + party template). See [GOBL Net](#gobl-net). |
| `gobl net who` | Authenticated mutual party exchange with a remote GOBL Net address. |
| `gobl net send` | POST a signed envelope to a remote `/inbox`. |
| `gobl net serve` | Run the GOBL Net HTTPS server (keys + `/who` + `/inbox` + bulk JWKS). |
| `gobl serve` | Launch the HTTP API server (see [API](#http-api)). |
| `gobl mcp` | Launch a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio for AI tools and editors. |
| `gobl version` | Print the version. |
Expand Down Expand Up @@ -129,6 +133,186 @@ workflow attaches the wasm build to each GitHub Release, uploads it to
addons the binaries ship with — one blank import per addon module. Add an
approved addon there and both `gobl` and `gobl.dev` pick it up.

## GOBL Net

> ⚠️ **EXPERIMENTAL** — GOBL Net is under active development. The CLI
> commands, on-disk layout, and the wire protocol may change without notice.

GOBL Net is a decentralised identity-and-discovery protocol for signed GOBL
documents: a signer's identity is an FQDN (e.g. `billing.invopop.com`), and
verifying keys, an endorsed identity, and a delivery inbox are all served
from well-known HTTPS endpoints at that domain. The protocol itself lives in
the core library at
[`github.com/invopop/gobl/net`](https://github.com/invopop/gobl/blob/net/net/README.md) —
that file is the authoritative spec for addresses, the signed `iss`/`aud`/`iat`
payload, the per-key and JWKS endpoints, `/who`, and `/inbox`. This section
covers only the CLI / server side.

### `gobl init <domain>`

Scaffolds a per-domain identity under `~/.config/gobl/<domain>/`:

```
~/.config/gobl/billing.invopop.com/
├── private.jwk ← active signing key (0600)
├── keys/<kid>.json ← published JWK (stamped valid_from=now)
├── party.json ← party template with a pre-filled gobl: endpoint
├── allow.json ← optional: gates /who and /inbox by signer
└── inbox/ ← envelopes received over /inbox land here
```

Flags: `--config-dir`, `--force`, `--name`. Rotation is just filesystem ops:
drop a new `<kid>.json` to publish a key, set `valid_until` on a file to
retire it, `rm` to remove it (future requests for that `kid` return `404`).

### `gobl sign --domain X [--to Y]`

Signs with the key from `~/.config/gobl/<X>/` and stamps `iss=gobl:X` /
`aud=gobl:Y` into the signed payload (alongside `uuid`, `dig`, and `iat`).

### `gobl verify`

Two flags activate remote verification:

- `-a, --address <fqdn>` — require the verified `iss` to equal this address.
- `-r, --remote` — fetch the verifying key from the issuer published in the
signed `iss`, via `<iss>/.well-known/gobl/keys/<kid>`.

### `gobl net who <address> --from <domain>`

Authenticated mutual party exchange: POSTs a signed request (`iss=gobl:from`,
`aud=gobl:address`) and prints the target's verified `org.Party` envelope —
including any authority countersignatures the target serves alongside its
self-signature.

### `gobl net send <envelope> --to <fqdn>`

Reads a signed envelope from a file (or stdin), POSTs it to the destination's
`/inbox`. Exits 0 on `202 Accepted`; otherwise `ErrInboxRejected`.

The envelope's signed `aud` MUST equal `--to`: receiving inboxes reject
envelopes signed without an audience or bound to a different one (replay
protection). `gobl sign --domain X --to Y` stamps `aud=gobl:Y` for you.

- `--insecure` — use `http://` and permit `host:port` form in `--to`
(development only).

### `gobl net serve`

Runs the HTTPS server. Always listens on an HTTP port (default 80); when a
TLS source is configured it also listens on the HTTPS port (default 443),
serving identical content — no redirect, senders choose the scheme.

**Multi-tenant.** Auto-discovers every `<config-dir>/<domain>/` directory and
routes by HTTP `Host`. `--domain` restricts to one; `--party` + `--keys-dir`
selects a single manual identity. ACME issues for every discovered domain.

**Startup checks** (each is a hard error with a clear message):

- If neither `keys/` nor `private.jwk` exists, the server generates an ECDSA
P-256 keypair, writes `private.jwk` (0600) and `keys/<kid>.json` (with
`valid_from = now`), and logs the new kid + paths.
- Every file in `keys/` MUST be named `<kid>.json` where `kid` equals the
JWK's `kid` field. Non-`.json` entries and subdirectories are ignored.
- The active `private.jwk`'s `kid` MUST be one of the published kids.
- The party envelope MUST contain at least one signature whose `kid` is
published and which verifies against that key. Endorser signatures are
allowed alongside.

**Ports:**

- `--http-port <int>` (default 80)
- `--https-port <int>` (default 443; only used with a TLS source)

**TLS sources (mutually exclusive):**

- `--acme-live` — Let's Encrypt production. Recommended: `--acme-email`.
- `--acme-test` — Let's Encrypt staging (untrusted certs; use during
iteration to dodge production rate limits).
- `--tls-cert <path>` + `--tls-key <path>` — operator-supplied PEM cert/key.

ACME options:

- `--domain <fqdn>` — hostname the ACME client is allowed to issue for; MUST
match the participant's GOBL Net address. Optional: when omitted, derived
from the party's `gobl:` endpoint (`org.Party.endpoints[?(@.uri ~ /^gobl:/)]`).
- `--acme-email <email>` — ACME account email (recommended by LE). Optional:
derived from the party's first `org.Party.emails` entry when omitted.
- `--cert-dir <path>` — directory used to cache ACME-issued certs (default
`<config-dir>/certs/`).

Explicit flags always override party-derived values.

**Operational stances:**

| Stance | Listens on | Use when |
|-------------------------------------------------|--------------|---------------------------------------------------|
| default (no TLS flags) | HTTP only | Behind a reverse proxy that terminates TLS. |
| `--acme-live` / `--acme-test` + `--domain` | HTTP + HTTPS | Direct internet exposure; LE manages the cert. |
| `--tls-cert` + `--tls-key` | HTTP + HTTPS | Cert is sourced elsewhere (corporate CA, …). |

**Docker:**

```bash
docker run \
-p 80:80 -p 443:443 \
-v gobl-config:/root/.config/gobl \
gobl net serve
```

For unprivileged containers, pick high ports inside and remap:

```bash
docker run \
-p 80:8080 -p 443:8443 \
-v gobl-config:/home/gobl/.config/gobl \
gobl net serve --http-port 8080 --https-port 8443
```

**ACME operational sequence:** start → challenge (HTTP-01 on the HTTP port,
TLS-ALPN-01 fallback on the HTTPS port) → cert issued + cached → ready. If
the public internet can't reach the configured `--domain`, the challenge
fails and the server logs a clear error. Successful issuance doubles as a
reachability check.

### Logging

All operator-facing log output goes through `log/slog` and is written to
**stderr**. Result output (signed envelopes, the `/who` party JSON,
`gobl version`'s JSON) stays on **stdout**, so a pipeline like
`gobl sign … | gobl net send …` is unaffected.

The top-level `--json` flag toggles the format:

| flag | stderr format | example |
|-----------|----------------------|----------------------------------------------------------------|
| (default) | slog text | `time=… level=INFO msg=listening scheme=http addr=:8080` |
| `--json` | slog JSON-per-line | `{"time":"…","level":"INFO","msg":"listening","scheme":"http","addr":":8080"}` |

**Startup messages (`gobl net serve`):** `generated keypair`
(fields: `kid`, `private`, `key_file`); `initialised domain` (`domain`,
`party`, `inbox`); `GOBL Net listening` (`scheme`, `addr`); `ACME enabled`
(`domains`); `Shutting down`.

**HTTP access logs** — one baseline entry plus handler-specific ones:

| msg | level | fields |
|----------------------|-------|-----------------------------------------------------------------------------------|
| `http_request` | INFO | `method`, `path`, `host`, `remote`, `status`, `duration_ms` |
| `keys.lookup` | INFO | `kid`, `found` |
| `jwks.served` | INFO | `count` |
| `who.exchange` | INFO | `caller` (verified `iss` as FQDN) |
| `who.rejected` | WARN | `reason` (`bad_body`/`verify_failed`/`not_allowed`), `remote`/`caller`/`error` |
| `inbox.accepted` | INFO | `caller`, `envelope` (UUID) |
| `inbox.rejected` | WARN | `reason` (`bad_body`/`validation`/`verify_failed`/`aud_missing`/`aud_mismatch`/`not_allowed`) |
| `inbox.write_failed` | ERROR | `caller`, `envelope`, `error` |

**Error reporting.** A CLI command that fails emits a single `command failed`
entry on stderr with `key=<gobl-error-key>` and (when present) `message=…`
and `faults=…`. With `--json` the same fields appear as a JSON object.
Successful commands write no log output and their result still lands on
stdout.

## Project structure

```
Expand Down
7 changes: 7 additions & 0 deletions bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ package bundle
// support the same set. Add a blank import per approved addon module — this is the
// one place to update.
import (
_ "github.com/invopop/gobl/addons" // all in-core addons

// Approved external addon modules.
_ "github.com/invopop/gobl.br.nfe/addon"
_ "github.com/invopop/gobl.br.nfse/addon"
_ "github.com/invopop/gobl.fr.ctc/addon"
_ "github.com/invopop/gobl.mx.cfdi/addon"
_ "github.com/invopop/gobl.pt.saft/addon"
_ "github.com/invopop/gobl.sa.zatca/addon"
)
35 changes: 35 additions & 0 deletions bundle/bundle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package bundle_test

import (
"testing"

_ "github.com/invopop/gobl"
"github.com/invopop/gobl/tax"

_ "github.com/invopop/gobl.dev/bundle"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// knownUnavailable exempts approved addon keys the bundle deliberately does
// not provide, mapped to the reason. Keep this empty whenever possible.
var knownUnavailable = map[string]string{}

// TestApprovedAddonsAvailable ensures every addon approved by GOBL is
// registered via the bundle's imports.
func TestApprovedAddonsAvailable(t *testing.T) {
approved := tax.ApprovedAddons()
require.NotEmpty(t, approved, "expected gobl to expose approved addons; is gobl imported?")

for _, ea := range approved {
t.Run(ea.Key.String(), func(t *testing.T) {
if reason, ok := knownUnavailable[ea.Key.String()]; ok {
t.Skipf("known gap for %q: %s", ea.Key, reason)
}
assert.NotNilf(t, tax.AddonForKey(ea.Key),
"approved addon %q (module %s) is not registered; add a blank import for %s/addon to bundle.go",
ea.Key, ea.Module, ea.Module)
})
}
}
50 changes: 50 additions & 0 deletions cmd/gobl/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"errors"

"github.com/spf13/cobra"

"github.com/invopop/gobl.dev/internal/ops"
)

type initCmdOpts struct {
*rootOpts
configDir string
name string
force bool
}

func initCmd(root *rootOpts) *initCmdOpts {
return &initCmdOpts{rootOpts: root}
}

func (o *initCmdOpts) cmd() *cobra.Command {
cmd := &cobra.Command{
Use: "init <domain>",
Short: "Initialise a new GOBL Net domain identity (EXPERIMENTAL)",
Long: "Initialise a new GOBL Net domain identity.\n\n" +
"EXPERIMENTAL: GOBL Net is under active development and may change without notice.",
Args: cobra.ExactArgs(1),
RunE: o.runE,
}
f := cmd.Flags()
f.StringVar(&o.configDir, "config-dir", defaultConfigDir(), "Base directory for domain identities")
f.StringVar(&o.name, "name", "", "Party name to seed into the generated party.json")
f.BoolVarP(&o.force, "force", "f", false, "Overwrite an existing non-empty domain directory")
return cmd
}

func (o *initCmdOpts) runE(cmd *cobra.Command, args []string) error {
domain := args[0]
if domain == "" {
return errors.New("a domain is required")
}
return ops.InitDomain(&ops.InitOptions{
ConfigDir: o.configDir,
Domain: domain,
Name: o.name,
Force: o.force,
Out: cmd.OutOrStdout(),
})
}
Loading
Loading