0.3.1: never overwrite outputs (seconds + -N suffix); OpenOnComplete (folder|pdf|none) on Windows+macOS; hold jobs on missing/invalid key (401/403) with one native sign-in prompt and auto-retry; macOS pkg in-installer key flow (mac-key-setup); Windows release installer (no debug naming, errors-only log, options page); VERAE_LOG_LEVEL=debug switch

This commit is contained in:
Grok Bot (for George) 2026-10-03 19:40:08 -04:00
commit e960883a0a
29 changed files with 1804 additions and 378 deletions

View file

@ -0,0 +1,10 @@
//go:build !windows && !darwin
package main
import "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
func acquireKeyInteractive(reason string) bool {
vlog.Printf("sign-in needed: %s — run: verae-print setup", reason)
return false
}

View file

@ -3,6 +3,9 @@
package main
import (
"bytes"
"encoding/base64"
"errors"
"os"
"os/exec"
"path/filepath"
@ -14,68 +17,238 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// WinForms dialog (powershell, console hidden): masked key field + "Get a key"
// button that opens the customer portal. Writes the key to the file in $env:VERAE_KEY_OUT.
const keyDialogPS = `
// Native WinForms dialogs (PowerShell, console hidden) shown in the user's
// session by the helper. Results come back on stdout as base64 fields
// ("action|b64|b64..."), so passwords/keys never touch disk, argv or logs.
const formsPS = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()
function B([string]$s) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($s)) }
function U([string]$s) { if ($s) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)) } else { '' } }
$kind = $env:VERAE_DLG_KIND; $msg = U $env:VERAE_DLG_MSG; $pre = U $env:VERAE_DLG_EMAIL
$f = New-Object Windows.Forms.Form
$f.Text = 'Verae TimeStamp PDF - signing key'; $f.Width = 520; $f.Height = 300; $f.StartPosition = 'CenterScreen'; $f.TopMost = $true
$f.Text = 'Verae TimeStamp PDF'; $f.Width = 540; $f.StartPosition = 'CenterScreen'; $f.TopMost = $true
$f.FormBorderStyle = 'FixedDialog'; $f.MaximizeBox = $false; $f.MinimizeBox = $false
$l = New-Object Windows.Forms.Label; $l.Left = 15; $l.Top = 12; $l.Width = 480; $l.Height = 95
$l.Text = $env:VERAE_KEY_MSG
$f.Controls.Add($l)
$b = New-Object Windows.Forms.Button; $b.Text = 'Get a Verae key (opens customer.verae.ai)'; $b.Left = 15; $b.Top = 110; $b.Width = 300
$b.Add_Click({ Start-Process 'https://customer.verae.ai/portal/' }); $f.Controls.Add($b)
$k = New-Object Windows.Forms.Label; $k.Text = 'Paste your xVerae- key:'; $k.Left = 15; $k.Top = 150; $k.Width = 300; $f.Controls.Add($k)
$t = New-Object Windows.Forms.TextBox; $t.Left = 15; $t.Top = 172; $t.Width = 475; $t.UseSystemPasswordChar = $true; $f.Controls.Add($t)
$ok = New-Object Windows.Forms.Button; $ok.Text = 'Save key'; $ok.Left = 300; $ok.Top = 215; $ok.Width = 90; $ok.DialogResult = 'OK'; $f.AcceptButton = $ok; $f.Controls.Add($ok)
$c = New-Object Windows.Forms.Button; $c.Text = 'Not now'; $c.Left = 400; $c.Top = 215; $c.Width = 90; $c.DialogResult = 'Cancel'; $f.CancelButton = $c; $f.Controls.Add($c)
if ($f.ShowDialog() -eq 'OK' -and $t.Text.Trim()) { [IO.File]::WriteAllText($env:VERAE_KEY_OUT, $t.Text.Trim()) ; exit 0 }
exit 1
$f.Font = New-Object Drawing.Font('Segoe UI', 9)
$script:y = 12; $script:result = 'cancel'
$l = New-Object Windows.Forms.Label; $l.Left = 15; $l.Top = $script:y; $l.Width = 495; $l.Height = 72; $l.Text = $msg; $f.Controls.Add($l); $script:y += 78
function Field([string]$label, [bool]$hidden, [string]$val) {
$a = New-Object Windows.Forms.Label; $a.Text = $label; $a.Left = 15; $a.Top = $script:y + 3; $a.Width = 150; $f.Controls.Add($a)
$t = New-Object Windows.Forms.TextBox; $t.Left = 170; $t.Top = $script:y; $t.Width = 340; $t.UseSystemPasswordChar = $hidden; $t.Text = $val; $f.Controls.Add($t)
$script:y += 30; return $t
}
function Btn([string]$text, [int]$x, [int]$w, [string]$res) {
$b = New-Object Windows.Forms.Button; $b.Text = $text; $b.Left = $x; $b.Top = $script:y; $b.Width = $w; $b.Height = 28
$b.Tag = $res; $b.Add_Click({ $script:result = $this.Tag; $f.Close() }); $f.Controls.Add($b); return $b
}
switch ($kind) {
'login' {
$e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $t = Field '2FA code (if enabled)' $false ''
$script:y += 8
$ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok
Btn 'Create account' 125 120 'create' | Out-Null
Btn 'Paste key' 255 100 'paste' | Out-Null
$c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
if ($pre) { $f.Add_Shown({ $p.Focus() }) }
[void]$f.ShowDialog()
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } else { $script:result }
}
'create' {
$n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true ''
$script:y += 8
$ok = Btn 'Create account' 15 130 'create'; $f.AcceptButton = $ok
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'create') { "create|$(B $n.Text)|$(B $e.Text)|$(B $p.Text)|$(B $p2.Text)" } else { 'cancel' }
}
'code' {
$k = Field 'Code from the email' $false ''
$script:y += 8
$ok = Btn 'Verify' 15 100 'verify'; $f.AcceptButton = $ok
Btn 'Send a new code' 125 130 'resend' | Out-Null
$c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'verify') { "verify|$(B $k.Text)" } else { $script:result }
}
'paste' {
$g = New-Object Windows.Forms.Button; $g.Text = 'Open customer.verae.ai'; $g.Left = 15; $g.Top = $script:y; $g.Width = 200; $g.Height = 28
$g.Add_Click({ Start-Process 'https://customer.verae.ai/portal/' }); $f.Controls.Add($g)
$script:y += 40
$k = Field 'xVerae- key' $true ''
$script:y += 8
$ok = Btn 'Save key' 15 100 'paste'; $f.AcceptButton = $ok
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
$f.Height = $script:y + 75
[void]$f.ShowDialog()
if ($script:result -eq 'paste') { "paste|$(B $k.Text)" } else { 'cancel' }
}
}
`
func showKeyForm(msg string) (string, bool) {
tmp, err := os.CreateTemp("", "verae-key-*.txt")
if err != nil {
return "", false
}
path := tmp.Name()
tmp.Close()
defer os.Remove(path)
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", keyDialogPS)
cmd.Env = append(os.Environ(), "VERAE_KEY_OUT="+path, "VERAE_KEY_MSG="+msg)
var errDialog = errors.New("dialog failed")
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
// winForm shows one form; returns the action and decoded fields.
func winForm(kind, msg, email string) (string, []string, error) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", formsPS)
cmd.Env = append(os.Environ(), "VERAE_DLG_KIND="+kind, "VERAE_DLG_MSG="+b64(msg), "VERAE_DLG_EMAIL="+b64(email))
hideWindow(cmd)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return "", false
vlog.Errorf("dialog %s: %v", kind, err)
return "", nil, errDialog
}
b, err := os.ReadFile(path)
if err != nil {
return "", false
parts := strings.Split(strings.TrimSpace(out.String()), "|")
var fields []string
for _, p := range parts[1:] {
d, _ := base64.StdEncoding.DecodeString(p)
fields = append(fields, string(d))
}
return strings.TrimSpace(string(b)), true
for len(fields) < 4 {
fields = append(fields, "")
}
return parts[0], fields, nil
}
// keyDialogFlow loops until a valid key is saved or the user cancels.
func keyDialogFlow(reason string) bool {
msg := reason + "\r\n\r\nClick 'Get a Verae key', sign in (or create an account), open Security and create an xVerae- token, then paste it below."
for i := 0; i < 5; i++ {
key, ok := showKeyForm(msg)
if !ok {
vlog.Printf("key-dialog: cancelled by user")
// acquireKeyInteractive: sign in / create account / paste key, then save the
// key for this user. True when a new key was saved.
func acquireKeyInteractive(reason string) bool {
caps := auth.GetCapabilities()
msg, email := reason, ""
for i := 0; i < 25; i++ {
act, f, err := winForm("login", msg, email)
if err != nil {
return false
}
var key string
switch act {
case "login":
email = strings.TrimSpace(f[0])
if !auth.ValidEmail(email) || f[1] == "" {
msg = "Please enter your email and password."
continue
}
vlog.Printf("sign-in: login %s", auth.MaskEmail(email))
key, err = auth.InstallLogin(caps, email, f[1], strings.TrimSpace(f[2]), tokenName())
f[1] = ""
if errors.Is(err, auth.ErrTOTPRequired) {
msg = "This account uses two-factor authentication. Enter the 6-digit code from your authenticator app, then click Sign in."
continue
}
if err != nil {
vlog.Errorf("sign-in: login failed for %s: %v", auth.MaskEmail(email), err)
msg = "Sign-in failed: " + err.Error()
continue
}
case "create":
k, ok := winCreateFlow(caps, email)
if !ok {
continue
}
key = k
case "paste":
if pasteFlow(reason) {
return true
}
continue
default:
vlog.Printf("sign-in: cancelled by user")
return false
}
if err := auth.SaveXVeraeToken(key); err != nil {
msg = "The key could not be saved: " + err.Error()
continue
}
home, _ := os.UserHomeDir()
vlog.Printf("sign-in: key %s saved to %s", auth.MaskKey(key), filepath.Join(home, ".verae", "xverae-token"))
return true
}
return false
}
func winCreateFlow(caps auth.Capabilities, email string) (string, bool) {
msg := "Create a Verae account.\r\n" + caps.Policy.Describe()
for i := 0; i < 10; i++ {
act, f, err := winForm("create", msg, email)
if err != nil || act != "create" {
return "", false
}
name, em := strings.TrimSpace(f[0]), strings.TrimSpace(f[1])
switch {
case name == "":
msg = "Please enter your name."
continue
case !auth.ValidEmail(em):
msg = "Please enter a valid email address."
continue
case f[2] != f[3]:
msg = "The passwords do not match."
continue
}
if perr := caps.Policy.Check(f[2], em); perr != nil {
msg = perr.Error() + "\r\n" + caps.Policy.Describe()
continue
}
vlog.Printf("sign-in: register %s installApi=%v", auth.MaskEmail(em), caps.InstallAPI)
rr, err := auth.InstallRegister(caps, name, em, f[2], tokenName())
f[2], f[3] = "", ""
if err != nil {
msg = "Could not create the account: " + err.Error()
continue
}
if rr.PendingID == "" {
return rr.Key, true
}
cmsg := "We emailed a 6-digit code to " + auth.MaskEmail(em) + ". It may take a minute; check your spam folder."
for j := 0; j < 10; j++ {
act, cf, err := winForm("code", cmsg, "")
if err != nil || act == "cancel" || act == "" {
return "", false
}
if act == "resend" {
if rerr := auth.InstallResend(rr.PendingID); rerr != nil {
cmsg = "Could not send a new code: " + rerr.Error()
} else {
cmsg = "A new code was sent to " + auth.MaskEmail(em) + "."
}
continue
}
key, verr := auth.InstallVerify(rr.PendingID, cf[0], tokenName())
if verr != nil {
cmsg = verr.Error()
continue
}
return key, true
}
return "", false
}
return "", false
}
// pasteFlow: masked key field + portal button; validated before saving.
func pasteFlow(reason string) bool {
msg := "Open customer.verae.ai, sign in, open Security and create an xVerae- token (shown once). Paste it below."
for i := 0; i < 10; i++ {
act, f, err := winForm("paste", msg, "")
if err != nil || act != "paste" {
return false
}
key := strings.TrimSpace(f[0])
st, det := auth.ValidateXVeraeKey(key)
vlog.Printf("key-dialog: entered %s validate=%s %s", auth.MaskKey(key), st, det)
vlog.Printf("sign-in: pasted %s validate=%s %s", auth.MaskKey(key), st, det)
switch st {
case "valid", "unreachable", "unknown":
if err := auth.SaveXVeraeToken(key); err != nil {
msg = "Could not save the key: " + err.Error()
continue
}
home, _ := os.UserHomeDir()
vlog.Printf("key-dialog: saved %s to %s", auth.MaskKey(key), filepath.Join(home, ".verae", "xverae-token"))
messageBox("Verae TimeStamp PDF", "Signing key saved ("+auth.MaskKey(key)+").", 0x40)
vlog.Printf("sign-in: key %s saved", auth.MaskKey(key))
return true
case "invalid":
msg = "Verae rejected that key (" + det + "). Please check it and paste again."
@ -86,9 +259,19 @@ func keyDialogFlow(reason string) bool {
return false
}
// keyDialogFlow is the Start-menu "Setup signing key" entry point.
func keyDialogFlow(reason string) bool { return acquireKeyInteractive(reason) }
func runKeyDialog() int {
vlog.Init("helper", false)
if keyDialogFlow("Set up your Verae signing key for the 'Verae TimeStamp PDF' printer.") {
release, ok := promptLock()
if !ok {
messageBox("Verae TimeStamp PDF", "A Verae sign-in window is already open.", 0x40)
return 1
}
defer release()
if keyDialogFlow("Sign in to Verae to set up the signing key for the 'Verae TimeStamp PDF' printer.") {
messageBox("Verae TimeStamp PDF", "Your Verae signing key is saved. Waiting print jobs (if any) will be timestamped within a minute.", 0x40)
return 0
}
return 1

View file

@ -0,0 +1,370 @@
//go:build darwin
package main
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// macOS in-installer key registration, run by the pkg postinstall as the
// console user (launchctl asuser + sudo -H -u). Native AppleScript dialogs;
// passwords come back through osascript's stdout pipe only — never argv,
// never files, never logs. Keys are logged masked only.
const dlgTitle = "Verae Print Stamp"
// AppleScript gets all user-visible text via argv (no string interpolation →
// no injection). Output: "<button>\t<text returned>" or "CANCEL".
const dialogScript = `on run argv
set msg to item 1 of argv
set defAns to item 2 of argv
set hide to (item 3 of argv) is "1"
set btns to paragraphs of (item 4 of argv)
set defBtn to item 5 of argv
set useAnswer to (item 6 of argv) is "1"
tell me to activate
try
if useAnswer then
if hide then
set r to display dialog msg default answer defAns with hidden answer buttons btns default button defBtn with title "` + dlgTitle + `" giving up after 900
else
set r to display dialog msg default answer defAns buttons btns default button defBtn with title "` + dlgTitle + `" giving up after 900
end if
if gave up of r then return "CANCEL"
return (button returned of r) & tab & (text returned of r)
else
set r to display dialog msg buttons btns default button defBtn with title "` + dlgTitle + `" giving up after 900
if gave up of r then return "CANCEL"
return (button returned of r) & tab
end if
on error number -128
return "CANCEL"
end try
end run`
const chooseScript = `on run argv
set msg to item 1 of argv
set opts to paragraphs of (item 2 of argv)
tell me to activate
set r to choose from list opts with prompt msg default items {item 1 of opts} OK button name "Continue" cancel button name "Skip" with title "` + dlgTitle + `"
if r is false then return "CANCEL"
return item 1 of r
end run`
var errCancel = errors.New("cancelled")
func osa(script string, args ...string) (string, error) {
cmd := exec.Command("osascript", append([]string{"-e", script, "--"}, args...)...)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("osascript: %v", err)
}
return strings.TrimRight(string(out), "\n"), nil
}
// ask shows a dialog; returns (button, answer).
func ask(msg, def string, hidden, withAnswer bool, buttons []string, defBtn string) (string, string, error) {
b2 := func(v bool) string {
if v {
return "1"
}
return "0"
}
out, err := osa(dialogScript, msg, def, b2(hidden), strings.Join(buttons, "\n"), defBtn, b2(withAnswer))
if err != nil {
return "", "", err
}
if out == "CANCEL" {
return "", "", errCancel
}
btn, ans, _ := strings.Cut(out, "\t")
return btn, ans, nil
}
func say(msg string) { _, _, _ = ask(msg, "", false, false, []string{"OK"}, "OK") }
func choose(msg string, opts []string) (string, error) {
out, err := osa(chooseScript, msg, strings.Join(opts, "\n"))
if err != nil {
return "", err
}
if out == "CANCEL" {
return "", errCancel
}
return out, nil
}
const (
optKeep = "Keep the existing key"
optLogin = "Sign in to my Verae account (email + password)"
optCreate = "Create a new Verae account"
optPaste = "Paste an xVerae- key"
optSkip = "Skip for now"
)
func runMacKeySetup(args []string) int {
fs := flag.NewFlagSet("mac-key-setup", flag.ContinueOnError)
askOpen := fs.Bool("ask-open", true, "also ask what to open after each print")
if err := fs.Parse(args); err != nil {
return 2
}
vlog.Init("installer", false)
vlog.Printf("mac-key-setup start user=%s home=%s", os.Getenv("USER"), os.Getenv("HOME"))
release, ok := promptLock()
if !ok {
say("A Verae sign-in window is already open.")
return 1
}
result, saved := keyFlow("")
release()
vlog.Printf("mac-key-setup key result: %s", result)
fmt.Println("key:", result)
if saved || strings.HasPrefix(result, "kept") {
if n := len(listPending()); n > 0 {
done, remaining, _ := retryPending(stampFile)
vlog.Printf("mac-key-setup: retried held print jobs: %d stamped, %d remaining", done, remaining)
fmt.Printf("pending jobs: %d stamped, %d remaining\n", done, remaining)
}
}
if *askOpen {
mode := askOpenOnComplete()
fmt.Println("openOnComplete:", mode)
}
return 0
}
// acquireKeyInteractive: native sign-in dialogs after a 401/403 or missing key.
func acquireKeyInteractive(reason string) bool {
_, saved := keyFlow(reason)
return saved
}
// keyFlow returns a masked result string and whether a NEW key was saved.
func keyFlow(reason string) (string, bool) {
existing, _ := auth.LoadXVeraeToken()
caps := auth.GetCapabilities()
vlog.Printf("mac-key-setup: installApi=%v emailVerification=%v existing=%v", caps.InstallAPI, caps.EmailVerification, existing != "")
var opts []string
intro := "No Verae signing key was found for this Mac user. You need one to timestamp documents."
if existing != "" {
st, det := auth.ValidateXVeraeKey(existing)
vlog.Printf("mac-key-setup: existing key %s check=%s %s", auth.MaskKey(existing), st, det)
switch st {
case "valid":
intro = "A Verae signing key is already saved: " + auth.MaskKey(existing) + " (verified with Verae)."
opts = append(opts, optKeep)
case "invalid":
intro = "A saved key (" + auth.MaskKey(existing) + ") was REJECTED by Verae. Please sign in or paste a new key."
default:
intro = "A Verae signing key is saved: " + auth.MaskKey(existing) + " (could not verify right now)."
opts = append(opts, optKeep)
}
}
opts = append(opts, optLogin, optCreate, optPaste, optSkip)
if reason != "" {
intro = reason + "\n\n" + intro
}
prompt := intro + "\n\nYour password is sent only to customer.verae.ai over HTTPS and is never stored. Only the issued key is saved (Keychain + ~/.verae)."
for {
c, err := choose(prompt, opts)
if err != nil {
c = optSkip
}
var key string
switch c {
case optKeep:
return "kept " + auth.MaskKey(existing), false
case optLogin:
key, err = macLogin(caps)
case optCreate:
key, err = macCreate(caps)
case optPaste:
key, err = macPaste()
default:
btn, _, _ := ask("Continue without a Verae key?\n\nThe printer is installed, but documents cannot be timestamped until you add a key. You will be asked again on your first print (or open Verae Print Stamp from Applications).", "", false, false, []string{"Go back", "Skip"}, "Go back")
if btn == "Skip" {
return "skipped", false
}
continue
}
if errors.Is(err, errCancel) {
continue // back to the menu
}
if err != nil {
vlog.Errorf("mac-key-setup: %v", err)
say("Could not get a key: " + err.Error())
continue
}
if err := auth.SaveXVeraeToken(key); err != nil {
vlog.Errorf("mac-key-setup: save failed: %v", err)
say("The key could not be saved: " + err.Error())
continue
}
vlog.Printf("mac-key-setup: key saved %s", auth.MaskKey(key))
say("Your Verae signing key (" + auth.MaskKey(key) + ") is saved.\n\nPrint from any app and choose \"Verae Timestamping\".")
return "saved " + auth.MaskKey(key), true
}
}
func macLogin(caps auth.Capabilities) (string, error) {
email := ""
for {
btn, e, err := ask("Sign in to Verae\n\nEmail:", email, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
return "", errCancel
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", "Sign in"}, "Sign in")
if err != nil || btn == "Back" {
continue
}
vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email))
key, err := auth.InstallLogin(caps, email, pw, "", tokenName())
if errors.Is(err, auth.ErrTOTPRequired) {
btn, code, cerr := ask("This account uses two-factor authentication.\n\nEnter the 6-digit code from your authenticator app:", "", false, true, []string{"Back", "Verify"}, "Verify")
if cerr != nil || btn == "Back" {
continue
}
key, err = auth.InstallLogin(caps, email, pw, strings.TrimSpace(code), tokenName())
}
pw = ""
if err != nil {
vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err)
say("Sign-in failed: " + err.Error())
continue
}
return key, nil
}
}
func macCreate(caps auth.Capabilities) (string, error) {
var name, email string
for {
btn, n, err := ask("Create a Verae account\n\nYour name:", name, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
return "", errCancel
}
name = strings.TrimSpace(n)
if name == "" {
continue
}
btn, e, err := ask("Email address:", email, false, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue
}
email = strings.TrimSpace(e)
if !auth.ValidEmail(email) {
say("Please enter a valid email address.")
continue
}
policy := caps.Policy.Describe()
btn, pw, err := ask("Choose a password.\n"+policy, "", true, true, []string{"Back", "Next"}, "Next")
if err != nil || btn == "Back" {
continue
}
if perr := caps.Policy.Check(pw, email); perr != nil {
say(perr.Error())
continue
}
btn, pw2, err := ask("Confirm the password:", "", true, true, []string{"Back", "Create account"}, "Create account")
if err != nil || btn == "Back" {
continue
}
if pw != pw2 {
say("The passwords do not match.")
continue
}
pw2 = ""
vlog.Printf("mac-key-setup: register %s installApi=%v", auth.MaskEmail(email), caps.InstallAPI)
rr, err := auth.InstallRegister(caps, name, email, pw, tokenName())
pw = ""
if err != nil {
vlog.Errorf("mac-key-setup: register failed for %s: %v", auth.MaskEmail(email), err)
say("Could not create the account: " + err.Error())
continue
}
if rr.PendingID == "" {
return rr.Key, nil // server without email verification (legacy signup)
}
vlog.Printf("mac-key-setup: verification code sent to %s", auth.MaskEmail(email))
msg := "We emailed a 6-digit code to " + auth.MaskEmail(email) + ".\nIt may take a minute; check spam.\n\nCode:"
for {
btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Verify"}, "Verify")
if err != nil || btn == "Cancel" {
return "", errCancel
}
if btn == "Send a new code" {
if rerr := auth.InstallResend(rr.PendingID); rerr != nil {
say("Could not send a new code: " + rerr.Error())
} else {
say("A new code was sent.")
}
continue
}
key, verr := auth.InstallVerify(rr.PendingID, code, tokenName())
if verr != nil {
say(verr.Error())
continue
}
return key, nil
}
}
}
func macPaste() (string, error) {
for {
btn, k, err := ask("Paste your xVerae- key.\n\nNo key yet? Click \"Open portal\", sign in, open Security and create an xVerae- token (shown once).", "", true, true, []string{"Back", "Open portal", "Save"}, "Save")
if err != nil || btn == "Back" {
return "", errCancel
}
if btn == "Open portal" {
_ = exec.Command("open", auth.PortalSignupURL).Start()
continue
}
k = strings.TrimSpace(k)
st, det := auth.ValidateXVeraeKey(k)
vlog.Printf("mac-key-setup: pasted key %s: %s %s", auth.MaskKey(k), st, det)
switch st {
case "valid":
return k, nil
case "invalid":
say("Verae rejected this key (" + det + "). Please check it and paste again.")
case "badformat":
say("That does not look like a Verae key: " + det)
default:
b, _, _ := ask("Could not reach Verae to check the key ("+det+").\nSave it anyway?", "", false, false, []string{"No", "Save anyway"}, "Save anyway")
if b == "Save anyway" {
return k, nil
}
}
}
}
func askOpenOnComplete() string {
cur, _ := settings.Load()
def := map[string]string{settings.OpenFolder: "Show in Finder", settings.OpenPDF: "Open the PDF", settings.OpenNone: "Do nothing"}[cur.OpenOnComplete]
btn, _, err := ask("After each print is timestamped:\n\n• Show in Finder - reveal the new certificate PDF in Verae Timestamps (recommended)\n• Open the PDF - open the certificate in Preview\n• Do nothing", "", false, false, []string{"Do nothing", "Open the PDF", "Show in Finder"}, def)
mode := cur.OpenOnComplete
if err == nil {
mode = map[string]string{"Show in Finder": settings.OpenFolder, "Open the PDF": settings.OpenPDF, "Do nothing": settings.OpenNone}[btn]
}
if err := settings.SetOpenOnComplete(settings.UserPath(""), mode); err != nil {
vlog.Errorf("mac-key-setup: write config: %v", err)
} else {
vlog.Printf("mac-key-setup: openOnComplete=%s saved to %s", mode, settings.UserPath(""))
}
return mode
}

View file

@ -0,0 +1,10 @@
//go:build !darwin
package main
import "fmt"
func runMacKeySetup(args []string) int {
fmt.Println("mac-key-setup is macOS-only (Windows uses the installer pages / key-dialog)")
return 1
}

View file

@ -35,6 +35,10 @@ func main() {
os.Exit(runInstallerAccount(os.Args[2:]))
case "validate-key":
os.Exit(runValidateKey(os.Args[2:]))
case "config":
os.Exit(runConfig(os.Args[2:]))
case "mac-key-setup":
os.Exit(runMacKeySetup(os.Args[2:]))
case "key-dialog":
os.Exit(runKeyDialog())
case "setup", "login":
@ -72,9 +76,13 @@ Usage:
verae-print serve [--addr 127.0.0.1:8631] [--no-ipp] [--no-watch] [--debug]
# Windows print helper: IPP loopback printer + fallback watcher
verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does
verae-print config [--open-on-complete folder|pdf|none] [--machine | --file PATH]
# after each print: reveal the certificate (folder, default), open it (pdf), or nothing
verae-print mac-key-setup # macOS: native dialogs to keep/sign in/create account/paste a key (run as the user)
verae-print version
Debug: set VERAE_DEBUG=1 (or --debug) for full IPP request dumps. Logs: see "verae-print version -v".
Debug logging is off by default; set VERAE_LOG_LEVEL=debug (or "logLevel": "debug" in
~/.verae/config.json, or serve --debug) for full IPP request dumps. Logs: see "verae-print version -v".
First install opens https://customer.verae.ai/portal/ so you can create an account
and mint an xVerae- signing key (Security tab). The key is stored in Keychain.
@ -109,7 +117,7 @@ func runStamp(args []string) int {
channel := fs.String("channel", string(auth.PreferredChannel()), "verae | zapier")
outDir := fs.String("out-dir", "", "output directory")
name := fs.String("name", "", "document stem override")
openFolder := fs.Bool("open", false, "open output folder on success (macOS)")
openFolder := fs.Bool("open", false, "on success, apply the OpenOnComplete setting (folder|pdf|none; see 'verae-print config')")
skipSetup := fs.Bool("skip-setup", false, "do not open first-run account dialog")
jobFlag := fs.String("job-id", "", "debug job id (for log correlation)")
_ = fs.Parse(args)
@ -170,6 +178,24 @@ func runStamp(args []string) int {
Channel: ch,
Client: client,
})
if err != nil && isAuthFailure(err) {
// Key invalid/expired: keep the job, ask the user to sign in (one prompt
// at a time across processes), then stamp everything that is pending.
title := *name
if title == "" {
title = filepath.Base(pdfPath)
}
m, herr := holdJob(pdfPath, title, "cli", *outDir, err, false)
if herr != nil {
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
return 1
}
lj.Printf("stamp: key rejected (%v); job HELD as pending %s", err, m.ID)
fmt.Printf("Verae did not accept the signing key; the job is saved (pending %s) and will be timestamped after you sign in.\n", m.ID)
handleAuthFailure(authReason(err), stampFile)
return 0
}
if err != nil {
lj.Errorf("stamp failed: %v", err)
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
@ -181,7 +207,12 @@ func runStamp(args []string) int {
res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel)
if *openFolder {
openFolderPath(filepath.Dir(res.OriginalPDF))
openOnComplete(lj, res.CertificatePDF)
}
// the key works: stamp anything held from earlier failures
if len(listPending()) > 0 {
done, remaining, _ := retryPending(stampFile)
lj.Printf("pending: %d stamped, %d remaining", done, remaining)
}
// macOS notification (best-effort)
notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)

View file

@ -0,0 +1,25 @@
//go:build !windows
package main
import (
"os/exec"
"path/filepath"
"runtime"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
)
// openResult: macOS `open -R` reveals the file in Finder, `open` opens it.
func openResult(mode, cert string) error {
if runtime.GOOS == "darwin" {
if mode == settings.OpenPDF {
return exec.Command("open", cert).Run()
}
return exec.Command("open", "-R", cert).Run()
}
if mode == settings.OpenPDF {
return exec.Command("xdg-open", cert).Start()
}
return exec.Command("xdg-open", filepath.Dir(cert)).Start()
}

View file

@ -0,0 +1,46 @@
//go:build windows
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"unsafe"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
)
var procShellExecuteW = syscall.NewLazyDLL("shell32.dll").NewProc("ShellExecuteW")
func shellOpen(path string) error {
verb, _ := syscall.UTF16PtrFromString("open")
file, _ := syscall.UTF16PtrFromString(path)
r, _, _ := procShellExecuteW.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(file)), 0, 0, 1 /*SW_SHOWNORMAL*/)
if r <= 32 {
return fmt.Errorf("ShellExecute returned %d", r)
}
return nil
}
// openResult runs in the helper, which already lives in the logged-in user's
// session (scheduled task, Interactive), so Explorer opens on their desktop.
func openResult(mode, cert string) error {
switch mode {
case settings.OpenPDF:
return shellOpen(cert)
default:
explorer := filepath.Join(os.Getenv("WINDIR"), "explorer.exe")
cmd := exec.Command(explorer)
// explorer needs exactly: /select,"C:\path with spaces\file.pdf" — Go's
// default argument quoting breaks that, so set the raw command line.
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: fmt.Sprintf(`"%s" /select,"%s"`, explorer, cert)}
if err := cmd.Start(); err != nil {
return err
}
go cmd.Wait() // explorer exits 1 even on success; don't treat as error
return nil
}
}

View file

@ -0,0 +1,59 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// openOnComplete applies the OpenOnComplete setting after a successful stamp.
func openOnComplete(lj vlog.Job, certPDF string) {
s, src := settings.Load()
mode := s.OpenOnComplete
if mode == settings.OpenNone {
lj.Printf("open-on-complete: mode=none (from %s) — not opening", src)
return
}
if _, err := os.Stat(certPDF); err != nil {
lj.Errorf("open-on-complete: mode=%s target missing %q: %v", mode, certPDF, err)
return
}
err := openResult(mode, certPDF)
if err != nil {
lj.Errorf("open-on-complete: mode=%s (from %s) target=%q FAILED: %v", mode, src, certPDF, err)
return
}
lj.Printf("open-on-complete: mode=%s (from %s) target=%q OK", mode, src, certPDF)
}
// runConfig: verae-print config [--show] [--open-on-complete folder|pdf|none] [--file PATH | --machine]
func runConfig(args []string) int {
fs := flag.NewFlagSet("config", flag.ContinueOnError)
open := fs.String("open-on-complete", "", "folder | pdf | none")
file := fs.String("file", "", "config file to update (default ~/.verae/config.json)")
machine := fs.Bool("machine", false, "update the all-users default file instead")
if err := fs.Parse(args); err != nil {
return 2
}
path := settings.UserPath("")
if *machine {
path = settings.MachinePath()
}
if *file != "" {
path = *file
}
if *open != "" {
if err := settings.SetOpenOnComplete(path, *open); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
vlog.Init("cli", false)
vlog.Printf("config: openOnComplete=%s written to %s", settings.NormalizeOpen(*open), path)
}
s, src := settings.Load()
fmt.Printf("openOnComplete=%s (from %s)\nuser file: %s\nmachine file: %s\n", s.OpenOnComplete, src, settings.UserPath(""), settings.MachinePath())
return 0
}

256
cmd/verae-print/pending.go Normal file
View file

@ -0,0 +1,256 @@
package main
// Held ("pending") print jobs: when a stamp fails because the key is missing,
// invalid or expired (HTTP 401/403), the PDF is moved into a per-user pending
// folder instead of being lost, ONE login prompt is shown (cross-process lock),
// and after a new key is saved every pending job is retried automatically.
// Cancelled → jobs stay pending and are retried on the next helper start,
// the next successful login/key save, or the next successful print.
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// errNoKey: no usable signing key is stored.
var errNoKey = errors.New("no Verae signing key is set up")
// isAuthFailure: the job can succeed after the user signs in again.
func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) }
type pendingMeta struct {
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
OutDir string `json:"outDir,omitempty"`
Reason string `json:"reason"`
Created string `json:"created"`
}
func pendingDir() string {
if d := os.Getenv("VERAE_PENDING_DIR"); d != "" {
return d
}
base, err := os.UserConfigDir() // %APPDATA% / ~/Library/Application Support
if err != nil {
base = os.TempDir()
}
return filepath.Join(base, "Verae", "PrintStamp", "pending")
}
func (m pendingMeta) pdf() string { return filepath.Join(pendingDir(), m.ID+".pdf") }
func (m pendingMeta) meta() string { return filepath.Join(pendingDir(), m.ID+".json") }
func moveOrCopy(src, dst string, move bool) error {
if move {
if err := os.Rename(src, dst); err == nil {
return nil
}
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return err
}
if err := out.Close(); err != nil {
return err
}
if move {
_ = os.Remove(src)
}
return nil
}
// holdJob stores the PDF + metadata in the pending folder.
func holdJob(pdfPath, title, source, outDir string, reason error, move bool) (pendingMeta, error) {
if err := os.MkdirAll(pendingDir(), 0o700); err != nil {
return pendingMeta{}, err
}
rb := make([]byte, 3)
_, _ = rand.Read(rb)
m := pendingMeta{ID: time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(rb), Title: title, Source: source, OutDir: outDir,
Reason: reason.Error(), Created: time.Now().Format(time.RFC3339)}
if err := moveOrCopy(pdfPath, m.pdf(), move); err != nil {
return pendingMeta{}, err
}
b, _ := json.MarshalIndent(m, "", " ")
if err := os.WriteFile(m.meta(), b, 0o600); err != nil {
return pendingMeta{}, err
}
return m, nil
}
func listPending() []pendingMeta {
ents, _ := os.ReadDir(pendingDir())
var out []pendingMeta
for _, e := range ents {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
b, err := os.ReadFile(filepath.Join(pendingDir(), e.Name()))
if err != nil {
continue
}
var m pendingMeta
if json.Unmarshal(b, &m) != nil || m.ID == "" {
continue
}
if _, err := os.Stat(m.pdf()); err != nil {
continue
}
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func removePending(m pendingMeta) {
_ = os.Remove(m.pdf())
_ = os.Remove(m.meta())
}
// stampFn stamps one PDF with the CURRENT stored key.
type stampFn func(pdf, title, outDir string, lj vlog.Job) (job.Result, error)
// acquireKeyHook replaces the interactive prompt in tests.
var acquireKeyHook func(reason string) bool
var retryMu sync.Mutex // one retry pass at a time within a process
// retryPending re-stamps every pending job. Stops at the first auth failure.
func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
retryMu.Lock()
defer retryMu.Unlock()
for _, m := range listPending() {
lj := vlog.Job(vlog.NewJobID("pending"))
lj.Printf("pending: retrying held job %s title=%q (held because: %s)", m.ID, m.Title, m.Reason)
res, err := stamp(m.pdf(), m.Title, m.OutDir, lj)
if err != nil {
if isAuthFailure(err) {
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
return done, len(listPending()), true
}
lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err)
continue
}
removePending(m)
done++
lj.Printf("pending: %s OK receipt=%s cert=%q", m.ID, res.ReceiptID, res.CertificatePDF)
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
openOnComplete(lj, res.CertificatePDF)
}
return done, len(listPending()), false
}
// promptLock guarantees a single login prompt across processes (macOS runs one
// process per print job; Windows has the helper plus the Start-menu dialog).
func promptLock() (func(), bool) {
_ = os.MkdirAll(pendingDir(), 0o700)
p := filepath.Join(pendingDir(), ".prompt.lock")
for i := 0; i < 2; i++ {
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
fmt.Fprintf(f, "%d %s\n", os.Getpid(), time.Now().Format(time.RFC3339))
f.Close()
return func() { _ = os.Remove(p) }, true
}
st, serr := os.Stat(p)
if serr == nil && time.Since(st.ModTime()) > 20*time.Minute {
_ = os.Remove(p) // stale (crashed prompt)
continue
}
return func() {}, false
}
return func() {}, false
}
// handleAuthFailure shows ONE login prompt (if none is open), then retries all
// pending jobs. Returns true when everything pending was stamped.
func handleAuthFailure(reason string, stamp stampFn) bool {
release, ok := promptLock()
if !ok {
vlog.Printf("pending: a Verae sign-in prompt is already open; job queued (%d pending)", len(listPending()))
return false
}
defer release()
for attempt := 0; attempt < 3; attempt++ {
n := len(listPending())
if n == 0 {
return true
}
msg := reason + "\n\n" + strconv.Itoa(n) + " print job(s) are saved and will be timestamped as soon as you sign in."
vlog.Printf("pending: prompting for sign-in (%d job(s) held)", n)
acquire := acquireKeyInteractive
if acquireKeyHook != nil {
acquire = acquireKeyHook
}
if !acquire(msg) {
vlog.Printf("pending: sign-in cancelled; %d job(s) kept in %s", n, pendingDir())
notifyUser("Verae: print jobs waiting", fmt.Sprintf("%d job(s) will be timestamped after you sign in.", n))
return false
}
markKeySeen()
done, remaining, authFail := retryPending(stamp)
vlog.Printf("pending: after sign-in: %d stamped, %d remaining", done, remaining)
if !authFail {
return remaining == 0
}
reason = "Verae did not accept the new key either."
}
return false
}
// keyFingerprint identifies the stored key without revealing it.
func keyFingerprint() string {
k, err := auth.LoadXVeraeToken()
if err != nil {
return ""
}
h := sha256.Sum256([]byte(k))
return hex.EncodeToString(h[:6])
}
var (
seenMu sync.Mutex
seenKey string
)
func markKeySeen() { seenMu.Lock(); seenKey = keyFingerprint(); seenMu.Unlock() }
// keyChangedSinceSeen: a key was saved elsewhere (Start-menu dialog, installer).
func keyChangedSinceSeen() bool {
fp := keyFingerprint()
seenMu.Lock()
defer seenMu.Unlock()
if fp != "" && fp != seenKey {
seenKey = fp
return true
}
return false
}

View file

@ -0,0 +1,90 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
func pendingEnv(t *testing.T) string {
d := t.TempDir()
t.Setenv("VERAE_PENDING_DIR", filepath.Join(d, "pending"))
t.Setenv("VERAE_NO_NOTIFY", "1")
t.Setenv("VERAE_OPEN_ON_COMPLETE", "none")
return d
}
func TestHoldAndRetryAfterSignIn(t *testing.T) {
d := pendingEnv(t)
src := filepath.Join(d, "job.pdf")
_ = os.WriteFile(src, []byte("%PDF-1.4 x"), 0o600)
authErr := &veraeapi.HTTPError{Service: "zapier stamp", Status: 401, Body: "unauthorized"}
if !isAuthFailure(authErr) || !isAuthFailure(errNoKey) || isAuthFailure(errors.New("timeout")) {
t.Fatal("isAuthFailure")
}
m, err := holdJob(src, "Printed 2026-10-03 192901", "watch", "", authErr, true)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatal("source should have been moved")
}
if l := listPending(); len(l) != 1 || l[0].ID != m.ID {
t.Fatalf("list %v", l)
}
keyOK := false
stamp := func(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
if !keyOK {
return job.Result{}, authErr
}
return job.Result{ReceiptID: "R1", CertificatePDF: pdf}, nil
}
// cancelled prompt → job stays pending
prompts := 0
acquireKeyHook = func(string) bool { prompts++; return false }
defer func() { acquireKeyHook = nil }()
if handleAuthFailure("x", stamp) || len(listPending()) != 1 || prompts != 1 {
t.Fatalf("cancel: prompts=%d pending=%d", prompts, len(listPending()))
}
// a second prompt while one is open is suppressed (single prompt)
release, ok := promptLock()
if !ok {
t.Fatal("lock")
}
if handleAuthFailure("x", stamp) || prompts != 1 {
t.Fatalf("second prompt shown while locked (prompts=%d)", prompts)
}
release()
// successful sign-in → retried and removed
acquireKeyHook = func(string) bool { prompts++; keyOK = true; return true }
if !handleAuthFailure("x", stamp) || len(listPending()) != 0 {
t.Fatalf("after sign-in pending=%d", len(listPending()))
}
}
func TestRetryKeepsNonAuthFailures(t *testing.T) {
d := pendingEnv(t)
for _, n := range []string{"a.pdf", "b.pdf"} {
p := filepath.Join(d, n)
_ = os.WriteFile(p, []byte("%PDF"), 0o600)
if _, err := holdJob(p, n, "ipp", "", errNoKey, false); err != nil {
t.Fatal(err)
}
}
calls := 0
done, remaining, authFail := retryPending(func(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
calls++
if calls == 1 {
return job.Result{}, errors.New("network down")
}
return job.Result{ReceiptID: "R"}, nil
})
if done != 1 || remaining != 1 || authFail {
t.Fatalf("done=%d remaining=%d authFail=%v", done, remaining, authFail)
}
}

View file

@ -4,6 +4,7 @@ package main
import (
"fmt"
"os"
"os/exec"
"runtime"
@ -11,7 +12,7 @@ import (
)
func notify(title, subtitle string) {
if runtime.GOOS != "darwin" {
if runtime.GOOS != "darwin" || os.Getenv("VERAE_NO_NOTIFY") == "1" {
return
}
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
@ -31,8 +32,6 @@ func openFolderPath(p string) {
_ = exec.Command("xdg-open", p).Start()
}
func launchSetupUI() { vlog.Printf("setup: no signing key — run: verae-print setup") }
func runInstallPrinterWindows() int { return 1 }
func debugFromRegistry() bool { return false }

View file

@ -37,19 +37,6 @@ func notify(title, subtitle string) { notifyUser(title, subtitle) }
func openFolderPath(p string) { _ = exec.Command("explorer.exe", p).Start() }
// launchSetupUI asks the user (GUI, no console) to set up a key, then
// re-processes jobs that were held because no key was present.
func launchSetupUI() {
go func() {
vlog.Printf("setup: no signing key — prompting user (GUI)")
if keyDialogFlow("Your print job was saved but not yet timestamped because no Verae signing key is set up on this PC.") {
retryHeldJobs()
} else {
notifyUser("Verae key not set", "Print jobs are kept and will be timestamped after you set up a key (Start > Verae > Setup signing key).")
}
}()
}
func runInstallPrinterWindows() int {
script := filepath.Join(exeDir(), "..", "scripts", "install-printer.ps1")
ps := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")

View file

@ -9,14 +9,13 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/OpenPrinting/goipp"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint"
)
@ -61,7 +60,8 @@ func runServe(args []string) int {
outDir := fs.String("out-dir", "", "output dir (default Documents\\Verae Timestamps)")
_ = fs.Parse(args)
vlog.SetDebug(*debug || debugFromRegistry())
cfg, _ := settings.Load()
vlog.SetDebug(*debug || debugFromRegistry() || cfg.LogLevel == "debug")
logPath := vlog.Init("helper", !isHelperExe())
host, _ := os.Hostname()
home, _ := os.UserHomeDir()
@ -71,6 +71,7 @@ func runServe(args []string) int {
logAuthState()
proc := makeProcessor(*outDir)
go pendingOnStart()
errc := make(chan error, 2)
if !*noIPP {
srv := &winprint.IPPServer{Addr: *addr, Name: "Verae TimeStamp PDF", SpoolDir: filepath.Join(userWorkDir(), "ipp"), Process: proc}
@ -102,90 +103,67 @@ func errState(err error) string {
return "missing (" + err.Error() + ")"
}
var (
setupMu sync.Mutex
setupActive bool
heldMu sync.Mutex
heldJobs []winprint.Job
processor winprint.Processor
)
// promptSetup shows the key dialog at most once at a time.
func promptSetup() {
setupMu.Lock()
defer setupMu.Unlock()
if setupActive {
return
}
setupActive = true
launchSetupUI()
}
func retryHeldJobs() {
setupMu.Lock()
setupActive = false
setupMu.Unlock()
heldMu.Lock()
jobs := heldJobs
heldJobs = nil
heldMu.Unlock()
for _, j := range jobs {
vlog.Job(j.ID).Printf("retry: re-processing held job after key setup")
if err := processor(j); err != nil {
vlog.Job(j.ID).Errorf("retry failed: %v", err)
}
}
}
var processor winprint.Processor
// makeProcessor: IPP/fallback jobs → stamp. Key missing/invalid/expired
// (HTTP 401/403) → the PDF is held in the pending folder, ONE sign-in prompt
// is shown, and all held jobs are retried after a new key is saved.
func makeProcessor(outDir string) winprint.Processor {
processor = func(j winprint.Job) error {
lj := vlog.Job(j.ID)
lj.Printf("process: source=%s title=%q user=%q format=%s bytes=%d path=%q", j.Source, j.Title, j.User, j.Format, j.Bytes, j.Path)
if !strings.Contains(j.Format, "pdf") && sniffPDF(j.Path) == false {
return fmt.Errorf("received %s (not PDF); raster→PDF conversion not implemented in this debug build — file kept at %s. Re-test with VERAE_IPP_FORMATS=application/pdf", j.Format, j.Path)
if !strings.Contains(j.Format, "pdf") && !sniffPDF(j.Path) {
return fmt.Errorf("received %s (not PDF); raster→PDF conversion is not implemented — file kept at %s. Set VERAE_IPP_FORMATS=application/pdf", j.Format, j.Path)
}
ch := auth.PreferredChannel()
client := &veraeapi.Client{Channel: ch}
switch ch {
case auth.ChannelZapier:
tok, err := auth.LoadXVeraeToken()
if err != nil {
if jwt, jerr := auth.LoadVeraeJWT(); jerr == nil {
lj.Printf("process: no xVerae- token, falling back to verae channel (auth.properties)")
ch, client.Channel, client.VeraeToken = auth.ChannelVerae, auth.ChannelVerae, jwt
break
}
heldMu.Lock()
heldJobs = append(heldJobs, j)
heldMu.Unlock()
promptSetup()
return fmt.Errorf("no signing key: %v — run 'verae-print setup' then reprint (PDF kept at %s)", err, j.Path)
}
client.XVeraeToken = tok
default:
tok, err := auth.LoadVeraeJWT()
if err != nil {
return fmt.Errorf("verae channel: %v", err)
}
client.VeraeToken = tok
}
client.HTTPDo = loggingDo(lj)
name := safeStem(j.Title)
start := time.Now()
res, err := job.Run(job.Options{SourcePDF: j.Path, OutDir: outDir, DocumentName: name + ".pdf", Channel: ch, Client: client})
res, err := stampFile(j.Path, j.Title, outDir, lj)
if err != nil {
if isAuthFailure(err) {
m, herr := holdJob(j.Path, j.Title, j.Source, outDir, err, true)
if herr != nil {
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
return err
}
lj.Printf("process: HELD as pending %s (%v); prompting for sign-in", m.ID, err)
go handleAuthFailure(authReason(err), stampFile)
return nil // the job is safe; IPP reports success to Windows
}
notifyUser("Verae timestamp FAILED", err.Error())
return err
}
lj.Printf("process: OK in %s receipt=%s sha256=%s\n original=%q\n cert=%q\n json=%q",
time.Since(start).Round(time.Millisecond), res.ReceiptID, res.SHA256, res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON)
_ = os.Remove(j.Path)
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
openOnComplete(lj, res.CertificatePDF)
// a successful stamp proves the key works: flush anything still pending
if len(listPending()) > 0 {
go retryPending(stampFile)
}
return nil
}
return processor
}
// pendingOnStart retries held jobs at helper start, and whenever a key is saved
// by another process (Start-menu "Setup signing key", installer).
func pendingOnStart() {
markKeySeen()
if n := len(listPending()); n > 0 {
vlog.Printf("pending: %d held job(s) found at start in %s", n, pendingDir())
if auth.HasXVeraeToken() {
if _, _, authFail := retryPending(stampFile); !authFail {
goto watch
}
}
handleAuthFailure("Print jobs from an earlier session are waiting for a valid Verae signing key.", stampFile)
}
watch:
for range time.Tick(30 * time.Second) {
if keyChangedSinceSeen() && len(listPending()) > 0 {
vlog.Printf("pending: a new key was saved; retrying held jobs")
retryPending(stampFile)
}
}
}
func loggingDo(lj vlog.Job) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
start := time.Now()

View file

@ -0,0 +1,61 @@
package main
import (
"fmt"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// clientForChannel builds an API client from the stored credentials.
// Missing xVerae- key (and no JWT fallback) → errNoKey.
func clientForChannel(ch auth.Channel, lj vlog.Job) (*veraeapi.Client, auth.Channel, error) {
client := &veraeapi.Client{Channel: ch}
switch ch {
case auth.ChannelZapier:
tok, err := auth.LoadXVeraeToken()
if err != nil {
if jwt, jerr := auth.LoadVeraeJWT(); jerr == nil {
lj.Printf("auth: no xVerae- token, using verae channel (auth.properties)")
client.Channel, client.VeraeToken = auth.ChannelVerae, jwt
return client, auth.ChannelVerae, nil
}
return nil, ch, fmt.Errorf("%w (%v)", errNoKey, err)
}
client.XVeraeToken = tok
default:
tok, err := auth.LoadVeraeJWT()
if err != nil {
return nil, ch, fmt.Errorf("verae channel: %v", err)
}
client.VeraeToken = tok
}
return client, ch, nil
}
// stampFile stamps one PDF with the current key (no holding / prompting).
func stampFile(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
client, ch, err := clientForChannel(auth.PreferredChannel(), lj)
if err != nil {
return job.Result{}, err
}
client.HTTPDo = loggingDo(lj)
start := time.Now()
res, err := job.Run(job.Options{SourcePDF: pdf, OutDir: outDir, DocumentName: safeStem(title) + ".pdf", Channel: ch, Client: client})
if err != nil {
return res, err
}
lj.Printf("stamp: OK in %s receipt=%s sha256=%s\n original=%q\n cert=%q\n json=%q",
time.Since(start).Round(time.Millisecond), res.ReceiptID, res.SHA256, res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON)
return res, nil
}
func authReason(err error) string {
if veraeapi.IsAuthError(err) {
return "Verae did not accept your signing key (it may have expired or been revoked). Please sign in again."
}
return "No Verae signing key is set up for this user yet. Please sign in or create an account."
}

View file

@ -3,7 +3,7 @@
Status: **proposal + client implementation + reference server pieces**
(`internal/installapi` handler, `internal/mailer` SMTP mailer; §7). The client side is implemented in
`internal/auth/portal_install.go` and driven by the Windows installer
(`platform/windows/installer-debug.nsi` → `verae-print installer-account`).
(`platform/windows/installer-windows.nsi`, macOS `verae-print mac-key-setup` from the pkg postinstall → `verae-print installer-account`).
The server side does **not exist yet**. The portal backend source was not
available, so nothing was changed or deployed on the server. Until the server
implements it, the client automatically falls back to the endpoints the portal

View file

@ -15,7 +15,7 @@ import (
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
)
const HelperVersion = "0.1.2"
const HelperVersion = "0.3.1"
// Result paths written for one print job.
type Result struct {
@ -111,14 +111,17 @@ func Run(opt Options) (Result, error) {
if origBase == "" || origBase == "." {
origBase = stem + ".pdf"
}
origPath := filepath.Join(outDir, origBase)
srcBytes, err := os.ReadFile(opt.SourcePDF)
if err != nil {
return Result{}, err
}
if err := os.WriteFile(origPath, srcBytes, 0o600); err != nil {
// Reserve a name that collides with nothing already there: the original,
// its .verae.json sidecar. Never overwrite; add -2, -3, ... instead.
origPath, stem, err := reserveOriginal(outDir, origBase, stem, srcBytes)
if err != nil {
return Result{}, err
}
origBase = filepath.Base(origPath)
digest, err := hash.FileSHA256(origPath)
if err != nil {
@ -136,7 +139,7 @@ func Run(opt Options) (Result, error) {
}
safeID := SanitizeReceiptID(rec.ReceiptID)
certPath := filepath.Join(outDir, fmt.Sprintf("%s-verae-timestamp-%s.pdf", stem, safeID))
certStem := fmt.Sprintf("%s-verae-timestamp-%s", stem, safeID)
// Always prefer the branded Registration Document (digests-only wording,
// no mcp.verae.ai). Edge attachPdf is optional and may still say MCP.
@ -154,7 +157,8 @@ func Run(opt Options) (Result, error) {
return Result{}, err
}
_ = rec.PDF // upstream PDF ignored in favor of branded local certificate
if err := os.WriteFile(certPath, pdfBytes, 0o600); err != nil {
certPath, err := writeExclusive(outDir, certStem, ".pdf", pdfBytes)
if err != nil {
return Result{}, err
}
@ -173,12 +177,12 @@ func Run(opt Options) (Result, error) {
"status": "ok",
"saved_at": time.Now().UTC().Format(time.RFC3339),
}
jsonPath := filepath.Join(outDir, stem+".verae.json")
jb, err := json.MarshalIndent(sidecar, "", " ")
if err != nil {
return Result{}, err
}
if err := os.WriteFile(jsonPath, jb, 0o600); err != nil {
jsonPath, err := writeExclusive(outDir, stem, ".verae.json", jb)
if err != nil {
return Result{}, err
}
@ -191,3 +195,72 @@ func Run(opt Options) (Result, error) {
Channel: opt.Channel,
}, nil
}
const maxSuffix = 10000
func suffixed(base string, n int) string {
if n <= 1 {
return base
}
return fmt.Sprintf("%s-%d", base, n)
}
func exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
// createNew writes data to path only if path does not exist yet (O_EXCL).
func createNew(path string, data []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(path)
return err
}
return f.Close()
}
// writeExclusive writes dir/<stem><ext>, or <stem>-2<ext>, -3 ... if taken.
func writeExclusive(dir, stem, ext string, data []byte) (string, error) {
for n := 1; n < maxSuffix; n++ {
p := filepath.Join(dir, suffixed(stem, n)+ext)
err := createNew(p, data)
if err == nil {
return p, nil
}
if !os.IsExist(err) {
return "", err
}
}
return "", fmt.Errorf("no free file name for %s%s in %s", stem, ext, dir)
}
// reserveOriginal saves the original under origBase (or origBase-N) such that
// neither it nor the matching <stem>.verae.json already exists. Returns the
// saved path and the stem to use for the certificate and sidecar.
func reserveOriginal(dir, origBase, stem string, data []byte) (string, string, error) {
ext := filepath.Ext(origBase)
base := strings.TrimSuffix(origBase, ext)
if base == "" {
base = stem
}
for n := 1; n < maxSuffix; n++ {
st := suffixed(stem, n)
if exists(filepath.Join(dir, st+".verae.json")) {
continue
}
p := filepath.Join(dir, suffixed(base, n)+ext)
err := createNew(p, data)
if err == nil {
return p, st, nil
}
if !os.IsExist(err) {
return "", "", err
}
}
return "", "", fmt.Errorf("no free file name for %s in %s", origBase, dir)
}

View file

@ -103,3 +103,46 @@ func TestRunVeraeRendersLocalCert(t *testing.T) {
t.Fatal("not a pdf")
}
}
func TestRunNeverOverwritesSameName(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out")
n := 0
client := &veraeapi.Client{
Channel: auth.ChannelZapier,
ZapierBase: "https://zap.test",
XVeraeToken: "xVerae-x",
HTTPDo: func(req *http.Request) (*http.Response, error) {
n++
// same receipt id every time, to also exercise cert-name collisions
body := `{"receipt_id":"bafySame"}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
},
}
var results []Result
for i, content := range []string{"%PDF-1.4 first", "%PDF-1.4 second", "%PDF-1.4 third"} {
src := filepath.Join(dir, "src"+string(rune('a'+i))+".pdf")
_ = os.WriteFile(src, []byte(content), 0o600)
res, err := Run(Options{SourcePDF: src, OutDir: out, DocumentName: "Printed 2026-10-03 192901.pdf", Channel: auth.ChannelZapier, Client: client})
if err != nil {
t.Fatal(err)
}
results = append(results, res)
}
wantOrig := []string{"Printed 2026-10-03 192901.pdf", "Printed 2026-10-03 192901-2.pdf", "Printed 2026-10-03 192901-3.pdf"}
wantJSON := []string{"Printed-2026-10-03-192901.verae.json", "Printed-2026-10-03-192901-2.verae.json", "Printed-2026-10-03-192901-3.verae.json"}
wantCert := []string{"Printed-2026-10-03-192901-verae-timestamp-bafySame.pdf", "Printed-2026-10-03-192901-2-verae-timestamp-bafySame.pdf", "Printed-2026-10-03-192901-3-verae-timestamp-bafySame.pdf"}
for i, r := range results {
if filepath.Base(r.OriginalPDF) != wantOrig[i] || filepath.Base(r.ReceiptJSON) != wantJSON[i] || filepath.Base(r.CertificatePDF) != wantCert[i] {
t.Fatalf("job %d: %s | %s | %s", i, filepath.Base(r.OriginalPDF), filepath.Base(r.ReceiptJSON), filepath.Base(r.CertificatePDF))
}
}
if b, _ := os.ReadFile(results[0].OriginalPDF); string(b) != "%PDF-1.4 first" {
t.Fatalf("first original overwritten: %q", b)
}
// a pre-existing cert with the target name is never overwritten
p, err := writeExclusive(out, "Printed-2026-10-03-192901-verae-timestamp-bafySame", ".pdf", []byte("x"))
if err != nil || filepath.Base(p) != "Printed-2026-10-03-192901-verae-timestamp-bafySame-2.pdf" {
t.Fatal(p, err)
}
}

View file

@ -0,0 +1,128 @@
// Package settings holds user preferences for the print helper.
//
// Lookup order (first hit wins per field):
// 1. env VERAE_OPEN_ON_COMPLETE
// 2. user file ~/.verae/config.json (written by the installers for the console/logged-in user)
// 3. machine file %ProgramData%\Verae\PrintStamp\config.json (Windows)
// or /Library/Application Support/Verae/PrintStamp/config.json (macOS)
// 4. defaults (OpenOnComplete = "folder", LogLevel = info)
package settings
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// OpenOnComplete values.
const (
OpenFolder = "folder" // reveal the certificate PDF in Explorer/Finder
OpenPDF = "pdf" // open the certificate PDF in the default viewer
OpenNone = "none"
)
// Settings is the on-disk JSON shape.
type Settings struct {
OpenOnComplete string `json:"openOnComplete,omitempty"`
// LogLevel "debug" enables verbose helper logs (same as VERAE_LOG_LEVEL=debug).
LogLevel string `json:"logLevel,omitempty"`
}
// Default settings.
var Default = Settings{OpenOnComplete: OpenFolder}
// NormalizeOpen maps user input to a valid value ("" if invalid).
func NormalizeOpen(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "folder", "reveal", "finder", "explorer", "show":
return OpenFolder
case "pdf", "open", "file":
return OpenPDF
case "none", "off", "no", "false", "0":
return OpenNone
}
return ""
}
// UserPath is ~/.verae/config.json for the given home ("" = current user).
func UserPath(home string) string {
if home == "" {
home, _ = os.UserHomeDir()
}
return filepath.Join(home, ".verae", "config.json")
}
// MachinePath is the all-users default file.
func MachinePath() string {
if runtime.GOOS == "windows" {
pd := os.Getenv("ProgramData")
if pd == "" {
pd = `C:\ProgramData`
}
return filepath.Join(pd, "Verae", "PrintStamp", "config.json")
}
return "/Library/Application Support/Verae/PrintStamp/config.json"
}
func readFile(p string) (Settings, bool) {
var s Settings
b, err := os.ReadFile(p)
if err != nil {
return s, false
}
b = []byte(strings.TrimPrefix(string(b), "\uFEFF"))
if json.Unmarshal(b, &s) != nil {
return Settings{}, false
}
return s, true
}
// Load resolves the effective settings and a short description of where each came from.
func Load() (Settings, string) {
eff := Default
src := "default"
if s, ok := readFile(MachinePath()); ok {
if v := NormalizeOpen(s.OpenOnComplete); v != "" {
eff.OpenOnComplete, src = v, MachinePath()
}
}
if s, ok := readFile(UserPath("")); ok {
if v := NormalizeOpen(s.OpenOnComplete); v != "" {
eff.OpenOnComplete, src = v, UserPath("")
}
}
for _, p := range []string{MachinePath(), UserPath("")} {
if s, ok := readFile(p); ok && s.LogLevel != "" {
eff.LogLevel = strings.ToLower(strings.TrimSpace(s.LogLevel))
}
}
if v := NormalizeOpen(os.Getenv("VERAE_OPEN_ON_COMPLETE")); v != "" {
eff.OpenOnComplete, src = v, "env VERAE_OPEN_ON_COMPLETE"
}
return eff, src
}
// SetOpenOnComplete updates (merges) the JSON file at path, preserving unknown keys.
func SetOpenOnComplete(path, value string) error {
v := NormalizeOpen(value)
if v == "" {
return fmt.Errorf("openOnComplete must be folder, pdf or none (got %q)", value)
}
m := map[string]any{}
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal([]byte(strings.TrimPrefix(string(b), "\uFEFF")), &m)
}
m["openOnComplete"] = v
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, _ := json.MarshalIndent(m, "", " ")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}

View file

@ -0,0 +1,48 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestLoadOrderAndMerge(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("VERAE_OPEN_ON_COMPLETE", "")
if s, src := Load(); s.OpenOnComplete != OpenFolder || (src != "default" && src != MachinePath()) {
t.Fatalf("default: %v %s", s, src)
}
p := UserPath("")
_ = os.MkdirAll(filepath.Dir(p), 0o700)
_ = os.WriteFile(p, []byte(`{"other":1}`), 0o600)
if err := SetOpenOnComplete(p, "PDF"); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(p)
if s, _ := Load(); s.OpenOnComplete != OpenPDF {
t.Fatalf("user: %s", b)
}
if string(b) == "" || !contains(string(b), `"other": 1`) {
t.Fatalf("unknown key lost: %s", b)
}
t.Setenv("VERAE_OPEN_ON_COMPLETE", "none")
if s, src := Load(); s.OpenOnComplete != OpenNone || src != "env VERAE_OPEN_ON_COMPLETE" {
t.Fatal(s, src)
}
if SetOpenOnComplete(p, "bogus") == nil {
t.Fatal("want error")
}
}
func contains(s, sub string) bool { return indexOf(s, sub) >= 0 }
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -105,7 +106,7 @@ func (c *Client) stampVerae(digest string) (Receipt, error) {
_ = json.Unmarshal(raw, &parsed)
if res.StatusCode >= 300 {
return Receipt{}, fmt.Errorf("verae stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
return Receipt{}, &HTTPError{Service: "verae stamp", Status: res.StatusCode, Body: truncate(raw, 400)}
}
cid, created, merkle := extractVeraeReceipt(parsed)
@ -311,7 +312,7 @@ func (c *Client) stampZapier(digest, documentName string) (Receipt, error) {
return Receipt{}, err
}
if res.StatusCode >= 300 {
return Receipt{}, fmt.Errorf("zapier stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
return Receipt{}, &HTTPError{Service: "zapier stamp", Status: res.StatusCode, Body: truncate(raw, 400)}
}
parsed := map[string]any{}
_ = json.Unmarshal(raw, &parsed)
@ -377,3 +378,18 @@ func truncate(b []byte, n int) string {
func decodeBase64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
// HTTPError is a non-2xx answer from a stamp endpoint.
type HTTPError struct {
Service string
Status int
Body string
}
func (e *HTTPError) Error() string { return fmt.Sprintf("%s HTTP %d: %s", e.Service, e.Status, e.Body) }
// IsAuthError reports whether err is an HTTP 401/403 (invalid, revoked or expired key).
func IsAuthError(err error) bool {
var he *HTTPError
return errors.As(err, &he) && (he.Status == 401 || he.Status == 403)
}

Some files were not shown because too many files have changed in this diff Show more