@wemush/wols 0.1.0 → 1.3.0

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 (121) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +204 -31
  3. package/dist/cjs/compact-url.d.ts +150 -0
  4. package/dist/cjs/compact-url.d.ts.map +1 -0
  5. package/dist/cjs/compact-url.js +64 -0
  6. package/dist/cjs/compact-url.js.map +1 -1
  7. package/dist/cjs/crypto/decrypt.d.ts +110 -0
  8. package/dist/cjs/crypto/decrypt.d.ts.map +1 -0
  9. package/dist/cjs/crypto/decrypt.js +521 -90
  10. package/dist/cjs/crypto/decrypt.js.map +1 -1
  11. package/dist/cjs/crypto/encrypt.d.ts +132 -0
  12. package/dist/cjs/crypto/encrypt.d.ts.map +1 -0
  13. package/dist/cjs/crypto/encrypt.js +288 -105
  14. package/dist/cjs/crypto/encrypt.js.map +1 -1
  15. package/dist/cjs/crypto/index.d.ts +12 -0
  16. package/dist/cjs/crypto/index.d.ts.map +1 -0
  17. package/dist/cjs/crypto/index.js +15 -2
  18. package/dist/cjs/crypto/index.js.map +1 -1
  19. package/dist/cjs/crypto/shared.d.ts +116 -0
  20. package/dist/cjs/crypto/shared.d.ts.map +1 -0
  21. package/dist/cjs/crypto/shared.js +238 -0
  22. package/dist/cjs/crypto/shared.js.map +1 -0
  23. package/dist/cjs/errors.d.ts +133 -0
  24. package/dist/cjs/errors.d.ts.map +1 -0
  25. package/dist/cjs/errors.js +39 -1
  26. package/dist/cjs/errors.js.map +1 -1
  27. package/dist/cjs/index.d.ts +29 -0
  28. package/dist/cjs/index.d.ts.map +1 -0
  29. package/dist/cjs/index.js +70 -3
  30. package/dist/cjs/index.js.map +1 -1
  31. package/dist/cjs/package.json +3 -0
  32. package/dist/cjs/parser.d.ts +30 -0
  33. package/dist/cjs/parser.d.ts.map +1 -0
  34. package/dist/cjs/parser.js +3 -0
  35. package/dist/cjs/parser.js.map +1 -1
  36. package/dist/cjs/qr/generate.d.ts +62 -0
  37. package/dist/cjs/qr/generate.d.ts.map +1 -0
  38. package/dist/cjs/qr/index.d.ts +10 -0
  39. package/dist/cjs/qr/index.d.ts.map +1 -0
  40. package/dist/cjs/specimen.d.ts +96 -0
  41. package/dist/cjs/specimen.d.ts.map +1 -0
  42. package/dist/cjs/specimen.js +95 -9
  43. package/dist/cjs/specimen.js.map +1 -1
  44. package/dist/cjs/types.d.ts +735 -0
  45. package/dist/cjs/types.d.ts.map +1 -0
  46. package/dist/cjs/types.js +327 -3
  47. package/dist/cjs/types.js.map +1 -1
  48. package/dist/cjs/utils/cuid.d.ts +46 -0
  49. package/dist/cjs/utils/cuid.d.ts.map +1 -0
  50. package/dist/cjs/utils/cuid.js +6 -4
  51. package/dist/cjs/utils/cuid.js.map +1 -1
  52. package/dist/cjs/utils/environment.d.ts +137 -0
  53. package/dist/cjs/utils/environment.d.ts.map +1 -0
  54. package/dist/cjs/utils/environment.js +206 -0
  55. package/dist/cjs/utils/environment.js.map +1 -0
  56. package/dist/cjs/utils/iso8601.d.ts +79 -0
  57. package/dist/cjs/utils/iso8601.d.ts.map +1 -0
  58. package/dist/cjs/utils/migration.d.ts +162 -0
  59. package/dist/cjs/utils/migration.d.ts.map +1 -0
  60. package/dist/cjs/utils/migration.js +248 -0
  61. package/dist/cjs/utils/migration.js.map +1 -0
  62. package/dist/cjs/validator.d.ts +25 -0
  63. package/dist/cjs/validator.d.ts.map +1 -0
  64. package/dist/cjs/validator.js +85 -21
  65. package/dist/cjs/validator.js.map +1 -1
  66. package/dist/esm/compact-url.js +62 -0
  67. package/dist/esm/compact-url.js.map +1 -1
  68. package/dist/esm/crypto/decrypt.js +517 -90
  69. package/dist/esm/crypto/decrypt.js.map +1 -1
  70. package/dist/esm/crypto/encrypt.js +284 -105
  71. package/dist/esm/crypto/encrypt.js.map +1 -1
  72. package/dist/esm/crypto/index.js +5 -3
  73. package/dist/esm/crypto/index.js.map +1 -1
  74. package/dist/esm/crypto/shared.js +222 -0
  75. package/dist/esm/crypto/shared.js.map +1 -0
  76. package/dist/esm/errors.js +37 -0
  77. package/dist/esm/errors.js.map +1 -1
  78. package/dist/esm/index.js +26 -6
  79. package/dist/esm/index.js.map +1 -1
  80. package/dist/esm/parser.js +3 -0
  81. package/dist/esm/parser.js.map +1 -1
  82. package/dist/esm/specimen.js +95 -10
  83. package/dist/esm/specimen.js.map +1 -1
  84. package/dist/esm/types.js +317 -2
  85. package/dist/esm/types.js.map +1 -1
  86. package/dist/esm/utils/cuid.js +6 -4
  87. package/dist/esm/utils/cuid.js.map +1 -1
  88. package/dist/esm/utils/environment.js +193 -0
  89. package/dist/esm/utils/environment.js.map +1 -0
  90. package/dist/esm/utils/migration.js +237 -0
  91. package/dist/esm/utils/migration.js.map +1 -0
  92. package/dist/esm/validator.js +86 -22
  93. package/dist/esm/validator.js.map +1 -1
  94. package/dist/types/compact-url.d.ts +46 -0
  95. package/dist/types/compact-url.d.ts.map +1 -1
  96. package/dist/types/crypto/decrypt.d.ts +95 -18
  97. package/dist/types/crypto/decrypt.d.ts.map +1 -1
  98. package/dist/types/crypto/encrypt.d.ts +114 -14
  99. package/dist/types/crypto/encrypt.d.ts.map +1 -1
  100. package/dist/types/crypto/index.d.ts +5 -3
  101. package/dist/types/crypto/index.d.ts.map +1 -1
  102. package/dist/types/crypto/shared.d.ts +116 -0
  103. package/dist/types/crypto/shared.d.ts.map +1 -0
  104. package/dist/types/errors.d.ts +31 -1
  105. package/dist/types/errors.d.ts.map +1 -1
  106. package/dist/types/index.d.ts +15 -7
  107. package/dist/types/index.d.ts.map +1 -1
  108. package/dist/types/parser.d.ts.map +1 -1
  109. package/dist/types/specimen.d.ts +29 -4
  110. package/dist/types/specimen.d.ts.map +1 -1
  111. package/dist/types/types.d.ts +483 -17
  112. package/dist/types/types.d.ts.map +1 -1
  113. package/dist/types/utils/cuid.d.ts +5 -3
  114. package/dist/types/utils/cuid.d.ts.map +1 -1
  115. package/dist/types/utils/environment.d.ts +137 -0
  116. package/dist/types/utils/environment.d.ts.map +1 -0
  117. package/dist/types/utils/migration.d.ts +162 -0
  118. package/dist/types/utils/migration.d.ts.map +1 -0
  119. package/dist/types/validator.d.ts +1 -1
  120. package/dist/types/validator.d.ts.map +1 -1
  121. package/package.json +32 -14
package/CHANGELOG.md ADDED
@@ -0,0 +1,170 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@wemush/wols` are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/), except that its major and minor versions match the
5
+ WOLS specification version it implements. A spec minor release can require breaking changes, and
6
+ those are listed below.
7
+
8
+ ## [1.3.0]
9
+
10
+ Brings the library in line with the [WOLS 1.3.0 specification](https://github.com/wemush/open-standard/blob/main/SPECIFICATION.md).
11
+ Implements WOLS `1.3.0-rc.2` (open-standard tag `1.3.0-rc.2`, commit `9d200c9`). All `MUST` and `SHOULD` cases in the shared conformance vectors pass:
12
+
13
+ | Vector file | MUST | SHOULD |
14
+ | ----------------- | ----- | ------ |
15
+ | `embedded.json` | 12/12 | 1/1 |
16
+ | `primordia.json` | 4/4 | 1/1 |
17
+ | `generation.json` | 9/9 | 1/1 |
18
+ | `encrypted.json` | 19/19 | 2/2 |
19
+ | `field-encryption.json` | 6/6 | — |
20
+
21
+ ### WOLS 1.3.0-rc.2 encryption rules
22
+
23
+ These rules were added on top of `rc.1` before 1.3.0 was published. Field-level ciphertexts made by
24
+ earlier unpublished 1.3.0 builds don't decrypt under them. Because nothing was released, no
25
+ migration is needed.
26
+
27
+ - **NFC passphrases.** The passphrase is normalized to Unicode NFC, then UTF-8 encoded, before
28
+ PBKDF2. This applies to encryption, decryption and `derivePassphraseKey`.
29
+ - **Field-level AAD.** Each encrypted field is bound to its label and field name. The AES-GCM
30
+ additional data is the UTF-8 of `"{id}\n{field}"`, and every field gets its own random IV.
31
+ Values that are swapped between fields or copied from another label fail authentication.
32
+ `decryptField` now takes a third argument, `{ id, field }`. The URL format has no AAD and keeps
33
+ the decrypted-`id` check.
34
+ - **Identifying fields.** `@context`, `@type`, `id`, `version`, `type` and `species` are never
35
+ encrypted. `decryptFields` refuses a label that has an encrypted identifying field, with reason
36
+ `identifying-field`.
37
+ - **Iterations.**
38
+ - Generation defaults to 600,000 iterations. Anything from 100,000 to 10,000,000 is allowed.
39
+ - Decoding rejects fewer than 10,000 or more than 10,000,000, before deriving any key.
40
+ `maxIterations` may be set lower, never higher.
41
+ - For field-level decryption, the 10,000,000 limit applies to the label's total across distinct
42
+ salt and iteration-count pairs.
43
+ - **ID path encoding.** Malformed percent-escapes and invalid UTF-8 in the URL path are rejected
44
+ with reason `invalid-encoding`. `+` is a literal character.
45
+ - **New failure reasons:** `invalid-encoding` and `identifying-field`.
46
+ - **Vectors.** The vectors are re-vendored from `1.3.0-rc.2` and verified against upstream's
47
+ `SHA256SUMS`, which is now vendored too. This adds `field-encryption.json` and its
48
+ `decryptFields` operation, and 10 new `encrypted.json` cases.
49
+
50
+ ### ⚠️ Breaking changes
51
+
52
+ **Encrypted generation.** The encrypted format follows the 1.3.0 wire format:
53
+
54
+ ```
55
+ wemush://v1/encrypted/{id}?e=&iv=&kdf=pbkdf2|raw[&salt=&i=][&sig=]
56
+ ```
57
+
58
+ - **Passphrase keys use a random per-label salt.** The fixed `@wemush/wols/crypto/v1` salt is no
59
+ longer used when generating. Every passphrase-encrypted label gets a fresh 16-byte salt, and the
60
+ label records `kdf=pbkdf2`, `salt` and `i`.
61
+ - Labels created by 1.3.0 **cannot** be decrypted by 1.2.x, so upgrade readers before writers.
62
+ - Labels created by 1.2.x have no `kdf`. They fail with reason `legacy-format` unless you pass
63
+ `allowLegacy: true`. Legacy labels are never generated.
64
+ - **Generation iterations default to 600,000 and must be between 100,000 and 10,000,000**
65
+ (previously 100,000 by default, with a minimum of 10,000). `encryptSpecimen` throws outside that
66
+ range. Decoders reject `i` below 10,000 or above 10,000,000.
67
+ - **`EncryptedSpecimen` no longer includes `cryptoVersion`** in generated output. It now has `kdf`,
68
+ `salt` (pbkdf2), `iterations` (pbkdf2), `id`, and `url`. The deprecated `tag` and
69
+ `cryptoVersion` fields will be removed in 2.0.
70
+ - **Field-level encryption** values have the 1.3.0 shape
71
+ `{ encrypted, algorithm, payload, iv, kdf, salt?, iterations? }` instead of
72
+ `{ ..., cryptoVersion }`.
73
+ - **Field-level encryption is strict.** Naming a field the specimen doesn't have (for example a
74
+ typo), naming the same field twice, or naming an inherited property such as `constructor` now
75
+ throws. In 1.2.x such a field was skipped silently and left in cleartext. `@context`, `@type`,
76
+ `id` and `version` can't be encrypted.
77
+ - **`encryptSpecimen(s, { fields: [...] })`** called with a literal `fields` array is now typed as
78
+ returning `FieldEncryptedSpecimen`, which is what it returns at runtime.
79
+ - **`CryptoKey` keys** must be AES-GCM with a length of 256 and allow `encrypt` or `decrypt`. That
80
+ applies to decrypting legacy labels too.
81
+ - **WebCrypto is required.** Node.js 18 has no global `crypto` by default. Use Node.js 20 or later,
82
+ a browser, Deno or Bun. On a runtime without WebCrypto, encryption throws and decryption returns
83
+ `unsupported-environment`.
84
+
85
+ **Validation and creation.**
86
+
87
+ - **The default `idMode` is now `'any'`** (previously `'strict'`): any non-empty suffix after
88
+ `wemush:` is accepted, as the spec requires. This loosens validation. Pass `idMode: 'strict'`,
89
+ `'ulid'` or `'uuid'` to opt into a format check.
90
+ - **`createSpecimen({ type: 'PRIMORDIA' })`** now resolves to `FRUITING`, sets
91
+ `stage: 'PRIMORDIA'` when no stage was supplied, and warns through `console.warn` unless you pass
92
+ `onWarning`.
93
+ - **`registerTypeAlias('PRIMORDIA', …)`** throws for any type other than `FRUITING`.
94
+ - **`isEncrypted`** now narrows to `EncryptedSpecimen | EncryptedField`. Use `isEncryptedSpecimen`
95
+ or `isEncryptedField` to tell them apart.
96
+
97
+ ### Added
98
+
99
+ - **Encrypted URLs.** `encryptSpecimen` returns `url`. `decryptSpecimen` accepts the URL or the
100
+ object.
101
+ - **Raw keys (`kdf=raw`).** Pass 32 key bytes (`Uint8Array` or `ArrayBuffer`) or an AES-GCM 256
102
+ `CryptoKey` as `key`. A passphrase string selects `kdf=pbkdf2`.
103
+ - **`encryptFields(specimen, { key, fields })`** returns a typed `FieldEncryptedSpecimen`.
104
+ `decryptFields` returns a validated `Specimen`. `encryptSpecimen`'s `fields` option is
105
+ deprecated.
106
+ - **Typed decryption errors.** A failed decryption carries a `WolsDecryptionError` whose `reason` is a
107
+ `DecryptionFailureReason`, also copied into `details.reason`. The decryption functions return
108
+ `DecryptResult<T>`, which is assignable to `ParseResult<T>`. They never throw.
109
+ - **Decryption hardening.**
110
+ - Every parameter, encoding, the IV length and the key type are checked before PBKDF2 runs, so a
111
+ hostile label costs no key derivation.
112
+ - `maxIterations` (default 10,000,000) bounds the iteration count a label can demand.
113
+ `decryptFields` validates every field first, derives once per distinct salt, and bounds the
114
+ whole document's total.
115
+ - The decrypted `id` must equal `wemush:{id}` from the URL path.
116
+ - Unknown `kdf` values are rejected, as are `salt` or `i` with `kdf=raw`, a `salt` on a legacy
117
+ label, ciphertext shorter than the GCM tag, and plaintext starting with a byte order mark.
118
+ - Padded base64url is accepted.
119
+ - **`toEncryptedUrl` enforces the generator rules.** Every value must be canonical unpadded
120
+ base64url, and the IV must be 12 bytes. It refuses an iteration count outside 100,000 to
121
+ 2^32-1, a salt shorter than 16 bytes or byte-equal to the legacy salt, and `salt` with
122
+ `kdf=raw`.
123
+ - **New exports:**
124
+ - Functions: `encryptFields`, `decryptPlaintext` (exact plaintext), `toEncryptedUrl`,
125
+ `parseEncryptedUrl`, `decryptField`, `decryptFields`, `isEncryptedSpecimen`,
126
+ `isEncryptedField`, `derivePassphraseKey`, `base64UrlEncode`, `base64UrlDecode`,
127
+ `resolveTypeAndStage`.
128
+ - Classes: `WolsDecryptionError`.
129
+ - **`strain.transferDepth`** counts every transfer from the original culture.
130
+ - It must be a non-negative integer, and validation warns when it is less than
131
+ `clonalGeneration`.
132
+ - It round-trips through `serializeSpecimen` and `parseSpecimen`.
133
+ - **`createSpecimen` accepts `created`**, as an ISO 8601 string or a `Date`. Invalid values throw.
134
+ When `created` is omitted the field is left out, as before.
135
+ - **`createSpecimen(input, { onWarning })`** receives non-fatal warnings. Without it, warnings go
136
+ to `console.warn`.
137
+ - **`resolveTypeAndStage(type, stage?)`** is the single implementation of the reserved `PRIMORDIA`
138
+ rule. `createSpecimen` and `serializeSpecimen` both use it. `resolveTypeAlias` and
139
+ `mapToWolsType` still map the type only.
140
+ - **Conformance suite.**
141
+ - The WOLS vectors are vendored in `tests/conformance/vectors`, pinned to upstream commit
142
+ `3066545`. Run them with `npm run test:conformance`.
143
+ - `SOURCE.json` records each file's SHA-256 and case count. The runner fails when the vendored
144
+ files or their contents differ from it, when a file has no `MUST` cases, or when a case uses an
145
+ operation it doesn't know.
146
+ - `npm run conformance:update` refreshes the vectors. It discovers upstream files through the
147
+ GitHub API, validates everything before writing anything, and removes stale files.
148
+ - `CHANGELOG.md`.
149
+
150
+ ### Changed
151
+
152
+ - **`PRIMORDIA` supplied as a type** is never serialized as a `type`. `serializeSpecimen` resolves
153
+ it on hand-built objects too.
154
+ - `isValidSpecimenId(id, strict?)` accepts any non-empty suffix by default, matching `idMode`.
155
+ - **`strain.clonalGeneration` accepts `0`** for the original culture. It previously required
156
+ `>= 1`, which contradicted the spec.
157
+ - **`strain.generation` accepts `P<n>`** (for example `P1`).
158
+ - `WOLS_VERSION` is `1.3.0`, so new specimens are stamped with `version: "1.3.0"`.
159
+ - Growth-stage validation messages list all seven stages.
160
+
161
+ ### Fixed
162
+
163
+ - **`require('@wemush/wols')` works** ([#9](https://github.com/wemush/specimen-labels-js/pull/9)).
164
+ It was broken in 1.2.0 because `dist/cjs` was loaded as ESM. CommonJS consumers now also get
165
+ CommonJS type declarations.
166
+
167
+ ### Unchanged
168
+
169
+ - The compact URL format. Its parameter and species-code registries aren't settled yet.
170
+ - `sig` is preserved when parsing and generating encrypted URLs, but it isn't verified.
package/README.md CHANGED
@@ -12,7 +12,8 @@ Official TypeScript library for **WOLS** (WeMush Open Labeling Standard) - the o
12
12
  - 📜 **Parse** - Parse JSON/QR data back to typed specimens
13
13
  - 📱 **QR Codes** - Generate QR codes for specimens (PNG, SVG, Data URL)
14
14
  - 🔗 **Compact URLs** - Create short URLs for space-constrained labels
15
- - 🔐 **Encryption** - AES-256-GCM encryption for sensitive data
15
+ - 🔐 **Encryption** - WOLS 1.3.0 encrypted format (AES-256-GCM, per-label PBKDF2 salt or raw keys)
16
+ - 🧪 **Conformance** - Passes every `MUST` vector in the WOLS 1.3.0 conformance suite
16
17
 
17
18
  ## Installation
18
19
 
@@ -51,7 +52,7 @@ console.log(specimen['@context']); // "https://wemush.com/wols/v1"
51
52
 
52
53
  ## Core Module API
53
54
 
54
- ### `createSpecimen(input: SpecimenInput): Specimen`
55
+ ### `createSpecimen(input: SpecimenInput, options?): Specimen`
55
56
 
56
57
  Creates a new specimen with auto-generated ID and JSON-LD fields.
57
58
 
@@ -59,8 +60,9 @@ Creates a new specimen with auto-generated ID and JSON-LD fields.
59
60
  const specimen = createSpecimen({
60
61
  type: 'SPAWN',
61
62
  species: 'Hericium erinaceus',
62
- strain: { name: "Lion's Mane", generation: 'F2' },
63
+ strain: { name: "Lion's Mane", generation: 'F2', clonalGeneration: 3, transferDepth: 4 },
63
64
  stage: 'INOCULATION',
65
+ created: '2025-12-16T10:30:00Z',
64
66
  batch: 'BATCH-2024-001',
65
67
  organization: 'org_123',
66
68
  custom: { substrate: 'hardwood' },
@@ -69,15 +71,53 @@ const specimen = createSpecimen({
69
71
 
70
72
  **Parameters:**
71
73
 
72
- - `type` - Specimen type: `'CULTURE'`, `'SPAWN'`, `'SUBSTRATE'`, `'FRUITING'`, `'HARVEST'`
74
+ - `type` - Specimen type: `'CULTURE'`, `'SPAWN'`, `'SUBSTRATE'`, `'FRUITING'`, `'HARVEST'`, or an alias such as `'LC'`, `'GRAIN_SPAWN'`, `'BAG'`, `'PINNING'` (case-insensitive)
73
75
  - `species` - Scientific species name
74
76
  - `strain` - String (auto-expands to `{ name: string }`) or full `Strain` object
75
- - `stage` - Growth stage: `'INOCULATION'`, `'COLONIZATION'`, `'FRUITING'`, `'HARVEST'`
77
+ - `stage` - Growth stage: `'INOCULATION'`, `'INCUBATION'`, `'COLONIZATION'`, `'PRIMORDIA'`, `'FRUITING'`, `'SENESCENCE'`, `'HARVEST'`
78
+ - `created` - Creation timestamp, an ISO 8601 string or a `Date` (omitted when not supplied; invalid values throw)
76
79
  - `batch` - Batch identifier
77
80
  - `organization` - Organization ID
78
81
  - `creator` - Creator user ID
79
82
  - `custom` - Custom vendor-specific fields
80
83
 
84
+ **Options:**
85
+
86
+ - `onWarning(warning)` - Receives non-fatal warnings. If omitted, warnings go to `console.warn`.
87
+
88
+ #### `PRIMORDIA` as a type
89
+
90
+ `PRIMORDIA` is a growth stage, not a specimen type. Because people often supply it as a type,
91
+ WOLS 1.3.0 makes it a reserved alias. When `type` is `PRIMORDIA` (in any case), the library:
92
+
93
+ - resolves the type to `FRUITING`
94
+ - sets `stage: 'PRIMORDIA'` if no stage was supplied, and never overrides a supplied stage
95
+ - emits a `PRIMORDIA_AS_TYPE` warning that recommends `type: 'FRUITING', stage: 'PRIMORDIA'`
96
+ - never serializes `PRIMORDIA` as a `type`. `serializeSpecimen` applies the same resolution to hand-built objects.
97
+
98
+ ```typescript
99
+ const s = createSpecimen(
100
+ { type: 'primordia', species: 'Pleurotus ostreatus' },
101
+ { onWarning: w => log.info(w.message) }
102
+ );
103
+ // s.type === 'FRUITING', s.stage === 'PRIMORDIA'
104
+ ```
105
+
106
+ `validateSpecimen` still rejects `type: "PRIMORDIA"` in a serialized label.
107
+
108
+ #### Generation, subculture, and transfer depth
109
+
110
+ `strain` carries three independent counters (WOLS 1.3.0):
111
+
112
+ | Field | Meaning | Original culture |
113
+ | ------------------ | ---------------------------------------------------------- | ---------------- |
114
+ | `generation` | Filial generation (`P`, `F1`, `G2`, `3`, …) | `P` |
115
+ | `clonalGeneration` | Culture-to-culture subcultures | `0` |
116
+ | `transferDepth` | Transfers of any kind (subculture, to grain, to substrate) | `0` |
117
+
118
+ Both counters must be non-negative integers. Validation warns when `transferDepth` is less
119
+ than `clonalGeneration`.
120
+
81
121
  ### `serializeSpecimen(specimen: Specimen): string`
82
122
 
83
123
  Serializes a specimen to a JSON-LD string, suitable for QR encoding.
@@ -119,6 +159,11 @@ if (!result.valid) {
119
159
 
120
160
  - `allowUnknownFields` - Allow unknown fields in custom namespace
121
161
  - `level` - Validation strictness: `'strict'` or `'lenient'`
162
+ - `idMode` - ID check. Every mode requires the `wemush:` prefix.
163
+ - `'any'` (default): any non-empty suffix, as WOLS requires. CUIDs, ULIDs, UUIDs and human-readable IDs are all accepted.
164
+ - `'strict'` (opt-in): the suffix must be a CUID-style `[a-z0-9]+`.
165
+ - `'ulid'` / `'uuid'` (opt-in): the suffix must be a ULID or a UUID.
166
+ - `customIdValidator` - Your own `(id) => boolean` check, which takes precedence over `idMode`
122
167
 
123
168
  ## QR Code Module API
124
169
 
@@ -189,57 +234,148 @@ if (result.success) {
189
234
 
190
235
  ## Encryption Module API
191
236
 
192
- Encrypt specimens using AES-256-GCM for secure data handling.
237
+ Implements the WOLS 1.3.0 encrypted format with WebCrypto, so it works in Node.js 20+, browsers,
238
+ Deno and Bun.
239
+
240
+ ```
241
+ wemush://v1/encrypted/{id}?e={ciphertext}&iv={iv}&kdf=pbkdf2|raw[&salt={salt}&i={iterations}][&sig={signature}]
242
+ ```
243
+
244
+ - AES-256-GCM. `e` is the ciphertext with the 16-byte tag appended. `iv` is a fresh random 12-byte IV for every encryption.
245
+ - Every binary value is base64url without padding. Decoders also accept padded input.
246
+ - `{id}` is the specimen ID without the `wemush:` prefix, percent-encoded as one URL path segment (`encodeURIComponent`). Decoders percent-decode it as UTF-8 before the `id` check, keep `+` as a literal character, and reject malformed escapes or invalid UTF-8. It is cleartext, so don't put sensitive data in it.
247
+ - Passphrases are normalized to Unicode NFC before PBKDF2, so a passphrase typed in composed or decomposed form (for example `ü` as one code point, or `u` plus a combining mark) gives the same key.
193
248
 
194
249
  ```typescript
195
- import { encryptSpecimen, decryptSpecimen, isEncrypted } from '@wemush/wols';
250
+ import { encryptSpecimen, decryptSpecimen } from '@wemush/wols';
196
251
  // Or import from submodule:
197
252
  // import { encryptSpecimen } from '@wemush/wols/crypto';
198
253
  ```
199
254
 
200
255
  ### `encryptSpecimen(specimen, options): Promise<EncryptedSpecimen>`
201
256
 
202
- Encrypt a specimen with a password or CryptoKey.
257
+ Encrypts the whole label. The kind of `key` you pass selects the key derivation:
258
+
259
+ | `key` | `kdf` | Behavior |
260
+ | ------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------- |
261
+ | passphrase `string` | `pbkdf2` | PBKDF2-HMAC-SHA256 over the NFC-normalized passphrase, a fresh random 16-byte salt per label, 600,000 iterations by default |
262
+ | 32-byte `Uint8Array` / `ArrayBuffer`, or an AES-GCM 256 `CryptoKey` | `raw` | Key shared out of band. No `salt` or `i`. |
203
263
 
204
264
  ```typescript
205
- const encrypted = await encryptSpecimen(specimen, {
206
- key: 'my-secret-password',
207
- });
265
+ const enc = await encryptSpecimen(specimen, { key: 'correct horse battery staple' });
266
+ enc.url;
267
+ // "wemush://v1/encrypted/clx1a2b3c4?e=...&iv=...&kdf=pbkdf2&salt=...&i=600000"
208
268
 
209
- console.log(encrypted);
210
- // { encrypted: true, payload: "...", iv: "...", algorithm: "AES-256-GCM" }
269
+ const raw = await encryptSpecimen(specimen, { key: crypto.getRandomValues(new Uint8Array(32)) });
270
+ raw.url; // "wemush://v1/encrypted/clx1a2b3c4?e=...&iv=...&kdf=raw"
211
271
  ```
212
272
 
213
- ### `decryptSpecimen(encrypted, options): Promise<ParseResult<Specimen>>`
273
+ The result also carries `payload`, `iv`, `kdf`, `salt`, `iterations` and `id`. Put `url` in the QR code.
274
+
275
+ **Options:**
276
+
277
+ - `key` - A passphrase (`kdf=pbkdf2`), or raw key bytes or a `CryptoKey` (`kdf=raw`)
278
+ - `iterations` - PBKDF2 iterations. Passphrase keys only. The default is 600,000, and anything
279
+ from 100,000 to 10,000,000 is allowed.
280
+ - `fields` - **Deprecated.** Use `encryptFields` instead.
281
+
282
+ ### `encryptFields(specimen, { key, fields }): Promise<FieldEncryptedSpecimen>`
214
283
 
215
- Decrypt an encrypted specimen.
284
+ Field-level encryption. Each named field's value is replaced in place with
285
+ `{ encrypted: true, algorithm: "AES-256-GCM", payload, iv, kdf, salt?, iterations? }`, and the rest
286
+ of the label stays in cleartext. Every name in `fields` must be a field the specimen actually has.
287
+ A misspelled or missing field throws instead of being left unprotected. `@context`, `@type`, `id`,
288
+ `version`, `type` and `species` identify the label, so they can't be encrypted. The result is typed
289
+ precisely: the named fields are `EncryptedField`, and every other field keeps its cleartext type.
216
290
 
217
291
  ```typescript
218
- const result = await decryptSpecimen(encrypted, {
219
- key: 'my-secret-password',
220
- });
292
+ const partial = await encryptFields(specimen, { key: passphrase, fields: ['strain'] });
293
+ partial.species; // cleartext
294
+ partial.strain; // { encrypted: true, kdf: 'pbkdf2', ... }
221
295
 
222
- if (result.success) {
223
- console.log(result.data.species);
224
- } else {
225
- console.error('Decryption failed:', result.error.message);
226
- }
296
+ const restored = await decryptFields(partial, { key: passphrase });
297
+ if (restored.success) restored.data.strain?.name; // a validated Specimen
227
298
  ```
228
299
 
229
- ### `isEncrypted(value): boolean`
300
+ Each value is bound to its label and field. The AES-GCM additional data is the UTF-8 encoding of
301
+ the label's full `id`, a line feed, and the field name, for example `"wemush:clx1a2b3c4\nstrain"`.
302
+ Every field gets its own random IV. A value moved to another field, copied from another label, or
303
+ left behind after the `id` changes fails with `authentication-failed`.
304
+
305
+ `decryptFields` refuses a label in which an identifying field is encrypted (`identifying-field`).
306
+ It never substitutes a decrypted value for one. Before deriving any key, it adds up the iterations
307
+ of each distinct salt and iteration-count pair, and refuses the label when the total exceeds
308
+ `maxIterations`.
309
+
310
+ To decrypt one value, pass its binding: `decryptField(partial.strain, { key }, { id: partial.id, field: 'strain' })`.
230
311
 
231
- Check if a value is an encrypted specimen.
312
+ ### `decryptSpecimen(input, options): Promise<DecryptResult<Specimen>>`
313
+
314
+ `input` can be an encrypted URL or an `EncryptedSpecimen` object. Use `decryptPlaintext` to get the
315
+ exact decrypted JSON string instead. Decryption never throws. A failure carries a
316
+ `WolsDecryptionError` with a typed `reason`:
232
317
 
233
318
  ```typescript
234
- if (isEncrypted(data)) {
235
- // Handle encrypted specimen
319
+ const result = await decryptSpecimen(url, { key: 'correct horse battery staple' });
320
+
321
+ if (result.success) {
322
+ console.log(result.data.species);
323
+ } else if (result.error.reason === 'legacy-format') {
324
+ // a pre-1.3.0 label: see "Legacy labels" below
325
+ } else {
326
+ console.error(result.error.reason, result.error.message);
236
327
  }
237
328
  ```
238
329
 
239
- **Encryption Options:**
330
+ **Options:**
240
331
 
241
- - `key` - Password string (uses PBKDF2) or `CryptoKey`
242
- - `fields` - Encrypt only specific fields (partial encryption)
332
+ - `key` - The passphrase or raw key
333
+ - `allowLegacy` - Decrypt pre-1.3.0 labels (see below)
334
+ - `maxIterations` - The largest PBKDF2 iteration count accepted from a label. The default and the
335
+ maximum are both 10,000,000. You can set it lower, never higher. The count comes from the label,
336
+ so this caps how much CPU a hostile QR code can cost. A server that decodes untrusted labels
337
+ should set it close to the count it generates with. `decryptFields` applies it to the whole
338
+ label: fields that share a salt share one key derivation, and the total across distinct salts
339
+ must stay under the limit.
340
+
341
+ Every cheap check runs before key derivation, so a malformed label costs no PBKDF2 work. The
342
+ failure reasons are:
343
+
344
+ | `reason` | When |
345
+ | --- | --- |
346
+ | `id-mismatch` | The decrypted specimen's `id` isn't `wemush:{id}` from the URL path |
347
+ | `authentication-failed` | The key is wrong, or the ciphertext was tampered with |
348
+ | `insufficient-iterations` / `excessive-iterations` | `i` is below 10,000, or above `maxIterations` (alone, or in total for field-level) |
349
+ | `missing-parameter` | `e` or `iv` is missing, or `kdf=pbkdf2` lacks `salt` or `i` |
350
+ | `unexpected-parameter` | `kdf=raw` has `salt` or `i`, or a legacy label has `salt` |
351
+ | `invalid-parameter` | Bad base64url, `i` that isn't plain decimal digits, an IV that isn't 12 bytes, ciphertext shorter than the tag, a duplicate parameter |
352
+ | `invalid-encoding` | The ID in the URL path has a malformed percent-escape or isn't valid UTF-8 |
353
+ | `identifying-field` | A field-level label has an encrypted `@context`, `@type`, `id`, `version`, `type` or `species` |
354
+ | `unsupported-kdf` | `kdf` is something other than `pbkdf2` or `raw` |
355
+ | `invalid-key` | The key doesn't fit the label, for example a passphrase for a `raw` label |
356
+ | `legacy-format` | No `kdf`: a pre-1.3.0 label, and `allowLegacy` wasn't set |
357
+ | `invalid-plaintext` | The decrypted bytes aren't UTF-8 JSON (or start with a BOM), or aren't a valid specimen |
358
+ | `invalid-url` | Not a `wemush://v1/encrypted/...` URL |
359
+ | `unsupported-environment` | The runtime has no WebCrypto |
360
+
361
+ The `id` check applies to the URL form and to objects that carry an `id`. An object without `id`
362
+ isn't bound to any ID, so prefer URLs for input you don't trust.
363
+
364
+ After a label authenticates, error messages and `details` can contain decrypted content, such as
365
+ the mismatched `id` or the specimen validation errors. Don't forward them to untrusted parties.
366
+
367
+ **Legacy labels:** payloads from before 1.3.0 have no `kdf`. They were encrypted with a fixed
368
+ library-wide salt, and they fail with `legacy-format` by default. Pass `{ allowLegacy: true }` only
369
+ when you are decrypting labels you know are old, for example during a migration. Re-encrypt them
370
+ with `encryptSpecimen`. Legacy labels are never generated.
371
+
372
+ ### Other helpers
373
+
374
+ - `toEncryptedUrl(encrypted)` / `parseEncryptedUrl(url)` - convert between the URL and the object form. `toEncryptedUrl` refuses objects that would make a non-conformant label: values that aren't canonical unpadded base64url, an IV that isn't 12 bytes, fewer than 100,000 iterations, a salt that is short or byte-equal to the legacy salt, or `salt` with `kdf=raw`.
375
+ - `decryptField(field, options)` - decrypt a single encrypted field value
376
+ - `isEncryptedSpecimen(value)` / `isEncryptedField(value)` - tell a whole-label encryption from a field-level one. `isEncrypted(value)` matches either.
377
+ - `derivePassphraseKey(passphrase, salt, iterations)` - raw PBKDF2 output, for debugging interoperability. It doesn't apply the label rules.
378
+ - `base64UrlEncode(bytes)` / `base64UrlDecode(str)`
243
379
 
244
380
  ## Types
245
381
 
@@ -257,7 +393,13 @@ import type {
257
393
  ValidationResult,
258
394
  ValidationError,
259
395
  EncryptedSpecimen,
396
+ EncryptedField,
397
+ FieldEncryptedSpecimen,
260
398
  EncryptionOptions,
399
+ FieldEncryptionOptions,
400
+ DecryptionOptions,
401
+ DecryptionFailureReason,
402
+ DecryptResult,
261
403
  QRCodeOptions,
262
404
  } from '@wemush/wols';
263
405
  ```
@@ -287,7 +429,38 @@ if (!result.success) {
287
429
 
288
430
  ## WOLS Specification
289
431
 
290
- This library implements **WOLS v1.1.0**. For the full specification, see the [WOLS Specification](https://wemush.com/open-standard).
432
+ This library implements **WOLS v1.3.0**. For the full specification, see the
433
+ [WOLS Specification](https://github.com/wemush/open-standard/blob/main/SPECIFICATION.md).
434
+
435
+ ### Conformance
436
+
437
+ The shared [WOLS conformance vectors](https://github.com/wemush/open-standard/tree/main/conformance)
438
+ are vendored in `tests/conformance/vectors` and run with `npm test` (or `npm run test:conformance`).
439
+ Every `MUST` case passes. `SHOULD` cases are reported but don't fail the build. See
440
+ [`tests/conformance/README.md`](tests/conformance/README.md) for how to refresh the vectors.
441
+
442
+ ### Upgrading from 1.2
443
+
444
+ See [CHANGELOG.md](CHANGELOG.md). Every change that can break a 1.2.x caller:
445
+
446
+ - **Encrypted generation changed.** Passphrase encryption now uses a random per-label salt and
447
+ records `kdf`, `salt` and `i`. Labels made by 1.3.0 can't be read by 1.2.x, so upgrade readers
448
+ before writers. 1.2.x labels fail with `legacy-format` unless you pass `allowLegacy: true`.
449
+ - Passphrase encryption defaults to 600,000 iterations and allows 100,000 to 10,000,000. It used
450
+ to default to 100,000, with a minimum of 10,000. Passphrases are now normalized to NFC.
451
+ - A `CryptoKey` must now be AES-GCM with a length of 256 and allow `encrypt` or `decrypt`. That
452
+ applies to legacy labels too.
453
+ - Field-level encryption (`fields`) throws for a field the specimen doesn't have. It used to skip
454
+ the field silently, leaving it in cleartext. Field values use the 1.3.0 shape (`kdf`, `salt`,
455
+ `iterations` instead of `cryptoVersion`).
456
+ - The default `idMode` is now `'any'`. Pass `idMode: 'strict'` to keep the old CUID-style check.
457
+ This loosens validation, so if `validateSpecimen` gates untrusted input, decide which mode you
458
+ want.
459
+ - `createSpecimen({ type: 'PRIMORDIA' })` now sets `stage: 'PRIMORDIA'` and warns through
460
+ `console.warn` unless you pass `onWarning`. `registerTypeAlias('PRIMORDIA', …)` with anything other
461
+ than `FRUITING` throws.
462
+ - `isEncrypted` now narrows to `EncryptedSpecimen | EncryptedField`.
463
+ - The encryption functions need a global WebCrypto, which means Node.js 20 or later.
291
464
 
292
465
  ## License
293
466
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Compact URL Format Functions
3
+ *
4
+ * This module provides functions to generate and parse compact URL format
5
+ * for WOLS specimens. The compact format is designed for small QR codes
6
+ * (petri dishes, culture tubes) where space is limited.
7
+ *
8
+ * Format: wemush://v1/{id}?s={species_code}&st={stage}&t={timestamp}
9
+ *
10
+ * @module compact-url
11
+ */
12
+ import type { Specimen, ParseResult, SpecimenRef } from './types.js';
13
+ /**
14
+ * Standard species codes from the WOLS specification
15
+ *
16
+ * @see https://github.com/wemush/open-standard for additional codes
17
+ */
18
+ export declare const SPECIES_CODES: Record<string, string>;
19
+ /**
20
+ * Get the full species name from a species code
21
+ *
22
+ * @param code - Species code (e.g., "POSTR")
23
+ * @returns Species name or undefined if code is unknown
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * getSpeciesFromCode('POSTR') // Returns "Pleurotus ostreatus"
28
+ * getSpeciesFromCode('XXXXX') // Returns undefined
29
+ * ```
30
+ */
31
+ export declare function getSpeciesFromCode(code: string): string | undefined;
32
+ /**
33
+ * Get the species code from a species name
34
+ *
35
+ * @param species - Full species name (e.g., "Pleurotus ostreatus")
36
+ * @returns Species code or undefined if species is not registered
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * getCodeFromSpecies('Pleurotus ostreatus') // Returns "POSTR"
41
+ * getCodeFromSpecies('Unknown species') // Returns undefined
42
+ * ```
43
+ */
44
+ export declare function getCodeFromSpecies(species: string): string | undefined;
45
+ /**
46
+ * Register a custom species code
47
+ *
48
+ * Use this to add species codes that are not in the standard WOLS list.
49
+ *
50
+ * @param code - Species code (uppercase, 5 characters recommended)
51
+ * @param species - Full species name
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * registerSpeciesCode('CCOMA', 'Coprinus comatus');
56
+ * getSpeciesFromCode('CCOMA') // Returns "Coprinus comatus"
57
+ * ```
58
+ */
59
+ export declare function registerSpeciesCode(code: string, species: string): void;
60
+ /**
61
+ * Generate a compact URL from a specimen
62
+ *
63
+ * The compact URL format is designed for small QR codes:
64
+ * ```
65
+ * wemush://v1/{id}?s={species_code}&st={stage}&t={timestamp}
66
+ * ```
67
+ *
68
+ * @param specimen - The specimen to encode
69
+ * @returns Compact URL string
70
+ *
71
+ * @example
72
+ * ```typescript
73
+ * const specimen = createSpecimen({
74
+ * type: 'CULTURE',
75
+ * species: 'Pleurotus ostreatus',
76
+ * stage: 'COLONIZATION'
77
+ * });
78
+ *
79
+ * const url = toCompactUrl(specimen);
80
+ * // wemush://v1/clx1a2b3c4?s=POSTR&st=COLONIZATION&ty=CULTURE&t=1734307200
81
+ * ```
82
+ */
83
+ export declare function toCompactUrl(specimen: Specimen): string;
84
+ /**
85
+ * Parse a compact URL into a SpecimenRef
86
+ *
87
+ * Returns a reference object containing the minimal information
88
+ * encoded in the compact URL. Use the id to fetch full specimen
89
+ * data from the API if needed.
90
+ *
91
+ * @param url - Compact URL to parse (wemush://v1/...)
92
+ * @returns ParseResult with SpecimenRef on success, error on failure
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * const result = parseCompactUrl('wemush://v1/clx1a2b3c4?s=POSTR&st=COLONIZATION');
97
+ *
98
+ * if (result.success) {
99
+ * console.log(result.data.species); // "Pleurotus ostreatus"
100
+ * }
101
+ * ```
102
+ */
103
+ export declare function parseCompactUrl(url: string): ParseResult<SpecimenRef>;
104
+ /**
105
+ * Parse a compact URL, throwing an error on failure.
106
+ *
107
+ * This is a convenience wrapper around parseCompactUrl() for cases
108
+ * where you prefer exceptions over result types.
109
+ *
110
+ * @param url - Compact URL to parse (wemush://v1/...)
111
+ * @returns SpecimenRef on success
112
+ * @throws WolsParseError on parse failure
113
+ *
114
+ * @example
115
+ * ```typescript
116
+ * try {
117
+ * const ref = parseCompactUrlOrThrow('wemush://v1/clx1a2b3c4?s=POSTR');
118
+ * console.log(ref.species); // "Pleurotus ostreatus"
119
+ * } catch (err) {
120
+ * console.error('Failed to parse:', err.message);
121
+ * }
122
+ * ```
123
+ *
124
+ * @since 1.2.0
125
+ */
126
+ export declare function parseCompactUrlOrThrow(url: string): SpecimenRef;
127
+ /**
128
+ * Parse a compact URL, returning null on failure.
129
+ *
130
+ * This is a convenience wrapper around parseCompactUrl() for cases
131
+ * where you want to handle failures with nullish coalescing.
132
+ *
133
+ * @param url - Compact URL to parse (wemush://v1/...)
134
+ * @returns SpecimenRef on success, null on failure
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * const ref = parseCompactUrlOrNull('wemush://v1/clx1a2b3c4?s=POSTR');
139
+ * if (ref !== null) {
140
+ * console.log(ref.species);
141
+ * }
142
+ *
143
+ * // Or with nullish coalescing:
144
+ * const species = parseCompactUrlOrNull(url)?.species ?? 'Unknown';
145
+ * ```
146
+ *
147
+ * @since 1.2.0
148
+ */
149
+ export declare function parseCompactUrlOrNull(url: string): SpecimenRef | null;
150
+ //# sourceMappingURL=compact-url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compact-url.d.ts","sourceRoot":"","sources":["../../src/compact-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAA6B,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEhG;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAWhD,CAAC;AAYF;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEnE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEtE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CASvE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAsDvD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAAC,WAAW,CAAC,CA6GrE;AAMD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAM/D;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAOrE"}