@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.
- package/dist/claustrum/consumer.d.ts +93 -0
- package/dist/claustrum/consumer.js +276 -0
- package/dist/claustrum/custody.d.ts +128 -0
- package/dist/claustrum/custody.js +321 -0
- package/dist/claustrum/enrollment.d.ts +121 -0
- package/dist/claustrum/enrollment.js +579 -0
- package/dist/claustrum/errors.d.ts +16 -0
- package/dist/claustrum/errors.js +8 -0
- package/dist/claustrum/host-slot.d.ts +39 -0
- package/dist/claustrum/host-slot.js +72 -0
- package/dist/claustrum/index.d.ts +18 -1
- package/dist/claustrum/index.js +22 -2
- package/dist/claustrum/interlock.d.ts +29 -0
- package/dist/claustrum/interlock.js +36 -0
- package/dist/claustrum/roster.d.ts +103 -0
- package/dist/claustrum/roster.js +334 -0
- package/dist/commands/builtins.d.ts +71 -0
- package/dist/commands/builtins.js +508 -0
- package/dist/commands/index.d.ts +10 -1
- package/dist/commands/index.js +5 -2
- package/dist/commands/menu.d.ts +39 -0
- package/dist/commands/menu.js +249 -0
- package/dist/commands/model.d.ts +188 -0
- package/dist/commands/model.js +16 -0
- package/dist/commands/pi.d.ts +22 -0
- package/dist/commands/pi.js +178 -0
- package/dist/commands/seam.d.ts +36 -0
- package/dist/commands/seam.js +185 -0
- package/dist/store/errors.d.ts +1 -1
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +1 -0
- package/dist/store/pool.d.ts +12 -0
- package/dist/store/pool.js +3 -0
- package/dist/store/settings.d.ts +63 -0
- package/dist/store/settings.js +120 -0
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ClaustrumConsumerError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* The host's own auth slot (OpenCode's stored login for the provider, Pi's
|
|
4
|
+
* equivalent) must hold something, or the host drops the provider. Under
|
|
5
|
+
* custody it holds this placeholder: an OAuth-shaped value that is never a
|
|
6
|
+
* credential. Its empty access token also makes the vault's sealer refuse it,
|
|
7
|
+
* should it ever be offered for import.
|
|
8
|
+
*/
|
|
9
|
+
export const CUSTODY_PLACEHOLDER_PREFIX = 'claustrum-tombstone:v1:';
|
|
10
|
+
export function custodyPlaceholderKey(provider) {
|
|
11
|
+
return `${CUSTODY_PLACEHOLDER_PREFIX}${provider}`;
|
|
12
|
+
}
|
|
13
|
+
export function custodyPlaceholder(provider) {
|
|
14
|
+
return {
|
|
15
|
+
type: 'oauth',
|
|
16
|
+
access: '',
|
|
17
|
+
refresh: custodyPlaceholderKey(provider),
|
|
18
|
+
expires: 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
export function isCustodyPlaceholderValue(value) {
|
|
25
|
+
return (typeof value === 'string' && value.startsWith(CUSTODY_PLACEHOLDER_PREFIX));
|
|
26
|
+
}
|
|
27
|
+
export function isCustodyPlaceholder(auth, provider) {
|
|
28
|
+
if (!isRecord(auth) || auth.type !== 'oauth')
|
|
29
|
+
return false;
|
|
30
|
+
return auth.refresh === custodyPlaceholderKey(provider);
|
|
31
|
+
}
|
|
32
|
+
export function classifyHostSlot(auth, provider) {
|
|
33
|
+
if (isCustodyPlaceholder(auth, provider))
|
|
34
|
+
return 'placeholder';
|
|
35
|
+
if (isRecord(auth) &&
|
|
36
|
+
auth.type === 'oauth' &&
|
|
37
|
+
typeof auth.refresh === 'string' &&
|
|
38
|
+
auth.refresh.length > 0 &&
|
|
39
|
+
!isCustodyPlaceholderValue(auth.refresh))
|
|
40
|
+
return 'login';
|
|
41
|
+
if (isRecord(auth) &&
|
|
42
|
+
auth.type === 'api' &&
|
|
43
|
+
typeof auth.key === 'string' &&
|
|
44
|
+
auth.key.length > 0)
|
|
45
|
+
return 'login';
|
|
46
|
+
return 'empty';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check the host slot against the plugin's mode before serving anything.
|
|
50
|
+
*
|
|
51
|
+
* - Custody mode with a real login in the slot fails closed: someone signed in
|
|
52
|
+
* through the host while the vault owns the accounts, and serving either the
|
|
53
|
+
* login or the vault would silently pick one. The plugin surfaces the error
|
|
54
|
+
* and the user chooses (leave custody, or remove the login).
|
|
55
|
+
* - Local mode with the placeholder in the slot also fails: there is no local
|
|
56
|
+
* credential to serve, and the user has to sign in.
|
|
57
|
+
*
|
|
58
|
+
* Returns the slot content when the combination is consistent.
|
|
59
|
+
*/
|
|
60
|
+
export function assertHostSlotMatchesMode(input) {
|
|
61
|
+
const content = classifyHostSlot(input.auth, input.provider);
|
|
62
|
+
if (input.mode === 'custody' && content === 'login')
|
|
63
|
+
throw new ClaustrumConsumerError('host-slot-login', `${input.provider} has a host login while its accounts are vault-custodied; refusing to serve until one is removed`);
|
|
64
|
+
if (input.mode === 'local' && content === 'placeholder')
|
|
65
|
+
throw new ClaustrumConsumerError('host-slot-placeholder', `${input.provider} host slot holds the vault placeholder; sign in to use local accounts`);
|
|
66
|
+
return content;
|
|
67
|
+
}
|
|
68
|
+
/** Refuse to run a local token refresh with the placeholder as the refresh token. */
|
|
69
|
+
export function assertNotCustodyPlaceholder(refreshToken, provider) {
|
|
70
|
+
if (isCustodyPlaceholderValue(refreshToken))
|
|
71
|
+
throw new ClaustrumConsumerError('placeholder-refresh', `${provider} credentials are vault-custodied; local token refresh is forbidden`);
|
|
72
|
+
}
|
|
@@ -1 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
import { type ClaustrumClientOptions } from '@cortexkit/claustrum-client';
|
|
2
|
+
import type { ClaustrumScopedClient } from './custody.js';
|
|
3
|
+
export { ClaustrumCredentialError, type ClaustrumReporterSource, type EnrollmentTokenFile, } from '@cortexkit/claustrum-client';
|
|
4
|
+
export { ClaustrumConsumer, type ClaustrumConsumerOptions, type SendOptions, } from './consumer.js';
|
|
5
|
+
export { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultInventory, } from './custody.js';
|
|
6
|
+
export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, ClaustrumEnrollmentManager, type ClaustrumEnrollmentPaths, type ClaustrumEnrollmentResetResult, type ClaustrumEnrollmentStatus, classifyEnrollmentError, connectClaustrumEnrollmentClient, type EnrollmentDisposition, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
|
|
7
|
+
export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
|
|
8
|
+
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
9
|
+
export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, pruneDeclined, } from './interlock.js';
|
|
10
|
+
export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, readVaultRoster, recordVaultQuota, refreshVaultRoster, type VaultRosterFile, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
|
|
11
|
+
/**
|
|
12
|
+
* Connect the client that lists and fetches this consumer's vault credentials
|
|
13
|
+
* on the request path. `connectionFile` is required: this library
|
|
14
|
+
* reads no environment, so the plugin resolves the vault's connection file.
|
|
15
|
+
*/
|
|
16
|
+
export declare function connectClaustrumScopedClient(options: ClaustrumClientOptions & {
|
|
17
|
+
connectionFile: string;
|
|
18
|
+
}): Promise<ClaustrumScopedClient>;
|
package/dist/claustrum/index.js
CHANGED
|
@@ -1,2 +1,22 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// Claustrum vault custody shared by the auth plugins: scoped enrollment (run
|
|
2
|
+
// from setup), discovery of this consumer's vault accounts as rows for this
|
|
3
|
+
// package's `/routing` subpath, per-send authorization, 401 reporting, the
|
|
4
|
+
// declined-account interlock and the host-slot guard. Needs the optional
|
|
5
|
+
// peer `@cortexkit/claustrum-client`.
|
|
6
|
+
import { ClaustrumClient, } from '@cortexkit/claustrum-client';
|
|
7
|
+
export { ClaustrumCredentialError, } from '@cortexkit/claustrum-client';
|
|
8
|
+
export { ClaustrumConsumer, } from './consumer.js';
|
|
9
|
+
export { ClaustrumScopedCustody, decideScopedRetryAfter401, isScopedCredentialRotation, SERVING_MARGIN_MS, } from './custody.js';
|
|
10
|
+
export { ClaustrumEnrollmentManager, classifyEnrollmentError, connectClaustrumEnrollmentClient, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
|
|
11
|
+
export { ClaustrumConsumerError, } from './errors.js';
|
|
12
|
+
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
13
|
+
export { acceptAccount, declineAccount, isDeclined, pruneDeclined, } from './interlock.js';
|
|
14
|
+
export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
|
|
15
|
+
/**
|
|
16
|
+
* Connect the client that lists and fetches this consumer's vault credentials
|
|
17
|
+
* on the request path. `connectionFile` is required: this library
|
|
18
|
+
* reads no environment, so the plugin resolves the vault's connection file.
|
|
19
|
+
*/
|
|
20
|
+
export function connectClaustrumScopedClient(options) {
|
|
21
|
+
return ClaustrumClient.connect(options);
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declined-account interlock: the user's "do not use this vault account",
|
|
3
|
+
* kept on the consumer side because the vault has no notion of it.
|
|
4
|
+
*
|
|
5
|
+
* An entry is keyed on the credential id plus the provider identity the
|
|
6
|
+
* credential logged into, never on the record version, so a token refresh
|
|
7
|
+
* cannot lift it. It is sticky whenever either side's identity is unknown: an
|
|
8
|
+
* adapter that makes no identity claim cannot prove the account changed. It
|
|
9
|
+
* lifts on its own only when both identities are known and differ, which
|
|
10
|
+
* means the credential now logs into a different account than the one the
|
|
11
|
+
* user declined.
|
|
12
|
+
*/
|
|
13
|
+
export interface DeclinedAccount {
|
|
14
|
+
readonly credentialId: string;
|
|
15
|
+
readonly accountIdentity?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function isDeclined(entries: readonly DeclinedAccount[], credentialId: string, accountIdentity?: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Drop entries the vault has proven stale: the same credential id now listed
|
|
20
|
+
* with a known identity that differs from the declined one. Entries for
|
|
21
|
+
* credentials that are not listed stay, so an account that leaves and comes
|
|
22
|
+
* back is still declined.
|
|
23
|
+
*/
|
|
24
|
+
export declare function pruneDeclined(entries: readonly DeclinedAccount[], listed: readonly {
|
|
25
|
+
credentialId: string;
|
|
26
|
+
accountIdentity?: string;
|
|
27
|
+
}[]): DeclinedAccount[];
|
|
28
|
+
export declare function declineAccount(entries: readonly DeclinedAccount[], credentialId: string, accountIdentity?: string): DeclinedAccount[];
|
|
29
|
+
export declare function acceptAccount(entries: readonly DeclinedAccount[], credentialIds: readonly string[]): DeclinedAccount[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function matches(entry, credentialId, accountIdentity) {
|
|
2
|
+
return (entry.credentialId === credentialId &&
|
|
3
|
+
(entry.accountIdentity === undefined ||
|
|
4
|
+
accountIdentity === undefined ||
|
|
5
|
+
entry.accountIdentity === accountIdentity));
|
|
6
|
+
}
|
|
7
|
+
export function isDeclined(entries, credentialId, accountIdentity) {
|
|
8
|
+
return entries.some((entry) => matches(entry, credentialId, accountIdentity));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Drop entries the vault has proven stale: the same credential id now listed
|
|
12
|
+
* with a known identity that differs from the declined one. Entries for
|
|
13
|
+
* credentials that are not listed stay, so an account that leaves and comes
|
|
14
|
+
* back is still declined.
|
|
15
|
+
*/
|
|
16
|
+
export function pruneDeclined(entries, listed) {
|
|
17
|
+
return entries.filter((entry) => {
|
|
18
|
+
const current = listed.find((credential) => credential.credentialId === entry.credentialId);
|
|
19
|
+
return !(current &&
|
|
20
|
+
entry.accountIdentity !== undefined &&
|
|
21
|
+
current.accountIdentity !== undefined &&
|
|
22
|
+
current.accountIdentity !== entry.accountIdentity);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function declineAccount(entries, credentialId, accountIdentity) {
|
|
26
|
+
return [
|
|
27
|
+
...entries.filter((entry) => entry.credentialId !== credentialId),
|
|
28
|
+
{
|
|
29
|
+
credentialId,
|
|
30
|
+
...(accountIdentity !== undefined && { accountIdentity }),
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
export function acceptAccount(entries, credentialIds) {
|
|
35
|
+
return entries.filter((entry) => !credentialIds.includes(entry.credentialId));
|
|
36
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type QuotaMap, type QuotaObservation } from '../quota/index.js';
|
|
2
|
+
import type { RoutingRow } from '../routing/index.js';
|
|
3
|
+
import type { ClaustrumScopedCustody, VaultCredential, VaultCredentialType, VaultInventory } from './custody.js';
|
|
4
|
+
import { type DeclinedAccount } from './interlock.js';
|
|
5
|
+
/**
|
|
6
|
+
* One vault account as the pool sees it. It carries no bearer material: the
|
|
7
|
+
* access token is fetched from the vault for every send. Several vault
|
|
8
|
+
* records logged into the same provider account collapse into one row, so
|
|
9
|
+
* duplicate logins never count as extra quota.
|
|
10
|
+
*/
|
|
11
|
+
export interface VaultRosterRow {
|
|
12
|
+
routeId: string;
|
|
13
|
+
credentialId: string;
|
|
14
|
+
credentialType: VaultCredentialType;
|
|
15
|
+
accountIdentity?: string;
|
|
16
|
+
/** Other credential ids logged into the same provider account. */
|
|
17
|
+
aliases?: string[];
|
|
18
|
+
state: string;
|
|
19
|
+
label: string;
|
|
20
|
+
email?: string;
|
|
21
|
+
orgName?: string;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
addedAt: number;
|
|
24
|
+
quota?: QuotaMap;
|
|
25
|
+
/**
|
|
26
|
+
* The vault listed this record in a form this consumer could not use, so the
|
|
27
|
+
* row is the last good projection kept as it was rather than dropped.
|
|
28
|
+
*/
|
|
29
|
+
stale?: true;
|
|
30
|
+
}
|
|
31
|
+
export interface VaultRosterFile {
|
|
32
|
+
version: 1;
|
|
33
|
+
view?: string;
|
|
34
|
+
rows: VaultRosterRow[];
|
|
35
|
+
declined: DeclinedAccount[];
|
|
36
|
+
}
|
|
37
|
+
/** How a vault credential is presented in the pool. */
|
|
38
|
+
export type AccountMapper = (credential: VaultCredential) => {
|
|
39
|
+
label?: string;
|
|
40
|
+
};
|
|
41
|
+
export interface ProjectionOptions {
|
|
42
|
+
/** Ids already used by local rows; a vault route never takes one. */
|
|
43
|
+
reservedRouteIds?: ReadonlySet<string>;
|
|
44
|
+
/** Prefix for vault route ids, so they read differently from local ids. */
|
|
45
|
+
routePrefix?: string;
|
|
46
|
+
mapAccount?: AccountMapper;
|
|
47
|
+
now?: number;
|
|
48
|
+
}
|
|
49
|
+
export declare const DEFAULT_ROUTE_PREFIX = "vault:";
|
|
50
|
+
/**
|
|
51
|
+
* Project the vault's list onto the previous roster. Pure: callers serialize
|
|
52
|
+
* discovery and commit. Route ids, quota and the time an account was added
|
|
53
|
+
* survive for the same account; a credential that now logs into a different
|
|
54
|
+
* known account gets a new route id and no inherited quota.
|
|
55
|
+
*/
|
|
56
|
+
export declare function projectVaultRoster(previous: VaultRosterFile | undefined, inventory: VaultInventory, options?: ProjectionOptions): VaultRosterFile;
|
|
57
|
+
/**
|
|
58
|
+
* The rows `/routing` selects among. Only enabled, active accounts route;
|
|
59
|
+
* a declined or cold vault account stays listed for the menu but never
|
|
60
|
+
* reaches admission.
|
|
61
|
+
*/
|
|
62
|
+
export declare function vaultRoutingRows(roster: VaultRosterFile | undefined): RoutingRow[];
|
|
63
|
+
export declare function readVaultRoster(path: string): Promise<VaultRosterFile | undefined>;
|
|
64
|
+
/**
|
|
65
|
+
* Read, change and write the roster under its write lock. `change` returns
|
|
66
|
+
* undefined to leave the file as it is.
|
|
67
|
+
*/
|
|
68
|
+
export declare function mutateVaultRoster<T>(path: string, change: (current: VaultRosterFile | undefined) => Promise<{
|
|
69
|
+
next?: VaultRosterFile;
|
|
70
|
+
result: T;
|
|
71
|
+
}> | {
|
|
72
|
+
next?: VaultRosterFile;
|
|
73
|
+
result: T;
|
|
74
|
+
}): Promise<T>;
|
|
75
|
+
/** Mark a vault account declined; it stays listed but never routes. */
|
|
76
|
+
export declare function declineVaultRoute(path: string, routeId: string): Promise<undefined>;
|
|
77
|
+
/** Lift the user's decline for a vault account and every alias of it. */
|
|
78
|
+
export declare function acceptVaultRoute(path: string, routeId: string): Promise<undefined>;
|
|
79
|
+
/**
|
|
80
|
+
* Merge a quota observation into a vault row through `/quota`'s merge. The
|
|
81
|
+
* write is fenced on the account: an observation taken for an identity the
|
|
82
|
+
* row no longer holds is dropped, so a slow read for a replaced account can
|
|
83
|
+
* never land on its successor. Returns whether the observation was kept.
|
|
84
|
+
*/
|
|
85
|
+
export declare function recordVaultQuota(path: string, input: {
|
|
86
|
+
routeId: string;
|
|
87
|
+
observation: QuotaObservation;
|
|
88
|
+
accountIdentity?: string;
|
|
89
|
+
}): Promise<boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Discover and commit the roster. The lease covers the list call and the
|
|
92
|
+
* commit, not just the write: an opaque view cannot tell a delayed old reply
|
|
93
|
+
* from a newer one, so a run that lost its lease is refused at commit. A
|
|
94
|
+
* failed list, or custody switching off mid-run, never writes anything; a
|
|
95
|
+
* peer holding the lease means its last committed roster is served instead.
|
|
96
|
+
*/
|
|
97
|
+
export declare function refreshVaultRoster(options: {
|
|
98
|
+
path: string;
|
|
99
|
+
custody: Pick<ClaustrumScopedCustody, 'discover'>;
|
|
100
|
+
isActive?: () => boolean | Promise<boolean>;
|
|
101
|
+
signal?: AbortSignal;
|
|
102
|
+
projection?: ProjectionOptions | (() => ProjectionOptions);
|
|
103
|
+
}): Promise<VaultRosterFile | undefined>;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { acquireRefreshFileLock, withLock, writeJsonAtomic, } from '../fs/index.js';
|
|
4
|
+
import { isQuotaMap, mergeQuotaObservation, } from '../quota/index.js';
|
|
5
|
+
import { ClaustrumConsumerError } from './errors.js';
|
|
6
|
+
import { acceptAccount, declineAccount, isDeclined, pruneDeclined, } from './interlock.js';
|
|
7
|
+
export const DEFAULT_ROUTE_PREFIX = 'vault:';
|
|
8
|
+
function hash(value) {
|
|
9
|
+
return createHash('sha256').update(value).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
function defaultLabel(credential) {
|
|
12
|
+
// `oauth:<provider>:<label>` is the vault's conventional spelling; use the
|
|
13
|
+
// trailing label when present and fall back to the account's email.
|
|
14
|
+
const parts = credential.credentialId.split(':');
|
|
15
|
+
const label = parts.length >= 3 ? parts.slice(2).join(':').trim() : undefined;
|
|
16
|
+
return label || credential.email || credential.credentialId;
|
|
17
|
+
}
|
|
18
|
+
function compatible(left, right) {
|
|
19
|
+
return left === undefined || right === undefined || left === right;
|
|
20
|
+
}
|
|
21
|
+
function groupKey(credential) {
|
|
22
|
+
return credential.accountIdentity !== undefined
|
|
23
|
+
? `identity:${credential.accountIdentity}`
|
|
24
|
+
: `credential:${credential.credentialId}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Project the vault's list onto the previous roster. Pure: callers serialize
|
|
28
|
+
* discovery and commit. Route ids, quota and the time an account was added
|
|
29
|
+
* survive for the same account; a credential that now logs into a different
|
|
30
|
+
* known account gets a new route id and no inherited quota.
|
|
31
|
+
*/
|
|
32
|
+
export function projectVaultRoster(previous, inventory, options = {}) {
|
|
33
|
+
const now = options.now ?? Date.now();
|
|
34
|
+
const prefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
|
|
35
|
+
const reserved = options.reservedRouteIds ?? new Set();
|
|
36
|
+
const previousRows = previous?.rows ?? [];
|
|
37
|
+
const declined = pruneDeclined(previous?.declined ?? [], inventory.credentials);
|
|
38
|
+
const groups = new Map();
|
|
39
|
+
for (const credential of inventory.credentials) {
|
|
40
|
+
const key = groupKey(credential);
|
|
41
|
+
const group = groups.get(key) ?? [];
|
|
42
|
+
group.push(credential);
|
|
43
|
+
groups.set(key, group);
|
|
44
|
+
}
|
|
45
|
+
const used = new Set();
|
|
46
|
+
const taken = new Set(reserved);
|
|
47
|
+
const projected = [];
|
|
48
|
+
const sortedGroups = [...groups.values()].sort((left, right) => (left[0]?.credentialId ?? '').localeCompare(right[0]?.credentialId ?? ''));
|
|
49
|
+
for (const group of sortedGroups) {
|
|
50
|
+
const ordered = group.toSorted((left, right) => left.credentialId.localeCompare(right.credentialId));
|
|
51
|
+
const active = ordered.filter((entry) => entry.state === 'active');
|
|
52
|
+
const choices = active.length ? active : ordered;
|
|
53
|
+
const members = new Set(ordered.map((entry) => entry.credentialId));
|
|
54
|
+
const bound = previousRows.find((row) => !used.has(row) &&
|
|
55
|
+
members.has(row.credentialId) &&
|
|
56
|
+
compatible(row.accountIdentity, ordered.find((entry) => entry.credentialId === row.credentialId)
|
|
57
|
+
?.accountIdentity));
|
|
58
|
+
const representative = choices.find((entry) => entry.credentialId === bound?.credentialId) ??
|
|
59
|
+
choices[0];
|
|
60
|
+
if (!representative)
|
|
61
|
+
continue;
|
|
62
|
+
const identity = representative.accountIdentity;
|
|
63
|
+
const existing = bound ??
|
|
64
|
+
(identity === undefined
|
|
65
|
+
? undefined
|
|
66
|
+
: previousRows.find((row) => !used.has(row) && row.accountIdentity === identity));
|
|
67
|
+
if (existing)
|
|
68
|
+
used.add(existing);
|
|
69
|
+
let routeId = existing?.routeId;
|
|
70
|
+
if (!routeId || reserved.has(routeId)) {
|
|
71
|
+
const base = `${prefix}${representative.credentialId}${identity === undefined ? '' : `~${hash(identity).slice(0, 10)}`}`;
|
|
72
|
+
routeId = base;
|
|
73
|
+
for (let suffix = 2; taken.has(routeId); suffix++)
|
|
74
|
+
routeId = `${base}~${suffix}`;
|
|
75
|
+
}
|
|
76
|
+
taken.add(routeId);
|
|
77
|
+
const aliases = ordered
|
|
78
|
+
.map((entry) => entry.credentialId)
|
|
79
|
+
.filter((id) => id !== representative.credentialId);
|
|
80
|
+
const mapped = options.mapAccount?.(representative) ?? {};
|
|
81
|
+
projected.push({
|
|
82
|
+
previousIndex: existing ? previousRows.indexOf(existing) : Infinity,
|
|
83
|
+
row: {
|
|
84
|
+
routeId,
|
|
85
|
+
credentialId: representative.credentialId,
|
|
86
|
+
credentialType: representative.credentialType,
|
|
87
|
+
...(identity !== undefined && { accountIdentity: identity }),
|
|
88
|
+
...(aliases.length && { aliases }),
|
|
89
|
+
state: representative.state,
|
|
90
|
+
label: mapped.label?.trim() || defaultLabel(representative),
|
|
91
|
+
...(representative.email !== undefined && {
|
|
92
|
+
email: representative.email,
|
|
93
|
+
}),
|
|
94
|
+
...(representative.orgName !== undefined && {
|
|
95
|
+
orgName: representative.orgName,
|
|
96
|
+
}),
|
|
97
|
+
enabled: !ordered.some((entry) => isDeclined(declined, entry.credentialId, entry.accountIdentity)),
|
|
98
|
+
addedAt: existing?.addedAt ?? now,
|
|
99
|
+
...(existing?.quota !== undefined && { quota: existing.quota }),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// A record skipped as malformed keeps its last good projection: skipping it
|
|
104
|
+
// must not read as the account having been removed from the vault.
|
|
105
|
+
const skippedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
|
|
106
|
+
previousRows.forEach((row, previousIndex) => {
|
|
107
|
+
if (used.has(row) || !skippedIds.has(row.credentialId))
|
|
108
|
+
return;
|
|
109
|
+
if (taken.has(row.routeId))
|
|
110
|
+
return;
|
|
111
|
+
taken.add(row.routeId);
|
|
112
|
+
projected.push({ previousIndex, row: { ...row, stale: true } });
|
|
113
|
+
});
|
|
114
|
+
projected.sort((left, right) => left.previousIndex === right.previousIndex
|
|
115
|
+
? left.row.credentialId.localeCompare(right.row.credentialId)
|
|
116
|
+
: left.previousIndex - right.previousIndex);
|
|
117
|
+
return {
|
|
118
|
+
version: 1,
|
|
119
|
+
view: inventory.view,
|
|
120
|
+
rows: projected.map((entry) => entry.row),
|
|
121
|
+
declined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The rows `/routing` selects among. Only enabled, active accounts route;
|
|
126
|
+
* a declined or cold vault account stays listed for the menu but never
|
|
127
|
+
* reaches admission.
|
|
128
|
+
*/
|
|
129
|
+
export function vaultRoutingRows(roster) {
|
|
130
|
+
return (roster?.rows ?? [])
|
|
131
|
+
.filter((row) => row.enabled && row.state === 'active')
|
|
132
|
+
.map((row) => ({
|
|
133
|
+
id: row.routeId,
|
|
134
|
+
kind: row.credentialType === 'oauth' ? 'oauth' : 'api-key',
|
|
135
|
+
...(row.quota !== undefined && { quota: row.quota }),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
function isRecord(value) {
|
|
139
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
function optionalString(value) {
|
|
142
|
+
return value === undefined || typeof value === 'string';
|
|
143
|
+
}
|
|
144
|
+
function decodeRow(value) {
|
|
145
|
+
if (!isRecord(value) ||
|
|
146
|
+
typeof value.routeId !== 'string' ||
|
|
147
|
+
typeof value.credentialId !== 'string' ||
|
|
148
|
+
(value.credentialType !== 'oauth' && value.credentialType !== 'api_key') ||
|
|
149
|
+
typeof value.state !== 'string' ||
|
|
150
|
+
typeof value.label !== 'string' ||
|
|
151
|
+
typeof value.enabled !== 'boolean' ||
|
|
152
|
+
typeof value.addedAt !== 'number' ||
|
|
153
|
+
!optionalString(value.accountIdentity) ||
|
|
154
|
+
!optionalString(value.email) ||
|
|
155
|
+
!optionalString(value.orgName) ||
|
|
156
|
+
(value.aliases !== undefined &&
|
|
157
|
+
(!Array.isArray(value.aliases) ||
|
|
158
|
+
!value.aliases.every((alias) => typeof alias === 'string'))) ||
|
|
159
|
+
(value.quota !== undefined && !isQuotaMap(value.quota)))
|
|
160
|
+
return undefined;
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
function decodeRoster(value) {
|
|
164
|
+
if (!isRecord(value) ||
|
|
165
|
+
value.version !== 1 ||
|
|
166
|
+
!optionalString(value.view) ||
|
|
167
|
+
!Array.isArray(value.rows) ||
|
|
168
|
+
!Array.isArray(value.declined))
|
|
169
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
|
|
170
|
+
const rows = [];
|
|
171
|
+
for (const entry of value.rows) {
|
|
172
|
+
const row = decodeRow(entry);
|
|
173
|
+
if (!row)
|
|
174
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
|
|
175
|
+
rows.push(row);
|
|
176
|
+
}
|
|
177
|
+
const declined = [];
|
|
178
|
+
for (const entry of value.declined) {
|
|
179
|
+
if (!isRecord(entry) ||
|
|
180
|
+
typeof entry.credentialId !== 'string' ||
|
|
181
|
+
!optionalString(entry.accountIdentity))
|
|
182
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
|
|
183
|
+
declined.push(entry);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
version: 1,
|
|
187
|
+
...(typeof value.view === 'string' && { view: value.view }),
|
|
188
|
+
rows,
|
|
189
|
+
declined,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export async function readVaultRoster(path) {
|
|
193
|
+
let text;
|
|
194
|
+
try {
|
|
195
|
+
text = await readFile(path, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error.code === 'ENOENT')
|
|
199
|
+
return undefined;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
let value;
|
|
203
|
+
try {
|
|
204
|
+
value = JSON.parse(text);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
|
|
208
|
+
}
|
|
209
|
+
return decodeRoster(value);
|
|
210
|
+
}
|
|
211
|
+
const WRITE_LOCK = {
|
|
212
|
+
name: 'claustrum-roster-write',
|
|
213
|
+
ttlMs: 10_000,
|
|
214
|
+
timeoutMs: 5_000,
|
|
215
|
+
};
|
|
216
|
+
const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
|
|
217
|
+
/**
|
|
218
|
+
* Read, change and write the roster under its write lock. `change` returns
|
|
219
|
+
* undefined to leave the file as it is.
|
|
220
|
+
*/
|
|
221
|
+
export async function mutateVaultRoster(path, change) {
|
|
222
|
+
return withLock(path, WRITE_LOCK, async (lock) => {
|
|
223
|
+
const current = await readVaultRoster(path);
|
|
224
|
+
const { next, result } = await change(current);
|
|
225
|
+
if (next) {
|
|
226
|
+
await lock.assertOwned();
|
|
227
|
+
await writeJsonAtomic(path, next);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** Mark a vault account declined; it stays listed but never routes. */
|
|
233
|
+
export function declineVaultRoute(path, routeId) {
|
|
234
|
+
return mutateVaultRoster(path, (current) => {
|
|
235
|
+
const row = current?.rows.find((entry) => entry.routeId === routeId);
|
|
236
|
+
if (!current || !row)
|
|
237
|
+
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is not in the roster');
|
|
238
|
+
const declined = declineAccount(current.declined, row.credentialId, row.accountIdentity);
|
|
239
|
+
return {
|
|
240
|
+
next: {
|
|
241
|
+
...current,
|
|
242
|
+
declined,
|
|
243
|
+
rows: current.rows.map((entry) => entry === row ? { ...entry, enabled: false } : entry),
|
|
244
|
+
},
|
|
245
|
+
result: undefined,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** Lift the user's decline for a vault account and every alias of it. */
|
|
250
|
+
export function acceptVaultRoute(path, routeId) {
|
|
251
|
+
return mutateVaultRoster(path, (current) => {
|
|
252
|
+
const row = current?.rows.find((entry) => entry.routeId === routeId);
|
|
253
|
+
if (!current || !row)
|
|
254
|
+
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is not in the roster');
|
|
255
|
+
return {
|
|
256
|
+
next: {
|
|
257
|
+
...current,
|
|
258
|
+
declined: acceptAccount(current.declined, [
|
|
259
|
+
row.credentialId,
|
|
260
|
+
...(row.aliases ?? []),
|
|
261
|
+
]),
|
|
262
|
+
rows: current.rows.map((entry) => entry === row ? { ...entry, enabled: true } : entry),
|
|
263
|
+
},
|
|
264
|
+
result: undefined,
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Merge a quota observation into a vault row through `/quota`'s merge. The
|
|
270
|
+
* write is fenced on the account: an observation taken for an identity the
|
|
271
|
+
* row no longer holds is dropped, so a slow read for a replaced account can
|
|
272
|
+
* never land on its successor. Returns whether the observation was kept.
|
|
273
|
+
*/
|
|
274
|
+
export function recordVaultQuota(path, input) {
|
|
275
|
+
return mutateVaultRoster(path, (current) => {
|
|
276
|
+
const row = current?.rows.find((entry) => entry.routeId === input.routeId);
|
|
277
|
+
if (!current ||
|
|
278
|
+
!row ||
|
|
279
|
+
!compatible(row.accountIdentity, input.accountIdentity))
|
|
280
|
+
return { result: false };
|
|
281
|
+
const quota = mergeQuotaObservation(row.quota, input.observation);
|
|
282
|
+
return {
|
|
283
|
+
next: {
|
|
284
|
+
...current,
|
|
285
|
+
rows: current.rows.map((entry) => entry === row ? { ...entry, quota } : entry),
|
|
286
|
+
},
|
|
287
|
+
result: true,
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Discover and commit the roster. The lease covers the list call and the
|
|
293
|
+
* commit, not just the write: an opaque view cannot tell a delayed old reply
|
|
294
|
+
* from a newer one, so a run that lost its lease is refused at commit. A
|
|
295
|
+
* failed list, or custody switching off mid-run, never writes anything; a
|
|
296
|
+
* peer holding the lease means its last committed roster is served instead.
|
|
297
|
+
*/
|
|
298
|
+
export async function refreshVaultRoster(options) {
|
|
299
|
+
options.signal?.throwIfAborted();
|
|
300
|
+
const active = async () => (await options.isActive?.()) ?? true;
|
|
301
|
+
if (!(await active()))
|
|
302
|
+
return undefined;
|
|
303
|
+
const lease = await acquireRefreshFileLock({
|
|
304
|
+
...DISCOVERY_LEASE,
|
|
305
|
+
path: options.path,
|
|
306
|
+
renew: true,
|
|
307
|
+
});
|
|
308
|
+
if (!lease) {
|
|
309
|
+
const persisted = await readVaultRoster(options.path);
|
|
310
|
+
options.signal?.throwIfAborted();
|
|
311
|
+
if (!persisted?.view)
|
|
312
|
+
throw new ClaustrumConsumerError('roster-busy', 'Claustrum account discovery is already in progress');
|
|
313
|
+
return persisted;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const inventory = await options.custody.discover(options.signal);
|
|
317
|
+
options.signal?.throwIfAborted();
|
|
318
|
+
return await mutateVaultRoster(options.path, async (current) => {
|
|
319
|
+
if (!(await active()))
|
|
320
|
+
return { result: undefined };
|
|
321
|
+
options.signal?.throwIfAborted();
|
|
322
|
+
await lease.assertOwned();
|
|
323
|
+
const projection = typeof options.projection === 'function'
|
|
324
|
+
? options.projection()
|
|
325
|
+
: options.projection;
|
|
326
|
+
const next = projectVaultRoster(current, inventory, projection);
|
|
327
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
328
|
+
return { ...(changed && { next }), result: next };
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await lease.release();
|
|
333
|
+
}
|
|
334
|
+
}
|