diff --git a/README.md b/README.md index 9953fd6..320c5a8 100644 --- a/README.md +++ b/README.md @@ -646,6 +646,37 @@ When `assignmentStrategy.type` is `Single`, you can set `assignmentStrategy.inst If `instantMatch` is non-empty, `acurast deploy` skips the market pricing check for that project, since matching is explicit. +```json +{ + "projects": { + "my-instant-match-project": { + "projectName": "my-instant-match-project", + "fileUrl": "examples/canary-test.js", + "network": "canary", + "onlyAttestedDevices": true, + "assignmentStrategy": { + "type": "Single", + "instantMatch": [ + { + "processor": "5CiPPseXPECbkjWCa6MnjNokrgYjMqmKndv2rSnekmSK2DjL", + "maxAllowedStartDelayInMs": 10000 + } + ] + }, + "execution": { "type": "onetime", "maxExecutionTimeInMs": 60000 }, + "maxAllowedStartDelayInMs": 10000, + "usageLimit": { + "maxMemory": 0, + "maxNetworkRequests": 0, + "maxStorage": 0 + }, + "numberOfReplicas": 1, + "maxCostPerExecution": 1000000000 + } + } +} +``` + ### Shell Runtime The Shell runtime runs your deployment as a native binary inside a Linux distro image on the processor (PRoot-isolated). This unlocks shell scripts, native tooling, and any language you can ship as a Linux binary. diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 220d892..b9b2939 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -51,9 +51,13 @@ import type { AcurastSigner } from '@acurast/sdk/chain' import { getSigningMode, getLoggedInAddress, + getExpiredAuth, touchAuth, } from '../util/authStore.js' -import { signingNoticeLine } from '../util/signingNotice.js' +import { + signingNoticeLine, + expiredSessionMessage, +} from '../util/signingNotice.js' import { startSignServer } from '../util/cliServer.js' import { RemoteSigner } from '../acurast/remoteSigner.js' import { buildDeploySummary } from '../acurast/deploySummary.js' @@ -501,6 +505,20 @@ export async function executeDeployFlow( const signingMode = getSigningMode() filelogger.info(`Signing mode: ${signingMode}`) + // An aged-out login silently resolves back to `local`, so without a mnemonic + // the env-var check below would blame a missing ACURAST_MNEMONIC. Say what + // actually happened instead. + if (signingMode === 'local' && !process.env.ACURAST_MNEMONIC) { + const expired = getExpiredAuth() + if (expired) { + filelogger.warn( + `Login expired (${expired.scope}), last logged in at ${expired.record.loggedInAt}` + ) + log(expiredSessionMessage(expired)) + return + } + } + try { validateDeployEnvVars({ requireMnemonic: signingMode === 'local' }) } catch (e: any) { diff --git a/src/util/authStore.ts b/src/util/authStore.ts index b51305d..de2e29f 100644 --- a/src/util/authStore.ts +++ b/src/util/authStore.ts @@ -49,21 +49,43 @@ const isExpired = (record: AuthRecord): boolean => { return Date.now() - at > SESSION_MAX_AGE_MS } -const read = (scope: AuthScope): AuthRecord | null => { +/** The stored record as-is, without the expiry check. */ +const parse = (scope: AuthScope): AuthRecord | null => { const raw = store(scope).getItem(AUTH_KEY) if (!raw) return null try { - const record = JSON.parse(raw) as AuthRecord - if (isExpired(record)) return null - return record + return JSON.parse(raw) as AuthRecord } catch { return null } } +const read = (scope: AuthScope): AuthRecord | null => { + const record = parse(scope) + if (!record || isExpired(record)) return null + return record +} + export const getGlobalAuth = (): AuthRecord | null => read('global') export const getProjectAuth = (): AuthRecord | null => read('project') +/** + * A stored login that exists but has aged out, if any (project pin first, to + * match `getActiveAuth`). Lets callers say "your session expired" instead of + * the misleading "ACURAST_MNEMONIC is not defined" they would otherwise hit + * once an expired session drops the signing mode back to `local`. + */ +export const getExpiredAuth = (): { + scope: AuthScope + record: AuthRecord +} | null => { + for (const scope of ['project', 'global'] as const) { + const record = parse(scope) + if (record && isExpired(record)) return { scope, record } + } + return null +} + /** The account that will actually be used: a project pin wins over the global login. */ export const getActiveAuth = (): AuthRecord | null => getProjectAuth() ?? getGlobalAuth() diff --git a/src/util/signingNotice.ts b/src/util/signingNotice.ts index ae85bfc..2eb7d7e 100644 --- a/src/util/signingNotice.ts +++ b/src/util/signingNotice.ts @@ -1,4 +1,4 @@ -import type { SigningMode } from './authStore.js' +import type { AuthRecord, AuthScope, SigningMode } from './authStore.js' /** * One-line, user-facing notice shown at deploy time so it is always clear how @@ -9,3 +9,35 @@ export const signingNoticeLine = (mode: SigningMode, address: string): string => mode === 'local' ? `Signing with local mnemonic (${address}) — private key is read from your environment.` : `Signing with your browser wallet (${address}).` + +const daysAgo = (iso: string): number | null => { + const at = Date.parse(iso) + if (Number.isNaN(at)) return null + return Math.floor((Date.now() - at) / (24 * 60 * 60 * 1000)) +} + +/** + * Shown when the only thing standing between the user and a deploy is an aged-out + * login. Without this they get `"ACURAST_MNEMONIC" is not defined in the + * environment.` — technically true (the expired session dropped the signing mode + * back to `local`) but it points at the wrong fix. + */ +export const expiredSessionMessage = (expired: { + scope: AuthScope + record: AuthRecord +}): string => { + const { scope, record } = expired + const days = daysAgo(record.loggedInAt) + const since = days === null ? '' : ` ${days} days ago` + const where = + scope === 'project' + ? 'This project is pinned to an account whose session expired' + : 'Your Acurast session expired' + + return [ + `${where}${since} (${record.address}).`, + '', + 'Run `acurast login` to sign in with your browser wallet again,', + 'or set ACURAST_MNEMONIC to sign locally instead.', + ].join('\n') +} diff --git a/test/authStore.test.ts b/test/authStore.test.ts index 67ac140..a71b08a 100644 --- a/test/authStore.test.ts +++ b/test/authStore.test.ts @@ -40,6 +40,7 @@ const { isLoggedIn, getSigningMode, getAuthSource, + getExpiredAuth, } = await import('../src/util/authStore.js') const record = (address: string) => ({ @@ -114,6 +115,45 @@ describe('authStore', () => { }) }) + describe('getExpiredAuth', () => { + const stale = (address: string) => ({ + ...record(address), + loggedInAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(), + }) + + test('surfaces an expired global session that getGlobalAuth hides', () => { + setAuth(stale('5Old'), 'global') + expect(getGlobalAuth()).toBeNull() + expect(getExpiredAuth()).toMatchObject({ + scope: 'global', + record: { address: '5Old' }, + }) + }) + + test('prefers the project pin, matching getActiveAuth', () => { + setAuth(stale('5OldGlobal'), 'global') + setAuth(stale('5OldProject'), 'project') + expect(getExpiredAuth()).toMatchObject({ + scope: 'project', + record: { address: '5OldProject' }, + }) + }) + + test('null when the session is still valid', () => { + setAuth(record('5Fresh'), 'global') + expect(getExpiredAuth()).toBeNull() + }) + + test('null when nothing is stored', () => { + expect(getExpiredAuth()).toBeNull() + }) + + test('null on malformed JSON', () => { + storeFor(ACURAST_GLOBAL_BASE_PATH).set('auth', '{ not json') + expect(getExpiredAuth()).toBeNull() + }) + }) + describe('getSigningMode resolution order', () => { test('1. explicit ACURAST_SIGNING_MODE wins over everything', () => { setAuth(record('5Global'), 'global') diff --git a/test/signingNotice.test.ts b/test/signingNotice.test.ts index ac5a6c4..a91d8fc 100644 --- a/test/signingNotice.test.ts +++ b/test/signingNotice.test.ts @@ -1,4 +1,7 @@ -import { signingNoticeLine } from '../src/util/signingNotice.js' +import { + signingNoticeLine, + expiredSessionMessage, +} from '../src/util/signingNotice.js' describe('signingNoticeLine', () => { test('local mnemonic notice names the address and warns about key on disk', () => { @@ -14,3 +17,44 @@ describe('signingNoticeLine', () => { expect(line.toLowerCase()).toContain('browser wallet') }) }) + +describe('expiredSessionMessage', () => { + const staleRecord = (address: string, days: number) => ({ + address, + signatureType: 'sr25519', + network: 'canary' as const, + loggedInAt: new Date( + Date.now() - days * 24 * 60 * 60 * 1000 + ).toISOString(), + }) + + test('names the account, the age, and both ways out', () => { + const message = expiredSessionMessage({ + scope: 'global', + record: staleRecord('5Alice', 21), + }) + expect(message).toContain('5Alice') + expect(message).toContain('21 days ago') + expect(message).toContain('acurast login') + expect(message).toContain('ACURAST_MNEMONIC') + }) + + test('says the project is pinned when the expired login is a project pin', () => { + const message = expiredSessionMessage({ + scope: 'project', + record: staleRecord('5Bob', 30), + }) + expect(message.toLowerCase()).toContain('project') + expect(message.toLowerCase()).toContain('pinned') + }) + + test('omits the age when loggedInAt is unparseable', () => { + const message = expiredSessionMessage({ + scope: 'global', + record: { ...staleRecord('5Carol', 21), loggedInAt: 'not-a-date' }, + }) + expect(message).toContain('5Carol') + expect(message).not.toContain('NaN') + expect(message).toContain('acurast login') + }) +})