@astram/guide 0.2.26 → 0.2.28

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 +67 -6
  2. package/package.json +1 -1
package/CAPABILITIES.md CHANGED
@@ -82,7 +82,14 @@ imports: [
82
82
  - **Live roles** — a role posting change, a grant change or a user disable bumps the permission
83
83
  version; the next request reloads facts and roles. No re-login needed.
84
84
  - **Engine errors → HTTP** — `AuthorizationDeniedError` → 403, `ValidationError` → 400,
85
- `EngineUnavailableError` → 503 for your controllers too (global filter; id-free messages).
85
+ `EngineUnavailableError` → 503, `UniqueConflictError` → 409 `unique_conflict` for your
86
+ controllers too (global filter; id-free, value-free messages).
87
+ - **Duplicate keys** — a create / update that hits a UNIQUE index throws `UniqueConflictError`
88
+ (`typeKey`, `constraint` = the index name, `rowIndex` for `createMany`). Match it with
89
+ `isUniqueConflict(err, '<constraint>')`, never by message text. Inside `transaction(fn)` the
90
+ transaction is then rollback-only (a further write is a 503): re-read in a fresh transaction for
91
+ the idempotent answer. On a tenant type, lead every UNIQUE index with the tenant column, or a
92
+ 409 reveals that another tenant holds the value.
86
93
 
87
94
  ## Authentication and sessions (`@astram/guard`, `auth: true`)
88
95
  - `POST /auth/login` `{ identityHint, password }` → `{ session, tokens }`, or an MFA challenge.
@@ -748,6 +755,51 @@ upload(@Req() req: Record<string, unknown>) {
748
755
  - Not provided: malware scanning, per-tenant encryption of file bytes (SSE-S3 only), streaming
749
756
  downloads, dedup (each upload is its own record; `sha256` is returned so you can detect repeats).
750
757
 
758
+ ### Sharing an attached file with a role (`SHIELD_FILE_SHARE`, guard 0.33, identity 0.32; ADR-0040 addendum)
759
+ Use this when users of a role in the same company must read a file attached to one of your records
760
+ ("the stewards of a source read its revision file"). Never write `record_share` yourself and never
761
+ grant a tenant-wide `stored_file` rule for this.
762
+ 1. **Declare the attachment field** on your parent type: `@Field({ type: 'uuid', references:
763
+ 'stored_file' })`. The type must be tenant-walled (a `trusted: 'tenantId'` field), data plane,
764
+ not global, not `operationReads`, with a uuid primary key, and the field must not be `trusted`.
765
+ A type that declares such a field but breaks a rule fails boot naming the type and field; boot
766
+ logs one line `file-share parents: <type> (<fields>)`.
767
+ 2. **Inject the port** in an app service (not an agent capability: that fails agent boot,
768
+ `inject_framework`):
769
+ ```ts
770
+ import { Inject } from '@nestjs/common';
771
+ import { SHIELD_FILE_SHARE, FileShareError, type FileSharePort } from '@astram/guard';
772
+ constructor(@Inject(SHIELD_FILE_SHARE) private readonly fileShare: FileSharePort) {}
773
+ const r = await this.fileShare.shareWithRole(fileId, roleId, { typeKey: 'source', id: sourceId });
774
+ // r = { created, effective, missing }
775
+ ```
776
+ It is request-scoped: actor and tenant come from the session, no argument names them. The caller
777
+ must be able to `update` the parent and read the file itself; the parent must reference the file
778
+ and the role must belong to the same company. Each call runs in one transaction (it joins yours;
779
+ a refusal then rolls yours back).
780
+ 3. **The role still needs `identity:stored_file:read`.** The port never grants it. Without it the
781
+ share is written and `shareWithRole` returns `{ effective: false, missing:
782
+ ['identity:stored_file:read'] }`; it works with no further call once the key is granted.
783
+ 4. **Revoke in the same transaction as the detach, deactivate or delete.** Call
784
+ `unshare(fileId, roleId, parent)` when you detach a file (it works after the detach) and
785
+ `revokeForParent(parent)` before you deactivate or delete the parent. A share outlives its reason
786
+ if you skip this. After a parent is already deleted, `revokeForParent` still works for a caller
787
+ holding the parent type's `delete` key. `reconcileFile(fileId)` removes the file's port rows
788
+ whose parent is gone, no longer references it or is no longer eligible (any user who can read
789
+ the file); `listForParent(parent)` returns `{ fileId, roleId, createdAtMs }[]`. Those ids are
790
+ for linking only: resolve them to labels (role name, file display name) before showing them,
791
+ and never show a raw id in the UI.
792
+ - **Errors** (`FileShareError.code`, fixed messages, `httpStatus`): `file_share_invalid` 400
793
+ (malformed id, ineligible parent type), `file_share_not_found` 404 (any missing, hidden, foreign
794
+ or unattached id; root or tenantless caller), `file_share_denied` 403 (caller reads but may not
795
+ update the parent), `file_share_unavailable` 503 (database or audit outage; rolled back).
796
+ - **Audit and owner view.** Each change writes `share.created` / `share.removed` with operation
797
+ `shield:file-share` (`FILE_SHARE_OPERATION` from `@astram/identity`), step, parent type and id,
798
+ never the file name. The file owner (or a user `decide()` allows to `update` the file) sees these
799
+ rows in `GET /identity/record-shares` as `via: <parent type label>`, `removable: false`; deleting
800
+ one there answers 403 `This share is managed by a linked record.`
801
+ - Run `astram-migrate` before deploying (guard migration 126 on `record_share`); no env change.
802
+
751
803
  ## Database names and several environments on one server (`SHIELD_DB_NAMESPACE`, ADR-0037)
752
804
  - The framework uses four databases, `<ns>_app`, `<ns>_identity`, `<ns>_authz` and `<ns>_audit`,
753
805
  with its own accounts (`<ns>_core`, `<ns>_migrate`, `<ns>_audit_ops`, …). If you leave
@@ -972,11 +1024,20 @@ upload(@Req() req: Record<string, unknown>) {
972
1024
  **propose**, and a person applies.
973
1025
  - Declare what a change means with `defineProposalKind({ id, version, label, payloadSchema,
974
1026
  targetTypes: [oneAppType], keys: { propose, review, apply }, plan, validate?, preview?,
975
- distinctApprover?, maxItems?, expiryMs? })` (`@astram/identity`) and pass it to
976
- `IdentityFeatureModule.forRoot({ proposalKinds })`. `plan` turns a payload into `create` /
977
- `update` items on that one type; `plan` and `validate` get a read-only `ctx.records`. There is no
978
- apply handler: the framework writes the approved items through the record pipe as the approver,
979
- in one transaction, with read-back. The three keys are custom actions on an app type (ADR-0038).
1027
+ distinctApprover?, maxItems?, expiryMs?, screen?, screenCodes?, submit? })` (`@astram/identity`)
1028
+ and pass it to `IdentityFeatureModule.forRoot({ proposalKinds })`. `plan` turns a payload into
1029
+ `create` / `update` items on that one type; `plan` and `validate` get a read-only `ctx.records`.
1030
+ There is no apply handler: the framework writes the approved items through the record pipe as the
1031
+ approver, in one transaction, with read-back. The three keys are custom actions on an app type
1032
+ (ADR-0038).
1033
+ - To check a draft before anything is stored (for example personal data in a label), give the kind
1034
+ `screen({ payload, title, rationale, metadata }, ctx)` and the codes it may refuse with in
1035
+ `screenCodes`. It runs on every submit path (the HTTP route, your own code through
1036
+ `SHIELD_PROPOSAL_PORT`, the assistant). Return `{ refuse: '<code>' }` to refuse: the caller gets
1037
+ 422 `screened` with that code, and no proposal, audit row or notice is written. A throw or an
1038
+ undeclared code refuses too (`screen_failed`). Do not screen in `validate`: its refusals are stored.
1039
+ `submit: 'app-only'` stops the generic `POST /identity/proposals` route for that kind, so only your
1040
+ own routes (through `SHIELD_PROPOSAL_PORT`) and the assistant can submit it.
980
1041
  - A capability with `effect: 'propose'`, `proposes: [kindIds]` and `requires` including
981
1042
  `identity:agent:propose` returns a draft `{ kind, payload, title, rationale?, metadata? }`; the
982
1043
  framework submits it as the user. The model gets a handle and counts; the browser gets
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guide",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
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",