diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 88d374e..db64cbb 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -38,6 +38,11 @@ jobs:
- run: cargo clippy --locked --all-targets -- -D warnings
- run: cargo test --locked
- run: cargo test --locked --tests -- --ignored --test-threads=1
+ - uses: azure/setup-helm@v4
+ - name: Helm chart
+ run: |
+ helm lint helm/meguri --strict -f helm/meguri/ci/values.yaml
+ helm template meguri helm/meguri -f helm/meguri/ci/values.yaml > /dev/null
- uses: actions/setup-node@v4
with:
node-version: 22
diff --git a/docs/deployment.md b/docs/deployment.md
index bc34c6f..696dfbf 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -1,6 +1,6 @@
# GKE and Cloud SQL
-Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Templates are in `deploy/`. They have not been applied to a cloud project.
+Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Plain templates are in `deploy/`; the Helm chart in `helm/meguri` (see its README) deploys the same layout and runs the migrate Job as a pre-install/pre-upgrade hook, so the steps below are what the chart automates. Neither has been applied to a cloud project.
1. Provision a private-IP Cloud SQL instance, database and application login; enable backups/PITR. Use a VPC-native GKE cluster with access to the database VPC. Configure Workload Identity and grant its Google service account Cloud SQL Client. Follow [Google's GKE connection guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine).
2. Build `Dockerfile`, push your image, and replace project, region, instance, release, service-account, image, and public URL placeholders. Review/pin the proxy image for your rollout; the template uses the version in Google's documented example. Native sidecars are required. Docker/GKE deployment must be validated in your environment.
diff --git a/docs/development.md b/docs/development.md
index 4827141..00b98a5 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -49,7 +49,7 @@ Workspace configuration edits take effect when saved. Historical queries use sav
Navigation and language coverage: tests/browser-navigation.cjs checks direct menu/workspace links, reload and browser history, saved language versus browser preference, Russian/French display, safe interpolation, catalog completeness and the prohibition on literal template copy. It runs in npm run test:browser.
-Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner and 18 Japanese holidays/days off from https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html (2026 table, verified 2026-09-24). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation.
+Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner, the 8 England and Wales bank holidays of 2026 from https://www.gov.uk/bank-holidays.json (verified 2026-10-07), and the 13 statutory Armenian public holidays/days off of 2026 (listed from the Labour Code, not re-verified against an official calendar). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation. Demo accounts also differ in scheduling country so that country holidays are visible: alice and dave Cyprus, bob and erin United Kingdom (`GB`), carol Armenia (`AM`).
Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice administers all four; Bob is an employee in Acme/Regional, Carol in Acme/Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview.
diff --git a/docs/product.md b/docs/product.md
index 109906c..7255e13 100644
--- a/docs/product.md
+++ b/docs/product.md
@@ -8,6 +8,8 @@ Meguri is a multi-tenant on-call configuration service. Workspace is the single
**Roles.** An *instance administrator* sees every enabled workspace, may act in it with every permission without being a member, and manages password users and disabled workspaces from the instance page; configuration, workspaces and group mappings are read-only there. A *workspace administrator* holds every permission in exactly that workspace and cannot see, create or affect any other. An *employee* holds the workspace's employee permission set: calendar, schedule editing and replacement requests by default (`view`, `edit_schedules`, `request_overrides`). Administrators change that set in workspace settings and it applies to every current and future employee immediately; `view` is mandatory and `manage_members`/`manage_integration` can never be granted to employees. Granting `force_overrides` lets every employee apply replacements without confirmation; leaving it out requires approval.
+**Employees and workspace settings.** `edit_schedules` lets an employee plan: schedules, escalations, routing, absences, holidays and new email contacts. It never covers the workspace preferences (name, planning horizon), existing entries of the scheduling directory, the working interval, the drift policy, the provider integration or access; those are workspace administrator (and instance administrator) actions that employees may view but not change. The server rejects such edits with 403 and the UI hides the controls.
+
**Groups decide roles.** A person's groups are compared to the configured groups by exact string match (case, slashes and prefixes matter). Workspace administrator beats employee when both match; matching `instance_admin_groups` makes an instance administrator. Memberships are recomputed from the stored groups at every SSO sign-in, whenever an administrator edits a password user's groups, and for every user at each configuration sync, so removing a group or a workspace removes access. A sign-in whose groups map to nothing is refused with an explanation and a support reference; no account is created or kept enabled for it.
**Accounts.** There is no self-registration. Accounts come into being only through an SSO sign-in whose groups map to something, or when an instance administrator creates a password user (username, display name, password of 12–256 characters, optional email, groups chosen from the configured list). SSO accounts have no password and their groups belong to the identity provider (read-only in Meguri); password accounts have groups edited by an instance administrator, who cannot edit their own, and can have their password reset (which ends their sessions). An SSO identity is never linked to an existing password account automatically. Ordinary password sign-in works only when `auth.password_login` is true.
diff --git a/helm/meguri/.helmignore b/helm/meguri/.helmignore
new file mode 100644
index 0000000..37bb79f
--- /dev/null
+++ b/helm/meguri/.helmignore
@@ -0,0 +1,5 @@
+.git
+.gitignore
+ci/
+*.swp
+*.bak
diff --git a/helm/meguri/Chart.yaml b/helm/meguri/Chart.yaml
new file mode 100644
index 0000000..3237041
--- /dev/null
+++ b/helm/meguri/Chart.yaml
@@ -0,0 +1,7 @@
+apiVersion: v2
+name: meguri
+description: On-call schedules, replacements and escalation policies as a source of truth
+type: application
+version: 0.1.0
+appVersion: "0.1.0"
+kubeVersion: ">=1.29.0-0"
diff --git a/helm/meguri/README.md b/helm/meguri/README.md
new file mode 100644
index 0000000..51c271f
--- /dev/null
+++ b/helm/meguri/README.md
@@ -0,0 +1,79 @@
+# Meguri Helm chart
+
+Deploys the web/worker Deployment, Service, optional Ingress and PodDisruptionBudget, and runs a
+**pre-install/pre-upgrade migrate Job** that applies Cot migrations and the declarative instance
+configuration *before* the Deployment is updated. The chart contains no secrets and no Helm-managed
+database. Requires Kubernetes 1.29+ (native sidecars) and Helm 3.
+
+## What you provide
+
+1. An image: `ultradesu/meguri:latest` by default (build your own from the repository `Dockerfile` and set `image.repository` / `image.tag`; pin a tag for reproducible releases).
+2. A Secret in the release namespace, named by `secretName`. All of its keys become environment
+ variables:
+
+ ```sh
+ kubectl -n meguri create secret generic meguri-secrets \
+ --from-literal=DATABASE_URL='postgresql://USER:PERCENT_ENCODED_PASSWORD@HOST:5432/meguri' \
+ --from-literal=MEGURI_OIDC_CLIENT_SECRET='...'
+ ```
+
+ Per-tenant provider credentials (`MEGURI_JSM__EMAIL` / `_TOKEN`) go in the same Secret. `extraSecretNames` lists further Secrets injected the same way, for example one that holds only `MEGURI_OIDC_CLIENT_SECRET`.
+3. Your values. The two required ones are `publicUrl` (the exact browser origin) and `secretName`;
+ everything about people and access lives under `instanceConfig`.
+
+## Install and upgrade
+
+```sh
+helm upgrade --install meguri ./helm/meguri -n meguri --create-namespace -f my-values.yaml
+kubectl -n meguri logs job/meguri-migrate-1 # what the sync did; revision number increments
+```
+
+`instanceConfig` is rendered verbatim into `/etc/meguri/config.yaml` (field reference:
+`deploy/instance-config.example.yaml`, behavior: `docs/deployment.md`). The chart schema rejects
+unknown fields and obviously invalid values at `helm lint`/`install`; the application validates it
+again, and a rejected configuration fails the migrate Job, so the release stops before any pod
+changes. A configuration change rolls the Deployment (checksum annotation) after the Job succeeded.
+
+```yaml
+publicUrl: https://meguri.example.internal
+secretName: meguri-secrets
+image: {repository: ultradesu/meguri, tag: "1.4.0"}
+instanceConfig:
+ auth: {password_login: false}
+ oidc:
+ issuer: https://sso.example.internal/realms/corp
+ client_id: meguri
+ instance_admin_groups: [/meguri/admins]
+ workspaces:
+ - key: payments
+ name: Payments
+ admin_groups: [/payments/leads]
+ member_groups: [/payments/team]
+ - key: customer-support
+ name: Customer Support
+ admin_groups: [/support/leads]
+ member_groups: [/support/team, /support/contractors]
+```
+
+Removing a workspace from `workspaces` **disables** it at the next release (data is kept; an
+instance administrator can delete it permanently). Giving a new team access is one more list entry.
+
+## Break-glass administrator
+
+Add `instanceConfig.auth.bootstrap_admin.usernames: [rescue]` and upgrade. The generated password is
+printed in the migrate Job log (`kubectl logs job/meguri-migrate-N`) and rotates each UTC hour; anyone who can read
+that log can sign in as an instance administrator, so remove the block as soon as SSO works again.
+Finished Jobs are deleted after `migrate.ttlSecondsAfterFinished` (a day).
+
+## Notable values
+
+| Value | Purpose |
+|---|---|
+| `replicaCount`, `resources`, `podDisruptionBudget` | Sizing and availability (publication is serialized by database leases, so replicas are safe). |
+| `cloudSqlProxy.*` | Cloud SQL Auth Proxy native sidecar for the app and the Job; point `DATABASE_URL` at `127.0.0.1:5432`. |
+| `serviceAccount.create` / `.annotations` | Workload Identity account, created once at install (the pre-install hook needs it). Later annotation changes need `kubectl annotate`. |
+| `ingress.*` | Host and TLS follow `publicUrl`. |
+| `allowDisableAll` | Permit a release whose `workspaces` list is empty to disable every workspace. |
+| `extraEnv`, `nodeSelector`, `tolerations`, `affinity`, `topologySpreadConstraints` | Usual scheduling and environment hooks. |
+
+The demonstration mode is not available in the chart: it only runs on loopback.
diff --git a/helm/meguri/ci/values.yaml b/helm/meguri/ci/values.yaml
new file mode 100644
index 0000000..cb31197
--- /dev/null
+++ b/helm/meguri/ci/values.yaml
@@ -0,0 +1,14 @@
+# Values for `helm lint` and `helm template` in CI; not a deployment example.
+publicUrl: https://meguri.example.internal
+secretName: meguri-secrets
+serviceAccount:
+ create: true
+ annotations:
+ iam.gke.io/gcp-service-account: meguri@project.iam.gserviceaccount.com
+cloudSqlProxy:
+ enabled: true
+ instanceConnectionName: project:region:instance
+ingress:
+ enabled: true
+ className: nginx
+ tlsSecretName: meguri-tls
diff --git a/helm/meguri/templates/NOTES.txt b/helm/meguri/templates/NOTES.txt
new file mode 100644
index 0000000..a502310
--- /dev/null
+++ b/helm/meguri/templates/NOTES.txt
@@ -0,0 +1,17 @@
+Meguri {{ .Chart.AppVersion }} is released as {{ .Release.Name }}.
+
+Open {{ .Values.publicUrl }} (register {{ .Values.publicUrl | trimSuffix "/" }}/api/oidc/callback as the redirect URI of the OIDC client).
+
+Workspaces and group mappings come only from `instanceConfig`; the migrate Job applied them
+before the Deployment was updated. See what it did:
+
+ kubectl -n {{ .Release.Namespace }} logs job/{{ include "meguri.fullname" . }}-migrate-{{ .Release.Revision }}
+{{- if (.Values.instanceConfig.auth).bootstrap_admin }}
+
+WARNING: bootstrap_admin is enabled. Its generated password is in that Job log, so anyone who can
+read the log can sign in as an instance administrator. Remove the block when SSO works again.
+{{- end }}
+{{- if not .Values.instanceConfig.workspaces }}
+
+No workspaces are configured.
+{{- end }}
diff --git a/helm/meguri/templates/_helpers.tpl b/helm/meguri/templates/_helpers.tpl
new file mode 100644
index 0000000..21188c0
--- /dev/null
+++ b/helm/meguri/templates/_helpers.tpl
@@ -0,0 +1,84 @@
+{{- define "meguri.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 40 | trimSuffix "-" -}}
+{{- end -}}
+
+{{- define "meguri.fullname" -}}
+{{- if .Values.fullnameOverride -}}
+{{- .Values.fullnameOverride | trunc 40 | trimSuffix "-" -}}
+{{- else if contains .Chart.Name .Release.Name -}}
+{{- .Release.Name | trunc 40 | trimSuffix "-" -}}
+{{- else -}}
+{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 40 | trimSuffix "-" -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "meguri.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "meguri.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end -}}
+
+{{- define "meguri.labels" -}}
+helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{ include "meguri.selectorLabels" . }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end -}}
+
+{{- define "meguri.image" -}}
+{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
+{{- end -}}
+
+{{- define "meguri.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create -}}
+{{- default (include "meguri.fullname" .) .Values.serviceAccount.name -}}
+{{- else -}}
+{{- default "default" .Values.serviceAccount.name -}}
+{{- end -}}
+{{- end -}}
+
+{{/* The configuration file exactly as the application reads it. */}}
+{{- define "meguri.instanceConfig" -}}
+{{- toYaml .Values.instanceConfig -}}
+{{- end -}}
+
+{{/* Fails the render early with a readable message. Outputs nothing. */}}
+{{- define "meguri.requirements" -}}
+{{- $_ := required "publicUrl is required: the exact browser origin, for example https://meguri.example.internal" .Values.publicUrl -}}
+{{- $_ := required "secretName is required: a Secret with DATABASE_URL (and MEGURI_OIDC_CLIENT_SECRET when oidc is configured)" .Values.secretName -}}
+{{- end -}}
+
+{{- define "meguri.cloudSqlProxy" -}}
+- name: cloud-sql-proxy
+ restartPolicy: Always
+ image: {{ .Values.cloudSqlProxy.image | quote }}
+ args:
+ {{- if .Values.cloudSqlProxy.privateIp }}
+ - --private-ip
+ {{- end }}
+ - --structured-logs
+ - --port={{ .Values.cloudSqlProxy.port }}
+ - --health-check
+ - --http-address=0.0.0.0
+ - --http-port=9090
+ - {{ required "cloudSqlProxy.instanceConnectionName is required when the proxy is enabled" .Values.cloudSqlProxy.instanceConnectionName | quote }}
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities: {drop: [ALL]}
+ resources:
+ {{- toYaml .Values.cloudSqlProxy.resources | nindent 4 }}
+ startupProbe:
+ httpGet: {path: /startup, port: 9090}
+ periodSeconds: 2
+ failureThreshold: 30
+{{- end -}}
+
+{{/* Environment shared by the application and the migrate Job. */}}
+{{- define "meguri.env" -}}
+- {name: MEGURI_BIND, value: "0.0.0.0:3000"}
+- {name: MEGURI_PUBLIC_URL, value: {{ .Values.publicUrl | trimSuffix "/" | quote }}}
+- {name: MEGURI_CONFIG_FILE, value: /etc/meguri/config.yaml}
+- {name: RUST_LOG, value: {{ .Values.logLevel | quote }}}
+{{- with .Values.extraEnv }}
+{{ toYaml . }}
+{{- end }}
+{{- end -}}
diff --git a/helm/meguri/templates/configmap.yaml b/helm/meguri/templates/configmap.yaml
new file mode 100644
index 0000000..35318bb
--- /dev/null
+++ b/helm/meguri/templates/configmap.yaml
@@ -0,0 +1,25 @@
+{{- include "meguri.requirements" . -}}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "meguri.fullname" . }}-instance-config
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+data:
+ config.yaml: |
+ {{- include "meguri.instanceConfig" . | nindent 4 }}
+---
+{{- /* The migrate Job runs before ordinary resources are updated, so it reads its own copy. */}}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "meguri.fullname" . }}-instance-config-migrate
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+ annotations:
+ helm.sh/hook: pre-install,pre-upgrade
+ helm.sh/hook-weight: "-10"
+ helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
+data:
+ config.yaml: |
+ {{- include "meguri.instanceConfig" . | nindent 4 }}
diff --git a/helm/meguri/templates/deployment.yaml b/helm/meguri/templates/deployment.yaml
new file mode 100644
index 0000000..e725783
--- /dev/null
+++ b/helm/meguri/templates/deployment.yaml
@@ -0,0 +1,89 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "meguri.fullname" . }}
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+spec:
+ replicas: {{ .Values.replicaCount }}
+ selector:
+ matchLabels:
+ {{- include "meguri.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: app
+ template:
+ metadata:
+ labels:
+ {{- include "meguri.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: app
+ {{- with .Values.podLabels }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ annotations:
+ checksum/instance-config: {{ include "meguri.instanceConfig" . | sha256sum }}
+ {{- with .Values.podAnnotations }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ serviceAccountName: {{ include "meguri.serviceAccountName" . }}
+ terminationGracePeriodSeconds: 60
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile: {type: RuntimeDefault}
+ {{- if .Values.cloudSqlProxy.enabled }}
+ initContainers:
+ {{- include "meguri.cloudSqlProxy" . | nindent 8 }}
+ {{- end }}
+ volumes:
+ - name: instance-config
+ configMap:
+ name: {{ include "meguri.fullname" . }}-instance-config
+ containers:
+ - name: meguri
+ image: {{ include "meguri.image" . | quote }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ ports: [{name: http, containerPort: 3000}]
+ envFrom:
+ - secretRef: {name: {{ required "secretName is required" .Values.secretName | quote }}}
+ {{- range .Values.extraSecretNames }}
+ - secretRef: {name: {{ . | quote }}}
+ {{- end }}
+ env:
+ {{- include "meguri.env" . | nindent 12 }}
+ - {name: MEGURI_ROLE, value: {{ .Values.role | quote }}}
+ - {name: MEGURI_SKIP_MIGRATIONS, value: "true"}
+ volumeMounts:
+ - {name: instance-config, mountPath: /etc/meguri, readOnly: true}
+ securityContext:
+ readOnlyRootFilesystem: true
+ allowPrivilegeEscalation: false
+ capabilities: {drop: [ALL]}
+ resources:
+ {{- toYaml .Values.resources | nindent 12 }}
+ startupProbe:
+ httpGet: {path: /health/ready, port: http}
+ periodSeconds: 2
+ failureThreshold: 60
+ readinessProbe:
+ httpGet: {path: /health/ready, port: http}
+ livenessProbe:
+ httpGet: {path: /health/live, port: http}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.topologySpreadConstraints }}
+ topologySpreadConstraints:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/helm/meguri/templates/ingress.yaml b/helm/meguri/templates/ingress.yaml
new file mode 100644
index 0000000..c310628
--- /dev/null
+++ b/helm/meguri/templates/ingress.yaml
@@ -0,0 +1,32 @@
+{{- if .Values.ingress.enabled }}
+{{- $url := urlParse (required "publicUrl is required" .Values.publicUrl) }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "meguri.fullname" . }}
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- with .Values.ingress.className }}
+ ingressClassName: {{ . }}
+ {{- end }}
+ {{- if .Values.ingress.tlsSecretName }}
+ tls:
+ - hosts: [{{ $url.hostname | quote }}]
+ secretName: {{ .Values.ingress.tlsSecretName }}
+ {{- end }}
+ rules:
+ - host: {{ $url.hostname | quote }}
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ include "meguri.fullname" . }}
+ port: {name: http}
+{{- end }}
diff --git a/helm/meguri/templates/job-migrate.yaml b/helm/meguri/templates/job-migrate.yaml
new file mode 100644
index 0000000..fab3c81
--- /dev/null
+++ b/helm/meguri/templates/job-migrate.yaml
@@ -0,0 +1,62 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ include "meguri.fullname" . }}-migrate-{{ .Release.Revision }}
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+ app.kubernetes.io/component: migrate
+ annotations:
+ helm.sh/hook: pre-install,pre-upgrade
+ helm.sh/hook-weight: "0"
+ helm.sh/hook-delete-policy: before-hook-creation
+spec:
+ backoffLimit: 0
+ activeDeadlineSeconds: {{ .Values.migrate.activeDeadlineSeconds }}
+ ttlSecondsAfterFinished: {{ .Values.migrate.ttlSecondsAfterFinished }}
+ template:
+ metadata:
+ labels:
+ {{- include "meguri.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: migrate
+ spec:
+ restartPolicy: Never
+ serviceAccountName: {{ include "meguri.serviceAccountName" . }}
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile: {type: RuntimeDefault}
+ {{- if .Values.cloudSqlProxy.enabled }}
+ initContainers:
+ {{- include "meguri.cloudSqlProxy" . | nindent 8 }}
+ {{- end }}
+ volumes:
+ - name: instance-config
+ configMap:
+ name: {{ include "meguri.fullname" . }}-instance-config-migrate
+ containers:
+ - name: migrate
+ image: {{ include "meguri.image" . | quote }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ envFrom:
+ - secretRef: {name: {{ required "secretName is required" .Values.secretName | quote }}}
+ {{- range .Values.extraSecretNames }}
+ - secretRef: {name: {{ . | quote }}}
+ {{- end }}
+ env:
+ {{- include "meguri.env" . | nindent 12 }}
+ - {name: MEGURI_ROLE, value: migrate}
+ - {name: MEGURI_SKIP_MIGRATIONS, value: "false"}
+ {{- if .Values.allowDisableAll }}
+ - {name: MEGURI_ALLOW_DISABLE_ALL, value: "true"}
+ {{- end }}
+ volumeMounts:
+ - {name: instance-config, mountPath: /etc/meguri, readOnly: true}
+ securityContext:
+ readOnlyRootFilesystem: true
+ allowPrivilegeEscalation: false
+ capabilities: {drop: [ALL]}
+ resources:
+ {{- toYaml .Values.migrate.resources | nindent 12 }}
diff --git a/helm/meguri/templates/pdb.yaml b/helm/meguri/templates/pdb.yaml
new file mode 100644
index 0000000..bb40939
--- /dev/null
+++ b/helm/meguri/templates/pdb.yaml
@@ -0,0 +1,14 @@
+{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.replicaCount) 1) }}
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: {{ include "meguri.fullname" . }}
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+spec:
+ minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
+ selector:
+ matchLabels:
+ {{- include "meguri.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: app
+{{- end }}
diff --git a/helm/meguri/templates/service.yaml b/helm/meguri/templates/service.yaml
new file mode 100644
index 0000000..0d5523b
--- /dev/null
+++ b/helm/meguri/templates/service.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "meguri.fullname" . }}
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+spec:
+ type: {{ .Values.service.type }}
+ selector:
+ {{- include "meguri.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: app
+ ports:
+ - {name: http, port: {{ .Values.service.port }}, targetPort: http}
diff --git a/helm/meguri/templates/serviceaccount.yaml b/helm/meguri/templates/serviceaccount.yaml
new file mode 100644
index 0000000..ba7d4e0
--- /dev/null
+++ b/helm/meguri/templates/serviceaccount.yaml
@@ -0,0 +1,16 @@
+{{- if .Values.serviceAccount.create }}
+{{- /* A hook, because the pre-install migrate Job needs it before ordinary resources exist. */}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include "meguri.serviceAccountName" . }}
+ labels:
+ {{- include "meguri.labels" . | nindent 4 }}
+ annotations:
+ helm.sh/hook: pre-install
+ helm.sh/hook-weight: "-20"
+ helm.sh/hook-delete-policy: before-hook-creation
+ {{- with .Values.serviceAccount.annotations }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+{{- end }}
diff --git a/helm/meguri/values.schema.json b/helm/meguri/values.schema.json
new file mode 100644
index 0000000..4127987
--- /dev/null
+++ b/helm/meguri/values.schema.json
@@ -0,0 +1,88 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "required": ["publicUrl", "secretName", "instanceConfig"],
+ "properties": {
+ "image": {
+ "type": "object",
+ "required": ["repository"],
+ "properties": {
+ "repository": {"type": "string", "minLength": 1},
+ "tag": {"type": "string"},
+ "pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]}
+ }
+ },
+ "publicUrl": {"type": "string", "pattern": "^https?://[^/\\s]+/?$"},
+ "secretName": {"type": "string", "minLength": 1},
+ "extraSecretNames": {"type": "array", "items": {"type": "string", "minLength": 1}},
+ "replicaCount": {"type": "integer", "minimum": 1},
+ "role": {"enum": ["all", "web", "worker"]},
+ "logLevel": {"type": "string"},
+ "allowDisableAll": {"type": "boolean"},
+ "cloudSqlProxy": {
+ "type": "object",
+ "properties": {
+ "enabled": {"type": "boolean"},
+ "instanceConnectionName": {"type": "string"},
+ "port": {"type": "integer", "minimum": 1, "maximum": 65535}
+ }
+ },
+ "instanceConfig": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "auth": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "password_login": {"type": "boolean"},
+ "bootstrap_admin": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["usernames"],
+ "properties": {
+ "usernames": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {"type": "string", "pattern": "^[a-z0-9._-]{3,64}$"}
+ }
+ }
+ }
+ }
+ },
+ "oidc": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["issuer", "client_id"],
+ "properties": {
+ "issuer": {"type": "string", "pattern": "^https://[^\\s]+$"},
+ "client_id": {"type": "string", "minLength": 1},
+ "scopes": {"type": "array", "items": {"type": "string"}, "contains": {"const": "openid"}},
+ "groups_claim": {"type": "string", "minLength": 1},
+ "groups_sources": {
+ "type": "array",
+ "minItems": 1,
+ "items": {"enum": ["id_token", "userinfo"]}
+ }
+ }
+ },
+ "instance_admin_groups": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
+ "workspaces": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["key", "name", "admin_groups"],
+ "properties": {
+ "key": {"type": "string", "pattern": "^[a-z0-9]([a-z0-9-]{1,62})[a-z0-9]$"},
+ "name": {"type": "string", "minLength": 1, "maxLength": 120},
+ "admin_groups": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
+ "member_groups": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/helm/meguri/values.yaml b/helm/meguri/values.yaml
new file mode 100644
index 0000000..db066b7
--- /dev/null
+++ b/helm/meguri/values.yaml
@@ -0,0 +1,116 @@
+# Meguri Helm chart. Secrets are never part of the chart: create the Secret named by
+# `secretName` first (see README.md). Everything under `instanceConfig` is rendered verbatim into
+# the instance configuration file; the chart schema and the application validate it strictly.
+
+image:
+ repository: ultradesu/meguri
+ # Pin an immutable tag or digest for reproducible releases; `latest` is re-pulled on every restart.
+ tag: latest
+ pullPolicy: Always
+imagePullSecrets: []
+
+# Exact browser origin (scheme, host, port) of the service. Cookies, CSRF checks and the OIDC
+# redirect URI ({publicUrl}/api/oidc/callback) depend on it. Required.
+publicUrl: ""
+
+# Name of a Secret holding at least DATABASE_URL (postgresql://USER:PERCENT_ENCODED_PASSWORD@HOST:5432/DB)
+# and, when instanceConfig.oidc is set, MEGURI_OIDC_CLIENT_SECRET. All of its keys become environment
+# variables, so per-tenant provider credentials (MEGURI_JSM__EMAIL / _TOKEN) belong there too.
+# Required.
+secretName: ""
+# Further Secrets whose keys are also injected as environment variables (for example a separate
+# MEGURI_OIDC_CLIENT_SECRET kept apart from the database credentials).
+extraSecretNames: []
+
+replicaCount: 2
+# all = web and publication worker in each replica; web = HTTP only (add a separate release
+# with role worker if you split them).
+role: all
+logLevel: meguri=info,cot=warn
+
+# Disables every existing workspace when instanceConfig.workspaces is empty. Leave false.
+allowDisableAll: false
+
+# The instance configuration. Written to /etc/meguri/config.yaml. The migrate hook applies it at
+# every install and upgrade: workspaces are created, adopted, renamed, enabled or DISABLED (never
+# deleted) so that the database matches this list. Field reference: deploy/instance-config.example.yaml.
+instanceConfig:
+ auth:
+ # Lets ordinary password accounts (created by an instance administrator) sign in.
+ password_login: false
+ # Break-glass instance administrators. Remove in production. While present, their generated
+ # password is written to the migrate Job log.
+ # bootstrap_admin:
+ # usernames: [rescue]
+ oidc:
+ issuer: https://sso.example.internal/realms/corp
+ client_id: meguri
+ scopes: [openid, profile, email]
+ groups_claim: groups
+ groups_sources: [id_token, userinfo]
+ instance_admin_groups:
+ - /meguri/admins
+ workspaces:
+ - key: payments
+ name: Payments
+ admin_groups:
+ - /payments/leads
+ member_groups:
+ - /payments/team
+
+migrate:
+ # Runs the Cot migrations and the configuration sync before the Deployment is touched.
+ activeDeadlineSeconds: 300
+ # Finished Jobs (and the log that may contain bootstrap passwords) are removed after this long.
+ ttlSecondsAfterFinished: 86400
+ resources:
+ requests: {cpu: 250m, memory: 128Mi}
+ limits: {memory: 256Mi}
+
+resources:
+ requests: {cpu: 250m, memory: 256Mi}
+ limits: {memory: 512Mi}
+
+# Optional Cloud SQL Auth Proxy as a native sidecar (Kubernetes 1.29+), for the application and
+# the migrate Job. Point DATABASE_URL at 127.0.0.1:.
+cloudSqlProxy:
+ enabled: false
+ image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1
+ instanceConnectionName: "" # PROJECT:REGION:INSTANCE
+ privateIp: true
+ port: 5432
+ resources:
+ requests: {cpu: 100m, memory: 128Mi}
+ limits: {memory: 256Mi}
+
+serviceAccount:
+ # Created once, at install, because the migrate hook runs before ordinary resources exist.
+ # Later annotation changes need `kubectl annotate`. Leave create false to use an existing
+ # account named `name` (or the namespace default).
+ create: false
+ name: ""
+ annotations: {}
+ # iam.gke.io/gcp-service-account: meguri@PROJECT.iam.gserviceaccount.com
+
+service:
+ type: ClusterIP
+ port: 80
+
+ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ # Host and TLS follow publicUrl. Set tlsSecretName for an https publicUrl terminated at the ingress.
+ tlsSecretName: ""
+
+podDisruptionBudget:
+ enabled: true
+ minAvailable: 1
+
+podAnnotations: {}
+podLabels: {}
+extraEnv: []
+nodeSelector: {}
+tolerations: []
+affinity: {}
+topologySpreadConstraints: []
diff --git a/src/access.rs b/src/access.rs
index 95343fc..24a3431 100644
--- a/src/access.rs
+++ b/src/access.rs
@@ -508,6 +508,15 @@ workspaces:
assert!(config.bootstrap_usernames().is_empty());
}
+ #[test]
+ fn the_helm_chart_default_instance_configuration_is_valid() {
+ let values: serde_yaml_ng::Value =
+ serde_yaml_ng::from_str(include_str!("../helm/meguri/values.yaml")).unwrap();
+ let rendered = serde_yaml_ng::to_string(&values["instanceConfig"]).unwrap();
+ let config = Config::parse(&rendered).unwrap();
+ assert!(config.oidc.is_some() && !config.workspaces.is_empty());
+ }
+
#[test]
fn known_groups_are_unique_and_sorted_and_the_form_flags_follow_the_configuration() {
let c = config();
diff --git a/src/admin.rs b/src/admin.rs
index c65efb3..78c0894 100644
--- a/src/admin.rs
+++ b/src/admin.rs
@@ -127,6 +127,9 @@ pub struct NewUser {
pub email: Option,
#[serde(default)]
pub groups: Vec,
+ /// Scheduling country (ISO 3166-1 alpha-2); defaults to CY like every new account.
+ #[serde(default)]
+ pub country: Option,
}
/// Creates a password account. `actor` is None only for trusted startup seeding; the request
@@ -158,6 +161,19 @@ pub async fn create_password_user(
let hash = service::hash_password(input.password).await?;
let mut row = new_user_row(username.clone(), input.display_name, Some(hash));
row.email = email;
+ if let Some(country) = input
+ .country
+ .as_deref()
+ .map(str::trim)
+ .filter(|c| !c.is_empty())
+ {
+ let country = country.to_ascii_uppercase();
+ ensure(
+ crate::domain::valid_country(&country),
+ crate::i18n::text("err_choose_a_valid_country_code_e79c72"),
+ )?;
+ row.country = country;
+ }
match row.clone().insert(&db.db).await {
Ok(_) => {}
Err(cot::db::DatabaseError::UniqueViolation) => {
diff --git a/src/demo-holidays-2026.csv b/src/demo-holidays-2026.csv
index b34140e..45d9b25 100644
--- a/src/demo-holidays-2026.csv
+++ b/src/demo-holidays-2026.csv
@@ -14,21 +14,24 @@ CY,2026-10-28,Ohi Day
CY,2026-12-24,Christmas Eve
CY,2026-12-25,Christmas Day
CY,2026-12-26,Boxing Day
-JP,2026-01-01,New Year's Day
-JP,2026-01-12,Coming of Age Day
-JP,2026-02-11,National Foundation Day
-JP,2026-02-23,Emperor's Birthday
-JP,2026-03-20,Vernal Equinox Day
-JP,2026-04-29,Showa Day
-JP,2026-05-03,Constitution Memorial Day
-JP,2026-05-04,Greenery Day
-JP,2026-05-05,Children's Day
-JP,2026-05-06,Substitute Holiday
-JP,2026-07-20,Marine Day
-JP,2026-08-11,Mountain Day
-JP,2026-09-21,Respect for the Aged Day
-JP,2026-09-22,Citizens' Holiday
-JP,2026-09-23,Autumnal Equinox Day
-JP,2026-10-12,Sports Day
-JP,2026-11-03,Culture Day
-JP,2026-11-23,Labour Thanksgiving Day
+GB,2026-01-01,New Year's Day
+GB,2026-04-03,Good Friday
+GB,2026-04-06,Easter Monday
+GB,2026-05-04,Early May bank holiday
+GB,2026-05-25,Spring bank holiday
+GB,2026-08-31,Summer bank holiday
+GB,2026-12-25,Christmas Day
+GB,2026-12-28,Boxing Day (substitute day)
+AM,2026-01-01,New Year's Day
+AM,2026-01-02,New Year holiday
+AM,2026-01-06,Christmas Day
+AM,2026-01-28,Army Day
+AM,2026-03-08,Women's Day
+AM,2026-04-07,Motherhood and Beauty Day
+AM,2026-04-24,Genocide Remembrance Day
+AM,2026-05-01,Labour Day
+AM,2026-05-09,Victory and Peace Day
+AM,2026-05-28,Republic Day
+AM,2026-07-05,Constitution Day
+AM,2026-09-21,Independence Day
+AM,2026-12-31,New Year's Eve
diff --git a/src/demo.rs b/src/demo.rs
index eb4eaa4..99ea33f 100644
--- a/src/demo.rs
+++ b/src/demo.rs
@@ -228,12 +228,14 @@ pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec Result {
pub fn accounts() -> Vec {
ACCOUNTS
.iter()
- .map(|(username, _, role, _)| crate::demo_runtime::DemoAccount {
- username: (*username).into(),
- role: (*role).into(),
- })
+ .map(
+ |(username, _, role, _, _)| crate::demo_runtime::DemoAccount {
+ username: (*username).into(),
+ role: (*role).into(),
+ },
+ )
.collect()
}
@@ -315,7 +321,7 @@ pub async fn seed(db: &Store) -> Result<()> {
},
)
.await?;
- for (username, display, _, groups) in ACCOUNTS {
+ for (username, display, _, country, groups) in ACCOUNTS {
let name = username.to_owned();
if query!(UserRow,$username==name).exists(&db.db).await? {
continue;
@@ -330,6 +336,7 @@ pub async fn seed(db: &Store) -> Result<()> {
password: notice.password.clone(),
email: None,
groups: groups.iter().map(|g| (*g).to_owned()).collect(),
+ country: Some(country.into()),
},
)
.await?;
diff --git a/src/service.rs b/src/service.rs
index 6941f3c..f787f2b 100644
--- a/src/service.rs
+++ b/src/service.rs
@@ -104,6 +104,41 @@ pub async fn login(
Ok((user, token, csrf))
}
+/// Employees with schedule-editing rights plan schedules, but the workspace preferences and the
+/// scheduling directory belong to workspace administrators: name, planning horizon and every
+/// existing person stay exactly as they were. New email contacts may be added while planning.
+fn ensure_employee_edit(before: &WorkspaceDocument, after: &WorkspaceDocument) -> Result<()> {
+ let normal = |p: &Person| {
+ (
+ p.display_name.clone(),
+ p.email
+ .as_deref()
+ .map(|e| e.trim().to_lowercase())
+ .filter(|e| !e.is_empty()),
+ p.country
+ .as_deref()
+ .map(|c| c.trim().to_ascii_uppercase())
+ .filter(|c| !c.is_empty())
+ .unwrap_or_else(|| "CY".into()),
+ p.user_id,
+ )
+ };
+ let unchanged = after.name == before.name
+ && after.horizon_days == before.horizon_days
+ && before.people.iter().all(|old| {
+ after
+ .people
+ .iter()
+ .find(|p| p.id == old.id)
+ .is_some_and(|new| normal(new) == normal(old))
+ });
+ if unchanged {
+ Ok(())
+ } else {
+ Err(Error::Forbidden)
+ }
+}
+
pub async fn update_document(
db: &Store,
actor: Uuid,
@@ -115,6 +150,9 @@ pub async fn update_document(
.prepare(workspace, revision, actor, Permission::EditSchedules)
.await?;
document.requests = record.document.requests.clone();
+ if db.owner(workspace, actor).await.is_err() {
+ ensure_employee_edit(&record.document, &document)?;
+ }
for request in &mut document.requests {
if request.status == RequestStatus::Pending
&& request
diff --git a/static/index.html b/static/index.html
index b5bfeef..60abe5f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -393,7 +393,7 @@
-
+
@@ -460,7 +460,7 @@
-
+
@@ -649,7 +649,7 @@
-
+
diff --git a/tests/access.rs b/tests/access.rs
index 8f63daa..f3a650c 100644
--- a/tests/access.rs
+++ b/tests/access.rs
@@ -692,6 +692,7 @@ async fn only_instance_administrators_manage_password_users_and_delete_disabled_
password: "long-enough-password".into(),
email: None,
groups: vec![],
+ country: None,
};
// Nothing is available to ordinary users, not even read access to the overview.
@@ -1079,6 +1080,7 @@ async fn taken_username(db: &Store, config: &Config) {
password: "long-enough-password".into(),
email: None,
groups: vec![],
+ country: None,
},
)
.await
@@ -1265,3 +1267,93 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
);
iso.drop().await;
}
+
+#[tokio::test]
+#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
+async fn employees_plan_schedules_but_only_administrators_change_preferences_and_the_directory() {
+ let f = support::Fixture::new().await;
+ let (lead, bob) = (f.people[0].id, f.people[1].id);
+ let team = f.team.id;
+ // Employees may edit schedules here, as with the default employee permission set.
+ let record = service::set_member_permissions(
+ &f.db,
+ lead,
+ team,
+ f.team.revision,
+ Permission::employee_default(),
+ )
+ .await
+ .unwrap();
+ let contact = Uuid::new_v4();
+ let mut doc = record.document.clone();
+ doc.people.push(Person {
+ id: contact,
+ user_id: None,
+ display_name: "Dana Contact".into(),
+ email: Some("dana@corp.test".into()),
+ country: Some("AM".into()),
+ aliases: vec![],
+ });
+ let record = service::update_document(&f.db, lead, team, record.revision, doc)
+ .await
+ .unwrap();
+
+ let try_edit = |change: &dyn Fn(&mut WorkspaceDocument)| {
+ let mut doc = record.document.clone();
+ change(&mut doc);
+ service::update_document(&f.db, bob, team, record.revision, doc)
+ };
+ // Workspace preferences and existing directory entries are administrator-only.
+ for (label, change) in [
+ (
+ "name",
+ &(|d: &mut WorkspaceDocument| d.name = "Renamed".into())
+ as &dyn Fn(&mut WorkspaceDocument),
+ ),
+ ("horizon", &|d| d.horizon_days = 30),
+ ("person name", &|d| {
+ d.people[0].display_name = "Someone else".into()
+ }),
+ ("person email", &|d| {
+ d.people[0].email = Some("other@corp.test".into())
+ }),
+ ("person country", &|d| {
+ d.people[0].country = Some("CY".into())
+ }),
+ ("person removal", &|d| d.people.clear()),
+ ] {
+ assert!(
+ matches!(try_edit(change).await, Err(Error::Forbidden)),
+ "an employee must not change the {label}"
+ );
+ }
+ // Planning is allowed, including new email contacts and unchanged people sent back as is.
+ let planned = try_edit(&|d| {
+ d.schedules[0].name = "Weekly rotation".into();
+ d.people.push(Person {
+ id: Uuid::new_v4(),
+ user_id: None,
+ display_name: "New contact".into(),
+ email: Some("new@corp.test".into()),
+ country: None,
+ aliases: vec![],
+ });
+ d.people[0].email = Some(" DANA@corp.test ".into());
+ })
+ .await
+ .unwrap();
+ assert_eq!(planned.document.schedules[0].name, "Weekly rotation");
+ assert_eq!(planned.document.name, record.document.name);
+ assert_eq!(planned.document.people.len(), 2);
+ // The workspace administrator still changes both.
+ let mut doc = planned.document.clone();
+ doc.name = "Administrators rename".into();
+ doc.horizon_days = 45;
+ doc.people[0].display_name = "Dana Renamed".into();
+ let changed = service::update_document(&f.db, lead, team, planned.revision, doc)
+ .await
+ .unwrap();
+ assert_eq!(changed.document.name, "Administrators rename");
+ assert_eq!(changed.document.horizon_days, 45);
+ assert_eq!(changed.document.people[0].display_name, "Dana Renamed");
+}
diff --git a/tests/browser-access.cjs b/tests/browser-access.cjs
index ca1efd3..aeaea21 100644
--- a/tests/browser-access.cjs
+++ b/tests/browser-access.cjs
@@ -142,6 +142,11 @@ fs.mkdirSync('test-results', { recursive: true });
const detail = await carol.get('/api/workspaces/' + commerceId);
const denied = await carol.call('PUT', `/api/workspaces/${commerceId}/member-permissions`, { revision: detail.workspace.revision, permissions: ['view', 'force_overrides', 'edit_schedules'] });
assert.equal(denied.status, 403);
+ // Employees may plan schedules, but workspace preferences and the scheduling directory are read-only.
+ assert.equal(await worker.getByRole('button', { name: 'Edit preferences', exact: true }).count(), 0, 'Employees cannot edit workspace preferences');
+ assert.equal(await worker.getByRole('button', { name: 'Edit person', exact: true }).count(), 0, 'Employees cannot edit the scheduling directory');
+ const rename = await carol.call('PUT', `/api/workspaces/${commerceId}/config`, { revision: detail.workspace.revision, document: { ...detail.workspace.document, name: 'Renamed by an employee' } });
+ assert.equal(rename.status, 403);
await worker.screenshot({ path: 'test-results/access-employee.png', fullPage: true });
// The change applies at once to everyone: turning it off removes the permission without a new login.
diff --git a/tests/browser-setup-layout.cjs b/tests/browser-setup-layout.cjs
index cac1c61..0974bec 100644
--- a/tests/browser-setup-layout.cjs
+++ b/tests/browser-setup-layout.cjs
@@ -1,2 +1,2 @@
const assert=require('node:assert/strict');
-(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'36');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});for(const [country,count] of [['Cyprus','15'],['United Kingdom','8'],['Armenia','13']]){const group=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:country})});assert.equal(await group.getAttribute('open'),null,country);assert.equal((await group.locator('.badge').innerText()).trim(),count,country);}const armenia=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Armenia'})});await armenia.locator('summary').click();await armenia.getByText('Independence Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 36 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index a41731d..b1b9de2 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -61,6 +61,7 @@ pub async fn account(db: &Store, config: &Config, name: &str, groups: &[String])
password: "test-only-password".into(),
email: None,
groups: groups.to_vec(),
+ country: None,
},
)
.await