Skip to content
Merged
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
10 changes: 9 additions & 1 deletion docs/ops/backups.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ file to `storage/backups/<layer>.json` after every run (`state` ok/failed, `at`,
that has never reported shows **Not reporting**, which means it is not installed on this server. The MCP tool
`backup_freshness` reports the same layers to connected assistants.

**Settings → Backups** is the owner's page: one plain status (`BackupHealth`: off, starting, working, needs
attention), the last good backup, the weekly check, and the two-step **Turn on backups** path below. While backups
are off or a layer has failed or gone quiet past its schedule, every super admin sees a red alert on the bell, a
banner on the dashboard and a red entry in the status bar, and `BackupHealthAlertJob` leaves one notification a day.
The rolling snapshot and local set always count; the off-host copy and weekly check count once they have reported;
the mirror and the recovery-kit drill are listed as extra protection and never alarm. After a key is turned on each
layer gets one interval of its schedule to make its first encrypted backup before an older refusal counts.

## Encryption and the recovery kit (#421)

Every backup file is encrypted with [age](https://age-encryption.org) (X25519, ChaCha20-Poly1305: authenticated, so
Expand All @@ -20,7 +28,7 @@ SER8 (container or host) can decrypt a backup, which is the point: a copy of the
mirror is useless without the kit.

- **Install age** in the container (and on any computer that will restore): `apt-get install age`.
- **Create the key**: Settings → Backups → *Create backup key* (super admin with `settings.write`, signed in within the
- **Turn backups on**: Settings → Backups → *Turn on backups* (later: *Replace the backup key*) (super admin with `settings.write`, signed in within the
last 15 minutes, or a password re-entry for password accounts). The server makes the key pair in memory, stores the
public key in `backup_encryption_keys`, and answers with the kit PDF. The private key is never stored, logged or
shown again (`test/integration/backup_keys_test.rb` checks every table and the log after creating one).
Expand Down
2 changes: 1 addition & 1 deletion docs/ops/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Shopify sends `customers/data_request`, `customers/redact`, `shop/redact`, `app/
| Chat, payroll, HR and 1099 columns | database | encrypted at rest since #421; run `CONFIRM=1 bin/rails ops:encrypt_sensitive_fields` once after upgrading ([encryption-at-rest.md](encryption-at-rest.md)) | — |
| Square access token | Settings → env (`SQUARE_ACCESS_TOKEN`) | create a new token in the Square developer dashboard for the same environment, save, run a Square sync | — |
| Google OAuth / Drive | Settings → Google sign-in and Drive sections | regenerate the client secret in Google Cloud, save; reconnect Drive (consent) | Drive refresh token must be re-issued |
| Backup encryption key (age) | public half in `backup_encryption_keys`; private half only in the owner's recovery kit (password manager) | Settings → Backups → Create backup key, save the new kit, type its check code; keep the old kit until its backups age out (~35 days) | — |
| Backup encryption key (age) | public half in `backup_encryption_keys`; private half only in the owner's recovery kit (password manager) | Settings → Backups → Replace the backup key (Make a new recovery kit), save the new kit, type its check code; keep the old kit until its backups age out (~35 days) | — |
| `SENTRY_DSN` | `/etc/rupert.env` | replace, restart | — |

Every rotation: take a full backup first (`systemctl start rupert-backup.service`), note the date in the Activity page or the operator log.
Expand Down
12 changes: 11 additions & 1 deletion docs/time-and-pay/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ to Square.
| Rule | Default | Why |
| --- | --- | --- |
| Rule preset | Federal (FLSA) | The stores' state is not recorded in the repo; the owner picks the state preset (CT, MA, NY, NJ, CA, OR, CO available) and the defaults below update. |
| Meal break | Reminder after 6 h, 30 min, unpaid | Common retail practice and the MA/NY/CT rule; federal law requires none. A meal break under 20 min is treated as paid rest. |
| Meal break | Reminder after 6 h, 30 min, unpaid | Common retail practice and the MA/NY/CT rule; federal law requires none. A punched meal of 20 minutes or less counts as paid time (see "Short breaks are always paid"). |
| Rest break | Every 4 h, 10 min, paid | FLSA: short breaks are paid time. |
| Break reminders | On | Push/in-app nudge at the threshold; never auto-clocks anyone out. |
| Daily overtime | Off | Only CA/AK/NV/CO have it; the CA/CO presets turn it on. |
Expand Down Expand Up @@ -122,6 +122,16 @@ unpaid breaks (meals by default, per the store's break rules). Before this the i
while the Pay page, overtime checks and accrual took paid hours, so the payroll export disagreed with what
the worker saw. Sheets approved before the change are not rewritten.

## Short breaks are always paid (2026-11-11)

A punched break of 20 minutes or less is paid time even when it was punched as a meal or the store marks that
kind of break unpaid, because federal law (FLSA, 29 CFR 785.18 and 785.19) counts short breaks as work and only
lets a real meal period, normally 30 minutes or more, go unpaid. It is a fixed floor, not a setting
(`ClockBreak::SHORT_BREAK_PAID`), and it applies wherever paid hours are counted: the clock, the Pay page, the
timesheet import and so the payroll export. Rupert never deducts a break automatically: only breaks someone
actually punched (or a manager-approved correction recorded) come off. Sheets approved before the change are not
rewritten, and Square timecards keep the paid or unpaid flag Square recorded for each break.

## Retention, PII, offboarding

Punches, allocations and corrections are kept for 4 years (FLSA 3 years, plus a year); nothing is
Expand Down
18 changes: 15 additions & 3 deletions web/app/controllers/backup_keys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Settings → Backups (#421): the key that encrypts backups, and the owner's recovery kit.
#
# "Create backup key" makes an age key pair in memory, stores ONLY the public half (BackupEncryptionKey) and answers
# "Turn on backups" (later "Make a new recovery kit") makes an age key pair in memory, stores ONLY the public half (BackupEncryptionKey) and answers
# with the recovery kit PDF holding the private half and the Active Record encryption keys. The private key is not
# saved, logged, cached or shown again: losing the kit means losing access to the backups made with it.
#
Expand All @@ -27,6 +27,13 @@ def show
@recent_sign_in = recent_sign_in?
@password_session = session[:auth_method] == 'password'
@workspace = workspace_name
@health = BackupHealth.new(key: @current)
@signature = [@current&.id, @pending&.id, @health.state].join(':')
respond_to do |format|
format.html
# Polled by the page while the kit downloads or the first backup is due: ids and a state word, nothing secret.
format.json { render(json: { signature: @signature }) }
end
end

# POST /settings/backups/key → the recovery kit PDF (once).
Expand Down Expand Up @@ -58,7 +65,7 @@ def create
def activate
key = pending_key
if key.nil?
return redirect_to(settings_backups_path, alert: 'No backup key is waiting for its check code. Create a new key.')
return redirect_to(settings_backups_path, alert: 'No recovery kit is waiting for its check code (a code works for one hour). Make a new kit below.')
end
unless key.code_matches?(params[:check_code])
ActivityLogger.log('backup_key_activation_failed', subject: key, label: key.fingerprint, details: 'Wrong check code')
Expand All @@ -69,7 +76,12 @@ def activate
key.activate!(actor: Current.user)
ActivityLogger.log('backup_key_activated', subject: key, label: key.fingerprint,
details: ["Backups now encrypt to #{key.fingerprint}", ("#{previous.fingerprint} retired; its kit still opens older backups" if previous)].compact.join('. '))
redirect_to(settings_backups_path, notice: "Backup key #{key.fingerprint} is active. The next backup is encrypted with it.")
notice = if previous
"Backup key #{key.fingerprint} is active. The next backup is encrypted with it."
else
'Backups are on. The first encrypted backup is made within five minutes and shows on this page.'
end
redirect_to(settings_backups_path, notice: notice)
end

private
Expand Down
3 changes: 3 additions & 0 deletions web/app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ def panel_view?
def system_alerts
@system_alerts ||= begin
alerts = []
if (health = BackupHealth.alert_for(Current.user))
alerts << { title: health.title, body: health.summary, path: settings_backups_path, link: health.link_label }
end
if (location = card_processing_down_location)
alerts << { title: 'Card processing is down', body: "Customers at #{location.name} cannot pay by card until the payment gateway is connected and verified.",
path: pos_setting_path(location.id), link: 'Open POS settings' }
Expand Down
59 changes: 59 additions & 0 deletions web/app/javascript/controllers/backup_status_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Controller } from "@hotwired/stimulus"

// Settings → Backups. The recovery kit arrives as a file download, which leaves the page where it was, and the
// first backup lands a few minutes after the key is confirmed. This asks the server for the page's state every few
// seconds while something is expected to change and reloads once it has, so the owner never has to refresh by hand.
export default class extends Controller {
static targets = ["hint"]
static values = {
url: String,
signature: String,
poll: Boolean,
interval: { type: Number, default: 4000 },
limit: { type: Number, default: 900000 }
}

connect() {
if (this.pollValue) this.start()
}

disconnect() {
this.stop()
}

// The "Turn on backups" form was submitted: the kit downloads and the page must move on to the check code.
watch() {
if (this.hasHintTarget) this.hintTarget.hidden = false
this.start()
}

start() {
if (this.timer) return
this.startedAt = Date.now()
this.timer = setInterval(() => this.check(), this.intervalValue)
}

stop() {
clearInterval(this.timer)
this.timer = null
}

async check() {
if (Date.now() - this.startedAt > this.limitValue) return this.stop()
if (document.hidden || this.inFlight) return
this.inFlight = true
try {
const response = await fetch(this.urlValue, { headers: { Accept: "application/json" }, credentials: "same-origin" })
if (!response.ok) return
const data = await response.json()
if (data.signature && data.signature !== this.signatureValue) {
this.stop()
window.location.assign(window.location.pathname)
}
} catch (_error) {
// Offline or signed out: the next tick tries again; the owner can always refresh.
} finally {
this.inFlight = false
}
}
}
25 changes: 25 additions & 0 deletions web/app/jobs/backup_health_alert_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

# Hourly: when backups are off or failing (BackupHealth), every active instance owner (super admin) gets one in-app
# notification per day until it is fixed. The bell and the dashboard show the live state on every page; this leaves
# a dated record for someone who has not looked. Backups failed unnoticed from late October to Nov 11, 2026.
class BackupHealthAlertJob < ApplicationJob
queue_as :default

def perform
return unless BackupHealth.alerts_enabled?

health = BackupHealth.current
return unless health.alert?

User.where(role: 'super_admin', active: true).find_each do |owner|
tenant = Tenant.find_by(id: owner.tenant_id)
next unless tenant

Current.with_tenant(tenant) do
Notification.deliver(owner.id, kind: 'backup_health', title: health.title, body: health.summary,
url: '/settings/backups', dedupe_key: "backup-health:#{Time.current.utc.to_date.iso8601}", actor: nil)
end
end
end
end
10 changes: 10 additions & 0 deletions web/app/models/clock_break.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class ClockBreak < ApplicationRecord

KINDS = %w[rest meal].freeze
LABELS = { 'rest' => 'Break', 'meal' => 'Meal' }.freeze
# Federal floor (FLSA, 29 CFR 785.18-785.19): breaks of about 5 to 20 minutes are hours worked; only a real meal
# period, normally 30 minutes or more, may be unpaid. So a punched "meal" (or any break a store marked unpaid) of 20
# minutes or less still counts as paid time. Not a setting: a store can be more generous, never less.
SHORT_BREAK_PAID = 20.minutes

belongs_to :clock_shift

Expand All @@ -32,6 +36,12 @@ def seconds(at: Time.current)
[(ended_at || at) - started_at, 0].max
end

# A short break is paid time whatever it is called and whatever the store's rules say (see SHORT_BREAK_PAID).
def short?(at: Time.current) = seconds(at: at) <= SHORT_BREAK_PAID

# Does this break come off paid hours? Only when the rules make its kind unpaid AND it ran longer than a short break.
def unpaid?(rules, at: Time.current) = !rules.break_paid?(kind) && !short?(at: at)

private

def inside_shift
Expand Down
8 changes: 6 additions & 2 deletions web/app/models/clock_shift.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ def hours(at: Time.current)
end

# Seconds of unpaid break inside the shift (the location's rules decide which kinds are unpaid).
# A break of 20 minutes or less is never unpaid (ClockBreak::SHORT_BREAK_PAID). Nothing is deducted automatically:
# only breaks the worker actually punched (or a manager-approved correction recorded) come off.
# A break is judged by its whole length, also when a window clips it (a meal across midnight).
def unpaid_break_seconds(at: Time.current, rules: nil)
rules ||= TimeRules.for(location_id)
breaks.sum { |b| rules.break_paid?(b.kind) ? 0 : b.seconds(at: [at, ended_at].compact.min) }
limit = [at, ended_at].compact.min
breaks.sum { |pause| pause.unpaid?(rules, at: limit) ? pause.seconds(at: limit) : 0 }
end

# Hours that count for pay: punches minus unpaid breaks.
Expand All @@ -67,7 +71,7 @@ def paid_hours_between(from, to, rules: nil)

rules ||= TimeRules.for(location_id)
unpaid = breaks.sum do |pause|
next 0 if pause.ended_at.nil? || rules.break_paid?(pause.kind)
next 0 if pause.ended_at.nil? || !pause.unpaid?(rules)

[[pause.ended_at, end_at].min - [pause.started_at, start_at].max, 0].max
end
Expand Down
Loading
Loading