@astram/guide 0.2.4 → 0.2.6

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.
Files changed (2) hide show
  1. package/CAPABILITIES.md +211 -7
  2. package/package.json +1 -1
package/CAPABILITIES.md CHANGED
@@ -44,8 +44,19 @@ imports: [
44
44
  `identityControlPlaneKeys?`, `config?`, and optional overrides `credentialResolver?`,
45
45
  `profileResolver?`, `credentialWriter?`, `resetTokenStore?`, `recoveryCodeStore?`,
46
46
  `profileWriter?`, `sessionSweeper?`, `auditSink?`, `mfaDeviceStore?`, `totpVerifier?`,
47
- `sessionFactsProjector?`, `scopeSubtreeResolver?`, `membershipChecker?`. With `auth: true` every
48
- override has a MariaDB/Redis default; leave them out unless you have a reason.
47
+ `sessionFactsProjector?`, `scopeSubtreeResolver?`, `membershipChecker?`, and
48
+ `storage?: ObjectStore | 'env'` (file storage, below). With `auth: true` every override has a
49
+ MariaDB/Redis default; leave them out unless you have a reason.
50
+ - **Secrets at boot** — `main.ts` calls `await resolveBootSecrets()` (`@astram/guard`) first,
51
+ then loads `AppModule` with `await import('./app.module')` (the scaffold already does). Every env
52
+ value written as `secret-ref:<path>` is replaced with the secret before any `forRoot` reads env.
53
+ Provider: `SECRETS_PROVIDER` = `none` (default) | `encrypted-file` (`SECRETS_FILE`,
54
+ `SECRETS_KEK_FILE` (a mounted 32-byte key file, never an env value), `SECRETS_DEK_FILE`); `vault`
55
+ is reserved and refused. A missing, unreadable or corrupt `SECRETS_FILE` aborts boot naming the
56
+ variable, even before any reference uses it. An unresolvable reference, or a reference with no provider, aborts
57
+ boot (env key and path only, never a value). If `main.ts` skips the call while a reference is in
58
+ env, `ShieldModule` refuses to boot (`assertNoUnresolvedSecretRefs`). Do not import `AppModule`
59
+ statically in `main.ts`.
49
60
  - **`IdentityFeatureModule.forRoot({ rootIds?, extraObjects? })`** (`@astram/identity`). Pass the
50
61
  SAME `rootIds` as `ShieldModule`. `extraObjects` (your own registered object types) are given
51
62
  HERE ONLY: the module builds the record authorization from them and `ShieldModule` decides with
@@ -123,8 +134,8 @@ imports: [
123
134
  (099/100) run only with `NODE_ENV=development` or `ASTRAM_SEED_DEV=1`.
124
135
 
125
136
  ## The record pipe (`@astram/engine`)
126
- - **`RecordEngine`** (token `RECORD_ENGINE`) / `RecordsService` — create/get/update/delete/list
127
- over registered object types through one pipe: permission gate (`decide()`) → server-trusted
137
+ - **`RecordEngine`** (token `RECORD_ENGINE`) / `RecordsService` — create/get/update/delete/list,
138
+ plus `aggregate` and `createMany`, over registered object types through one pipe: permission gate (`decide()`) → server-trusted
128
139
  write context → validate → write → junction sync → audit (mandatory) → event.
129
140
  - **Record decision inputs**: `get`, `update` and `delete` read the stored row first and pass its
130
141
  fields and tenant to `decide()`, so record rules such as "deny update where status eq locked"
@@ -134,6 +145,44 @@ imports: [
134
145
  (record rules, owner shares, cross-tenant grants, subtree), filtered in SQL; `get()` uses the
135
146
  same scope. Membership/subtree-scoped lists ("companies I belong to") are provided.
136
147
  - Stable paging: every `list()` orders by the primary key last.
148
+ - **`list()` filters** (`where`, field key → value, ANDed): a scalar is equality (`null` ⇒
149
+ `IS NULL`); or exactly one operator object per field: `eq`, `in` (≤ 1000 values; `[]` matches
150
+ nothing), `gt`/`gte`/`lt`/`lte`, `between: [lo, hi]` (range operators on `string`, `number`,
151
+ `uuid`, `timestamp` only), `isNull: true`, `isNotNull: true`. Every operand is validated and
152
+ coerced like a write (bad operand ⇒ `ValidationError`; uuids lower-cased, booleans bound as
153
+ 1/0, timestamps as `Date`) and bound as a parameter. An unknown field or operator, two
154
+ operators in one object, or an operator not allowed for the field type (`eq`/`in` on `json`,
155
+ ranges on `boolean`/`json`) throws `RegistrationError`; nothing degrades to "no filter".
156
+ Only a scalar `null` means `IS NULL` (or use `isNull: true`): `{ eq: null }` and a `null`
157
+ inside `in` are rejected with `ValidationError`. A reversed `between` (`lo > hi`) is not
158
+ swapped and matches nothing. A `number` operand of ±Infinity is a `ValidationError`.
159
+ Filters run inside the same scoped query, so `list ≡ decide()` still holds.
160
+ - **Behaviour change (engine 0.7):** equality in `where` is now validated and coerced like a
161
+ write; `eq` on json fields is rejected; scalar `null` now means `IS NULL` (it matched nothing
162
+ before). A key present with the value `undefined` (`where: { x: undefined }`) now throws
163
+ `ValidationError`: when spreading optional filters into `where`, omit undefined keys. An invalid timestamp (an invalid `Date`, `NaN` or ±Infinity epoch) is now a
164
+ `ValidationError` on writes too, and so is ±Infinity on a `number` field (writes and filters).
165
+ - **`aggregate(typeKey, { where?, search?, groupBy?, measures, limit? })`**: `count` / `sum` /
166
+ `avg` / `min` / `max` over exactly the rows `list()` returns for the same `where`/`search`.
167
+ Gated as object-type `read` like `list()`; writes no audit row. `groupBy` ≤ 3 non-json keys;
168
+ 1–10 measures, each `{ fn, field?, as }` with `as` matching `^[a-z_][a-z0-9_]{0,63}$`;
169
+ `sum`/`avg` on `number` only; `min`/`max` on `string`, `number`, `uuid`, `timestamp` (returns
170
+ the column's value, a `Date` for timestamps). Groups are ordered by the group keys; ungrouped ⇒
171
+ one group, grouped with no rows ⇒ `groups: []`. `limit` caps the number of groups (default 100,
172
+ max 1000).
173
+ - **Silent truncation:** a grouped aggregate returns at most `limit` groups and gives no signal
174
+ that more exist. If the number of groups can exceed `limit`, count the groups first or narrow
175
+ `where`.
176
+ - **DECIMAL precision:** `sum`/`avg` (and numeric `min`/`max`) come back as JS `number`. An
177
+ integer outside the safe range throws `EngineUnavailableError`, but a DECIMAL result that
178
+ does not fit a double is returned rounded. Do not use `aggregate` for exact money totals on
179
+ DECIMAL columns.
180
+ - **`createMany(typeKey, inputs)`**: 1–500 rows per call (otherwise `ValidationError` before any
181
+ write). Each row runs the full single `create()` pipe (gate, validate, create wall, audit) in
182
+ one transaction, in input order; ids come back in input order. The first failing row throws
183
+ and the whole batch rolls back, audit rows included. Each row gets its own audit event tagged
184
+ with the call's `batchId` and the row's `batchIndex` (ADR-0039). Inside `transaction(fn)` it
185
+ joins the outer transaction.
137
186
  - `transaction(fn)` runs several pipe calls in one unit of work (rolls back on any failed write);
138
187
  `lockForUpdate(typeKey, id)` serialises a check-then-write.
139
188
  - `@Field({ immutable: true })` create-only fields; `trusted` fields are stamped server-side.
@@ -149,12 +198,167 @@ imports: [
149
198
  (a Rule-4 STOP). Field types this version: `string`, `number`, `boolean`, `uuid`, `timestamp`,
150
199
  `json`.
151
200
 
201
+ ## Custom object actions (ADR-0038; engine 0.7, guard 0.14, identity 0.11)
202
+ - **Declare domain verbs on the type**: `@ObjectType({ ..., actions: ['activate', 'review'] })`.
203
+ The type's vocabulary is `read, create, update, delete` plus the declared actions, and each one
204
+ is a Gated permission key `identity:<typeKey>:<action>` on the type's `access.plane`.
205
+ `crudActions: false` drops the CRUD verbs (for authority-only types never written through the
206
+ engine, which then refuses create/update/delete on them; engine get/list on such a type without
207
+ a `read` action are denied by the gate, not refused pre-SQL like the writes); it needs a
208
+ non-empty `actions`.
209
+ - Action names: snake_case (`^[a-z][a-z0-9_]*$`), at most 64 characters, no duplicates; `*` and
210
+ `access` are reserved; a CRUD verb in `actions` is rejected unless `crudActions: false`. A bad
211
+ declaration fails boot with `RegistrationError` naming the type and action.
212
+ - **Gate a route on it**: `@Classified({ typeKey: '<type>', action: 'activate' })`. List the type in
213
+ `IdentityFeatureModule.forRoot({ extraObjects })` (the decision authority and the catalog only
214
+ know those types). An action the type does not declare still aborts boot (`verifyGatable`).
215
+ Without the grant the route is 403.
216
+ - **Record-level check in the handler**: `records.authorize(typeKey, id, 'activate')` (also on
217
+ `BoundRecords` and the `transaction(fn)` handle). It runs the type gate, reads the row by id in
218
+ the caller's tenant, and decides at record level (record rules and owner shares for that
219
+ action or `*`). Allow resolves `void`; deny, a missing row and another tenant's row all throw
220
+ the same `AuthorizationDeniedError` (403); an undeclared action throws `RegistrationError`
221
+ (500) before any query. It returns no row data, writes no audit row and is not a list filter.
222
+ - **A handler that writes needs two grants**: `authorize()` for the custom action, then the
223
+ write through the normal pipe (`records.update(...)`), which needs `update` and is audited.
224
+ There is no write under the custom action's key. With `activate` alone the update is 403. To
225
+ keep check and write consistent, run both inside `records.transaction(fn)`.
226
+ - **Two behaviours of `authorize()` to know**:
227
+ - On a type with `readScope`, a non-root caller is denied for a row outside its write scope
228
+ (the same wall `update` uses), even when the custom action reads like a non-write check.
229
+ - Inside `transaction(fn)` it reads the row `FOR UPDATE` (a row lock held until commit) even
230
+ though it writes nothing; outside a transaction it is a plain read.
231
+ - **Admin surface picks them up**: the permission catalog (`GET /identity/permission-catalog`),
232
+ role grants, the grant ceiling, `identity:<type>:*` wildcards and `@AccessBundle` all accept
233
+ the declared actions. A record share or a record policy for an action the type does not
234
+ declare is a 400 (`*` still allowed). A record policy on action `create` is also a 400
235
+ (`InvalidPolicyError`): record rules are evaluated per record and never apply at create, so
236
+ gate creation with the type's `create` grant; a `*` rule applies to the other actions. The
237
+ scaffolded access form shows each type's extra
238
+ actions as extra checkboxes.
239
+ - **Auth facts**: pass `buildIdentityAuthKeys(extraObjects)` (`@astram/identity`, returns
240
+ `{ actionKeys, controlPlaneKeys }`) to `ShieldModule.forRoot({ identityActionKeys,
241
+ identityControlPlaneKeys })` with the SAME `extraObjects` array, so `/identity/auth-facts`
242
+ includes your types' custom keys. The plain `identityAuthActionKeys` /
243
+ `identityControlPlaneKeys` cover identity's own types only.
244
+ - **Changing the action list later**:
245
+ - Removing a declared action orphans its catalog row: new grants and bundles on it fail, held
246
+ keys stop counting in auth facts, and a route classified on it fails boot. Re-adding the action
247
+ reactivates the row, and existing role grants on it take effect again. If that is not wanted,
248
+ revoke the grants before removing the action.
249
+ - Record shares and record rules with action `*` cover custom actions added later. Review
250
+ wildcard shares and rules when you add a sensitive action.
251
+ - A share whose action has since been removed can still be revoked: `unshare` skips the
252
+ declared-action check (revoking cannot grant). An unknown type is still a 400.
253
+ - `actionsOf(reg)` (`@astram/guard`) returns a registration's vocabulary. `actionsFor(typeKey)`
254
+ is deprecated and returns only the CRUD verbs for every type.
255
+
256
+ ## File upload and object storage (`@astram/guard` + `@astram/identity`)
257
+ One file per request, kept in a private S3/MinIO bucket with SSE-S3, with a `stored_file` record
258
+ on the record pipe. You write no storage, MIME or authorization code.
259
+
260
+ ```ts
261
+ // app.module.ts: ShieldModule.forRoot({ ..., storage: 'env' }) + IdentityFeatureModule.forRoot(...)
262
+ import { Classified, FileIntake, FILE_INTAKE_REQUEST_KEY, type FileIntakeResult } from '@astram/guard';
263
+ import { FilesService } from '@astram/identity';
264
+
265
+ @Post('imports')
266
+ @Classified({ typeKey: 'stored_file', action: 'create' }) // or your own type/action
267
+ @UseInterceptors(FileIntake({ field: 'file', profile: 'xlsx' }))
268
+ upload(@Req() req: Record<string, unknown>) {
269
+ const intake = req[FILE_INTAKE_REQUEST_KEY] as FileIntakeResult;
270
+ return this.files.store({ ...intake.file, bytes: intake.bytes });
271
+ // 201 { uuid, sha256, sizeBytes, contentType, originalName }; never the storage key
272
+ }
273
+ ```
274
+
275
+ - **`FileIntake({ field, profile, maxBytes? })`** — runs after the global guard, so a denied
276
+ caller's body is never read. Exactly one file in the named field, no other fields. Checks size,
277
+ extension, declared type and magic bytes; the stored type comes from the profile, never from the
278
+ client; the name is sanitized for display only. Errors (HTTP status + fixed reason code, never
279
+ the name or bytes): 400 `missing_file`, `too_many_files` (also a file sent under another field
280
+ name: multer reports it as an unexpected file), `empty`, `bad_request` (malformed or truncated
281
+ multipart, raw control characters in part headers); 413 `too_large`; 415 `type_not_allowed`,
282
+ `content_mismatch`. Raw UTF-8 and RFC 5987 (`filename*=UTF-8''…`) file names decode to the
283
+ same name. Size: the route cap defaults to the profile's configured cap
284
+ (`OBJECT_STORAGE_MAX_BYTES` under `storage: 'env'`, else 10 MiB); `maxBytes` can only lower it.
285
+ Profiles: `xlsx` only (50 MiB hard ceiling for any profile, route or env value). Needs
286
+ `@nestjs/platform-express` (an optional peer; the scaffold has it).
287
+ - **`FilesService`** (provided by `IdentityFeatureModule`) — `store(intake)`, `open(uuid)`,
288
+ `remove(uuid)`. No actor argument: tenant, owner and audit actor come from the session. `store`
289
+ puts the object at `t/<tenant>/<uuid>`, then creates the record (gate, tenant stamp, audit); a
290
+ failed or denied create deletes the object. `remove` is a hard delete of record and object.
291
+ - **`GET /files/:uuid`** (framework route) — `stored_file:read` decided per record: the uploader
292
+ reads by owner baseline, anyone else needs a grant or a share; access is never inherited from
293
+ your own record that points at the file. Missing and foreign answer the same 403; non-uuid 400.
294
+ The bytes are re-hashed against the stored sha256. Headers: `Content-Disposition: attachment`
295
+ (RFC 5987 name), `X-Content-Type-Options: nosniff`, `Cache-Control: no-store`, and
296
+ `Content-Type` from the record when it is an allowlisted type, else `application/octet-stream`.
297
+ - **Status codes** — storage not configured (no `storage` option or `OBJECT_STORAGE_BUCKET`
298
+ unset) ⇒ 501 from `FilesService` and the route; store down, stored object missing or sha256
299
+ mismatch ⇒ 503 with a fixed message.
300
+ - **Audit** — the create is the pipe's `admin_action` row; each read, delete and refused access
301
+ writes a `file_access` event (no key, name or bytes). `file_access` is a new `AuditEventType`
302
+ member: add a case if you `switch` over the union.
303
+ - **Env** (`loadObjectStorageConfig`): `OBJECT_STORAGE_BUCKET` (unset ⇒ storage off),
304
+ `OBJECT_STORAGE_ENDPOINT` (`https:`; `http:` only in development/test or to a loopback host),
305
+ `OBJECT_STORAGE_REGION` (default `us-east-1`), `OBJECT_STORAGE_ACCESS_KEY_ID`,
306
+ `OBJECT_STORAGE_SECRET_ACCESS_KEY`, `OBJECT_STORAGE_SSE` (`AES256` only),
307
+ `OBJECT_STORAGE_MAX_BYTES` (plain decimal bytes, no leading zeros; the per-file cap for the
308
+ `xlsx` profile and every `FileIntake` route). Outside development/test the two credentials must be
309
+ `secret-ref:` values resolved at boot; a literal is refused. With `storage: 'env'` the bucket is
310
+ pinged at boot (probe put/get/delete under `_probe/`); failure aborts boot.
311
+ - **Your xlsx parser must cap entry count and total uncompressed size.** The framework checks only
312
+ the zip header and `[Content_Types].xml`, not every entry, so a zip bomb in another part passes
313
+ the upload check.
314
+ - **Lint wall** — the scaffold bans `multer`, `@aws-sdk/*`, `minio`, `fs` / `node:fs` (and
315
+ `/promises`) in `src/features/**`: use `FileIntake` and `FilesService`, never the bucket.
316
+ - Do not grant `stored_file:create` to app roles; uploads go through `FilesService`.
317
+ - Not provided: malware scanning, per-tenant encryption of file bytes (SSE-S3 only), streaming
318
+ downloads, dedup (each upload is its own record; `sha256` is returned so you can detect repeats).
319
+
320
+ ## Database names and several environments on one server (`SHIELD_DB_NAMESPACE`, ADR-0037)
321
+ - The framework uses four databases, `<ns>_app`, `<ns>_identity`, `<ns>_authz` and `<ns>_audit`,
322
+ with its own accounts (`<ns>_core`, `<ns>_migrate`, `<ns>_audit_ops`, …). If you leave
323
+ `SHIELD_DB_NAMESPACE` unset, `<ns>` is `shield` (`shield_app`, …), which is unchanged from
324
+ earlier releases.
325
+ - **To run dev and beta on one MariaDB server**, give each environment its own namespace. It must
326
+ be lowercase letters and digits only (`beta`, `staging2`; no `_`). Then:
327
+ 1. Provision it with the framework's `db-provision --namespace beta` (from the astram-framework
328
+ repo, runbook §6). Use distinct passwords per namespace.
329
+ 2. Set `SHIELD_DB_NAMESPACE=beta` for both the server and `npm run migration:run`. The same
330
+ value must reach both.
331
+ 3. Run migrations (the ledger is `beta_app.schema_migrations`), then start the server.
332
+ - Defaults follow the namespace: `APP_DB_USER` ⇒ `<ns>_core`, `APP_DB_NAME` ⇒ `<ns>_identity`,
333
+ `DDL_DB_USER` ⇒ `<ns>_migrate`. Explicit values win.
334
+ - **Rules your app must follow:**
335
+ - `APP_DB_NAME` must be one of the four framework databases, even with the namespace unset.
336
+ Any other value refuses to boot.
337
+ - An invalid namespace refuses to boot and to migrate.
338
+ - Use a separate Redis (instance or DB index) per environment: Redis keys are not namespaced.
339
+ - Your own object types keep a literal `schema` or none. Do not hard-code `shield_*` names in
340
+ app SQL or migrations: read them from the injected `FRAMEWORK_SCHEMAS` (`@astram/guard`).
341
+ - **Boot check:** outside development/test (an unset `NODE_ENV` counts as production), the server
342
+ refuses to start when framework migrations are missing from the ledger, no ledger is found, or
343
+ the ledger is unreachable. Run `migration:run` before starting. With the namespace unset, the
344
+ server looks for the ledger in `DDL_DB_NAME`, its own `APP_DB_NAME`, then `shield_app`, so
345
+ `migration:run` with the server's `.env` is found. If your migrate job uses a different
346
+ `APP_DB_NAME`, set the same `DDL_DB_NAME` for migrate and the server.
347
+ - The `<ns>_backup` and `<ns>_monitor` accounts keep server-wide grants and can see every
348
+ namespace on the server.
349
+ - One database per account (some managed hosts) is NOT supported yet (needs the four databases).
350
+
152
351
  ## Migrations (`@astram/migrate`)
153
352
  - `migrate` / `migrate:revert` / `migrate:status` with the deploy-time DDL account (`DDL_DB_*`);
154
353
  the running app never holds DDL. Framework migrations merge with yours (`APP_MIGRATION_DIR`).
155
354
  - Numeric ordering, duplicate-id detection, a database lock so concurrent deploys never apply a
156
355
  migration twice (`APP_MIGRATION_LOCK_TIMEOUT`, default 120 s), and drift detection (a changed
157
356
  applied file is reported; `--strict` fails on it). Never edit a migration that already ran.
357
+ - Upgrade `@astram/migrate` together with `@astram/guard` and `@astram/identity`. The framework
358
+ migrations read the database names from the migrate context (`ctx.schemas`). An older migrate
359
+ works only while `SHIELD_DB_NAMESPACE` is unset, and stops with "upgrade @astram/migrate"
360
+ otherwise. On upgrade, `migrate:status --strict` reports the framework's rewritten migrations as
361
+ SUPERSEDED, not drift, and `migrate` updates their ledger rows without re-running them.
158
362
 
159
363
  ## Frontend (`@astram/frontend-core`, `@astram/sdk`, scaffolded `frontend/`)
160
364
  - `@astram/frontend-core`: auth, account, identity, audit, record-policy, share, cross-tenant and
@@ -170,9 +374,8 @@ imports: [
170
374
 
171
375
  ## Available, but NOT wired to a data path (do not assume they protect data yet)
172
376
  - **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
173
- engine exists, but no record field is encrypted and no production `DekStore` ships.
174
- - **Secrets provider** (`SecretsProvider`, `resolveSecretRefs`, `EncryptedFileSecretsProvider`):
175
- the guard's runtime config does not resolve `secret-ref:` values at boot.
377
+ engine exists, but no record field is encrypted and no production `DekStore` ships. Stored
378
+ files are encrypted by the bucket (SSE-S3), not per tenant.
176
379
  - **Risk engine** (`createRiskEngine`): advisory scoring with no request-path caller.
177
380
  - **Hash-chained audit** (`createAuditRecorder`, `verifyChain`): the shipped audit writer is
178
381
  append-only but does not store the chain.
@@ -186,5 +389,6 @@ imports: [
186
389
  - External identity providers (OIDC, SAML, LDAP, Entra ID, Cognito, Okta, Google Workspace) and
187
390
  WebAuthn ceremonies.
188
391
  - Tenant- or role-mandated MFA (MFA is enforced for users who enrolled a device).
392
+ - Single-database hosting (all framework tables in one database per account).
189
393
 
190
394
  If you need one of these, STOP and flag it in `.astram/guide/gaps.md` (Rule 4).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guide",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the AI/dev GUIDE layer (ADR-0025). Ships the framework's hard rules + capabilities manifest as installed, upgrade-refreshed docs. A consumer app's CLAUDE.md references FRAMEWORK_RULES.md from this package; `npm update` replaces it wholesale so new rules arrive cleanly, while the app's own .astram/guide/ layer is never touched. This is the framework-owned FLOOR: an app rule may tighten it, never weaken it.",
6
6
  "type": "commonjs",