@astram/guide 0.2.3 → 0.2.5
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 +177 -8
- package/FRAMEWORK_RULES.md +5 -1
- 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
|
|
48
|
-
|
|
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.
|
|
@@ -142,11 +191,132 @@ imports: [
|
|
|
142
191
|
`server/eslint.config.mjs` fails lint on driver imports, raw pools, `.query()` and the
|
|
143
192
|
framework's internal DB exports anywhere in `src/**` except `app.module.ts` and `main.ts`, and on
|
|
144
193
|
uuid-named route/body inputs read without `UuidPipe` / `uuidField` (`@astram/guard`). A lint
|
|
145
|
-
rule is a backstop, not a sandbox: do not go around it.
|
|
194
|
+
rule is a backstop, not a sandbox: do not go around it. The uuid check matches names only
|
|
195
|
+
(`id`, `uuid`, `…Id(s)`, `…Uuid(s)`): it is a nudge, not a guarantee, so put `UuidPipe` on
|
|
196
|
+
every uuid route param and a uuid helper on every uuid body/query field whatever its name.
|
|
146
197
|
- **Gap signal** — an unsupported field type or operation throws `CapabilityNotSupportedError`
|
|
147
198
|
(a Rule-4 STOP). Field types this version: `string`, `number`, `boolean`, `uuid`, `timestamp`,
|
|
148
199
|
`json`.
|
|
149
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
|
+
|
|
150
320
|
## Migrations (`@astram/migrate`)
|
|
151
321
|
- `migrate` / `migrate:revert` / `migrate:status` with the deploy-time DDL account (`DDL_DB_*`);
|
|
152
322
|
the running app never holds DDL. Framework migrations merge with yours (`APP_MIGRATION_DIR`).
|
|
@@ -168,9 +338,8 @@ imports: [
|
|
|
168
338
|
|
|
169
339
|
## Available, but NOT wired to a data path (do not assume they protect data yet)
|
|
170
340
|
- **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
|
|
171
|
-
engine exists, but no record field is encrypted and no production `DekStore` ships.
|
|
172
|
-
|
|
173
|
-
the guard's runtime config does not resolve `secret-ref:` values at boot.
|
|
341
|
+
engine exists, but no record field is encrypted and no production `DekStore` ships. Stored
|
|
342
|
+
files are encrypted by the bucket (SSE-S3), not per tenant.
|
|
174
343
|
- **Risk engine** (`createRiskEngine`): advisory scoring with no request-path caller.
|
|
175
344
|
- **Hash-chained audit** (`createAuditRecorder`, `verifyChain`): the shipped audit writer is
|
|
176
345
|
append-only but does not store the chain.
|
package/FRAMEWORK_RULES.md
CHANGED
|
@@ -77,7 +77,11 @@ correctly.
|
|
|
77
77
|
answer 400 for anything that is not a strict uuid and hand you the canonical lower-case
|
|
78
78
|
form. The database matches uuids ignoring case, trailing spaces and character width, so
|
|
79
79
|
a raw `"<id> "` reaches the row while your exact checks in code miss it. The lint wall
|
|
80
|
-
flags uuid-named inputs (`id`, `uuid`, `…Id(s)`, `…Uuid(s)`) read without them
|
|
80
|
+
flags uuid-named inputs (`id`, `uuid`, `…Id(s)`, `…Uuid(s)`) read without them, but it is a
|
|
81
|
+
name-based nudge, not a guarantee: a uuid under another name (`@Param('target')`), a
|
|
82
|
+
destructured body, a body bound to another variable name, a value read off `req` or a param
|
|
83
|
+
given any other pipe passes lint unchecked. Apply the
|
|
84
|
+
pipe or helper to every uuid input whatever its name; a clean lint run proves nothing more.
|
|
81
85
|
|
|
82
86
|
## 4. When the framework can't do it: STOP and FLAG the gap — do NOT work around it
|
|
83
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/guide",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|