@astram/guide 0.2.25 → 0.2.27
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.
- package/CAPABILITIES.md +174 -7
- package/package.json +1 -1
package/CAPABILITIES.md
CHANGED
|
@@ -331,7 +331,26 @@ imports: [
|
|
|
331
331
|
with the call's `batchId` and the row's `batchIndex` (ADR-0039). Inside `transaction(fn)` it
|
|
332
332
|
joins the outer transaction.
|
|
333
333
|
- `transaction(fn)` runs several pipe calls in one unit of work (rolls back on any failed write);
|
|
334
|
-
`lockForUpdate(typeKey, id)` serialises a check-then-write
|
|
334
|
+
`lockForUpdate(typeKey, id)` serialises a check-then-write: lock first, then re-read and
|
|
335
|
+
re-check, then write. It locks only a row you could `update` (your tenant, your `readScope`
|
|
336
|
+
subtree); a foreign or out-of-scope row is never locked.
|
|
337
|
+
- **Behaviour change (engine 0.19):** before this version a missing, foreign or out-of-scope id
|
|
338
|
+
was silently not locked. From this version `lockForUpdate` on such an id throws the same deny
|
|
339
|
+
an `update` of that id gets (`AuthorizationDeniedError`, reason `no_record_permit`; one answer
|
|
340
|
+
for all three) and locks nothing. Code that locks first and checks for `null` after must
|
|
341
|
+
either check existence first (`get` inside the transaction, then `lockForUpdate`) or catch
|
|
342
|
+
that deny. Prefer catching the deny when the re-check after the lock must see the latest
|
|
343
|
+
committed state: a plain read before the lock fixes the transaction's read snapshot before
|
|
344
|
+
the lock wait. The deny comes before any lock statement, so a caught deny leaves the
|
|
345
|
+
transaction usable: you may continue and commit. An `EngineUnavailableError` from it (the engine's
|
|
346
|
+
lock-probe pool did not answer within 2000 ms) is different: let it propagate (503); no lock
|
|
347
|
+
was taken.
|
|
348
|
+
- A row another transaction inserted but has not committed yet is "not found" (it used to wait
|
|
349
|
+
for it). If a row is deleted between the existence check and the lock, the lock briefly holds
|
|
350
|
+
the empty key range around that id until your transaction ends (nothing is revealed).
|
|
351
|
+
- **Read lock pattern:** to hold rows you only read, call `tx.authorize(typeKey, id, 'read')`
|
|
352
|
+
inside `transaction(fn)` (a locking read of the row as you may see it), taking ids in sorted
|
|
353
|
+
order to avoid deadlocks. Like `lockForUpdate`, it never locks a foreign or out-of-scope row.
|
|
335
354
|
- `@Field({ immutable: true })` create-only fields; `@Field({ nullable: true })` fields that
|
|
336
355
|
create, update and `createMany` may set to NULL (`json` included: SQL NULL, not `"null"`;
|
|
337
356
|
the audit row lists the cleared keys, never values; the column must be NULL-able in your
|
|
@@ -629,7 +648,8 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
629
648
|
only lower it. A route naming a profile the app did not register aborts boot. Needs
|
|
630
649
|
`@nestjs/platform-express` (an optional peer; the scaffold has it).
|
|
631
650
|
- **Profiles** — turn them on with `ShieldModule.forRoot({ fileProfiles: ['xlsx', 'csv', 'pdf', 'docx'] })`
|
|
632
|
-
(default `['xlsx']`; an unknown, repeated or empty list aborts boot).
|
|
651
|
+
(default `['xlsx']`; an unknown, repeated or empty list aborts boot). For a format of your own, see
|
|
652
|
+
**App profiles** below.
|
|
633
653
|
| Profile | Default cap | Declared types accepted (stored as the first) | Content check |
|
|
634
654
|
|---|---|---|---|
|
|
635
655
|
| `xlsx` | 10 MiB | spreadsheetml `.sheet` | ZIP + `[Content_Types].xml` names a spreadsheetml main part |
|
|
@@ -653,6 +673,46 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
653
673
|
`xl/embeddings/` (a Word chart keeps its data there); they count toward the limits and are never
|
|
654
674
|
opened. Files Office, LibreOffice, Google and common zip libraries write pass; a file with bytes
|
|
655
675
|
added after the zip's end record (some mail gateways pad attachments) is refused.
|
|
676
|
+
- **App profiles (`appFileProfiles`, DP1)** — a format you own (a vendor export, a `.json` record,
|
|
677
|
+
a domain CSV dialect) is registered next to the shipped profiles and goes through the same
|
|
678
|
+
intake (cap, type, extension, your `magic`, sha256, tenant key) and `FilesService`:
|
|
679
|
+
```ts
|
|
680
|
+
ShieldModule.forRoot({
|
|
681
|
+
storage: 'env',
|
|
682
|
+
fileProfiles: ['xlsx'],
|
|
683
|
+
appFileProfiles: [
|
|
684
|
+
{
|
|
685
|
+
name: 'inspection_json',
|
|
686
|
+
profile: {
|
|
687
|
+
maxBytes: 2 * 1024 * 1024,
|
|
688
|
+
contentTypes: ['application/json'], // first = the stored type
|
|
689
|
+
extensions: ['.json'],
|
|
690
|
+
magic: isInspectionJson, // your content check over the raw bytes; false or a throw = 415
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
],
|
|
694
|
+
});
|
|
695
|
+
// route: @UseInterceptors(FileIntake({ field: 'file', profile: 'inspection_json' }))
|
|
696
|
+
```
|
|
697
|
+
Boot refuses (naming the entry index and rule): more than 16 entries; a name not
|
|
698
|
+
`^[a-z][a-z0-9_]{0,31}$` or equal to a shipped name or repeated; `magic` not a function;
|
|
699
|
+
`maxBytes` not a positive integer ≤ 50 MiB; a content type that is not a plain lower-case
|
|
700
|
+
`type/subtype` (no parameters; stricter than the spec, so a malformed value can never reach a
|
|
701
|
+
download header), that is denied, or that a shipped profile (registered or not) or another app
|
|
702
|
+
profile already uses; an extension not `^\.[a-z0-9]{1,10}$` or already used. **Denied types**
|
|
703
|
+
(`APP_CONTENT_TYPE_DENY_LIST` + `isDeniedAppContentType` in `@astram/guard`; wider than the spec):
|
|
704
|
+
`text/html`, `application/octet-stream`; xml (`application/xml`, `text/xml`, and any `+xml`
|
|
705
|
+
subtype such as `image/svg+xml`, `application/xhtml+xml`); php; `application/wasm`; shell and
|
|
706
|
+
script types (`sh`, `csh`, `bash`, `powershell`, python, perl, ruby, `bat`, DOS programs); and
|
|
707
|
+
every javascript / ecmascript / jscript variant. **Cap:** your profile's `maxBytes` is its
|
|
708
|
+
ceiling. An explicit `OBJECT_STORAGE_MAX_BYTES` can only **lower** it, never raise it (stricter
|
|
709
|
+
than the spec; shipped profiles differ: the env value replaces their cap). Downloads serve the
|
|
710
|
+
app type with the same `attachment` + `nosniff` headers.
|
|
711
|
+
**ZIP-based formats:** your `magic` MUST call `zipWithinLimits(bytes)` (from `@astram/core`)
|
|
712
|
+
before reading anything else; it applies every archive limit above without inflating anything.
|
|
713
|
+
For another OOXML format, `ooxmlMagic('<main part content type>')` is the shipped xlsx/docx check.
|
|
714
|
+
The framework never parses app-profile bytes and the file analyser never receives them (reading
|
|
715
|
+
them is DP2 or your own isolated worker).
|
|
656
716
|
- **`FilesService`** (provided by `IdentityFeatureModule`) — `store(intake)`, `open(uuid)`,
|
|
657
717
|
`remove(uuid)`. No actor argument: tenant, owner and audit actor come from the session. `store`
|
|
658
718
|
puts the object at `t/<tenant>/<uuid>`, then creates the record (gate, tenant stamp, audit); a
|
|
@@ -688,6 +748,51 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
688
748
|
- Not provided: malware scanning, per-tenant encryption of file bytes (SSE-S3 only), streaming
|
|
689
749
|
downloads, dedup (each upload is its own record; `sha256` is returned so you can detect repeats).
|
|
690
750
|
|
|
751
|
+
### Sharing an attached file with a role (`SHIELD_FILE_SHARE`, guard 0.33, identity 0.32; ADR-0040 addendum)
|
|
752
|
+
Use this when users of a role in the same company must read a file attached to one of your records
|
|
753
|
+
("the stewards of a source read its revision file"). Never write `record_share` yourself and never
|
|
754
|
+
grant a tenant-wide `stored_file` rule for this.
|
|
755
|
+
1. **Declare the attachment field** on your parent type: `@Field({ type: 'uuid', references:
|
|
756
|
+
'stored_file' })`. The type must be tenant-walled (a `trusted: 'tenantId'` field), data plane,
|
|
757
|
+
not global, not `operationReads`, with a uuid primary key, and the field must not be `trusted`.
|
|
758
|
+
A type that declares such a field but breaks a rule fails boot naming the type and field; boot
|
|
759
|
+
logs one line `file-share parents: <type> (<fields>)`.
|
|
760
|
+
2. **Inject the port** in an app service (not an agent capability: that fails agent boot,
|
|
761
|
+
`inject_framework`):
|
|
762
|
+
```ts
|
|
763
|
+
import { Inject } from '@nestjs/common';
|
|
764
|
+
import { SHIELD_FILE_SHARE, FileShareError, type FileSharePort } from '@astram/guard';
|
|
765
|
+
constructor(@Inject(SHIELD_FILE_SHARE) private readonly fileShare: FileSharePort) {}
|
|
766
|
+
const r = await this.fileShare.shareWithRole(fileId, roleId, { typeKey: 'source', id: sourceId });
|
|
767
|
+
// r = { created, effective, missing }
|
|
768
|
+
```
|
|
769
|
+
It is request-scoped: actor and tenant come from the session, no argument names them. The caller
|
|
770
|
+
must be able to `update` the parent and read the file itself; the parent must reference the file
|
|
771
|
+
and the role must belong to the same company. Each call runs in one transaction (it joins yours;
|
|
772
|
+
a refusal then rolls yours back).
|
|
773
|
+
3. **The role still needs `identity:stored_file:read`.** The port never grants it. Without it the
|
|
774
|
+
share is written and `shareWithRole` returns `{ effective: false, missing:
|
|
775
|
+
['identity:stored_file:read'] }`; it works with no further call once the key is granted.
|
|
776
|
+
4. **Revoke in the same transaction as the detach, deactivate or delete.** Call
|
|
777
|
+
`unshare(fileId, roleId, parent)` when you detach a file (it works after the detach) and
|
|
778
|
+
`revokeForParent(parent)` before you deactivate or delete the parent. A share outlives its reason
|
|
779
|
+
if you skip this. After a parent is already deleted, `revokeForParent` still works for a caller
|
|
780
|
+
holding the parent type's `delete` key. `reconcileFile(fileId)` removes the file's port rows
|
|
781
|
+
whose parent is gone, no longer references it or is no longer eligible (any user who can read
|
|
782
|
+
the file); `listForParent(parent)` returns `{ fileId, roleId, createdAtMs }[]`. Those ids are
|
|
783
|
+
for linking only: resolve them to labels (role name, file display name) before showing them,
|
|
784
|
+
and never show a raw id in the UI.
|
|
785
|
+
- **Errors** (`FileShareError.code`, fixed messages, `httpStatus`): `file_share_invalid` 400
|
|
786
|
+
(malformed id, ineligible parent type), `file_share_not_found` 404 (any missing, hidden, foreign
|
|
787
|
+
or unattached id; root or tenantless caller), `file_share_denied` 403 (caller reads but may not
|
|
788
|
+
update the parent), `file_share_unavailable` 503 (database or audit outage; rolled back).
|
|
789
|
+
- **Audit and owner view.** Each change writes `share.created` / `share.removed` with operation
|
|
790
|
+
`shield:file-share` (`FILE_SHARE_OPERATION` from `@astram/identity`), step, parent type and id,
|
|
791
|
+
never the file name. The file owner (or a user `decide()` allows to `update` the file) sees these
|
|
792
|
+
rows in `GET /identity/record-shares` as `via: <parent type label>`, `removable: false`; deleting
|
|
793
|
+
one there answers 403 `This share is managed by a linked record.`
|
|
794
|
+
- Run `astram-migrate` before deploying (guard migration 126 on `record_share`); no env change.
|
|
795
|
+
|
|
691
796
|
## Database names and several environments on one server (`SHIELD_DB_NAMESPACE`, ADR-0037)
|
|
692
797
|
- The framework uses four databases, `<ns>_app`, `<ns>_identity`, `<ns>_authz` and `<ns>_audit`,
|
|
693
798
|
with its own accounts (`<ns>_core`, `<ns>_migrate`, `<ns>_audit_ops`, …). If you leave
|
|
@@ -729,10 +834,11 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
729
834
|
- **Set `POOL_MAX_ENGINE`** (e.g. 10). Unset, the engine pool uses `APP_DB_MAX_CONNECTIONS`, and
|
|
730
835
|
the single-layout budget `workers × (POOL_MAX_CORE + POOL_MAX_ENGINE + APP_AUTHZ_POOL_MAX) +
|
|
731
836
|
headroom` fails boot. `APP_DB_MAX_USER_CONNECTIONS` (the account's cap, if the host sets one) is
|
|
732
|
-
checked too. The budget also counts 1 connection per worker for the audit sealer's own pool
|
|
837
|
+
checked too. The budget also counts 1 connection per worker for the audit sealer's own pool and
|
|
838
|
+
2 per worker for the engine's lock-probe pool.
|
|
733
839
|
- **Set `POOL_WORKERS` and `APP_DB_MAX_CONNECTIONS` too** (your real worker count and what the
|
|
734
840
|
server gives you). Their defaults (8 workers, 250) describe a dedicated four-database server; on
|
|
735
|
-
them the single-layout budget is 8 ×
|
|
841
|
+
them the single-layout budget is 8 × 29 + 40 = 272 > 250 and boot fails.
|
|
736
842
|
- **Idle connections and timeouts (G-026, both layouts):** the `POOL_MAX_*` values are caps. Idle,
|
|
737
843
|
every framework pool shrinks to `POOL_MIN_IDLE` (default 1) after `POOL_IDLE_TIMEOUT_S` (default
|
|
738
844
|
600), so an idle process holds about 4 connections. Optional `APP_DB_QUERY_TIMEOUT_MS`
|
|
@@ -830,7 +936,8 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
830
936
|
`AGENT_RUNTIME_URL` (`https:`, or `http:` in development/test or to a loopback host),
|
|
831
937
|
`AGENT_RUNTIME_KEY_ID`, `AGENT_RUNTIME_KEY` (`secret-ref:` outside development/test).
|
|
832
938
|
- **Who may use it.** Keys `identity:agent:chat` (use it), `identity:agent:restricted_data` (send
|
|
833
|
-
`restricted` data to an approved provider), `identity:agent:propose` (M3
|
|
939
|
+
`restricted` data to an approved provider), `identity:agent:propose` (M3: `propose` capabilities,
|
|
940
|
+
see Proposals below). A tenant also needs
|
|
834
941
|
mode `synthetic` (org bootstrap `agent: { mode }`); each company is set on its own. `identity:*`
|
|
835
942
|
holders get all three keys, including `restricted_data` (GAPS #23): grant `identity:agent:chat`
|
|
836
943
|
explicitly on production roles.
|
|
@@ -877,8 +984,68 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
877
984
|
masking (not built yet) is a prerequisite before real data.
|
|
878
985
|
- Totals the assistant states come from `profile_sheet` or `aggregate`, never from adding rows it read.
|
|
879
986
|
Ids inside file content reach the model as `[id]`.
|
|
880
|
-
-
|
|
881
|
-
|
|
987
|
+
- **Workbook structure (G-037 G1).** Three more framework tools for xlsx and csv, for sheets that are
|
|
988
|
+
not "row 1 is the header" tables (matrix, transposed, stacked blocks, merged headers):
|
|
989
|
+
`files.list_sheets@1` (each sheet's state, computed used range, hidden rows and columns, counts of
|
|
990
|
+
merges, tables, charts, names, validations and formulas; a sheet over the 1,000,000-cell scan cap is
|
|
991
|
+
listed with `scanned: false`), `files.read_grid@1` (raw cells with A1 addresses for a range the model
|
|
992
|
+
picks, at most 400 cells and 16 KiB a call; `truncated.nextRange` gives the rest; a formula cell has
|
|
993
|
+
`f`: `x` external, `v` volatile, `f` other, and `v` is its cached value, never evaluated) and
|
|
994
|
+
`files.read_structure@1` (merges, formula groups as R1C1 shapes with string literals replaced by `"…"`,
|
|
995
|
+
tables, defined names, list validations, chart series refs; `parts`, `range`, `offset`). They return
|
|
996
|
+
structure only: deciding what a block means is your code's job (a consumer step, not a framework one).
|
|
997
|
+
An analyser older than the app is refused at boot (`analyser_version_skew`): upgrade the analyser image
|
|
998
|
+
together with `@astram/agent`. App code gets the same three reads, unmasked, on `FileAnalyser`
|
|
999
|
+
(`outlineWorkbook`, `readGrid`, `readStructure`).
|
|
1000
|
+
- **Masking of these results, and its limits.** Like every `files.*` result they are masked before the
|
|
1001
|
+
model sees them. `read_grid` has no header row, so the analyser marks each cell covered by a header hint
|
|
1002
|
+
(`p: <kind>`, from the column or transposed row of a header such as `Name` or `Email`, even when the
|
|
1003
|
+
header lies outside the range read) and the cell is masked whole. Emails, phones and the other built-in
|
|
1004
|
+
kinds are masked wherever they appear, including table column names, chart titles, defined-name text
|
|
1005
|
+
and validation lists. **Person names have no detector.** A name that no header hint covers reaches the
|
|
1006
|
+
model unmasked: a table column that is itself a name, a chart title or series name, the text of a
|
|
1007
|
+
defined name, a validation list outside a hinted column. Put the data the model must not see behind a
|
|
1008
|
+
hinted header, or keep such files out of a conversation until your own detector (`defineDetector`)
|
|
1009
|
+
covers them.
|
|
1010
|
+
- **Disclosed over-mask.** The hint rule can mask more than the personal data: a leftmost header cell
|
|
1011
|
+
that is itself a hint (`Name`) masks the other header labels of that row, and in a stacked sheet a
|
|
1012
|
+
later block's `Name` label and the values under an earlier block's hinted column are masked too. This
|
|
1013
|
+
fails safe (values are never shown); the model may need to ask for the labels by position.
|
|
1014
|
+
- Not yet: the attach control in the scaffold UI (the client service supports `attachments`), marking
|
|
1015
|
+
an upload `restricted`. The earlier "workbook map" design (D2b) was replaced by the three tools above.
|
|
1016
|
+
- **Proposals (G-036 M3, ADR-0049 M3 addendum).** The assistant never writes records; it may
|
|
1017
|
+
**propose**, and a person applies.
|
|
1018
|
+
- Declare what a change means with `defineProposalKind({ id, version, label, payloadSchema,
|
|
1019
|
+
targetTypes: [oneAppType], keys: { propose, review, apply }, plan, validate?, preview?,
|
|
1020
|
+
distinctApprover?, maxItems?, expiryMs?, screen?, screenCodes?, submit? })` (`@astram/identity`)
|
|
1021
|
+
and pass it to `IdentityFeatureModule.forRoot({ proposalKinds })`. `plan` turns a payload into
|
|
1022
|
+
`create` / `update` items on that one type; `plan` and `validate` get a read-only `ctx.records`.
|
|
1023
|
+
There is no apply handler: the framework writes the approved items through the record pipe as the
|
|
1024
|
+
approver, in one transaction, with read-back. The three keys are custom actions on an app type
|
|
1025
|
+
(ADR-0038).
|
|
1026
|
+
- To check a draft before anything is stored (for example personal data in a label), give the kind
|
|
1027
|
+
`screen({ payload, title, rationale, metadata }, ctx)` and the codes it may refuse with in
|
|
1028
|
+
`screenCodes`. It runs on every submit path (the HTTP route, your own code through
|
|
1029
|
+
`SHIELD_PROPOSAL_PORT`, the assistant). Return `{ refuse: '<code>' }` to refuse: the caller gets
|
|
1030
|
+
422 `screened` with that code, and no proposal, audit row or notice is written. A throw or an
|
|
1031
|
+
undeclared code refuses too (`screen_failed`). Do not screen in `validate`: its refusals are stored.
|
|
1032
|
+
`submit: 'app-only'` stops the generic `POST /identity/proposals` route for that kind, so only your
|
|
1033
|
+
own routes (through `SHIELD_PROPOSAL_PORT`) and the assistant can submit it.
|
|
1034
|
+
- A capability with `effect: 'propose'`, `proposes: [kindIds]` and `requires` including
|
|
1035
|
+
`identity:agent:propose` returns a draft `{ kind, payload, title, rationale?, metadata? }`; the
|
|
1036
|
+
framework submits it as the user. The model gets a handle and counts; the browser gets
|
|
1037
|
+
`approval.required` (`AgentTaskState.approvals` in `@astram/frontend-core`). Workers cannot propose.
|
|
1038
|
+
- To let a capability see proposals, declare `readsProposalKinds: [kindIds]` with
|
|
1039
|
+
`identity:proposal:read` in `requires` and call `ctx.proposals.list({ kind, state?, page?,
|
|
1040
|
+
pageSize? })` (≤ 20 a page, the same scope as `GET /identity/proposals`, handles and labels only).
|
|
1041
|
+
- **Never read or write the `proposal` / `proposal_item` rows yourself**: the engine refuses every
|
|
1042
|
+
`get`, `list`, `aggregate`, lock and write on them outside the framework's own lifecycle operation,
|
|
1043
|
+
whatever keys the user holds. Use `/identity/proposals` (review UI: `createProposalService` in
|
|
1044
|
+
`@astram/frontend-core`; render values as text and never display ids) or the capability reader.
|
|
1045
|
+
- By default the requester cannot approve or apply their own proposal; `distinctApprover: false`
|
|
1046
|
+
opts out and adds a caution to the kind's `review` / `apply` permission text. Terminal proposals
|
|
1047
|
+
are deleted after `PROPOSAL_RETENTION_DAYS` (365, 30–3650); their audit rows stay.
|
|
1048
|
+
- Not yet: a review page in the scaffolded template.
|
|
882
1049
|
|
|
883
1050
|
## Available, but NOT wired to a data path (do not assume they protect data yet)
|
|
884
1051
|
- **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/guide",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
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",
|