Versie 0.3.2
This commit is contained in:
1 parent
e77d8fd373
commit
1cf6eeed29
16 files changed
+551
-44
No files matched your search
+2
-3
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:8874091aa075a54dd49f5d1aad9244d40ef61ac47ce7c83d7d4e346d8133207a
|
// Generated, do not edit. sha256:98f4651e5cb7036965f92ae8206b5dd6cbf62ce4e42388c68825bc8d9de6ca9d
|
||||||
import { configureSharedUi } from './ui/shared-setup.js';
|
import { configureSharedUi } from './ui/shared-setup.js';
|
||||||
import './edition.js';
|
import './edition.js';
|
||||||
import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js';
|
import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js';
|
||||||
@@ -253,8 +253,7 @@ async function boot() {
|
|||||||
initCommands();
|
initCommands();
|
||||||
await renderPage();
|
await renderPage();
|
||||||
document.documentElement.dataset.app = 'ready';
|
document.documentElement.dataset.app = 'ready';
|
||||||
finishSplash();
|
finishSplash().then(maybeStartSetup);
|
||||||
maybeStartSetup();
|
|
||||||
|
|
||||||
refreshAttention();
|
refreshAttention();
|
||||||
listAssets()
|
listAssets()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Generated, do not edit. sha256:36518f0a81f251fc91cc7a50cccd80f8e26c117fb0b6017e85098cc76621857c */
|
/* Generated, do not edit. sha256:d875d85b0d41e419eb6bae7090608b0914629b07925648a2d82dc7a8e8eb4309 */
|
||||||
:root {
|
:root {
|
||||||
--logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E");
|
--logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E");
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
@@ -2019,6 +2019,7 @@ html[data-pagetitle="off"] { --guide-top:1.2rem; }
|
|||||||
border:0; border-radius:50%; background:transparent; color:var(--text-ter); cursor:pointer; }
|
border:0; border-radius:50%; background:transparent; color:var(--text-ter); cursor:pointer; }
|
||||||
.dialog-eye:hover, .dialog-eye:focus-visible { background:var(--accent-soft); color:var(--accent-text); }
|
.dialog-eye:hover, .dialog-eye:focus-visible { background:var(--accent-soft); color:var(--accent-text); }
|
||||||
.dialog-eye svg { width:1rem; height:1rem; }
|
.dialog-eye svg { width:1rem; height:1rem; }
|
||||||
|
.dialog:not(:has(.val)) .dialog-eye { display:none; }
|
||||||
.dialog:has(.dialog-close) { position:relative; }
|
.dialog:has(.dialog-close) { position:relative; }
|
||||||
.dialog-close {
|
.dialog-close {
|
||||||
position:absolute; top:0.65rem; right:0.65rem; z-index:3;
|
position:absolute; top:0.65rem; right:0.65rem; z-index:3;
|
||||||
|
|||||||
+7
-3
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:13240cf3e3de47963beb3e5b97f3a5cb83beb4a9a635ce540f88a745df4d753d
|
// Generated, do not edit. sha256:a167e18e55f858b6191c5f1c50bdab5b3bf485873744837c0e096e77f639cd0e
|
||||||
import { nl } from './nl.js';
|
import { nl } from './nl.js';
|
||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
@@ -60,7 +60,7 @@ export const en = {
|
|||||||
|
|
||||||
license: {
|
license: {
|
||||||
lockedTitle: 'Part of the paid version',
|
lockedTitle: 'Part of the paid version',
|
||||||
counter: '{n} of {max}',
|
counter: '{naam} in the free edition: {n} of {max}',
|
||||||
whatIsPaid: 'What does the paid version include?',
|
whatIsPaid: 'What does the paid version include?',
|
||||||
why_develop: 'Development version: nothing is restricted below 1.0.0',
|
why_develop: 'Development version: nothing is restricted below 1.0.0',
|
||||||
why_forced: 'Forced via ASSETAPP_LICENSE_STATE (development only)',
|
why_forced: 'Forced via ASSETAPP_LICENSE_STATE (development only)',
|
||||||
@@ -210,6 +210,9 @@ export const en = {
|
|||||||
infoPrivacy: 'Your npub and the orders with it. An email address only if you provide one, and then '
|
infoPrivacy: 'Your npub and the orders with it. An email address only if you provide one, and then '
|
||||||
+ 'made unrecognizable, so it can only be matched if you come forward with it yourself.',
|
+ 'made unrecognizable, so it can only be matched if you come forward with it yourself.',
|
||||||
name_fiatAccounts: 'Fiat accounts',
|
name_fiatAccounts: 'Fiat accounts',
|
||||||
|
name_assets: 'Assets besides currencies',
|
||||||
|
name_valuations: 'Balances',
|
||||||
|
name_portfolios: 'Portfolios',
|
||||||
featTitle: 'What the editions include',
|
featTitle: 'What the editions include',
|
||||||
featFull: 'Full',
|
featFull: 'Full',
|
||||||
featFree: 'Free',
|
featFree: 'Free',
|
||||||
@@ -1219,7 +1222,7 @@ export const en = {
|
|||||||
groupBy: 'Group',
|
groupBy: 'Group',
|
||||||
standing: 'Current position',
|
standing: 'Current position',
|
||||||
toWealth: 'To Wealth',
|
toWealth: 'To Wealth',
|
||||||
standingNone: 'No tax-relevant positions yet.',
|
standingNone: 'No positions yet. Book a purchase or record a balance.',
|
||||||
showToday: 'Show return',
|
showToday: 'Show return',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2084,6 +2087,7 @@ export const en = {
|
|||||||
scanning: 'Scanning…',
|
scanning: 'Scanning…',
|
||||||
scanningTx: 'Fetching transactions',
|
scanningTx: 'Fetching transactions',
|
||||||
scanError: 'Update failed',
|
scanError: 'Update failed',
|
||||||
|
scanErrorWhy: 'Update failed: {reden}',
|
||||||
scanNotOnchain: 'Cannot be scanned on-chain',
|
scanNotOnchain: 'Cannot be scanned on-chain',
|
||||||
lightningMovements: 'Lightning transactions updated',
|
lightningMovements: 'Lightning transactions updated',
|
||||||
addressesScanned: 'addresses scanned',
|
addressesScanned: 'addresses scanned',
|
||||||
|
|||||||
+7
-3
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:5337809d822cab071fd28371d6976e564034e1f27296fd058188237da0027ce9
|
// Generated, do not edit. sha256:f025d672341c54d23ebad8e10876b424ac570cd500126582bf4b9c905f2cd672
|
||||||
export const nl = {
|
export const nl = {
|
||||||
app: { name: 'Atreides Assets' },
|
app: { name: 'Atreides Assets' },
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export const nl = {
|
|||||||
|
|
||||||
license: {
|
license: {
|
||||||
lockedTitle: 'Hoort bij de betaalde versie',
|
lockedTitle: 'Hoort bij de betaalde versie',
|
||||||
counter: '{n} van {max}',
|
counter: '{naam} in de gratis editie: {n} van {max}',
|
||||||
whatIsPaid: 'Wat zit er in de betaalde versie?',
|
whatIsPaid: 'Wat zit er in de betaalde versie?',
|
||||||
why_develop: 'Ontwikkelversie: er wordt niets beperkt onder 1.0.0',
|
why_develop: 'Ontwikkelversie: er wordt niets beperkt onder 1.0.0',
|
||||||
why_forced: 'Geforceerd via ASSETAPP_LICENSE_STATE (alleen bij ontwikkelen)',
|
why_forced: 'Geforceerd via ASSETAPP_LICENSE_STATE (alleen bij ontwikkelen)',
|
||||||
@@ -210,6 +210,9 @@ export const nl = {
|
|||||||
infoPrivacy: 'Uw npub en de orders erbij. Een e-mailadres alleen als u dat opgeeft, en dan onherkenbaar '
|
infoPrivacy: 'Uw npub en de orders erbij. Een e-mailadres alleen als u dat opgeeft, en dan onherkenbaar '
|
||||||
+ 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.',
|
+ 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.',
|
||||||
name_fiatAccounts: 'Fiatrekeningen',
|
name_fiatAccounts: 'Fiatrekeningen',
|
||||||
|
name_assets: 'Assets naast valuta',
|
||||||
|
name_valuations: 'Balansen',
|
||||||
|
name_portfolios: 'Portefeuilles',
|
||||||
featTitle: 'Wat de edities bieden',
|
featTitle: 'Wat de edities bieden',
|
||||||
featFull: 'Volledig',
|
featFull: 'Volledig',
|
||||||
featFree: 'Gratis',
|
featFree: 'Gratis',
|
||||||
@@ -1235,7 +1238,7 @@ export const nl = {
|
|||||||
groupBy: 'Groeperen',
|
groupBy: 'Groeperen',
|
||||||
standing: 'Huidige stand',
|
standing: 'Huidige stand',
|
||||||
toWealth: 'Naar Vermogen',
|
toWealth: 'Naar Vermogen',
|
||||||
standingNone: 'Nog geen tax-relevante posities.',
|
standingNone: 'Nog geen posities. Boek een aankoop of leg een balans vast.',
|
||||||
showToday: 'Rendement tonen',
|
showToday: 'Rendement tonen',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2108,6 +2111,7 @@ export const nl = {
|
|||||||
scanning: 'Scannen…',
|
scanning: 'Scannen…',
|
||||||
scanningTx: 'Transacties ophalen',
|
scanningTx: 'Transacties ophalen',
|
||||||
scanError: 'Bijwerken mislukt',
|
scanError: 'Bijwerken mislukt',
|
||||||
|
scanErrorWhy: 'Bijwerken mislukt: {reden}',
|
||||||
scanNotOnchain: 'Niet via de keten te scannen',
|
scanNotOnchain: 'Niet via de keten te scannen',
|
||||||
lightningMovements: 'lightning-bewegingen bijgewerkt',
|
lightningMovements: 'lightning-bewegingen bijgewerkt',
|
||||||
addressesScanned: 'adressen gescand',
|
addressesScanned: 'adressen gescand',
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:e2056779c9285b8b0c7aa5d702fc44b067694e358ed2b791b9b58447551ece12
|
// Generated, do not edit. sha256:1df2c5fd2e650856e801961b8f9b2f63fd3868b6f612cd0c41498384a12aea9b
|
||||||
import { licenseStatus } from './data/license-status.js';
|
import { licenseStatus } from './data/license-status.js';
|
||||||
import { getPref, setPref } from './data/preferences.js';
|
import { getPref, setPref } from './data/preferences.js';
|
||||||
import { t } from './i18n/index.js';
|
import { t } from './i18n/index.js';
|
||||||
@@ -24,7 +24,7 @@ export function limitFor(kind) {
|
|||||||
|
|
||||||
export function counterText(kind, current) {
|
export function counterText(kind, current) {
|
||||||
const max = limitFor(kind);
|
const max = limitFor(kind);
|
||||||
return max === null ? null : t('license.counter', { n: current, max });
|
return max === null ? null : t('license.counter', { naam: t(`license.name_${kind}`), n: current, max });
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREF_SCOPE = 'license';
|
const PREF_SCOPE = 'license';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:51ab737fb6604c8493f238a8893184408226a1a90ddd465fcd92bf0040b5a50e
|
// Generated, do not edit. sha256:9b4688b90b1d72ebe6c49262c3d23557d132d9066d8379a911157bbf3dae8ae8
|
||||||
import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js';
|
import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js';
|
||||||
import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js';
|
import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js';
|
||||||
import { listConnections } from '../data/connections.js';
|
import { listConnections } from '../data/connections.js';
|
||||||
@@ -102,7 +102,7 @@ async function buildTable(el) {
|
|||||||
tableApi = renderTable(el.querySelector('#tbl'), {
|
tableApi = renderTable(el.querySelector('#tbl'), {
|
||||||
columns, rows: rijen,
|
columns, rows: rijen,
|
||||||
...(limitFor('fiatAccounts') !== null
|
...(limitFor('fiatAccounts') !== null
|
||||||
? { toolbarNote: () => `${t('license.name_fiatAccounts')}: ${counterText('fiatAccounts', bankCount)}` } : {}),
|
? { toolbarNote: () => counterText('fiatAccounts', bankCount) } : {}),
|
||||||
...(await tableViewCfg('accounts', { key: 'name', dir: 'asc' })),
|
...(await tableViewCfg('accounts', { key: 'name', dir: 'asc' })),
|
||||||
emptyText: t('accounts.none'),
|
emptyText: t('accounts.none'),
|
||||||
noneLabel: t('table.none'),
|
noneLabel: t('table.none'),
|
||||||
@@ -317,6 +317,8 @@ async function mountSources(host, accountId, allowed) {
|
|||||||
};
|
};
|
||||||
let api = null;
|
let api = null;
|
||||||
const reloadSrc = async () => api.refresh(await listWalletSources(accountId));
|
const reloadSrc = async () => api.refresh(await listWalletSources(accountId));
|
||||||
|
const rows = await listWalletSources(accountId);
|
||||||
|
const nodeColumn = !locked('lightning') || rows.some(r => r.connectionId != null);
|
||||||
|
|
||||||
api = renderTable(host.querySelector('[data-src-table]'), {
|
api = renderTable(host.querySelector('[data-src-table]'), {
|
||||||
title: t('accounts.sources'),
|
title: t('accounts.sources'),
|
||||||
@@ -348,8 +350,8 @@ async function mountSources(host, accountId, allowed) {
|
|||||||
{ key: 'scriptType', label: t('accounts.colScriptType'),
|
{ key: 'scriptType', label: t('accounts.colScriptType'),
|
||||||
render: s => s.kind === 'xpub' ? t('accounts.script_' + s.scriptType) : '<span class="t-muted-cell">-</span>',
|
render: s => s.kind === 'xpub' ? t('accounts.script_' + s.scriptType) : '<span class="t-muted-cell">-</span>',
|
||||||
edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } },
|
edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } },
|
||||||
],
|
].filter(c => nodeColumn || c.key !== 'connectionId'),
|
||||||
rows: await listWalletSources(accountId),
|
rows,
|
||||||
...(await tableViewCfg('accounts.sources', { key: 'name', dir: 'asc' })),
|
...(await tableViewCfg('accounts.sources', { key: 'name', dir: 'asc' })),
|
||||||
emptyText: t('accounts.noSources'),
|
emptyText: t('accounts.noSources'),
|
||||||
newLabel: t('table.new'),
|
newLabel: t('table.new'),
|
||||||
|
|||||||
+12
-5
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:7de3dc53eab20f070a6ac8c7162fee1bd0695f724c6a13e314a994181782906e
|
// Generated, do not edit. sha256:26e9a2b26a7c0fa9e0b6979080609ba55baa351ce8440240a006def072475893
|
||||||
import { listAssets, listBookableAssets, createAsset, updateAsset, deleteAsset } from '../data/assets.js';
|
import { listAssets, listBookableAssets, createAsset, updateAsset, deleteAsset } from '../data/assets.js';
|
||||||
import { hasMetalAssets } from '../data/metals.js';
|
import { hasMetalAssets } from '../data/metals.js';
|
||||||
import { tableViewCfg } from '../data/preferences.js';
|
import { tableViewCfg } from '../data/preferences.js';
|
||||||
@@ -132,7 +132,14 @@ async function buildTable(el) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const rijen = await listAssets();
|
const nieuw = new Set();
|
||||||
|
const zichtbaar = async () => {
|
||||||
|
const b = await listBookableAssets();
|
||||||
|
if (!nieuw.size) return b;
|
||||||
|
const ids = new Set(b.map(a => a.id));
|
||||||
|
return [...b, ...(await listAssets()).filter(a => nieuw.has(a.id) && !ids.has(a.id))];
|
||||||
|
};
|
||||||
|
const rijen = await zichtbaar();
|
||||||
const bookable = async () => (await listBookableAssets()).filter(r => r.symbol !== baseCurrency()).length;
|
const bookable = async () => (await listBookableAssets()).filter(r => r.symbol !== baseCurrency()).length;
|
||||||
let used = await bookable();
|
let used = await bookable();
|
||||||
tableApi = renderTable(el.querySelector('#tbl'), {
|
tableApi = renderTable(el.querySelector('#tbl'), {
|
||||||
@@ -145,11 +152,11 @@ async function buildTable(el) {
|
|||||||
newLabel: t('table.new'),
|
newLabel: t('table.new'),
|
||||||
newDefaults: { kind: 'crypto', decimals: '', displayUnit: 'unit', taxCategory: '', taxScope: '', balanceCapture: '' },
|
newDefaults: { kind: 'crypto', decimals: '', displayUnit: 'unit', taxCategory: '', taxScope: '', balanceCapture: '' },
|
||||||
saveLabel: t('assets.save'), cancelLabel: t('assets.cancel'),
|
saveLabel: t('assets.save'), cancelLabel: t('assets.cancel'),
|
||||||
onCreate: async v => { const id = await createAsset(toPayload(v)); used = await bookable(); tableApi.refresh(await listAssets(), { locate: id }); },
|
onCreate: async v => { const id = await createAsset(toPayload(v)); nieuw.add(id); used = await bookable(); tableApi.refresh(await zichtbaar(), { locate: id }); },
|
||||||
onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await listAssets()); },
|
onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await zichtbaar()); },
|
||||||
onDelete: async id => {
|
onDelete: async id => {
|
||||||
if (!(await dialog.confirm({ message: t('assets.confirmDelete'), danger: true }))) return;
|
if (!(await dialog.confirm({ message: t('assets.confirmDelete'), danger: true }))) return;
|
||||||
try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await listAssets()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
|
try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await zichtbaar()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
tableApi.focusRow(takeRowFocus('assets'));
|
tableApi.focusRow(takeRowFocus('assets'));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:fcc1131e05f7794fbe4f303c8c1237f96201e5ee9ed17491fd18d55b64828d40
|
// Generated, do not edit. sha256:bcb22b0408193ecc7c34d5e55a86c15a89be89fdd2d96b8d21f03771f4179be1
|
||||||
import { localDate } from '../../shared/time.js';
|
import { localDate } from '../../shared/time.js';
|
||||||
import { listAccounts } from '../data/accounts.js';
|
import { listAccounts } from '../data/accounts.js';
|
||||||
import { listLabels, createLabel } from '../data/labels.js';
|
import { listLabels, createLabel } from '../data/labels.js';
|
||||||
@@ -553,18 +553,18 @@ async function scanAll(el) {
|
|||||||
const r = await scanLightningSource(s);
|
const r = await scanLightningSource(s);
|
||||||
ln += r.count;
|
ln += r.count;
|
||||||
if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]);
|
if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]);
|
||||||
} catch (err) { console.error('[lightning-scan]', err); error = true; }
|
} catch (err) { console.error('[lightning-scan]', err); error = err.message || true; }
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isOnchainSource(s)) { geslagen++; continue; }
|
if (!isOnchainSource(s)) { geslagen++; continue; }
|
||||||
try {
|
try {
|
||||||
const r = await scanOne(el, s, g.id);
|
const r = await scanOne(el, s, g.id);
|
||||||
count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0);
|
count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0);
|
||||||
} catch (err) { console.error('[onchain-scan-all]', err); error = true; }
|
} catch (err) { console.error('[onchain-scan-all]', err); error = err.message || true; }
|
||||||
}
|
}
|
||||||
const skipped = geslagen && !lnBronnen && !count && !failed;
|
const skipped = geslagen && !lnBronnen && !count && !failed;
|
||||||
if (error) nMislukt++; else if (!skipped) nWallets++;
|
if (error) nMislukt++; else if (!skipped) nWallets++;
|
||||||
results[g.id] = error ? { error: true } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen });
|
results[g.id] = error ? { error } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen });
|
||||||
}
|
}
|
||||||
if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err));
|
if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err));
|
||||||
for (const [accountId, txids] of kanalen) {
|
for (const [accountId, txids] of kanalen) {
|
||||||
@@ -582,7 +582,8 @@ async function scanAll(el) {
|
|||||||
zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt));
|
zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt));
|
||||||
for (const id in results) {
|
for (const id in results) {
|
||||||
const r = results[id];
|
const r = results[id];
|
||||||
if (r.error) setLogRow(el, id, 't-neg', t('tracking.scanError'));
|
if (r.error) setLogRow(el, id, 't-neg', typeof r.error === 'string'
|
||||||
|
? t('tracking.scanErrorWhy', { reden: r.error }) : t('tracking.scanError'));
|
||||||
else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain'));
|
else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain'));
|
||||||
else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r));
|
else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
// Generated, do not edit. sha256:4bf6f8ef0a0bf339d290a677844034b6b894f341f060833980b2dc5ccb489b09
|
// Generated, do not edit. sha256:d2bc926ea0bee8d8465cb325428dc8ec0f61a809df8e09696d2ce30fb50111c2
|
||||||
import { esc } from './escape.js';
|
import { esc } from './escape.js';
|
||||||
import { SHIELD } from './icons.js';
|
import { SHIELD } from './icons.js';
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
import { TARGETS, targetHash } from '../../shared/config.js';
|
import { TARGETS, targetHash } from '../../shared/config.js';
|
||||||
import { limitFor } from '../license.js';
|
import { limitFor, counterText } from '../license.js';
|
||||||
|
|
||||||
export function lockIcon(title = t('license.lockedTitle')) {
|
export function lockIcon(title = t('license.lockedTitle')) {
|
||||||
return `<span class="lock-icon" title="${esc(title)}" aria-label="${esc(title)}">${SHIELD}</span>`;
|
return `<span class="lock-icon" title="${esc(title)}" aria-label="${esc(title)}">${SHIELD}</span>`;
|
||||||
@@ -31,7 +31,7 @@ export function capacityCfg(kind, used) {
|
|||||||
const max = limitFor(kind);
|
const max = limitFor(kind);
|
||||||
if (max === null) return {};
|
if (max === null) return {};
|
||||||
return {
|
return {
|
||||||
toolbarNote: () => t('license.counter', { n: used(), max }),
|
toolbarNote: () => counterText(kind, used()),
|
||||||
createHidden: () => used() >= max,
|
createHidden: () => used() >= max,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:a433ae61cf1d51dd5a396f13922a2aaa329bfea96e00b35d9456a671a84c5e04
|
// Generated, do not edit. sha256:45c5f1058c49379b8b6b1e8f53d554bf4f5826219c9349649a60020eb36c8064
|
||||||
import { CONFIG } from '../../shared/config.js';
|
import { CONFIG } from '../../shared/config.js';
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
@@ -24,10 +24,10 @@ let typeTimer = 0;
|
|||||||
|
|
||||||
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
function fadeOut(splash) {
|
function fadeOut(splash, after = () => {}) {
|
||||||
splash.style.transitionDuration = `${CONFIG.SPLASH.FADE_MS}ms`;
|
splash.style.transitionDuration = `${CONFIG.SPLASH.FADE_MS}ms`;
|
||||||
splash.classList.add('is-done');
|
splash.classList.add('is-done');
|
||||||
setTimeout(() => reset(splash), CONFIG.SPLASH.FADE_MS + 50);
|
setTimeout(() => { reset(splash); after(); }, CONFIG.SPLASH.FADE_MS + 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset(splash) {
|
function reset(splash) {
|
||||||
@@ -41,9 +41,9 @@ function reset(splash) {
|
|||||||
|
|
||||||
export function finishSplash() {
|
export function finishSplash() {
|
||||||
const splash = document.getElementById('splash');
|
const splash = document.getElementById('splash');
|
||||||
if (!splash || document.documentElement.dataset.splash !== 'on') return;
|
if (!splash || document.documentElement.dataset.splash !== 'on') return Promise.resolve();
|
||||||
const wait = Math.max(0, CONFIG.SPLASH.MIN_MS - (performance.now() - bootT0));
|
const wait = Math.max(0, CONFIG.SPLASH.MIN_MS - (performance.now() - bootT0));
|
||||||
setTimeout(() => fadeOut(splash), wait);
|
return new Promise(resolve => setTimeout(() => fadeOut(splash, resolve), wait));
|
||||||
}
|
}
|
||||||
|
|
||||||
function showManualSplash() {
|
function showManualSplash() {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "assetapp",
|
"name": "assetapp",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "assetapp",
|
"name": "assetapp",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "2.4.0"
|
"@noble/curves": "2.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "assetapp",
|
"name": "assetapp",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:31a0448818ec43a013271278ccfca034b12a6cd78e023ce301c87ce6d267b944
|
// Generated, do not edit. sha256:30f785c6890a03397971aad68496979bd9657deab8cb4a7a61fd0569a7cf87a8
|
||||||
import { randomUUID, createHash } from 'node:crypto';
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
@@ -342,18 +342,23 @@ export function createMarketClient({
|
|||||||
const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to });
|
const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to });
|
||||||
rid = requestId;
|
rid = requestId;
|
||||||
const cols = Array.isArray(json.columns) ? json.columns : [];
|
const cols = Array.isArray(json.columns) ? json.columns : [];
|
||||||
const [ti, pi, ri] = ['t', 'price', 'resolution'].map(c => cols.indexOf(c));
|
const [ti, pi, ri, si] = ['t', 'price', 'resolution', 'source'].map(c => cols.indexOf(c));
|
||||||
if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) {
|
if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) {
|
||||||
fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.');
|
fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.');
|
||||||
}
|
}
|
||||||
if (meta && json.currency !== meta.currency) {
|
if (meta && json.currency !== meta.currency) {
|
||||||
fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.');
|
fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.');
|
||||||
}
|
}
|
||||||
meta ??= { instrument, currency: json.currency, source: json.source || null };
|
meta ??= { instrument, currency: json.currency, source: json.source || null, sourceLabels: {} };
|
||||||
|
if (json.sourceLabels && typeof json.sourceLabels === 'object') {
|
||||||
|
for (const [key, label] of Object.entries(json.sourceLabels)) if (typeof label === 'string' && label) meta.sourceLabels[key] = label;
|
||||||
|
}
|
||||||
for (const r of json.rows) {
|
for (const r of json.rows) {
|
||||||
const t = Number(r?.[ti]);
|
const t = Number(r?.[ti]);
|
||||||
const p = Number(r?.[pi]);
|
const p = Number(r?.[pi]);
|
||||||
if (Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0) points.push([t, p, r[ri]]);
|
if (!(Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0)) continue;
|
||||||
|
const src = si >= 0 && typeof r[si] === 'string' && r[si] ? r[si] : null;
|
||||||
|
points.push(src ? [t, p, r[ri], src] : [t, p, r[ri]]);
|
||||||
}
|
}
|
||||||
if (json.next == null || !(Number(json.next) > cursor)) break;
|
if (json.next == null || !(Number(json.next) > cursor)) break;
|
||||||
cursor = Number(json.next);
|
cursor = Number(json.next);
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// Generated, do not edit. sha256:b010e85d240007ea64c04ad8392259f493d1479d73de4754cba27fd27e215811
|
||||||
|
import { describe, it, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createRequestListener } from '../../server/app.mjs';
|
||||||
|
import {
|
||||||
|
passwordMatches, parseCookies, sessionCookie, clearedSessionCookie, sessionTokenOf, resolveBinding,
|
||||||
|
} from '../../server/auth.mjs';
|
||||||
|
import { createSessionStore, createAttemptLimiter } from '../../server/sessions.mjs';
|
||||||
|
import { CONFIG } from '../../shared/config.js';
|
||||||
|
|
||||||
|
const GELDIG = { password: 'geheim' };
|
||||||
|
|
||||||
|
function serverMet(opties) {
|
||||||
|
const ctx = {};
|
||||||
|
before(async () => {
|
||||||
|
ctx.server = createServer(createRequestListener(opties));
|
||||||
|
await new Promise(klaar => ctx.server.listen(0, '127.0.0.1', klaar));
|
||||||
|
ctx.basis = `http://127.0.0.1:${ctx.server.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => new Promise(klaar => ctx.server.close(klaar)));
|
||||||
|
ctx.haal = (pad, init = {}) => fetch(`${ctx.basis}${pad}`, { redirect: 'manual', ...init });
|
||||||
|
ctx.login = (wachtwoord) => ctx.haal('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: wachtwoord }),
|
||||||
|
});
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieUit = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
|
||||||
|
|
||||||
|
describe('passwordMatches', () => {
|
||||||
|
it('accepteert het juiste wachtwoord en weigert een verkeerd', () => {
|
||||||
|
assert.equal(passwordMatches('geheim', GELDIG), true);
|
||||||
|
assert.equal(passwordMatches('fout', GELDIG), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weigert alles zodra er niets is ingesteld of het ingestelde leeg is', () => {
|
||||||
|
assert.equal(passwordMatches('', { password: '' }), false);
|
||||||
|
assert.equal(passwordMatches('watdanook', { password: '' }), false);
|
||||||
|
assert.equal(passwordMatches('watdanook', null), false);
|
||||||
|
assert.equal(passwordMatches(null, GELDIG), false);
|
||||||
|
assert.equal(passwordMatches(undefined, GELDIG), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepteert geen niet-string die toevallig gelijk oogt', () => {
|
||||||
|
assert.equal(passwordMatches({ toString: () => 'geheim' }, GELDIG), false);
|
||||||
|
assert.equal(passwordMatches(['geheim'], GELDIG), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behandelt niet-ASCII en dubbele punten gewoon als tekens', () => {
|
||||||
|
assert.equal(passwordMatches('a:b:c', { password: 'a:b:c' }), true);
|
||||||
|
assert.equal(passwordMatches('wachtwoord€', { password: 'wachtwoord€' }), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseCookies', () => {
|
||||||
|
it('leest een enkel cookie en meerdere naast elkaar', () => {
|
||||||
|
assert.deepEqual(parseCookies('a=1'), { a: '1' });
|
||||||
|
assert.deepEqual(parseCookies('a=1; b=2'), { a: '1', b: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slaat brokken zonder = over in plaats van om te vallen', () => {
|
||||||
|
assert.deepEqual(parseCookies('rommel; a=1'), { a: '1' });
|
||||||
|
assert.deepEqual(parseCookies(''), {});
|
||||||
|
assert.deepEqual(parseCookies(undefined), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat de EERSTE waarde winnen bij een dubbele naam', () => {
|
||||||
|
assert.equal(parseCookies('s=echt; s=vals').s, 'echt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat een waarde met een = erin heel', () => {
|
||||||
|
assert.equal(parseCookies('s=a=b').s, 'a=b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('het sessiecookie', () => {
|
||||||
|
it('draagt HttpOnly, SameSite en een Max-Age die bij de sessie past', () => {
|
||||||
|
const c = sessionCookie('abc');
|
||||||
|
assert.match(c, /^assetapp_session=abc;/);
|
||||||
|
assert.match(c, /HttpOnly/);
|
||||||
|
assert.match(c, /SameSite=Lax/);
|
||||||
|
assert.match(c, new RegExp(`Max-Age=${Math.floor(CONFIG.AUTH.SESSION_TTL_MS / 1000)}\\b`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('krijgt Secure alleen mét TLS', () => {
|
||||||
|
assert.doesNotMatch(sessionCookie('abc', { secure: false }), /Secure/);
|
||||||
|
assert.match(sessionCookie('abc', { secure: true }), /Secure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haalt zichzelf weg met dezelfde vlaggen als waarmee hij gezet is', () => {
|
||||||
|
const gezet = sessionCookie('abc', { secure: true });
|
||||||
|
const weg = clearedSessionCookie({ secure: true });
|
||||||
|
const vlaggenVan = (c) => c.split(';').slice(1).map(d => d.trim()).filter(d => !d.startsWith('Max-Age'));
|
||||||
|
assert.deepEqual(vlaggenVan(weg), vlaggenVan(gezet));
|
||||||
|
assert.match(weg, /Max-Age=0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leest het token terug uit een verzoek, en geeft null zonder cookie', () => {
|
||||||
|
assert.equal(sessionTokenOf({ headers: { cookie: 'assetapp_session=xyz' } }), 'xyz');
|
||||||
|
assert.equal(sessionTokenOf({ headers: { cookie: 'iets=anders' } }), null);
|
||||||
|
assert.equal(sessionTokenOf({ headers: {} }), null);
|
||||||
|
assert.equal(sessionTokenOf(undefined), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSessionStore', () => {
|
||||||
|
it('geeft een vers token dat geldig is, en twee keer niet hetzelfde', () => {
|
||||||
|
const s = createSessionStore();
|
||||||
|
const a = s.create();
|
||||||
|
const b = s.create();
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
assert.equal(s.isValid(a), true);
|
||||||
|
assert.equal(s.isValid(b), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weigert een token dat hij nooit heeft uitgegeven', () => {
|
||||||
|
const s = createSessionStore();
|
||||||
|
s.create();
|
||||||
|
assert.equal(s.isValid('a'.repeat(64)), false);
|
||||||
|
assert.equal(s.isValid(''), false);
|
||||||
|
assert.equal(s.isValid(undefined), false);
|
||||||
|
assert.equal(s.isValid(null), false);
|
||||||
|
assert.equal(s.isValid(123), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat een token vervallen zodra de tijd om is', () => {
|
||||||
|
let nu = 1000;
|
||||||
|
const s = createSessionStore({ ttlMs: 100, now: () => nu });
|
||||||
|
const token = s.create();
|
||||||
|
|
||||||
|
nu = 1099;
|
||||||
|
assert.equal(s.isValid(token), true, 'net vóór de grens telt hij nog');
|
||||||
|
nu = 1100;
|
||||||
|
assert.equal(s.isValid(token), false, 'op de grens is hij voorbij');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlengt een sessie NIET door hem te gebruiken', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const s = createSessionStore({ ttlMs: 100, now: () => nu });
|
||||||
|
const token = s.create();
|
||||||
|
for (nu = 10; nu < 100; nu += 10) assert.equal(s.isValid(token), true);
|
||||||
|
nu = 100;
|
||||||
|
assert.equal(s.isValid(token), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ruimt een verlopen token op in plaats van hem te laten liggen', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const s = createSessionStore({ ttlMs: 100, now: () => nu });
|
||||||
|
s.create();
|
||||||
|
nu = 200;
|
||||||
|
s.create();
|
||||||
|
assert.equal(s.size(), 1, 'de verlopen sessie hoort bij het aanmaken opgeruimd te zijn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beëindigt een sessie met drop, en een onbekend token is geen fout', () => {
|
||||||
|
const s = createSessionStore();
|
||||||
|
const token = s.create();
|
||||||
|
assert.equal(s.drop(token), true);
|
||||||
|
assert.equal(s.isValid(token), false);
|
||||||
|
assert.equal(s.drop('bestaat-niet'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deelt niets tussen twee opslagen', () => {
|
||||||
|
const a = createSessionStore();
|
||||||
|
const b = createSessionStore();
|
||||||
|
assert.equal(b.isValid(a.create()), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAttemptLimiter', () => {
|
||||||
|
it('laat de eerste pogingen vrij en gaat daarna oplopend wachten', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const l = createAttemptLimiter({ free: 3, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||||
|
const adres = '127.0.0.1';
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) { l.fail(adres); assert.equal(l.retryAfterMs(adres), 0); }
|
||||||
|
assert.equal(l.fail(adres), 100, 'de vierde misser kost de basiswachttijd');
|
||||||
|
assert.equal(l.fail(adres), 200, 'en daarna verdubbelt het');
|
||||||
|
assert.equal(l.fail(adres), 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat de wachttijd aflopen met de tijd', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||||
|
l.fail('a');
|
||||||
|
assert.equal(l.retryAfterMs('a'), 100);
|
||||||
|
nu = 60;
|
||||||
|
assert.equal(l.retryAfterMs('a'), 40);
|
||||||
|
nu = 100;
|
||||||
|
assert.equal(l.retryAfterMs('a'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zet de teller NIET terug door de wachttijd uit te zitten', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||||
|
l.fail('a');
|
||||||
|
nu = 100;
|
||||||
|
assert.equal(l.retryAfterMs('a'), 0);
|
||||||
|
assert.equal(l.fail('a'), 200, 'de volgende misser bouwt voort op de vorige');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gaat niet boven het maximum uit', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu });
|
||||||
|
for (let i = 0; i < 20; i++) l.fail('a');
|
||||||
|
assert.equal(l.retryAfterMs('a'), 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telt per adres en niet over adressen heen', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||||
|
l.fail('a'); l.fail('a'); l.fail('a');
|
||||||
|
assert.equal(l.retryAfterMs('b'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergeet een adres na een geslaagde poging', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 10000, now: () => nu });
|
||||||
|
l.fail('a');
|
||||||
|
l.succeed('a');
|
||||||
|
assert.equal(l.retryAfterMs('a'), 0);
|
||||||
|
assert.equal(l.size(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergeet een adres dat lang genoeg stil is, zodat de lijst niet eindeloos groeit', () => {
|
||||||
|
let nu = 0;
|
||||||
|
const l = createAttemptLimiter({ free: 0, baseLockMs: 100, maxLockMs: 250, now: () => nu });
|
||||||
|
l.fail('a');
|
||||||
|
nu = 100;
|
||||||
|
assert.equal(l.size(), 1, 'net uitgezeten is nog niet vergeten');
|
||||||
|
nu = 100000;
|
||||||
|
l.retryAfterMs('a');
|
||||||
|
assert.equal(l.size(), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveBinding', () => {
|
||||||
|
it('bindt op alle interfaces zodra TLS én inloggegevens er zijn', () => {
|
||||||
|
assert.deepEqual(resolveBinding({ tls: true, credentials: GELDIG }), { host: '0.0.0.0', waarschuwing: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bindt op loopback zolang een van de twee ontbreekt', () => {
|
||||||
|
assert.equal(resolveBinding({ tls: false, credentials: GELDIG }).host, '127.0.0.1');
|
||||||
|
assert.equal(resolveBinding({ tls: true, credentials: null }).host, '127.0.0.1');
|
||||||
|
assert.equal(resolveBinding({}).host, '127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('volgt een expliciete host, maar waarschuwt als die onbeschermd buiten loopback ligt', () => {
|
||||||
|
const uit = resolveBinding({ tls: false, credentials: null, host: '0.0.0.0' });
|
||||||
|
assert.equal(uit.host, '0.0.0.0');
|
||||||
|
assert.match(uit.waarschuwing, /TLS en inloggegevens/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('noemt in de waarschuwing alleen wat er werkelijk ontbreekt', () => {
|
||||||
|
const uit = resolveBinding({ tls: true, credentials: null, host: '192.168.1.50' });
|
||||||
|
assert.match(uit.waarschuwing, /inloggegevens ontbreekt/);
|
||||||
|
assert.doesNotMatch(uit.waarschuwing, /TLS/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waarschuwt niet bij een expliciete loopback-host', () => {
|
||||||
|
for (const host of ['127.0.0.1', '::1', 'localhost']) {
|
||||||
|
assert.equal(resolveBinding({ tls: false, credentials: null, host }).waarschuwing, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRequestListener: alles zit achter de sessiecontrole', () => {
|
||||||
|
const ctx = serverMet({ credentials: GELDIG });
|
||||||
|
|
||||||
|
it('weigert /api/health zonder cookie, met een kale 401', async () => {
|
||||||
|
const r = await ctx.haal('/api/health');
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stuurt op een DOCUMENTverzoek om naar het inlogscherm', async () => {
|
||||||
|
const r = await ctx.haal('/');
|
||||||
|
assert.equal(r.status, 302);
|
||||||
|
assert.equal(r.headers.get('location'), CONFIG.AUTH.LOGIN_PATH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stuurt op een API-verzoek NIET om, want fetch zou die omleiding volgen', async () => {
|
||||||
|
const r = await ctx.haal('/api/data/assets/listAssets', { method: 'POST' });
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
assert.equal(r.headers.get('location'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weigert een onbekend /api-pad ook vóór de router, dus zonder 404 te verklappen', async () => {
|
||||||
|
const r = await ctx.haal('/api/bestaat-niet');
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draagt NERGENS een WWW-Authenticate-header', async () => {
|
||||||
|
for (const pad of ['/', '/api/health', '/api/bestaat-niet', '/css/styles.css']) {
|
||||||
|
const r = await ctx.haal(pad);
|
||||||
|
assert.equal(r.headers.get('www-authenticate'), null, `${pad} draagt de header nog`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRequestListener: inloggen en uitloggen', () => {
|
||||||
|
const ctx = serverMet({ credentials: GELDIG });
|
||||||
|
|
||||||
|
it('weigert een verkeerd wachtwoord zonder cookie te zetten', async () => {
|
||||||
|
const r = await ctx.login('fout');
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
assert.equal(r.headers.get('set-cookie'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zet bij het juiste wachtwoord een HttpOnly-sessiecookie', async () => {
|
||||||
|
const r = await ctx.login('geheim');
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const cookie = r.headers.get('set-cookie');
|
||||||
|
assert.match(cookie, /^assetapp_session=[0-9a-f]{64};/);
|
||||||
|
assert.match(cookie, /HttpOnly/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opent daarmee de app én de API', async () => {
|
||||||
|
const cookie = cookieUit(await ctx.login('geheim'));
|
||||||
|
const app = await ctx.haal('/', { headers: { cookie } });
|
||||||
|
assert.equal(app.status, 200);
|
||||||
|
const api = await ctx.haal('/api/health', { headers: { cookie } });
|
||||||
|
assert.equal(api.status, 200);
|
||||||
|
assert.deepEqual(await api.json(), { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weigert een verzonnen cookie', async () => {
|
||||||
|
const r = await ctx.haal('/api/health', { headers: { cookie: `assetapp_session=${'a'.repeat(64)}` } });
|
||||||
|
assert.equal(r.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maakt het cookie ongeldig bij uitloggen, aan beide kanten', async () => {
|
||||||
|
const cookie = cookieUit(await ctx.login('geheim'));
|
||||||
|
const uit = await ctx.haal('/api/logout', { method: 'POST', headers: { cookie } });
|
||||||
|
|
||||||
|
assert.equal(uit.status, 200);
|
||||||
|
assert.match(uit.headers.get('set-cookie'), /Max-Age=0/, 'de browser hoort het cookie weg te gooien');
|
||||||
|
const na = await ctx.haal('/api/health', { headers: { cookie } });
|
||||||
|
assert.equal(na.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stuurt een ingelogde bezoeker van het inlogscherm door naar de app', async () => {
|
||||||
|
const cookie = cookieUit(await ctx.login('geheim'));
|
||||||
|
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH, { headers: { cookie } });
|
||||||
|
assert.equal(r.status, 302);
|
||||||
|
assert.equal(r.headers.get('location'), '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weigert GET op de inlog- en uitlogroute', async () => {
|
||||||
|
assert.equal((await ctx.haal('/api/login')).status, 405);
|
||||||
|
assert.equal((await ctx.haal('/api/logout')).status, 405);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat het formulier ook zonder JavaScript geen wachtwoord in de URL zetten', async () => {
|
||||||
|
const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8');
|
||||||
|
const form = /<form[^>]*id="login-form"[^>]*>/.exec(html);
|
||||||
|
assert.ok(form, 'geen formulier met id login-form gevonden');
|
||||||
|
assert.match(form[0], /method="post"/i);
|
||||||
|
assert.match(form[0], /action="\/api\/login"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weigert een body die geen JSON is, zonder de teller te verstoren', async () => {
|
||||||
|
const r = await ctx.haal('/api/login', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'geen json',
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal((await ctx.login('geheim')).status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRequestListener: de uitzondering voor het inlogscherm', () => {
|
||||||
|
const ctx = serverMet({ credentials: GELDIG });
|
||||||
|
|
||||||
|
it('laat het inlogscherm zelf zien', async () => {
|
||||||
|
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.match(r.headers.get('content-type'), /text\/html/);
|
||||||
|
assert.match(await r.text(), /login-form/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat ELK bestand door dat login.html noemt', async () => {
|
||||||
|
const html = readFileSync(new URL('../../client/login.html', import.meta.url), 'utf8');
|
||||||
|
const verwijzingen = [...html.matchAll(/(?:src|href)\s*=\s*"([^"]+)"/g)].map(m => m[1])
|
||||||
|
.filter(pad => !/^(https?:|data:|#|\/\/)/.test(pad));
|
||||||
|
assert.ok(verwijzingen.length >= 3, `verwacht minstens drie verwijzingen, gevonden ${verwijzingen.length}`);
|
||||||
|
|
||||||
|
for (const pad of verwijzingen) {
|
||||||
|
assert.equal((await ctx.haal(`/${pad}`)).status, 200, `/${pad} staat in login.html maar is dicht`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat de lettertypebestanden door waar die stylesheet naar wijst', async () => {
|
||||||
|
const css = readFileSync(new URL('../../client/vendor/inter/inter.css', import.meta.url), 'utf8');
|
||||||
|
const eerste = /url\(\.\/(files\/[^)]+)\)/.exec(css);
|
||||||
|
assert.ok(eerste, 'geen enkel woff2-bestand gevonden in inter.css');
|
||||||
|
assert.equal((await ctx.haal(`/vendor/inter/${eerste[1]}`)).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('houdt de app-schil dicht, en dat is de reden dat dit een eigen pagina is', async () => {
|
||||||
|
for (const pad of ['/index.html', '/app.js', '/boot-gate.js', '/shared/config.js',
|
||||||
|
'/portfolio.js', '/i18n/nl.js', '/data/_rpc.js']) {
|
||||||
|
const r = await ctx.haal(pad);
|
||||||
|
assert.equal(r.status, 302, `${pad} is bereikbaar zonder in te loggen`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat geen ander bestand uit de font-map van de app door dan de fonts zelf', async () => {
|
||||||
|
assert.equal((await ctx.haal('/vendor/crypto/noble-scure.js')).status, 302);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laat de uitzondering niet door een gecodeerd pad misbruiken', async () => {
|
||||||
|
for (const pad of ['/css/%2e%2e/app.js', '/vendor/inter/files/%2e%2e/%2e%2e/%2e%2e/app.js']) {
|
||||||
|
const r = await ctx.haal(pad);
|
||||||
|
assert.equal(r.status, 302, `${pad} kwam langs de uitzondering`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRequestListener: de pogingenlimiet', () => {
|
||||||
|
const ctx = serverMet({ credentials: GELDIG });
|
||||||
|
|
||||||
|
it('gaat na een reeks missers wachten en meldt hoe lang', async () => {
|
||||||
|
let laatste;
|
||||||
|
for (let i = 0; i < CONFIG.AUTH.LOGIN_ATTEMPTS.FREE + 1; i++) laatste = await ctx.login('fout');
|
||||||
|
|
||||||
|
const json = await laatste.json();
|
||||||
|
assert.equal(laatste.status, 401);
|
||||||
|
assert.ok(json.retryAfterMs > 0, 'de wachttijd hoort in het antwoord te staan');
|
||||||
|
|
||||||
|
const geblokkeerd = await ctx.login('geheim');
|
||||||
|
assert.equal(geblokkeerd.status, 429);
|
||||||
|
assert.ok(Number(geblokkeerd.headers.get('retry-after')) >= 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRequestListener zonder inloggegevens: bewust open', () => {
|
||||||
|
const ctx = serverMet({});
|
||||||
|
|
||||||
|
it('serveert zonder cookie, want dit is de modus die resolveBinding op loopback houdt', async () => {
|
||||||
|
assert.equal((await ctx.haal('/api/health')).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toont geen inlogscherm, want er valt niets te ontsluiten', async () => {
|
||||||
|
const r = await ctx.haal(CONFIG.AUTH.LOGIN_PATH);
|
||||||
|
assert.equal(r.status, 302);
|
||||||
|
assert.equal(r.headers.get('location'), '/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:0218bafc8f02c1e9ace72164cc21b81de083223f11fc50e42ec7fedf10c23058
|
// Generated, do not edit. sha256:90b8d22d967cee3d2817f9d1b730418df66b8dba385cbdf2b3f5d3091a0a67ef
|
||||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
||||||
@@ -263,6 +263,16 @@ describe('createMarketClient: de reeks (fase 2)', () => {
|
|||||||
await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
|
await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.badAnswer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('de historie neemt de bron per rij mee als vierde element, met de naam per bronsleutel', async () => {
|
||||||
|
const { c } = client({ '/v1/series': () => json({
|
||||||
|
instrument: 'crypto:BTC', currency: 'EUR', source: 'bitvavo', kind: 'history',
|
||||||
|
columns: ['t', 'price', 'resolution', 'source'], sourceLabels: { bitvavo: 'Een Nederlandse aanbieder' },
|
||||||
|
rows: [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day', null]], next: null }) });
|
||||||
|
const uit = await c.history({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO });
|
||||||
|
assert.deepEqual(uit.points, [[FROM, 61000, 'day', 'bitvavo'], [FROM + 86_400_000, 61010, 'day']]);
|
||||||
|
assert.deepEqual(uit.sourceLabels, { bitvavo: 'Een Nederlandse aanbieder' });
|
||||||
|
});
|
||||||
|
|
||||||
it('503 api.monthUnreachable op een reeks: later opnieuw, met de maand', async () => {
|
it('503 api.monthUnreachable op een reeks: later opnieuw, met de maand', async () => {
|
||||||
const { c } = client({ '/v1/series': () => json({ error: 'x', key: 'api.monthUnreachable', month: '2026-09' }, 503) });
|
const { c } = client({ '/v1/series': () => json({ error: 'x', key: 'api.monthUnreachable', month: '2026-09' }, 503) });
|
||||||
const e = await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.monthUnreachable');
|
const e = await rejectsWith(c.series({ instrument: 'crypto:BTC', quote: 'EUR', from: FROM, to: TO }), 'marketService.monthUnreachable');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated, do not edit. sha256:6129a8bbffc98b951f97ab835b37848de841b315ab86079ee5c45996f7fe3fe5
|
// Generated, do not edit. sha256:48e4432f919c88e3f0adaad52e46a88287459c06124c6a46c43d717bfbc8d8d8
|
||||||
import { describe, it, beforeEach, mock } from 'node:test';
|
import { describe, it, beforeEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
@@ -27,8 +27,10 @@ mock.module('../../client/data/_rpc.js', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loadLicense, licenseFingerprint, licenseChangedSinceSeen, markLicenseSeen } =
|
const { loadLicense, licenseFingerprint, licenseChangedSinceSeen, markLicenseSeen, counterText } =
|
||||||
await import('../../client/license.js');
|
await import('../../client/license.js');
|
||||||
|
const { setLocale } = await import('../../client/i18n/index.js');
|
||||||
|
const { CONFIG } = await import('../../shared/config.js');
|
||||||
|
|
||||||
const GRATIS = {
|
const GRATIS = {
|
||||||
level: 'free', why: 'none', enforced: true, version: '1.0.0', installed: null,
|
level: 'free', why: 'none', enforced: true, version: '1.0.0', installed: null,
|
||||||
@@ -94,3 +96,24 @@ describe('is er iets te melden', () => {
|
|||||||
assert.equal(await licenseChangedSinceSeen(), false);
|
assert.equal(await licenseChangedSinceSeen(), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('de teller naast de plus', () => {
|
||||||
|
it('noemt wat hij telt, voor elke grens van de gratis editie en in elke taal', async () => {
|
||||||
|
const max = Object.fromEntries(Object.keys(CONFIG.LICENSE.FREE.MAX).map(k => [k, 1]));
|
||||||
|
stand = { ...GRATIS, max };
|
||||||
|
await loadLicense();
|
||||||
|
try {
|
||||||
|
for (const taal of ['nl', 'en']) {
|
||||||
|
setLocale(taal);
|
||||||
|
for (const soort of Object.keys(max)) assert.match(counterText(soort, 0), /^\S.*: 0 \S+ 1$/, `${taal}, ${soort}`);
|
||||||
|
}
|
||||||
|
} finally { setLocale('nl'); }
|
||||||
|
assert.equal(counterText('assets', 1), 'Assets naast valuta in de gratis editie: 1 van 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('valt weg zonder grens', async () => {
|
||||||
|
stand = BETAALD;
|
||||||
|
await loadLicense();
|
||||||
|
assert.equal(counterText('assets', 1), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user