diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be9e1f5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,80 @@ +# Changelog + +## [Unreleased] + +### Added + +- `gobl init `: scaffolds a per-domain identity under + `~/.config/gobl//` (auto-generated keypair + a raw + `party.json` template with a pre-filled `gobl:` endpoint). +- `gobl net who
--from `: 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 --to --from `: + delivers a signed envelope to a remote `/inbox`. +- `gobl net serve`: HTTPS server with per-key `/.well-known/gobl/keys/` + 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 `/` 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//` 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`: + `//{private.jwk, keys/.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 `. +- `gobl net serve --keys` → `--keys-dir`. The on-disk layout for + published keys is now `/keys/.json` (one file per + `kid`) instead of a single `/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. diff --git a/Dockerfile b/Dockerfile index 7897ee6..a223a35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -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 @@ -6,6 +6,6 @@ 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"] diff --git a/README.md b/README.md index 2cc1352..960ba02 100644 --- a/README.md +++ b/README.md @@ -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//` (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. | @@ -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 ` + +Scaffolds a per-domain identity under `~/.config/gobl//`: + +``` +~/.config/gobl/billing.invopop.com/ +├── private.jwk ← active signing key (0600) +├── keys/.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 `.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//` 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 ` — require the verified `iss` to equal this address. +- `-r, --remote` — fetch the verifying key from the issuer published in the + signed `iss`, via `/.well-known/gobl/keys/`. + +### `gobl net who
--from ` + +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 --to ` + +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 `//` 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/.json` (with + `valid_from = now`), and logs the new kid + paths. +- Every file in `keys/` MUST be named `.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 ` (default 80) +- `--https-port ` (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 ` + `--tls-key ` — operator-supplied PEM cert/key. + +ACME options: + +- `--domain ` — 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 ` — ACME account email (recommended by LE). Optional: + derived from the party's first `org.Party.emails` entry when omitted. +- `--cert-dir ` — directory used to cache ACME-issued certs (default + `/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=` 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 ``` diff --git a/bundle/bundle.go b/bundle/bundle.go index 7d43c9e..0d35149 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -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" ) diff --git a/bundle/bundle_test.go b/bundle/bundle_test.go new file mode 100644 index 0000000..58fc224 --- /dev/null +++ b/bundle/bundle_test.go @@ -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) + }) + } +} diff --git a/cmd/gobl/init.go b/cmd/gobl/init.go new file mode 100644 index 0000000..572ca55 --- /dev/null +++ b/cmd/gobl/init.go @@ -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 ", + 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(), + }) +} diff --git a/cmd/gobl/init_test.go b/cmd/gobl/init_test.go new file mode 100644 index 0000000..308f234 --- /dev/null +++ b/cmd/gobl/init_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newInitCmd builds a cobra.Command and re-applies any pre-set fields +// on opts (cmd flag wiring resets them to their defined defaults). +func newInitCmd(t *testing.T, opts *initCmdOpts) *cobra.Command { + t.Helper() + if opts.rootOpts == nil { + opts.rootOpts = &rootOpts{} + } + preCfg := opts.configDir + preName := opts.name + preForce := opts.force + c := opts.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + if preCfg != "" { + opts.configDir = preCfg + } + if preName != "" { + opts.name = preName + } + if preForce { + opts.force = preForce + } + return c +} + +func TestInitCmdFlags(t *testing.T) { + o := &initCmdOpts{rootOpts: &rootOpts{}} + c := o.cmd() + assert.Equal(t, "init ", c.Use) + assert.NotNil(t, c.Flags().Lookup("config-dir")) + assert.NotNil(t, c.Flags().Lookup("name")) + assert.NotNil(t, c.Flags().Lookup("force")) +} + +func TestInitCmdRunESuccess(t *testing.T) { + tmp := t.TempDir() + o := &initCmdOpts{ + rootOpts: &rootOpts{}, + configDir: tmp, + name: "Alice Co", + } + c := newInitCmd(t, o) + err := o.runE(c, []string{"alice.example"}) + require.NoError(t, err) + assert.DirExists(t, filepath.Join(tmp, "alice.example", "keys")) + assert.FileExists(t, filepath.Join(tmp, "alice.example", "private.jwk")) + assert.FileExists(t, filepath.Join(tmp, "alice.example", "party.json")) + assert.DirExists(t, filepath.Join(tmp, "alice.example", "inbox")) +} + +func TestInitCmdRunEEmptyDomain(t *testing.T) { + o := &initCmdOpts{rootOpts: &rootOpts{}} + c := newInitCmd(t, o) + err := o.runE(c, []string{""}) + require.Error(t, err) +} + +func TestInitCmdRunEExistingDomain(t *testing.T) { + tmp := t.TempDir() + o := &initCmdOpts{rootOpts: &rootOpts{}, configDir: tmp} + c := newInitCmd(t, o) + require.NoError(t, o.runE(c, []string{"bob.example"})) + // Re-run without --force fails. + err := o.runE(c, []string{"bob.example"}) + require.Error(t, err) +} + +func TestInitCmdCtor(t *testing.T) { + o := initCmd(&rootOpts{}) + require.NotNil(t, o) + assert.NotNil(t, o.rootOpts) +} + +// TestInitCmdRunEUsesDefaultConfigDir exercises the empty-configDir +// fallback to defaultConfigDir() — we point HOME at a temp dir so the +// init writes there. +func TestInitCmdRunEUsesDefaultConfigDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + o := &initCmdOpts{rootOpts: &rootOpts{}, configDir: defaultConfigDir()} + c := newInitCmd(t, o) + require.NoError(t, o.runE(c, []string{"default.example"})) + _, err := os.Stat(filepath.Join(tmp, ".config", "gobl", "default.example", "private.jwk")) + require.NoError(t, err) +} diff --git a/cmd/gobl/keygen.go b/cmd/gobl/keygen.go index ad9f89f..442c7ec 100644 --- a/cmd/gobl/keygen.go +++ b/cmd/gobl/keygen.go @@ -28,10 +28,11 @@ func keygen(root *rootOpts) *keygenOpts { func (k *keygenOpts) cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "keygen [flags] [outfile]", - Short: "Generate a keypair", - Args: cobra.MaximumNArgs(1), - RunE: k.runE, + Use: "keygen [flags] [outfile]", + Short: "Generate a keypair", + Args: cobra.MaximumNArgs(1), + RunE: k.runE, + Deprecated: "use `gobl init ` to set up a domain identity (keys + party) instead.", } f := cmd.Flags() diff --git a/cmd/gobl/keygen_test.go b/cmd/gobl/keygen_test.go index fa5ad79..442161f 100644 --- a/cmd/gobl/keygen_test.go +++ b/cmd/gobl/keygen_test.go @@ -30,6 +30,122 @@ var jwkREs = []testy.Replacement{ }, } +func TestExpandHome(t *testing.T) { + // Without the leading ~/, returned verbatim. + got, err := expandHome("/abs/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/abs/path" { + t.Errorf("expected pass-through, got %q", got) + } + + // With ~/, resolved against $HOME. + t.Setenv("HOME", "/tmp/home") + got, err = expandHome("~/sub/key.jwk") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/tmp/home/sub/key.jwk" { + t.Errorf("expected expanded path, got %q", got) + } +} + +func TestHomedir(t *testing.T) { + t.Setenv("HOME", "/tmp/home") + got, err := homedir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/tmp/home" { + t.Errorf("expected /tmp/home, got %q", got) + } +} + +func TestHomedirNoHOME(t *testing.T) { + // HOME unset -> falls back to user.Current(). Whichever path the + // underlying machine takes, the function should return a value + // (or an error). Either way the fallback branch is exercised. + t.Setenv("HOME", "") + _, _ = homedir() +} + +func TestKeygenRunEPubFileExists(t *testing.T) { + // Pre-create the .pub.jwk file so writeKey on the public side + // errors out, exercising the second writeKey error branch. + dir := t.TempDir() + priv := filepath.Join(dir, "id_test") + pub := pubfileFromPriv(priv) + if err := os.WriteFile(pub, []byte("existing"), 0o644); err != nil { + t.Fatal(err) + } + o := &keygenOpts{rootOpts: &rootOpts{}} + c := &cobra.Command{} + c.SetOut(&bytes.Buffer{}) + err := o.runE(c, []string{priv}) + if err == nil { + t.Fatal("expected error when pub file pre-exists") + } +} + +func TestWriteKeyMkdirFailure(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("write-permission tests do not apply when running as root") + } + // Make a non-writable parent so MkdirAll fails inside writeKey. + dir := t.TempDir() + ro := filepath.Join(dir, "ro") + if err := os.MkdirAll(ro, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(ro, 0o755) }) + + err := writeKey(filepath.Join(ro, "sub", "id.jwk"), []byte("data"), 0o600, false) + if err == nil { + t.Fatal("expected mkdir failure") + } +} + +func TestWriteKeyDefaultDirMkdirFailure(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("write-permission tests do not apply when running as root") + } + // Point HOME at a non-writable parent so the default keyfile's + // containing dir cannot be created, triggering the L119 MkdirAll + // branch inside writeKey. + parent := t.TempDir() + if err := os.Chmod(parent, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(parent, 0o755) }) + t.Setenv("HOME", parent) + + def, err := defaultKeyfile() + if err != nil { + t.Fatal(err) + } + if err := writeKey(def, []byte("data"), 0o600, false); err == nil { + t.Fatal("expected mkdir failure for default keyfile dir") + } +} + +func TestPubfileFromPriv(t *testing.T) { + got := pubfileFromPriv("id.jwk") + if got != "id.pub.jwk" { + t.Errorf("got %q", got) + } +} + +func TestOutputKeyfile(t *testing.T) { + got, err := outputKeyfile([]string{"a.jwk"}) + if err != nil { + t.Fatal(err) + } + if got != "a.jwk" { + t.Errorf("got %q", got) + } +} + func Test_keygen(t *testing.T) { type tt struct { env map[string]string diff --git a/cmd/gobl/main.go b/cmd/gobl/main.go index ae9ac12..8ffb6d9 100644 --- a/cmd/gobl/main.go +++ b/cmd/gobl/main.go @@ -4,16 +4,18 @@ package main import ( "context" "encoding/json" - "fmt" + "errors" "io" + "log/slog" "os" "os/signal" "runtime/debug" "syscall" - "github.com/invopop/gobl" "github.com/spf13/cobra" + "github.com/invopop/gobl" + // Register the full GOBL addon set (see bundle/bundle.go). _ "github.com/invopop/gobl.dev/bundle" ) @@ -105,10 +107,34 @@ func encode(in any, out io.WriteCloser, indent bool) error { return enc.Encode(in) } +// newLogger builds the slog.Logger used for all operator-facing log +// output. The result writes one entry per line to stderr; result +// output (signed envelopes, /who party JSON, version) lives on stdout +// and is not affected by this flag. +func newLogger(jsonMode bool) *slog.Logger { + opts := &slog.HandlerOptions{Level: slog.LevelInfo} + var h slog.Handler + if jsonMode { + h = slog.NewJSONHandler(os.Stderr, opts) + } else { + h = slog.NewTextHandler(os.Stderr, opts) + } + return slog.New(h) +} + func printError(err error) { - enc := json.NewEncoder(os.Stderr) - enc.SetIndent("", "\t") // always indent errors - if err = enc.Encode(err); err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) + // Normalise to a *gobl.Error so every report carries a "key" and + // (when present) a "message" + structured faults. + var ge *gobl.Error + if !errors.As(err, &ge) { + ge = gobl.ErrInternal.WithCause(err) + } + attrs := []any{"key", ge.Key().String()} + if msg := ge.Message(); msg != "" { + attrs = append(attrs, "message", msg) + } + if faults := ge.Faults(); faults != nil { + attrs = append(attrs, "faults", faults) } + slog.Error("command failed", attrs...) } diff --git a/cmd/gobl/main_test.go b/cmd/gobl/main_test.go index 2f3ba0d..ea2e1d6 100644 --- a/cmd/gobl/main_test.go +++ b/cmd/gobl/main_test.go @@ -1,12 +1,17 @@ package main import ( + "bytes" + "errors" "io" + "log/slog" "strings" "testing" "github.com/stretchr/testify/assert" "gitlab.com/flimzy/testy" + + "github.com/invopop/gobl" ) func Test_root(t *testing.T) { @@ -48,6 +53,61 @@ func Test_root(t *testing.T) { } } +// withLogger pins slog.Default() to a logger that writes text-handler +// output into buf for the duration of the test. +func withLogger(t *testing.T) *bytes.Buffer { + t.Helper() + prev := slog.Default() + buf := new(bytes.Buffer) + slog.SetDefault(slog.New(slog.NewTextHandler(buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return buf +} + +func TestPrintErrorPlain(t *testing.T) { + buf := withLogger(t) + printError(errors.New("boom")) + out := buf.String() + assert.Contains(t, out, "key=internal") + assert.Contains(t, out, "message=boom") +} + +func TestPrintErrorGoblError(t *testing.T) { + buf := withLogger(t) + printError(gobl.ErrInput.WithReason("nope")) + out := buf.String() + assert.Contains(t, out, "key=input") + assert.Contains(t, out, "message=nope") +} + +func TestPrintErrorJSON(t *testing.T) { + prev := slog.Default() + buf := new(bytes.Buffer) + slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + printError(gobl.ErrInput.WithReason("missing field")) + out := buf.String() + assert.Contains(t, out, `"key":"input"`) + assert.Contains(t, out, `"message":"missing field"`) +} + +func TestInputFilename(t *testing.T) { + assert.Equal(t, "", inputFilename(nil)) + assert.Equal(t, "", inputFilename([]string{"-"})) + assert.Equal(t, "foo.json", inputFilename([]string{"foo.json"})) +} + +func TestRunExecutesRootCommand(_ *testing.T) { + // Drive `run()` via an invocation that doesn't actually need any + // stdin. Cobra prints help when no args are given and returns nil; + // we only need to confirm it doesn't panic. + stdout, _ := testy.RedirIO(nil, func() { + _ = run() + }) + _, _ = io.ReadAll(stdout) +} + func Test_version(t *testing.T) { cmd := versionCmd() stdout, stderr := testy.RedirIO(nil, func() { diff --git a/cmd/gobl/net.go b/cmd/gobl/net.go new file mode 100644 index 0000000..8c59954 --- /dev/null +++ b/cmd/gobl/net.go @@ -0,0 +1,25 @@ +package main + +import "github.com/spf13/cobra" + +type netOpts struct { + *rootOpts +} + +func netCmd(root *rootOpts) *netOpts { + return &netOpts{rootOpts: root} +} + +func (n *netOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "net", + Short: "GOBL Net operations (EXPERIMENTAL)", + Long: "GOBL Net operations.\n\n" + + "EXPERIMENTAL: GOBL Net is under active development. Commands, on-disk\n" + + "layout, and the wire protocol may change without notice.", + } + cmd.AddCommand(netServe(n.rootOpts).cmd()) + cmd.AddCommand(netSend(n.rootOpts).cmd()) + cmd.AddCommand(netWho(n.rootOpts).cmd()) + return cmd +} diff --git a/cmd/gobl/net_send.go b/cmd/gobl/net_send.go new file mode 100644 index 0000000..eb3e6fe --- /dev/null +++ b/cmd/gobl/net_send.go @@ -0,0 +1,50 @@ +package main + +import ( + "github.com/spf13/cobra" + + "github.com/invopop/gobl.dev/internal/ops" + goblnet "github.com/invopop/gobl/net" +) + +type netSendOpts struct { + *rootOpts + to string + insecure bool +} + +func netSend(root *rootOpts) *netSendOpts { + return &netSendOpts{rootOpts: root} +} + +func (s *netSendOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "send [infile]", + Short: "Send a signed GOBL envelope to a GOBL Net inbox (EXPERIMENTAL)", + Long: "Send a signed GOBL envelope to a GOBL Net inbox.\n\n" + + "EXPERIMENTAL: GOBL Net is under active development and may change without notice.", + Args: cobra.MaximumNArgs(1), + RunE: s.runE, + } + f := cmd.Flags() + f.StringVarP(&s.to, "to", "t", "", "Destination GOBL Net address (FQDN, or host:port with --insecure)") + f.BoolVar(&s.insecure, "insecure", false, "Use plain HTTP and permit host:port form in --to (development)") + _ = cmd.MarkFlagRequired("to") + return cmd +} + +func (s *netSendOpts) runE(cmd *cobra.Command, args []string) error { + ctx := commandContext(cmd) + + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer input.Close() // nolint:errcheck + + return ops.NetSend(ctx, &ops.NetSendOptions{ + Input: input, + To: goblnet.Address(s.to), + Insecure: s.insecure, + }) +} diff --git a/cmd/gobl/net_serve.go b/cmd/gobl/net_serve.go new file mode 100644 index 0000000..7ebd28d --- /dev/null +++ b/cmd/gobl/net_serve.go @@ -0,0 +1,142 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/invopop/gobl.dev/internal/ops" +) + +type netServeOpts struct { + *rootOpts + configDir string + partyFile string + keysDir string + privateKey string + inboxDir string + + httpPort int + httpsPort int + + acmeLive bool + acmeTest bool + domain string + acmeEmail string + certDir string + + tlsCert string + tlsKey string +} + +func netServe(root *rootOpts) *netServeOpts { + return &netServeOpts{rootOpts: root} +} + +func (s *netServeOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "serve", + Short: "Serve the GOBL Net well-known endpoints (EXPERIMENTAL)", + Long: "Serve the GOBL Net well-known endpoints.\n\n" + + "EXPERIMENTAL: GOBL Net is under active development and may change without notice.", + RunE: s.runE, + } + configDir := defaultConfigDir() + + f := cmd.Flags() + f.StringVar(&s.configDir, "config-dir", configDir, "Base directory; its / subdirectories are auto-discovered and served, routed by Host") + f.StringVar(&s.partyFile, "party", "", "Manual single-identity mode: party.json (raw org.Party or signed envelope) served at /.well-known/gobl/who") + f.StringVarP(&s.keysDir, "keys-dir", "k", "", "Manual single-identity mode: directory of .json public keys published at /.well-known/gobl/keys/") + f.StringVar(&s.privateKey, "private-key", "", "Manual single-identity mode: private key paired with the JWKS") + f.StringVar(&s.inboxDir, "inbox", "", "Manual single-identity mode: directory to write accepted envelopes into") + + f.IntVar(&s.httpPort, "http-port", 80, "HTTP listen port") + f.IntVar(&s.httpsPort, "https-port", 443, "HTTPS listen port (used only when a TLS source is configured)") + + f.BoolVar(&s.acmeLive, "acme-live", false, "Activate HTTPS via Let's Encrypt production directory") + f.BoolVar(&s.acmeTest, "acme-test", false, "Activate HTTPS via Let's Encrypt staging directory (for testing)") + f.StringVar(&s.domain, "domain", "", "Hostname the ACME client is allowed to issue for; MUST match the participant's GOBL Net address") + f.StringVar(&s.acmeEmail, "acme-email", "", "Account email for ACME registration") + f.StringVar(&s.certDir, "cert-dir", "", "Directory to cache ACME-issued certificates (default /certs)") + f.StringVar(&s.tlsCert, "tls-cert", "", "PEM-encoded TLS certificate; activates HTTPS with file-based TLS") + f.StringVar(&s.tlsKey, "tls-key", "", "PEM-encoded TLS private key paired with --tls-cert") + + return cmd +} + +func (s *netServeOpts) runE(cmd *cobra.Command, _ []string) error { + if s.configDir == "" { + s.configDir = defaultConfigDir() + } + if s.certDir == "" { + s.certDir = filepath.Join(s.configDir, "certs") + } + if err := s.validate(); err != nil { + return err + } + + opts := &ops.NetServeOptions{ + ConfigDir: s.configDir, + Out: cmd.OutOrStdout(), + + HTTPPort: s.httpPort, + HTTPSPort: s.httpsPort, + + ACMELive: s.acmeLive, + ACMETest: s.acmeTest, + Domain: s.domain, + ACMEEmail: s.acmeEmail, + CertDir: s.certDir, + + CertFile: s.tlsCert, + KeyFile: s.tlsKey, + } + + // Manual single-identity mode: triggered by an explicit --party or + // --keys-dir. Unset companion paths default to the flat config-dir + // layout. + if cmd.Flags().Changed("party") || cmd.Flags().Changed("keys-dir") { + opts.PartyFile = orDefault(s.partyFile, filepath.Join(s.configDir, "party.json")) + opts.KeysDir = orDefault(s.keysDir, filepath.Join(s.configDir, "keys")) + opts.PrivateKeyFile = orDefault(s.privateKey, filepath.Join(s.configDir, "private.jwk")) + opts.InboxDir = orDefault(s.inboxDir, filepath.Join(s.configDir, "inbox")) + } + + ctx := commandContext(cmd) + return ops.NetServe(ctx, opts) +} + +func orDefault(v, def string) string { + if v != "" { + return v + } + return def +} + +func (s *netServeOpts) validate() error { + if s.acmeLive && s.acmeTest { + return errors.New("--acme-live and --acme-test are mutually exclusive") + } + acme := s.acmeLive || s.acmeTest + fileTLS := s.tlsCert != "" || s.tlsKey != "" + if acme && fileTLS { + return errors.New("--acme-* and --tls-cert/--tls-key are mutually exclusive") + } + // Note: --domain is optional here. When absent and ACME is active, + // the domain is derived from the party's GOBL inbox at startup; the + // ops layer fails clearly if neither source provides one. + if (s.tlsCert == "") != (s.tlsKey == "") { + return errors.New("--tls-cert and --tls-key must be provided together") + } + return nil +} + +func defaultConfigDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "gobl" + } + return filepath.Join(home, ".config", "gobl") +} diff --git a/cmd/gobl/net_test.go b/cmd/gobl/net_test.go new file mode 100644 index 0000000..e329fc1 --- /dev/null +++ b/cmd/gobl/net_test.go @@ -0,0 +1,262 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + stdnet "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/invopop/gobl" + "github.com/invopop/gobl.dev/internal/ops" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/note" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/uuid" +) + +// initDomainForCLI scaffolds a domain at // using +// the internal ops layer so cmd/gobl tests can run without invoking +// the full init command. +func initDomainForCLI(t *testing.T, configDir, domain string) { + t.Helper() + require.NoError(t, ops.InitDomain(&ops.InitOptions{ + ConfigDir: configDir, + Domain: domain, + Name: domain, + Out: new(bytes.Buffer), + })) +} + +func TestNetCmdSubcommands(t *testing.T) { + n := netCmd(&rootOpts{}) + c := n.cmd() + assert.Equal(t, "net", c.Use) + have := map[string]bool{} + for _, sub := range c.Commands() { + have[sub.Name()] = true + } + assert.True(t, have["serve"]) + assert.True(t, have["send"]) + assert.True(t, have["who"]) +} + +// ---------- net send ----------- + +func signedNoteBody(t *testing.T) []byte { + t.Helper() + priv := dsig.NewES256Key() + msg := ¬e.Message{Content: "hi"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(priv, + head.WithIssuer(net.Address("peer.example").URI()), + head.WithAudience(net.Address("acme.example").URI()))) + out, err := json.Marshal(env) + require.NoError(t, err) + return out +} + +func TestNetSendCmdMissingTo(t *testing.T) { + o := netSend(&rootOpts{}) + c := o.cmd() + c.SetArgs([]string{"-"}) + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + err := c.Execute() + require.Error(t, err) +} + +func TestNetSendCmdSuccess(t *testing.T) { + body := signedNoteBody(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + tmp := t.TempDir() + infile := filepath.Join(tmp, "env.json") + require.NoError(t, os.WriteFile(infile, body, 0o644)) + + o := netSend(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + u := strings.TrimPrefix(srv.URL, "http://") + c.SetArgs([]string{"--to", u, "--insecure", infile}) + require.NoError(t, c.Execute()) +} + +func TestNetSendCmdBadInput(t *testing.T) { + o := netSend(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"--to", "acme.example", "--insecure", "/no/such/file.json"}) + err := c.Execute() + require.Error(t, err) +} + +// ---------- net who ----------- + +func TestNetWhoCmdMissingFromFlag(t *testing.T) { + o := netWho(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"acme.example"}) + err := c.Execute() + require.Error(t, err) +} + +func TestNetWhoCmdMissingKey(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + o := netWho(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"--from", "missing.example", "acme.example"}) + err := c.Execute() + require.Error(t, err) +} + +func TestNetWhoCmdMissingParty(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + initDomainForCLI(t, filepath.Join(tmp, ".config", "gobl"), "from.example") + require.NoError(t, os.Remove(filepath.Join(tmp, ".config", "gobl", "from.example", "party.json"))) + + o := netWho(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"--from", "from.example", "target.example"}) + err := c.Execute() + require.Error(t, err) +} + +func TestNetWhoCmdBadPartyJSON(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + initDomainForCLI(t, filepath.Join(tmp, ".config", "gobl"), "from.example") + pj := filepath.Join(tmp, ".config", "gobl", "from.example", "party.json") + require.NoError(t, os.WriteFile(pj, []byte("not json"), 0o644)) + + o := netWho(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"--from", "from.example", "target.example"}) + err := c.Execute() + require.Error(t, err) +} + +func TestNetWhoCmdRunENilCase(t *testing.T) { + // Direct runE call with empty --from -> short-circuits with an + // explicit error message. + o := &netWhoOpts{rootOpts: &rootOpts{}} + err := o.runE(&cobra.Command{}, []string{"acme.example"}) + require.Error(t, err) +} + +// ---------- net serve ----------- + +func TestNetServeCmdValidateMutualACME(t *testing.T) { + o := &netServeOpts{rootOpts: &rootOpts{}, acmeLive: true, acmeTest: true} + require.Error(t, o.validate()) +} + +func TestNetServeCmdValidateMutualACMEAndTLS(t *testing.T) { + o := &netServeOpts{rootOpts: &rootOpts{}, acmeLive: true, tlsCert: "x.pem"} + require.Error(t, o.validate()) +} + +func TestNetServeCmdValidatePartialTLS(t *testing.T) { + o := &netServeOpts{rootOpts: &rootOpts{}, tlsCert: "x.pem"} + require.Error(t, o.validate()) +} + +func TestNetServeCmdValidateOK(t *testing.T) { + o := &netServeOpts{rootOpts: &rootOpts{}} + require.NoError(t, o.validate()) +} + +func TestNetServeCmdNoDomainsErrors(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + o := netServe(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"--config-dir", tmp, "--http-port", strconv.Itoa(freeCLIPort(t))}) + err := c.Execute() + require.Error(t, err) +} + +func TestNetServeCmdManualMode(t *testing.T) { + // Manual mode wires --party + --keys-dir + --private-key explicitly. + tmp := t.TempDir() + priv := dsig.NewES256Key() + keysDir := filepath.Join(tmp, "keys") + require.NoError(t, os.MkdirAll(keysDir, 0o755)) + pub, err := json.Marshal(priv.Public()) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(keysDir, priv.ID()+".json"), pub, 0o644)) + privBytes, err := json.MarshalIndent(priv, "", " ") + require.NoError(t, err) + privFile := filepath.Join(tmp, "private.jwk") + require.NoError(t, os.WriteFile(privFile, privBytes, 0o600)) + partyFile := filepath.Join(tmp, "party.json") + partyBytes, err := json.Marshal(&org.Party{Name: "Solo"}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(partyFile, partyBytes, 0o644)) + + o := netServe(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + port := freeCLIPort(t) + c.SetArgs([]string{ + "--keys-dir", keysDir, + "--party", partyFile, + "--private-key", privFile, + "--inbox", filepath.Join(tmp, "inbox"), + "--http-port", strconv.Itoa(port), + }) + + ctx, cancel := context.WithCancel(context.Background()) + c.SetContext(ctx) + done := make(chan error, 1) + go func() { done <- c.Execute() }() + time.Sleep(50 * time.Millisecond) + cancel() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("netServe did not return") + } +} + +func freeCLIPort(t *testing.T) int { + t.Helper() + ln, err := stdnet.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() //nolint:errcheck + return ln.Addr().(*stdnet.TCPAddr).Port +} diff --git a/cmd/gobl/net_who.go b/cmd/gobl/net_who.go new file mode 100644 index 0000000..c96461a --- /dev/null +++ b/cmd/gobl/net_who.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/invopop/gobl.dev/internal/ops" + goblnet "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" +) + +type netWhoOpts struct { + *rootOpts + from string + insecure bool +} + +func netWho(root *rootOpts) *netWhoOpts { + return &netWhoOpts{rootOpts: root} +} + +func (w *netWhoOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "who
", + Short: "Look up the party a GOBL Net domain belongs to (EXPERIMENTAL)", + Long: "Fetch and verify the org.Party published at a GOBL Net address's\n" + + "/.well-known/gobl/who endpoint. The request is authenticated as the\n" + + "--from domain, so /who is a mutual party exchange.\n\n" + + "EXPERIMENTAL: GOBL Net is under active development and may change without notice.", + Args: cobra.ExactArgs(1), + RunE: w.runE, + } + f := cmd.Flags() + f.StringVar(&w.from, "from", "", "Local domain identity (~/.config/gobl//) used to sign the request") + f.BoolVar(&w.insecure, "insecure", false, "Query over plain HTTP and permit host:port (development)") + _ = cmd.MarkFlagRequired("from") + return cmd +} + +func (w *netWhoOpts) runE(cmd *cobra.Command, args []string) error { + ctx := commandContext(cmd) + + if w.from == "" { + return errors.New("--from is required to authenticate the request") + } + dir := filepath.Join(defaultConfigDir(), w.from) + + key, err := loadPrivateKey(filepath.Join(dir, "private.jwk")) + if err != nil { + return err + } + partyData, err := os.ReadFile(filepath.Join(dir, "party.json")) + if err != nil { + return err + } + party := new(org.Party) + if err := json.Unmarshal(partyData, party); err != nil { + return err + } + + result, err := ops.NetWho(ctx, &ops.NetWhoOptions{ + Target: goblnet.Address(args[0]), + From: goblnet.Address(w.from), + FromKey: key, + FromParty: party, + Insecure: w.insecure, + }) + if err != nil { + return err + } + + enc := json.NewEncoder(cmd.OutOrStdout()) + if w.indent { + enc.SetIndent("", "\t") + } + return enc.Encode(result) +} diff --git a/cmd/gobl/root.go b/cmd/gobl/root.go index 2ba6685..492cdd9 100644 --- a/cmd/gobl/root.go +++ b/cmd/gobl/root.go @@ -3,6 +3,7 @@ package main import ( "errors" "io" + "log/slog" "os" "github.com/spf13/cobra" @@ -12,6 +13,7 @@ type rootOpts struct { indent bool // when true, indent output, mainly for testing overwriteOutputFile bool inPlace bool + jsonLogs bool // when true, emit structured JSON log lines (otherwise text) } func root() *rootOpts { @@ -23,6 +25,15 @@ func (o *rootOpts) cmd() *cobra.Command { Use: "gobl", SilenceUsage: true, SilenceErrors: true, + // Apply the --json flag to slog after cobra parses flags but + // before any subcommand runs. Kept on the command (not via + // cobra.OnInitialize) because OnInitialize mutates a + // package-global slice and races with parallel tests that + // build their own root commands. + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + slog.SetDefault(newLogger(o.jsonLogs)) + return nil + }, } o.setFlags(cmd) @@ -37,6 +48,8 @@ func (o *rootOpts) cmd() *cobra.Command { cmd.AddCommand(serve().cmd()) cmd.AddCommand(mcpServe().cmd()) cmd.AddCommand(keygen(o).cmd()) + cmd.AddCommand(initCmd(o).cmd()) + cmd.AddCommand(netCmd(o).cmd()) return cmd } @@ -45,6 +58,7 @@ func (o *rootOpts) setFlags(cmd *cobra.Command) { f.BoolVarP(&o.indent, "indent", "i", false, "format JSON output with indentation") f.BoolVarP(&o.overwriteOutputFile, "force", "f", false, "force writing output file, even if it exists") f.BoolVarP(&o.inPlace, "in-place", "w", false, "overwrite the input file in place (only outputs JSON)") + f.BoolVar(&o.jsonLogs, "json", false, "emit logs and error reports as structured JSON on stderr (result output is unaffected)") } func (o *rootOpts) outputFilename(args []string) string { diff --git a/cmd/gobl/serve.go b/cmd/gobl/serve.go index b1fe5fe..6c42f09 100644 --- a/cmd/gobl/serve.go +++ b/cmd/gobl/serve.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "fmt" + "log/slog" "net/http" "strconv" "time" @@ -51,8 +51,7 @@ func (s *serveOpts) runE(cmd *cobra.Command, _ []string) error { if addr == "" { addr = ":80" } - fmt.Fprintf(cmd.OutOrStdout(), "GOBL %s\n", gobl.VERSION) //nolint:errcheck - fmt.Fprintf(cmd.OutOrStdout(), "Listening on %s\n", addr) //nolint:errcheck + slog.Info("GOBL serve starting", "version", gobl.VERSION, "addr", addr) var startErr error go func() { @@ -64,7 +63,7 @@ func (s *serveOpts) runE(cmd *cobra.Command, _ []string) error { }() <-ctx.Done() - fmt.Fprintln(cmd.OutOrStdout(), "Shutting down...") //nolint:errcheck + slog.Info("Shutting down") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() diff --git a/cmd/gobl/serve_test.go b/cmd/gobl/serve_test.go index 29f98d7..6605b60 100644 --- a/cmd/gobl/serve_test.go +++ b/cmd/gobl/serve_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "testing" @@ -18,14 +19,17 @@ import ( const prefix = "/v0" func TestServeRunE(t *testing.T) { - t.Parallel() + // Capture slog output into a buffer for this test. + prev := slog.Default() + logBuf := new(bytes.Buffer) + slog.SetDefault(slog.New(slog.NewTextHandler(logBuf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) // Create a context that we cancel immediately to stop the server. ctx, cancel := context.WithCancel(context.Background()) cmd := &cobra.Command{} - buf := &bytes.Buffer{} - cmd.SetOut(buf) + cmd.SetOut(new(bytes.Buffer)) cmd.SetContext(ctx) s := serve() @@ -36,8 +40,8 @@ func TestServeRunE(t *testing.T) { err := s.runE(cmd, nil) assert.NoError(t, err) - assert.Contains(t, buf.String(), "GOBL") - assert.Contains(t, buf.String(), "Shutting down...") + assert.Contains(t, logBuf.String(), "GOBL serve starting") + assert.Contains(t, logBuf.String(), "Shutting down") } func TestServeVersion(t *testing.T) { diff --git a/cmd/gobl/sign.go b/cmd/gobl/sign.go index 0251af0..32606dc 100644 --- a/cmd/gobl/sign.go +++ b/cmd/gobl/sign.go @@ -2,13 +2,17 @@ package main import ( "encoding/json" + "errors" "io" "os" + "path/filepath" "github.com/spf13/cobra" - "github.com/invopop/gobl/dsig" "github.com/invopop/gobl.dev/internal/ops" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/dsig" + goblnet "github.com/invopop/gobl/net" ) type signOpts struct { @@ -18,6 +22,8 @@ type signOpts struct { setStrings map[string]string template string privateKeyFile string + domain string + audience string docType string // Command options @@ -47,6 +53,8 @@ func (opts *signOpts) cmd() *cobra.Command { f.StringToStringVar(&opts.setStrings, "set-string", nil, "Set STRING value from the command line") f.StringVarP(&opts.template, "template", "T", "", "Template YAML/JSON file into which data is merged") f.StringVarP(&opts.privateKeyFile, "key", "k", defaultKeyFilename, "Private key file for signing") + f.StringVar(&opts.domain, "domain", "", "Sign with the key from ~/.config/gobl// and stamp iss=gobl:") + f.StringVar(&opts.audience, "to", "", "GOBL Net address to bind the signature to (stamps aud=gobl:)") f.StringVarP(&opts.docType, "type", "t", "", "Specify the document type") return cmd @@ -77,7 +85,20 @@ func (opts *signOpts) runE(cmd *cobra.Command, args []string) error { } defer out.Close() // nolint:errcheck - key, err := loadPrivateKey(opts.privateKeyFile) + keyFile := opts.privateKeyFile + var iss, aud cbc.URI + if opts.domain != "" { + if cmd.Flags().Changed("key") { + return errors.New("--domain and --key are mutually exclusive") + } + keyFile = filepath.Join(defaultConfigDir(), opts.domain, "private.jwk") + iss = goblnet.Address(opts.domain).URI() + } + if opts.audience != "" { + aud = goblnet.Address(opts.audience).URI() + } + + key, err := loadPrivateKey(keyFile) if err != nil { return err } @@ -92,6 +113,8 @@ func (opts *signOpts) runE(cmd *cobra.Command, args []string) error { DocType: opts.docType, }, PrivateKey: key, + Issuer: iss, + Audience: aud, } env, err := ops.Sign(ctx, signOpts) diff --git a/cmd/gobl/sign_extra_test.go b/cmd/gobl/sign_extra_test.go new file mode 100644 index 0000000..4378290 --- /dev/null +++ b/cmd/gobl/sign_extra_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestSignCmdDomainAndKeyMutuallyExclusive(t *testing.T) { + o := sign(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetIn(strings.NewReader(`{}`)) + c.SetArgs([]string{"--domain", "x.example", "--key", "key.jwk", "-"}) + err := c.Execute() + require.Error(t, err) +} + +func TestSignCmdMissingTemplate(t *testing.T) { + o := sign(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"--template", "/no/such/template.yaml", "-"}) + err := c.Execute() + require.Error(t, err) +} + +func TestSignCmdMissingKeyFile(t *testing.T) { + o := sign(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetIn(strings.NewReader(`{}`)) + c.SetArgs([]string{"--key", "/no/such/key.jwk", "-"}) + err := c.Execute() + require.Error(t, err) +} + +// TestSignCmdRunEAudience verifies the --to / audience branch. +func TestSignCmdRunEAudience(t *testing.T) { + o := sign(&rootOpts{}) + c := &cobra.Command{} + c.SetIn(strings.NewReader(`{}`)) + c.SetOut(new(bytes.Buffer)) + o.privateKeyFile = "/no/such/key.jwk" + o.audience = "to.example" + err := o.runE(c, []string{"-"}) + require.Error(t, err) +} + +// TestSignCmdRunEDomainBranch exercises the --domain key-file +// resolution branch by pointing HOME at a tempdir without the +// expected private.jwk file -> open error. +func TestSignCmdRunEDomainBranch(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + o := sign(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetIn(strings.NewReader(`{}`)) + c.SetArgs([]string{"--domain", "x.example", "-"}) + err := c.Execute() + require.Error(t, err) +} + +// TestSignCmdRunETemplateSuccess verifies the --template branch by +// supplying a readable template file. The sign proceeds and only +// errors on the absent key file. +func TestSignCmdRunETemplateSuccess(t *testing.T) { + tmp := t.TempDir() + tpl := filepath.Join(tmp, "tpl.yaml") + require.NoError(t, os.WriteFile(tpl, []byte("doc:\n foo: bar\n"), 0o644)) + o := sign(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetIn(strings.NewReader(`{}`)) + c.SetArgs([]string{"--template", tpl, "--key", "/no/such/key.jwk", "-"}) + err := c.Execute() + require.Error(t, err) +} diff --git a/cmd/gobl/testdata/Test_build_args_force_long b/cmd/gobl/testdata/Test_build_args_force_long index 69bf9a9..4808a9a 100644 --- a/cmd/gobl/testdata/Test_build_args_force_long +++ b/cmd/gobl/testdata/Test_build_args_force_long @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) true, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_build_args_force_short b/cmd/gobl/testdata/Test_build_args_force_short index 69bf9a9..4808a9a 100644 --- a/cmd/gobl/testdata/Test_build_args_force_short +++ b/cmd/gobl/testdata/Test_build_args_force_short @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) true, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_build_args_in-place_long b/cmd/gobl/testdata/Test_build_args_in-place_long index 9cb8aad..a4ac5df 100644 --- a/cmd/gobl/testdata/Test_build_args_in-place_long +++ b/cmd/gobl/testdata/Test_build_args_in-place_long @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) true + inPlace: (bool) true, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_build_args_in-place_short b/cmd/gobl/testdata/Test_build_args_in-place_short index 9cb8aad..a4ac5df 100644 --- a/cmd/gobl/testdata/Test_build_args_in-place_short +++ b/cmd/gobl/testdata/Test_build_args_in-place_short @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) true + inPlace: (bool) true, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_build_args_no_args b/cmd/gobl/testdata/Test_build_args_no_args index 62b43bf..6431961 100644 --- a/cmd/gobl/testdata/Test_build_args_no_args +++ b/cmd/gobl/testdata/Test_build_args_no_args @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_build_args_set_files b/cmd/gobl/testdata/Test_build_args_set_files index 3436349..eb7c850 100644 --- a/cmd/gobl/testdata/Test_build_args_set_files +++ b/cmd/gobl/testdata/Test_build_args_set_files @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) (len=1) { diff --git a/cmd/gobl/testdata/Test_build_args_set_string_values b/cmd/gobl/testdata/Test_build_args_set_string_values index 47c78f8..cff0602 100644 --- a/cmd/gobl/testdata/Test_build_args_set_string_values +++ b/cmd/gobl/testdata/Test_build_args_set_string_values @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_build_args_set_values b/cmd/gobl/testdata/Test_build_args_set_values index 66104bf..3e6b845 100644 --- a/cmd/gobl/testdata/Test_build_args_set_values +++ b/cmd/gobl/testdata/Test_build_args_set_values @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) (len=2) { (string) (len=3) "bar": (string) (len=3) "baz", diff --git a/cmd/gobl/testdata/Test_build_args_template b/cmd/gobl/testdata/Test_build_args_template index 3e02e21..1c4db15 100644 --- a/cmd/gobl/testdata/Test_build_args_template +++ b/cmd/gobl/testdata/Test_build_args_template @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_build_args_type b/cmd/gobl/testdata/Test_build_args_type index a393f0f..7edb952 100644 --- a/cmd/gobl/testdata/Test_build_args_type +++ b/cmd/gobl/testdata/Test_build_args_type @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , diff --git a/cmd/gobl/testdata/Test_correct_args_credit b/cmd/gobl/testdata/Test_correct_args_credit index 29153f7..e960fac 100644 --- a/cmd/gobl/testdata/Test_correct_args_credit +++ b/cmd/gobl/testdata/Test_correct_args_credit @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), options: (bool) false, data: (string) "", diff --git a/cmd/gobl/testdata/Test_correct_args_data b/cmd/gobl/testdata/Test_correct_args_data index 2448c14..4dbfad7 100644 --- a/cmd/gobl/testdata/Test_correct_args_data +++ b/cmd/gobl/testdata/Test_correct_args_data @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), options: (bool) false, data: (string) (len=17) "{\"type\":\"credit\"}", diff --git a/cmd/gobl/testdata/Test_correct_args_data_short b/cmd/gobl/testdata/Test_correct_args_data_short index 2448c14..4dbfad7 100644 --- a/cmd/gobl/testdata/Test_correct_args_data_short +++ b/cmd/gobl/testdata/Test_correct_args_data_short @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), options: (bool) false, data: (string) (len=17) "{\"type\":\"credit\"}", diff --git a/cmd/gobl/testdata/Test_correct_args_debit b/cmd/gobl/testdata/Test_correct_args_debit index 7509752..e1b2587 100644 --- a/cmd/gobl/testdata/Test_correct_args_debit +++ b/cmd/gobl/testdata/Test_correct_args_debit @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), options: (bool) false, data: (string) "", diff --git a/cmd/gobl/testdata/Test_correct_args_no_args b/cmd/gobl/testdata/Test_correct_args_no_args index 9a624e4..0cc5ba8 100644 --- a/cmd/gobl/testdata/Test_correct_args_no_args +++ b/cmd/gobl/testdata/Test_correct_args_no_args @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), options: (bool) false, data: (string) "", diff --git a/cmd/gobl/testdata/Test_correct_args_options b/cmd/gobl/testdata/Test_correct_args_options index e6de3f5..d589c7e 100644 --- a/cmd/gobl/testdata/Test_correct_args_options +++ b/cmd/gobl/testdata/Test_correct_args_options @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), options: (bool) true, data: (string) "", diff --git a/cmd/gobl/testdata/Test_sign_args_force_long b/cmd/gobl/testdata/Test_sign_args_force_long index b9ee31f..b7cdb69 100644 --- a/cmd/gobl/testdata/Test_sign_args_force_long +++ b/cmd/gobl/testdata/Test_sign_args_force_long @@ -2,13 +2,16 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) true, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_force_short b/cmd/gobl/testdata/Test_sign_args_force_short index b9ee31f..b7cdb69 100644 --- a/cmd/gobl/testdata/Test_sign_args_force_short +++ b/cmd/gobl/testdata/Test_sign_args_force_short @@ -2,13 +2,16 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) true, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_in-place_long b/cmd/gobl/testdata/Test_sign_args_in-place_long index 855f922..fe9e946 100644 --- a/cmd/gobl/testdata/Test_sign_args_in-place_long +++ b/cmd/gobl/testdata/Test_sign_args_in-place_long @@ -2,13 +2,16 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) true + inPlace: (bool) true, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_in-place_short b/cmd/gobl/testdata/Test_sign_args_in-place_short index 855f922..fe9e946 100644 --- a/cmd/gobl/testdata/Test_sign_args_in-place_short +++ b/cmd/gobl/testdata/Test_sign_args_in-place_short @@ -2,13 +2,16 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) true + inPlace: (bool) true, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_no_args b/cmd/gobl/testdata/Test_sign_args_no_args index bae2fea..7caa884 100644 --- a/cmd/gobl/testdata/Test_sign_args_no_args +++ b/cmd/gobl/testdata/Test_sign_args_no_args @@ -2,13 +2,16 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_set_files b/cmd/gobl/testdata/Test_sign_args_set_files index 73a8270..3abface 100644 --- a/cmd/gobl/testdata/Test_sign_args_set_files +++ b/cmd/gobl/testdata/Test_sign_args_set_files @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) (len=1) { @@ -11,6 +12,8 @@ setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_set_string_values b/cmd/gobl/testdata/Test_sign_args_set_string_values index 956e79f..4c2ac31 100644 --- a/cmd/gobl/testdata/Test_sign_args_set_string_values +++ b/cmd/gobl/testdata/Test_sign_args_set_string_values @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , @@ -12,6 +13,8 @@ }, template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_set_values b/cmd/gobl/testdata/Test_sign_args_set_values index 9eb1010..2ce38af 100644 --- a/cmd/gobl/testdata/Test_sign_args_set_values +++ b/cmd/gobl/testdata/Test_sign_args_set_values @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) (len=2) { (string) (len=3) "bar": (string) (len=3) "baz", @@ -12,6 +13,8 @@ setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_template b/cmd/gobl/testdata/Test_sign_args_template index 0234957..fc441fb 100644 --- a/cmd/gobl/testdata/Test_sign_args_template +++ b/cmd/gobl/testdata/Test_sign_args_template @@ -2,13 +2,16 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , setStrings: (map[string]string) , template: (string) (len=8) "foo.yaml", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) "", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_sign_args_type b/cmd/gobl/testdata/Test_sign_args_type index 7e46ce1..5b65047 100644 --- a/cmd/gobl/testdata/Test_sign_args_type +++ b/cmd/gobl/testdata/Test_sign_args_type @@ -2,13 +2,16 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), set: (map[string]string) , setFiles: (map[string]string) , setStrings: (map[string]string) , template: (string) "", privateKeyFile: (string) (len=20) "~/.gobl/id_es256.jwk", + domain: (string) "", + audience: (string) "", docType: (string) (len=12) "bill.Invoice", use: (string) (len=23) "sign [infile] [outfile]", short: (string) (len=37) "Signs an envelope using a private key" diff --git a/cmd/gobl/testdata/Test_validate_args_force_long b/cmd/gobl/testdata/Test_validate_args_force_long index 9d23685..05609cf 100644 --- a/cmd/gobl/testdata/Test_validate_args_force_long +++ b/cmd/gobl/testdata/Test_validate_args_force_long @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) true, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), use: (string) (len=27) "validate [infile] [outfile]", short: (string) (len=53) "Validate checks if the input is a valid GOBL document" diff --git a/cmd/gobl/testdata/Test_validate_args_force_short b/cmd/gobl/testdata/Test_validate_args_force_short index 9d23685..05609cf 100644 --- a/cmd/gobl/testdata/Test_validate_args_force_short +++ b/cmd/gobl/testdata/Test_validate_args_force_short @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) true, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), use: (string) (len=27) "validate [infile] [outfile]", short: (string) (len=53) "Validate checks if the input is a valid GOBL document" diff --git a/cmd/gobl/testdata/Test_validate_args_in-place_long b/cmd/gobl/testdata/Test_validate_args_in-place_long index d9b90a5..3536886 100644 --- a/cmd/gobl/testdata/Test_validate_args_in-place_long +++ b/cmd/gobl/testdata/Test_validate_args_in-place_long @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) true + inPlace: (bool) true, + jsonLogs: (bool) false }), use: (string) (len=27) "validate [infile] [outfile]", short: (string) (len=53) "Validate checks if the input is a valid GOBL document" diff --git a/cmd/gobl/testdata/Test_validate_args_in-place_short b/cmd/gobl/testdata/Test_validate_args_in-place_short index d9b90a5..3536886 100644 --- a/cmd/gobl/testdata/Test_validate_args_in-place_short +++ b/cmd/gobl/testdata/Test_validate_args_in-place_short @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) true + inPlace: (bool) true, + jsonLogs: (bool) false }), use: (string) (len=27) "validate [infile] [outfile]", short: (string) (len=53) "Validate checks if the input is a valid GOBL document" diff --git a/cmd/gobl/testdata/Test_validate_args_no_args b/cmd/gobl/testdata/Test_validate_args_no_args index b41f03f..a6ce50e 100644 --- a/cmd/gobl/testdata/Test_validate_args_no_args +++ b/cmd/gobl/testdata/Test_validate_args_no_args @@ -2,7 +2,8 @@ rootOpts: (*main.rootOpts)({ indent: (bool) false, overwriteOutputFile: (bool) false, - inPlace: (bool) false + inPlace: (bool) false, + jsonLogs: (bool) false }), use: (string) (len=27) "validate [infile] [outfile]", short: (string) (len=53) "Validate checks if the input is a valid GOBL document" diff --git a/cmd/gobl/verify.go b/cmd/gobl/verify.go index 72a1eb4..ea6dff4 100644 --- a/cmd/gobl/verify.go +++ b/cmd/gobl/verify.go @@ -6,12 +6,15 @@ import ( "github.com/spf13/cobra" - "github.com/invopop/gobl/dsig" "github.com/invopop/gobl.dev/internal/ops" + "github.com/invopop/gobl/dsig" + goblnet "github.com/invopop/gobl/net" ) type verifyOpts struct { publicKeyFile string + address string + remote bool } func verify() *verifyOpts { @@ -28,6 +31,8 @@ func (v *verifyOpts) cmd() *cobra.Command { f := cmd.Flags() f.StringVarP(&v.publicKeyFile, "key", "k", pubfileFromPriv(defaultKeyFilename), "Public key file for signature validation") + f.StringVarP(&v.address, "address", "a", "", "GOBL Net address (FQDN) for remote key discovery") + f.BoolVarP(&v.remote, "remote", "r", false, "Auto-discover keys from the signature's gn header") return cmd } @@ -41,6 +46,17 @@ func (v *verifyOpts) runE(cmd *cobra.Command, args []string) error { } defer input.Close() // nolint:errcheck + if v.address != "" || v.remote { + var addr goblnet.Address + if v.address != "" { + addr, err = goblnet.ParseAddress(v.address) + if err != nil { + return err + } + } + return ops.VerifyRemote(ctx, input, goblnet.NewClient(), addr) + } + pbFilename, err := expandHome(v.publicKeyFile) if err != nil { return err diff --git a/cmd/gobl/verify_test.go b/cmd/gobl/verify_test.go index a52caea..c3dafb0 100644 --- a/cmd/gobl/verify_test.go +++ b/cmd/gobl/verify_test.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gitlab.com/flimzy/testy" ) @@ -94,3 +95,34 @@ func Test_verify(t *testing.T) { }) } } + +func TestVerifyCmdRemoteAddressInvalid(t *testing.T) { + v := verify() + c := v.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetIn(strings.NewReader(`{}`)) + c.SetArgs([]string{"--address", "single-label", "-"}) + err := c.Execute() + require.Error(t, err) +} + +func TestVerifyCmdRemoteShortCircuit(t *testing.T) { + // --remote with malformed JSON drives the remote-verify path to + // an error at the JSON-unmarshal step (before Validate). + v := verify() + c := v.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetIn(strings.NewReader("\t\t\t@@:")) + c.SetArgs([]string{"--remote", "-"}) + err := c.Execute() + require.Error(t, err) +} + +func TestVerifyCmdCtor(t *testing.T) { + v := verify() + require.NotNil(t, v) + c := v.cmd() + assert.Equal(t, "verify [infile]", c.Use) +} diff --git a/go.mod b/go.mod index c17686e..009b641 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,19 @@ module github.com/invopop/gobl.dev -go 1.24.0 +go 1.25.0 require ( github.com/a-h/templ v0.3.1001 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/google/go-cmp v0.7.0 github.com/imdario/mergo v0.3.16 - github.com/invopop/gobl v0.500.0 - github.com/invopop/gobl.fr.ctc v0.0.3-0.20260609134133-16fd5925da73 - github.com/invopop/gobl.sa.zatca v0.0.1 + github.com/invopop/gobl v0.502.2 + github.com/invopop/gobl.br.nfe v0.0.1 + github.com/invopop/gobl.br.nfse v0.0.1 + github.com/invopop/gobl.fr.ctc v0.0.4 + github.com/invopop/gobl.mx.cfdi v0.61.0 + github.com/invopop/gobl.pt.saft v0.0.1 + github.com/invopop/gobl.sa.zatca v0.0.2 github.com/invopop/icons v0.14.0 github.com/invopop/popui.go v0.30.0 github.com/invopop/yaml v0.3.1 @@ -17,19 +22,19 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.11.1 gitlab.com/flimzy/testy v0.14.0 + golang.org/x/crypto v0.53.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - cloud.google.com/go v0.116.0 // indirect - github.com/Masterminds/semver/v3 v3.3.0 // indirect + cloud.google.com/go v0.118.0 // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/Oudwins/tailwind-merge-go v0.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/expr-lang/expr v1.17.8 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -40,15 +45,14 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index e79ee66..c023849 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go v0.118.0 h1:tvZe1mgqRxpiVa3XlIGMiPcEUbP1gNXELgD4y/IXmeQ= +cloud.google.com/go v0.118.0/go.mod h1:zIt2pkedt/mo+DQjcT4/L3NDxzHPR29j5HcclNH+9PM= github.com/LastPossum/kamino v0.0.2 h1:Zry5lS7x7TTU1hzzk3Utnp+rX8kk/wWhuW52Ha9As+U= github.com/LastPossum/kamino v0.0.2/go.mod h1:H8Qm+6DGeNOoXk9hHIOEAQWS9nbo0YwK32pC/7REsOE= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE= github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U= github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= @@ -15,8 +15,8 @@ github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xW github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -33,14 +33,20 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.403.1-0.20260610211443-8cfb6ccbc876 h1:oFbsWBGQUp9H/8z+c4yZGePeAK5iJbE3q/qaa8Jd9NA= -github.com/invopop/gobl v0.403.1-0.20260610211443-8cfb6ccbc876/go.mod h1:EGDHHuPbF8JxRfct0q/s9t7ei2gjkOwjH3SytjB3aS0= -github.com/invopop/gobl v0.500.0 h1:Ob3uO8wCywDkzJFpKI8zsUY/pL0sYsFobZlfRAZjd44= -github.com/invopop/gobl v0.500.0/go.mod h1:EGDHHuPbF8JxRfct0q/s9t7ei2gjkOwjH3SytjB3aS0= -github.com/invopop/gobl.fr.ctc v0.0.3-0.20260609134133-16fd5925da73 h1:lDYca5PCe2EMMBrBVHpiW9YaY9mv3tksWpzOxQeIihY= -github.com/invopop/gobl.fr.ctc v0.0.3-0.20260609134133-16fd5925da73/go.mod h1:9eowIMNRW8pU7lwoC8pz6I3iCvclr2eXrNDro8sGNMw= -github.com/invopop/gobl.sa.zatca v0.0.1 h1:P4d6IDPId6CU9zA0c1YaXzFYsNq2hgV5kvNRNSI2sVE= -github.com/invopop/gobl.sa.zatca v0.0.1/go.mod h1:HTTAUUQoJgpvqbsd2wvLxbH6aAeypHBfuNadoeafQK8= +github.com/invopop/gobl v0.502.2 h1:guH++uYsy5RjCjn7qGNwFq1xWLceNx0Pmjz/Fm2KCRw= +github.com/invopop/gobl v0.502.2/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= +github.com/invopop/gobl.br.nfe v0.0.1 h1:ywJycz2wiyeNygbRhuRJg52gTnIECsbZ5+pGFqh3eyE= +github.com/invopop/gobl.br.nfe v0.0.1/go.mod h1:ZIJSVz5257xciD+RkBhyvqjtS46Pgw23idTca6A8ZIk= +github.com/invopop/gobl.br.nfse v0.0.1 h1:BdwNiG7vk7bPMgE4m102P+UysOxmYWhOKIDfFI6RKEA= +github.com/invopop/gobl.br.nfse v0.0.1/go.mod h1:m22voo72ZScRiwIS49wXhzGRahzBO+IEAN0M0Kij3QI= +github.com/invopop/gobl.fr.ctc v0.0.4 h1:x4eJ3hp9Y9lTCzWFhNqZYm9kCXyuC34EytOuaU03faE= +github.com/invopop/gobl.fr.ctc v0.0.4/go.mod h1:YXJ0G7lCWxUehI4C2xBPL1y6rTNC29t/kOul7wY+3Xs= +github.com/invopop/gobl.mx.cfdi v0.61.0 h1:f/rtRl5mIgePWQxjLB1UfsU45dQp/4/ZxJjsHGGtjiA= +github.com/invopop/gobl.mx.cfdi v0.61.0/go.mod h1:2ag6z2QhCMltl5YcxL9yL8IzY+mXuOJe39yhk9YWTsM= +github.com/invopop/gobl.pt.saft v0.0.1 h1:sXG+HsiN8rZVAxSvv33i/5+8QM8UJbadklmZZUkA5dM= +github.com/invopop/gobl.pt.saft v0.0.1/go.mod h1:0itJS6OpV+2BcaD4xRwEf4gEOcVeGdrfoghwxB+UWrg= +github.com/invopop/gobl.sa.zatca v0.0.2 h1:qU2Y0LP+4mjvZkuG1TOUp/Zs0XxRAzkzQFHy3WOVFEU= +github.com/invopop/gobl.sa.zatca v0.0.2/go.mod h1:tmLCoaKb4X7sZ2zRU8LalA1sgxM2kQv/uqMzZslhE6A= github.com/invopop/icons v0.14.0 h1:Bk0hF+EI/x3XwWX1IqMeFDMdu766zY3zFBqTkImCdhU= github.com/invopop/icons v0.14.0/go.mod h1:rXrrY2Rz7Z7KINyQIPMaauqXTZNhVMDI2MYIiux436Y= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= @@ -69,8 +75,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -92,15 +98,15 @@ gitlab.com/flimzy/testy v0.14.0 h1:2nZV4Wa1OSJb3rOKHh0GJqvvhtE03zT+sKnPCI0owfQ= gitlab.com/flimzy/testy v0.14.0/go.mod h1:m3aGuwdXc+N3QgnH+2Ar2zf1yg0UxNdIaXKvC5SlfMk= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/ops/bulk_test.go b/internal/ops/bulk_test.go index d8eb520..fe39ae3 100644 --- a/internal/ops/bulk_test.go +++ b/internal/ops/bulk_test.go @@ -17,6 +17,9 @@ import ( "github.com/invopop/gobl/rules" "github.com/stretchr/testify/assert" "gitlab.com/flimzy/testy" + + // Register the full addon set so the schema list matches production. + _ "github.com/invopop/gobl.dev/bundle" ) func TestBulk(t *testing.T) { //nolint:gocyclo @@ -49,15 +52,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, }, }) - tests.Add("one verification", func(t *testing.T) interface{} { + tests.Add("one verification", func(t *testing.T) any { payload, err := os.ReadFile("testdata/success.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "verify", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "publickey": publicKey, }, @@ -83,13 +86,13 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("two verifications", func(_ *testing.T) interface{} { - req1, _ := json.Marshal(map[string]interface{}{ + tests.Add("two verifications", func(_ *testing.T) any { + req1, _ := json.Marshal(map[string]any{ "action": "sleep", "req_id": "abc", "payload": "10ms", }) - req2, _ := json.Marshal(map[string]interface{}{ + req2, _ := json.Marshal(map[string]any{ "action": "sleep", "req_id": "def", "payload": "50ms", @@ -118,15 +121,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("success then failure", func(t *testing.T) interface{} { + tests.Add("success then failure", func(t *testing.T) any { payload, err := os.ReadFile("testdata/success.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "verify", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "publickey": publicKey, }, @@ -153,8 +156,8 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("non-fatal payload error", func(t *testing.T) interface{} { - req, err := json.Marshal(map[string]interface{}{ + tests.Add("non-fatal payload error", func(t *testing.T) any { + req, err := json.Marshal(map[string]any{ "action": "verify", "req_id": "asdf", "payload": "not an object", @@ -180,11 +183,11 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("non-fatal data error", func(t *testing.T) interface{} { - req, err := json.Marshal(map[string]interface{}{ + tests.Add("non-fatal data error", func(t *testing.T) any { + req, err := json.Marshal(map[string]any{ "action": "verify", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": json.RawMessage(`"oink"`), "publickey": publicKey, }, @@ -210,15 +213,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("one build, already signed", func(t *testing.T) interface{} { + tests.Add("one build, already signed", func(t *testing.T) any { payload, err := os.ReadFile("testdata/success.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "build", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "privatekey": privateKey, }, @@ -246,15 +249,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("one build, field errors", func(t *testing.T) interface{} { + tests.Add("one build, field errors", func(t *testing.T) any { payload := []byte(`{ "$schema":"https://gobl.org/draft-0/note/message", "title":"This is a title" }`) - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "build", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), // "privatekey": privateKey, }, @@ -280,15 +283,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("one build, success", func(t *testing.T) interface{} { + tests.Add("one build, success", func(t *testing.T) any { payload, err := os.ReadFile("testdata/nosig.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "build", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "privatekey": privateKey, }, @@ -316,14 +319,14 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("build, invalid doc type", func(t *testing.T) interface{} { + tests.Add("build, invalid doc type", func(t *testing.T) any { payload, err := os.ReadFile("testdata/nosig.json") if err != nil { t.Fatal(err) } - req, _ := json.Marshal(map[string]interface{}{ + req, _ := json.Marshal(map[string]any{ "action": "build", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "type": "chicken", }, @@ -344,14 +347,14 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("build, invalid template", func(t *testing.T) interface{} { + tests.Add("build, invalid template", func(t *testing.T) any { payload, err := os.ReadFile("testdata/nosig.json") if err != nil { t.Fatal(err) } - req, _ := json.Marshal(map[string]interface{}{ + req, _ := json.Marshal(map[string]any{ "action": "build", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "template": "chicken", }, @@ -372,8 +375,8 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("non-fatal payload error, build", func(t *testing.T) interface{} { - req, err := json.Marshal(map[string]interface{}{ + tests.Add("non-fatal payload error, build", func(t *testing.T) any { + req, err := json.Marshal(map[string]any{ "action": "build", "req_id": "asdf", "payload": "not an object", @@ -399,11 +402,11 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("non-fatal data error, build", func(t *testing.T) interface{} { - req, err := json.Marshal(map[string]interface{}{ + tests.Add("non-fatal data error, build", func(t *testing.T) any { + req, err := json.Marshal(map[string]any{ "action": "build", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString([]byte(`"oink"`)), "publickey": publicKey, }, @@ -429,15 +432,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("correct, success", func(t *testing.T) interface{} { + tests.Add("correct, success", func(t *testing.T) any { payload, err := os.ReadFile("testdata/success.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "correct", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "options": []byte(`{"type":"credit-note","ext":{"es-facturae-correction":"01"}}`), }, @@ -465,15 +468,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("correct, options", func(t *testing.T) interface{} { + tests.Add("correct, options", func(t *testing.T) any { payload, err := os.ReadFile("testdata/success.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "correct", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "schema": true, }, @@ -501,15 +504,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("replicate, success", func(t *testing.T) interface{} { + tests.Add("replicate, success", func(t *testing.T) any { payload, err := os.ReadFile("testdata/success.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "replicate", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), }, }) @@ -536,8 +539,8 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("unknown action", func(t *testing.T) interface{} { - req, err := json.Marshal(map[string]interface{}{ + tests.Add("unknown action", func(t *testing.T) any { + req, err := json.Marshal(map[string]any{ "action": "frobnicate", "req_id": "asdf", }) @@ -562,8 +565,8 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("keygen", func(t *testing.T) interface{} { - req, err := json.Marshal(map[string]interface{}{ + tests.Add("keygen", func(t *testing.T) any { + req, err := json.Marshal(map[string]any{ "action": "keygen", "req_id": "asdf", }) @@ -623,7 +626,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo {SeqID: 2, IsFinal: true}, }, }) - tests.Add("schema", func(_ *testing.T) interface{} { + tests.Add("schema", func(_ *testing.T) any { return tt{ opts: &BulkOptions{ In: strings.NewReader(`{"action":"schema","payload":{"path":"head/stamp"}}`), @@ -665,7 +668,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("regime", func(_ *testing.T) interface{} { + tests.Add("regime", func(_ *testing.T) any { return tt{ opts: &BulkOptions{ In: strings.NewReader(`{"action":"regime","payload":{"code":"es"}}`), @@ -697,15 +700,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo {SeqID: 3, IsFinal: true}, }, }) - tests.Add("sign, explicit key given", func(t *testing.T) interface{} { + tests.Add("sign, explicit key given", func(t *testing.T) any { payload, err := os.ReadFile("testdata/nosig.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "sign", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), "privatekey": privateKey, }, @@ -730,15 +733,15 @@ func TestBulk(t *testing.T) { //nolint:gocyclo }, } }) - tests.Add("sign, default key", func(t *testing.T) interface{} { + tests.Add("sign, default key", func(t *testing.T) any { payload, err := os.ReadFile("testdata/nosig.json") if err != nil { t.Fatal(err) } - req, err := json.Marshal(map[string]interface{}{ + req, err := json.Marshal(map[string]any{ "action": "sign", "req_id": "asdf", - "payload": map[string]interface{}{ + "payload": map[string]any{ "data": base64.StdEncoding.EncodeToString(payload), }, }) @@ -775,12 +778,12 @@ func TestBulk(t *testing.T) { //nolint:gocyclo } for i, row := range results { if tt.want[i].Payload != nil { - var got map[string]interface{} + var got map[string]any if err := json.Unmarshal(row.Payload, &got); err != nil { t.Errorf("row %d: %v", i, err) continue } - var want map[string]interface{} + var want map[string]any if err := json.Unmarshal(tt.want[i].Payload, &want); err != nil { t.Errorf("row %d: %v", i, err) continue diff --git a/internal/ops/net_access_log.go b/internal/ops/net_access_log.go new file mode 100644 index 0000000..204945f --- /dev/null +++ b/internal/ops/net_access_log.go @@ -0,0 +1,77 @@ +package ops + +import ( + "log/slog" + "net/http" + "time" +) + +// statusRecorder is an http.ResponseWriter that remembers the first +// status code written so an outer middleware can emit it on the access +// log entry. +type statusRecorder struct { + http.ResponseWriter + status int +} + +// WriteHeader records the status code and forwards to the underlying +// writer. The first call wins; subsequent calls are tracked by the +// wrapped writer but our status field reflects the initial response. +func (r *statusRecorder) WriteHeader(code int) { + if r.status == 0 { + r.status = code + } + r.ResponseWriter.WriteHeader(code) +} + +// Write implements http.ResponseWriter. When the handler writes the +// body without first calling WriteHeader, net/http implicitly responds +// with 200; record that so the access log reflects it. +func (r *statusRecorder) Write(b []byte) (int, error) { + if r.status == 0 { + r.status = http.StatusOK + } + return r.ResponseWriter.Write(b) +} + +// accessLog wraps next so every request emits one structured +// "http_request" entry on log after the handler returns. Handler- +// specific entries (e.g. who.rejected) are emitted from inside the +// handlers themselves; this baseline guarantees a record of every +// request regardless of the handler path taken. +func accessLog(log *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w} + next.ServeHTTP(rec, r) + log.Info("http_request", + "method", r.Method, + "path", r.URL.Path, + "host", stripPort(r.Host), + "remote", r.RemoteAddr, + "status", rec.status, + "duration_ms", time.Since(start).Milliseconds(), + ) + }) +} + +// corsAllowAll wraps next with permissive CORS headers so browser-side +// JWT tooling (jwt.io, OIDC consumers) can fetch /.well-known/jwks.json +// and the per-kid endpoint from any origin. /who and /inbox also pick +// up the same headers — those endpoints are authenticated by the +// signed request body, not the browser origin, so opening CORS does +// not weaken anything. OPTIONS preflight short-circuits to 204. +func corsAllowAll(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Content-Type, Accept") + h.Set("Access-Control-Max-Age", "86400") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/ops/net_access_log_test.go b/internal/ops/net_access_log_test.go new file mode 100644 index 0000000..20f2e24 --- /dev/null +++ b/internal/ops/net_access_log_test.go @@ -0,0 +1,257 @@ +package ops + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/note" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupServerWithLog stands up the test domain handler chain with a +// captured logger so individual test cases can assert on log lines. +func setupServerWithLog(t *testing.T) (*httptest.Server, *bytes.Buffer, string) { + t.Helper() + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + net.Address(testServeDomain).KeyURL(privateKey.ID()): jwkBytes(t, privateKey), + }})) + + buf := new(bytes.Buffer) + log := slog.New(slog.NewTextHandler(buf, nil)) + h, err := buildDomainHandler(dc, client, log) + require.NoError(t, err) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv, buf, dc.InboxDir +} + +func TestAccessLogKeysLookup(t *testing.T) { + srv, buf, _ := setupServerWithLog(t) + + // Known kid -> 200 + keys.lookup found=true. + resp, err := http.Get(srv.URL + net.KeyPath(privateKey.ID())) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + out := buf.String() + assert.Contains(t, out, "keys.lookup") + assert.Contains(t, out, "kid="+privateKey.ID()) + assert.Contains(t, out, "found=true") + assert.Contains(t, out, "http_request") + assert.Contains(t, out, "status=200") + + buf.Reset() + // Unknown kid -> 404 + keys.lookup found=false. + resp404, err := http.Get(srv.URL + net.KeyPath("ghost")) + require.NoError(t, err) + _ = resp404.Body.Close() + assert.Equal(t, http.StatusNotFound, resp404.StatusCode) + out = buf.String() + assert.Contains(t, out, "keys.lookup") + assert.Contains(t, out, "found=false") + assert.Contains(t, out, "status=404") +} + +func TestAccessLogWhoRejectsBadBody(t *testing.T) { + srv, buf, _ := setupServerWithLog(t) + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", strings.NewReader("not json")) + require.NoError(t, err) + _ = resp.Body.Close() + out := buf.String() + assert.Contains(t, out, "who.rejected") + assert.Contains(t, out, "reason=bad_body") + assert.Contains(t, out, "status=400") +} + +func TestAccessLogWhoRejectsVerifyFailed(t *testing.T) { + srv, buf, _ := setupServerWithLog(t) + // Signed by an iss we don't have keys for. + other := dsig.NewES256Key() + env, err := gobl.Envelop(&org.Party{Name: "Stranger"}) + require.NoError(t, err) + require.NoError(t, env.Sign(other, head.WithIssuer(net.Address("unknown.example").URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, _ := json.Marshal(env) + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + out := buf.String() + assert.Contains(t, out, "who.rejected") + assert.Contains(t, out, "reason=verify_failed") + assert.Contains(t, out, "status=401") +} + +func TestAccessLogWhoNotAllowed(t *testing.T) { + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + // allow-list excludes the peer. + require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["other.example"]`), 0o644)) + + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + }})) + buf := new(bytes.Buffer) + log := slog.New(slog.NewTextHandler(buf, nil)) + h, err := buildDomainHandler(dc, client, log) + require.NoError(t, err) + srv := httptest.NewServer(h) + defer srv.Close() + + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(signedRequest(t, testServeDomain))) + require.NoError(t, err) + _ = resp.Body.Close() + out := buf.String() + assert.Contains(t, out, "who.rejected") + assert.Contains(t, out, "reason=not_allowed") + assert.Contains(t, out, "status=403") +} + +func TestAccessLogWhoExchangeSuccess(t *testing.T) { + srv, buf, _ := setupServerWithLog(t) + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(signedRequest(t, testServeDomain))) + require.NoError(t, err) + _ = resp.Body.Close() + out := buf.String() + assert.Contains(t, out, "who.exchange") + assert.Contains(t, out, "caller="+testPeerDomain) + assert.Contains(t, out, "status=200") +} + +func TestAccessLogInboxAccepted(t *testing.T) { + srv, buf, inboxDir := setupServerWithLog(t) + + msg := ¬e.Message{Content: "logged"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, _ := json.Marshal(env) + + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + out := buf.String() + assert.Contains(t, out, "inbox.accepted") + assert.Contains(t, out, "envelope="+env.Head.UUID.String()) + assert.Contains(t, out, "status=202") + + // Sanity: the envelope was persisted. + files, _ := os.ReadDir(inboxDir) + require.Len(t, files, 1) +} + +func TestAccessLogInboxAudMismatch(t *testing.T) { + srv, buf, _ := setupServerWithLog(t) + msg := ¬e.Message{Content: "wrong aud"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) + body, _ := json.Marshal(env) + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + out := buf.String() + assert.Contains(t, out, "inbox.rejected") + assert.Contains(t, out, "reason=aud_mismatch") + assert.Contains(t, out, "status=401") +} + +func TestStatusRecorderImplicit200(t *testing.T) { + // When the inner handler writes a body without an explicit + // WriteHeader, the recorder treats it as 200. + rec := &statusRecorder{ResponseWriter: httptest.NewRecorder()} + _, _ = rec.Write([]byte("hello")) + assert.Equal(t, http.StatusOK, rec.status) +} + +func TestStatusRecorderRespectsFirst(t *testing.T) { + rec := &statusRecorder{ResponseWriter: httptest.NewRecorder()} + rec.WriteHeader(http.StatusCreated) + rec.WriteHeader(http.StatusBadRequest) // second call must not overwrite + assert.Equal(t, http.StatusCreated, rec.status) +} + +func TestCORSAllowAll(t *testing.T) { + srv, _, _ := setupServerWithLog(t) + + t.Run("GET response carries ACAO=*", func(t *testing.T) { + resp, err := http.Get(srv.URL + net.JWKSPath) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("OPTIONS preflight returns 204 with full CORS headers", func(t *testing.T) { + req, err := http.NewRequest(http.MethodOptions, srv.URL+net.JWKSPath, nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://jwt.io") + req.Header.Set("Access-Control-Request-Method", "GET") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "GET") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Content-Type") + assert.NotEmpty(t, resp.Header.Get("Access-Control-Max-Age")) + }) + + t.Run("per-kid endpoint also carries ACAO", func(t *testing.T) { + resp, err := http.Get(srv.URL + net.KeyPath(privateKey.ID())) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) + }) +} + +func TestAccessLogMiddlewareDirect(t *testing.T) { + // Drive the middleware directly to confirm field shape. + buf := new(bytes.Buffer) + log := slog.New(slog.NewTextHandler(buf, nil)) + h := accessLog(log, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "10.0.0.1:1234" + req.Host = "acme.example:8080" + h.ServeHTTP(rec, req) + + out := buf.String() + assert.Contains(t, out, "http_request") + assert.Contains(t, out, "method=GET") + assert.Contains(t, out, "path=/x") + assert.Contains(t, out, "host=acme.example") + assert.Contains(t, out, "remote=10.0.0.1:1234") + assert.Contains(t, out, "status=200") + assert.Contains(t, out, "duration_ms=") +} diff --git a/internal/ops/net_init.go b/internal/ops/net_init.go new file mode 100644 index 0000000..c544635 --- /dev/null +++ b/internal/ops/net_init.go @@ -0,0 +1,71 @@ +package ops + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" +) + +// InitOptions configures InitDomain. +type InitOptions struct { + ConfigDir string + Domain string + Name string // optional party name seed + Force bool // overwrite a non-empty existing directory + Out io.Writer + Log *slog.Logger // optional; defaults to slog.Default() +} + +// InitDomain scaffolds a new GOBL Net domain identity under +// //: a single private key (private.jwk), the +// matching public key as keys/.json (stamped with valid_from), a +// raw org.Party template with a pre-filled gobl: endpoint, and an +// inbox/ directory. The party is intentionally left unsigned — serve +// signs it on demand. +func InitDomain(opts *InitOptions) error { + log := logger(opts.Log) + if opts.Domain == "" { + return fmt.Errorf("init: domain is required") + } + + dc := domainConfigFor(opts.ConfigDir, opts.Domain) + dir := filepath.Join(opts.ConfigDir, opts.Domain) + + if entries, err := os.ReadDir(dir); err == nil && len(entries) > 0 && !opts.Force { + return fmt.Errorf("init: %s already exists and is not empty (use --force to overwrite)", dir) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("init: create domain dir: %w", err) + } + + if _, err := generateKeypair(dc.KeysDir, dc.PrivateKeyFile, log); err != nil { + return err + } + + party := &org.Party{ + Name: opts.Name, + Endpoints: []*org.Endpoint{ + {URI: cbc.URI(net.Scheme + ":" + opts.Domain)}, + }, + } + partyBytes, err := json.MarshalIndent(party, "", " ") + if err != nil { + return fmt.Errorf("init: marshal party: %w", err) + } + if err := os.WriteFile(dc.PartyFile, partyBytes, 0o644); err != nil { + return fmt.Errorf("init: write party: %w", err) + } + if err := os.MkdirAll(dc.InboxDir, 0o755); err != nil { + return fmt.Errorf("init: create inbox dir: %w", err) + } + + log.Info("initialised domain", "domain", opts.Domain, "party", dc.PartyFile, "inbox", dc.InboxDir) + return nil +} diff --git a/internal/ops/net_jwks_test.go b/internal/ops/net_jwks_test.go new file mode 100644 index 0000000..c82c0c5 --- /dev/null +++ b/internal/ops/net_jwks_test.go @@ -0,0 +1,211 @@ +package ops + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// jwksKeysFromBody decodes the JWKS body to a slice of jose JWKs for +// easy field-level assertions. +func jwksKeysFromBody(t *testing.T, body []byte) []jose.JSONWebKey { + t.Helper() + out := struct { + Keys []json.RawMessage `json:"keys"` + }{} + require.NoError(t, json.Unmarshal(body, &out)) + keys := make([]jose.JSONWebKey, 0, len(out.Keys)) + for _, raw := range out.Keys { + var k jose.JSONWebKey + require.NoError(t, json.Unmarshal(raw, &k)) + keys = append(keys, k) + } + return keys +} + +func TestJWKSEndpointSingleKey(t *testing.T) { + srv, _, _ := setupServerWithLog(t) + + resp, err := http.Get(srv.URL + net.JWKSPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + keys := jwksKeysFromBody(t, body) + require.Len(t, keys, 1) + assert.Equal(t, privateKey.ID(), keys[0].KeyID) +} + +func TestJWKSEndpointNewestFirst(t *testing.T) { + // Build a domain with two keys: one with an older valid_from and + // one with a newer one. The JWKS response must put the newer + // first. + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writePrivate(t, dc.PrivateKeyFile, privateKey) + require.NoError(t, os.MkdirAll(dc.KeysDir, 0o755)) + + older := cal.TimestampOf(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + newer := cal.TimestampOf(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + olderKey := writeKeyWithValidFrom(t, dc.KeysDir, dsig.NewES256Key(), &older) + newerKey := writeKeyWithValidFrom(t, dc.KeysDir, privateKey, &newer) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{}})) + h, err := buildDomainHandler(dc, client, slog.New(slog.NewTextHandler(new(bytes.Buffer), nil))) + require.NoError(t, err) + srv := httptest.NewServer(h) + defer srv.Close() + + resp, err := http.Get(srv.URL + net.JWKSPath) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + keys := jwksKeysFromBody(t, body) + require.Len(t, keys, 2) + assert.Equal(t, newerKey, keys[0].KeyID, "newest key (valid_from 2026) sorts first") + assert.Equal(t, olderKey, keys[1].KeyID, "older key (valid_from 2024) sorts second") +} + +func TestJWKSAccessLog(t *testing.T) { + srv, buf, _ := setupServerWithLog(t) + resp, err := http.Get(srv.URL + net.JWKSPath) + require.NoError(t, err) + _ = resp.Body.Close() + out := buf.String() + assert.Contains(t, out, "jwks.served") + assert.Contains(t, out, "count=1") + assert.Contains(t, out, "http_request") + assert.Contains(t, out, "status=200") +} + +func TestBuildJWKSSortFallback(t *testing.T) { + // Two keys with no valid_from: fall back to kid descending. The + // helper takes a kid-keyed map of raw JWK bytes — using + // minimally-shaped EC JWKs keeps the test focused on ordering. + keys := map[string][]byte{ + "aaa": jwkRawNoWindow(t, "aaa"), + "bbb": jwkRawNoWindow(t, "bbb"), + "ccc": jwkRawNoWindow(t, "ccc"), + } + body, count, err := buildJWKS(keys) + require.NoError(t, err) + assert.Equal(t, 3, count) + parsed := jwksKeysFromBody(t, body) + ids := []string{parsed[0].KeyID, parsed[1].KeyID, parsed[2].KeyID} + want := []string{"ccc", "bbb", "aaa"} + assert.Equal(t, want, ids, "kid descending when no valid_from") +} + +func TestBuildJWKSMixedValidFrom(t *testing.T) { + // One key has valid_from, the other doesn't. The one with a + // timestamp sorts before the one without, regardless of kid. + ts := cal.TimestampOf(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + keys := map[string][]byte{ + "zzz-no-ts": jwkRawNoWindow(t, "zzz-no-ts"), + "aaa-with-ts": jwkRawWithValidFrom(t, "aaa-with-ts", &ts), + } + body, _, err := buildJWKS(keys) + require.NoError(t, err) + parsed := jwksKeysFromBody(t, body) + assert.Equal(t, "aaa-with-ts", parsed[0].KeyID) + assert.Equal(t, "zzz-no-ts", parsed[1].KeyID) +} + +func TestBuildJWKSEmpty(t *testing.T) { + body, count, err := buildJWKS(map[string][]byte{}) + require.NoError(t, err) + assert.Equal(t, 0, count) + assert.JSONEq(t, `{"keys":[]}`, string(body)) +} + +func TestBuildJWKSBadJSON(t *testing.T) { + _, _, err := buildJWKS(map[string][]byte{ + "broken": []byte("not json"), + }) + require.Error(t, err) +} + +// --- helpers --- + +// writeKeyWithValidFrom writes a single PublishedKey-shaped JWK with a +// custom valid_from to /.json and returns the kid. Used to +// pin chronological ordering across two keys. +func writeKeyWithValidFrom(t *testing.T, dir string, priv *dsig.PrivateKey, validFrom *cal.Timestamp) string { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o755)) + raw, err := json.Marshal(priv.Public()) + require.NoError(t, err) + pk := new(dsig.PublicKey) + require.NoError(t, json.Unmarshal(raw, pk)) + pk.ValidFrom = validFrom + out, err := json.Marshal(pk) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, priv.ID()+".json"), out, 0o644)) + return priv.ID() +} + +func jwkRawNoWindow(t *testing.T, kid string) []byte { + t.Helper() + priv := dsig.NewES256Key() + raw, err := json.Marshal(priv.Public()) + require.NoError(t, err) + // Rewrite the kid in the marshaled bytes via a parse+remarshal pass. + m := map[string]any{} + require.NoError(t, json.Unmarshal(raw, &m)) + m["kid"] = kid + out, err := json.Marshal(m) + require.NoError(t, err) + return out +} + +func jwkRawWithValidFrom(t *testing.T, kid string, ts *cal.Timestamp) []byte { + t.Helper() + priv := dsig.NewES256Key() + raw, err := json.Marshal(priv.Public()) + require.NoError(t, err) + m := map[string]any{} + require.NoError(t, json.Unmarshal(raw, &m)) + m["kid"] = kid + m["valid_from"] = ts + out, err := json.Marshal(m) + require.NoError(t, err) + return out +} + +// Confirm equal-timestamp entries fall back deterministically to +// kid-descending order. +func TestBuildJWKSEqualValidFromTiebreak(t *testing.T) { + ts := cal.TimestampOf(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + keys := map[string][]byte{ + "k-1": jwkRawWithValidFrom(t, "k-1", &ts), + "k-2": jwkRawWithValidFrom(t, "k-2", &ts), + "k-3": jwkRawWithValidFrom(t, "k-3", &ts), + } + body, _, err := buildJWKS(keys) + require.NoError(t, err) + parsed := jwksKeysFromBody(t, body) + ids := []string{parsed[0].KeyID, parsed[1].KeyID, parsed[2].KeyID} + assert.Equal(t, []string{"k-3", "k-2", "k-1"}, ids) +} diff --git a/internal/ops/net_send.go b/internal/ops/net_send.go new file mode 100644 index 0000000..07958ff --- /dev/null +++ b/internal/ops/net_send.go @@ -0,0 +1,84 @@ +package ops + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/net" +) + +const netSendTimeout = 10 * time.Second + +// NetSendOptions configures the gobl net send command. +type NetSendOptions struct { + Input io.Reader + To net.Address + Insecure bool // when true: use http:// and accept host:port form + Client *http.Client // optional; defaults to a 10s-timeout client +} + +// NetSend reads a GOBL envelope from opts.Input and POSTs it to the +// destination address's inbox endpoint. Returns ErrInboxRejected if +// the inbox does not respond with 202. +func NetSend(ctx context.Context, opts *NetSendOptions) error { + body, err := io.ReadAll(cancelableReader(ctx, opts.Input)) + if err != nil { + return gobl.ErrInput.WithCause(err) + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(body, env); err != nil { + return gobl.ErrInput.WithCause(err) + } + if err := env.Validate(); err != nil { + return gobl.ErrValidation.WithCause(err) + } + + url, err := inboxURL(opts.To, opts.Insecure) + if err != nil { + return err + } + + client := opts.Client + if client == nil { + client = &http.Client{Timeout: netSendTimeout} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("net send: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("net send: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode == http.StatusAccepted { + return nil + } + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("%w: HTTP %d: %s", net.ErrInboxRejected, resp.StatusCode, bytes.TrimSpace(respBody)) +} + +func inboxURL(addr net.Address, insecure bool) (string, error) { + if insecure { + if addr == "" { + return "", net.ErrAddressEmpty + } + return "http://" + string(addr) + net.InboxPath, nil + } + if err := addr.Validate(); err != nil { + return "", err + } + return addr.InboxURL(), nil +} diff --git a/internal/ops/net_send_test.go b/internal/ops/net_send_test.go new file mode 100644 index 0000000..759470f --- /dev/null +++ b/internal/ops/net_send_test.go @@ -0,0 +1,186 @@ +package ops + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/note" + "github.com/invopop/gobl/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func signedNoteEnvelope(t *testing.T, content string) []byte { + t.Helper() + msg := ¬e.Message{Content: content} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + return body +} + +func TestNetSendSuccess(t *testing.T) { + body := signedNoteEnvelope(t, "round trip") + + var received []byte + var receivedContentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, net.InboxPath, r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + receivedContentType = r.Header.Get("Content-Type") + received, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + to := hostFromURL(t, srv.URL) + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader(body), + To: net.Address(to), + Insecure: true, + }) + require.NoError(t, err) + assert.Equal(t, "application/json", receivedContentType) + assert.JSONEq(t, string(body), string(received)) +} + +func TestNetSendRejectsBadEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader([]byte("not json")), + To: net.Address(hostFromURL(t, srv.URL)), + Insecure: true, + }) + require.Error(t, err) +} + +func TestNetSendNon202(t *testing.T) { + body := signedNoteEnvelope(t, "bad sig path") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no thanks", http.StatusUnauthorized) + })) + defer srv.Close() + + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader(body), + To: net.Address(hostFromURL(t, srv.URL)), + Insecure: true, + }) + require.Error(t, err) + assert.True(t, errors.Is(err, net.ErrInboxRejected)) +} + +func TestNetSendInsecureURL(t *testing.T) { + got, err := inboxURL("localhost:8080", true) + require.NoError(t, err) + assert.Equal(t, "http://localhost:8080/.well-known/gobl/inbox", got) +} + +func TestNetSendSecureURL(t *testing.T) { + got, err := inboxURL("example.com", false) + require.NoError(t, err) + assert.Equal(t, "https://example.com/.well-known/gobl/inbox", got) +} + +// hostFromURL strips the scheme from an httptest.NewServer URL so it +// can be used as a `host:port`-form GOBL Net address in --insecure mode. +func hostFromURL(t *testing.T, raw string) string { + t.Helper() + u, err := url.Parse(raw) + require.NoError(t, err) + return strings.TrimPrefix(u.Host, "") +} + +func TestNetSendInboxURLErrors(t *testing.T) { + t.Run("insecure empty address", func(t *testing.T) { + _, err := inboxURL("", true) + require.Error(t, err) + assert.True(t, errors.Is(err, net.ErrAddressEmpty)) + }) + t.Run("secure invalid FQDN", func(t *testing.T) { + _, err := inboxURL("localhost", false) + require.Error(t, err) + }) +} + +func TestNetSendInvalidEnvelopeJSON(t *testing.T) { + // Looks like JSON but fails to unmarshal into Envelope. + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader([]byte("[1,2,3]")), + To: net.Address("example.com"), + Insecure: true, + }) + require.Error(t, err) +} + +func TestNetSendInputReadError(t *testing.T) { + err := NetSend(context.Background(), &NetSendOptions{ + Input: errReader{}, + To: net.Address("example.com"), + Insecure: true, + }) + require.Error(t, err) +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +func TestNetSendInboxURLError(t *testing.T) { + body := signedNoteEnvelope(t, "x") + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader(body), + To: net.Address("localhost"), // single label fails FQDN validation + Insecure: false, + }) + require.Error(t, err) +} + +func TestNetSendTransportError(t *testing.T) { + body := signedNoteEnvelope(t, "x") + // Send to a closed loopback port so client.Do returns an error. + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader(body), + To: net.Address("127.0.0.1:1"), + Insecure: true, + Client: &http.Client{Timeout: 100 * 1000 * 1000}, // 100ms + }) + require.Error(t, err) +} + +func TestNetSendRoundTrip(t *testing.T) { + srv, inboxDir := setupNetServer(t) + + body := signedNoteEnvelope(t, "round trip via serve") + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader(body), + To: net.Address(hostFromURL(t, srv.URL)), + Insecure: true, + }) + require.NoError(t, err) + + // Confirm the envelope landed in the inbox directory. + files, err := readDirNames(inboxDir) + require.NoError(t, err) + require.Len(t, files, 1) + assert.True(t, strings.HasSuffix(files[0], ".json")) +} diff --git a/internal/ops/net_serve.go b/internal/ops/net_serve.go new file mode 100644 index 0000000..8d74621 --- /dev/null +++ b/internal/ops/net_serve.go @@ -0,0 +1,906 @@ +package ops + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + stdnet "net" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/uuid" +) + +const ( + netServeShutdownTimeout = 10 * time.Second + netInboxMaxBody = 1 << 20 // 1 MiB + + defaultHTTPPort = 80 + defaultHTTPSPort = 443 + + acmeStagingDirectoryURL = "https://acme-staging-v02.api.letsencrypt.org/directory" +) + +// NetServeOptions configures the GOBL Net HTTP server. +type NetServeOptions struct { + // ConfigDir is the base directory whose / subdirectories are + // auto-discovered when no explicit single identity is provided. + ConfigDir string + + // Explicit single-identity ("manual") mode: when PartyFile or KeysDir + // is set, exactly one identity is served from these paths. + PartyFile string + KeysDir string // directory of .json public JWK files + PrivateKeyFile string + InboxDir string + + Client *net.Client // optional; defaults to net.NewClient() + Out io.Writer // optional; defaults to os.Stdout (reserved for results, currently unused) + Log *slog.Logger // optional; defaults to slog.Default() + + // Port overrides (zero means use the default — 80 / 443). + HTTPPort int + HTTPSPort int + + // ACME options. ACMELive and ACMETest are mutually exclusive. + ACMELive bool + ACMETest bool + Domain string // restricts multi-domain discovery to one, or names the manual identity + ACMEEmail string + CertDir string + + // File-based TLS. CertFile and KeyFile must be supplied together. + CertFile string + KeyFile string +} + +// domainConfig groups the on-disk paths that make up one GOBL Net +// identity. The directory name is the domain. +type domainConfig struct { + Domain string + KeysDir string // directory of .json public JWK files + PrivateKeyFile string + PartyFile string + InboxDir string + AllowFile string +} + +// logger returns the configured slog.Logger, falling back to slog.Default() +// so library callers (and tests) get sensible behaviour without explicit +// wiring. +func (o *NetServeOptions) logger() *slog.Logger { + if o != nil && o.Log != nil { + return o.Log + } + return slog.Default() +} + +// domainConfigFor builds the standard paths for a domain inside configDir. +func domainConfigFor(configDir, domain string) domainConfig { + dir := filepath.Join(configDir, domain) + return domainConfig{ + Domain: domain, + KeysDir: filepath.Join(dir, "keys"), + PrivateKeyFile: filepath.Join(dir, "private.jwk"), + PartyFile: filepath.Join(dir, "party.json"), + InboxDir: filepath.Join(dir, "inbox"), + AllowFile: filepath.Join(dir, "allow.json"), + } +} + +// loadAllowList reads /allow.json (a JSON array of GOBL Net +// addresses). It returns the set of accepted addresses and whether a +// list is configured at all. An absent file means "accept any verified +// caller" (present == false). +func loadAllowList(dc domainConfig) (map[net.Address]bool, bool, error) { + if dc.AllowFile == "" || !fileExists(dc.AllowFile) { + return nil, false, nil + } + data, err := os.ReadFile(dc.AllowFile) + if err != nil { + return nil, false, fmt.Errorf("net serve: read allow list: %w", err) + } + var addrs []net.Address + if err := json.Unmarshal(data, &addrs); err != nil { + return nil, false, fmt.Errorf("net serve: invalid allow list: %w", err) + } + set := make(map[net.Address]bool, len(addrs)) + for _, a := range addrs { + set[a] = true + } + return set, true, nil +} + +// allowed reports whether addr may call a protected endpoint: any +// verified caller when no list is configured, otherwise only listed ones. +func allowed(set map[net.Address]bool, present bool, addr net.Address) bool { + return !present || set[addr] +} + +// discoverDomains lists the immediate subdirectories of configDir (skipping +// "certs") that look like a domain identity (containing a keys/ dir +// and/or a party.json), returning a domainConfig for each. +func discoverDomains(configDir string) ([]domainConfig, error) { + entries, err := os.ReadDir(configDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("net serve: read config dir: %w", err) + } + var out []domainConfig + for _, e := range entries { + if !e.IsDir() || e.Name() == "certs" { + continue + } + dc := domainConfigFor(configDir, e.Name()) + if dirExists(dc.KeysDir) || fileExists(dc.PartyFile) { + out = append(out, dc) + } + } + return out, nil +} + +// NetServeHandler builds a single-identity HTTP handler from explicit +// options (manual mode). Multi-domain serving uses buildRouter. It is +// exported so tests can drive the resulting handler via httptest. +func NetServeHandler(opts *NetServeOptions) (http.Handler, error) { + client := opts.Client + if client == nil { + client = net.NewClient() + } + dc := domainConfig{ + Domain: opts.Domain, + KeysDir: opts.KeysDir, + PrivateKeyFile: opts.PrivateKeyFile, + PartyFile: opts.PartyFile, + InboxDir: opts.InboxDir, + } + return buildDomainHandler(dc, client, opts.logger()) +} + +// buildDomainHandler prepares one domain's on-disk state (keys, party, +// inbox, allow-list) and returns its mux. +// +// - GET /keys — open, serves the public JWKS. +// - POST /who — authenticated party exchange (see handleWho). +// - POST /inbox — authenticated envelope delivery (see handleInbox). +func buildDomainHandler(dc domainConfig, client *net.Client, log *slog.Logger) (http.Handler, error) { + keysByKID, err := ensureKeys(dc, log) + if err != nil { + return nil, err + } + priv, err := loadPrivateKeyFile(dc.PrivateKeyFile) + if err != nil { + return nil, err + } + partyEnv, err := readPartyEnvelope(dc) + if err != nil { + return nil, err + } + partyEnvBytes, err := json.Marshal(partyEnv) // canonical, unsigned, stable UUID + if err != nil { + return nil, fmt.Errorf("net serve: marshal party: %w", err) + } + if err := os.MkdirAll(dc.InboxDir, 0o755); err != nil { + return nil, fmt.Errorf("net serve: create inbox dir: %w", err) + } + allow, present, err := loadAllowList(dc) + if err != nil { + return nil, err + } + var self cbc.URI + if dc.Domain != "" { + self = net.Address(dc.Domain).URI() + } + + l := logger(log) + jwksBytes, keyCount, err := buildJWKS(keysByKID) + if err != nil { + return nil, err + } + mux := http.NewServeMux() + mux.HandleFunc("GET "+net.KeysPath+"/{kid}", handleKey(l, keysByKID)) + mux.HandleFunc("GET "+net.JWKSPath, handleJWKS(l, jwksBytes, keyCount)) + mux.HandleFunc("POST "+net.WhoPath, handleWho(l, client, partyEnvBytes, priv, self, allow, present)) + mux.HandleFunc("POST "+net.InboxPath, handleInbox(l, client, dc.InboxDir, self, allow, present)) + return accessLog(l, corsAllowAll(mux)), nil +} + +// buildJWKS materialises the bulk JWK Set response by sorting the +// published keys newest-first (by valid_from descending, with +// UUIDv7 kid descending as a tie-breaker) and wrapping them in the +// standard `{"keys":[...]}` envelope. Returned bytes are ready to be +// served as application/json verbatim. +func buildJWKS(keysByKID map[string][]byte) ([]byte, int, error) { + type entry struct { + kid string + validFrom *cal.Timestamp + raw json.RawMessage + } + entries := make([]entry, 0, len(keysByKID)) + for kid, body := range keysByKID { + pk := new(dsig.PublicKey) + if err := json.Unmarshal(body, pk); err != nil { + return nil, 0, fmt.Errorf("net serve: build jwks: parse %s: %w", kid, err) + } + entries = append(entries, entry{ + kid: kid, + validFrom: pk.ValidFrom, + raw: append(json.RawMessage(nil), body...), + }) + } + sort.SliceStable(entries, func(i, j int) bool { + ai, aj := entries[i].validFrom, entries[j].validFrom + switch { + case ai != nil && aj != nil: + if !ai.Equal(aj.Time) { + return ai.After(aj.Time) + } + case ai != nil && aj == nil: + return true // keys with valid_from sort before keys without + case ai == nil && aj != nil: + return false + } + // Fall back to kid descending — UUIDv7 kids are time-ordered. + return entries[i].kid > entries[j].kid + }) + out := struct { + Keys []json.RawMessage `json:"keys"` + }{Keys: make([]json.RawMessage, len(entries))} + for i, e := range entries { + out.Keys[i] = e.raw + } + b, err := json.Marshal(out) + if err != nil { + return nil, 0, fmt.Errorf("net serve: build jwks: %w", err) + } + return b, len(entries), nil +} + +// handleJWKS serves the pre-built JWK Set bytes verbatim. +func handleJWKS(log *slog.Logger, body []byte, count int) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + log.Info("jwks.served", "count", count) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = w.Write(body) + } +} + +// handleKey serves a single published JWK by its kid path value, or 404 +// if the kid is not in the domain's published set. +func handleKey(log *slog.Logger, keysByKID map[string][]byte) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + kid := r.PathValue("kid") + body, ok := keysByKID[kid] + log.Info("keys.lookup", "kid", kid, "found", ok) + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = w.Write(body) + } +} + +// buildRouter returns an HTTP handler dispatching by the request Host +// header to the matching domain's handler. A single unnamed identity +// (manual mode without a domain) is served for all hosts. +func buildRouter(domains []domainConfig, client *net.Client, log *slog.Logger) (http.Handler, error) { + if len(domains) == 1 && domains[0].Domain == "" { + return buildDomainHandler(domains[0], client, log) + } + handlers := make(map[string]http.Handler, len(domains)) + for _, dc := range domains { + h, err := buildDomainHandler(dc, client, log) + if err != nil { + return nil, err + } + handlers[dc.Domain] = h + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := stripPort(r.Host) + if h, ok := handlers[host]; ok { + h.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }), nil +} + +func stripPort(host string) string { + if h, _, err := stdnet.SplitHostPort(host); err == nil { + return h + } + return host +} + +// ensureKeys returns a map of kid → single-JWK JSON bytes for every +// public key published by this domain. Each key lives in its own file +// at /.json. If neither the keys directory nor the +// private key file exist, a fresh ECDSA P-256 keypair is generated and +// persisted (single-key bootstrap). If only one of the two exists the +// setup is inconsistent. +func ensureKeys(dc domainConfig, log *slog.Logger) (map[string][]byte, error) { + keysExists := dirExists(dc.KeysDir) + privExists := fileExists(dc.PrivateKeyFile) + + switch { + case keysExists && privExists: + keysByKID, err := readKeysDir(dc.KeysDir) + if err != nil { + return nil, err + } + if len(keysByKID) == 0 { + return nil, fmt.Errorf("net serve: keys directory %s contains no JWKs", dc.KeysDir) + } + priv, err := loadPrivateKeyFile(dc.PrivateKeyFile) + if err != nil { + return nil, err + } + if _, ok := keysByKID[priv.ID()]; !ok { + return nil, fmt.Errorf("net serve: private key kid %q is not published under %s", priv.ID(), dc.KeysDir) + } + return keysByKID, nil + + case !keysExists && !privExists: + return generateKeypair(dc.KeysDir, dc.PrivateKeyFile, log) + + default: + present, missing := dc.KeysDir, dc.PrivateKeyFile + if !keysExists { + present, missing = dc.PrivateKeyFile, dc.KeysDir + } + return nil, fmt.Errorf( + "net serve: inconsistent key setup — %s exists but %s does not "+ + "(remove both to auto-generate, or supply both)", + present, missing, + ) + } +} + +// readKeysDir reads each .json file in dir, validates that the +// JWK's kid matches the filename stem, and returns the raw file bytes +// keyed by kid. Non-JSON entries and subdirectories are ignored so the +// operator can drop sidecar files alongside their keys. +func readKeysDir(dir string) (map[string][]byte, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("net serve: read keys dir: %w", err) + } + keysByKID := make(map[string][]byte, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + kid := strings.TrimSuffix(name, ".json") + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, fmt.Errorf("net serve: read %s: %w", name, err) + } + pk := new(dsig.PublicKey) + if err := json.Unmarshal(data, pk); err != nil { + return nil, fmt.Errorf("net serve: %s: invalid JWK: %w", name, err) + } + if pk.ID() != kid { + return nil, fmt.Errorf("net serve: %s: filename kid %q does not match JWK kid %q", name, kid, pk.ID()) + } + keysByKID[kid] = data + } + return keysByKID, nil +} + +// generateKeypair creates an ECDSA P-256 keypair, writes the private +// key to privFile (0600) and the public key to keysDir/.json +// (stamping valid_from = now), logs the action, and returns the +// per-kid JWK map. +func generateKeypair(keysDir, privFile string, log *slog.Logger) (map[string][]byte, error) { + if err := os.MkdirAll(filepath.Dir(privFile), 0o700); err != nil { + return nil, fmt.Errorf("net serve: create config dir: %w", err) + } + priv := dsig.NewES256Key() + privBytes, err := json.MarshalIndent(priv, "", " ") + if err != nil { + return nil, fmt.Errorf("net serve: marshal private key: %w", err) + } + if err := os.WriteFile(privFile, privBytes, 0o600); err != nil { + return nil, fmt.Errorf("net serve: write private key: %w", err) + } + pubBytes, err := publishedKeyBytes(priv) + if err != nil { + return nil, fmt.Errorf("net serve: marshal public key: %w", err) + } + if err := os.MkdirAll(keysDir, 0o755); err != nil { + return nil, fmt.Errorf("net serve: create keys dir: %w", err) + } + keyFile := filepath.Join(keysDir, priv.ID()+".json") + if err := os.WriteFile(keyFile, pubBytes, 0o644); err != nil { + return nil, fmt.Errorf("net serve: write key file: %w", err) + } + logger(log).Info("generated keypair", "kid", priv.ID(), "private", privFile, "key_file", keyFile) + return map[string][]byte{priv.ID(): pubBytes}, nil +} + +// logger normalises a possibly-nil *slog.Logger to slog.Default(). +func logger(l *slog.Logger) *slog.Logger { + if l != nil { + return l + } + return slog.Default() +} + +// dirExists reports whether path exists and is a directory. +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// publishedKeyBytes marshals the public counterpart of priv as a +// dsig.PublicKey with valid_from stamped to the current UTC time. +func publishedKeyBytes(priv *dsig.PrivateKey) ([]byte, error) { + pubJSON, err := json.Marshal(priv.Public()) + if err != nil { + return nil, err + } + pk := new(dsig.PublicKey) + if err := json.Unmarshal(pubJSON, pk); err != nil { + return nil, err + } + now := cal.TimestampNow() + pk.ValidFrom = &now + return json.Marshal(pk) +} + +func loadPrivateKeyFile(path string) (*dsig.PrivateKey, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("net serve: read private key: %w", err) + } + k := new(dsig.PrivateKey) + if err := json.Unmarshal(b, k); err != nil { + return nil, fmt.Errorf("net serve: invalid private key: %w", err) + } + return k, nil +} + +// readPartyEnvelope reads the domain's party.json (a raw org.Party or an +// envelope, possibly already signed by an external authority) and returns +// it as an unsigned *gobl.Envelope. The /who handler signs a fresh copy +// per request with iss=self, aud=requester. +func readPartyEnvelope(dc domainConfig) (*gobl.Envelope, error) { + data, err := os.ReadFile(dc.PartyFile) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf( + "net serve: party file not found at %s — create one with `gobl init %s` "+ + "or supply a raw org.Party / signed envelope", + dc.PartyFile, dc.Domain, + ) + } + return nil, fmt.Errorf("net serve: read party file: %w", err) + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(data, env); err == nil && env.Document != nil && !env.Document.IsEmpty() { + return env, nil + } + // Not an envelope — parse as a raw org.Party and wrap it. + party := new(org.Party) + if err := json.Unmarshal(data, party); err != nil { + return nil, fmt.Errorf("net serve: party file: invalid JSON: %w", err) + } + env, err = gobl.Envelop(party) + if err != nil { + return nil, fmt.Errorf("net serve: party file: %w", err) + } + return env, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +// resolveDomains determines which identities to serve: an explicit single +// identity (manual mode) when PartyFile/KeysDir are set, otherwise the +// domains discovered under ConfigDir (optionally filtered by Domain). +func resolveDomains(opts *NetServeOptions) ([]domainConfig, error) { + if opts.PartyFile != "" || opts.KeysDir != "" { + return []domainConfig{{ + Domain: opts.Domain, + KeysDir: opts.KeysDir, + PrivateKeyFile: opts.PrivateKeyFile, + PartyFile: opts.PartyFile, + InboxDir: opts.InboxDir, + }}, nil + } + if opts.ConfigDir == "" { + return nil, errors.New("net serve: no config dir configured") + } + all, err := discoverDomains(opts.ConfigDir) + if err != nil { + return nil, err + } + if opts.Domain != "" { + for _, dc := range all { + if dc.Domain == opts.Domain { + return []domainConfig{dc}, nil + } + } + // Not yet on disk — construct it (keys auto-generate; party required). + return []domainConfig{domainConfigFor(opts.ConfigDir, opts.Domain)}, nil + } + return all, nil +} + +func domainNames(domains []domainConfig) []string { + var names []string + for _, dc := range domains { + if dc.Domain != "" { + names = append(names, dc.Domain) + } + } + return names +} + +// NetServe runs the GOBL Net HTTP server. It always serves over plain +// HTTP and, when a TLS source is configured, additionally over HTTPS with +// identical content (no HTTP→HTTPS redirect). In the default mode it +// discovers every / directory under ConfigDir and routes requests +// by the HTTP Host header. The server shuts down gracefully on ctx cancel. +func NetServe(ctx context.Context, opts *NetServeOptions) error { + if opts.Out == nil { + opts.Out = os.Stdout + } + if opts.Client == nil { + opts.Client = net.NewClient() + } + + domains, err := resolveDomains(opts) + if err != nil { + return err + } + if len(domains) == 0 { + return gobl.ErrInput.WithReason("net serve: no domains configured — run `gobl init ` or pass --party/--keys") + } + + log := opts.logger() + router, err := buildRouter(domains, opts.Client, log) + if err != nil { + return err + } + + httpHandler := router + var tlsConfig *tls.Config + + switch { + case opts.ACMELive || opts.ACMETest: + names := domainNames(domains) + if len(names) == 0 { + return gobl.ErrInput.WithReason("net serve: ACME requires named domains — use --domain or per-domain config directories") + } + m := newAutocertManager(opts, names) + httpHandler = m.HTTPHandler(router) + tlsConfig = m.TLSConfig() + log.Info("ACME enabled", "domains", names) + case opts.CertFile != "" && opts.KeyFile != "": + cert, err := tls.LoadX509KeyPair(opts.CertFile, opts.KeyFile) + if err != nil { + return fmt.Errorf("net serve: load TLS keypair: %w", err) + } + tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + } + + httpPort := opts.HTTPPort + if httpPort == 0 { + httpPort = defaultHTTPPort + } + httpsPort := opts.HTTPSPort + if httpsPort == 0 { + httpsPort = defaultHTTPSPort + } + + httpLn, err := listenTCP(httpPort) + if err != nil { + return err + } + + var httpsLn stdnet.Listener + if tlsConfig != nil { + httpsLn, err = listenTCP(httpsPort) + if err != nil { + _ = httpLn.Close() + return err + } + } + + return serveOnListeners(ctx, opts, httpHandler, router, tlsConfig, httpLn, httpsLn) +} + +// serveOnListeners runs the HTTP (and optionally HTTPS) servers on the +// provided listeners. Both listeners are closed by the http.Server lifecycle. +func serveOnListeners( + ctx context.Context, + opts *NetServeOptions, + httpHandler http.Handler, + httpsHandler http.Handler, + tlsConfig *tls.Config, + httpLn stdnet.Listener, + httpsLn stdnet.Listener, +) error { + httpSrv := &http.Server{ + Handler: httpHandler, + ReadHeaderTimeout: 10 * time.Second, + } + var httpsSrv *http.Server + if httpsLn != nil { + httpsSrv = &http.Server{ + Handler: httpsHandler, + TLSConfig: tlsConfig, + ReadHeaderTimeout: 10 * time.Second, + } + } + + srvCtx, cancel := context.WithCancel(ctx) + defer cancel() + + log := opts.logger() + log.Info("GOBL Net listening", "scheme", "http", "addr", httpLn.Addr().String()) + if httpsLn != nil { + log.Info("GOBL Net listening", "scheme", "https", "addr", httpsLn.Addr().String()) + } + + errCh := make(chan error, 2) + go func() { + err := httpSrv.Serve(httpLn) + if !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("http: %w", err) + cancel() + return + } + errCh <- nil + }() + if httpsSrv != nil { + go func() { + err := httpsSrv.ServeTLS(httpsLn, "", "") + if !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("https: %w", err) + cancel() + return + } + errCh <- nil + }() + } + + <-srvCtx.Done() + log.Info("Shutting down") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), netServeShutdownTimeout) + defer shutdownCancel() + _ = httpSrv.Shutdown(shutdownCtx) + if httpsSrv != nil { + _ = httpsSrv.Shutdown(shutdownCtx) + } + + expected := 1 + if httpsSrv != nil { + expected = 2 + } + var firstErr error + for i := 0; i < expected; i++ { + if err := <-errCh; err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func newAutocertManager(opts *NetServeOptions, domains []string) *autocert.Manager { + certDir := opts.CertDir + if certDir == "" { + certDir = "certs" + } + m := &autocert.Manager{ + Cache: autocert.DirCache(certDir), + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(domains...), + Email: opts.ACMEEmail, + } + if opts.ACMETest { + m.Client = &acme.Client{DirectoryURL: acmeStagingDirectoryURL} + } + return m +} + +// listenTCP binds to the requested port on all interfaces. On EACCES it +// returns a wrapped error that guides the operator to a fix. +func listenTCP(port int) (stdnet.Listener, error) { + addr := ":" + strconv.Itoa(port) + ln, err := stdnet.Listen("tcp", addr) + if err == nil { + return ln, nil + } + if errors.Is(err, syscall.EACCES) { + return nil, fmt.Errorf( + "net serve: cannot bind %s — permission denied. "+ + "Use --http-port / --https-port to pick an unprivileged port, "+ + "grant the binary CAP_NET_BIND_SERVICE "+ + "(setcap 'cap_net_bind_service=+ep' ), or run with sudo / "+ + "inside a container that maps the host port externally", + addr, + ) + } + return nil, fmt.Errorf("net serve: listen %s: %w", addr, err) +} + +func serveBytes(body []byte) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = w.Write(body) + } +} + +// handleWho answers an authenticated party-exchange request. The caller +// POSTs a signed envelope (iss=gobl:caller, aud=gobl:self); the server +// verifies it, allow-lists the caller, and responds with its own party +// envelope signed with iss/aud reversed (iss=gobl:self, aud=gobl:caller). +func handleWho(log *slog.Logger, client *net.Client, partyEnvBytes []byte, priv *dsig.PrivateKey, self cbc.URI, allow map[net.Address]bool, present bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, netInboxMaxBody)) + if err != nil { + log.Warn("who.rejected", "reason", "read_body", "remote", r.RemoteAddr, "error", err.Error()) + http.Error(w, "could not read body", http.StatusBadRequest) + return + } + req := new(gobl.Envelope) + if err := json.Unmarshal(body, req); err != nil { + log.Warn("who.rejected", "reason", "bad_body", "remote", r.RemoteAddr) + http.Error(w, "invalid envelope JSON", http.StatusBadRequest) + return + } + caller, err := client.VerifyEnvelope(r.Context(), req, self) + if err != nil { + log.Warn("who.rejected", "reason", "verify_failed", "remote", r.RemoteAddr, "error", err.Error()) + http.Error(w, "request verification failed: "+err.Error(), http.StatusUnauthorized) + return + } + if !allowed(allow, present, caller) { + log.Warn("who.rejected", "reason", "not_allowed", "caller", string(caller)) + http.Error(w, "caller not accepted", http.StatusForbidden) + return + } + + resp := new(gobl.Envelope) + if err := json.Unmarshal(partyEnvBytes, resp); err != nil { + log.Error("who.party_load_failed", "caller", string(caller), "error", err.Error()) + http.Error(w, "could not load party", http.StatusInternalServerError) + return + } + if err := resp.Sign(priv, head.WithIssuer(self), head.WithAudience(caller.URI())); err != nil { + log.Error("who.sign_failed", "caller", string(caller), "error", err.Error()) + http.Error(w, "could not sign party: "+err.Error(), http.StatusInternalServerError) + return + } + out, err := json.Marshal(resp) + if err != nil { + log.Error("who.encode_failed", "caller", string(caller), "error", err.Error()) + http.Error(w, "could not encode party", http.StatusInternalServerError) + return + } + log.Info("who.exchange", "caller", string(caller)) + serveBytes(out)(w, r) + } +} + +func handleInbox(log *slog.Logger, client *net.Client, dir string, self cbc.URI, allow map[net.Address]bool, present bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, netInboxMaxBody)) + if err != nil { + log.Warn("inbox.rejected", "reason", "read_body", "remote", r.RemoteAddr, "error", err.Error()) + http.Error(w, "could not read body", http.StatusBadRequest) + return + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(body, env); err != nil { + log.Warn("inbox.rejected", "reason", "bad_body", "remote", r.RemoteAddr) + http.Error(w, "invalid envelope JSON", http.StatusBadRequest) + return + } + + if err := env.Validate(); err != nil { + log.Warn("inbox.rejected", "reason", "validation", "remote", r.RemoteAddr, "error", err.Error()) + http.Error(w, "envelope failed validation: "+err.Error(), http.StatusUnprocessableEntity) + return + } + + sender, err := client.VerifyEnvelope(r.Context(), env, "") + if err != nil { + log.Warn("inbox.rejected", "reason", "verify_failed", "remote", r.RemoteAddr, "error", err.Error()) + http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized) + return + } + // Inboxes require the envelope to be bound to this address. A + // missing or mismatched aud is rejected so the same valid + // envelope cannot be replayed against a different inbox. + if self != "" { + p, perr := head.SignedPayload(env.Signatures[0]) + if perr != nil { + log.Warn("inbox.rejected", "reason", "verify_failed", "caller", string(sender), "error", perr.Error()) + http.Error(w, "could not read signed payload", http.StatusUnauthorized) + return + } + if p.Aud == "" { + log.Warn("inbox.rejected", "reason", "aud_missing", "caller", string(sender)) + http.Error(w, "envelope must be signed with an audience matching this inbox", http.StatusUnauthorized) + return + } + if p.Aud != self { + log.Warn("inbox.rejected", "reason", "aud_mismatch", "caller", string(sender), "aud", string(p.Aud)) + http.Error(w, "envelope audience does not match this inbox", http.StatusUnauthorized) + return + } + } + if !allowed(allow, present, sender) { + log.Warn("inbox.rejected", "reason", "not_allowed", "caller", string(sender)) + http.Error(w, "sender not accepted", http.StatusForbidden) + return + } + + // Re-parse the UUID before using it as a filename component. + // env.Validate() above has already rejected non-UUID values, + // but re-parsing here is defence-in-depth: any future change + // that weakens upstream validation cannot let a path-traversal + // payload reach filepath.Join. The parsed canonical form is + // guaranteed to match [0-9a-f-]{36}. + parsedUUID, err := uuid.Parse(env.Head.UUID.String()) + if err != nil { + log.Warn("inbox.rejected", "reason", "malformed_uuid", "caller", string(sender), "error", err.Error()) + http.Error(w, "envelope UUID is malformed", http.StatusUnprocessableEntity) + return + } + filename := filepath.Join(dir, parsedUUID.String()+".json") + f, err := os.Create(filename) + if err != nil { + log.Error("inbox.write_failed", "caller", string(sender), "envelope", parsedUUID.String(), "error", err.Error()) + http.Error(w, "could not write inbox file", http.StatusInternalServerError) + return + } + defer f.Close() //nolint:errcheck + if _, err := f.Write(body); err != nil { + log.Error("inbox.write_failed", "caller", string(sender), "envelope", parsedUUID.String(), "error", err.Error()) + http.Error(w, "could not write inbox file", http.StatusInternalServerError) + return + } + + log.Info("inbox.accepted", "caller", string(sender), "envelope", parsedUUID.String()) + w.WriteHeader(http.StatusAccepted) + } +} diff --git a/internal/ops/net_serve_multidomain_test.go b/internal/ops/net_serve_multidomain_test.go new file mode 100644 index 0000000..e2a89d7 --- /dev/null +++ b/internal/ops/net_serve_multidomain_test.go @@ -0,0 +1,243 @@ +package ops + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/note" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initTestDomain scaffolds a domain under configDir via InitDomain. +func initTestDomain(t *testing.T, configDir, domain string) { + t.Helper() + require.NoError(t, InitDomain(&InitOptions{ + ConfigDir: configDir, + Domain: domain, + Name: domain, + Out: new(bytes.Buffer), + })) +} + +func TestInitDomain(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "billing.invopop.com") + + dir := filepath.Join(configDir, "billing.invopop.com") + assert.DirExists(t, filepath.Join(dir, "keys")) + assert.FileExists(t, filepath.Join(dir, "private.jwk")) + assert.FileExists(t, filepath.Join(dir, "party.json")) + assert.DirExists(t, filepath.Join(dir, "inbox")) + + // Exactly one published key file matching the private key's kid. + entries, err := os.ReadDir(filepath.Join(dir, "keys")) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.True(t, strings.HasSuffix(entries[0].Name(), ".json")) + + info, err := os.Stat(filepath.Join(dir, "private.jwk")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + pb, err := os.ReadFile(filepath.Join(dir, "party.json")) + require.NoError(t, err) + party := new(org.Party) + require.NoError(t, json.Unmarshal(pb, party)) + require.Len(t, party.Endpoints, 1) + assert.Equal(t, "gobl:billing.invopop.com", party.Endpoints[0].URI.String()) + + err = InitDomain(&InitOptions{ConfigDir: configDir, Domain: "billing.invopop.com", Out: new(bytes.Buffer)}) + require.Error(t, err) +} + +func TestInitDomainMissingDomain(t *testing.T) { + err := InitDomain(&InitOptions{ConfigDir: t.TempDir()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "domain is required") +} + +func TestInitDomainExistingNotEmpty(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "a.example") + // Re-init without --force fails. + err := InitDomain(&InitOptions{ + ConfigDir: configDir, + Domain: "a.example", + Out: new(bytes.Buffer), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestInitDomainPartyWriteFails(t *testing.T) { + // Pre-stage //party.json as a directory so + // os.WriteFile fails after generateKeypair succeeds. + configDir := t.TempDir() + dir := filepath.Join(configDir, "x.example") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "party.json"), 0o755)) + + err := InitDomain(&InitOptions{ + ConfigDir: configDir, + Domain: "x.example", + Force: true, + Out: new(bytes.Buffer), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "write party") +} + +func TestInitDomainInboxIsFile(t *testing.T) { + // Pre-stage //inbox as a regular file so + // os.MkdirAll(InboxDir) fails after the key + party writes succeed. + configDir := t.TempDir() + dir := filepath.Join(configDir, "x.example") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "inbox"), []byte("file-not-dir"), 0o644)) + + err := InitDomain(&InitOptions{ + ConfigDir: configDir, + Domain: "x.example", + Force: true, + Out: new(bytes.Buffer), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "create inbox dir") +} + +func TestInitDomainMkdirError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("write-permission tests do not apply when running as root") + } + // Place ConfigDir inside an unwritable parent so MkdirAll fails. + parent := t.TempDir() + ro := filepath.Join(parent, "ro") + require.NoError(t, os.MkdirAll(ro, 0o500)) + t.Cleanup(func() { _ = os.Chmod(ro, 0o755) }) + + err := InitDomain(&InitOptions{ + ConfigDir: filepath.Join(ro, "sub"), + Domain: "x.example", + Out: new(bytes.Buffer), + }) + require.Error(t, err) +} + +func TestInitDomainForceOverwrite(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "a.example") + // --force allows re-init over a populated directory. + err := InitDomain(&InitOptions{ + ConfigDir: configDir, + Domain: "a.example", + Force: true, + Out: new(bytes.Buffer), + }) + require.NoError(t, err, "Force re-init should succeed; new key adds alongside the old one") +} + +func TestInitDomainDefaultsStdout(t *testing.T) { + // Smoke: omitting Out routes to os.Stdout without panicking. We + // can't intercept os.Stdout here cleanly, so just verify the call + // returns success. + configDir := t.TempDir() + err := InitDomain(&InitOptions{ConfigDir: configDir, Domain: "default-out.example"}) + require.NoError(t, err) +} + +func TestDiscoverDomainsSkipsCerts(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "a.example") + initTestDomain(t, configDir, "b.example") + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "certs"), 0o755)) + + domains, err := discoverDomains(configDir) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"a.example", "b.example"}, domainNames(domains)) +} + +func TestMultiDomainRouter(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "a.example") + initTestDomain(t, configDir, "b.example") + domains, err := discoverDomains(configDir) + require.NoError(t, err) + + peerKey := dsig.NewES256Key() + const peer = "peer.example" + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(peer).KeyURL(peerKey.ID()): jwkBytes(t, peerKey), + }})) + + router, err := buildRouter(domains, client, discardLog()) + require.NoError(t, err) + srv := httptest.NewServer(router) + defer srv.Close() + + // POST /who on each host returns a party signed by that host, bound to peer. + for _, host := range []string{"a.example", "b.example"} { + reqEnv, err := gobl.Envelop(&org.Party{Name: "Peer"}) + require.NoError(t, err) + require.NoError(t, reqEnv.Sign(peerKey, head.WithIssuer(net.Address(peer).URI()), head.WithAudience(net.Address(host).URI()))) + body, err := json.Marshal(reqEnv) + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodPost, srv.URL+net.WhoPath, bytes.NewReader(body)) + req.Host = host + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + env := new(gobl.Envelope) + require.NoError(t, json.NewDecoder(resp.Body).Decode(env)) + _ = resp.Body.Close() + p, err := headSignedPayload(env) + require.NoError(t, err) + assert.Equal(t, net.Address(host).URI(), p.Iss) + assert.Equal(t, net.Address(peer).URI(), p.Aud) + } + + // Unknown host -> 404. + req, _ := http.NewRequest(http.MethodPost, srv.URL+net.WhoPath, bytes.NewReader(signedRequest(t, "zzz.example"))) + req.Host = "zzz.example" + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + _ = resp.Body.Close() + + // Inbox POST with Host a.example lands in a.example/inbox. + msg := ¬e.Message{Content: "routed"} + msg.SetUUID(uuid.V7()) + denv, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, denv.Sign(peerKey, head.WithIssuer(net.Address(peer).URI()), head.WithAudience(net.Address("a.example").URI()))) + body, err := json.Marshal(denv) + require.NoError(t, err) + + ireq, _ := http.NewRequest(http.MethodPost, srv.URL+net.InboxPath, bytes.NewReader(body)) + ireq.Host = "a.example" + ireq.Header.Set("Content-Type", "application/json") + iresp, err := http.DefaultClient.Do(ireq) + require.NoError(t, err) + assert.Equal(t, http.StatusAccepted, iresp.StatusCode) + _ = iresp.Body.Close() + + aFiles, err := os.ReadDir(filepath.Join(configDir, "a.example", "inbox")) + require.NoError(t, err) + assert.Len(t, aFiles, 1) + bFiles, err := os.ReadDir(filepath.Join(configDir, "b.example", "inbox")) + require.NoError(t, err) + assert.Empty(t, bFiles) +} diff --git a/internal/ops/net_serve_startup_test.go b/internal/ops/net_serve_startup_test.go new file mode 100644 index 0000000..965f6cd --- /dev/null +++ b/internal/ops/net_serve_startup_test.go @@ -0,0 +1,718 @@ +package ops + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + stdnet "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// discardLog returns a slog.Logger that swallows all output, suitable +// for tests that don't care about log content. +func discardLog() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// captureLog returns a slog.Logger that writes text-formatted entries +// to buf, plus the buf itself so tests can assert on the captured output. +func captureLog() (*slog.Logger, *bytes.Buffer) { + buf := new(bytes.Buffer) + return slog.New(slog.NewTextHandler(buf, nil)), buf +} + +// writeRawParty writes a raw (unsigned) org.Party to path. +func writeRawParty(t *testing.T, path string, party *org.Party) { + t.Helper() + data, err := json.Marshal(party) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) +} + +// writeKey writes a single public JWK to /.json, creating +// dir if needed. +func writeKey(t *testing.T, dir string, key *dsig.PrivateKey) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o755)) + b, err := json.Marshal(key.Public()) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, key.ID()+".json"), b, 0o644)) +} + +func writePrivate(t *testing.T, path string, key *dsig.PrivateKey) { + t.Helper() + b, err := json.Marshal(key) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, b, 0o600)) +} + +func dcFor(dir, domain string) domainConfig { + return domainConfig{ + Domain: domain, + KeysDir: filepath.Join(dir, "keys"), + PrivateKeyFile: filepath.Join(dir, "private.jwk"), + PartyFile: filepath.Join(dir, "party.json"), + InboxDir: filepath.Join(dir, "inbox"), + AllowFile: filepath.Join(dir, "allow.json"), + } +} + +func TestEnsureKeysAutoGenerates(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + + before := time.Now().UTC() + log, buf := captureLog() + keysByKID, err := ensureKeys(dc, log) + require.NoError(t, err) + after := time.Now().UTC() + + assert.DirExists(t, dc.KeysDir) + assert.FileExists(t, dc.PrivateKeyFile) + + logged := buf.String() + assert.Contains(t, logged, "generated keypair") + assert.Contains(t, logged, dc.PrivateKeyFile) + + privBytes, err := os.ReadFile(dc.PrivateKeyFile) + require.NoError(t, err) + priv := new(dsig.PrivateKey) + require.NoError(t, json.Unmarshal(privBytes, priv)) + + // The per-kid map served by the handler must include the freshly + // generated key. + require.Contains(t, keysByKID, priv.ID()) + + // The published key is at /.json on disk. + keyFile := filepath.Join(dc.KeysDir, priv.ID()+".json") + onDisk, err := os.ReadFile(keyFile) + require.NoError(t, err) + pk := new(dsig.PublicKey) + require.NoError(t, json.Unmarshal(onDisk, pk)) + require.Equal(t, priv.ID(), pk.ID()) + + // Freshly generated keys are stamped with valid_from = now and no + // valid_until. + require.NotNil(t, pk.ValidFrom, "valid_from must be stamped on a freshly generated key") + assert.True(t, + !pk.ValidFrom.Before(before.Add(-time.Second)) && + !pk.ValidFrom.After(after.Add(time.Second)), + "valid_from %v should fall within [%v, %v]", pk.ValidFrom.Time, before, after, + ) + assert.Nil(t, pk.ValidUntil) + + info, err := os.Stat(dc.PrivateKeyFile) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestEnsureKeysRejectsPartialState(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, dsig.NewES256Key()) // only the public side + + _, err := ensureKeys(dc, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "inconsistent key setup") +} + +func TestEnsureKeysRejectsMismatchedKid(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, dsig.NewES256Key()) + writePrivate(t, dc.PrivateKeyFile, dsig.NewES256Key()) + + _, err := ensureKeys(dc, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not published under") +} + +func TestEnsureKeysRejectsMismatchedFilename(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + // Write the public JWK under the wrong filename. + require.NoError(t, os.MkdirAll(dc.KeysDir, 0o755)) + k := dsig.NewES256Key() + b, err := json.Marshal(k.Public()) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dc.KeysDir, "wrong-name.json"), b, 0o644)) + writePrivate(t, dc.PrivateKeyFile, k) + + _, err = ensureKeys(dc, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match JWK kid") +} + +func TestNetServeHandlerPartyMissing(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + + _, err := NetServeHandler(&NetServeOptions{ + PartyFile: dc.PartyFile, + KeysDir: dc.KeysDir, + PrivateKeyFile: dc.PrivateKeyFile, + InboxDir: dc.InboxDir, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "party file not found") + assert.Contains(t, err.Error(), "gobl init") +} + +func TestReadPartyEnvelopeRaw(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "d.example.com") + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Acme"}) + + env, err := readPartyEnvelope(dc) + require.NoError(t, err) + require.False(t, env.Signed(), "party is returned unsigned; /who signs per request") + party, ok := env.Extract().(*org.Party) + require.True(t, ok) + assert.Equal(t, "Acme", party.Name) +} + +func TestReadPartyEnvelopeMissing(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "d.example.com") + _, err := readPartyEnvelope(dc) + require.Error(t, err) + assert.Contains(t, err.Error(), "party file not found") +} + +func TestReadPartyEnvelopeReadError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("read-permission tests do not apply when running as root") + } + dir := t.TempDir() + dc := dcFor(dir, "d.example.com") + require.NoError(t, os.WriteFile(dc.PartyFile, []byte("{}"), 0o000)) + t.Cleanup(func() { _ = os.Chmod(dc.PartyFile, 0o644) }) + _, err := readPartyEnvelope(dc) + require.Error(t, err) + assert.Contains(t, err.Error(), "read party file") +} + +func TestReadPartyEnvelopeSignedEnvelopeRoundTrip(t *testing.T) { + // A pre-signed envelope on disk passes through unchanged. + dir := t.TempDir() + dc := dcFor(dir, "d.example.com") + env, err := gobl.Envelop(&org.Party{Name: "Pre-signed"}) + require.NoError(t, err) + require.NoError(t, env.Sign(privateKey, + head.WithIssuer(net.Address("d.example.com").URI()), + head.WithAudience(net.Address("other.example").URI()))) + data, err := json.Marshal(env) + require.NoError(t, err) + require.NoError(t, os.WriteFile(dc.PartyFile, data, 0o644)) + + got, err := readPartyEnvelope(dc) + require.NoError(t, err) + party, ok := got.Extract().(*org.Party) + require.True(t, ok) + assert.Equal(t, "Pre-signed", party.Name) +} + +func TestReadPartyEnvelopeInvalidJSON(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "d.example.com") + require.NoError(t, os.WriteFile(dc.PartyFile, []byte("not json"), 0o644)) + _, err := readPartyEnvelope(dc) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestLoadAllowList(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "x.example") + + t.Run("absent file: present=false", func(t *testing.T) { + set, present, err := loadAllowList(dc) + require.NoError(t, err) + assert.False(t, present) + assert.Nil(t, set) + }) + + t.Run("empty AllowFile path: present=false", func(t *testing.T) { + bare := domainConfig{} // AllowFile == "" + set, present, err := loadAllowList(bare) + require.NoError(t, err) + assert.False(t, present) + assert.Nil(t, set) + }) + + t.Run("valid list", func(t *testing.T) { + require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["a.example","b.example"]`), 0o644)) + set, present, err := loadAllowList(dc) + require.NoError(t, err) + assert.True(t, present) + assert.True(t, set["a.example"]) + assert.True(t, set["b.example"]) + assert.False(t, set["c.example"]) + }) + + t.Run("invalid JSON", func(t *testing.T) { + require.NoError(t, os.WriteFile(dc.AllowFile, []byte("not json"), 0o644)) + _, _, err := loadAllowList(dc) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid allow list") + }) +} + +func TestAllowed(t *testing.T) { + t.Run("no list present: any caller accepted", func(t *testing.T) { + assert.True(t, allowed(nil, false, "any.example")) + }) + t.Run("list present: only listed caller accepted", func(t *testing.T) { + set := map[net.Address]bool{"a.example": true} + assert.True(t, allowed(set, true, "a.example")) + assert.False(t, allowed(set, true, "b.example")) + }) +} + +func TestDiscoverDomainsMissingConfigDir(t *testing.T) { + // Non-existent config dir returns nil slice, no error. + dcs, err := discoverDomains(filepath.Join(t.TempDir(), "does-not-exist")) + require.NoError(t, err) + assert.Empty(t, dcs) +} + +func TestDomainNames(t *testing.T) { + got := domainNames([]domainConfig{ + {Domain: "a.example"}, + {Domain: ""}, + {Domain: "b.example"}, + }) + assert.Equal(t, []string{"a.example", "b.example"}, got) +} + +func TestStripPort(t *testing.T) { + assert.Equal(t, "x.example", stripPort("x.example:8080")) + assert.Equal(t, "x.example", stripPort("x.example")) +} + +func TestFileExistsAndDirExists(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "f.txt") + require.NoError(t, os.WriteFile(f, []byte("x"), 0o644)) + assert.True(t, fileExists(f)) + assert.False(t, fileExists(dir), "fileExists returns false for directories") + assert.True(t, dirExists(dir)) + assert.False(t, dirExists(f), "dirExists returns false for files") +} + +func TestLoadPrivateKeyFileErrors(t *testing.T) { + dir := t.TempDir() + t.Run("missing", func(t *testing.T) { + _, err := loadPrivateKeyFile(filepath.Join(dir, "nope.jwk")) + require.Error(t, err) + }) + t.Run("bad JSON", func(t *testing.T) { + p := filepath.Join(dir, "bad.jwk") + require.NoError(t, os.WriteFile(p, []byte("not json"), 0o600)) + _, err := loadPrivateKeyFile(p) + require.Error(t, err) + }) +} + +func TestResolveDomains(t *testing.T) { + t.Run("manual mode via KeysDir", func(t *testing.T) { + dcs, err := resolveDomains(&NetServeOptions{KeysDir: "/keys", Domain: "x"}) + require.NoError(t, err) + require.Len(t, dcs, 1) + assert.Equal(t, "x", dcs[0].Domain) + assert.Equal(t, "/keys", dcs[0].KeysDir) + }) + t.Run("manual mode via PartyFile", func(t *testing.T) { + dcs, err := resolveDomains(&NetServeOptions{PartyFile: "/p"}) + require.NoError(t, err) + require.Len(t, dcs, 1) + }) + t.Run("no config dir", func(t *testing.T) { + _, err := resolveDomains(&NetServeOptions{}) + require.Error(t, err) + }) + t.Run("discovered domains", func(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "a.example") + initTestDomain(t, configDir, "b.example") + dcs, err := resolveDomains(&NetServeOptions{ConfigDir: configDir}) + require.NoError(t, err) + assert.Len(t, dcs, 2) + }) + t.Run("named domain found", func(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "a.example") + dcs, err := resolveDomains(&NetServeOptions{ConfigDir: configDir, Domain: "a.example"}) + require.NoError(t, err) + require.Len(t, dcs, 1) + assert.Equal(t, "a.example", dcs[0].Domain) + }) + t.Run("named domain bootstrapped", func(t *testing.T) { + configDir := t.TempDir() + // No domains on disk — resolveDomains returns the constructed config. + dcs, err := resolveDomains(&NetServeOptions{ConfigDir: configDir, Domain: "fresh.example"}) + require.NoError(t, err) + require.Len(t, dcs, 1) + assert.Equal(t, "fresh.example", dcs[0].Domain) + }) +} + +func TestEnsureKeysOnlyKeysDirExists(t *testing.T) { + // keys dir present but no private.jwk -> inconsistent setup with + // keys as `present` (covers the other arm of the default branch). + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, dsig.NewES256Key()) + _, err := ensureKeys(dc, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "inconsistent key setup") +} + +func TestEnsureKeysBadPrivateKey(t *testing.T) { + // keys dir + private.jwk both exist but private.jwk is unparseable. + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, dsig.NewES256Key()) + require.NoError(t, os.WriteFile(dc.PrivateKeyFile, []byte("not json"), 0o600)) + _, err := ensureKeys(dc, discardLog()) + require.Error(t, err) +} + +func TestEnsureKeysEmptyKeysDir(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + // keys dir exists but is empty (no .json files). + require.NoError(t, os.MkdirAll(dc.KeysDir, 0o755)) + writePrivate(t, dc.PrivateKeyFile, privateKey) + _, err := ensureKeys(dc, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "contains no JWKs") +} + +func TestReadKeysDirInvalidJSON(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644)) + _, err := readKeysDir(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JWK") +} + +func TestReadKeysDirIgnoresNonJSON(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(dir, 0o755)) + // Non-JSON file: ignored. + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello"), 0o644)) + // Subdirectory: ignored. + require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir"), 0o755)) + got, err := readKeysDir(dir) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestNetServeHandlerDefaultsClient(t *testing.T) { + // Omitting Out + Client routes to defaults without panic. + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "X"}) + + h, err := NetServeHandler(&NetServeOptions{ + PartyFile: dc.PartyFile, + KeysDir: dc.KeysDir, + PrivateKeyFile: dc.PrivateKeyFile, + InboxDir: dc.InboxDir, + }) + require.NoError(t, err) + require.NotNil(t, h) +} + +func TestBuildRouterSingleUnnamed(t *testing.T) { + // One unnamed identity: router shortcircuits to a single handler. + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Solo"}) + + h, err := buildRouter([]domainConfig{dc}, nil, discardLog()) + require.NoError(t, err) + require.NotNil(t, h) +} + +func TestNetServeRunCancel(t *testing.T) { + // End-to-end smoke of NetServe via a temp config dir. Picks a free + // unprivileged port (race-safe enough for tests), then cancels the + // context to drive the graceful-shutdown branch. + configDir := t.TempDir() + initTestDomain(t, configDir, "x.example") + + port := freePort(t) + ctx, cancel := context.WithCancel(context.Background()) + log, buf := captureLog() + doneCh := make(chan error, 1) + go func() { + doneCh <- NetServe(ctx, &NetServeOptions{ + ConfigDir: configDir, + HTTPPort: port, + Log: log, + }) + }() + // Let the goroutine reach Serve before shutting it down. + time.Sleep(50 * time.Millisecond) + cancel() + select { + case err := <-doneCh: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("NetServe did not return after cancel") + } + // Confirm it logged the listening address. + assert.Contains(t, buf.String(), "GOBL Net listening") + assert.Contains(t, buf.String(), "scheme=http") + assert.Contains(t, buf.String(), "Shutting down") +} + +// freePort returns a TCP port that's currently free on 127.0.0.1. +// There's a small race window between close and reuse, but it is good +// enough for short-lived test binds. +func freePort(t *testing.T) int { + t.Helper() + ln, err := stdnet.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := ln.Addr().(*stdnet.TCPAddr).Port + _ = ln.Close() + return port +} + +func TestNetServeNoDomains(t *testing.T) { + configDir := t.TempDir() + // Empty config dir → discoverDomains returns nothing → error. + err := NetServe(context.Background(), &NetServeOptions{ConfigDir: configDir}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no domains configured") +} + +func TestNetServeWithACMETest(t *testing.T) { + // ACMETest with a named domain: drives the ACME case in NetServe + // (sets up the autocert manager). We use HTTPPort=freePort to + // avoid privilege issues, and cancel quickly to shut down. + configDir := t.TempDir() + initTestDomain(t, configDir, "x.example") + + ctx, cancel := context.WithCancel(context.Background()) + log, buf := captureLog() + doneCh := make(chan error, 1) + go func() { + doneCh <- NetServe(ctx, &NetServeOptions{ + ConfigDir: configDir, + ACMETest: true, + Domain: "x.example", + HTTPPort: freePort(t), + HTTPSPort: freePort(t), + CertDir: filepath.Join(configDir, "certs"), + Log: log, + }) + }() + time.Sleep(50 * time.Millisecond) + cancel() + select { + case <-doneCh: + case <-time.After(5 * time.Second): + t.Fatal("NetServe did not return after cancel") + } + assert.Contains(t, buf.String(), "ACME enabled") +} + +func TestNetServeACMEManualMode(t *testing.T) { + // Manual mode (no Domain) + ACME requires named domains -> error. + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Solo"}) + + err := NetServe(context.Background(), &NetServeOptions{ + KeysDir: dc.KeysDir, + PrivateKeyFile: dc.PrivateKeyFile, + PartyFile: dc.PartyFile, + InboxDir: dc.InboxDir, + ACMETest: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "ACME requires named domains") +} + +func TestNetServeCertFileMissing(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "x.example") + err := NetServe(context.Background(), &NetServeOptions{ + ConfigDir: configDir, + CertFile: "/no/such/cert.pem", + KeyFile: "/no/such/key.pem", + HTTPPort: freePort(t), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "load TLS keypair") +} + +func TestNetServeBuildRouterError(t *testing.T) { + // Domain exists on disk but has an inconsistent key state: only + // the keys/ dir is populated, no private.jwk. buildRouter fails. + configDir := t.TempDir() + domain := "broken.example" + dc := domainConfigFor(configDir, domain) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, domain), 0o755)) + writeKey(t, dc.KeysDir, dsig.NewES256Key()) + err := NetServe(context.Background(), &NetServeOptions{ConfigDir: configDir}) + require.Error(t, err) +} + +func TestGenerateKeypairWriteErrors(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("write-permission tests do not apply when running as root") + } + t.Run("private dir not writable", func(t *testing.T) { + dir := t.TempDir() + // Make the parent dir read-only so MkdirAll(parent, ...) for + // the private key file fails. + ro := filepath.Join(dir, "ro") + require.NoError(t, os.MkdirAll(ro, 0o500)) + t.Cleanup(func() { _ = os.Chmod(ro, 0o755) }) + + _, err := generateKeypair(filepath.Join(dir, "keys"), filepath.Join(ro, "sub", "private.jwk"), discardLog()) + require.Error(t, err) + }) + t.Run("keys dir not writable", func(t *testing.T) { + dir := t.TempDir() + ro := filepath.Join(dir, "ro") + require.NoError(t, os.MkdirAll(ro, 0o500)) + t.Cleanup(func() { _ = os.Chmod(ro, 0o755) }) + + // Private file path is fine; keysDir path is inside a non-writable parent. + _, err := generateKeypair(filepath.Join(ro, "keys"), filepath.Join(dir, "private.jwk"), discardLog()) + require.Error(t, err) + }) +} + +func TestReadKeysDirNonExistent(t *testing.T) { + _, err := readKeysDir(filepath.Join(t.TempDir(), "missing")) + require.Error(t, err) + assert.Contains(t, err.Error(), "read keys dir") +} + +func TestLoadAllowListReadError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("read-permission tests do not apply when running as root") + } + dir := t.TempDir() + dc := dcFor(dir, "") + require.NoError(t, os.WriteFile(dc.AllowFile, []byte("[]"), 0o000)) + t.Cleanup(func() { _ = os.Chmod(dc.AllowFile, 0o644) }) + _, _, err := loadAllowList(dc) + require.Error(t, err) + assert.Contains(t, err.Error(), "read allow list") +} + +func TestDiscoverDomainsReadError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("read-permission tests do not apply when running as root") + } + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + _, err := discoverDomains(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "read config dir") +} + +func TestReadKeysDirReadError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("read-permission tests do not apply when running as root") + } + dir := t.TempDir() + keysDir := filepath.Join(dir, "keys") + require.NoError(t, os.MkdirAll(keysDir, 0o755)) + // A file inside that we cannot read. + bad := filepath.Join(keysDir, "abc.json") + require.NoError(t, os.WriteFile(bad, []byte(`{}`), 0o000)) + t.Cleanup(func() { _ = os.Chmod(bad, 0o644) }) + + _, err := readKeysDir(keysDir) + require.Error(t, err) +} + +func TestBuildRouterPropagatesDomainError(t *testing.T) { + // Domain with no keys/private key and no party -> ensureKeys fails. + dir := t.TempDir() + dc := dcFor(dir, "broken.example") + // Write keys/ dir without private.jwk to trigger the "inconsistent" path. + writeKey(t, dc.KeysDir, dsig.NewES256Key()) + _, err := buildRouter([]domainConfig{dc}, nil, discardLog()) + require.Error(t, err) +} + +// TestBuildDomainHandlerErrors covers the buildDomainHandler error +// branches that come after ensureKeys: bad private key, missing party, +// malformed allow-list, and inbox-mkdir-fail. +func TestBuildDomainHandlerErrors(t *testing.T) { + t.Run("bad private key", func(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + require.NoError(t, os.WriteFile(dc.PrivateKeyFile, []byte("not json"), 0o600)) + _, err := buildDomainHandler(dc, nil, discardLog()) + require.Error(t, err) + }) + + t.Run("missing party", func(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + _, err := buildDomainHandler(dc, nil, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "party file not found") + }) + + t.Run("bad allow list", func(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + require.NoError(t, os.WriteFile(dc.AllowFile, []byte("not json"), 0o644)) + _, err := buildDomainHandler(dc, nil, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid allow list") + }) + + t.Run("inbox is a file", func(t *testing.T) { + dir := t.TempDir() + dc := dcFor(dir, "") + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + // Pre-create dc.InboxDir as a regular file so MkdirAll fails. + require.NoError(t, os.WriteFile(dc.InboxDir, []byte("x"), 0o644)) + _, err := buildDomainHandler(dc, nil, discardLog()) + require.Error(t, err) + assert.Contains(t, err.Error(), "create inbox dir") + }) +} diff --git a/internal/ops/net_serve_test.go b/internal/ops/net_serve_test.go new file mode 100644 index 0000000..ad8818e --- /dev/null +++ b/internal/ops/net_serve_test.go @@ -0,0 +1,446 @@ +package ops + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-jose/go-jose/v4" + "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/note" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mapFetcher struct { + data map[string][]byte +} + +func (m *mapFetcher) Fetch(_ context.Context, url string) ([]byte, error) { + body, ok := m.data[url] + if !ok { + return nil, net.ErrFetchFailed + } + return body, nil +} + +// jwkBytes returns the single-JWK bytes served at the per-key endpoint +// for this key. +func jwkBytes(t *testing.T, key *dsig.PrivateKey) []byte { + t.Helper() + b, err := json.Marshal(key.Public()) + require.NoError(t, err) + return b +} + +const ( + testServeDomain = "me.example" + testPeerDomain = "peer.example" +) + +var testPeerKey = dsig.NewES256Key() + +// setupNetServer stands up a single-domain handler for testServeDomain +// (signed by the package privateKey) whose client can resolve both the +// served domain's and the peer's /keys. Returns the server and inbox dir. +func setupNetServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + net.Address(testServeDomain).KeyURL(privateKey.ID()): jwkBytes(t, privateKey), + }})) + + h, err := buildDomainHandler(dc, client, discardLog()) + require.NoError(t, err) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv, dc.InboxDir +} + +// signedRequest builds an envelope wrapping the peer's party, signed +// iss=peer, aud=. +func signedRequest(t *testing.T, aud net.Address) []byte { + t.Helper() + env, err := gobl.Envelop(&org.Party{Name: "Peer"}) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(aud.URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + return body +} + +func TestNetServeKeys(t *testing.T) { + srv, _ := setupNetServer(t) + + // Per-key endpoint: known kid returns the single JWK. + resp, err := http.Get(srv.URL + net.KeyPath(privateKey.ID())) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + jwk := new(jose.JSONWebKey) + require.NoError(t, json.NewDecoder(resp.Body).Decode(jwk)) + assert.Equal(t, privateKey.ID(), jwk.KeyID) + + // Unknown kid returns 404 — no enumeration is exposed. + resp404, err := http.Get(srv.URL + net.KeyPath("unknown-kid")) + require.NoError(t, err) + defer resp404.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp404.StatusCode) + + // The bulk /keys endpoint no longer exists. + respBulk, err := http.Get(srv.URL + net.KeysPath) + require.NoError(t, err) + defer respBulk.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, respBulk.StatusCode) +} + +func TestNetServeWhoExchange(t *testing.T) { + srv, _ := setupNetServer(t) + + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", + bytes.NewReader(signedRequest(t, testServeDomain))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + env := new(gobl.Envelope) + require.NoError(t, json.NewDecoder(resp.Body).Decode(env)) + require.True(t, env.Signed()) + + p, err := headSignedPayload(env) + require.NoError(t, err) + assert.Equal(t, net.Address(testServeDomain).URI(), p.Iss, "response signed by the served domain") + assert.Equal(t, net.Address(testPeerDomain).URI(), p.Aud, "response bound to the caller") + + party, ok := env.Extract().(*org.Party) + require.True(t, ok) + assert.Equal(t, "Me", party.Name) +} + +func TestNetServeWhoUnauthenticated(t *testing.T) { + srv, _ := setupNetServer(t) + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader([]byte("not json"))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestNetServeInboxAccepts(t *testing.T) { + srv, inboxDir := setupNetServer(t) + + msg := ¬e.Message{Content: "hello inbox"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + + files, err := os.ReadDir(inboxDir) + require.NoError(t, err) + require.Len(t, files, 1) + assert.Equal(t, env.Head.UUID.String()+".json", files[0].Name()) +} + +// callHandleWho drives the handleWho factory directly so we can craft +// corrupt internal state (bad partyEnvBytes, bad signing key) that the +// HTTP-level tests cannot reach via setupNetServer. +func callHandleWho(t *testing.T, partyEnvBytes []byte, priv *dsig.PrivateKey) *httptest.ResponseRecorder { + t.Helper() + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + }})) + self := net.Address(testServeDomain).URI() + h := handleWho(discardLog(), client, partyEnvBytes, priv, self, nil, false) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, net.WhoPath, bytes.NewReader(signedRequest(t, testServeDomain))) + h(rec, req) + return rec +} + +func TestHandleWhoBadPartyBytes(t *testing.T) { + rec := callHandleWho(t, []byte("not json"), privateKey) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "could not load party") +} + +func TestHandleWhoSignFails(t *testing.T) { + // Valid party bytes but a zero-value PrivateKey so resp.Sign errors. + env, err := gobl.Envelop(&org.Party{Name: "Me"}) + require.NoError(t, err) + partyBytes, err := json.Marshal(env) + require.NoError(t, err) + rec := callHandleWho(t, partyBytes, &dsig.PrivateKey{}) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "could not sign party") +} + +func TestNetServeInboxValidationFails(t *testing.T) { + srv, _ := setupNetServer(t) + // Envelope JSON that parses but lacks required fields (digest, etc.) + // so env.Validate fails with 422. + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", + bytes.NewReader([]byte(`{"$schema":"https://gobl.org/draft-0/envelope","head":{"uuid":"01906c00-0000-7000-0000-000000000000","dig":{"alg":"sha256","val":"x"}},"doc":null}`))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + // 422 (validation), or other 4xx — anything that isn't 202. + assert.NotEqual(t, http.StatusAccepted, resp.StatusCode) +} + +// TestNetServeInboxRejectsTraversalUUID confirms that a payload trying +// to escape the inbox directory via the head.uuid field is rejected +// (env.Validate enforces UUID format; handleInbox re-parses as +// defence-in-depth) and that no file is written outside the inbox dir. +func TestNetServeInboxRejectsTraversalUUID(t *testing.T) { + srv, inboxDir := setupNetServer(t) + + // Send a fully-formed envelope but with a path-traversal payload + // in head.uuid. Since UUIDs are signed (the digest covers the + // header), the signature won't match — but Validate / the UUID + // re-parse fires before signature verification anyway, so the + // 422 is what we expect. + body := []byte(`{"$schema":"https://gobl.org/draft-0/envelope","head":{"uuid":"../../etc/passwd","dig":{"alg":"sha256","val":"x"}},"doc":{}}`) + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.NotEqual(t, http.StatusAccepted, resp.StatusCode) + + // Nothing was written inside the inbox dir... + files, err := os.ReadDir(inboxDir) + require.NoError(t, err) + assert.Empty(t, files) + + // ...nor anywhere up the path. Walk a few levels above and assert + // no "passwd"-like artefacts appeared. + parent := filepath.Dir(filepath.Dir(inboxDir)) + for _, suspect := range []string{"passwd", "passwd.json", "etc"} { + _, statErr := os.Stat(filepath.Join(parent, suspect)) + assert.True(t, os.IsNotExist(statErr), "traversal artefact at %s/%s should not exist", parent, suspect) + } +} + +func TestNetServeInboxWriteFails(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("write-permission tests do not apply when running as root") + } + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + }})) + h, err := buildDomainHandler(dc, client, discardLog()) + require.NoError(t, err) + srv := httptest.NewServer(h) + defer srv.Close() + + // Make the inbox directory read-only so os.Create fails. + require.NoError(t, os.Chmod(dc.InboxDir, 0o500)) + t.Cleanup(func() { _ = os.Chmod(dc.InboxDir, 0o755) }) + + msg := ¬e.Message{Content: "fail to write"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestNetServeInboxRejectsBadJSON(t *testing.T) { + srv, _ := setupNetServer(t) + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader([]byte("not json"))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestNetServeWhoUnauthorizedSignature(t *testing.T) { + srv, _ := setupNetServer(t) + // Build a request signed by an iss whose /keys the server can't resolve. + other := dsig.NewES256Key() + env, err := gobl.Envelop(&org.Party{Name: "Stranger"}) + require.NoError(t, err) + require.NoError(t, env.Sign(other, head.WithIssuer(net.Address("unknown.example").URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestNetServeWhoForbidden(t *testing.T) { + // Allow list rejecting the peer triggers 403. + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + // Allow-list contains a different caller. + require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["other.example"]`), 0o644)) + + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + }})) + h, err := buildDomainHandler(dc, client, discardLog()) + require.NoError(t, err) + srv := httptest.NewServer(h) + defer srv.Close() + + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(signedRequest(t, testServeDomain))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestNetServeWhoUnauthorized(t *testing.T) { + srv, _ := setupNetServer(t) + // Signed but with aud != self -> /who server rejects with 401. + env, err := gobl.Envelop(&org.Party{Name: "Peer"}) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestNetServeInboxAudMismatch(t *testing.T) { + srv, _ := setupNetServer(t) + // An envelope bound to a different recipient is rejected — prevents + // replay against an inbox the signer didn't intend. + msg := ¬e.Message{Content: "wrong aud"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestNetServeInboxAudMissing(t *testing.T) { + srv, _ := setupNetServer(t) + // An envelope signed without an aud is rejected — inboxes require + // the signature to be bound to their address so the same envelope + // cannot be replayed against multiple inboxes. + msg := ¬e.Message{Content: "no aud"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestNetServeInboxForbidden(t *testing.T) { + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["other.example"]`), 0o644)) + + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + }})) + h, err := buildDomainHandler(dc, client, discardLog()) + require.NoError(t, err) + srv := httptest.NewServer(h) + defer srv.Close() + + msg := ¬e.Message{Content: "rejected"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestNetServeInboxRejectsBadSignature(t *testing.T) { + srv, inboxDir := setupNetServer(t) + + // Signed by a key whose /keys the server cannot resolve for the iss. + other := dsig.NewES256Key() + msg := ¬e.Message{Content: "bad sig"} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) + require.NoError(t, err) + require.NoError(t, env.Sign(other, head.WithIssuer(net.Address("unknown.example").URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + + resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + files, err := os.ReadDir(inboxDir) + require.NoError(t, err) + assert.Empty(t, files) +} + +func headSignedPayload(env *gobl.Envelope) (*head.SigningPayload, error) { + return head.SignedPayload(env.Signatures[0]) +} + +func readDirNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names, nil +} diff --git a/internal/ops/net_serve_tls_test.go b/internal/ops/net_serve_tls_test.go new file mode 100644 index 0000000..c1e6d99 --- /dev/null +++ b/internal/ops/net_serve_tls_test.go @@ -0,0 +1,208 @@ +package ops + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + stdnet "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeSelfSignedCert generates an ECDSA self-signed cert valid for +// localhost and 127.0.0.1, and writes the cert + key to disk. Returns +// the file paths. +func writeSelfSignedCert(t *testing.T, dir string) (certPath, keyPath string) { + t.Helper() + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []stdnet.IP{stdnet.ParseIP("127.0.0.1"), stdnet.ParseIP("::1")}, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + require.NoError(t, err) + + certPath = filepath.Join(dir, "cert.pem") + keyPath = filepath.Join(dir, "key.pem") + + certOut, err := os.Create(certPath) + require.NoError(t, err) + defer certOut.Close() //nolint:errcheck + require.NoError(t, pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})) + + keyOut, err := os.Create(keyPath) + require.NoError(t, err) + defer keyOut.Close() //nolint:errcheck + keyDER, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + require.NoError(t, pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + + return certPath, keyPath +} + +// runServeOnListeners spins up NetServe with the given options against +// the supplied listeners and returns a stop function plus the listener +// addresses for client use. +func runServeOnListeners(t *testing.T, opts *NetServeOptions, tlsConfig *tls.Config) (httpAddr, httpsAddr string, stop func()) { + t.Helper() + + handler, err := NetServeHandler(opts) + require.NoError(t, err) + + httpHandler := handler + httpsHandler := handler + + httpLn, err := stdnet.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + var httpsLn stdnet.Listener + if tlsConfig != nil { + httpsLn, err = stdnet.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + httpsAddr = httpsLn.Addr().String() + } + httpAddr = httpLn.Addr().String() + + if opts.Out == nil { + opts.Out = io.Discard + } + + ctx, cancel := context.WithCancel(context.Background()) + doneCh := make(chan struct{}) + go func() { + _ = serveOnListeners(ctx, opts, httpHandler, httpsHandler, tlsConfig, httpLn, httpsLn) + close(doneCh) + }() + + // Tiny wait so the goroutine's Serve calls are accepting before tests fire. + time.Sleep(20 * time.Millisecond) + + return httpAddr, httpsAddr, func() { + cancel() + <-doneCh + } +} + +func TestNetServeFileTLS(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writeSelfSignedCert(t, dir) + + // Reuse the party + keys setup from net_serve_test.go. + partyFile := filepath.Join(dir, "party.json") + keysDir := filepath.Join(dir, "keys") + privFile := filepath.Join(dir, "private.jwk") + inboxDir := filepath.Join(dir, "inbox") + + signKey := privateKey + writeRawParty(t, partyFile, &org.Party{Name: "TLS Party"}) + writeKey(t, keysDir, signKey) + writePrivate(t, privFile, signKey) + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + require.NoError(t, err) + tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} + + opts := &NetServeOptions{ + PartyFile: partyFile, + KeysDir: keysDir, + PrivateKeyFile: privFile, + InboxDir: inboxDir, + CertFile: certPath, + KeyFile: keyPath, + } + + httpAddr, httpsAddr, stop := runServeOnListeners(t, opts, tlsConfig) + defer stop() + + // HTTP path: plain request works. + httpResp, err := http.Get("http://" + httpAddr + net.KeyPath(signKey.ID())) + require.NoError(t, err) + defer httpResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, httpResp.StatusCode) + + // HTTPS path: must accept the self-signed cert. + tlsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test only + }, + } + httpsResp, err := tlsClient.Get("https://" + httpsAddr + net.KeyPath(signKey.ID())) + require.NoError(t, err) + defer httpsResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, httpsResp.StatusCode) +} + +func TestListenTCPGenericError(t *testing.T) { + // Bind the same port twice — the second listen returns an error + // that is NOT EACCES, exercising the bare-error wrap branch. + first, err := listenTCP(0) + require.NoError(t, err) + defer first.Close() //nolint:errcheck + port := first.Addr().(*stdnet.TCPAddr).Port + _, err = listenTCP(port) + require.Error(t, err) + assert.Contains(t, err.Error(), "net serve: listen") +} + +func TestListenTCPEACCES(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("EACCES semantics differ on Windows") + } + if os.Geteuid() == 0 { + t.Skip("test must run as a non-root user") + } + // Port 1 is privileged on Unix-like systems; non-root cannot bind. + _, err := listenTCP(1) + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") + assert.Contains(t, err.Error(), "--http-port") + assert.Contains(t, err.Error(), "setcap") +} + +func TestNewAutocertManagerLive(t *testing.T) { + dir := t.TempDir() + m := newAutocertManager(&NetServeOptions{ACMELive: true, CertDir: dir, ACMEEmail: "ops@example.com"}, []string{"example.com"}) + require.NotNil(t, m) + assert.Equal(t, "ops@example.com", m.Email) + assert.Nil(t, m.Client, "live mode uses autocert's default LE production directory") + // Verify HostPolicy accepts the configured domain and rejects others. + assert.NoError(t, m.HostPolicy(context.Background(), "example.com")) + assert.Error(t, m.HostPolicy(context.Background(), "other.example.com")) + // DirCache stores under the supplied path; round-trip via Put/Get to confirm. + require.NoError(t, m.Cache.Put(context.Background(), "probe", []byte("ok"))) + got, err := m.Cache.Get(context.Background(), "probe") + require.NoError(t, err) + assert.Equal(t, "ok", string(got)) +} + +func TestNewAutocertManagerTest(t *testing.T) { + m := newAutocertManager(&NetServeOptions{ACMETest: true}, []string{"example.com"}) + require.NotNil(t, m.Client, "test mode must override the Client to point at LE staging") + assert.True(t, strings.HasPrefix(m.Client.DirectoryURL, "https://acme-staging-v02.api.letsencrypt.org")) +} diff --git a/internal/ops/net_who.go b/internal/ops/net_who.go new file mode 100644 index 0000000..252f25f --- /dev/null +++ b/internal/ops/net_who.go @@ -0,0 +1,162 @@ +package ops + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" +) + +// schemeRewriteFetcher rewrites well-known https:///... URLs to +// the given http:// base so --insecure mode reuses net.Client logic +// (which always builds https URLs) over plain HTTP. +type schemeRewriteFetcher struct { + base string // e.g. http://acme.example + inner net.Fetcher +} + +func (s *schemeRewriteFetcher) Fetch(ctx context.Context, raw string) ([]byte, error) { + u, err := url.Parse(raw) + if err != nil { + return s.inner.Fetch(ctx, raw) + } + bu, err := url.Parse(s.base) + if err == nil { + u.Scheme = bu.Scheme + u.Host = bu.Host + raw = u.String() + } + return s.inner.Fetch(ctx, raw) +} + +const netWhoTimeout = 10 * time.Second + +// NetWhoOptions configures NetWho. +type NetWhoOptions struct { + Target net.Address // domain being queried + From net.Address // caller's GOBL Net address (signs the request) + FromKey *dsig.PrivateKey // caller's signing key + FromParty *org.Party // caller's party, sent as the request document + Insecure bool // query over http:// and permit host:port + Fetcher net.Fetcher // optional (for /keys); defaults to net.NewHTTPFetcher() + Client *http.Client // optional (for POST /who); defaults to 10s timeout +} + +// NetWho performs an authenticated GOBL Net party exchange: it POSTs a +// signed request envelope (the caller's party, iss=gobl:from, +// aud=gobl:target) to the target's /who endpoint, verifies the response +// is signed by the target (iss=gobl:target) and bound to the caller +// (aud=gobl:from), and returns the verified envelope. Callers that +// only need the party can read it via `env.Extract().(*org.Party)`; +// returning the whole envelope preserves the signature and signed +// `iss`/`aud`/`ts` so the artifact remains independently verifiable. +func NetWho(ctx context.Context, opts *NetWhoOptions) (*gobl.Envelope, error) { + if opts.Target == "" { + return nil, gobl.ErrInput.WithReason("target address is required") + } + if opts.From == "" || opts.FromKey == nil || opts.FromParty == nil { + return nil, gobl.ErrInput.WithReason("a --from identity (key + party) is required to authenticate the request") + } + + scheme := "https" + if opts.Insecure { + scheme = "http" + } + base := scheme + "://" + string(opts.Target) + + // Build and sign the request envelope: iss=from, aud=target. + reqEnv, err := gobl.Envelop(opts.FromParty) + if err != nil { + return nil, fmt.Errorf("net who: build request: %w", err) + } + if err := reqEnv.Sign(opts.FromKey, head.WithIssuer(opts.From.URI()), head.WithAudience(opts.Target.URI())); err != nil { + return nil, fmt.Errorf("net who: sign request: %w", err) + } + reqBody, err := json.Marshal(reqEnv) + if err != nil { + return nil, fmt.Errorf("net who: encode request: %w", err) + } + + client := opts.Client + if client == nil { + client = &http.Client{Timeout: netWhoTimeout} + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+net.WhoPath, bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("net who: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("net who: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, netInboxMaxBody)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("net who: %w: HTTP %d: %s", net.ErrFetchFailed, resp.StatusCode, bytes.TrimSpace(respBody)) + } + + respEnv := new(gobl.Envelope) + if err := json.Unmarshal(respBody, respEnv); err != nil { + return nil, fmt.Errorf("net who: invalid /who response: %w", err) + } + if !respEnv.Signed() { + return nil, fmt.Errorf("net who: /who response is not signed") + } + + // Verify the response is signed by the target, bound to us. The + // fetcher is wrapped so /key/ URLs honour --insecure by being + // rewritten to the http:// base. + fetcher := opts.Fetcher + if fetcher == nil { + fetcher = net.NewHTTPFetcher() + } + if opts.Insecure { + fetcher = &schemeRewriteFetcher{base: base, inner: fetcher} + } + verifyClient := net.NewClient(net.WithFetcher(fetcher)) + + wantIss := opts.Target.URI() + wantAud := opts.From.URI() + verified := false + for _, sig := range respEnv.Signatures { + p, perr := head.SignedPayload(sig) + if perr != nil || p.Iss != wantIss { + continue + } + pubKey, kerr := verifyClient.FetchKey(ctx, opts.Target, sig.KeyID()) + if kerr != nil { + continue + } + // VerifySignature enforces the key's validity window via + // head.Header.Verify, so no extra Allows call is needed here. + if respEnv.VerifySignature(sig, pubKey) != nil { + continue + } + if p.Aud != "" && p.Aud != wantAud { + return nil, fmt.Errorf("net who: response audience mismatch (got %q, want %q)", p.Aud, wantAud) + } + verified = true + break + } + if !verified { + return nil, fmt.Errorf("net who: response not signed by %s with a published key", wantIss) + } + + // Sanity check: the protocol defines /who responses to wrap an + // org.Party. Other document types indicate a misbehaving peer. + if _, ok := respEnv.Extract().(*org.Party); !ok { + return nil, fmt.Errorf("net who: /who response document is not an org.Party") + } + return respEnv, nil +} diff --git a/internal/ops/net_who_test.go b/internal/ops/net_who_test.go new file mode 100644 index 0000000..0710fdb --- /dev/null +++ b/internal/ops/net_who_test.go @@ -0,0 +1,271 @@ +package ops + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hostRewrite routes every request to base, regardless of the request's +// host, so a test can use a real domain identity while talking to an +// httptest server. +type hostRewrite struct{ base string } + +func (h hostRewrite) RoundTrip(req *http.Request) (*http.Response, error) { + u, _ := url.Parse(h.base) + req.URL.Scheme = u.Scheme + req.URL.Host = u.Host + return http.DefaultTransport.RoundTrip(req) +} + +func TestNetWho(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "acme.example") + dc := domainConfigFor(configDir, "acme.example") + + // The served domain's client resolves the caller's per-key endpoint + // to verify the incoming request. + serverClient := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + }})) + handler, err := buildDomainHandler(dc, serverClient, discardLog()) + require.NoError(t, err) + srv := httptest.NewServer(handler) + defer srv.Close() + + // Read the freshly-generated private key for acme.example so the + // test fetcher can serve its public counterpart at /keys/. + privBytes, err := os.ReadFile(dc.PrivateKeyFile) + require.NoError(t, err) + targetKey := new(dsig.PrivateKey) + require.NoError(t, json.Unmarshal(privBytes, targetKey)) + + env, err := NetWho(context.Background(), &NetWhoOptions{ + Target: "acme.example", + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + FromParty: &org.Party{Name: "Peer"}, + Insecure: true, + // POSTs to http://acme.example/... but routed to the test server. + Client: &http.Client{Transport: hostRewrite{base: srv.URL}}, + // Resolves the target's per-key endpoint (the served domain's + // published key). + Fetcher: &mapFetcher{data: map[string][]byte{ + "http://acme.example" + net.KeyPath(targetKey.ID()): jwkBytes(t, targetKey), + }}, + }) + require.NoError(t, err) + require.NotNil(t, env) + require.True(t, env.Signed(), "returned envelope retains the target's signature") + + // The signed payload binds the response to the caller. + p, err := head.SignedPayload(env.Signatures[0]) + require.NoError(t, err) + assert.Equal(t, net.Address("acme.example").URI(), p.Iss) + assert.Equal(t, net.Address(testPeerDomain).URI(), p.Aud) + + party, ok := env.Extract().(*org.Party) + require.True(t, ok) + assert.Equal(t, "acme.example", party.Name) + require.Len(t, party.Endpoints, 1) + assert.Equal(t, "gobl:acme.example", party.Endpoints[0].URI.String()) +} + +func TestNetWhoMissingFrom(t *testing.T) { + _, err := NetWho(context.Background(), &NetWhoOptions{Target: "acme.example"}) + require.Error(t, err) +} + +func TestNetWhoMissingTarget(t *testing.T) { + _, err := NetWho(context.Background(), &NetWhoOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "target address is required") +} + +// staticHandler returns a fixed status+body — useful for error-path tests. +func staticHandler(status int, body string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + }) +} + +func newWhoOpts(t *testing.T, target string, srvURL string) *NetWhoOptions { + t.Helper() + return &NetWhoOptions{ + Target: net.Address(target), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + FromParty: &org.Party{Name: "Peer"}, + Insecure: true, + Client: &http.Client{Transport: hostRewrite{base: srvURL}}, + Fetcher: &mapFetcher{data: map[string][]byte{}}, + } +} + +func TestNetWhoNon200(t *testing.T) { + srv := httptest.NewServer(staticHandler(http.StatusForbidden, "no")) + defer srv.Close() + _, err := NetWho(context.Background(), newWhoOpts(t, "acme.example", srv.URL)) + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 403") +} + +func TestNetWhoInvalidResponseJSON(t *testing.T) { + srv := httptest.NewServer(staticHandler(http.StatusOK, "not json")) + defer srv.Close() + _, err := NetWho(context.Background(), newWhoOpts(t, "acme.example", srv.URL)) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid /who response") +} + +func TestNetWhoUnsignedResponse(t *testing.T) { + srv := httptest.NewServer(staticHandler(http.StatusOK, `{"doc":{}}`)) + defer srv.Close() + _, err := NetWho(context.Background(), newWhoOpts(t, "acme.example", srv.URL)) + require.Error(t, err) + assert.Contains(t, err.Error(), "not signed") +} + +// TestNetWhoResponseWrongIssuer: response is signed but by the peer +// (i.e., not by the target). Verification loop finds no matching iss. +func TestNetWhoResponseWrongIssuer(t *testing.T) { + // Build a signed envelope where iss/aud are reversed from what NetWho + // expects to find on a /who response. + env, err := gobl.Envelop(&org.Party{Name: "Wrong"}) + require.NoError(t, err) + // iss = peer (caller) — but NetWho expects iss=target. + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + _, err = NetWho(context.Background(), newWhoOpts(t, testServeDomain, srv.URL)) + require.Error(t, err) + assert.Contains(t, err.Error(), "not signed by") +} + +// TestNetWhoTransportError exercises the default http.Client + default +// Fetcher branches plus the client.Do error path. Uses port 1 which is +// closed on a non-root host. +func TestNetWhoTransportError(t *testing.T) { + _, err := NetWho(context.Background(), &NetWhoOptions{ + Target: net.Address("127.0.0.1:1"), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + FromParty: &org.Party{Name: "Peer"}, + Insecure: true, + // Client + Fetcher omitted to exercise the default branches. + }) + require.Error(t, err) +} + +// TestNetWhoResponseAudMismatch: response is correctly signed by the +// target but the aud names someone else. +func TestNetWhoResponseAudMismatch(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, testServeDomain) + dc := domainConfigFor(configDir, testServeDomain) + privBytes, err := os.ReadFile(dc.PrivateKeyFile) + require.NoError(t, err) + targetKey := new(dsig.PrivateKey) + require.NoError(t, json.Unmarshal(privBytes, targetKey)) + + env, err := gobl.Envelop(&org.Party{Name: "X"}) + require.NoError(t, err) + require.NoError(t, env.Sign(targetKey, head.WithIssuer(net.Address(testServeDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) + body, err := json.Marshal(env) + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + opts := newWhoOpts(t, testServeDomain, srv.URL) + opts.Fetcher = &mapFetcher{data: map[string][]byte{ + "http://" + testServeDomain + net.KeyPath(targetKey.ID()): jwkBytes(t, targetKey), + }} + _, err = NetWho(context.Background(), opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "audience mismatch") +} + +// TestNetWhoResponseDocNotParty: response is correctly signed by the +// target, but the document is not an org.Party. +func TestNetWhoResponseDocNotParty(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, testServeDomain) + dc := domainConfigFor(configDir, testServeDomain) + privBytes, err := os.ReadFile(dc.PrivateKeyFile) + require.NoError(t, err) + targetKey := new(dsig.PrivateKey) + require.NoError(t, json.Unmarshal(privBytes, targetKey)) + + // Wrap a non-party document. + wrap, err := gobl.Envelop(&org.Endpoint{URI: "gobl:x.example"}) + require.NoError(t, err) + require.NoError(t, wrap.Sign(targetKey, head.WithIssuer(net.Address(testServeDomain).URI()), head.WithAudience(net.Address(testPeerDomain).URI()))) + body, err := json.Marshal(wrap) + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + + opts := newWhoOpts(t, testServeDomain, srv.URL) + opts.Fetcher = &mapFetcher{data: map[string][]byte{ + "http://" + testServeDomain + net.KeyPath(targetKey.ID()): jwkBytes(t, targetKey), + }} + _, err = NetWho(context.Background(), opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "not an org.Party") +} + +// TestSchemeRewriteFetcher confirms scheme/host rewriting plus error +// passthrough for malformed input. +func TestSchemeRewriteFetcher(t *testing.T) { + t.Run("rewrites scheme + host", func(t *testing.T) { + var seen string + inner := stubFetcher(func(_ context.Context, u string) ([]byte, error) { + seen = u + return []byte("ok"), nil + }) + f := &schemeRewriteFetcher{base: "http://localhost:1234", inner: inner} + body, err := f.Fetch(context.Background(), "https://acme.example/.well-known/gobl/keys/abc") + require.NoError(t, err) + assert.Equal(t, "ok", string(body)) + assert.Equal(t, "http://localhost:1234/.well-known/gobl/keys/abc", seen) + }) + + t.Run("invalid raw URL falls through unchanged", func(t *testing.T) { + var seen string + inner := stubFetcher(func(_ context.Context, u string) ([]byte, error) { + seen = u + return []byte("x"), nil + }) + f := &schemeRewriteFetcher{base: "http://localhost:1234", inner: inner} + _, err := f.Fetch(context.Background(), "://broken") + require.NoError(t, err) + assert.Equal(t, "://broken", seen) + }) +} + +type stubFetcher func(context.Context, string) ([]byte, error) + +func (s stubFetcher) Fetch(ctx context.Context, u string) ([]byte, error) { return s(ctx, u) } diff --git a/internal/ops/sign_test.go b/internal/ops/sign_test.go index 9b179ac..940fd2a 100644 --- a/internal/ops/sign_test.go +++ b/internal/ops/sign_test.go @@ -2,13 +2,85 @@ package ops import ( "context" + "encoding/json" "regexp" + "strings" "testing" + "time" + "github.com/invopop/gobl" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gitlab.com/flimzy/testy" ) +const noteMessageJSON = `{"$schema":"https://gobl.org/draft-0/note/message","content":"hi"}` + +// signIss signs the note message with the given iss/aud and round-trips +// the envelope through JSON (the signed payload is read after parse). +func signIss(t *testing.T, iss, aud cbc.URI) *gobl.Envelope { + t.Helper() + env, err := Sign(context.Background(), &SignOptions{ + ParseOptions: &ParseOptions{Input: strings.NewReader(noteMessageJSON)}, + PrivateKey: privateKey, + Issuer: iss, + Audience: aud, + }) + require.NoError(t, err) + data, err := json.Marshal(env) + require.NoError(t, err) + out := new(gobl.Envelope) + require.NoError(t, json.Unmarshal(data, out)) + return out +} + +func TestSignWithIss(t *testing.T) { + env := signIss(t, "gobl:billing.invopop.com", "gobl:acme.example") + require.True(t, env.Signed()) + p, err := head.SignedPayload(env.Signatures[0]) + require.NoError(t, err) + assert.Equal(t, cbc.URI("gobl:billing.invopop.com"), p.Iss) + assert.Equal(t, cbc.URI("gobl:acme.example"), p.Aud) +} + +func TestSignWithoutIss(t *testing.T) { + env := signIss(t, "", "") + require.True(t, env.Signed()) + p, err := head.SignedPayload(env.Signatures[0]) + require.NoError(t, err) + assert.Empty(t, p.Iss) +} + +func TestSignInvalidKey(t *testing.T) { + // Sign should propagate env.Sign's error when the private key is + // zero-valued (no underlying JWK). + _, err := Sign(context.Background(), &SignOptions{ + ParseOptions: &ParseOptions{Input: strings.NewReader(noteMessageJSON)}, + PrivateKey: &dsig.PrivateKey{}, + }) + require.Error(t, err) +} + +// TestSignSetsTimestamp asserts that signing automatically stamps the +// signed payload with a JWT-standard `iat` (Unix seconds) close to +// "now". +func TestSignSetsTimestamp(t *testing.T) { + before := time.Now().UTC().Unix() + env := signIss(t, "gobl:a.example", "gobl:b.example") + after := time.Now().UTC().Unix() + + p, err := head.SignedPayload(env.Signatures[0]) + require.NoError(t, err) + require.NotZero(t, p.IssuedAt, "signing must stamp iat automatically") + assert.True(t, + p.IssuedAt >= before && p.IssuedAt <= after, + "iat %d should fall within [%d, %d]", p.IssuedAt, before, after, + ) +} + func TestSign(t *testing.T) { type tt struct { opts *SignOptions diff --git a/internal/ops/verify.go b/internal/ops/verify.go index 107a8d8..7514589 100644 --- a/internal/ops/verify.go +++ b/internal/ops/verify.go @@ -8,6 +8,7 @@ import ( "github.com/invopop/gobl" "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/net" ) // Verify reads a GOBL document from in, and returns an error if there are any @@ -35,3 +36,27 @@ func Verify(ctx context.Context, in io.Reader, key *dsig.PublicKey) error { } return nil } + +// VerifyRemote reads a GOBL envelope and verifies it using remote +// JWKS discovery via the GOBL Net client. +func VerifyRemote(ctx context.Context, in io.Reader, client *net.Client, addr net.Address) error { + body, err := io.ReadAll(cancelableReader(ctx, in)) + if err != nil { + return gobl.ErrInput.WithCause(err) + } + env := new(gobl.Envelope) + if err := jsonyaml.Unmarshal(body, env); err != nil { + return gobl.ErrInput.WithCause(err) + } + if err := env.Validate(); err != nil { + return gobl.ErrValidation.WithCause(err) + } + issuer, err := client.VerifyEnvelope(ctx, env, "") + if err != nil { + return gobl.ErrValidation.WithCause(err) + } + if addr != "" && issuer != addr { + return gobl.ErrValidation.WithReason("envelope signed by %s, expected %s", issuer, addr) + } + return nil +} diff --git a/internal/ops/verify_test.go b/internal/ops/verify_test.go index 6a8e1f5..d3e870c 100644 --- a/internal/ops/verify_test.go +++ b/internal/ops/verify_test.go @@ -8,7 +8,9 @@ import ( "testing" "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/net" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gitlab.com/flimzy/testy" ) @@ -83,3 +85,86 @@ func TestVerify(t *testing.T) { } }) } + +func TestVerifyInvalidYAML(t *testing.T) { + err := Verify(context.Background(), bytes.NewReader([]byte("\t\t\t\n@@:")), publicKey) + require.Error(t, err) +} + +func TestVerifyValidationFail(t *testing.T) { + // Envelope missing digest -> validation fails (not a signature error). + err := Verify(context.Background(), bytes.NewReader([]byte(`{}`)), publicKey) + require.Error(t, err) +} + +func TestVerifyReadError(t *testing.T) { + err := Verify(context.Background(), errReader{}, publicKey) + require.Error(t, err) +} + +func TestVerifyRemote(t *testing.T) { + addr := net.Address("billing.invopop.com") + // Sign with iss set so VerifyRemote can resolve the issuer. + env, err := Sign(context.Background(), &SignOptions{ + ParseOptions: &ParseOptions{ + Input: testFileReader(t, "testdata/invoice-es-es.env.yaml"), + SetFile: map[string]string{ + "doc": "testdata/invoice-es-es.yaml", + }, + }, + PrivateKey: privateKey, + Issuer: addr.URI(), + }) + require.NoError(t, err) + body, err := json.Marshal(env) + require.NoError(t, err) + + // Build a fetcher that serves the matching public key for whichever + // kid is requested. + pkBytes, err := json.Marshal(privateKey.Public()) + require.NoError(t, err) + c := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + addr.KeyURL(privateKey.ID()): pkBytes, + }})) + + t.Run("success no address pin", func(t *testing.T) { + assert.NoError(t, VerifyRemote(context.Background(), bytes.NewReader(body), c, "")) + }) + + t.Run("address pin matches", func(t *testing.T) { + assert.NoError(t, VerifyRemote(context.Background(), bytes.NewReader(body), c, addr)) + }) + + t.Run("address pin mismatch", func(t *testing.T) { + err := VerifyRemote(context.Background(), bytes.NewReader(body), c, net.Address("other.example")) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected other.example") + }) + + t.Run("invalid JSON", func(t *testing.T) { + err := VerifyRemote(context.Background(), bytes.NewReader([]byte("\t\t\t\n@@:")), c, "") + require.Error(t, err) + }) + + t.Run("read error", func(t *testing.T) { + err := VerifyRemote(context.Background(), errReader{}, c, "") + require.Error(t, err) + }) + + t.Run("validation fails", func(t *testing.T) { + err := VerifyRemote(context.Background(), bytes.NewReader([]byte(`{}`)), c, "") + require.Error(t, err) + }) + + t.Run("verify fails", func(t *testing.T) { + // Tamper with the envelope so VerifyEnvelope rejects it. + var env map[string]any + require.NoError(t, json.Unmarshal(body, &env)) + // Remove signatures so Signed() returns false. + delete(env, "sigs") + tampered, err := json.Marshal(env) + require.NoError(t, err) + err = VerifyRemote(context.Background(), bytes.NewReader(tampered), c, "") + require.Error(t, err) + }) +} diff --git a/wasm/package-lock.json b/wasm/package-lock.json index f27b3d8..a6cb512 100644 --- a/wasm/package-lock.json +++ b/wasm/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@cypress-audit/lighthouse": "^1.4.2", "browser-sync": "^3.0.3", - "cypress": "^14.1.0", + "cypress": "^15.17.0", "eslint": "^9.21.0", "eslint-config-prettier": "^10.0.2", "eslint-plugin-cypress": "^4.1.0", @@ -24,16 +24,6 @@ "prettier": "^3.5.2" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@cypress-audit/lighthouse": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@cypress-audit/lighthouse/-/lighthouse-1.4.2.tgz", @@ -45,10 +35,11 @@ } }, "node_modules/@cypress/request": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", - "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-4.0.1.tgz", + "integrity": "sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -63,14 +54,13 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.14.1", + "qs": "^6.15.2", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" + "tunnel-agent": "^0.6.0" }, "engines": { - "node": ">= 6" + "node": ">= 14.17.0" } }, "node_modules/@cypress/xvfb": { @@ -668,6 +658,13 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", "dev": true }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -725,7 +722,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -738,7 +734,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -756,22 +751,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -799,15 +780,16 @@ } }, "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, + "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "environment": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -913,15 +895,6 @@ "node": ">=4" } }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -984,13 +957,14 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -1165,9 +1139,9 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/basic-ftp": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz", - "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "dev": true, "license": "MIT", "engines": { @@ -1358,10 +1332,11 @@ } }, "node_modules/cachedir": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", - "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1461,15 +1436,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -1578,32 +1544,28 @@ "node": ">=8" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, + "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-table3": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.2.tgz", - "integrity": "sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.0" }, @@ -1611,20 +1573,38 @@ "node": "10.* || >= 12.*" }, "optionalDependencies": { - "@colors/colors": "1.5.0" + "colors": "1.4.0" } }, "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, + "license": "MIT", "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1685,10 +1665,22 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/colorette": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", - "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==", - "dev": true + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } }, "node_modules/combined-stream": { "version": "1.0.8", @@ -1849,43 +1841,38 @@ "license": "Apache-2.0" }, "node_modules/cypress": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.1.0.tgz", - "integrity": "sha512-pPPj8Uu9NwjaaiXAEcjYZZmgsq6v9Zs1Nw6a+zRF+ANgYSNhH4S32SjFRsvMcuOHR/8dp4GBJhBPqIPSs+TxaA==", + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.17.0.tgz", + "integrity": "sha512-WL5Gcqi1GaDWozBwXmkSAtOPafTsVSRS764iX6xvuz3DPzvBAxbkRyEi4BreVdVWxLDpiYRgZCyJUafBw44njw==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@cypress/request": "^3.0.7", + "@cypress/request": "^4.0.0", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", + "@types/tmp": "^0.2.3", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", "buffer": "^5.7.1", - "cachedir": "^2.3.0", + "cachedir": "^2.4.0", "chalk": "^4.1.0", - "check-more-types": "^2.24.0", "ci-info": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.1", + "cli-table3": "0.6.1", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", "debug": "^4.3.4", - "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", - "extract-zip": "2.0.1", - "figures": "^3.2.0", "fs-extra": "^9.1.0", - "getos": "^3.2.1", + "hasha": "5.2.2", "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", - "listr2": "^3.8.3", - "lodash": "^4.17.21", + "listr2": "^9.0.5", + "lodash": "^4.17.23", "log-symbols": "^4.0.0", "minimist": "^1.2.8", "ospath": "^1.2.2", @@ -1893,18 +1880,19 @@ "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", - "semver": "^7.5.3", "supports-color": "^8.1.1", - "tmp": "~0.2.3", + "systeminformation": "^5.31.1", + "tmp": "~0.2.4", "tree-kill": "1.2.2", + "tslib": "1.14.1", "untildify": "^4.0.0", - "yauzl": "^2.10.0" + "yauzl": "^3.3.1" }, "bin": { "cypress": "bin/cypress" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.1.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/cypress/node_modules/commander": { @@ -1982,6 +1970,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/cypress/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/cypress/node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -1991,6 +1986,19 @@ "node": ">= 10.0.0" } }, + "node_modules/cypress/node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -2375,6 +2383,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2435,15 +2456,6 @@ "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", "dev": true }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -2921,21 +2933,6 @@ "pend": "~1.2.0" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3049,16 +3046,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3222,21 +3219,6 @@ "dev": true, "license": "MIT" }, - "node_modules/getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "dependencies": { - "async": "^3.2.0" - } - }, - "node_modules/getos/node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", - "dev": true - }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -3340,10 +3322,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3514,7 +3513,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -3528,7 +3526,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3546,7 +3543,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/human-signals": { @@ -3643,15 +3639,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3686,15 +3673,11 @@ "license": "BSD-3-Clause" }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -3914,9 +3897,9 @@ "license": "MIT" }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -3955,13 +3938,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4075,15 +4051,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=", - "dev": true, - "engines": { - "node": "> 0.8" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4186,30 +4153,84 @@ } }, "node_modules/listr2": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", - "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", - "dev": true, - "dependencies": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.1", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/locate-path": { @@ -4270,73 +4291,112 @@ } }, "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/log-update/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/lookup-closest-locale": { @@ -4459,9 +4519,9 @@ } }, "node_modules/markdownlint-cli/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -5173,6 +5233,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5451,21 +5524,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -5955,9 +6013,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -6053,23 +6111,57 @@ } }, "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, + "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/rfdc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", - "dev": true + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" }, "node_modules/robots-parser": { "version": "3.0.1", @@ -6148,24 +6240,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/secure-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", - "integrity": "sha1-8aAymzCLIh+uN7mXTz1XjQypmeM=" - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha1-8aAymzCLIh+uN7mXTz1XjQypmeM=" + }, "node_modules/send": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", @@ -6498,17 +6577,49 @@ "dev": true }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/smart-buffer": { @@ -6694,13 +6805,13 @@ "dev": true }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -6784,13 +6895,6 @@ "node": ">=8.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -6964,6 +7068,33 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/systeminformation": { + "version": "5.31.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", + "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tar-fs": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", @@ -7078,30 +7209,31 @@ } }, "node_modules/tldts": { - "version": "6.1.79", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.79.tgz", - "integrity": "sha512-wjlYwK8lC/WcywLWf3A7qbK07SexezXjTRVwuPWXHvcjD7MnpPS2RXY5rLO3g12a8CNc7Y7jQRQsV7XyuBZjig==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.79" + "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.79", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.79.tgz", - "integrity": "sha512-HM+Ud/2oQuHt4I43Nvjc213Zji/z25NSH5OkJskJwHXNtYh9DTRlHMDFhms9dFMP7qyve/yVaXFIxmcJ7TdOjw==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", - "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.14" } @@ -7129,9 +7261,9 @@ } }, "node_modules/tough-cookie": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.1.tgz", - "integrity": "sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7198,15 +7330,13 @@ } }, "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/typedarray-to-buffer": { @@ -7338,16 +7468,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7515,9 +7635,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -7618,13 +7738,6 @@ } }, "dependencies": { - "@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true - }, "@cypress-audit/lighthouse": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@cypress-audit/lighthouse/-/lighthouse-1.4.2.tgz", @@ -7635,9 +7748,9 @@ } }, "@cypress/request": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", - "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-4.0.1.tgz", + "integrity": "sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==", "dev": true, "requires": { "aws-sign2": "~0.7.0", @@ -7653,11 +7766,10 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.14.1", + "qs": "^6.15.2", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" + "tunnel-agent": "^0.6.0" } }, "@cypress/xvfb": { @@ -8096,6 +8208,12 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", "dev": true }, + "@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true + }, "@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -8139,7 +8257,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "requires": { "debug": "4" }, @@ -8148,7 +8265,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "requires": { "ms": "^2.1.3" } @@ -8156,21 +8272,10 @@ "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" } } }, - "aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, "ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -8190,12 +8295,12 @@ "dev": true }, "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "requires": { - "type-fest": "^0.21.3" + "environment": "^1.0.0" } }, "ansi-regex": { @@ -8258,12 +8363,6 @@ "tslib": "^2.0.1" } }, - "astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true - }, "async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -8308,12 +8407,13 @@ "dev": true }, "axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "requires": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" }, "dependencies": { @@ -8423,9 +8523,9 @@ } }, "basic-ftp": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz", - "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "dev": true }, "batch": { @@ -8571,9 +8671,9 @@ "dev": true }, "cachedir": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", - "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", "dev": true }, "call-bind-apply-helpers": { @@ -8633,12 +8733,6 @@ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", "dev": true }, - "check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=", - "dev": true - }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -8707,39 +8801,45 @@ "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==", "dev": true }, - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true - }, "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "requires": { - "restore-cursor": "^3.1.0" + "restore-cursor": "^5.0.0" } }, "cli-table3": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.2.tgz", - "integrity": "sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", "dev": true, "requires": { - "@colors/colors": "1.5.0", + "colors": "1.4.0", "string-width": "^4.2.0" } }, "cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "requires": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "dependencies": { + "string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + } + } } }, "cliui": { @@ -8784,11 +8884,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "colorette": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", - "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "optional": true + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -8907,41 +9014,36 @@ "dev": true }, "cypress": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.1.0.tgz", - "integrity": "sha512-pPPj8Uu9NwjaaiXAEcjYZZmgsq6v9Zs1Nw6a+zRF+ANgYSNhH4S32SjFRsvMcuOHR/8dp4GBJhBPqIPSs+TxaA==", + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.17.0.tgz", + "integrity": "sha512-WL5Gcqi1GaDWozBwXmkSAtOPafTsVSRS764iX6xvuz3DPzvBAxbkRyEi4BreVdVWxLDpiYRgZCyJUafBw44njw==", "dev": true, "requires": { - "@cypress/request": "^3.0.7", + "@cypress/request": "^4.0.0", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", + "@types/tmp": "^0.2.3", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", "buffer": "^5.7.1", - "cachedir": "^2.3.0", + "cachedir": "^2.4.0", "chalk": "^4.1.0", - "check-more-types": "^2.24.0", "ci-info": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.1", + "cli-table3": "0.6.1", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", "debug": "^4.3.4", - "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", - "extract-zip": "2.0.1", - "figures": "^3.2.0", "fs-extra": "^9.1.0", - "getos": "^3.2.1", + "hasha": "5.2.2", "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", - "listr2": "^3.8.3", - "lodash": "^4.17.21", + "listr2": "^9.0.5", + "lodash": "^4.17.23", "log-symbols": "^4.0.0", "minimist": "^1.2.8", "ospath": "^1.2.2", @@ -8949,12 +9051,13 @@ "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", - "semver": "^7.5.3", "supports-color": "^8.1.1", - "tmp": "~0.2.3", + "systeminformation": "^5.31.1", + "tmp": ">=0.2.6", "tree-kill": "1.2.2", + "tslib": "1.14.1", "untildify": "^4.0.0", - "yauzl": "^2.10.0" + "yauzl": "^3.3.1" }, "dependencies": { "commander": { @@ -9009,11 +9112,26 @@ "has-flag": "^4.0.0" } }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", "dev": true + }, + "yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "requires": { + "pend": "~1.2.0" + } } } }, @@ -9225,7 +9343,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "^8.18.3" + "ws": "^8.21.0" }, "dependencies": { "debug": { @@ -9254,7 +9372,7 @@ "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "^8.18.3", + "ws": "^8.21.0", "xmlhttprequest-ssl": "~2.1.1" }, "dependencies": { @@ -9296,6 +9414,12 @@ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true }, + "environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true + }, "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -9337,12 +9461,6 @@ "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", "dev": true }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, "escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -9658,15 +9776,6 @@ "pend": "~1.2.0" } }, - "figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "requires": { - "escape-string-regexp": "^1.0.5" - } - }, "file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -9738,15 +9847,15 @@ "dev": true }, "form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" } }, "fresh": { @@ -9853,23 +9962,6 @@ } } }, - "getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "requires": { - "async": "^3.2.0" - }, - "dependencies": { - "async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", - "dev": true - } - } - }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -9932,10 +10024,20 @@ "has-symbols": "^1.0.3" } }, + "hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + } + }, "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "requires": { "function-bind": "^1.1.2" } @@ -10058,7 +10160,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "requires": { "agent-base": "6", "debug": "4" @@ -10068,7 +10169,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "requires": { "ms": "^2.1.3" } @@ -10076,8 +10176,7 @@ "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" } } }, @@ -10136,12 +10235,6 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true - }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -10170,14 +10263,10 @@ "dev": true }, "ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dev": true, - "requires": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - } + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true }, "is-alphabetical": { "version": "2.0.1", @@ -10317,9 +10406,9 @@ "dev": true }, "joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", "requires": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -10349,12 +10438,6 @@ "argparse": "^2.0.1" } }, - "jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true - }, "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -10444,12 +10527,6 @@ "json-buffer": "3.0.1" } }, - "lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=", - "dev": true - }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10490,7 +10567,7 @@ "semver": "^5.3.0", "speedline-core": "^1.4.3", "third-party-web": "^0.23.3", - "ws": "^8.18.3", + "ws": "^8.21.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, @@ -10535,19 +10612,59 @@ } }, "listr2": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", - "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", - "dev": true, - "requires": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.1", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "requires": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, + "eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "requires": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + } + } } }, "locate-path": { @@ -10593,52 +10710,69 @@ } }, "log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "requires": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true }, + "is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.3.1" + } + }, "slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "requires": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" } }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "requires": { - "ansi-regex": "^5.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" } }, "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" } } } @@ -10742,9 +10876,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -11144,6 +11278,12 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true + }, "minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -11332,15 +11472,6 @@ "p-limit": "^3.0.2" } }, - "p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, "pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -11652,7 +11783,7 @@ "cross-fetch": "4.0.0", "debug": "4.3.4", "devtools-protocol": "0.0.1147663", - "ws": "^8.18.3" + "ws": "^8.21.0" }, "dependencies": { "debug": { @@ -11679,9 +11810,9 @@ } }, "qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "requires": { "side-channel": "^1.1.0" } @@ -11750,19 +11881,36 @@ } }, "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "dependencies": { + "onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "requires": { + "mimic-function": "^5.0.0" + } + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + } } }, "rfdc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true }, "robots-parser": { @@ -11821,12 +11969,6 @@ "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", "integrity": "sha1-8aAymzCLIh+uN7mXTz1XjQypmeM=" }, - "semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true - }, "send": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", @@ -12078,14 +12220,30 @@ "dev": true }, "slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "requires": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.3.1" + } + } } }, "smart-buffer": { @@ -12139,7 +12297,7 @@ "dev": true, "requires": { "debug": "~4.3.4", - "ws": "^8.18.3" + "ws": "^8.21.0" }, "dependencies": { "debug": { @@ -12216,12 +12374,12 @@ } }, "socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "requires": { - "ip-address": "^9.0.5", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, @@ -12277,12 +12435,6 @@ "jpeg-js": "^0.4.1" } }, - "sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true - }, "sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -12402,6 +12554,12 @@ "tslib": "^2.6.2" } }, + "systeminformation": { + "version": "5.31.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", + "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "dev": true + }, "tar-fs": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", @@ -12488,24 +12646,24 @@ } }, "tldts": { - "version": "6.1.79", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.79.tgz", - "integrity": "sha512-wjlYwK8lC/WcywLWf3A7qbK07SexezXjTRVwuPWXHvcjD7MnpPS2RXY5rLO3g12a8CNc7Y7jQRQsV7XyuBZjig==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "requires": { - "tldts-core": "^6.1.79" + "tldts-core": "^6.1.86" } }, "tldts-core": { - "version": "6.1.79", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.79.tgz", - "integrity": "sha512-HM+Ud/2oQuHt4I43Nvjc213Zji/z25NSH5OkJskJwHXNtYh9DTRlHMDFhms9dFMP7qyve/yVaXFIxmcJ7TdOjw==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true }, "tmp": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", - "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true }, "to-regex-range": { @@ -12524,9 +12682,9 @@ "dev": true }, "tough-cookie": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.1.tgz", - "integrity": "sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "requires": { "tldts": "^6.1.32" @@ -12574,9 +12732,9 @@ } }, "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true }, "typedarray-to-buffer": { @@ -12665,12 +12823,6 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", "dev": true }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true - }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12693,7 +12845,7 @@ "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.2.tgz", "integrity": "sha512-qHlU6AawrgAIHlueGQHQ+ETcPLAauXbnoTKl3RKq20W0T8x0DKVAo5xWIYjHSyvHxQlcYbFdR0jp4T9bDVITFA==", "requires": { - "axios": "^1.7.9", + "axios": ">=1.16.0", "joi": "^17.13.3", "lodash": "^4.17.21", "minimist": "^1.2.8", @@ -12796,9 +12948,9 @@ } }, "ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "requires": {} }, diff --git a/wasm/package.json b/wasm/package.json index 0dda012..53d21f9 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -23,7 +23,7 @@ "devDependencies": { "@cypress-audit/lighthouse": "^1.4.2", "browser-sync": "^3.0.3", - "cypress": "^14.1.0", + "cypress": "^15.17.0", "eslint": "^9.21.0", "eslint-config-prettier": "^10.0.2", "eslint-plugin-cypress": "^4.1.0", @@ -37,7 +37,9 @@ }, "overrides": { "tar-fs": "^3.1.1", - "ws": "^8.18.3", - "cookie": "^0.7.0" + "ws": "^8.21.0", + "cookie": "^0.7.0", + "axios": ">=1.16.0", + "tmp": ">=0.2.6" } } diff --git a/wasm/worker/package-lock.json b/wasm/worker/package-lock.json index f2c9f7f..71e19ca 100644 --- a/wasm/worker/package-lock.json +++ b/wasm/worker/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@types/node": "^22.13.5", "typescript": "^5.7.3", - "vite": "^6.4.1", + "vite": "^8.0.16", "vite-plugin-dts": "^4.5.1" } }, @@ -65,429 +65,38 @@ "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -555,47 +164,39 @@ "resolve": "~1.22.2" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "@tybys/wasm-util": "^0.10.2" }, - "engines": { - "node": ">=14.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -604,12 +205,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -618,12 +222,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -632,26 +239,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -660,84 +256,36 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, "libc": [ "glibc" ], @@ -745,14 +293,17 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "libc": [ @@ -762,50 +313,19 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, "libc": [ "glibc" ], @@ -813,29 +333,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -847,12 +353,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -864,12 +373,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -880,27 +392,16 @@ "license": "MIT", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -909,40 +410,51 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -951,21 +463,40 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } }, "node_modules/@rushstack/node-core-library": { "version": "5.22.0", @@ -1051,6 +582,17 @@ "string-argv": "~0.3.1" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -1296,9 +838,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1347,6 +889,16 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -1380,47 +932,6 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" - } - }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -1436,9 +947,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.0.0.tgz", + "integrity": "sha512-l90y339r2DkZs/ldcWQXcwTjkbp/NbuJDGYoQ3awBgaT3GXOFkm3OkVpz6Z86TywYcya0eVP2r1kTV90f3krGQ==", "dev": true, "funding": [ { @@ -1453,11 +964,14 @@ "license": "BSD-3-Clause" }, "node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -1606,6 +1120,279 @@ "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/local-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.0.tgz", @@ -1698,9 +1485,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1770,9 +1557,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1790,7 +1577,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1847,49 +1634,38 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/semver": { @@ -1988,14 +1764,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2004,6 +1780,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2043,24 +1827,23 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -2069,14 +1852,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -2085,13 +1869,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { diff --git a/wasm/worker/package.json b/wasm/worker/package.json index 927e70c..e856a4d 100644 --- a/wasm/worker/package.json +++ b/wasm/worker/package.json @@ -34,7 +34,11 @@ "devDependencies": { "@types/node": "^22.13.5", "typescript": "^5.7.3", - "vite": "^6.4.1", + "vite": "^8.0.16", "vite-plugin-dts": "^4.5.1" + }, + "overrides": { + "esbuild": ">=0.28.1", + "fast-uri": ">=3.1.2" } }