@cortexkit/common-auth 0.5.0 → 0.7.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.
@@ -18,6 +18,11 @@ export const REFRESH_STAMP_TOLERANCE_MS = 5 * 60_000;
18
18
  * credential beside it belongs to. Older readers ignore it.
19
19
  */
20
20
  export const CREDENTIAL_STAMP_KEY = POOL_KEY;
21
+ /**
22
+ * Key, inside a state-file account entry, of the provider state kept beside
23
+ * the credential. Older readers ignore it.
24
+ */
25
+ export const PROVIDER_STATE_KEY = 'commonAuthProviderState';
21
26
  export function isRecord(value) {
22
27
  return value != null && typeof value === 'object' && !Array.isArray(value);
23
28
  }
@@ -112,8 +117,49 @@ export function stampFor(credential, credentialEpoch, binding, options = {}) {
112
117
  dispatch: dispatchDigest(credential),
113
118
  binding: { ...binding },
114
119
  ...(options.replace ? { replace: true } : {}),
120
+ ...(options.providerState !== undefined
121
+ ? { providerState: options.providerState }
122
+ : {}),
115
123
  };
116
124
  }
125
+ /**
126
+ * The digest a stamp carries for a provider state: of its credential-bound
127
+ * part (`ProviderStateCodec.credentialBound`, the whole value without it), as
128
+ * it serializes. The store only ever stores values that survive a JSON round
129
+ * trip unchanged, so the digest of a value read back from disk equals the one
130
+ * computed when it was written. Its input prefix differs from every other
131
+ * digest the store writes.
132
+ */
133
+ export function providerStateDigest(codec, value) {
134
+ const bound = codec?.credentialBound ? codec.credentialBound(value) : value;
135
+ return createHash('sha256')
136
+ .update(`provider-state\0${JSON.stringify(bound ?? null)}`)
137
+ .digest('hex');
138
+ }
139
+ /**
140
+ * The provider-state digest of the stamp in a state-file account entry, when
141
+ * that stamp binds it to this row: written with this credential (its lineage
142
+ * digest matches), at this credential epoch, naming this recorded identity.
143
+ * Whether the value beside it still has that digest is checked apart, by
144
+ * `providerStateFields`; this alone is what a write that keeps the value
145
+ * carries into the stamp it writes, so a value no stamp of this store bound
146
+ * to the row stays unbound rather than being vouched for, and one edited
147
+ * after it was bound stays detectably edited.
148
+ */
149
+ export function boundProviderStateDigest(account, credential, credentialEpoch, identity) {
150
+ if (!credential || credentialEpoch === undefined)
151
+ return undefined;
152
+ if (!isRecord(account) || !Object.hasOwn(account, PROVIDER_STATE_KEY))
153
+ return undefined;
154
+ const stamp = parseStamp(account[CREDENTIAL_STAMP_KEY]);
155
+ if (!stamp ||
156
+ stamp.providerState === undefined ||
157
+ stamp.digest !== credentialDigest(credential) ||
158
+ stamp.credentialEpoch !== credentialEpoch ||
159
+ stamp.binding?.identity !== identity)
160
+ return undefined;
161
+ return stamp.providerState;
162
+ }
117
163
  /**
118
164
  * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
119
165
  * adding one may give back the same number, so a replace would not move the
@@ -135,15 +181,20 @@ export function parseStamp(raw) {
135
181
  return undefined;
136
182
  if ('replace' in raw && raw.replace !== true)
137
183
  return undefined;
184
+ if ('providerState' in raw && typeof raw.providerState !== 'string')
185
+ return undefined;
138
186
  const marks = {
139
187
  ...(typeof raw.dispatch === 'string' ? { dispatch: raw.dispatch } : {}),
140
188
  ...(raw.replace === true ? { replace: true } : {}),
189
+ ...(typeof raw.providerState === 'string'
190
+ ? { providerState: raw.providerState }
191
+ : {}),
141
192
  };
142
- // Every stamp that carries a dispatch digest is written with a binding; one
143
- // without is not a stamp this store writes, and it would leave the row's
144
- // identity unchecked.
193
+ // Every stamp that carries a dispatch digest or a provider-state digest is
194
+ // written with a binding; one without is not a stamp this store writes,
195
+ // and it would leave the row's identity unchecked.
145
196
  if (!('binding' in raw))
146
- return 'dispatch' in raw || 'replace' in raw
197
+ return 'dispatch' in raw || 'replace' in raw || 'providerState' in raw
147
198
  ? undefined
148
199
  : { credentialEpoch: epoch, digest: raw.digest };
149
200
  const binding = raw.binding;
@@ -389,7 +440,7 @@ export function setEntryIn(config, id, entry) {
389
440
  * malformed per-row entry makes that one row invalid (never a candidate) and
390
441
  * blocks nothing else.
391
442
  */
392
- export function buildRawRows(config, state, codec) {
443
+ export function buildRawRows(config, state, codec, providerCodec) {
393
444
  const entries = entriesOf(config);
394
445
  const stateAccounts = stateAccountsOf(state);
395
446
  const seen = new Set();
@@ -419,6 +470,9 @@ export function buildRawRows(config, state, codec) {
419
470
  const entry = hasEntry ? parseEntry(entries[id], codec) : undefined;
420
471
  const credential = credentialFor(raw, stateAccounts[id]);
421
472
  const enabled = raw.enabled !== false;
473
+ // A row without a per-row entry is at credential epoch 1 (the epoch the
474
+ // store stamps and later gives it), so its stamp is checked against 1.
475
+ const stampEpoch = entry ? entry.credentialEpoch : hasEntry ? undefined : 1;
422
476
  const identity = typeof raw.accountId === 'string' && raw.accountId
423
477
  ? raw.accountId
424
478
  : undefined;
@@ -440,9 +494,8 @@ export function buildRawRows(config, state, codec) {
440
494
  ? { disabledReason: entry.disabledReason }
441
495
  : {}),
442
496
  ...(entry && 'quota' in entry ? { quota: entry.quota } : {}),
443
- // A row without a per-row entry is at credential epoch 1 (the epoch the
444
- // store stamps and later gives it), so its stamp is checked against 1.
445
- stamp: stampStatusOf(credential, entry ? entry.credentialEpoch : hasEntry ? undefined : 1, identity, stateAccounts[id]),
497
+ ...providerStateFields(stateAccounts[id], credential, stampEpoch, identity, providerCodec),
498
+ stamp: stampStatusOf(credential, stampEpoch, identity, stateAccounts[id]),
446
499
  };
447
500
  if (hasEntry && !entry) {
448
501
  row.invalid = 'entry';
@@ -454,6 +507,30 @@ export function buildRawRows(config, state, codec) {
454
507
  }
455
508
  return rows;
456
509
  }
510
+ /**
511
+ * The provider-state fields of a loaded row. A value on disk is shown only
512
+ * when the stamp beside the credential binds a provider state to this row's
513
+ * credential, epoch and identity (see `boundProviderStateDigest`), the codec
514
+ * accepts the value, and the value's credential-bound part has the digest
515
+ * that stamp names. Otherwise the row says why it shows none and stays
516
+ * usable: the value is the plugin's own derived data, which it can rebuild,
517
+ * and the credential beside it may well be sound.
518
+ */
519
+ function providerStateFields(account, credential, credentialEpoch, identity, codec) {
520
+ if (!isRecord(account) || !Object.hasOwn(account, PROVIDER_STATE_KEY))
521
+ return {};
522
+ const digest = boundProviderStateDigest(account, credential, credentialEpoch, identity);
523
+ if (digest === undefined)
524
+ return { providerStateDropped: 'uncovered' };
525
+ const value = account[PROVIDER_STATE_KEY];
526
+ // The value is validated before it is projected, so the projection only
527
+ // ever sees a value of the shape the codec accepts.
528
+ if (!codec?.validate(value))
529
+ return { providerStateDropped: 'invalid' };
530
+ if (providerStateDigest(codec, value) !== digest)
531
+ return { providerStateDropped: 'uncovered' };
532
+ return { providerState: value };
533
+ }
457
534
  /** The row-lock key: recorded wire identity when known, else the local id. */
458
535
  export function rowLockKey(row) {
459
536
  return row.identity ?? row.id;
@@ -1,5 +1,31 @@
1
1
  import { type RowEditor } from './identity.js';
2
- import { type CredentialBinding, type CredentialStamp, type PoolRow, type QuotaCodec } from './schema.js';
2
+ import { type CredentialBinding, type CredentialStamp, type PoolRow, type ProviderStateCodec, type QuotaCodec } from './schema.js';
3
+ /** Key, inside a credential stamp, of an attributed enable or disable. */
4
+ export declare const TRANSITION_STAMP_KEY = "transition";
5
+ /**
6
+ * Key, inside a per-row config entry, of the mark of the last transition the
7
+ * config carries out. Older readers ignore it.
8
+ */
9
+ export declare const TRANSITION_MARK_KEY = "transitionMark";
10
+ /**
11
+ * An attributed enable or disable as its state write records it: `mark` is
12
+ * unique to that write, `enabled` is the flag it sets, and `reason` the
13
+ * disabled reason (a disable's only).
14
+ */
15
+ export interface StampedTransition {
16
+ mark: string;
17
+ enabled: boolean;
18
+ reason?: string;
19
+ }
20
+ /**
21
+ * Carries a transition out in a config being edited: the row's enabled flag
22
+ * and reason as the transition says, and its mark recorded in the entry (a
23
+ * row without an entry gets one at epoch 1, as `disableIn` gives it). An
24
+ * enable then applies the duplicate-identity rule, as every write that
25
+ * enables an OAuth row with a known identity does: the earlier row in roster
26
+ * order keeps the identity.
27
+ */
28
+ export declare function applyTransition(editor: RowEditor, id: string, transition: StampedTransition): void;
3
29
  /** How a row left between the two writes of an operation is completed. */
4
30
  export type TornCompletion = {
5
31
  kind: 'replace';
@@ -9,6 +35,9 @@ export type TornCompletion = {
9
35
  } | {
10
36
  kind: 'identity';
11
37
  identity: string;
38
+ } | {
39
+ kind: 'transition';
40
+ transition: StampedTransition;
12
41
  };
13
42
  /** Rows left between the two writes of an operation, by row id. */
14
43
  export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
@@ -45,4 +74,5 @@ export declare function completeTornRows(config: Record<string, unknown>, state:
45
74
  */
46
75
  export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
47
76
  requireCredentialStamps?: boolean;
77
+ providerState?: ProviderStateCodec;
48
78
  }): PoolRow[];
@@ -1,4 +1,4 @@
1
- import { disableIdentityDuplicates, recordIdentityIn, } from './identity.js';
1
+ import { disableIdentityDuplicates, disableIn, enableIn, 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
@@ -46,7 +46,69 @@ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, e
46
46
  * exactly (digest and dispatch digest), that names an identity the config
47
47
  * does not record; that is completed forward the same way, by recording the
48
48
  * identity.
49
+ *
50
+ * An attributed `disable` or `enable` that also changes the provider state
51
+ * follows the same order. Its state write carries the new value and, in the
52
+ * stamp, the transition (`transition: {mark, enabled, reason?}`, see
53
+ * `StampedTransition`); its config write then flips the row and records the
54
+ * transition's mark in the row's entry (`transitionMark`). A stamp bound to
55
+ * the row (this credential, epoch and identity, the binding that also shows
56
+ * the provider state beside it) whose transition mark the entry does not
57
+ * record is such a write stopped between its two writes, and is completed
58
+ * forward the same way: readers are shown the row disabled or enabled as the
59
+ * transition says, beside the value written with it. Once the config records
60
+ * the mark the transition says nothing more, so a later `enable` or
61
+ * `disable` of the row is never undone by it; a credential write drops it
62
+ * with the rest of the old stamp.
63
+ */
64
+ /** Key, inside a credential stamp, of an attributed enable or disable. */
65
+ export const TRANSITION_STAMP_KEY = 'transition';
66
+ /**
67
+ * Key, inside a per-row config entry, of the mark of the last transition the
68
+ * config carries out. Older readers ignore it.
49
69
  */
70
+ export const TRANSITION_MARK_KEY = 'transitionMark';
71
+ /** The well-formed transition in a raw stamp, or undefined. */
72
+ function stampedTransition(rawStamp) {
73
+ if (!isRecord(rawStamp))
74
+ return undefined;
75
+ const raw = rawStamp[TRANSITION_STAMP_KEY];
76
+ if (!isRecord(raw))
77
+ return undefined;
78
+ if (typeof raw.mark !== 'string' || raw.mark.length === 0)
79
+ return undefined;
80
+ if (raw.enabled === true)
81
+ return { mark: raw.mark, enabled: true };
82
+ if (raw.enabled === false && typeof raw.reason === 'string')
83
+ return { mark: raw.mark, enabled: false, reason: raw.reason };
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Carries a transition out in a config being edited: the row's enabled flag
88
+ * and reason as the transition says, and its mark recorded in the entry (a
89
+ * row without an entry gets one at epoch 1, as `disableIn` gives it). An
90
+ * enable then applies the duplicate-identity rule, as every write that
91
+ * enables an OAuth row with a known identity does: the earlier row in roster
92
+ * order keeps the identity.
93
+ */
94
+ export function applyTransition(editor, id, transition) {
95
+ if (transition.enabled)
96
+ enableIn(editor, id);
97
+ else
98
+ disableIn(editor, id, transition.reason ?? '');
99
+ if (!editor.rosterRow(id))
100
+ return;
101
+ const entry = editor.entry(id) ?? {
102
+ credentialEpoch: 1,
103
+ needsFirstReading: true,
104
+ };
105
+ editor.setEntry(id, { ...entry, [TRANSITION_MARK_KEY]: transition.mark });
106
+ if (!transition.enabled)
107
+ return;
108
+ const row = editor.rows().find((candidate) => candidate.id === id);
109
+ if (row?.type === 'oauth' && row.identity !== undefined)
110
+ disableIdentityDuplicates(editor, row.identity);
111
+ }
50
112
  /**
51
113
  * The credential a torn replace leaves once its config write lands: the
52
114
  * credential loaded beside the stamp, with the endpoint the stamp's binding
@@ -122,6 +184,13 @@ export function tornStamps(config, state, codec, options = {}) {
122
184
  row.identity === undefined) {
123
185
  torn.set(row.id, { kind: 'identity', identity: stamp.binding.identity });
124
186
  }
187
+ else if (stamp.credentialEpoch === epoch &&
188
+ stamp.binding.identity === row.identity) {
189
+ const transition = stampedTransition(account[CREDENTIAL_STAMP_KEY]);
190
+ if (transition &&
191
+ entryIn(config, row.id)?.[TRANSITION_MARK_KEY] !== transition.mark)
192
+ torn.set(row.id, { kind: 'transition', transition });
193
+ }
125
194
  }
126
195
  return torn;
127
196
  }
@@ -175,6 +244,8 @@ export function completeTornRows(config, state, codec, options = {}) {
175
244
  for (const [id, completion] of completions) {
176
245
  if (completion.kind === 'identity')
177
246
  recordIdentityIn(editor, id, completion.identity);
247
+ else if (completion.kind === 'transition')
248
+ applyTransition(editor, id, completion.transition);
178
249
  else if (completion.stamp.binding.identity !== undefined)
179
250
  disableIdentityDuplicates(editor, completion.stamp.binding.identity);
180
251
  }
@@ -192,7 +263,10 @@ export function completeTornRows(config, state, codec, options = {}) {
192
263
  */
193
264
  export function loadRows(config, state, codec, options = {}) {
194
265
  const { config: whole, torn } = completeTornRows(config, state, codec, options);
195
- const rows = buildRawRows(whole, state, codec);
266
+ // A torn replace wrote its provider state beside the new credential, under
267
+ // the stamp naming the new epoch, so the completed row shows the new
268
+ // credential with the state written for it, never with the old one.
269
+ const rows = buildRawRows(whole, state, codec, options.providerState);
196
270
  for (const row of rows) {
197
271
  if (torn.includes(row.id)) {
198
272
  row.torn = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {