spiderly 19.8.6 → 19.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -182,6 +182,15 @@ cols: Column<ProductDTO>[] = [
182
182
 
183
183
  The `onClick` callback receives an `ActionClickEvent` — `{ id, row, element, originalEvent }`. Use `element` (or `originalEvent`) to anchor an overlay/popover to the clicked action; `row` gives you the full row object. It fires only for custom `field` values (never `'Details'` / `'Delete'`).
184
184
 
185
+ For a click on the cell value itself (not an action icon), set a column's `onCellClick` callback — the mirror of `Action.onClick`, but for plain value cells:
186
+
187
+ ```typescript
188
+ new Column({ field: 'total', name: 'Total', filterType: 'numeric',
189
+ onCellClick: (e) => this.itemsPopover.show(e.originalEvent, e.element) }),
190
+ ```
191
+
192
+ It receives a `CellClickEvent` — `{ id, field, row, value, displayValue, element, originalEvent }`, where `value` is the raw cell value and `displayValue` is the formatted text shown. Only columns that set it become clickable (they get a `cursor`/hover affordance), and the click stops propagation so it does **not** also trigger `navigateOnRowClick`. Anchor overlays with `element` — it's the clicked `<td>`, captured synchronously, so it stays valid inside an async handler (`originalEvent.currentTarget` is null once dispatch ends). Not applied to editable cells.
193
+
185
194
  ### Key Inputs
186
195
 
187
196
  | Input | Type | Default | Purpose |
@@ -32,6 +32,7 @@ Reusable helpers exported from `helper-functions.ts`. Import the one you need in
32
32
  | `nameof(key1: any, key2?: any): any` | |
33
33
  | `parseDateOnlyLocal(s: string): Date \| null` | |
34
34
  | `pushAction(cols: Column[], action: Action)` | |
35
+ | `saveResponseAsFile(res: HttpResponse<Blob>, fallbackName: string): void` | |
35
36
  | `selectedTab(tabs: SpiderlyTab[]): number` | |
36
37
  | `singleOrDefault<T>(array: T[], predicate: (item: T) => boolean): T \| undefined` | |
37
38
  | `splitPascalCase(input: string)` | |
@@ -49,6 +49,34 @@ public class Achievement : BusinessObject<long>
49
49
 
50
50
  The string is appended directly to the generated `.NewConfig<Entity, EntityDTO>()` chain. Use this for simple field mappings that the generator doesn't produce automatically.
51
51
 
52
+ ### `[ProjectToDTO]` only fills the value — the field must exist on the DTO
53
+
54
+ `[ProjectToDTO]` adds a Mapster *mapping*; it does **not** create the property. If `dest.ProductId`
55
+ is not a property on the DTO, the field **never appears in the generated Angular type**
56
+ (`entities.generated.ts`) — `[ProjectToDTO]` fills a value that has nowhere to land. (A common
57
+ false alarm: the value maps fine at runtime, so it looks like the frontend "didn't regenerate" —
58
+ but a normal `dotnet build` *does* regenerate the Angular files; the property was just never on
59
+ the DTO for the generator to emit.)
60
+
61
+ To add a computed/projected field end-to-end, declare the property on a `partial class {Entity}DTO`
62
+ extension — it merges into the generated DTO automatically, no attribute needed — **then** map its value:
63
+
64
+ ```csharp
65
+ // 1. The property — a partial extension of the generated OrderItemDTO. No [SpiderlyDTO]
66
+ // needed: a partial that extends a generated DTO is merged in by name.
67
+ public partial class OrderItemDTO
68
+ {
69
+ public int? ProductId { get; set; }
70
+ }
71
+
72
+ // 2. The value — [ProjectToDTO] on the entity fills it during projection.
73
+ [ProjectToDTO(".Map(dest => dest.ProductId, src => src.ProductVariant.ProductId)")]
74
+ public class OrderItem : BusinessObject<long> { /* ... */ }
75
+ ```
76
+
77
+ Then `dotnet build` the backend — the source generators run on build and the field appears
78
+ in `entities.generated.ts`. There is no separate "regenerate" command; the build is it.
79
+
52
80
  ## Partial Method Override — Full Control
53
81
 
54
82
  For complex mapping logic, override the entire generated method. The generator **skips generation** for any method that already exists in the user's partial `Mapper` class (detected by method name match).
@@ -5,10 +5,20 @@
5
5
  "surface": "skill",
6
6
  "description": "Scaffold a new Spiderly entity end-to-end (entity class, Angular pages, routes, menu, migration)"
7
7
  },
8
+ {
9
+ "name": "api-keys",
10
+ "surface": "skill",
11
+ "description": "Add the API-keys feature to a Spiderly app — per-key authentication via an X-Api-Key header, where each key is a first-class principal carrying its own roles. Use when a project needs machine/partner/agent access to its REST API (generate, scope-to-roles, expire, revoke). Opt-in; not part of the default app."
12
+ },
13
+ {
14
+ "name": "backups",
15
+ "surface": "skill",
16
+ "description": "Back up and restore a Spiderly app's Postgres database, and archive logs off the VPS. Use when setting up automated database backups, scheduling pg_dump to object storage (Cloudflare R2), restoring from a backup, running a restore drill, configuring backup retention, or archiving Serilog file logs. Covers the backup and restore scripts, cron scheduling, and the R2 bucket Terraform."
17
+ },
8
18
  {
9
19
  "name": "deployment",
10
20
  "surface": "skill",
11
- "description": "Deploy a Spiderly project to your own infrastructure with Docker, Caddy, and Terraform. Use when deploying, redeploying, shipping, releasing, or rolling out the .NET backend or Angular admin to production — first-time setup or an ongoing deploy — and when diagnosing a down or erroring production origin (502/521, container crash-loop, failed deploy workflow). Also covers CI/CD pipelines, TLS with Cloudflare origin certificates, database backups and restores, and infrastructure-as-code layout for a Spiderly app."
21
+ "description": "Deploy a Spiderly project to production and keep it running. Use when deploying, redeploying, shipping, releasing, or rolling out the .NET backend or Angular admin — first-time setup or an ongoing deploy — and when diagnosing a down or erroring production origin (502/521, container crash-loop, failed deploy workflow). Covers the recommended VPS + Docker Compose + Caddy + Cloudflare + Terraform stack, CI/CD pipelines, TLS with Cloudflare origin certificates, and infrastructure-as-code layout."
12
22
  },
13
23
  {
14
24
  "name": "ef-migrations",
@@ -0,0 +1,267 @@
1
+ ---
2
+ name: api-keys
3
+ description: Add the API-keys feature to a Spiderly app — per-key authentication via an X-Api-Key header, where each key is a first-class principal carrying its own roles. Use when a project needs machine/partner/agent access to its REST API (generate, scope-to-roles, expire, revoke). Opt-in; not part of the default app.
4
+ ---
5
+
6
+ # Add API Keys
7
+
8
+ This builds the API-keys feature into a Spiderly consumer app: external clients authenticate with an `X-Api-Key` header, and each key is a **first-class principal** (`PrincipalKinds.ApiKey`) that carries its **own roles** — it is not an impersonation of a user. Authorization resolves a key's permissions through the same principal pipeline as a user, so `[HasPermission]` endpoints accept either a JWT or a key with no per-endpoint change.
9
+
10
+ **This is opt-in.** API keys are not part of a default Spiderly app — nothing here exists until you run this skill, and an app without it has zero API-key surface. Don't add it unless the project actually needs machine/partner/agent API access.
11
+
12
+ ## Why a key is a principal (read once)
13
+
14
+ An API key must be **individually revocable** and **separately expirable** — a stateless token (e.g. a long-lived JWT) can't be revoked without either rotating the signing key (logging out every user) or a per-request denylist. So a key is an opaque random secret, stored **hashed**, checked against a row on every request. The framework owns that mechanism (`Spiderly.Security`); this skill assembles the per-app pieces on top.
15
+
16
+ Modeling the key as its own `ISecurityPrincipal` (rather than "resolve to the owning user + cap permissions") means:
17
+ - The key's authority = the union of **its own** roles' permissions. A role-less key has **no** permissions.
18
+ - The owning `User` is just **management metadata** (who created/lists/revokes it), decoupled from authority.
19
+ - No runtime permission-cap machinery — authorization treats the key like any other principal.
20
+
21
+ ## Step 0 — Prerequisites
22
+
23
+ 1. **Spiderly version** — confirm the compiled mechanism is present. It must expose `Spiderly.Security.Authentication.IApiKeyAuthenticator`, `AddSpiderlyApiKeyAuthentication`, and `PrincipalKinds.ApiKey`. If a `using Spiderly.Security.Authentication;` doesn't resolve those, the app is on too old a Spiderly version — upgrade first (see the `spiderly-upgrade` skill).
24
+ 2. **The app must already use Spiderly auth** — it registers a human principal (`AddSpiderlyPrincipal<User>(PrincipalKinds.User)`) and calls `spiderly.AddAuthentication()`. API keys add a *second* principal kind alongside it.
25
+
26
+ Decide with the user up front: **do they want an admin UI** to manage keys, or **API/agent-only** (drive generation via the REST endpoints / a management skill)? The backend is identical either way; the UI is the optional Step 8.
27
+
28
+ ## Step 1 — The `ApiKey` entity (+ junction + navs)
29
+
30
+ Create the entity in the Business project's `Entities/`. Adapt the namespace and the `User`/`Role` types to the app.
31
+
32
+ ```csharp
33
+ [Index(nameof(KeyHash), IsUnique = true)]
34
+ [SpiderlyEntity]
35
+ public class ApiKey : BusinessObject<long>, IApiKey
36
+ {
37
+ [UIDoNotGenerate]
38
+ [Required]
39
+ [StringLength(64, MinimumLength = 1)]
40
+ public string KeyHash { get; set; } // SHA-256 of the key; the plaintext is never stored
41
+
42
+ [Required]
43
+ [StringLength(100, MinimumLength = 1)]
44
+ [DisplayName]
45
+ public string Name { get; set; }
46
+
47
+ // Creator — the principal (any kind) that minted this key. Audit metadata, auto-stamped at creation.
48
+ // A soft reference (no FK): the creator can be any principal kind (User, ApiKey, ...), so it's stored
49
+ // as principal id + kind rather than a typed navigation.
50
+ [UIDoNotGenerate]
51
+ public long CreatedById { get; set; }
52
+
53
+ [UIDoNotGenerate]
54
+ [Required]
55
+ [StringLength(50, MinimumLength = 1)]
56
+ public string CreatedByPrincipalKind { get; set; }
57
+
58
+ public DateTime? ExpiresAt { get; set; }
59
+
60
+ public bool? IsRevoked { get; set; }
61
+
62
+ // ISecurityPrincipal: the key's authority is the union of its own roles' permissions (M2M, like User.Roles).
63
+ public virtual List<Role> Roles { get; } = new(); // M2M
64
+ IReadOnlyCollection<IRole> ISecurityPrincipal.Roles => Roles;
65
+
66
+ public bool? IsDisabled { get; set; }
67
+ }
68
+ ```
69
+
70
+ `IApiKey` (which extends `ISecurityPrincipal`) is `Spiderly.Security.Interfaces`; `IRole` is `Spiderly.Shared.Interfaces`. The entity implements `IApiKey` so the framework's default authenticator (Step 2) reads it generically.
71
+
72
+ Add the M2M junction (mirrors the app's `UserRole`):
73
+
74
+ ```csharp
75
+ [M2M]
76
+ [SpiderlyEntity]
77
+ public class ApiKeyRole
78
+ {
79
+ [M2MWithMany(nameof(ApiKey.Roles))]
80
+ public virtual ApiKey ApiKey { get; set; }
81
+
82
+ [M2MWithMany(nameof(Role.ApiKeys))]
83
+ public virtual Role Role { get; set; }
84
+ }
85
+ ```
86
+
87
+ Add the inverse M2M nav `public virtual List<ApiKey> ApiKeys { get; } = new(); // M2M` on **`Role`**. There is **no** nav on `User` — the creator is a soft reference (id + kind), not a navigation.
88
+
89
+ ## Step 2 — The authenticator (you don't write one)
90
+
91
+ The framework ships `DefaultApiKeyAuthenticator<TApiKey>`, which does the hash→active-id lookup over your `ApiKey` entity generically (it reads it via the `IApiKey` interface, so it stays decoupled from your namespace). You get it for free from the Step 4 registration — there is **no authenticator to hand-write**.
92
+
93
+ Only implement your own `IApiKeyAuthenticator` for a non-standard lookup (e.g. an external key store). If you do, register it **before** `AddSpiderlyApiKeyAuthentication<ApiKey>()` — the framework adds the default with `TryAdd`, so your registration wins.
94
+
95
+ ## Step 3 — `ApiKeyService` hooks (generate + hash + show-once)
96
+
97
+ Extend the generated `ApiKeyServiceGenerated` so the admin/CRUD save path mints the key, stores only its hash, and returns the plaintext **exactly once**:
98
+
99
+ ```csharp
100
+ [SpiderlyService]
101
+ public class ApiKeyService : ApiKeyServiceGenerated
102
+ {
103
+ private readonly AuthenticationService _authenticationService;
104
+ private readonly AuthorizationService _authorizationService; // the app's most-derived authorization service
105
+ private string _generatedPlainTextKey;
106
+
107
+ public ApiKeyService(EntityServiceDependencies deps, AuthenticationService authenticationService, AuthorizationService authorizationService) : base(deps)
108
+ {
109
+ _authenticationService = authenticationService;
110
+ _authorizationService = authorizationService;
111
+ }
112
+
113
+ protected override async Task OnBeforeSaveApiKeyAndReturnMainUIFormDTO(ApiKeySaveBodyDTO saveBodyDTO)
114
+ {
115
+ if (saveBodyDTO.ApiKeyDTO.Id <= 0)
116
+ {
117
+ string plainTextKey = ApiKeyHelper.GenerateRandomKey();
118
+ saveBodyDTO.ApiKeyDTO.KeyHash = ApiKeyHelper.ComputeSha256Hash(plainTextKey);
119
+ _generatedPlainTextKey = plainTextKey;
120
+ saveBodyDTO.ApiKeyDTO.CreatedById = _authenticationService.GetCurrentUserId();
121
+ saveBodyDTO.ApiKeyDTO.CreatedByPrincipalKind = _authenticationService.GetCurrentPrincipalKind();
122
+ }
123
+ else
124
+ {
125
+ saveBodyDTO.ApiKeyDTO.KeyHash = await _deps.Context.DbSet<ApiKey>()
126
+ .AsNoTracking().Where(x => x.Id == saveBodyDTO.ApiKeyDTO.Id).Select(x => x.KeyHash).FirstAsync();
127
+ }
128
+ }
129
+
130
+ protected override async Task OnAfterSaveApiKeyAndReturnMainUIFormDTO(ApiKeySaveBodyDTO saveBodyDTO, ApiKeyMainUIFormDTO mainUIFormDTO)
131
+ {
132
+ if (_generatedPlainTextKey != null)
133
+ {
134
+ mainUIFormDTO.PlainTextKey = _generatedPlainTextKey; // add this property via a partial ApiKeyMainUIFormDTO (Step 6)
135
+ _generatedPlainTextKey = null;
136
+ }
137
+ }
138
+
139
+ // Guard EVERY role-assignment path, not just the Generate endpoint: an admin may only grant a key roles
140
+ // whose permissions they themselves hold. This is the M2M role-mutation method, so guarding it here covers
141
+ // the admin roles editor (and any future caller) by construction.
142
+ public override async Task UpdateRolesForApiKey(long id, List<int> selectedIds)
143
+ {
144
+ await _authorizationService.AuthorizeApiKeyRoleAssignmentAsync(selectedIds);
145
+ await base.UpdateRolesForApiKey(id, selectedIds);
146
+ }
147
+ }
148
+ ```
149
+
150
+ `ApiKeyHelper` is `Spiderly.Security.Authentication`. If the app's authorization service has a different class name, inject that.
151
+
152
+ ## Step 4 — Register the principal + the auth scheme
153
+
154
+ In the app's service-registration (where `AddSpiderlyPrincipal<User>` is called, **after** `AddSpiderly(...)`):
155
+
156
+ ```csharp
157
+ services.AddSpiderlyPrincipal<ApiKey>(PrincipalKinds.ApiKey);
158
+ services.AddSpiderlyApiKeyAuthentication<ApiKey>();
159
+ ```
160
+
161
+ `AddSpiderlyApiKeyAuthentication` registers the `ApiKey` scheme **and** a forwarding policy scheme set as the default — so `X-Api-Key` requests route to the key handler and everything else to JWT, with no per-endpoint `[Authorize(AuthenticationSchemes=...)]` churn. Registering a second principal kind makes the app multi-principal; the JWT login already stamps `PrincipalKinds.User`, the key handler stamps `PrincipalKinds.ApiKey`, so both satisfy the now-required `principal_kind` claim.
162
+
163
+ ## Step 5 — The issuance guard (authorization service)
164
+
165
+ In the app's authorization service (the `[SpiderlyService]` extending `AuthorizationServiceGenerated`), add the guard the `UpdateRolesForApiKey` override and the Generate endpoint both call:
166
+
167
+ ```csharp
168
+ /// <summary>
169
+ /// Issuance guard: the principal creating a key may only grant it roles whose permissions that principal
170
+ /// already holds, so a key can never be minted more powerful than its creator. The creator can be any
171
+ /// principal kind (a user, a service account, another key), so the ceiling is resolved principal-agnostically
172
+ /// via GetCurrentPrincipalPermissionCodesAsync() (not the User-typed GetCurrentUserPermissionCodes). No
173
+ /// runtime cap — a key authorizes through its own roles — so this is enforced once, at assignment.
174
+ /// </summary>
175
+ public async Task AuthorizeApiKeyRoleAssignmentAsync(List<int> roleIds)
176
+ {
177
+ if (roleIds == null || roleIds.Count == 0)
178
+ return;
179
+
180
+ HashSet<string> creatorPermissions = (await GetCurrentPrincipalPermissionCodesAsync()).ToHashSet();
181
+
182
+ List<string> targetRolePermissions = await _context.DbSet<Role>()
183
+ .AsNoTracking()
184
+ .Where(r => roleIds.Contains(r.Id))
185
+ .SelectMany(r => r.Permissions)
186
+ .Select(p => p.Code)
187
+ .Distinct()
188
+ .ToListAsync();
189
+
190
+ if (!targetRolePermissions.All(p => creatorPermissions.Contains(p)))
191
+ throw new BusinessException(_localizer["ApiKeyRoleExceedsYourPermissionsException"]);
192
+ }
193
+ ```
194
+
195
+ Add the `ApiKeyRoleExceedsYourPermissionsException` translation key (see the `backend-localization` doc).
196
+
197
+ ## Step 6 — Generate / Revoke endpoints + DTOs
198
+
199
+ These are the machine-facing REST surface (also what a management skill drives). Add to a `[SpiderlyController]` (e.g. the security controller). DTOs go in the Admin DTO folder; the `PlainTextKey` extension is a **partial** of the generated `ApiKeyMainUIFormDTO` and must use the bare `...Business.DTO` namespace (see the `entity-design` doc on partial-DTO namespacing).
200
+
201
+ ```csharp
202
+ // GenerateApiKeyRequestDTO: string Name; int? ExpiresInDays; [Required] List<int> RoleIds = new();
203
+ // GenerateApiKeyResponseDTO: string Key; string Name; DateTime? ExpiresAt; [Required] List<int> RoleIds = new(); [Required] List<string> RoleNames = new();
204
+ // RevokeApiKeyRequestDTO: [Required] long ApiKeyId;
205
+ // partial ApiKeyMainUIFormDTO (bare DTO namespace): [UIDoNotGenerate][StringLength(64)] string PlainTextKey;
206
+
207
+ [HttpPost, AuthGuard]
208
+ public async Task<GenerateApiKeyResponseDTO> GenerateApiKey([FromBody] GenerateApiKeyRequestDTO request)
209
+ {
210
+ await _authorizationService.AuthorizeAndThrowAsync(PermissionCodes.InsertApiKey); // principal-agnostic — any principal kind can create a key
211
+ long currentUserId = _authenticationService.GetCurrentUserId();
212
+
213
+ List<Role> roles = new();
214
+ if (request.RoleIds != null && request.RoleIds.Count > 0)
215
+ {
216
+ roles = await _context.DbSet<Role>().Where(x => request.RoleIds.Contains(x.Id)).ToListAsync();
217
+ List<int> missing = request.RoleIds.Except(roles.Select(r => r.Id)).ToList();
218
+ if (missing.Count > 0) throw new BusinessException($"Role(s) not found: {string.Join(", ", missing)}.");
219
+ await _authorizationService.AuthorizeApiKeyRoleAssignmentAsync(request.RoleIds);
220
+ }
221
+
222
+ string plainTextKey = ApiKeyHelper.GenerateRandomKey();
223
+ DateTime? expiresAt = request.ExpiresInDays.HasValue ? DateTime.UtcNow.AddDays(request.ExpiresInDays.Value) : null;
224
+
225
+ ApiKey apiKey = new() { KeyHash = ApiKeyHelper.ComputeSha256Hash(plainTextKey), Name = request.Name,
226
+ CreatedById = currentUserId, CreatedByPrincipalKind = _authenticationService.GetCurrentPrincipalKind(),
227
+ ExpiresAt = expiresAt, IsRevoked = false };
228
+ apiKey.Roles.AddRange(roles);
229
+ _context.DbSet<ApiKey>().Add(apiKey);
230
+ await _context.SaveChangesAsync();
231
+
232
+ return new GenerateApiKeyResponseDTO { Key = plainTextKey, Name = request.Name, ExpiresAt = expiresAt,
233
+ RoleIds = roles.Select(r => r.Id).ToList(), RoleNames = roles.Select(r => r.Name).ToList() };
234
+ }
235
+
236
+ [HttpPost, AuthGuard]
237
+ public async Task RevokeApiKey([FromBody] RevokeApiKeyRequestDTO request)
238
+ {
239
+ await _authorizationService.AuthorizeAndThrowAsync(PermissionCodes.UpdateApiKey);
240
+ ApiKey apiKey = await _context.DbSet<ApiKey>().FirstOrDefaultAsync(x => x.Id == request.ApiKeyId)
241
+ ?? throw new BusinessException("API key not found.");
242
+ apiKey.IsRevoked = true;
243
+ await _context.SaveChangesAsync();
244
+ }
245
+ ```
246
+
247
+ ## Step 7 — Seed the `ApiKey` permissions + migrate
248
+
249
+ 1. **Seed permissions** so the `PermissionCodes.{Read,Insert,Update,Delete}ApiKey` constants generate — add `Permission` seed rows (codes `ReadApiKey`/`InsertApiKey`/`UpdateApiKey`/`DeleteApiKey`) in the app's DbContext seed data, alongside the existing User/Role permission seeds, and grant them to the admin role.
250
+ 2. **Migration** — add + apply an EF migration for the new `ApiKey` + `ApiKeyRole` tables (see the `ef-migrations` skill). If you're converting an *existing* single-`Role` API-key table to this M2M model, make the migration **data-preserving**: create the junction first, `INSERT ... SELECT` the old `RoleId`s into it, then drop the old column.
251
+
252
+ ## Step 8 — (Optional) admin UI
253
+
254
+ Only if the user wants a clickable admin surface (otherwise skip — keys are managed via the endpoints / a management skill). Scaffold the list + details pages for the `ApiKey` entity (see the `add-entity` skill for pages/routes/menu), then customize the details page to show the generated key **once**: intercept the save response, and if it carries `plainTextKey`, show it in a read-only "copy this — it won't be shown again" dialog and suppress the auto-reroute until the dialog closes. A roles editor on the form routes through `UpdateRolesForApiKey`, which Step 3 already guards.
255
+
256
+ To keep the feature **headless** instead, add `[UIDoNotGenerate]` to the `ApiKey` class and don't create pages.
257
+
258
+ ## Step 9 — Verify
259
+
260
+ Build the backend (`dotnet build` the Business project, then the WebAPI project). Then sanity-check the flow: generate a key (admin JWT) → call any `[HasPermission]` endpoint the key's roles cover with `X-Api-Key: <key>` → revoke it → confirm the same call now returns 401.
261
+
262
+ ## Invariants to preserve
263
+
264
+ - **Store only the hash.** The plaintext is returned once at generation and never persisted.
265
+ - **Guard every role-assignment path.** Both the Generate endpoint and `UpdateRolesForApiKey` call `AuthorizeApiKeyRoleAssignmentAsync` — a key can never be minted more powerful than its creator.
266
+ - **A role-less key has no permissions** (deny), by design. Assign an admin role for a full-access key.
267
+ - **Revocation/expiry/disabled are checked live** in `DefaultApiKeyAuthenticator` on every request — that's the whole point of opaque keys over JWTs.
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: backups
3
+ description: Back up and restore a Spiderly app's Postgres database, and archive logs off the VPS. Use when setting up automated database backups, scheduling pg_dump to object storage (Cloudflare R2), restoring from a backup, running a restore drill, configuring backup retention, or archiving Serilog file logs. Covers the backup and restore scripts, cron scheduling, and the R2 bucket Terraform.
4
+ ---
5
+
6
+ # Backups
7
+
8
+ If you ship this stack to prod, you ship a backup with it. Postgres data lives in a Docker volume on a single VPS — disk failure, accidental `docker volume rm`, or `terraform destroy` all wipe it without a snapshot to restore from.
9
+
10
+ ## Database backups (required)
11
+
12
+ Daily `pg_dump` to a Cloudflare R2 bucket, 7-day retention both local and remote. ~24h RPO; if you need tighter, layer WAL archiving on top (separate skill).
13
+
14
+ **1. R2 bucket — Terraform-managed.** No chicken-and-egg here (only the *state* bucket has to be bootstrapped manually); manage data buckets like any other resource:
15
+
16
+ ```hcl
17
+ # infrastructure/cloudflare-r2.tf
18
+ resource "cloudflare_r2_bucket" "db_backups" {
19
+ account_id = var.cloudflare_account_id
20
+ name = "<your-app>-db-backups"
21
+ location = "EEUR"
22
+
23
+ lifecycle {
24
+ prevent_destroy = true
25
+ }
26
+ }
27
+ ```
28
+
29
+ **2. Backup script — `infrastructure/scripts/pg_backup_s3.sh`** (deployed to `/usr/local/bin/` on the VPS). Note the `trap` cleanup, the `aws s3api list-objects-v2 --query` for retention (structured + locale-safe vs parsing `aws s3 ls` with awk), and the per-iteration warn-on-failure inside the `while` subshell (where `set -e` does NOT propagate):
30
+
31
+ ```bash
32
+ #!/usr/bin/env bash
33
+ set -euo pipefail
34
+
35
+ CONTAINER_NAME="<your-app>-postgres-1"
36
+ DB_NAME="<your-db>"
37
+ DB_USER="postgres"
38
+ CLOUDFLARE_ACCOUNT_ID="<account-id>"
39
+ S3_BUCKET="s3://<your-app>-db-backups"
40
+ R2_ENDPOINT="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
41
+ RETENTION_DAYS=7
42
+ LOCAL_BACKUP_DIR="/var/backups/postgresql"
43
+ LOG_FILE="/var/log/<your-app>_pg_backup.log"
44
+
45
+ TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
46
+ BACKUP_FILE="<your-app>_${TIMESTAMP}.sql.gz"
47
+ LOCAL_PATH="${LOCAL_BACKUP_DIR}/${BACKUP_FILE}"
48
+
49
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"; }
50
+
51
+ trap 'rc=$?; [[ $rc -ne 0 ]] && rm -f "$LOCAL_PATH" && log "Aborted, removed partial $LOCAL_PATH"; exit $rc' ERR INT TERM
52
+
53
+ mkdir -p "$LOCAL_BACKUP_DIR"
54
+
55
+ if ! docker exec "$CONTAINER_NAME" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$LOCAL_PATH"; then
56
+ log "ERROR: pg_dump failed"; exit 1
57
+ fi
58
+ log "Backup created: ${LOCAL_PATH} ($(du -h "$LOCAL_PATH" | cut -f1))"
59
+
60
+ if ! aws s3 --endpoint-url "$R2_ENDPOINT" cp "$LOCAL_PATH" "${S3_BUCKET}/${BACKUP_FILE}"; then
61
+ log "ERROR: S3 upload failed"; exit 1
62
+ fi
63
+ log "Uploaded to ${S3_BUCKET}/${BACKUP_FILE}"
64
+
65
+ find "$LOCAL_BACKUP_DIR" -name "<your-app>_*.sql.gz" -mtime +"$RETENTION_DAYS" -delete
66
+
67
+ CUTOFF_ISO=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)
68
+ BUCKET_NAME="${S3_BUCKET#s3://}"
69
+ OLD_KEYS=$(aws s3api --endpoint-url "$R2_ENDPOINT" list-objects-v2 \
70
+ --bucket "$BUCKET_NAME" \
71
+ --query "Contents[?LastModified<'${CUTOFF_ISO}'].Key" \
72
+ --output text 2>/dev/null || echo "")
73
+ if [[ -n "$OLD_KEYS" && "$OLD_KEYS" != "None" ]]; then
74
+ for KEY in $OLD_KEYS; do
75
+ aws s3 --endpoint-url "$R2_ENDPOINT" rm "${S3_BUCKET}/${KEY}" \
76
+ && log "Deleted remote: ${KEY}" \
77
+ || log "WARN: failed to delete remote ${KEY}"
78
+ done
79
+ fi
80
+ ```
81
+
82
+ **3. VPS prerequisites.** Add `awscli` and `cron` to `cloud-init` `packages:` so future server replacements have them. Configure R2 credentials in `/root/.aws/credentials` (same keys as the Terraform state backend — they're account-scoped):
83
+
84
+ ```ini
85
+ [default]
86
+ aws_access_key_id = <R2 access key>
87
+ aws_secret_access_key = <R2 secret>
88
+ ```
89
+
90
+ **4. Cron entry** (`/etc/cron.d/<your-app>-pg-backup`). Wrap with `flock -n` so a hung run doesn't get a second instance started 24h later:
91
+
92
+ ```
93
+ 0 2 * * * root /usr/bin/flock -n /var/lock/<your-app>-pg-backup.lock /usr/local/bin/pg_backup_s3.sh >> /var/log/<your-app>_pg_backup.log 2>&1
94
+ ```
95
+
96
+ **5. Restore — `scripts/db-restore.sh`** (run from local). Lists server-side backups via SSH, prompts for selection, takes a safety dump first (with size check — refuses to proceed if pg_dump silently produced an empty file), then restores. Note `set -euo pipefail` inside the SSH heredocs (without it, a `pg_dump` failure inside a pipeline is masked by a successful `gzip`) and the `OVERWRITE <db>` confirmation phrase (a bare DB name is too easy to typo into):
97
+
98
+ ```bash
99
+ #!/usr/bin/env bash
100
+ set -euo pipefail
101
+ SSH_ALIAS="<your-app>"
102
+ CONTAINER="<your-app>-postgres-1"
103
+ DB="<your-db>"
104
+ REMOTE_DIR="/var/backups/postgresql"
105
+ MIN_SAFETY_BYTES=1024
106
+
107
+ trap 'echo "Interrupted — DB may be inconsistent. Latest safety snapshot is in $SSH_ALIAS:$REMOTE_DIR."' INT TERM
108
+
109
+ ssh -o ConnectTimeout=5 "$SSH_ALIAS" "docker exec $CONTAINER pg_isready -U postgres -d $DB" >/dev/null \
110
+ || { echo "Postgres not ready"; exit 1; }
111
+
112
+ mapfile -t BACKUPS < <(ssh "$SSH_ALIAS" "ls -1t $REMOTE_DIR/<your-app>_*.sql.gz 2>/dev/null | xargs -n1 basename")
113
+ [[ ${#BACKUPS[@]} -gt 0 ]] || { echo "No backups found"; exit 1; }
114
+ for i in "${!BACKUPS[@]}"; do printf " %2d) %s\n" "$((i+1))" "${BACKUPS[$i]}"; done
115
+ read -rp "Pick: " PICK
116
+ [[ "$PICK" =~ ^[0-9]+$ ]] && (( PICK >= 1 && PICK <= ${#BACKUPS[@]} )) || { echo "Invalid"; exit 1; }
117
+ CHOSEN="${BACKUPS[$((PICK-1))]}"
118
+
119
+ read -rp "Type 'OVERWRITE $DB' to confirm: " CONFIRM
120
+ [[ "$CONFIRM" == "OVERWRITE $DB" ]] || { echo "aborted"; exit 1; }
121
+
122
+ SAFETY="<your-app>_pre_restore_$(date +%Y-%m-%d_%H%M%S).sql.gz"
123
+ ssh "$SSH_ALIAS" "set -euo pipefail; docker exec $CONTAINER pg_dump -U postgres $DB | gzip > $REMOTE_DIR/$SAFETY"
124
+ SIZE=$(ssh "$SSH_ALIAS" "stat -c%s $REMOTE_DIR/$SAFETY")
125
+ (( SIZE >= MIN_SAFETY_BYTES )) || { echo "Safety snapshot suspiciously small ($SIZE B) — aborting"; exit 1; }
126
+
127
+ ssh "$SSH_ALIAS" "
128
+ set -euo pipefail
129
+ docker exec $CONTAINER psql -U postgres -d postgres -c \"DROP DATABASE IF EXISTS \\\"$DB\\\" WITH (FORCE);\"
130
+ docker exec $CONTAINER psql -U postgres -d postgres -c \"CREATE DATABASE \\\"$DB\\\";\"
131
+ gunzip -c $REMOTE_DIR/$CHOSEN | docker exec -i $CONTAINER psql -U postgres -d $DB
132
+ "
133
+ echo "Restored. Safety snapshot at $SSH_ALIAS:$REMOTE_DIR/$SAFETY."
134
+ ```
135
+
136
+ **6. Restore drill — quarterly.** A backup you've never restored from is not a backup. At least once a quarter, restore the latest dump into a throwaway local Postgres and verify schema + row counts. Calendar reminder.
137
+
138
+ ## Log archival (optional)
139
+
140
+ When to use it: you need long-term log retention beyond Docker's `json-file` rotation buffer (default ~150 MB rolling per container, configured in compose).
141
+
142
+ Pattern: a Hangfire recurring job watches `/app/logs/`, ships files older than N days to R2 once total > threshold, deletes locally.
143
+
144
+ To make `/app/logs` writable under `USER app`:
145
+
146
+ - **Bind mount with chowned host dir** (recommended): `volumes: - /var/log/<your-app>:/app/logs` in compose, one-time `mkdir -p /var/log/<your-app> && chown 1654:1654 /var/log/<your-app>` on the VPS. Bind mounts respect host ownership; named volumes overlay the path with root-owned storage which `app` can't write to.
147
+ - **No File sink at all** (simpler): rely on Console + Docker rotation. Drop the `/app/logs` volume entirely. Right answer for greenfield deploys.
148
+