@cortexkit/common-auth 0.7.0 → 0.9.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.
@@ -5,6 +5,15 @@ export const POOL_KEY = 'commonAuthPool';
5
5
  export const POOL_SCHEMA_VERSION = 1;
6
6
  /** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
7
7
  export const POOL_ROWS_KEY = 'rows';
8
+ /**
9
+ * Property of `commonAuthPool` (since 0.8.0) holding, per id, the highest
10
+ * credential epoch a row with that id held when the store last dropped it
11
+ * from the pool. A row added later under the same id starts past it (see
12
+ * `nextAddEpochIn`), so an attribution taken for the dropped row never
13
+ * matches the new one. Older readers ignore it, and older writers keep it
14
+ * as they keep every pool key they do not know.
15
+ */
16
+ export const POOL_RETIRED_EPOCHS_KEY = 'retiredEpochs';
8
17
  /** The `version` older readers of the same files expect at the top level. */
9
18
  export const LEGACY_STORE_VERSION = 1;
10
19
  /**
@@ -410,11 +419,15 @@ function credentialFor(raw, stateEntry) {
410
419
  export function rosterRowIn(config, id) {
411
420
  return rosterOf(config).find((raw) => isRecord(raw) && raw.id === id);
412
421
  }
413
- /** The per-row entries of a config, created (empty) when absent. */
414
- export function ensureEntries(config) {
422
+ /** The pool object of a config, created (empty) when absent. */
423
+ function ensurePool(config) {
415
424
  if (!isRecord(config[POOL_KEY]))
416
425
  config[POOL_KEY] = {};
417
- const pool = config[POOL_KEY];
426
+ return config[POOL_KEY];
427
+ }
428
+ /** The per-row entries of a config, created (empty) when absent. */
429
+ export function ensureEntries(config) {
430
+ const pool = ensurePool(config);
418
431
  if (!isRecord(pool[POOL_ROWS_KEY]))
419
432
  pool[POOL_ROWS_KEY] = {};
420
433
  return pool[POOL_ROWS_KEY];
@@ -433,6 +446,77 @@ export function setEntryIn(config, id, entry) {
433
446
  configurable: true,
434
447
  });
435
448
  }
449
+ /**
450
+ * The credential epoch recorded for a dropped id (see
451
+ * `POOL_RETIRED_EPOCHS_KEY`), or undefined when none is. A value that is not
452
+ * a credential epoch counts as none.
453
+ */
454
+ export function retiredEpochIn(config, id) {
455
+ const pool = config[POOL_KEY];
456
+ if (!isRecord(pool))
457
+ return undefined;
458
+ const retired = pool[POOL_RETIRED_EPOCHS_KEY];
459
+ if (!isRecord(retired) || !Object.hasOwn(retired, id))
460
+ return undefined;
461
+ const epoch = retired[id];
462
+ return isCredentialEpoch(epoch) ? epoch : undefined;
463
+ }
464
+ /**
465
+ * The epoch a per-row entry claims, read without validating the rest of the
466
+ * entry, or undefined when it names none a reader would accept.
467
+ */
468
+ function entryEpochIn(config, id) {
469
+ const epoch = entryIn(config, id)?.credentialEpoch;
470
+ return isCredentialEpoch(epoch) ? epoch : undefined;
471
+ }
472
+ /**
473
+ * The credential epoch `add` gives a new row with this id: one past the
474
+ * highest epoch the id is known to have held, which is the epoch recorded
475
+ * when the store dropped it, or the epoch of an entry left behind by a writer
476
+ * that removed only its roster row; 1 for an id the pool never held.
477
+ *
478
+ * An attribution names a row by id and credential epoch (and identity), and
479
+ * an id is chosen by the plugin, so it is often the same one again (`main`).
480
+ * Were a re-added row to start at epoch 1 again, an attribution taken for the
481
+ * removed row's credential would match the new credential exactly, in this
482
+ * process or any other. Starting past every earlier epoch makes such an
483
+ * attribution fail as it does after a `replace`. The result may lie past the
484
+ * safe integers (an id whose last row was at `Number.MAX_SAFE_INTEGER`);
485
+ * `add` refuses such an id.
486
+ */
487
+ export function nextAddEpochIn(config, id) {
488
+ return (Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 0) + 1);
489
+ }
490
+ /**
491
+ * Records, in a config being written, the epochs of the ids it drops: for
492
+ * each, the epoch its entry claims (1 for a row without one, the epoch such a
493
+ * row is at), kept only when above what is already recorded, so the record
494
+ * for an id never goes down. Valid recorded values of other ids are kept; a
495
+ * record that is not an object, or a value in it that is not an epoch, says
496
+ * nothing and is replaced. An entry whose epoch cannot be read records 1.
497
+ */
498
+ export function retireEpochsIn(config, dropped) {
499
+ const ids = [...dropped];
500
+ if (ids.length === 0)
501
+ return;
502
+ const pool = ensurePool(config);
503
+ const previous = isRecord(pool[POOL_RETIRED_EPOCHS_KEY])
504
+ ? pool[POOL_RETIRED_EPOCHS_KEY]
505
+ : {};
506
+ const next = {};
507
+ const define = (id, epoch) => Object.defineProperty(next, id, {
508
+ value: epoch,
509
+ enumerable: true,
510
+ writable: true,
511
+ configurable: true,
512
+ });
513
+ for (const [id, epoch] of Object.entries(previous))
514
+ if (isCredentialEpoch(epoch))
515
+ define(id, epoch);
516
+ for (const id of ids)
517
+ define(id, Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 1));
518
+ pool[POOL_RETIRED_EPOCHS_KEY] = next;
519
+ }
436
520
  /**
437
521
  * Builds the rows of a ready pool from the files exactly as they are, without
438
522
  * looking at credential stamps (see `loadRows` for the rows every reader
@@ -1,4 +1,4 @@
1
- import { disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
1
+ import { disableIdentityDuplicates, disableIn, enableIn, IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
2
2
  import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
3
3
  /*
4
4
  * A replace changes both files: the state file gets the new credential and
@@ -209,6 +209,13 @@ export function bindReplacement(editor, id, credentialEpoch, binding) {
209
209
  needsFirstReading: true,
210
210
  };
211
211
  delete next.quota;
212
+ // An explicit identity supplied with a replacement is the adapter's validated
213
+ // account binding. Clear its quarantine on completion, including crash recovery,
214
+ // but leave the row disabled until a separate enable.
215
+ if (binding.identity !== undefined &&
216
+ typeof next.disabledReason === 'string' &&
217
+ next.disabledReason.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX))
218
+ delete next.disabledReason;
212
219
  editor.setEntry(id, next);
213
220
  const raw = editor.rosterRow(id);
214
221
  if (!raw)
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, realpathSync } from 'node:fs';
3
3
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
- import { basename, dirname, extname, join, relative, resolve } from 'node:path';
5
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { isBareSpecifier, parseImports } from './walker.js';
8
8
  export const runtimeModules = [
@@ -109,6 +109,35 @@ export async function buildTui(entryFile, variant, destinationDir, options = {})
109
109
  selector: 'selector.js',
110
110
  externals: new Set(),
111
111
  };
112
+ // Every name below is computed from the entry's real directory: an entry
113
+ // reached through a symlink (macOS's /var -> /private/var temp directory,
114
+ // a linked checkout) must not make its own siblings look like outside files.
115
+ const entryDirectory = dirname(realpathSync(entry));
116
+ const portable = (path) => path.split(sep).join('/');
117
+ // A shared copy's name must be the same wherever the checkout lives, so it
118
+ // is derived from a key that holds no absolute path. A file inside an
119
+ // inlined package is keyed by that package's name and its path within the
120
+ // package; any other file by its path relative to the entry's directory.
121
+ // Both are one-to-one: one root per package name (the innermost root wins
122
+ // when roots nest, so a file always picks the same one), a fixed base for
123
+ // relative paths, and distinct prefixes for the two kinds. The guard in
124
+ // `visit` turns the remaining risk, two keys sharing a truncated hash, into
125
+ // an error instead of one copy silently overwriting another.
126
+ const namingRoots = [...roots]
127
+ .map(([name, root]) => ({
128
+ name,
129
+ root: existsSync(root) ? realpathSync(root) : root,
130
+ }))
131
+ .sort((a, b) => b.root.length - a.root.length);
132
+ const sharedKey = (source) => {
133
+ for (const { name, root } of namingRoots) {
134
+ const path = relative(root, source);
135
+ if (path && !path.startsWith(`..${sep}`) && !isAbsolute(path))
136
+ return `package:${name}/${portable(path)}`;
137
+ }
138
+ return `path:${portable(relative(entryDirectory, source))}`;
139
+ };
140
+ const sharedOwners = new Map();
112
141
  const copied = new Map();
113
142
  const relativeImport = (from, to) => {
114
143
  const path = relative(dirname(from), to).split('\\').join('/');
@@ -116,17 +145,23 @@ export async function buildTui(entryFile, variant, destinationDir, options = {})
116
145
  };
117
146
  const visit = async (source, shared) => {
118
147
  source = realpathSync(source);
119
- if (relative(dirname(entry), source).startsWith('../'))
148
+ if (relative(entryDirectory, source).startsWith(`..${sep}`))
120
149
  shared = true;
121
150
  const previous = copied.get(source);
122
151
  if (previous)
123
152
  return previous;
124
153
  const extension = variant === 'raw' && extname(source) === '.tsx' ? '.tsx' : '.js';
125
154
  const name = shared
126
- ? `shared/${createHash('sha256').update(source).digest('hex').slice(0, 16)}-${basename(source).replace(/\.[^.]+$/, '')}${extension}`
127
- : relative(dirname(entry), source).replace(/\.[^.]+$/, extension);
155
+ ? `shared/${createHash('sha256').update(sharedKey(source)).digest('hex').slice(0, 16)}-${basename(source).replace(/\.[^.]+$/, '')}${extension}`
156
+ : portable(relative(entryDirectory, source)).replace(/\.[^.]+$/, extension);
128
157
  if (name.startsWith('../'))
129
158
  throw new Error(`Entry dependency escapes source directory: ${source}`);
159
+ if (shared) {
160
+ const owner = sharedOwners.get(name);
161
+ if (owner !== undefined)
162
+ throw new Error(`Shared module name ${name} is claimed by both ${owner} and ${source}`);
163
+ sharedOwners.set(name, source);
164
+ }
130
165
  copied.set(source, name);
131
166
  let code = await readFile(source, 'utf8');
132
167
  if (transform && /\.[jt]sx$/.test(source))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -89,16 +89,18 @@
89
89
  "format:check": "biome format .",
90
90
  "lint": "biome check .",
91
91
  "prepublishOnly": "bun run build",
92
- "check:ranges": "bun scripts/check-installed-ranges.mjs"
92
+ "check:ranges": "bun scripts/check-installed-ranges.mjs",
93
+ "check:claustrum-040": "bun scripts/check-claustrum-040.mjs",
94
+ "check:opentui-0511": "bun scripts/check-opentui-0511.mjs"
93
95
  },
94
96
  "dependencies": {
95
97
  "jsonc-parser": "^3.3.1"
96
98
  },
97
99
  "peerDependencies": {
98
- "@opentui/core": ">=0.5.12",
99
- "@opentui/solid": ">=0.5.12",
100
+ "@opentui/core": ">=0.5.11",
101
+ "@opentui/solid": ">=0.5.11",
100
102
  "solid-js": "1.9.12",
101
- "@cortexkit/claustrum-client": ">=0.5.0",
103
+ "@cortexkit/claustrum-client": ">=0.4.0",
102
104
  "@opencode/plugin": ">=2.0.21"
103
105
  },
104
106
  "peerDependenciesMeta": {