@cortexkit/common-auth 0.2.5 → 0.2.7

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.
@@ -0,0 +1,185 @@
1
+ // The one way out of the process for anything the command menu shows. Every
2
+ // payload a renderer receives (the dialog payload and every apply result) is
3
+ // produced here: each part is projected field by field from its definition,
4
+ // so action bodies and stray properties never travel, and the whole payload
5
+ // is then scrubbed of credential-shaped property names as a backstop. Nothing
6
+ // outside this module can build a payload without passing through
7
+ // `dialogPayload` or `applyResult`.
8
+ /** The confirmation shown when an irreversible action names none. */
9
+ export const DEFAULT_IRREVERSIBLE_CONFIRMATION = 'This cannot be undone. Continue?';
10
+ const CREDENTIAL_NAMES = new Set([
11
+ 'access',
12
+ 'refresh',
13
+ 'apikey',
14
+ 'password',
15
+ 'authheader',
16
+ 'credential',
17
+ 'credentials',
18
+ ]);
19
+ /**
20
+ * True for a property name that looks like it holds a secret. Case, `-` and
21
+ * `_` are ignored, so `api_key`, `API-Key` and `apiKey` all match. Any name
22
+ * ending in `token`, `key` or `secret` matches: no menu field is named that
23
+ * way, so a match is a leak whatever the rest of the name says.
24
+ */
25
+ function isCredentialName(name) {
26
+ const normalized = name.toLowerCase().replace(/[-_]/g, '');
27
+ return (CREDENTIAL_NAMES.has(normalized) ||
28
+ normalized.endsWith('token') ||
29
+ normalized.endsWith('key') ||
30
+ normalized.endsWith('secret'));
31
+ }
32
+ function isPlainRecord(value) {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+ /** A copy of `value` without credential-shaped properties; their paths go to `found`. */
36
+ function scrub(value, path, found) {
37
+ if (Array.isArray(value))
38
+ return value.map((entry, index) => scrub(entry, `${path}[${index}]`, found));
39
+ if (!isPlainRecord(value))
40
+ return value;
41
+ const out = {};
42
+ for (const [name, entry] of Object.entries(value)) {
43
+ if (isCredentialName(name)) {
44
+ found.push(`${path}.${name}`);
45
+ continue;
46
+ }
47
+ Object.defineProperty(out, name, {
48
+ value: scrub(entry, `${path}.${name}`, found),
49
+ enumerable: true,
50
+ writable: true,
51
+ configurable: true,
52
+ });
53
+ }
54
+ return out;
55
+ }
56
+ function sealed(payload, command, logger) {
57
+ const found = [];
58
+ const clean = scrub(payload, 'payload', found);
59
+ if (found.length > 0)
60
+ logger.warn('credential-shaped field dropped from a command payload', {
61
+ command,
62
+ fields: found,
63
+ });
64
+ return clean;
65
+ }
66
+ function projectChoice(choice) {
67
+ return { value: String(choice.value), label: String(choice.label) };
68
+ }
69
+ function projectKnob(knob) {
70
+ switch (knob.kind) {
71
+ case 'choice':
72
+ return {
73
+ kind: 'choice',
74
+ id: knob.id,
75
+ label: knob.label,
76
+ choices: (knob.choices ?? []).map(projectChoice),
77
+ ...(knob.value !== undefined ? { value: knob.value } : {}),
78
+ };
79
+ case 'toggle':
80
+ return {
81
+ kind: 'toggle',
82
+ id: knob.id,
83
+ label: knob.label,
84
+ value: knob.value === true,
85
+ };
86
+ case 'number':
87
+ return {
88
+ kind: 'number',
89
+ id: knob.id,
90
+ label: knob.label,
91
+ ...(knob.value !== undefined ? { value: knob.value } : {}),
92
+ ...(knob.min !== undefined ? { min: knob.min } : {}),
93
+ ...(knob.max !== undefined ? { max: knob.max } : {}),
94
+ ...(knob.required ? { required: true } : {}),
95
+ };
96
+ case 'text':
97
+ return {
98
+ kind: 'text',
99
+ id: knob.id,
100
+ label: knob.label,
101
+ ...(knob.value !== undefined ? { value: knob.value } : {}),
102
+ ...(knob.placeholder !== undefined
103
+ ? { placeholder: knob.placeholder }
104
+ : {}),
105
+ ...(knob.masked ? { masked: true } : {}),
106
+ ...(knob.required ? { required: true } : {}),
107
+ };
108
+ }
109
+ }
110
+ /**
111
+ * The confirmation an action must pass before it runs, or undefined. An
112
+ * irreversible action always has one, even when its definition (built
113
+ * outside the type checker) names none.
114
+ */
115
+ export function confirmationOf(action) {
116
+ if (action.irreversible === true)
117
+ return {
118
+ message: action.confirm || DEFAULT_IRREVERSIBLE_CONFIRMATION,
119
+ irreversible: true,
120
+ };
121
+ if (action.confirm)
122
+ return { message: action.confirm, irreversible: false };
123
+ return undefined;
124
+ }
125
+ function projectAction(action) {
126
+ const confirm = confirmationOf(action);
127
+ return {
128
+ id: action.id,
129
+ label: action.label,
130
+ ...(action.description !== undefined
131
+ ? { description: action.description }
132
+ : {}),
133
+ knobs: (action.knobs ?? []).map(projectKnob),
134
+ ...(confirm ? { confirm } : {}),
135
+ };
136
+ }
137
+ /** The account fields a renderer may show; see `MenuAccount`. */
138
+ export function projectAccount(account) {
139
+ return {
140
+ id: account.id,
141
+ ...(account.label !== undefined ? { label: account.label } : {}),
142
+ enabled: account.enabled,
143
+ type: account.type,
144
+ ...(account.identity !== undefined ? { identity: account.identity } : {}),
145
+ };
146
+ }
147
+ function projectItem(item) {
148
+ return {
149
+ id: item.id,
150
+ label: item.label,
151
+ ...(item.detail !== undefined ? { detail: item.detail } : {}),
152
+ ...(item.account ? { account: projectAccount(item.account) } : {}),
153
+ ...(item.facts !== undefined ? { facts: item.facts } : {}),
154
+ actions: (item.actions ?? []).map(projectAction),
155
+ };
156
+ }
157
+ function projectSection(section) {
158
+ const { content } = section;
159
+ return {
160
+ id: section.id,
161
+ slot: section.slot,
162
+ title: section.title,
163
+ lines: (content.lines ?? []).map(String),
164
+ items: (content.items ?? []).map(projectItem),
165
+ actions: (content.actions ?? []).map(projectAction),
166
+ ...(content.facts !== undefined ? { facts: content.facts } : {}),
167
+ };
168
+ }
169
+ function projectMenu(command, title, sections) {
170
+ return { command, title, sections: sections.map(projectSection) };
171
+ }
172
+ /** The payload a host's TUI receives when the slash command opens. */
173
+ export function dialogPayload(command, title, sections, logger) {
174
+ return sealed({ command, menu: projectMenu(command, title, sections) }, command, logger);
175
+ }
176
+ /** An apply's result: the message and the refreshed menu. */
177
+ export function applyResult(command, title, sections, outcome, logger) {
178
+ return sealed({
179
+ command,
180
+ ok: outcome.ok,
181
+ text: outcome.text,
182
+ ...(outcome.needsConfirmation ? { needsConfirmation: true } : {}),
183
+ menu: projectMenu(command, title, sections),
184
+ }, command, logger);
185
+ }
@@ -1,6 +1,6 @@
1
1
  import type { StoredCredential } from './schema.js';
2
2
  /** Every library operation that can fail, as named in the failure value. */
3
- export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'recordIdentity' | 'refresh' | 'pull';
3
+ export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'updateSettings' | 'recordIdentity' | 'refresh' | 'pull';
4
4
  /**
5
5
  * How far an operation got before it failed.
6
6
  *
@@ -14,3 +14,5 @@ export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, Rem
14
14
  export type { PullReason } from './runtime.js';
15
15
  export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, StoredCredential, } from './schema.js';
16
16
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
17
+ export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
18
+ export { POOL_OWNED_KEYS } from './settings.js';
@@ -3,3 +3,4 @@ export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity
3
3
  export { openPoolStore } from './pool.js';
4
4
  export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
5
5
  export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
6
+ export { POOL_OWNED_KEYS } from './settings.js';
@@ -7,6 +7,7 @@ import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from '
7
7
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
8
8
  import { type AddInput, type AddResult, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
9
9
  import { type PoolCredential, type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
10
+ import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
10
11
  export interface OpenPoolStoreOptions {
11
12
  /** The provider every row of this pool belongs to; keys the provider-wide lock. */
12
13
  provider: string;
@@ -108,6 +109,17 @@ export interface PoolStore {
108
109
  * provider-wide lock. Roster rows and their entries are left unchanged.
109
110
  */
110
111
  reorder(ids: readonly string[], options?: ReorderOptions): Promise<ReorderResult>;
112
+ /**
113
+ * The plugin's settings (since 0.2.6): every top-level key of the config
114
+ * file except the pool-owned ones (`POOL_OWNED_KEYS`). Never writes.
115
+ */
116
+ readSettings(): Promise<SettingsRead>;
117
+ /**
118
+ * One locked write of the plugin's settings (since 0.2.6) beside the pool,
119
+ * in the config file. Refuses a result that sets a pool-owned key
120
+ * (`invalid-input`). Takes `extraLocks`, then the store locks.
121
+ */
122
+ updateSettings(mutator: SettingsMutator, options?: UpdateSettingsOptions): Promise<UpdateSettingsResult>;
111
123
  recordIdentity(id: string, identity: string, options?: RowOperationOptions): Promise<{
112
124
  id: string;
113
125
  disabled: string[];
@@ -5,6 +5,7 @@ import { refreshRow, } from './refresh.js';
5
5
  import { POOL_LOCK_DEFAULTS, } from './refresh-lock.js';
6
6
  import { addRow, disableRow, enableRow, recordRowIdentity, removeRow, reorderRows, replaceRow, rotateRow, } from './rows.js';
7
7
  import { POOL_SCHEMA_VERSION, } from './schema.js';
8
+ import { readPoolSettings, updatePoolSettings, } from './settings.js';
8
9
  /**
9
10
  * Process-wide memory per config file: ids whose per-row entry a library
10
11
  * write dropped (not reused in this process), and rows a load-time pull
@@ -95,6 +96,8 @@ export function openPoolStore(options) {
95
96
  enable: (id, callOptions) => enableRow(rt, id, callOptions),
96
97
  remove: (id, callOptions) => removeRow(rt, id, callOptions),
97
98
  reorder: (ids, callOptions) => reorderRows(rt, ids, callOptions),
99
+ readSettings: () => readPoolSettings(rt),
100
+ updateSettings: (mutator, callOptions) => updatePoolSettings(rt, mutator, callOptions),
98
101
  recordIdentity: (id, identity, callOptions) => recordRowIdentity(rt, id, identity, callOptions),
99
102
  refresh: (id, provider, callOptions) => refreshRow(rt, id, provider, callOptions),
100
103
  recordQuota: (id, attribution, observation) => recordQuota(rt, id, attribution, observation),
@@ -0,0 +1,63 @@
1
+ import { PoolOperationError } from './errors.js';
2
+ import type { PoolLockSpec } from './refresh-lock.js';
3
+ import type { StoreRuntime } from './runtime.js';
4
+ /**
5
+ * Top-level config keys the pool owns: the legacy `version`, the legacy
6
+ * roster (`accounts`) and the pool's own key. A settings write never reads
7
+ * them into the settings it hands out and refuses a result that sets them,
8
+ * so the roster and the per-row entries change only through row operations.
9
+ */
10
+ export declare const POOL_OWNED_KEYS: readonly string[];
11
+ /** The plugin's settings: every top-level config key except the pool-owned ones. */
12
+ export type PoolSettings = Record<string, unknown>;
13
+ export type SettingsRead = {
14
+ status: 'ready';
15
+ settings: PoolSettings;
16
+ }
17
+ /**
18
+ * The config still holds a legacy roster with no pool key. Its settings
19
+ * read the same way; `updateSettings` refuses until the pool is initialized.
20
+ */
21
+ | {
22
+ status: 'pending-migration';
23
+ settings: PoolSettings;
24
+ } | {
25
+ status: 'error';
26
+ file: 'config' | 'state';
27
+ reason: string;
28
+ };
29
+ /**
30
+ * Receives a private copy of the current settings and either edits it in
31
+ * place (returning nothing) or returns the complete next settings object.
32
+ * It runs under the store locks, so it must not call back into the store:
33
+ * a store operation called from inside it is refused (`PoolReentryError`).
34
+ */
35
+ export type SettingsMutator = (settings: PoolSettings) => PoolSettings | undefined | Promise<PoolSettings | undefined>;
36
+ /**
37
+ * Options of `updateSettings`. It names no row, so its failure hook is
38
+ * handed only the failure; like `reorder` it takes the extra locks, then the
39
+ * store locks, and no row or provider-wide lock.
40
+ */
41
+ export interface UpdateSettingsOptions {
42
+ /** Called once, awaited, on every non-success path, before the extra locks release. */
43
+ onFailure?: (error: PoolOperationError) => void | Promise<void>;
44
+ /** Locks taken, in this order, before the store locks. */
45
+ extraLocks?: readonly PoolLockSpec[];
46
+ }
47
+ export type UpdateSettingsResult = {
48
+ /** The settings now on disk. */
49
+ settings: PoolSettings;
50
+ /** `unchanged` when the mutator left the settings as they were; nothing was written. */
51
+ outcome: 'updated' | 'unchanged';
52
+ };
53
+ /** Reads the settings without taking a lock or writing anything. */
54
+ export declare function readPoolSettings(rt: StoreRuntime): Promise<SettingsRead>;
55
+ /**
56
+ * One locked read-modify-write of the plugin's settings in the config file
57
+ * the pool lives in. The pool must be ready (a pending migration or a load
58
+ * error refuses before the mutator runs). A mutator result that sets a
59
+ * pool-owned key refuses (`invalid-input`) with nothing written; a result
60
+ * equal to the current settings writes nothing. The state file is never
61
+ * touched, and the pool-owned keys are written back exactly as read.
62
+ */
63
+ export declare function updatePoolSettings(rt: StoreRuntime, mutator: SettingsMutator, options?: UpdateSettingsOptions): Promise<UpdateSettingsResult>;
@@ -0,0 +1,120 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { writeJsonAtomic } from '../fs/atomic-write.js';
3
+ import { PoolOperationError } from './errors.js';
4
+ import { assertNotInsideHook, runInsideHook } from './hooks.js';
5
+ import { notReadyError, readPool, runOperation } from './mutate.js';
6
+ import { isRecord, POOL_KEY } from './schema.js';
7
+ /**
8
+ * Top-level config keys the pool owns: the legacy `version`, the legacy
9
+ * roster (`accounts`) and the pool's own key. A settings write never reads
10
+ * them into the settings it hands out and refuses a result that sets them,
11
+ * so the roster and the per-row entries change only through row operations.
12
+ */
13
+ export const POOL_OWNED_KEYS = Object.freeze([
14
+ 'version',
15
+ 'accounts',
16
+ POOL_KEY,
17
+ ]);
18
+ function settingsOf(config) {
19
+ const settings = {};
20
+ for (const [key, value] of Object.entries(config))
21
+ if (!POOL_OWNED_KEYS.includes(key))
22
+ defineKey(settings, key, value);
23
+ return settings;
24
+ }
25
+ /** Sets a key as an own data property, so a `__proto__` key stays a plain key. */
26
+ function defineKey(target, key, value) {
27
+ Object.defineProperty(target, key, {
28
+ value,
29
+ enumerable: true,
30
+ writable: true,
31
+ configurable: true,
32
+ });
33
+ }
34
+ /**
35
+ * The next config: the pool-owned keys exactly as read, every settings key
36
+ * from `next` in its existing position, settings keys `next` dropped left
37
+ * out, and new settings keys appended.
38
+ */
39
+ function composeConfig(config, next) {
40
+ const out = {};
41
+ for (const [key, value] of Object.entries(config)) {
42
+ if (POOL_OWNED_KEYS.includes(key))
43
+ defineKey(out, key, value);
44
+ else if (Object.hasOwn(next, key))
45
+ defineKey(out, key, next[key]);
46
+ }
47
+ for (const [key, value] of Object.entries(next))
48
+ if (!Object.hasOwn(config, key))
49
+ defineKey(out, key, value);
50
+ return out;
51
+ }
52
+ function settingsRefusal(message) {
53
+ return new PoolOperationError({
54
+ operation: 'updateSettings',
55
+ phase: 'before-first-write',
56
+ retryable: false,
57
+ kind: 'invalid-input',
58
+ message,
59
+ });
60
+ }
61
+ /** Reads the settings without taking a lock or writing anything. */
62
+ export async function readPoolSettings(rt) {
63
+ const result = await readPool(rt.ctx);
64
+ if (result.status === 'error')
65
+ return result;
66
+ return { status: result.status, settings: settingsOf(result.config) };
67
+ }
68
+ /**
69
+ * One locked read-modify-write of the plugin's settings in the config file
70
+ * the pool lives in. The pool must be ready (a pending migration or a load
71
+ * error refuses before the mutator runs). A mutator result that sets a
72
+ * pool-owned key refuses (`invalid-input`) with nothing written; a result
73
+ * equal to the current settings writes nothing. The state file is never
74
+ * touched, and the pool-owned keys are written back exactly as read.
75
+ */
76
+ export async function updatePoolSettings(rt, mutator, options = {}) {
77
+ assertNotInsideHook('updateSettings');
78
+ const { ctx } = rt;
79
+ const onFailure = options.onFailure;
80
+ return runOperation(ctx, 'updateSettings', undefined, onFailure && ((_rowId, error) => onFailure(error)), async (locks, progress) => {
81
+ for (const extra of options.extraLocks ?? [])
82
+ await locks.acquire(extra);
83
+ // `mark` is where the store locks start on the lock stack. They are
84
+ // released at the end of this block, so `onFailure` runs holding only
85
+ // the caller's extra locks, as it does for every other store operation.
86
+ const mark = locks.held.length;
87
+ try {
88
+ for (const spec of ctx.storeLocks)
89
+ await locks.acquire(spec);
90
+ const result = await readPool(ctx);
91
+ if (result.status !== 'ready')
92
+ throw notReadyError(result, 'updateSettings', undefined);
93
+ const current = settingsOf(result.config);
94
+ const draft = structuredClone(current);
95
+ const returned = await runInsideHook('updateSettings', () => mutator(draft));
96
+ const next = returned === undefined ? draft : returned;
97
+ if (!isRecord(next))
98
+ throw settingsRefusal('the settings mutator must produce an object');
99
+ const owned = POOL_OWNED_KEYS.filter((key) => Object.hasOwn(next, key));
100
+ if (owned.length > 0)
101
+ throw settingsRefusal(`settings cannot set the pool-owned key(s) ${owned.join(', ')}`);
102
+ if (isDeepStrictEqual(next, current))
103
+ return { settings: current, outcome: 'unchanged' };
104
+ const config = composeConfig(result.config, next);
105
+ const info = { operation: 'updateSettings', rowId: undefined };
106
+ await writeJsonAtomic(ctx.configPath, config, {
107
+ beforeRename: async () => {
108
+ await ctx.onStep?.('before-config-write', info);
109
+ await locks.assertAll();
110
+ },
111
+ });
112
+ progress.writes++;
113
+ await ctx.onStep?.('after-config-write', info);
114
+ return { settings: settingsOf(config), outcome: 'updated' };
115
+ }
116
+ finally {
117
+ await locks.releaseTo(mark);
118
+ }
119
+ });
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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": {