@defdone/annon 0.1.0-alpha.0 → 0.1.0-alpha.2
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/CHANGELOG.md +15 -0
- package/CLI.md +116 -10
- package/README.md +57 -15
- package/dist/api/annon-types.d.ts +2 -1
- package/dist/api/annon.js +9 -2
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/login-bundle.js +113 -77
- package/dist/browser/login-dom.d.ts +13 -0
- package/dist/browser/login-dom.js +67 -0
- package/dist/browser/login-dropdown.d.ts +32 -0
- package/dist/browser/login-dropdown.js +129 -0
- package/dist/browser/login-format.d.ts +16 -0
- package/dist/browser/login-format.js +52 -0
- package/dist/browser/login-status.d.ts +70 -0
- package/dist/browser/login-status.js +85 -0
- package/dist/browser/login-terms.d.ts +10 -0
- package/dist/browser/login-terms.js +69 -0
- package/dist/browser/login-view.d.ts +34 -0
- package/dist/browser/login-view.js +227 -0
- package/dist/browser/login-wallet.d.ts +28 -0
- package/dist/browser/login-wallet.js +179 -0
- package/dist/browser/login.js +115 -101
- package/dist/browser/purchase-bundle.js +45 -45
- package/dist/browser/purchases.d.ts +4 -0
- package/dist/browser/purchases.js +14 -0
- package/dist/browser/reconcile.d.ts +41 -0
- package/dist/browser/reconcile.js +77 -0
- package/dist/node/adapter.js +5 -3
- package/dist/node/approval-status.d.ts +34 -0
- package/dist/node/approval-status.js +71 -0
- package/dist/node/backup-secret-init.d.ts +9 -0
- package/dist/node/backup-secret-init.js +103 -0
- package/dist/node/backup-secret.d.ts +35 -0
- package/dist/node/backup-secret.js +104 -0
- package/dist/node/browser-login.d.ts +9 -0
- package/dist/node/browser-login.js +75 -14
- package/dist/node/buy-commands.js +9 -2
- package/dist/node/cli.js +23 -7
- package/dist/node/command-context.js +1 -1
- package/dist/node/config.d.ts +2 -0
- package/dist/node/config.js +3 -1
- package/dist/node/login-page.d.ts +7 -0
- package/dist/node/login-page.js +71 -0
- package/dist/node/purchase-commands.d.ts +2 -2
- package/dist/node/purchase-commands.js +10 -6
- package/dist/node/saved-state.d.ts +4 -0
- package/dist/node/saved-state.js +50 -0
- package/dist/node/signer-wallet.js +3 -1
- package/dist/node/version.d.ts +1 -0
- package/dist/node/version.js +3 -0
- package/dist/node/wallets.d.ts +1 -1
- package/dist/node/wallets.js +2 -10
- package/dist/pay/base.js +36 -43
- package/dist/pay/purchase.d.ts +2 -1
- package/dist/wallet/account-only.d.ts +1 -1
- package/dist/wallet/account-only.js +1 -1
- package/dist/wallet/uncertain.d.ts +6 -3
- package/dist/wallet/uncertain.js +4 -1
- package/package.json +3 -2
|
@@ -7,7 +7,9 @@ import { type PurchaseRecord } from '../pay/purchase.js';
|
|
|
7
7
|
import { type Store } from '../pay/store.js';
|
|
8
8
|
import type { IdentityWallet } from '../wallet/wallet.js';
|
|
9
9
|
import { type BrowserDownloadOptions } from './download.js';
|
|
10
|
+
import { type ReconcileOptions, type ReconcileResult } from './reconcile.js';
|
|
10
11
|
import type { BrowserPurchaseRecord, BrowserPurchaseState } from './record.js';
|
|
12
|
+
export type { ReconcileOptions, ReconcileResult } from './reconcile.js';
|
|
11
13
|
export type { BrowserPurchaseRecord, BrowserPurchaseState } from './record.js';
|
|
12
14
|
export type PurchaseSdk = Pick<Annon, 'buy' | 'accept' | 'open' | 'recover' | 'proveRecovery'>;
|
|
13
15
|
export interface BrowserPurchaseStorage {
|
|
@@ -91,6 +93,8 @@ export declare class BrowserPurchaseController {
|
|
|
91
93
|
poll(options?: Omit<PollOptions, 'checkpoint' | 'memo' | 'onProgress'> & {
|
|
92
94
|
oneClick?: Pick<OneClick, 'poll'>;
|
|
93
95
|
}): Promise<void>;
|
|
96
|
+
/** Check a failed purchase again under its lease. Never creates, starts or pays. */
|
|
97
|
+
reconcile(options?: ReconcileOptions): Promise<ReconcileResult>;
|
|
94
98
|
recover(wallet: IdentityWallet): Promise<void>;
|
|
95
99
|
/** Open supplied ciphertext, or download with the saved grant. Returns plaintext only in memory. */
|
|
96
100
|
open(input?: Uint8Array | BrowserOpenOptions): ReturnType<Annon['open']>;
|
|
@@ -9,6 +9,7 @@ import { purchaseKey } from '../pay/purchase.js';
|
|
|
9
9
|
import { checkNext, indexedDbStore, } from '../pay/store.js';
|
|
10
10
|
import { bytes, hex } from '../settle-near/codec.js';
|
|
11
11
|
import { BROWSER_DOWNLOAD_LIMIT, browserDownload, downloadProgress, } from './download.js';
|
|
12
|
+
import { checkReconcilable, reconcileFailed, } from './reconcile.js';
|
|
12
13
|
const INDEX = 'annon/browser/index';
|
|
13
14
|
const conflict = () => new AnnonError('store_conflict', 'browser purchase store conflict; reload the saved purchase');
|
|
14
15
|
const entryKey = (id) => `annon/browser/entry/${id}`;
|
|
@@ -663,6 +664,19 @@ export class BrowserPurchaseController {
|
|
|
663
664
|
await session.change({ state: 'accepted' });
|
|
664
665
|
});
|
|
665
666
|
}
|
|
667
|
+
/** Check a failed purchase again under its lease. Never creates, starts or pays. */
|
|
668
|
+
async reconcile(options = {}) {
|
|
669
|
+
const before = await this.read();
|
|
670
|
+
const busy = this.busy(before);
|
|
671
|
+
if (busy)
|
|
672
|
+
throw busy;
|
|
673
|
+
checkReconcilable(before);
|
|
674
|
+
return this.withLease((session) => reconcileFailed(session, {
|
|
675
|
+
accept: (record) => this.options.sdk.accept(record),
|
|
676
|
+
restorePurchase: (record) => this.storage.restorePurchase(record),
|
|
677
|
+
loadCanonical: (id) => encryptedPurchaseStore(this.storage.rawStore, this.storage.backupSecret).load(purchaseKey(id)),
|
|
678
|
+
}, options));
|
|
679
|
+
}
|
|
666
680
|
async recover(wallet) {
|
|
667
681
|
return this.withLease(async (session) => {
|
|
668
682
|
let saved = session.record;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check a failed browser purchase again. It keeps the saved purchase, its 1Click deposit and its
|
|
3
|
+
* recovery identity, and it never creates, starts or pays: it reads NEAR acceptance and, for 1Click,
|
|
4
|
+
* the existing deposit's status once.
|
|
5
|
+
*/
|
|
6
|
+
import { OneClick, type SwapStatus } from '../oneclick/oneclick.js';
|
|
7
|
+
import type { PurchaseRecord } from '../pay/purchase.js';
|
|
8
|
+
import type { BrowserPurchaseRecord, BrowserPurchaseState } from './record.js';
|
|
9
|
+
export interface ReconcileOptions {
|
|
10
|
+
/** Only the single status read is used; tests and custom endpoints pass their own client. */
|
|
11
|
+
oneClick?: Pick<OneClick, 'status'>;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
export interface ReconcileResult {
|
|
15
|
+
state: BrowserPurchaseState;
|
|
16
|
+
/** The 1Click status read by this check, when there was one. */
|
|
17
|
+
swapStatus?: SwapStatus['status'];
|
|
18
|
+
}
|
|
19
|
+
export interface ReconcileSession {
|
|
20
|
+
record: BrowserPurchaseRecord;
|
|
21
|
+
change(patch: Partial<BrowserPurchaseRecord>): Promise<BrowserPurchaseRecord>;
|
|
22
|
+
}
|
|
23
|
+
export interface ReconcileDeps {
|
|
24
|
+
accept(record: PurchaseRecord): Promise<boolean>;
|
|
25
|
+
restorePurchase(record: PurchaseRecord): Promise<PurchaseRecord>;
|
|
26
|
+
/** The canonical saved purchase, read only: no restore, no backup. */
|
|
27
|
+
loadCanonical(id: string): Promise<PurchaseRecord | null>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Throws unless the record is a failed purchase with a saved commitment whose quote and polling
|
|
31
|
+
* checkpoint name the same deposit. Reads nothing and writes nothing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkReconcilable(record: BrowserPurchaseRecord): PurchaseRecord;
|
|
34
|
+
/**
|
|
35
|
+
* A 1Click check must read the deposit this purchase was saved with. The restored purchase may be a
|
|
36
|
+
* newer revision of the same commitment, but its deposit address and memo, and those of any saved
|
|
37
|
+
* polling checkpoint, must be the saved ones. Anything else is refused before any read or write.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkSameDeposit(saved: BrowserPurchaseRecord, restored: PurchaseRecord): void;
|
|
40
|
+
/** Runs inside the purchase lease. A failed read leaves the saved state and checkpoint as they were. */
|
|
41
|
+
export declare function reconcileFailed(session: ReconcileSession, deps: ReconcileDeps, options?: ReconcileOptions): Promise<ReconcileResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check a failed browser purchase again. It keeps the saved purchase, its 1Click deposit and its
|
|
3
|
+
* recovery identity, and it never creates, starts or pays: it reads NEAR acceptance and, for 1Click,
|
|
4
|
+
* the existing deposit's status once.
|
|
5
|
+
*/
|
|
6
|
+
import { OneClick, } from '../oneclick/oneclick.js';
|
|
7
|
+
/**
|
|
8
|
+
* Throws unless the record is a failed purchase with a saved commitment whose quote and polling
|
|
9
|
+
* checkpoint name the same deposit. Reads nothing and writes nothing.
|
|
10
|
+
*/
|
|
11
|
+
export function checkReconcilable(record) {
|
|
12
|
+
if (record.state !== 'failed' || !record.purchase)
|
|
13
|
+
throw new Error('only a failed saved purchase can be checked again');
|
|
14
|
+
checkSameDeposit(record, record.purchase);
|
|
15
|
+
return record.purchase;
|
|
16
|
+
}
|
|
17
|
+
const depositOf = (record) => record?.quote?.deposit;
|
|
18
|
+
/**
|
|
19
|
+
* A 1Click check must read the deposit this purchase was saved with. The restored purchase may be a
|
|
20
|
+
* newer revision of the same commitment, but its deposit address and memo, and those of any saved
|
|
21
|
+
* polling checkpoint, must be the saved ones. Anything else is refused before any read or write.
|
|
22
|
+
*/
|
|
23
|
+
export function checkSameDeposit(saved, restored) {
|
|
24
|
+
if (saved.route !== '1click')
|
|
25
|
+
return;
|
|
26
|
+
const expected = depositOf(saved.purchase);
|
|
27
|
+
const actual = depositOf(restored);
|
|
28
|
+
const point = saved.checkpoint;
|
|
29
|
+
if (expected?.address !== actual?.address ||
|
|
30
|
+
expected?.memo !== actual?.memo ||
|
|
31
|
+
(point && (point.address !== expected?.address || point.memo !== expected?.memo)))
|
|
32
|
+
throw new Error('saved purchase changed deposit; check it with support');
|
|
33
|
+
}
|
|
34
|
+
const settled = {
|
|
35
|
+
SUCCESS: 'accepting',
|
|
36
|
+
REFUNDED: 'refunded',
|
|
37
|
+
};
|
|
38
|
+
function nextCheckpoint(previous, address, memo, status) {
|
|
39
|
+
const base = previous && previous.address === address && previous.memo === memo
|
|
40
|
+
? previous
|
|
41
|
+
: { address, ...(memo === undefined ? {} : { memo }), attempts: 0, errors: 0 };
|
|
42
|
+
const { nextPollAt: _wait, ...kept } = base;
|
|
43
|
+
return { ...kept, attempts: kept.attempts + 1, errors: 0, lastStatus: status };
|
|
44
|
+
}
|
|
45
|
+
/** Runs inside the purchase lease. A failed read leaves the saved state and checkpoint as they were. */
|
|
46
|
+
export async function reconcileFailed(session, deps, options = {}) {
|
|
47
|
+
let saved = session.record;
|
|
48
|
+
const committed = checkReconcilable(saved);
|
|
49
|
+
// Validate the canonical copy before restoring, which may write it and its backup.
|
|
50
|
+
if (saved.route === '1click') {
|
|
51
|
+
const canonical = await deps.loadCanonical(committed.id);
|
|
52
|
+
if (canonical)
|
|
53
|
+
checkSameDeposit(saved, canonical);
|
|
54
|
+
}
|
|
55
|
+
const restored = await deps.restorePurchase(committed);
|
|
56
|
+
if (restored.id !== saved.purchase?.id)
|
|
57
|
+
throw new Error('saved purchase changed identity');
|
|
58
|
+
checkSameDeposit(saved, restored);
|
|
59
|
+
if (JSON.stringify(restored) !== JSON.stringify(saved.purchase))
|
|
60
|
+
saved = await session.change({ purchase: restored });
|
|
61
|
+
if (await deps.accept(restored)) {
|
|
62
|
+
saved = await session.change({ state: 'accepted' });
|
|
63
|
+
return { state: saved.state };
|
|
64
|
+
}
|
|
65
|
+
const deposit = depositOf(restored);
|
|
66
|
+
if (saved.route !== '1click' || !deposit)
|
|
67
|
+
return { state: saved.state };
|
|
68
|
+
const status = await (options.oneClick ?? new OneClick()).status(deposit.address, {
|
|
69
|
+
...(deposit.memo === undefined ? {} : { memo: deposit.memo }),
|
|
70
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
71
|
+
});
|
|
72
|
+
saved = await session.change({
|
|
73
|
+
checkpoint: nextCheckpoint(saved.checkpoint, deposit.address, deposit.memo, status),
|
|
74
|
+
state: settled[status.status] ?? 'failed',
|
|
75
|
+
});
|
|
76
|
+
return { state: saved.state, swapStatus: status.status };
|
|
77
|
+
}
|
package/dist/node/adapter.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createCoreAdapter } from './core-adapter.js';
|
|
|
3
3
|
// core-adapter.js loads first: it imports the SDK modules in the order this file did, so they initialize as before.
|
|
4
4
|
import { identityJson, parseIdentity } from '../settle-near/identity.js';
|
|
5
5
|
import { agentPolicy, configPath, initAgent, savePublicConfig } from './agent.js';
|
|
6
|
+
import { initBackupSecret, initPolicy } from './backup-secret-init.js';
|
|
6
7
|
import { startBrowserSession } from './browser-login.js';
|
|
7
8
|
import { loadConfig } from './config.js';
|
|
8
9
|
import { AdapterError } from './errors.js';
|
|
@@ -18,11 +19,13 @@ export function createAdapter(env = process.env, d = {}) {
|
|
|
18
19
|
return async (o) => {
|
|
19
20
|
if (o.command !== 'init' && (o.signer || o.credentialEnv || o.credentialKeychain))
|
|
20
21
|
throw new AdapterError('signer_init_only', 'Signer and credential references can only be set by init.', 2);
|
|
21
|
-
const agent = await agentPolicy(o, env, d.agent);
|
|
22
|
+
const agent = await (o.command === 'init' && !o.agent ? initPolicy : agentPolicy)(o, env, d.agent);
|
|
22
23
|
if (isDiscovery(o))
|
|
23
24
|
return discoverShops(o, env, agent, d.fetch);
|
|
24
25
|
if (o.command === 'init')
|
|
25
|
-
return
|
|
26
|
+
return o.agent
|
|
27
|
+
? initAgent(o, env, d.keychain ?? osKeychain)
|
|
28
|
+
: initBackupSecret(o, env, d.notify);
|
|
26
29
|
const c = await loadConfig(o, env, o.command === 'login', d.agent);
|
|
27
30
|
if (agent || c.agent)
|
|
28
31
|
o = await confinePaths(o, c.workDir);
|
|
@@ -56,7 +59,6 @@ export function createAdapter(env = process.env, d = {}) {
|
|
|
56
59
|
message: challenge,
|
|
57
60
|
recipient: c.contractId || 'annon-cli',
|
|
58
61
|
nonce: [...crypto.getRandomValues(new Uint8Array(32))],
|
|
59
|
-
callbackUrl: new URL('/callback', s.url).href,
|
|
60
62
|
});
|
|
61
63
|
let identity;
|
|
62
64
|
await s.request(q, async (r) => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal lines and final errors for a browser approval. The page reports only a fixed reason
|
|
3
|
+
* name (browser/login-status.ts); every line printed here is written by the CLI, never by the page
|
|
4
|
+
* or a wallet.
|
|
5
|
+
*/
|
|
6
|
+
import { type WalletFailure } from '../browser/login-status.js';
|
|
7
|
+
import { TransactionUncertain } from '../wallet/uncertain.js';
|
|
8
|
+
import { AdapterError } from './errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* What the page may report: a failed wallet attempt, a cancel, or that it is about to call the
|
|
11
|
+
* wallet's send (after which the request can only end as sent or uncertain).
|
|
12
|
+
*/
|
|
13
|
+
export type PageReport = WalletFailure | 'cancelled' | 'send_started';
|
|
14
|
+
export declare const isPageReport: (v: unknown) => v is PageReport;
|
|
15
|
+
export declare const openingLine: (url: string) => string;
|
|
16
|
+
export declare const waitingLine: (ms: number) => string;
|
|
17
|
+
export declare const APPROVED_LINE = "Approved in the browser.";
|
|
18
|
+
export declare const failureLine: (reason: WalletFailure, ms: number) => string;
|
|
19
|
+
/** The command's error when the person cancels on the page. */
|
|
20
|
+
export declare const cancelledError: () => AdapterError;
|
|
21
|
+
/** The command's error when a request expires, naming the last wallet failure the page reported. */
|
|
22
|
+
export declare function expiredError(last?: WalletFailure): AdapterError;
|
|
23
|
+
/** The purchase a NEAR USDC payment request pays for, when its transfer message names one. */
|
|
24
|
+
export declare function requestPurchase(action: unknown): string | undefined;
|
|
25
|
+
/** A NEAR transaction hash as wallets return it: base58 of 32 bytes. Anything else is not evidence. */
|
|
26
|
+
export declare function usableHash(v: unknown): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* The line and error when a send may have reached the chain, keeping a usable hash when the
|
|
29
|
+
* wallet returned one. The CLI never pays again by itself.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sendUncertain(action: unknown, txHash?: string): {
|
|
32
|
+
line: string;
|
|
33
|
+
error: TransactionUncertain;
|
|
34
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal lines and final errors for a browser approval. The page reports only a fixed reason
|
|
3
|
+
* name (browser/login-status.ts); every line printed here is written by the CLI, never by the page
|
|
4
|
+
* or a wallet.
|
|
5
|
+
*/
|
|
6
|
+
import { base58 } from '@scure/base';
|
|
7
|
+
import { clock, record } from '../browser/login-format.js';
|
|
8
|
+
import { isWalletFailure } from '../browser/login-status.js';
|
|
9
|
+
import { parsePurchaseMessage, purchaseId } from '../settle-near/purchase.js';
|
|
10
|
+
import { TransactionUncertain } from '../wallet/uncertain.js';
|
|
11
|
+
import { AdapterError } from './errors.js';
|
|
12
|
+
export const isPageReport = (v) => v === 'cancelled' || v === 'send_started' || isWalletFailure(v);
|
|
13
|
+
export const openingLine = (url) => `Opening your browser. If it does not open, visit ${url}`;
|
|
14
|
+
export const waitingLine = (ms) => `Waiting for approval in the browser. Expires in ${clock(ms)}.`;
|
|
15
|
+
export const APPROVED_LINE = 'Approved in the browser.';
|
|
16
|
+
const FAILED = {
|
|
17
|
+
window_closed: 'The wallet window closed before signing.',
|
|
18
|
+
popup_blocked: 'The browser blocked the wallet window. Allow pop-ups for the 127.0.0.1 page.',
|
|
19
|
+
rejected: 'The wallet declined the request.',
|
|
20
|
+
wallet_error: 'The wallet could not complete the request.',
|
|
21
|
+
refused: 'The page refused the wallet, account or chain for this request.',
|
|
22
|
+
send_uncertain: 'The wallet may have sent the transaction.',
|
|
23
|
+
};
|
|
24
|
+
export const failureLine = (reason, ms) => `${FAILED[reason]} You can try again on the page for ${clock(ms)}.`;
|
|
25
|
+
/** The command's error when the person cancels on the page. */
|
|
26
|
+
export const cancelledError = () => new AdapterError('cancelled', 'You cancelled the approval in the browser.', 3);
|
|
27
|
+
/** The command's error when a request expires, naming the last wallet failure the page reported. */
|
|
28
|
+
export function expiredError(last) {
|
|
29
|
+
const again = 'Run the command again.';
|
|
30
|
+
if (!last)
|
|
31
|
+
return new AdapterError('cancelled', `The browser approval expired before the wallet signed. ${again}`, 3);
|
|
32
|
+
const code = last === 'rejected' ? 'rejected' : last === 'window_closed' ? 'cancelled' : 'wallet_error';
|
|
33
|
+
return new AdapterError(code, `${FAILED[last]} The browser approval then expired. ${again}`, 3);
|
|
34
|
+
}
|
|
35
|
+
/** The purchase a NEAR USDC payment request pays for, when its transfer message names one. */
|
|
36
|
+
export function requestPurchase(action) {
|
|
37
|
+
const p = record(record(action).payload);
|
|
38
|
+
const msg = record(p.args).msg;
|
|
39
|
+
if (p.method !== 'ft_transfer_call' || typeof msg !== 'string')
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return purchaseId(parsePurchaseMessage(msg));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A NEAR transaction hash as wallets return it: base58 of 32 bytes. Anything else is not evidence. */
|
|
49
|
+
export function usableHash(v) {
|
|
50
|
+
if (typeof v !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(v))
|
|
51
|
+
return undefined;
|
|
52
|
+
try {
|
|
53
|
+
return base58.decode(v).length === 32 ? v : undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The line and error when a send may have reached the chain, keeping a usable hash when the
|
|
61
|
+
* wallet returned one. The CLI never pays again by itself.
|
|
62
|
+
*/
|
|
63
|
+
export function sendUncertain(action, txHash) {
|
|
64
|
+
const purchase = requestPurchase(action);
|
|
65
|
+
return {
|
|
66
|
+
line: `The wallet may have sent the transaction. Do not pay again. ${purchase
|
|
67
|
+
? `Run annon status ${purchase} to check it.`
|
|
68
|
+
: 'Check the transaction in your wallet before you run the command again.'}`,
|
|
69
|
+
error: new TransactionUncertain(txHash, purchase),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Input } from './cli.js';
|
|
2
|
+
import type { Environment } from './wallets.js';
|
|
3
|
+
/**
|
|
4
|
+
* The agent-policy check for bare init. A config that cannot be read must stop setup with a
|
|
5
|
+
* setup error, not a generic failure; policy refusals stay as they are.
|
|
6
|
+
*/
|
|
7
|
+
export declare function initPolicy(o: Input, env: Environment, forced?: boolean): Promise<boolean>;
|
|
8
|
+
/** Never writes an explicit source; creates the default only when no secret exists at all. */
|
|
9
|
+
export declare function initBackupSecret(o: Partial<Input>, env: Environment, notify?: (message: string) => void): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** `annon init` without --agent: validate the selected backup secret or create the default. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { link, mkdir, open, unlink } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { newBackupSecret } from '../pay/backup.js';
|
|
7
|
+
import { hex } from '../settle-near/codec.js';
|
|
8
|
+
import { agentPolicy } from './agent.js';
|
|
9
|
+
import { checkAppDirectory, defaultBackupSecretPath, findBackupSecret, readDefaultSecret, unsafeDefault, } from './backup-secret.js';
|
|
10
|
+
import { AdapterError } from './errors.js';
|
|
11
|
+
import { assertNoSavedState } from './saved-state.js';
|
|
12
|
+
import { syncDir } from './sync-dir.js';
|
|
13
|
+
const setupError = (message) => new AdapterError('backup_secret_required', message, 2);
|
|
14
|
+
/**
|
|
15
|
+
* Writes the secret to an exclusive owner-only temporary file, then publishes it with link(),
|
|
16
|
+
* which never replaces an existing path (rename() would). Returns false when another
|
|
17
|
+
* invocation already published one; its file is validated and the generated bytes are dropped.
|
|
18
|
+
*/
|
|
19
|
+
async function createDefaultSecret(path) {
|
|
20
|
+
const dir = dirname(path);
|
|
21
|
+
// Missing parents are created 0700; an existing directory is only checked, never changed.
|
|
22
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
23
|
+
await checkAppDirectory(dir);
|
|
24
|
+
const temp = join(dir, `.${basename(path)}.${randomBytes(8).toString('hex')}.tmp`);
|
|
25
|
+
const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
26
|
+
let published;
|
|
27
|
+
try {
|
|
28
|
+
const secret = newBackupSecret();
|
|
29
|
+
try {
|
|
30
|
+
await handle.writeFile(`${hex(secret).slice(2)}\n`);
|
|
31
|
+
await handle.sync();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
secret.fill(0);
|
|
35
|
+
await handle.close();
|
|
36
|
+
}
|
|
37
|
+
published = await link(temp, path).then(() => true, (error) => {
|
|
38
|
+
if (error.code === 'EEXIST')
|
|
39
|
+
return false;
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await unlink(temp).catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
if (!published) {
|
|
47
|
+
if (!(await readDefaultSecret(path)))
|
|
48
|
+
throw unsafeDefault();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await syncDir(dir);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// The published file stays: deleting a secret is never cleanup. A rerun validates it.
|
|
56
|
+
throw setupError('The backup secret file was created but its directory could not be synced. Rerun annon init to validate it.');
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const printable = (path) => (/\p{Cc}/u.test(path) ? JSON.stringify(path) : path);
|
|
61
|
+
const notice = (path) => [
|
|
62
|
+
`Created a plaintext backup-secret file with owner-only permissions: ${printable(path)}`,
|
|
63
|
+
'Back up this file separately in protected storage. Losing it loses access to local saved purchase and sell records and backup files. Wallet-based purchase recovery through the contract still works.',
|
|
64
|
+
'Copy the file without displaying its contents. To restore it, select the copied file with ANNON_BACKUP_SECRET_FILE.',
|
|
65
|
+
].join('\n');
|
|
66
|
+
const result = (source, path, created) => ({
|
|
67
|
+
configured: true,
|
|
68
|
+
backupSecret: { source, ...(path ? { path } : {}), created },
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* The agent-policy check for bare init. A config that cannot be read must stop setup with a
|
|
72
|
+
* setup error, not a generic failure; policy refusals stay as they are.
|
|
73
|
+
*/
|
|
74
|
+
export async function initPolicy(o, env, forced) {
|
|
75
|
+
try {
|
|
76
|
+
return await agentPolicy(o, env, forced);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof AdapterError)
|
|
80
|
+
throw error;
|
|
81
|
+
throw setupError('The Annon config could not be read to check agent policy. Fix or restore it before running annon init.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Never writes an explicit source; creates the default only when no secret exists at all. */
|
|
85
|
+
export async function initBackupSecret(o, env, notify) {
|
|
86
|
+
const found = await findBackupSecret(o, env);
|
|
87
|
+
if (found)
|
|
88
|
+
return result(found.source, found.path, false);
|
|
89
|
+
await assertNoSavedState(o, env);
|
|
90
|
+
const path = defaultBackupSecretPath();
|
|
91
|
+
let created;
|
|
92
|
+
try {
|
|
93
|
+
created = await createDefaultSecret(path);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof AdapterError)
|
|
97
|
+
throw error;
|
|
98
|
+
throw setupError('The backup secret file could not be created. Nothing else was tried; fix ~/.config/annon and rerun annon init.');
|
|
99
|
+
}
|
|
100
|
+
if (created)
|
|
101
|
+
notify?.(notice(path));
|
|
102
|
+
return result('default-file', path, created);
|
|
103
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Input } from './cli.js';
|
|
2
|
+
import { AdapterError } from './errors.js';
|
|
3
|
+
import type { Environment } from './wallets.js';
|
|
4
|
+
export type BackupSecretSource = 'flag-file' | 'env-file' | 'env' | 'default-file';
|
|
5
|
+
export interface ResolvedSecret {
|
|
6
|
+
secret: Uint8Array;
|
|
7
|
+
source: BackupSecretSource;
|
|
8
|
+
/** Set for file sources only; never the value of ANNON_BACKUP_SECRET. */
|
|
9
|
+
path?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Fixed home, like the config anchor: neither XDG nor --config/--state-dir move it. */
|
|
12
|
+
export declare const defaultBackupSecretPath: () => string;
|
|
13
|
+
export declare const unsafeDefault: () => AdapterError;
|
|
14
|
+
/** Trimmed lowercase hex with an optional 0x prefix, exactly 32 bytes. */
|
|
15
|
+
export declare function parseSecret(text: string): Uint8Array;
|
|
16
|
+
/**
|
|
17
|
+
* Read-only check of the application directory that holds the managed default; false when it
|
|
18
|
+
* does not exist. An existing 0755 directory is fine. The user's home ancestry is trusted.
|
|
19
|
+
*/
|
|
20
|
+
export declare function checkAppDirectory(dir: string): Promise<boolean>;
|
|
21
|
+
/**
|
|
22
|
+
* Reads the managed default inside a private application directory, without following a
|
|
23
|
+
* symlink or blocking on a FIFO, and checks the opened file itself. Returns undefined only
|
|
24
|
+
* when nothing exists at the path.
|
|
25
|
+
*/
|
|
26
|
+
export declare function readDefaultSecret(path?: string): Promise<Uint8Array<ArrayBufferLike> | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* Today's explicit precedence first: the file selector (flag ?? environment), then
|
|
29
|
+
* ANNON_BACKUP_SECRET; empty values select nothing. A selected source that fails is final.
|
|
30
|
+
* Only with no explicit source is the default file read. Nothing here ever creates a secret.
|
|
31
|
+
* Returns undefined only when no source is selected and no default file exists.
|
|
32
|
+
*/
|
|
33
|
+
export declare function findBackupSecret(o: Partial<Input>, env: Environment): Promise<ResolvedSecret | undefined>;
|
|
34
|
+
/** The secret a command needs; missing input fails before any side effect that uses it. */
|
|
35
|
+
export declare function readBackupSecret(o: Partial<Input>, env: Environment): Promise<Uint8Array>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Where the CLI backup secret comes from: an explicit source, else the managed default file. */
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, readFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { bytes } from '../settle-near/codec.js';
|
|
7
|
+
import { AdapterError } from './errors.js';
|
|
8
|
+
/** Fixed home, like the config anchor: neither XDG nor --config/--state-dir move it. */
|
|
9
|
+
export const defaultBackupSecretPath = () => join(homedir(), '.config', 'annon', 'backup-secret');
|
|
10
|
+
const secretError = (message) => new AdapterError('backup_secret_required', message, 2);
|
|
11
|
+
const missingSecret = () => secretError('No backup secret is available. For first-time human setup, run `annon init` before enabling agent mode. If you have saved records or backups, restore their matching secret instead. Unattended callers must ask their operator to provision it.');
|
|
12
|
+
const invalidExplicit = () => secretError('The selected backup secret source could not be read as a 32-byte secret (64 hex characters). Fix that source; Annon does not fall back to another one.');
|
|
13
|
+
export const unsafeDefault = () => secretError('The backup secret file at ~/.config/annon/backup-secret is unreadable, malformed or not private (owner-only regular file required). Fix or restore it; Annon never repairs or replaces it.');
|
|
14
|
+
/** Trimmed lowercase hex with an optional 0x prefix, exactly 32 bytes. */
|
|
15
|
+
export function parseSecret(text) {
|
|
16
|
+
const value = text.trim();
|
|
17
|
+
return bytes(value.startsWith('0x') ? value : `0x${value}`, 32);
|
|
18
|
+
}
|
|
19
|
+
const unsafeDirectory = () => secretError('~/.config/annon must be a directory you own, not a symlink, that group and others cannot write. Fix it; Annon does not change existing directories.');
|
|
20
|
+
/**
|
|
21
|
+
* Read-only check of the application directory that holds the managed default; false when it
|
|
22
|
+
* does not exist. An existing 0755 directory is fine. The user's home ancestry is trusted.
|
|
23
|
+
*/
|
|
24
|
+
export async function checkAppDirectory(dir) {
|
|
25
|
+
let s;
|
|
26
|
+
try {
|
|
27
|
+
s = await lstat(dir);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code === 'ENOENT')
|
|
31
|
+
return false;
|
|
32
|
+
throw unsafeDirectory();
|
|
33
|
+
}
|
|
34
|
+
if (!s.isDirectory() || s.uid !== process.getuid?.() || (s.mode & 0o022) !== 0)
|
|
35
|
+
throw unsafeDirectory();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reads the managed default inside a private application directory, without following a
|
|
40
|
+
* symlink or blocking on a FIFO, and checks the opened file itself. Returns undefined only
|
|
41
|
+
* when nothing exists at the path.
|
|
42
|
+
*/
|
|
43
|
+
export async function readDefaultSecret(path = defaultBackupSecretPath()) {
|
|
44
|
+
if (!(await checkAppDirectory(dirname(path))))
|
|
45
|
+
return undefined;
|
|
46
|
+
let handle;
|
|
47
|
+
try {
|
|
48
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT')
|
|
52
|
+
return undefined;
|
|
53
|
+
throw unsafeDefault();
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const s = await handle.stat();
|
|
57
|
+
if (!s.isFile() ||
|
|
58
|
+
s.uid !== process.getuid?.() ||
|
|
59
|
+
(s.mode & 0o077) !== 0 ||
|
|
60
|
+
(s.mode & 0o400) === 0 ||
|
|
61
|
+
s.size > 256)
|
|
62
|
+
throw new Error('unsafe');
|
|
63
|
+
return parseSecret(await handle.readFile('utf8'));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw unsafeDefault();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await handle.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Today's explicit precedence first: the file selector (flag ?? environment), then
|
|
74
|
+
* ANNON_BACKUP_SECRET; empty values select nothing. A selected source that fails is final.
|
|
75
|
+
* Only with no explicit source is the default file read. Nothing here ever creates a secret.
|
|
76
|
+
* Returns undefined only when no source is selected and no default file exists.
|
|
77
|
+
*/
|
|
78
|
+
export async function findBackupSecret(o, env) {
|
|
79
|
+
const file = o.backupSecretFile ?? env.ANNON_BACKUP_SECRET_FILE;
|
|
80
|
+
if (file || env.ANNON_BACKUP_SECRET) {
|
|
81
|
+
try {
|
|
82
|
+
if (file)
|
|
83
|
+
return {
|
|
84
|
+
secret: parseSecret(await readFile(file, 'utf8')),
|
|
85
|
+
source: o.backupSecretFile ? 'flag-file' : 'env-file',
|
|
86
|
+
path: resolve(file),
|
|
87
|
+
};
|
|
88
|
+
return { secret: parseSecret(env.ANNON_BACKUP_SECRET), source: 'env' };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw invalidExplicit();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const path = defaultBackupSecretPath();
|
|
95
|
+
const secret = await readDefaultSecret(path);
|
|
96
|
+
return secret && { secret, source: 'default-file', path };
|
|
97
|
+
}
|
|
98
|
+
/** The secret a command needs; missing input fails before any side effect that uses it. */
|
|
99
|
+
export async function readBackupSecret(o, env) {
|
|
100
|
+
const found = await findBackupSecret(o, env);
|
|
101
|
+
if (!found)
|
|
102
|
+
throw missingSecret();
|
|
103
|
+
return found.secret;
|
|
104
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval page talks only to this loopback server, the NEAR RPC its Wallet Selector uses
|
|
3
|
+
* (fixed per network in browser/login.ts, independent of the CLI's configured RPC) and HOT
|
|
4
|
+
* Wallet's relay; it frames only the HOT widget. EVM wallets are reached through the injected
|
|
5
|
+
* provider, which needs no connection from the page.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LOGIN_PAGE_CSP = "default-src 'none'; script-src 'self'; connect-src 'self' https://free.rpc.fastnear.com https://test.rpc.fastnear.com https://h4n.app; frame-src https://hot-labs.org; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
|
|
1
8
|
export interface BrowserSession {
|
|
2
9
|
url: string;
|
|
3
10
|
pairingCode: string;
|
|
@@ -13,6 +20,8 @@ export declare function startBrowserSession(o: {
|
|
|
13
20
|
open?: (url: string) => void;
|
|
14
21
|
asset?: string;
|
|
15
22
|
timeoutMs?: number;
|
|
23
|
+
/** Human progress lines; stderr by default, never stdout. */
|
|
24
|
+
notify?: (line: string) => void;
|
|
16
25
|
}): Promise<BrowserSession>;
|
|
17
26
|
/** Only desktop routing and executable lookup reach the browser opener. */
|
|
18
27
|
export declare function browserOpenerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|