# unionops-push

A small program that sends one export file to a UnionOps Data source on a schedule, then tells you what became of it. It is for the person who looks after a local's computers, or an officer on a Windows PC who is comfortable with Task Scheduler.

Version 1.0. Needs [Node.js](https://nodejs.org/) 20 or newer (the current LTS installer, all defaults). Nothing else: no packages to install.

## What it does

1. Reads one file you name (a `.csv` or `.xlsx` export from your membership system or payroll).
2. Sends it, over https, to the push address of one source in UnionOps Data.
3. Waits, up to 30 minutes, to hear whether the file was published or is waiting for an officer.
4. Prints one plain line, such as `published 1,204 rows` or `waiting for an officer: 37 rows held (codes: mode_review)`, and exits with a code your scheduler can read.

A pushed file is never published just because it arrived. The source's own rule, set by an officer, decides whether it publishes or waits for review.

## What it never does

- It reads only the settings file, the token (from a variable or a file), and the export file you named. It does not look at anything else on the computer.
- It does not receive member data back. UnionOps answers with counts, a status, and short codes. No names, no numbers, no rows.
- It does not take the token on the command line. The token would be kept in shell history and in Task Scheduler. If you try `--token`, it refuses.
- It does not print the token, write it to the log, or send it anywhere but the address you gave. If the address redirects, the token is not sent on.
- It does not change or delete your export, except to move it to the archive folder if you ask for that.
- It sends nothing to anyone about this computer or how you use it.

## What you need from an officer

An officer with access to UnionOps Data for your local sets up a **source** (the same export arriving each time) and gives you two things:

- **The push address.** It is shown on the source in the Sources panel and looks like `https://…/api/data/sources/<id>/push/`. Copy it exactly.
- **A push token.** The officer chooses **Create push token** on the source and confirms with an authenticator code. The token is shown once. If it is lost, they choose **Rotate push token** and you get a new one.

Three things on the officer's side must be in place, or UnionOps will refuse the file (see Troubleshooting): your union has turned on signed push, the source has a statement of where its member lists come from that is still in date, and the officer who set up the source still has access to the local's Data.

Files are limited to 12 MiB by default (the host can change it), 50,000 data rows, and 200 columns. A source accepts 12 pushes an hour, and your union has a daily size limit, so run once or twice a day, not every few minutes.

## Download

Save each file from your UnionOps site. On unionops.org:

- [`unionops-push.mjs`](https://unionops.org/downloads/unionops-push/unionops-push.mjs), the program
- [`unionops-push.ps1`](https://unionops.org/downloads/unionops-push/unionops-push.ps1), which runs it on Windows
- [`register-task.ps1`](https://unionops.org/downloads/unionops-push/register-task.ps1), which sets up the scheduled task
- [`unionops-push.config.example.json`](https://unionops.org/downloads/unionops-push/unionops-push.config.example.json), the settings to copy
- [`README.md`](https://unionops.org/downloads/unionops-push/README.md), this page

If your union uses another UnionOps site, put that site's address in front of `/downloads/unionops-push/` instead. If the browser shows a file as text, use **Save as**.

## Set it up

1. Make a folder, for example `C:\UnionOps\push`, and put the downloaded files in it: `unionops-push.mjs`, `unionops-push.ps1`, `register-task.ps1`, and `unionops-push.config.example.json`.
2. Copy `unionops-push.config.example.json` to `unionops-push.config.json` and fill it in.

```json
{
  "pushUrl": "https://unionops.example/api/data/sources/PASTE-THE-SOURCE-ID/push/",
  "file": "C:/Exports/members.csv",
  "tokenEnv": "UNIONOPS_PUSH_TOKEN",
  "wait": true,
  "timeoutMinutes": 30,
  "archiveDir": "C:/Exports/sent",
  "logFile": "C:/Exports/unionops-push.log"
}
```

| Setting | Meaning |
|---|---|
| `pushUrl` | The push address. Must be https (http is allowed only for `localhost`, for testing). A missing trailing slash is added. |
| `file` | One file. No wildcards. If your export has a changing name, have the export job write to a fixed name, or a copy step rename it. |
| `tokenEnv` | The **name** of the environment variable that holds the token. Default `UNIONOPS_PUSH_TOKEN`. |
| `tokenFile` | A file that holds only the token. Use this instead of `tokenEnv` if you prefer. |
| `wait` | `true` (default) waits to hear what became of the file. `false` stops once UnionOps has it. |
| `timeoutMinutes` | How long to wait. Default 30. If the time runs out, the file is still accepted. |
| `archiveDir` | Optional. Once UnionOps has the file, it is moved here with the date and time in its name. |
| `logFile` | Optional. One line is added per run. |
| `requirePublished` | Optional. `true` is the same as `--require-published` (below). |

Relative paths in the settings file are relative to the settings file's own folder. In JSON, write Windows paths with forward slashes (`C:/Exports/members.csv`) or doubled backslashes (`C:\\Exports\\members.csv`).

The token never goes in this file. The program refuses a `token` setting.

### Store the token

Pick one. Neither is encrypted, so treat the token like a password: whoever has it can send files into your source until an officer rotates it.

**A token file (better).** Save the token, and nothing else, in a text file such as `C:\UnionOps\push\token.txt` (Notepad is fine). Then limit who can read it to you:

```
icacls "C:\UnionOps\push\token.txt" /inheritance:r /grant:r "%USERNAME%:R"
```

Name it in the settings: `"tokenFile": "token.txt"` (and remove `tokenEnv`).

**An environment variable.** In a new Command Prompt, as the Windows user who will run the task:

```
setx UNIONOPS_PUSH_TOKEN "paste-the-token-here"
```

This keeps the token in that user's settings. Open a new window afterwards; the window you typed it in does not see it. The command, with the token in it, stays in that window's history, so a token file is the cleaner choice.

### Try it by hand

```
node unionops-push.mjs --config unionops-push.config.json --verbose
```

`--verbose` shows each request and answer. The token is hidden in that output. Or use the wrapper, which checks Node is installed first:

```
powershell -NoProfile -ExecutionPolicy Bypass -File .\unionops-push.ps1
```

## Run it on a schedule

### Windows Task Scheduler

The easy way is `register-task.ps1`. In PowerShell, in the folder:

```
Unblock-File .\*.ps1
.\register-task.ps1 -Schedule Daily -Time 02:30
```

Weekly instead: `.\register-task.ps1 -Schedule Weekly -DayOfWeek Friday -Time 18:00`. Add `-WhatIf` to see what it would do without doing it, and `-Remove` to delete the task.

If PowerShell says running scripts is disabled, run it as `powershell -ExecutionPolicy Bypass -File .\register-task.ps1 -Schedule Daily -Time 02:30`.

The task runs as you, only while you are signed in, and starts as soon as it can if the computer was off at the time (`StartWhenAvailable`). It does not ask for or store a password. If the file must be sent when nobody is signed in, set the task up in Task Scheduler yourself with an account your IT policy allows for that, and store the token for that account.

To do it by hand in Task Scheduler: create a task, set the action to **Start a program**, program `powershell.exe`, arguments `-NoProfile -ExecutionPolicy Bypass -File "C:\UnionOps\push\unionops-push.ps1"`, and **Start in** `C:\UnionOps\push`. Task Scheduler's **Last Run Result** shows the exit code below.

### cron (Linux or macOS)

Keep the token in a file only you can read (`chmod 600 token.txt`, and `"tokenFile": "token.txt"` in the settings), then:

```
MAILTO=it@your-local.example
30 2 * * * /usr/bin/node /opt/unionops-push/unionops-push.mjs --config /opt/unionops-push/config.json >> /var/log/unionops-push.out 2>&1
```

Do not put the token in the crontab; anyone who can run `crontab -l` could read it.

## Exit codes

| Code | Meaning | What to do |
|---|---|---|
| 0 | Published, or accepted and waiting for an officer to review. Also: UnionOps already had this exact file (an export that has not changed). | Nothing. |
| 2 | Setup problem: bad settings, no token, the file is missing or empty, the push address redirected, or Node.js is missing. UnionOps did not take the file. | Read the message; fix the settings or the export. |
| 3 | The file is waiting for an officer. Only with `--require-published`. | Tell an officer there is a file to review. |
| 4 | UnionOps refused: token, source statement, file problem, rate limit, or a run that failed because of the file. Also: UnionOps took the file but would not say what became of it (the status check was refused or redirected). | See Troubleshooting. For a status check, look at the source in UnionOps Data. |
| 5 | UnionOps or the network failed, after three tries. | Usually passes by itself. The next run tries again. |
| 6 | The file was accepted but was still being read when the wait ran out. | Look at the run in UnionOps Data. Raise `timeoutMinutes` if it keeps happening. |
| 1 | An unexpected error in this program. | Run with `--verbose` and ask for help. |

By default a file that is waiting for an officer is a success (0), because that is how a source in review mode normally works. Add `--require-published` (or `"requirePublished": true`) if you want the task to show a failure until an officer publishes, so someone notices a file sitting unreviewed. Runs an officer cancelled or reversed count the same way.

### What the line says

| Line | Meaning |
|---|---|
| `published 1,204 rows` | In the local's data. |
| `waiting for an officer: 37 rows held (codes: …)` | The file arrived. An officer reviews it in UnionOps Data. The codes say why. |
| `published 1,167 rows; waiting for an officer: 37 rows held` | Part published, part held. |
| `failed (code: file_refused). Nothing was published` | The file could not be read as a roster. Check the export. |

## The log

If you set `logFile`, one line is added per run:

```
{"time":"2026-10-16T07:07:34.963Z","file":"members.csv","bytes":34,"runId":"…","status":"published","rows":{"total":2,"accepted":2,"held":0,"rejected":0},"outcome":"published","exit":0}
```

It holds the time, the file's name and size, the run, the final status, counts, and codes. Never the token and never anything from inside the file. The log grows by one short line a run; rotate it yearly.

## Rotating the token

Rotate when someone who knew the token leaves, or the token may have been seen. An officer chooses **Rotate push token** on the source; the old token stops working at once. Replace the value in the environment variable or the token file. Until you do, runs exit with 4.

## Troubleshooting

| What you see | Why, and what to do |
|---|---|
| `refused the token` (HTTP 401) | UnionOps answers 401 in every one of these cases and does not say which, so this program cannot either: the token is wrong or was replaced, signed push is turned off for the host or the union, the source was archived or has no token, or the officer who set up the source no longer has access. Ask your officer. |
| `source_statement_missing` (409) | The source has no statement of where its member lists come from. An officer confirms it on the source in UnionOps Data. |
| `source_statement_review_due` (409) | The statement's review date has passed. An officer confirms it again. |
| `already has this exact file` (409, exit 0) | The export has not changed since an earlier push. Normal if the export runs more often than the data changes. If it should have changed, check the export job. If the line adds that an earlier try in this run may be the one UnionOps received, a try timed out after UnionOps took the file: look at the source's latest run. |
| 409 with no code | The source's dataset was removed. Ask your officer. |
| 413 | The file is empty, over the size limit, or your local's storage allowance is full (`beta_storage_quota`). |
| 415 | Only `.csv` and `.xlsx` are accepted. |
| 422 | The file failed the safety check or could not be scanned. Check how it was exported. |
| 400 | The file could not be read as the layout the source expects. The message says why. Compare with an earlier export. |
| 429 | Too many pushes (12 an hour per source), too many status checks (120 an hour per source; the program checks every 10 seconds for the first minute, every 30 seconds until ten minutes, then every minute), or the union's daily size limit. The program waits for short `Retry-After` times, up to three times, and stops when the wait is longer than 15 minutes. The next run tries again. |
| 503 with `A file scanner must be configured` | UnionOps cannot take files yet: the site has no file scanner set up. Trying again will not help. Tell your officer, who tells the site's operator. The program exits 5 after three tries. |
| 5xx, or `could not reach UnionOps` | UnionOps or the network is down or restarting. Three tries are made. If it keeps happening, tell your officer. |
| `redirected somewhere else` | The address is not the one the source shows. Copy `pushUrl` again, exactly, including `https://`. |
| `was accepted, but the address for checking on it redirected` (exit 4) | UnionOps has the file; only the status check went wrong. Look at the source in UnionOps Data. If it keeps happening, tell your officer. |
| `Unexpected text on the command line` (exit 2) | Something other than an option was typed after the program name. It is not repeated in case it was the token. If the token was typed there, ask an officer to rotate it. |
| `No token found` | `tokenEnv` holds the variable's name, such as `UNIONOPS_PUSH_TOKEN`, never the token itself. The variable is not set for the user the task runs as. For `setx`, open a new window; for the scheduled task, make sure it runs as the same user. |
| Task shows 0x2 or 0x4 | That is exit code 2 or 4. Run it by hand with `--verbose`. |

## Updating

Download the files again and replace them. `node unionops-push.mjs --version` shows the version. Your settings file and token are separate and are not touched.
