@kubun/store-credential 0.17.2 → 0.17.3

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/lib/api.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
- import type { Kysely } from 'kysely';
2
+ import { type Kysely } from 'kysely';
3
3
  import type { CredentialEntry, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialKey, CredentialKeyBranch, CredentialKeyState, CredentialManifestKey, CredentialOp, CredentialOpSubjectKind, CredentialTables, CredentialWrapping, CredentialWrappingWithBranch } from './tables.js';
4
4
  /**
5
5
  * The persisted half of a credential manifest — a structural subset of the
@@ -183,6 +183,15 @@ export type CredentialStoreAPI = {
183
183
  * source and target keys before invoking this capability.
184
184
  */
185
185
  moveEntry(params: PutCredentialEntryParams, op: CredentialOpInput | null): Promise<void>;
186
+ /**
187
+ * Write over an entry's ciphertext ONLY if the row already exists under the
188
+ * same key — never inserts, never moves it. Op-first like putEntry: the op is
189
+ * written under the op-conflict lock, then the row; if no matching row is
190
+ * present (concurrently deleted, or re-keyed away) the op is rolled back, so a
191
+ * refresh writeback can neither revive a concurrently-deleted entry nor leave
192
+ * an op stranded without its row. Returns 'absent' when nothing was updated.
193
+ */
194
+ updateEntryIfPresent(params: PutCredentialEntryParams, op: CredentialOpInput | null): Promise<'updated' | 'absent'>;
186
195
  getEntry(entryID: string): Promise<CredentialEntry | undefined>;
187
196
  getEntryWithProvenance(entryID: string): Promise<CredentialEntryWithProvenance | undefined>;
188
197
  listEntries(keyID: string): Promise<Array<CredentialEntry>>;
@@ -228,8 +237,27 @@ export type CredentialStoreAPI = {
228
237
  getAliasEntryID(namespace: string, key: string): Promise<string | null>;
229
238
  /** Point `(namespace, key)` at `entryID`, replacing any prior target. */
230
239
  setAliasEntryID(namespace: string, key: string, entryID: string): Promise<void>;
240
+ /**
241
+ * Bind `(namespace, aliasKey)` to `entryID` only if the alias slot is free and
242
+ * the entry exists — unlike {@link setAliasEntryID}, this never overwrites an
243
+ * existing alias and never binds a nonexistent entry. Classifies the outcome
244
+ * instead of throwing, since both unique constraints are expected outcomes a
245
+ * caller branches on, not exceptional ones.
246
+ */
247
+ bindAliasIfAbsent(namespace: string, aliasKey: string, entryID: string): Promise<'inserted' | 'entry-missing' | {
248
+ conflict: 'alias';
249
+ existing: string;
250
+ } | {
251
+ conflict: 'entry';
252
+ }>;
231
253
  /** Clear an alias. A no-op, not an error, when none is set. */
232
254
  deleteAliasEntryID(namespace: string, key: string): Promise<void>;
255
+ /**
256
+ * Clear an alias only while it still points at `entryID`. A concurrent rebind
257
+ * that repaired the pointer onto a fresh entry in the gap after the old entry
258
+ * was deleted is left intact; a no-op when the pointer already moved or is unset.
259
+ */
260
+ deleteAliasIfEntry(namespace: string, key: string, entryID: string): Promise<void>;
233
261
  /**
234
262
  * The distinct keys currently set within `namespace`. Keys stay opaque here —
235
263
  * a caller interprets them (e.g. connector credentials store the normalized
package/lib/api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
+ import { sql } from 'kysely';
2
3
  /**
3
4
  * `refreshSeq` sentinel for a delegation chain that has no future refresh —
4
5
  * larger than any real ms timestamp, so a bounded refresh can never overtake
@@ -37,6 +38,26 @@ import { normalizeDID } from '@kokuin/token';
37
38
  }).where('kubun_credential_wrapping_authority.refresh_seq', '<', values.refresh_seq)).execute();
38
39
  }
39
40
  const OP_REJECTED = Symbol('credential op rejected');
41
+ // A repair that repoints a dangling alias can collide with UNIQUE(entry_id) when the
42
+ // caller's entry is already aliased under another name. The error classes and codes
43
+ // share nothing across the two drivers, so match a uniqueness marker AND the exact
44
+ // identifier each driver reports — never a bare substring, which would also swallow an
45
+ // unrelated error that merely mentions the column. Postgres: SQLSTATE 23505 plus the
46
+ // constraint name (on the error or in the message). SQLite: "UNIQUE constraint failed"
47
+ // plus the table.column.
48
+ function isEntryAliasUniqueViolation(error) {
49
+ if (error == null || typeof error !== 'object') return false;
50
+ const { message, code, constraint_name, constraint } = error;
51
+ const msg = typeof message === 'string' ? message : '';
52
+ const name = typeof constraint === 'string' && constraint || typeof constraint_name === 'string' && constraint_name || '';
53
+ const entryConstraint = 'uq_kubun_credential_aliases_entry' // Postgres constraint
54
+ ;
55
+ const entryColumn = 'kubun_credential_aliases.entry_id' // SQLite column
56
+ ;
57
+ const postgres = (code === '23505' || msg.includes('duplicate key value')) && (name === entryConstraint || msg.includes(entryConstraint));
58
+ const sqlite = msg.includes('UNIQUE constraint failed') && msg.includes(entryColumn);
59
+ return postgres || sqlite;
60
+ }
40
61
  async function writeOp(params) {
41
62
  const { db, subjectKind, subjectID, op, deleted, allowKeyMove = false } = params;
42
63
  const values = {
@@ -92,7 +113,7 @@ export function createCredentialStore(db, adapter) {
92
113
  if (accept == null) {
93
114
  await write(db);
94
115
  } else {
95
- await writeAtomically(db, async (tx)=>{
116
+ await writeAmbient(db, async (tx)=>{
96
117
  if (await accept(tx)) await write(tx);
97
118
  });
98
119
  }
@@ -100,7 +121,7 @@ export function createCredentialStore(db, adapter) {
100
121
  }
101
122
  const ownsTransaction = !db.isTransaction;
102
123
  try {
103
- await writeAtomically(db, async (tx)=>{
124
+ await writeAmbient(db, async (tx)=>{
104
125
  if (!await writeOp({
105
126
  db: tx,
106
127
  subjectKind,
@@ -125,7 +146,7 @@ export function createCredentialStore(db, adapter) {
125
146
  await remove(db);
126
147
  return;
127
148
  }
128
- await writeAtomically(db, async (tx)=>{
149
+ await writeAmbient(db, async (tx)=>{
129
150
  if (!await writeOp({
130
151
  db: tx,
131
152
  subjectKind,
@@ -173,6 +194,32 @@ export function createCredentialStore(db, adapter) {
173
194
  allowKeyMove: allowMove
174
195
  });
175
196
  }
197
+ // One async gate serializes EVERY write that joins a caller-supplied ambient
198
+ // transaction. The SAVEPOINT sections below (updateEntryIfPresent's op+row fence,
199
+ // bindAliasIfAbsent's repair fence) then hold the connection exclusively for their
200
+ // whole open → rollback/release window, so a ROLLBACK TO SAVEPOINT can never undo
201
+ // another concurrent write's committed work — a savepoint rollback reverts ALL work
202
+ // since the savepoint, not just this operation's statements. getStates resolves its
203
+ // connectors with Promise.all over one shared request tx, the source of that
204
+ // concurrency. The gate covers not only the savepoint sections but every ambient
205
+ // write (writeAmbient, and the raw alias writers via gatedIfAmbient), so no ungated
206
+ // statement can land inside a savepoint window. Reads are never gated. Owned
207
+ // transactions (db.isTransaction === false) open their own connection and are
208
+ // serialized by the driver, so they skip the gate. The gate is per api instance:
209
+ // `db.isTransaction` is fixed per instance, so a fresh transaction builds a fresh
210
+ // store with its own gate.
211
+ let ambientWriteChain = Promise.resolve();
212
+ const withAmbientWrite = (fn)=>{
213
+ const run = ambientWriteChain.then(fn, fn);
214
+ ambientWriteChain = run.then(()=>undefined, ()=>undefined);
215
+ return run;
216
+ };
217
+ // Gate a single raw ambient statement; run it directly when this store owns no
218
+ // ambient transaction (nothing else can interleave a savepoint there).
219
+ const gatedIfAmbient = (fn)=>db.isTransaction ? withAmbientWrite(fn) : fn();
220
+ // writeAtomically, but the inline (ambient) branch runs under the gate so it cannot
221
+ // interleave a concurrent savepoint section. The owned branch opens its own tx.
222
+ const writeAmbient = (innerDb, write)=>innerDb.isTransaction ? withAmbientWrite(()=>writeAtomically(innerDb, write)) : writeAtomically(innerDb, write);
176
223
  const api = {
177
224
  async transaction (fn) {
178
225
  if (db.isTransaction) {
@@ -351,7 +398,7 @@ export function createCredentialStore(db, adapter) {
351
398
  await remove(db);
352
399
  return;
353
400
  }
354
- await writeAtomically(db, async (tx)=>{
401
+ await writeAmbient(db, async (tx)=>{
355
402
  for (const tombstone of tombstones){
356
403
  if (!await writeOp({
357
404
  db: tx,
@@ -372,6 +419,76 @@ export function createCredentialStore(db, adapter) {
372
419
  async moveEntry (params, op) {
373
420
  await writeEntry(params, op, true);
374
421
  },
422
+ async updateEntryIfPresent (params, op) {
423
+ let outcome = 'absent';
424
+ const runWrite = async (tx)=>{
425
+ // Op-first, like putEntry/deleteEntry: take the op lock before the entry row,
426
+ // the same order deleteEntry uses, so a row-first inversion cannot deadlock a
427
+ // concurrent delete. writeOp's LWW guard rejects a stale-HLC writeback and its
428
+ // key_id predicate (default allowKeyMove = false) one aimed at a key the entry
429
+ // was re-keyed away from; either way it returns false and nothing is touched.
430
+ // Its insert branch mints the signed op for a GraphQL-created op-less entry
431
+ // (putEntry(..., null)), so that entry's first refresh is preserved.
432
+ if (op != null && !await writeOp({
433
+ db: tx,
434
+ subjectKind: 'entry',
435
+ subjectID: params.entryID,
436
+ op,
437
+ deleted: 0
438
+ })) {
439
+ return;
440
+ }
441
+ // The op winning does NOT prove the entry row is present under this key: the
442
+ // GraphQL admin surface writes a row with no op (putEntry(..., null)) and
443
+ // removes a row without touching its op (deleteEntry(..., null)), so the row
444
+ // can be absent or re-keyed here. UPDATE-only, keyed on entry_id AND key_id;
445
+ // on a miss, throw to roll the provisional op back, so op and row never
446
+ // diverge and no live op is left without its row.
447
+ const result = await tx.updateTable('kubun_credential_entries').set({
448
+ key_version: params.keyVersion,
449
+ iv: adapter.encodeBinary(params.iv),
450
+ ciphertext: adapter.encodeBinary(params.ciphertext),
451
+ hlc: params.hlc
452
+ }).where('entry_id', '=', params.entryID).where('key_id', '=', params.keyID).executeTakeFirst();
453
+ if ((result.numUpdatedRows ?? 0n) === 0n) {
454
+ if (op != null) throw OP_REJECTED;
455
+ return;
456
+ }
457
+ outcome = 'updated';
458
+ };
459
+ if (!db.isTransaction) {
460
+ // We own the transaction: writeAtomically opens it and an OP_REJECTED throw
461
+ // rolls the provisional op back before it can commit.
462
+ try {
463
+ await writeAmbient(db, runWrite);
464
+ } catch (error) {
465
+ if (error === OP_REJECTED) return 'absent';
466
+ throw error;
467
+ }
468
+ return outcome;
469
+ }
470
+ // Ambient caller transaction (e.g. a connector mutation's per-request refresh
471
+ // stack joins its tx). writeAtomically runs inline here with no rollback fence,
472
+ // and the refresh decorator catches every writeback error — so a bare throw
473
+ // would leave the provisional op committed with no row. Fence the op+row writes
474
+ // in a savepoint: a miss rolls back ONLY the provisional op, leaving the
475
+ // caller's transaction intact. The section runs under withAmbientWrite so no other
476
+ // ambient write can land inside this savepoint window and be reverted with it.
477
+ // SAVEPOINT is portable across SQLite and Postgres.
478
+ return await withAmbientWrite(async ()=>{
479
+ await sql`SAVEPOINT kubun_cred_update_entry`.execute(db);
480
+ try {
481
+ await runWrite(db);
482
+ } catch (error) {
483
+ await sql`ROLLBACK TO SAVEPOINT kubun_cred_update_entry`.execute(db);
484
+ await sql`RELEASE SAVEPOINT kubun_cred_update_entry`.execute(db);
485
+ if (error === OP_REJECTED) return 'absent';
486
+ throw error;
487
+ }
488
+ await sql`RELEASE SAVEPOINT kubun_cred_update_entry`.execute(db);
489
+ return outcome;
490
+ });
491
+ },
375
492
  async getEntry (entryID) {
376
493
  return await db.selectFrom('kubun_credential_entries').selectAll().where('entry_id', '=', entryID).executeTakeFirst();
377
494
  },
@@ -450,19 +567,134 @@ export function createCredentialStore(db, adapter) {
450
567
  return row?.entry_id ?? null;
451
568
  },
452
569
  async setAliasEntryID (namespace, key, entryID) {
453
- await db.insertInto('kubun_credential_aliases').values({
454
- namespace,
455
- alias_key: key,
456
- entry_id: entryID
457
- }).onConflict((oc)=>oc.columns([
458
- 'namespace',
459
- 'alias_key'
460
- ]).doUpdateSet({
570
+ await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').values({
571
+ namespace,
572
+ alias_key: key,
461
573
  entry_id: entryID
462
- })).execute();
574
+ }).onConflict((oc)=>oc.columns([
575
+ 'namespace',
576
+ 'alias_key'
577
+ ]).doUpdateSet({
578
+ entry_id: entryID
579
+ })).execute());
580
+ },
581
+ async bindAliasIfAbsent (namespace, aliasKey, entryID) {
582
+ // Entry-existence-conditioned insert: the alias row is written only in the same
583
+ // statement that confirms the entry exists. ON CONFLICT DO NOTHING fires for either
584
+ // the (namespace, alias_key) PK or the UNIQUE(entry_id); the read-back names which.
585
+ //
586
+ // The table has no FK to entries (SQLite ships with foreign_keys OFF, so a declared
587
+ // FK would be unenforced there). The INSERT...SELECT closes the race on any
588
+ // single-writer store; deleteCredential removes the entry before the pointer so a
589
+ // bind racing a delete refuses cleanly rather than dangling. A pointer that still
590
+ // outlives its entry (a Postgres delete committing mid-statement) is recovered two
591
+ // ways: getCredential/getCredentialRef resolve a missing entry to null, and the
592
+ // repair path below repoints a dangling pointer to a fresh, present entry.
593
+ const tryInsert = async ()=>{
594
+ const inserted = await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').columns([
595
+ 'namespace',
596
+ 'alias_key',
597
+ 'entry_id'
598
+ ]).expression((eb)=>eb.selectFrom('kubun_credential_entries').where('entry_id', '=', entryID).select((eb2)=>[
599
+ eb2.val(namespace).as('namespace'),
600
+ eb2.val(aliasKey).as('alias_key'),
601
+ eb2.ref('entry_id').as('entry_id')
602
+ ])).onConflict((oc)=>oc.doNothing()).returning('entry_id').executeTakeFirst());
603
+ return inserted != null;
604
+ };
605
+ const aliasAt = async ()=>{
606
+ const row = await db.selectFrom('kubun_credential_aliases').select('entry_id').where('namespace', '=', namespace).where('alias_key', '=', aliasKey).executeTakeFirst();
607
+ return row?.entry_id ?? null;
608
+ };
609
+ const entryExists = async (id)=>{
610
+ const row = await db.selectFrom('kubun_credential_entries').select('entry_id').where('entry_id', '=', id).executeTakeFirst();
611
+ return row != null;
612
+ };
613
+ // Repoint this alias to entryID, but ONLY while its stored pointer is still
614
+ // dangling (its entry row absent), so a concurrently-installed valid binding is
615
+ // never overwritten — the no-repoint guarantee. A UNIQUE(entry_id) clash (entryID
616
+ // already aliased elsewhere) must not abort an ambient Postgres transaction, so a
617
+ // caller-tx write is fenced in a savepoint and the clash mapped to conflict:'entry'.
618
+ const repairDangling = async (observedExisting)=>{
619
+ const doUpdate = async ()=>{
620
+ const res = await db.updateTable('kubun_credential_aliases').set({
621
+ entry_id: entryID
622
+ }).where('namespace', '=', namespace).where('alias_key', '=', aliasKey)// Compare-and-swap on the pointer: repoint only while it still holds the
623
+ // dangling id we observed. If a concurrent transaction repointed it onto a
624
+ // freshly created entry, this matches zero rows — under Postgres READ
625
+ // COMMITTED the re-checked current row carries the new id, so a valid binding
626
+ // is never overwritten even when that new entry is outside our snapshot.
627
+ .where('entry_id', '=', observedExisting)// The observed pointer must still be dangling (its entry absent)...
628
+ .where((eb)=>eb.not(eb.exists(eb.selectFrom('kubun_credential_entries as e').select(sql`1`.as('one')).whereRef('e.entry_id', '=', 'kubun_credential_aliases.entry_id'))))// ...and the target must be present, so repair can never form a NEW dangling
629
+ // pointer even if the target is deleted between the pre-check and here.
630
+ .where((eb)=>eb.exists(eb.selectFrom('kubun_credential_entries as t').select(sql`1`.as('one')).where('t.entry_id', '=', entryID))).executeTakeFirst();
631
+ return (res.numUpdatedRows ?? 0n) > 0n;
632
+ };
633
+ if (!db.isTransaction) {
634
+ try {
635
+ return await doUpdate();
636
+ } catch (error) {
637
+ if (isEntryAliasUniqueViolation(error)) return 'entry-clash';
638
+ throw error;
639
+ }
640
+ }
641
+ return await withAmbientWrite(async ()=>{
642
+ await sql`SAVEPOINT kubun_cred_bind_repair`.execute(db);
643
+ try {
644
+ const repaired = await doUpdate();
645
+ await sql`RELEASE SAVEPOINT kubun_cred_bind_repair`.execute(db);
646
+ return repaired;
647
+ } catch (error) {
648
+ await sql`ROLLBACK TO SAVEPOINT kubun_cred_bind_repair`.execute(db);
649
+ await sql`RELEASE SAVEPOINT kubun_cred_bind_repair`.execute(db);
650
+ if (isEntryAliasUniqueViolation(error)) return 'entry-clash';
651
+ throw error;
652
+ }
653
+ });
654
+ };
655
+ if (await tryInsert()) return 'inserted';
656
+ if (!await entryExists(entryID)) return 'entry-missing';
657
+ const existing = await aliasAt();
658
+ // PK free but the insert did not fire and the entry exists → the caller's entry is
659
+ // already aliased under another name (UNIQUE(entry_id)).
660
+ if (existing == null) return {
661
+ conflict: 'entry'
662
+ };
663
+ if (existing === entryID) return {
664
+ conflict: 'alias',
665
+ existing
666
+ };
667
+ // PK held by a different entry; repair only if that pointer is dangling.
668
+ if (await entryExists(existing)) return {
669
+ conflict: 'alias',
670
+ existing
671
+ };
672
+ const repaired = await repairDangling(existing);
673
+ if (repaired === true) return 'inserted';
674
+ if (repaired === 'entry-clash') return {
675
+ conflict: 'entry'
676
+ };
677
+ // The pointer stopped being dangling under us, or the target vanished (a
678
+ // concurrent rebind, repair, or delete). Reclassify against the current state,
679
+ // re-checking the target so a target that disappeared reports entry-missing
680
+ // rather than a spurious entry conflict.
681
+ if (await tryInsert()) return 'inserted';
682
+ if (!await entryExists(entryID)) return 'entry-missing';
683
+ const now = await aliasAt();
684
+ if (now == null) return {
685
+ conflict: 'entry'
686
+ };
687
+ if (now === entryID) return 'inserted';
688
+ return {
689
+ conflict: 'alias',
690
+ existing: now
691
+ };
463
692
  },
464
693
  async deleteAliasEntryID (namespace, key) {
465
- await db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).execute();
694
+ await gatedIfAmbient(()=>db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).execute());
695
+ },
696
+ async deleteAliasIfEntry (namespace, key, entryID) {
697
+ await gatedIfAmbient(()=>db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).where('entry_id', '=', entryID).execute());
466
698
  },
467
699
  async listAliasKeys (namespace) {
468
700
  const rows = await db.selectFrom('kubun_credential_aliases').select('alias_key').distinct().where('namespace', '=', namespace).execute();
@@ -558,7 +790,7 @@ export function createCredentialStore(db, adapter) {
558
790
  const owner = normalizeDID(params.ownerDID);
559
791
  const recipient = normalizeDID(params.recipientDID);
560
792
  const grantor = normalizeDID(params.grantorDID);
561
- await writeAtomically(db, async (tx)=>{
793
+ await writeAmbient(db, async (tx)=>{
562
794
  await tx.updateTable('kubun_credential_manifests').set((eb)=>({
563
795
  epoch: eb.ref('kubun_credential_manifests.epoch')
564
796
  })).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
@@ -617,7 +849,7 @@ export function createCredentialStore(db, adapter) {
617
849
  async beginProvisioningAttempt (params) {
618
850
  const ownerDid = normalizeDID(params.ownerDID);
619
851
  const grantorDid = normalizeDID(params.grantorDID);
620
- await writeAtomically(db, async (tx)=>{
852
+ await writeAmbient(db, async (tx)=>{
621
853
  // Ensure the expectation floor row exists, idempotent at floor 0.
622
854
  await tx.insertInto('kubun_credential_provisioning_expectations').values({
623
855
  owner_did: ownerDid,
@@ -640,7 +872,7 @@ export function createCredentialStore(db, adapter) {
640
872
  status: 'retry',
641
873
  minEpoch: params.heldEpoch
642
874
  };
643
- await writeAtomically(db, async (tx)=>{
875
+ await writeAmbient(db, async (tx)=>{
644
876
  if (params.epoch > params.heldEpoch) {
645
877
  // Phantom-attempt guard: if the id names no live attempt, move
646
878
  // nothing — raising the floor for it would wedge readiness forever.
package/lib/migrations.js CHANGED
@@ -48,6 +48,8 @@ export function getCredentialMigrations(ctx) {
48
48
  await db.schema.createTable('kubun_credential_aliases').ifNotExists().addColumn('namespace', t.text, (col)=>col.notNull()).addColumn('alias_key', t.text, (col)=>col.notNull()).addColumn('entry_id', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_kubun_credential_aliases', [
49
49
  'namespace',
50
50
  'alias_key'
51
+ ]).addUniqueConstraint('uq_kubun_credential_aliases_entry', [
52
+ 'entry_id'
51
53
  ]).execute();
52
54
  // Grantor-authority envelope for a wrapping; `refresh_seq` gates every upsert strictly-greater.
53
55
  await db.schema.createTable('kubun_credential_wrapping_authority').ifNotExists().addColumn('wrapping_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('authority_token', t.text, (col)=>col.notNull()).addColumn('refresh_seq', 'integer', (col)=>col.notNull()).execute();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-credential",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,