Skip to content
Open

Dev #110

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

## [3.18.0](https://github.com/DataSeer/snapshot-api/compare/v3.17.1...v3.18.0) (2026-09-01)


### Features

* **reports:** generate missing report URLs on demand ([7816df8](https://github.com/DataSeer/snapshot-api/commits/7816df81e6c156a5d9779cbb7e314562df5c5ad6))


### Bug Fixes

* **rate-limit:** reject every request when a user max is 0 ([9c8aade](https://github.com/DataSeer/snapshot-api/commits/9c8aade851a7916305c684204b23681d69f0aedd))


### Documentation

* **deploy:** document the systemd deployment and restart policy ([cc5e02b](https://github.com/DataSeer/snapshot-api/commits/cc5e02bf2746d780bfa20ba8a9413eba43439e85))
* refresh API documentation and guard internal-only files ([5e11e95](https://github.com/DataSeer/snapshot-api/commits/5e11e95413933664014656ea5c86eceab5bf8a8d))

### [3.17.1](https://github.com/DataSeer/snapshot-api/compare/v3.17.0...v3.17.1) (2026-07-14)


Expand Down
139 changes: 139 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,75 @@ npm run manage-users -- update-genshare snapshot-mails '{"authorizedVersions": [
npm run manage-users -- remove snapshot-mails
```

#### Rate limit semantics

Rate limiting is per user, configured in `conf/users.json` under `rateLimit`:

| Setting | Meaning |
|---|---|
| `max: <n>` | At most `n` requests per window. Request `n + 1` gets **429**. |
| `max: 0` | **Every request is rejected with 429.** Use this to suspend an account without deleting it. |
| `max` absent | Falls back to the default of 100 requests per window. |
| `windowMs: 0` | **Rate limiting disabled** — unlimited requests. Takes precedence over `max: 0`. |
| `message` | Body returned with the 429. Optional; a default message is used otherwise. |

```bash
# Suspend an account (every call answers 429)
npm run manage-users -- update-limit snapshot-mails '{"max": 0}'

# Restore it
npm run manage-users -- update-limit snapshot-mails '{"max": 100}'
```

Two implementation details worth knowing before editing `src/utils/rateLimiter.js`:

- `max: 0` is handled by a guard that runs **before** `express-rate-limit`. The installed version (v5)
skips its own check when `max` is falsy (`if (max && current > max)`), so without that guard a max
of 0 would let every request through instead of blocking it.
- `windowMs` is only read per user to detect the `0` (disabled) case. The window length itself is
fixed at 15 minutes for everyone — a user configured with, say, `windowMs: 60000` still gets a
15-minute window.

### Missing Report URLs (recovery patch)

When snapshot-reports is unavailable, `createReport` fails and the error is deliberately swallowed —
a report failure must never fail the request itself. Those requests end up with no report URL
(`requests.report_data` is NULL, no `report_link` in the archived response). This script generates
the missing URLs after the fact.

It is a **patch tool, not part of the normal flow**. It only ever adds a missing report URL — it
never replaces an existing one and never re-runs the analysis. Whether a given request deserves a
report (cached? demo? stale?) is the operator's judgement: start with a dry run and read the list.

```bash
# Dry run (default — nothing is written): what is missing a report URL?
npm run reports:missing -- --all
npm run reports:missing -- --user acme
npm run reports:missing -- --request 16d2bb78c7901c527b8e1b43b8f38b5c

# Apply
npm run reports:generate -- --request 16d2bb78c7901c527b8e1b43b8f38b5c
npm run reports:generate -- --user acme --limit 50
npm run reports:generate -- --all

# Override the report kind for users with no reports.defaultVersion
npm run reports:generate -- --user acme --report-kind v3

# Machine-readable output
npm run reports:missing -- --all --json
```

For each patched request three artifacts are updated, mirroring the normal processing path:
`requests.report_data` (SQLite), `<user>/<request>/report/report.json` (S3), and `report_link`
injected into the archived `<user>/<request>/response.json` (S3).

Requests are skipped — never failed — when they already have a report URL, when the owning user has
no `reports.defaultVersion` (pass `--report-kind` to override), when the user is absent from the
users configuration, or when `genshare/response.json` is missing from S3 (there is nothing to build a
report from). A skip of the last kind prints an S3 `NoSuchKey` line from the shared S3 helper before
the skip is reported — that is expected noise, not a failure. The script exits non-zero only when at
least one request genuinely failed.

### GenShare Version Management

```bash
Expand Down Expand Up @@ -1460,6 +1529,76 @@ docker run -d \
snapshot-api:latest
```

### Deployment on EC2 (systemd)

On the `snapshot-dev` and `snapshot-prod` instances the container is **managed by systemd**, not by
the `docker run` snippet above and not by any compose file. The unit is
`/etc/systemd/system/snapshot-api.service`; it runs the ECR image in the foreground
(`docker run --rm --name snapshot-api-instance --init --network host …`) with `Type=simple`, so
systemd supervises the `docker run` client rather than the container itself.

Manage it through systemd, never through Docker directly:

```bash
sudo systemctl status snapshot-api
sudo systemctl restart snapshot-api
sudo systemctl stop snapshot-api # NOT `docker stop snapshot-api-instance`
journalctl -u snapshot-api -b --no-pager # container stdout/stderr for this boot
```

`docker stop snapshot-api-instance` is undone by systemd within seconds (see the restart policy
below), and because the unit uses `--rm` there is no exited container left to inspect — the journal
is the only post-mortem source.

#### Restart policy

The unit files ship with no `Restart=` directive, which means systemd defaults to `Restart=no`: a
container that exits for any reason — crash, forced instance stop, or losing the race against
`dockerd` at boot — stays down permanently. In August 2026 this took snapshot-reports offline long
enough that report URLs stopped being created.

The policy is supplied by a drop-in rather than by editing the unit, so the unit file is untouched
and rollback is deleting one file:

```ini
# /etc/systemd/system/snapshot-api.service.d/10-restart.conf
[Unit]
StartLimitIntervalSec=0

[Service]
Restart=always
RestartSec=5
```

```bash
sudo mkdir -p /etc/systemd/system/snapshot-api.service.d
sudo tee /etc/systemd/system/snapshot-api.service.d/10-restart.conf >/dev/null <<'EOF'
[Unit]
StartLimitIntervalSec=0

[Service]
Restart=always
RestartSec=5
EOF
sudo systemctl daemon-reload

# Verify
systemctl show snapshot-api -p Restart -p RestartUSec -p StartLimitIntervalUSec
# expect: Restart=always RestartUSec=5s StartLimitIntervalUSec=0

# Roll back
sudo rm -rf /etc/systemd/system/snapshot-api.service.d && sudo systemctl daemon-reload
```

`daemon-reload` only re-reads configuration — it does not stop, start or restart the running
container, so this is safe to apply at any time. `StartLimitIntervalSec=0` is required, not
cosmetic: without it systemd's default limit of 5 starts in 10 s puts a fast-failing unit into
`failed` permanently, which is the exact failure the policy is meant to prevent.

Known limitation: if an unclean shutdown leaves a container holding the `--name`, restarts loop on a
name conflict. Adding `ExecStartPre=-/usr/bin/docker rm -f snapshot-api-instance` to the drop-in
covers it.

### Environment Variables for Production

For production deployments, make sure to set the following environment variables:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nodejs-rest-api",
"version": "3.17.1",
"version": "3.18.0",
"description": "nodejs REST API to interact with GenShare",
"main": "src/server.js",
"engines": {
Expand Down Expand Up @@ -33,6 +33,8 @@
"db:check": "node scripts/maintenance/initDB.js check",
"db:check-notification": "node scripts/maintenance/initDB.js check-scholarone-notifications",
"db:list-notifications": "node scripts/maintenance/initDB.js list-scholarone-notifications",
"reports:missing": "node scripts/maintenance/generate_missing_reports.js",
"reports:generate": "node scripts/maintenance/generate_missing_reports.js --apply",
"s3:audit": "node scripts/maintenance/refresh_s3_data.js",
"s3:refresh": "node scripts/maintenance/refresh_s3_data.js --apply",
"s3:refresh:rehash": "node scripts/maintenance/refresh_s3_data.js --apply --rehash",
Expand Down
182 changes: 182 additions & 0 deletions scripts/maintenance/generate_missing_reports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// File: scripts/maintenance/generate_missing_reports.js
//
// Generate report URLs for requests that never got one — typically after a
// snapshot-reports outage, during which createReport failed and the error was
// swallowed so the request itself would not fail.
//
// This is a PATCH tool, not part of the normal flow. It only ever adds a
// missing report URL; it never replaces an existing one and never re-runs the
// analysis. Deciding whether a given request should be patched at all (cached?
// demo? stale?) is the operator's job — start with --dry-run and read the list.
//
// Scope (exactly one required):
// --request <32-hex> a single request
// --user <userId> every request of one user
// --all every request, all users
//
// Options:
// --apply actually write (default is DRY-RUN, read-only)
// --limit <n> stop after n candidate requests
// --report-kind <kind> override the owning user's reports.defaultVersion
// --json print the raw result object instead of a table
// --help
//
// Examples:
// node scripts/maintenance/generate_missing_reports.js --all
// node scripts/maintenance/generate_missing_reports.js --user acme --apply
// node scripts/maintenance/generate_missing_reports.js --request 16d2… --apply

const { backfillReportUrls, Outcome } = require('../../src/utils/reportBackfiller');

const USAGE = `
Usage: node scripts/maintenance/generate_missing_reports.js <scope> [options]

Scope (exactly one):
--request <32-hex> Patch a single request
--user <userId> Patch every request owned by that user
--all Patch every request, all users

Options:
--apply Write the changes (default: dry run, nothing is written)
--limit <n> Process at most n candidate requests
--report-kind <kind> Use this report kind instead of the user's default
--json Print the raw JSON result
--help Show this message
`;

const parseArgs = (argv) => {
const args = {
requestId: null,
userId: null,
all: false,
dryRun: true,
limit: null,
reportKind: null,
json: false,
help: false
};

for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case '--request':
args.requestId = argv[++i];
break;
case '--user':
args.userId = argv[++i];
break;
case '--all':
args.all = true;
break;
case '--apply':
args.dryRun = false;
break;
case '--limit':
args.limit = parseInt(argv[++i], 10);
break;
case '--report-kind':
args.reportKind = argv[++i];
break;
case '--json':
args.json = true;
break;
case '--help':
case '-h':
args.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}

return args;
};

const OUTCOME_LABEL = {
[Outcome.CREATED]: 'CREATED',
[Outcome.WOULD_CREATE]: 'WOULD CREATE',
[Outcome.SKIPPED_HAS_REPORT]: 'skip (has report)',
[Outcome.SKIPPED_NO_REPORT_KIND]: 'skip (no report kind)',
[Outcome.SKIPPED_UNKNOWN_USER]: 'skip (unknown user)',
[Outcome.SKIPPED_NO_GENSHARE_RESPONSE]: 'skip (no genshare response)',
[Outcome.FAILED]: 'FAILED'
};

const printReport = (summary) => {
const mode = summary.dry_run ? 'DRY RUN — nothing was written' : 'APPLIED';
console.log(`\nScope: ${summary.scope} Mode: ${mode}`);
console.log(`Candidates scanned: ${summary.scanned}\n`);

if (summary.results.length === 0) {
console.log('No request is missing a report URL for this scope.\n');
return;
}

for (const result of summary.results) {
const label = OUTCOME_LABEL[result.outcome] || result.outcome;
console.log(`${result.request_id} ${result.user_id.padEnd(20)} ${label}`);
if (result.report_url) console.log(` url: ${result.report_url}`);
if (result.reason) console.log(` reason: ${result.reason}`);
}

console.log(
`\nCreated: ${summary.created} Would create: ${summary.would_create} ` +
`Skipped: ${summary.skipped} Failed: ${summary.failed}`
);

if (summary.dry_run && summary.would_create > 0) {
console.log('\nRe-run with --apply to create these report URLs.');
}
console.log('');
};

const main = async () => {
let args;
try {
args = parseArgs(process.argv);
} catch (error) {
console.error(`Error: ${error.message}`);
console.error(USAGE);
process.exitCode = 1;
return;
}

if (args.help) {
console.log(USAGE);
return;
}

try {
const summary = await backfillReportUrls({
requestId: args.requestId,
userId: args.userId,
all: args.all,
dryRun: args.dryRun,
limit: args.limit,
reportKind: args.reportKind
});

if (args.json) {
console.log(JSON.stringify(summary, null, 2));
} else {
printReport(summary);
}

// Non-zero when at least one request could not be patched, so a scripted
// run after an outage fails loudly instead of looking clean.
if (summary.failed > 0) process.exitCode = 1;
} catch (error) {
console.error(`Error: ${error.message}`);
console.error(USAGE);
process.exitCode = 1;
}
};

// Several managers pulled in above call watchConfig(), which installs fs.watch
// handles that keep the event loop alive forever. That is what the server
// wants; a one-shot script must exit instead. Force it once main() has settled,
// preserving whatever exit code main() decided on.
main().finally(() => {
// eslint-disable-next-line n/no-process-exit
process.exit(process.exitCode || 0);
});
Loading
Loading